feat(junctions): locked junctions, membership, merge, span junctions (p4-s4) (#319) - #341
Merged
Conversation
Adds two fields to `Junction` (issue #319): - `locked`: explicit user control. Automatic regeneration loops skip a locked junction, so hand-tuned connections, corners and stop lines survive edits to the arms; an explicit regenerate_junction still runs. - `spans`: the membership spans of a VIRTUAL (span) junction, a stretch [s_start, s_end] of one road that the junction covers without cutting it (ASAM OpenDRIVE 1.9.0 §12.7, identical in 1.8.1 §12.7). The new `SpanArm` generalizes the spec's single @mainRoad/@sStart/@send to a list so one junction can span parallel carriageways. Junction state stays DERIVED — no enum: foreign = no arms and no spans, automatic = arms and not locked, locked = arms and locked, span = spans (always locked). arms-xor-spans: a span junction has no arms and no connections, because the main road is never cut.
Layer 0 (ADR-0008) — a span junction exports as a spec-valid virtual
junction:
<junction id=… name=… type="virtual" mainRoad=… orientation="none"
sStart=… sEnd=…>
All four attributes are @required on <junction type="virtual"> (ASAM
OpenDRIVE 1.9.0 §12.7 Table 69; 1.8.1 §12.7 Table 69 is identical, and
its rules list states the same as prose) and forbidden on every other
junction type per
asam.net:xodr:1.5.0:junctions.common.virtual_junction_attributes — so an
arm-based junction still writes neither them nor @type and keeps its
exact pre-#319 bytes. orientation="none" = valid in both directions.
A span junction emits no <connection>, no <boundary> (that element is
"currently only valid for common junctions", §12.10), no elevation grid
and no rm:arms/rm:corners.
Layer 1:
- rm:junction gains the key `locked`, value `1`, emitted only when true.
Writer key order is r;mat;locked, so pre-existing values are unchanged.
A span junction is written locked whatever the flag says, because the
reader forces that invariant and the second write must match.
- New sibling code rm:spans, value ";"-joined "roadOdrId:s_start:s_end"
for EVERY span (spans[0] included), since Layer 0 can carry only the
one main-road span. A well-formed value REPLACES the Layer-0 span on
read, so save->load->save stays byte-identical.
Reader: parse_virtual_junction resolves @mainroad and validates
0 <= sStart <= sEnd; an unresolvable id or a bad interval warns and skips
the span rather than dropping input silently. rm:spans is all-or-nothing
like rm:arms/rm:corners — malformed warns and keeps the Layer-0 span.
A file mixing spans with arms/connections is degraded with a warning,
because the writer could not reproduce that state (arms-xor-spans).
Writer: spans_round_trip refuses to emit anything virtual when a span
road went stale, and validate_network reports it (issue #311: never
dereference a stale road ref). Span junctions are exempted from
junctions.common.not_only_two — a virtual junction legitimately joins one
uninterrupted road.
Round-trip coverage for issue #319: a locked arm-based cross junction keeps its flag and rewrites byte-identically; an unlocked one keeps its exact pre-feature bytes (no @type, no @mainroad, no locked key); the flag rides alongside r=/mat= in the documented r;mat;locked order. Span junctions are covered for both shapes — one mid-road span (the crosswalk case) and two parallel carriageways — asserting the four mandatory virtual attributes, the absence of connections/boundary/grid, the full rm:spans list and a byte-identical second write. Degradation: an unknown rm:junction key warns and the known keys still apply; locked=bogus warns and drops the whole value; a malformed rm:spans warns and leaves exactly the one Layer-0 span standing; an unresolvable @mainroad warns and loads as a plain junction; a stale span road is reported by validate_network and nothing virtual is written. Fuzz corpus: locked_junction, span_junction_single and span_junction_parallel are generated by the DISABLED_WriteCorpusSeed generator; bad_locked_value, bad_spans_inverted and bad_spans_unknown_road are hand-derived from them and never regenerated.
Explicit user control over the automatic regeneration loops (#319). Locking is a pure junction value edit (junctions_are_current). Unlocking hands the junction back to the loop, so it re-derives inside the same undo macro; when the arms no longer plan there is no automatic state left, so the unlock performs the full section-7 removal instead — delete_junction's closure body, now shared as junction_removal_command. Rejected (invalid_command): a stale id, a no-op toggle, a foreign junction (no arms and no spans), and unlocking a span junction, whose lock is structural. move_waypoint_following_junctions skips locked junctions, so dragging an arm node is a plain move with no mid-drag regeneration. regenerate_junction itself never consults the flag — the lock is a policy of the automatic loops only, so an explicit re-derive needs no bypass.
Document::push_applied_with_regeneration skips a locked junction alongside the foreign ones (#319), silently — a lock is not a failure, so no regeneration_skipped toast. Unlocking carries junctions_are_current=false, so the junction is re-derived inside the unlock's own undo macro and later arm edits follow again.
…ocked (p4-s4) Adds the SpanArm class (road + s_start/s_end of a virtual junction's membership interval, exported from the package root) and the two new Junction fields, plus the edit.set_junction_locked factory with its rejection cases documented (#319).
…nction (p4-s4) Splits the body of regenerate_junction into a file-local retarget_junction (issue #319, D5): it plans the union of turns for an ARBITRARY arm list, TurnKey-matches the planned turns against an existing connection table so survivors keep their connecting-road ids, erases the turns that vanished, creates the ones that appeared, and moves the arm road-link slots so exactly the new arm list points at the junction. regenerate_junction becomes a thin wrapper that retargets onto the arm list the junction already has, so the added/removed arm sets are empty and the creator re-assigns an identical arm vector — no behavior change, verified by the p2-s2 regeneration suite and the full 1449-test run. Two parameters exist only for the membership ops landing next: extra_existing folds another junction's connection table into the match (merge), and also_dirty names further junctions the edit touches. The engine returns a GenericCommand rather than a Command so a caller can extend it with its own erasures and a creator prologue.
… (p4-s4) Three new edit operations on top of the retarget engine (issue #319, D4): - add_junction_arm(network, junction, end, options): puts a road end into a LOCKED junction's arm list. The lock is a PRECONDITION, not a side effect — an automatic junction re-derives its arms from the roads that meet it, so the edit would not survive. Also refuses a span or foreign junction, an end that is already an arm, an end owned by another junction (the single-owner rule), an occupied link slot, and any arm list the generator refuses (notably the 50 m pairwise proximity limit). - remove_junction_arm(...): frees the arm's link slot and erases the connecting roads whose turn went with it. Authored corners and stop lines keyed by that RoadEnd STAY on the junction record — they go dormant and reactivate if the arm returns (the p4-s1 / p4-s3 dormancy contract). Refuses to leave fewer than two arms, pointing at unlock or delete instead. - merge_junctions(network, survivor, absorbed, options): one junction over the union of both arm lists. The creator's prologue re-points the absorbed junction's connecting-road back-references at the survivor before the retarget body runs, and the retarget moves the absorbed arms' link slots, so nothing references the erased junction afterwards (#311). The survivor keeps its odr id, name, default corner radius and material and inherits the absorbed corners and stop lines verbatim; the result is locked. The absorbed junction is erase_exact'd, so undo restores it under its own id. Surviving turns keep their connecting-road ids in all three (TurnKey matching), the absorbed junction's included. Bound in python as edit.add_junction_arm / remove_junction_arm / merge_junctions.
A §12.7 virtual junction covers a stretch of an UNINTERRUPTED road, so the command creates nothing but the junction record: no arms, no connecting roads, no connection table and no road link. It never routes through the retarget engine, which plans turns and rewrites arm link slots. The result is locked structurally — there is no derivation behind a span junction for the automatic loop to re-run. Accepts one span (a mid-road crosswalk) or two (the same crossing over two parallel carriageways), and refuses a stale road, a connecting road, the same road twice, and any span that is not a real interval inside its road.
…example (p4-s4) SpanArm gains a constructor so a caller can build the span list, and edit.create_span_junction is exposed alongside the WP2/WP3 junction-control ops. python/examples/junction_control.py walks the whole sprint story — generate, lock, add an arm, merge a neighbour, unlock, then author a single-road and a parallel-road span junction — asserting a byte-identical save/reload/save at every step and a clean validate_network at the end.
A span (virtual) junction has no arms and no connections, so junction_stoplines() derived nothing for it (issue #319). It now solves a different set: TWO FACES per SpanArm, keyed by the pseudo road ends {road, Start} (the s_start face) and {road, End} (the s_end face), so a mid-road crosswalk can carry stop lines. Geometry: the band sits OUTSIDE the span, where traffic approaching the crossing must stop — s_center = clamp(s_start - distance - thickness/2, half, length - half) s_center = clamp(s_end + distance + thickness/2, half, length - half) with max_distance the room between the span edge and the near end of the road (so the band always lands whole), and the span/road interval itself clamped to [0, length] — a span outlives an edit that shortens its road exactly as a dormant StopLine record does. Lane sampling happens AT THE SPAN EDGE, in the travel sense that APPROACHES it. That sense is the OPPOSITE contact of the face key (traffic reaching the s_start face runs toward +s, the same sense in which traffic reaches a road's End), which is why the key is a key and not a road end. A face whose direction carries no driving lane is omitted, so a one-way road guards only the edge its traffic approaches; `flipped` inverts the direction as it does for an arm. The legacy signalLines suppression does NOT apply — a span sits mid-road, where the arm branch's "near half" test means nothing. flip_stopline's own direction precondition moves behind the shared stopline_direction_has_lanes(), so the command and the query cannot drift on which end a pseudo key samples. The mesher reaches a span junction through its span list: the road is never cut, so junction_at_end finds nothing on it.
Span-junction faces materialize exactly like arm lines — one <object type="roadMark" subtype="signalLines"> tagged with <userData code="rm:stopline"> (ADR-0008 Layer 0 + Layer 1) — with ONE extra attribute: junction="<junction odr id>" It is written for span faces only. The reader's existing resolution keys on junction_at_end(road end), and a span junction satisfies nothing there: no arms, no connections, no road links, and the road is never cut. So the reader gains a SECOND path rather than extending the first — resolve_stoplines() branches on the presence of @junction, and span_junction_for() accepts the record only when the named junction exists AND carries a span on the enclosing road. The attribute's ABSENCE selects the arm path, so an arm line's bytes are untouched. The key is identity, not geometry, on purpose: a face station is clamped to the road, so two spans that both begin inside the first half-thickness of one would solve to the same station and the mapping would stop being injective. All-or-nothing with rm:spans (#311 — never write what you cannot read back): build_stopline_exports gates a span junction on spans_round_trip instead of arms_round_trip, so a junction that will not be written as <junction type="virtual"> exports no faces either. A record whose span left the junction lies dormant and is simply never written; a face whose @junction does not resolve degrades to a live object with its userData verbatim, one warning, never a silent drop. Adds the span_junction_stopline.xodr corpus seed (both tag shapes: one fully authored face, one pure default) plus its DISABLED generator, the span persistence suite, and JunctionStopLineInfo.span_face in the bindings + the junction_control example.
…p4-s4) The junction context menu gains the four controls issue #319's D7 calls for, all gated on the DERIVED junction state (arms/spans/locked) rather than on a stored mode: * Lock / Unlock junction — the text follows the state; enabled only for the two arm-based states, since a span junction is locked structurally (ASAM OpenDRIVE 1.9.0 12.7 virtual junctions are never derived) and a foreign one has no automatic derivation to guard. * Re-derive junction — plain regenerate_junction. The lock is a policy of the AUTOMATIC loops only, so an explicit re-derive needs no bypass flag. * Remove arm: <road> — one item per arm, offered only on a LOCKED junction and only while a third arm keeps the remainder above the kernel's 2-arm floor. * Merge selected junctions into <survivor> — the two-selected gating merge_roads established; the survivor is the FIRST-selected junction and the item names it, so the convention needs no documentation lookup. The node menu gains "Add end to junction <id>" when exactly one junction is selected. It mirrors add_junction_arm's preconditions and is omitted (not disabled) when they fail, so it can never offer a guaranteed refusal.
Authors a VIRTUAL junction — ASAM OpenDRIVE 1.9.0 12.7 (identical in 1.8.1 12.7): a stretch [sStart, sEnd] of an UNINTERRUPTED road that belongs to a junction without cutting it, the mid-road crosswalk and the parallel- carriageway crossing. Interaction: press-drag-release on a road stages that road's span; an optional second drag on another road stages the parallel span; Enter commits ONE edit::create_span_junction and selects the junction it created (read out of Document::last_dirty(), since the id only exists after apply); Esc resets and leaves the network byte-identical. Nothing enters the network before Enter, so — like Create Junction — there is no preview session and the drag lives entirely in PreviewGeometry. A connecting road is refused at press time with a toast, and cued on hover, rather than letting a failed command explain it afterwards. The road under the cursor comes from the viewport's own PickHit when it named one: inside a junction the connecting roads overlap the through roads and only the pick tells them apart. Ships ToolId::JunctionSpan, a categorized shortcut_registry row (Shift+J, toolbar group "Roads"; plain J stays Create Junction), the Actions entry, main_window wiring and an in-repo junction-span icon (MIT, ASSETS_LICENSES row). docs/user-guide/shortcuts.md is regenerated from the table.
…-s4) A read-only "Type" row showing Automatic / Locked / Span (virtual) / Foreign — derived from arms/spans/locked exactly as road/junction.hpp defines the states, never stored — plus a Locked checkbox that pushes one edit::set_junction_locked per click. The checkbox is disabled for a span junction (locked structurally) and for a foreign one (no derivation to guard), i.e. exactly the states the command refuses. Seeding from the record is blocked so a refresh can never echo a no-op command back at the kernel, which forbids them.
…4-s4) Four new weighted soak operations: lock/unlock a random junction, add or remove an arm on a locked one (locking it first when the draw lands on an automatic junction — the real user sequence), merge the two nearest junctions in either survivor orientation, and create a span junction over one or two through roads. Most draws are REFUSED by design, which is the point: a refused command must leave the network byte-unchanged and every invariant must still hold afterwards. SoakSmoke.FixedSeedRunsClean (seed 20260711) and SameSeedSameNetwork stay green against the shifted operation sequence, unweakened; seeds 1, 7, 99, 424242 and 20260711 also pass at 1500 ops. Plus the stop-line regression the lock motivates: a drag on a LOCKED junction's stop line still commits exactly one command and never unlocks the junction, and a cancelled drag is byte-identical.
…ns (p4-s4) GCC's -Wshadow (Werror in CI, clang-only-silent on macOS) rejects the inner Junction& binding named after the JunctionId parameter.
find() returns a pointer into the vector it is given; passing the menu temporary directly left it dangling for the EXPECT that followed. Held in a named local, and the rvalue overload is deleted so the mistake is a compile error instead of a use-after-free only Linux ASan reports.
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.
Automatic junctions stay the default. This sprint adds the explicit-control layer on top of them: a lock, hand-edited membership, merge, and a second kind of junction entirely — the span (virtual) junction that marks a stretch of an uninterrupted road.
Closes #319.
The model — state is derived, not an enum
Junctiongainsbool lockedandstd::vector<SpanArm> spans(SpanArm{road, s_start, s_end}):arms-xor-spansis a hard invariant, enforced in the reader, the writer and every membership op.regenerate_junctiondeliberately never consultslocked— the lock is a policy of the automatic loops (exactly two:Document::push_applied_with_regenerationandmove_waypoint_following_junctions). That is what lets the "Re-derive junction" action work with no bypass flag.Kernel operations (one
edit::Commandeach)set_junction_lockedadd_junction_armremove_junction_armmerge_junctionscreate_span_junction0 ≤ s_start < s_end ≤ lengthregenerate_junction's body was first extracted into a file-localretarget_junctionin its own zero-behavior-change commit (b42d958) — the p2-s2 regeneration suite is the safety net — and the membership ops build on it.Standards
Span junctions export as spec-valid virtual junctions per ASAM OpenDRIVE 1.9.0 §12.7 Table 69 (1.8.1 §12.7 is identical in substance; 1.9.0 only formalizes the prose as rule ids), with all four mandatory attributes and none of them on other junction types (
asam.net:xodr:1.5.0:junctions.common.virtual_junction_attributes):No connections, no
<boundary>, no road links — the main road is uninterrupted. Layer 1 per ADR-0008:locked=1joins the existingrm:junctionpayload (emitted only when true, so an unlocked junction keeps exactly its pre-#319 bytes), and spans ride a new sibling coderm:spansrather than overloadingrm:arms(which is all-or-nothing and rejects <2 entries).rm:spansrepeatsspans[0]redundantly with Layer 0 — that redundancy is what makes reader-replaces-list byte-stable.Span-face stop lines
Having no arms, a span junction derives stop lines differently: two faces per span, keyed by a pseudo road end, sitting outside the span. The subtle part is that lane sampling happens at the span edge in the travel sense that approaches it — the opposite
ContactPointof the face key — otherwise the band paints over the wrong carriageway.The reader's
resolve_stoplineskeys onjunction_at_end, which a span junction can never satisfy, so span faces get a second resolution path:rm:stoplinecarries ajunction="<odr id>"attribute for faces only, and the old path is untouched (arm-line bytes unchanged). The ordinary stop-line commands and the Stop Line tool work on a face key unmodified.Editor
<odr id>(locked junctions, hidden below 3 arms), Merge selected junctions into<survivor>(exactly two arm-based selections; survivor = first-selected).<id>when the kernel preconditions pass.Shift+J): drag an s-range on a road, optionally a second on a parallel road, Enter creates it as one command, Esc leaves the network byte-identical. Connecting roads are refused with a hover cue rather than a failed command.FixedSeedRunsClean(seed 20260711) andSameSeedSameNetworkstay green unmodified.Verification
ctest --preset ci-macos: 1522 passed, 0 failed (+~90 new tests), including the editor suite and the soak smoke.clang-format --dry-run --Werror: clean.pytest python/tests: 214 passed, 1 skipped.locked_junction.xodr,span_junction_single.xodr,span_junction_parallel.xodr,span_junction_stopline.xodr, plus hand-derivedbad_locked_value.xodr,bad_spans_inverted.xodr,bad_spans_unknown_road.xodr.The issue's hand script ships as
python/examples/junction_control.py(the required example for the API change) and runs clean — two crossing roads → automatic junction → lock → add an arm → merge → unlock → span junctions → span stop line, with a byte-identical save→reload→save asserted at every step andvalidate_networkreporting 0 findings: