Skip to content

feat(junctions): Corner tool — selection, control vertices, extents, radius (p4-s1) (#225) - #310

Merged
JArmandoAnaya merged 9 commits into
mainfrom
feat/225-corner-tool
Jul 20, 2026
Merged

feat(junctions): Corner tool — selection, control vertices, extents, radius (p4-s1) (#225)#310
JArmandoAnaya merged 9 commits into
mainfrom
feat/225-corner-tool

Conversation

@JArmandoAnaya

Copy link
Copy Markdown
Contributor

Closes #225.

First sprint of pillar P4. Junction corners were derived-only: append_corner_fillets
built 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, optional radius/extent_a/extent_b) on
    Junction::corners, sparse: a corner with no entry keeps the derived fillet. Identity
    is the ordered CCW-adjacent arm pair, so an override survives a regeneration and simply
    goes dormant if its pair stops being adjacent. regenerate_junction leaves it untouched.
  • junction_corner_detail (internal): corner_faces() + solve_corner() extracted
    from junction_surface.cpp in a behavior-preserving commit of its own.
  • mesh/junction_corners.hpp — public junction_corners(network, id) returning the
    solved 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.
  • Corner curve: rational quadratic Bézier (P0=tangent_a, P1=corner, P2=tangent_b,
    w=sin(φ/2)) — exactly the circular arc for equal legs, G1-tangent for unequal ones, which
    is what keeps independent per-side extents watertight.
  • Commands: set_corner_radius (≤0 clears the override and any extents) and
    set_corner_extents. Validate-first through junction_corners; 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.

Persistence

ASAM OpenDRIVE 1.9.0 §12.10 gives <boundary> no corner-radius carrier (segments are
lane/joint only), so overrides persist as <userData code="rm:corners"> on
<junction>, following the rm:arms pattern; the exported <boundary>/<elevationGrid>
stay fully derived.

<userData code="rm:corners" value="1:end:2:end:r=7.5;2:end:3:end:ea=3.25:eb=4.125"/>

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

  • Corner tool (Shift+R): hover highlights the solved curve and its two dashed extent
    guides, 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 EditNodesTool precedent); the owning
    junction is mirrored into SelectionModel.
  • Dashed preview channel: PreviewGeometry::dashed_line_positions +
    ViewportWidget::draw_dashed_lines in the QPainter overlay pass (GL 3.3 core has no
    line stipple).
  • Properties pane: a "Corner" group with the arm-pair row and a Corner Radius spin box
    bounded by the geometric maximum, with a ScrubLabel on 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.
  • Tree-wide clang-format --dry-run --Werror and check_asset_licenses.py clean.
  • Byte-identity of the refactor was verified directly, not inferred: raw IEEE-754 bit
    dumps of every junction-floor vertex across six scenarios (tee, four-way, five-way, skew
    tee, straight corridor, attach_t_junction) compared between 030a810 and the
    refactored tree — identical. Un-authored corners keep the original uniform-angle arc
    sampler verbatim; the Bézier runs only on authored corners.

Notes

  • Bound to Shift+R, not R: PropPolygonTool uses plain Key_R for Randomize, and a
    global QAction shortcut would intercept it before the tool sees it.
  • On a geometrically tight junction the derived radius already equals max_radius, so the
    spin 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.
  • Pre-existing, unrelated, filed separately: write_xodr segfaults when a junction arm
    road has been erased, leaving a dangling arm.

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.
@JArmandoAnaya
JArmandoAnaya merged commit 4195e27 into main Jul 20, 2026
14 checks passed
@JArmandoAnaya
JArmandoAnaya deleted the feat/225-corner-tool branch July 20, 2026 12:19
@JArmandoAnaya JArmandoAnaya mentioned this pull request Jul 20, 2026
17 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

p4-s1: Corner tool — selection, control vertices, extents, radius

1 participant