feat(junctions): Corner tool — selection, control vertices, extents, radius (p4-s1) (#225) - #310
Merged
Merged
Conversation
Authored corner fillets (JunctionCorner) round-trip through <userData code="rm:corners"> on <junction>, alongside rm:arms: entries are ";"-joined and fields ":"-joined — "roadA:start|end:roadB:start|end[:r=<num>][:ea=<num>][:eb=<num>]". ASAM OpenDRIVE 1.9.0 §12.10 gives <boundary> no corner-radius carrier, so §7.2 userData is the persistence home; the exported boundary and elevation grid stay fully derived. Writer drops stale road references and entries that author nothing, keeps storage order, and formats doubles with the shortest round-trippable form (num()). Reader parses all-or-nothing like rm:arms: a malformed value warns once and leaves corners empty while the junction still loads. Refs #225
…detail The junction surface mesher owned the whole corner-fillet solve inside its anonymous namespace: the arm-face construction, the CCW ordering, the connecting-road radius derivation, and the tangent/arc geometry. The Corner tool (p4-s1, issue #225) needs the same solve to place its handles, and two copies of it would drift. Move it verbatim into a new internal header/source pair, core/src/mesh/junction_corner_detail.{hpp,cpp}: - CornerFace (the old ArmFace plus the RoadEnd identity a corner is named by) and corner_faces(), which builds every arm face INTO the junction and returns them sorted CCW around their centroid. - CornerSolution and solve_corner(), the per-pair edge-line intersection, angle guards, derived radius and tangent legs. - corner_curve(), the sampled fillet boundary. - connecting_roads() and the radius derivation helpers, which now live with the code that needs them. Behavior-preserving: build_junction_surface no longer accumulates arm faces in its joint-quad loop and asks corner_faces() instead, and append_corner_fillets consumes solve_corner()/corner_curve(). Junction floor meshes are byte-identical before and after (verified by dumping the raw double bits of every junction floor for a tee, a four-way, a five-way, a skew tee, a straight corridor and an attached tee, on this commit and its parent).
Corners were purely derived: every CCW-adjacent arm pair got a fillet whose radius came off the crossing connecting road, clamped to [3, 15] m and to what the arm faces leave room for. The Corner tool (p4-s1, issue #225) has to override that per corner, and its handles must sit on exactly the pavement the mesher emits — so the mesher and the new query share one solve. Override semantics (junction_corner_detail::solve_corner): - A Junction::corners entry applies to the pair it names, ordered (entry.arm_a == a.arm && entry.arm_b == b.arm). Entries matching no currently adjacent pair lie dormant and are never consulted; they are not erased, so an override outlives a turn-set change. - An authored radius is clamped only to [0.05, max_radius], where max_radius = min(ta, tb) * tan(phi/2). The [3, 15] band is a property of the DERIVATION and is not imposed on an author. - Authored extents are clamped to [0.05, ta] / [0.05, tb] independently; unset extents stay the symmetric radius / tan(phi/2). Corner curve: the fillet boundary is now the rational quadratic Bezier P0=tangent_a, P1=corner, P2=tangent_b with w=sin(phi/2). With equal extents that IS the circular arc of the same radius; with unequal extents it stays G1-tangent to both edge lines, which is what keeps independent per-side setbacks watertight. Derived (unauthored) corners keep the original uniform-angle arc sampler verbatim, so junction meshes without overrides stay byte-identical. The kFilletTangentLift drop rule and the exact tangency endpoints are unchanged. Public query: junction_corners(network, junction_id) returns the solved JunctionCornerInfo of every corner in CCW order — identity, edge rays, bisector, tangencies, effective and maximum radius/extents, whether each came from an override, and the sampled curve (apex() is the Bezier at t=0.5). Empty for a stale id or a junction with fewer than two usable arms. append_corner_fillets consumes the same solve, so query and mesh cannot drift. Tests: core/tests/test_junction_corners.cpp — CCW order and tangency on a four-way, the derived radius on tight and roomy fixtures, authored radius (including an absurd one clamping to max_radius rather than failing), dormant overrides, authored extents moving the tangencies while the curve stays tangent, equal extents landing exactly on the circle, empty results, determinism, and the floor staying watertight with an authored radius and with asymmetric extents.
Expose the shared corner solve and the two authoring commands: - JunctionCorner (read-only) + Junction.corners - JunctionCornerInfo (read-only, curve/points as (x, y) tuples, apex()) - roadmaker.junction_corners(network, junction) - roadmaker.edit.set_corner_radius / set_corner_extents Invalid arm pairs, stale junction ids and non-positive values surface as ValueError when the command is pushed onto an EditStack. Adds the junction_corner_radius example and the pytest coverage.
Two junction value-edit commands, named by the ordered pair of CCW-adjacent
arms the corner sits between:
- set_corner_radius: a radius <= 0 removes the override and returns the
corner to its derived fillet; a positive one supersedes any per-side
extents, since a radius is symmetric by definition.
- set_corner_extents: independent tangent-leg setbacks, kept watertight by
the corner curve's G1 tangency.
Adjacency is validated through junction_corners(), the same solve the mesher
runs, so the tool, the panel and the command can never disagree about what a
corner is. Authored values are stored unclamped and clamped only at solve
time, so a later arm move that shrinks a corner can never fail the mesh.
Both dirty {junctions, junctions_are_current=true}: the turn set is
untouched, so the editor re-meshes the floor without regenerating the
connecting roads.
Committed separately from the suite itself by accident; without this line the whole CornerOperations suite is silently absent from the test binary.
Direct manipulation of a junction's fillet corners: hover highlights the solved corner curve plus its two dashed extent guides, a click makes the corner active (tool-local sub-selection; the owning junction is mirrored into SelectionModel), and dragging one of its three handles authors it — the apex handle sets the symmetric radius, each tangency handle sets that side's tangent-leg extent. Every drag is a preview session committed as exactly ONE undo entry, computed from a press-time JunctionCornerInfo snapshot so the factory never re-reads previewed geometry. PreviewGeometry gains dashed_line_positions (the line_positions encoding, painted with a Qt::DashLine pen in the QPainter overlay pass — GL 3.3 core has no line stipple). Shortcut is Shift+R: plain R is the Prop Polygon tool's re-scatter key and a global QAction would shadow it.
Binds the Properties pane to the Corner tool's active corner: an arm-pair row naming which fillet is being edited, and a Corner Radius spin box bounded by the corner's geometric maximum. The attribute NAME is a ScrubLabel, so the radius can be typed or dragged (GW-2 s9, GW-3 s5), each gesture landing as exactly one set_corner_radius entry. The section is shown only when the primary selection is a junction AND the tool's active corner belongs to it; the radius row itself hides when the arm faces leave no room above the 0.5 m floor. A tight corner's derived radius already sits at max_radius, so the spin box can open at its own ceiling - the tooltip states the geometric maximum rather than widening a range the mesher would clamp anyway.
The definition lives in junction_corners.cpp because it evaluates the corner curve through the internal junction_corner_detail solver, and under RM_BUILD_SHARED=ON the kernel hides symbols by default — so the out-of-line member failed to link from every consumer outside core (caught by the shared kernel CI jobs; the ci-macos preset builds the kernel static, where symbol visibility does not apply). RM_API goes on the member, not the struct: exporting JunctionCornerInfo wholesale would drag its std::vector member into MSVC's dll-interface warning (C4251), and no struct in this kernel is exported wholesale.
17 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #225.
First sprint of pillar P4. Junction corners were derived-only:
append_corner_filletsbuilt an arc per angularly-adjacent arm pair with a radius read off the crossing
connecting road, clamped to [3, 15] m — nothing authored, nothing persisted, nothing
selectable. This makes them first-class editable.
Kernel
JunctionCorner(arm_a,arm_b, optionalradius/extent_a/extent_b) onJunction::corners, sparse: a corner with no entry keeps the derived fillet. Identityis the ordered CCW-adjacent arm pair, so an override survives a regeneration and simply
goes dormant if its pair stops being adjacent.
regenerate_junctionleaves it untouched.junction_corner_detail(internal):corner_faces()+solve_corner()extractedfrom
junction_surface.cppin a behavior-preserving commit of its own.mesh/junction_corners.hpp— publicjunction_corners(network, id)returning thesolved geometry (corner/apex/tangency points, edge dirs, bisector, φ, effective and max
radius/extents, authored flags, sampled curve). One geometry source shared by the mesher,
the tool and the panel, so they cannot disagree about what a corner is.
w=sin(φ/2)) — exactly the circular arc for equal legs, G1-tangent for unequal ones, which
is what keeps independent per-side extents watertight.
set_corner_radius(≤0 clears the override and any extents) andset_corner_extents. Validate-first throughjunction_corners; authored values arestored unclamped and clamped only at solve time, so a later arm move that shrinks a
corner can never fail the mesh. Both dirty
{junctions, junctions_are_current=true}—the turn set is untouched, so the editor re-meshes the floor without regenerating.
Persistence
ASAM OpenDRIVE 1.9.0 §12.10 gives
<boundary>no corner-radius carrier (segments arelane/jointonly), so overrides persist as<userData code="rm:corners">on<junction>, following therm:armspattern; the exported<boundary>/<elevationGrid>stay fully derived.
Reader lands in the same change (it warns on unknown codes, which would otherwise break
diagnostic-clean round-trips). Malformed input is all-or-nothing: one warning, corners
dropped, junction still loads.
Editor
Shift+R): hover highlights the solved curve and its two dashed extentguides, click activates the corner, then the apex handle drags the symmetric radius and
each tangency handle drags that side's extent. Drag = preview session against a
press-time snapshot, one command on release. Esc cancels; state clears on load.
The active corner is tool-local sub-selection (the
EditNodesToolprecedent); the owningjunction is mirrored into
SelectionModel.PreviewGeometry::dashed_line_positions+ViewportWidget::draw_dashed_linesin the QPainter overlay pass (GL 3.3 core has noline stipple).
bounded by the geometric maximum, with a
ScrubLabelon the attribute name.Satisfies GW-2 step 9 and GW-3 step 5.
Python
JunctionCorner,Junction.corners,JunctionCornerInfo,junction_corners,edit.set_corner_radius/set_corner_extents, all added to the explicit re-export list,plus
examples/junction_corner_radius.py.Verification
ctest --preset ci-macos: 1293/1293, zero warnings under-Werror.pytest python/tests: 163 passed, 1 skipped.clang-format --dry-run --Werrorandcheck_asset_licenses.pyclean.dumps of every junction-floor vertex across six scenarios (tee, four-way, five-way, skew
tee, straight corridor,
attach_t_junction) compared between030a810and therefactored tree — identical. Un-authored corners keep the original uniform-angle arc
sampler verbatim; the Bézier runs only on authored corners.
Notes
PropPolygonTooluses plainKey_Rfor Randomize, and aglobal QAction shortcut would intercept it before the tool sees it.
max_radius, so thespin box's upper bound can equal its current value and a larger typed value is clamped.
That is correct — the arm faces leave no room — and the spin box tooltip states the
geometric maximum.
write_xodrsegfaults when a junction armroad has been erased, leaving a dangling arm.