Skip to content

feat(editor): junctions follow an arm live during a node drag#208

Merged
JArmandoAnaya merged 3 commits into
mainfrom
feat/live-junction-follow
Jul 15, 2026
Merged

feat(editor): junctions follow an arm live during a node drag#208
JArmandoAnaya merged 3 commits into
mainfrom
feat/live-junction-follow

Conversation

@JArmandoAnaya

Copy link
Copy Markdown
Contributor

Closes #156. Dragging a junction arm's node moved the arm while its connecting roads stayed put, snapping into place only on release. They now follow continuously.

Interaction change — opening for review rather than self-merging, per the convention on visual PRs. Everything else in this sprint landed on green CI.

The issue named the wrong blocker

Prereqs already in place (PR #155 / PR 4): verify_junction_welds, keyed regenerate_junction, Document::regeneration_skipped signal.

regeneration_skipped has nothing to do with drags — it is a semantic signal, raised when a turn set or connection count changes (operations.cpp:2504, :2543).

The real blocker was structural: regeneration lived only inside push_applied_with_regeneration, reachable from push_command and commit_preview (document.cpp:159, :305) but never from update_preview — and push_command is hard-refused during a session (document.cpp:145-148). Mid-drag frames only ever re-meshed. Nothing was broken; the code path simply did not exist.

No throttle — the cost premise was wrong

My plan (and the issue's framing) assumed 12 clothoid fits per frame was too expensive to do live, and prescribed an AutosaveManager-style debounce. I measured it on the 4-arm scene before writing any of it:

connections: 12
regenerate_junction:              mean 0.09 ms, worst 0.10 ms
FULL FRAME (move+regen+remesh):   mean 3.85 ms, worst 5.74 ms   → 16 ms budget: FITS

~3× headroom. The throttle would have been complexity bought with a guess, plus a fake-clock test to maintain. Dropped.

Design

move_waypoint_following_junctions composes the move and each touched junction's regeneration into one command using the existing file-local CompositeCommand, whose lazy stages already see the network with earlier stages applied — precisely what regen-after-move needs, and how attach_t_junction already works. So the preview session keeps its one-command contract and its snapshot-at-creation semantics rather than being worked around.

Two judgement calls worth flagging:

  • Foreign junctions are skipped, not refused. regenerate_junction treats an empty arm list as an error. Letting that abort a composite would mean an unrelated imported junction could fail your drag; skipping matches what the commit path already does (leaves it stale).
  • The composite is atomic, unlike the commit path's tolerant loop. Mid-drag that is the right trade: a failed frame is simply rejected and the last good preview stands. It is also close to unreachable — regeneration only fails when the turn set changes, and a node drag moves geometry, not lanes.

Document::commit_preview gains already_regenerated so committing a drag does not regenerate a second time. The second pass was byte-identical, but it cost a re-plan and landed an extra child in the undo entry.

Tests

JunctionRegen.ConnectingRoadsFollowTheArmMidDragNotOnlyOnRelease proves it by contrast: on the same drag, a plain move_waypoint preview leaves the weld breached (connector holds its old heading while the arm swings); the following move leaves it clean, with the session still open. Asserting "welds are clean mid-drag" alone would have passed even if nothing regenerated — so the test asserts the old path fails first.

Worth recording: my first version asserted the connector's tip moved, and failed at 1.8e-15. Dragging node 0 swings the arm's far end — the junction-facing contact point barely moves and the heading changes. The tip is welded to a point that stays put; the gap is angular, not positional. The test was wrong, not the code.

JunctionRegen.LiveFollowCommitsExactlyOneUndoEntry covers the flag: one undo entry, byte-identical undo.

Checks

  • ci-macos clean (-Werror); 750/750 ctest green.
  • The nine PreviewSession invariants still pass — CancelledSessionIsByteIdentical, CommitPushesExactlyOneEntryAndUndoRestoresBase, PushCommandIsRefusedWhileActive, FailedUpdateKeepsLastGoodStateAndSession.

Known, deliberately not fixed here

An objects/junction-dirty frame emits mesh_changed({}) — a wholesale viewport rebuild — because partial requires dirty.junctions to be empty (document.cpp:330-333). Live follow now hits that every frame. It fits the budget on GS-1-sized scenes, but making it partial needs mesh_changed to carry junction ids, a signal-signature change that does not belong in a polish PR. Happy to file it.

Closes #156

Dragging a junction arm's node moved the arm while its connecting roads
stayed put, snapping into place only on release. They now follow the arm
continuously.

move_waypoint_following_junctions composes the move and each touched
junction's regeneration into ONE command via the existing CompositeCommand,
whose lazy stages already see the network with earlier stages applied —
exactly what regen-after-move needs. That keeps the preview session's
one-command contract and its snapshot-at-creation semantics intact instead
of reaching around them: regeneration was previously reachable only from
push_applied_with_regeneration, which update_preview never calls and
push_command refuses outright during a session. That, not the
regeneration_skipped signal named in the issue, is why this never worked —
that signal reports a changed turn set and has nothing to do with drags.

No throttle. The issue and my own plan assumed 12 clothoid fits per frame
would be too expensive to do live; measured on the 4-arm scene it is 0.09 ms
for the regeneration and 3.85 ms mean / 5.74 ms worst for a whole frame
including re-mesh, against a 16 ms budget. A debounce would have been
complexity bought with a guess.

Foreign junctions (no recorded arms) are skipped rather than refused, so an
unrelated imported junction cannot fail a drag. The composite is atomic, so
a failed regeneration rejects the frame and the last good preview stands —
close to unreachable anyway, since regeneration only fails on a changed turn
set and a node drag moves geometry, not lanes.

Document::commit_preview takes already_regenerated, so committing a drag no
longer regenerates a second time: byte-identical output, but a wasted
re-plan and an extra child in the undo entry.

Closes #156
…ld path

The mid-drag assertion is by contrast: a plain move_waypoint previewed on
the same drag BREACHES the weld (the connector keeps its old heading while
the arm swings), and the following move does not. Asserting only that the
welds are clean would have passed even if nothing regenerated.

Dragging node 0 swings the arm's far end, so its contact point barely moves
and its heading changes — my first attempt asserted the connector tip moved
and failed at 1.8e-15, because the tip is welded to a contact point that
stays put. The gap is angular, not positional.
CI runs clang-format over every tracked file; `git clang-format` only
checks what is uncommitted, so it reported clean after the commit and the
violations only surfaced in CI.

node_drag's preview call is an if-style lambda rather than the formatter's
wrapped ternary, which was unreadable.
@JArmandoAnaya

Copy link
Copy Markdown
Contributor Author

14/14 green on a92252d, CLEAN/MERGEABLE against main (873f13c). 750/750 ctest locally.

Held for you rather than self-merged: every other PR in this sprint landed on green CI under the merge-as-you-go directive, but this one changes drag behaviour in both the Select and Edit Nodes tools, and #156 was explicitly scoped out of the release gate on 2026-07-13 — so it's the one item where "green" isn't the whole question. It's yours to take or leave; nothing else depends on it.

Two things worth a second look if you do review it:

  • The throttle is gone. The issue and my plan both assumed live regeneration was too expensive; the measurement says 0.09 ms per regen and 3.85 ms for a whole frame against a 16 ms budget. If you'd rather have a debounce anyway for bigger junctions than GS-1's, say so — but I didn't want to ship complexity justified by a guess I'd already disproved.
  • The composite is atomic, unlike the commit path's tolerant loop. Mid-drag a failed regeneration rejects the frame and the last good preview stands. I convinced myself it's near-unreachable (regeneration only fails on a changed turn set; a node drag moves geometry, not lanes), but it is a deliberate divergence from how push_applied_with_regeneration behaves, so it deserves your eye rather than mine.

@JArmandoAnaya
JArmandoAnaya merged commit c135093 into main Jul 15, 2026
14 checks passed
@JArmandoAnaya
JArmandoAnaya deleted the feat/live-junction-follow branch July 15, 2026 16:39
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.

Live mid-drag junction follow (regen during preview drag)

1 participant