Appearance
Crossing
1. Identity
A Crossing is an emergent scene feature emitted automatically when two like-layer primitives (segment / arc / circle / spline) intersect; it materialises a virtual node at the live intersection point so users can snap, dimension and constrain at the meeting point without manually placing it.
2. When to use it
Users never "create" a crossing. They encounter it when:
- Two construction-layer segments are drafted and cross — a yellow virtual node appears at the X for snapping center-finding / dimensioning.
- A new active segment is drawn through an existing active segment / arc / circle.
- An imported / scripted geometry contains intersecting primitives — crossings are emitted on scene load.
- The user wants to snap a subsequent click (e.g., a node, the start of an arc) to the intersection of two already-placed lines.
3. Inputs
Crossings have no user-facing tool. They are emitted by primitive-creation commands.
- Tool button — not applicable; no tool exists.
- Keyboard shortcut — not applicable.
- MotorScript builtin — not applicable. No
crossing(...)statement exists; crossings are never reverse-codegen'd (they are derived state). - Command Palette — not applicable.
- Context menu — not applicable. Right-clicking a virtual crossing node currently exposes only generic node actions (selection, snap-target inspection).
Emission sites: addSegment (commands.ts:625), addArc (commands.ts:~820), addSpline crossings (commands.ts:1110). Re-evaluation site: reEvaluateCrossings (fillet.ts:1388), invoked from store/helpers.ts:204 per mutation and from store/drag.ts:68 at drag-end.
4. State machine
N/A — Crossings are emitted automatically by the evaluator; no user-facing click sequence creates them. The lifecycle is:
- A primitive command (
addSegment,addArc,addSpline) computes intersections against existing same-layer primitives. - For each intersection point, a virtual
Nodeis inserted intoscene.geometry.nodesand aCrossingFeaturerecord is appended toscene.geometry.crossings(commands.ts:722–740). - The virtual node is decorated with
virtualRef: { kind: 'crossing-point', crossingId }(commands.ts:556–567). - On every subsequent scene mutation,
reEvaluateCrossingswalks eachCrossingFeature.sources, re-resolves both primitives' current geometry, re-intersects them, and writes the newvirtualPoint(fillet.ts:1388).
There is no preview, no cancel, no finalize.
5. Committed state
After a primitive command that produces a crossing:
- One new
Nodeinscene.geometry.nodesper intersection, carryingvirtualRef: { kind: 'crossing-point', crossingId }(seescene-model.tsNode). - One
CrossingFeatureper intersection (scene-model.ts:457–484):id—'xf_...'uid.layer—'active' | 'construction', inherited from the emitting primitive's layer (cross-layer crossings are intentionally not recorded).virtualPoint—{ x, y }, kept in sync byreEvaluateCrossings.virtualNodeId— id of the promoted Node.sources?: [CrossingSource, CrossingSource]—[existingPrim, newPrim]. Optional during staged rollout: legacy records withoutsourcesare silently skipped by re-evaluation (scene-model.ts:477–483).
- No constraints emitted. The crossing is not pinned by
coincident; the intersection is recomputed parametrically every mutation. - No MotorScript generated. Crossings are derived; reverse-codegen omits them entirely.
6. Constraints / interactions
| Constraint | Behavior on the crossing's virtual node |
|---|---|
| coincident / horizontal / vertical / fix / distance / any user constraint | Rejected by the constraint palette per . The virtual node cannot be a direct constraint operand while its virtualRef.kind === 'crossing-point'. The user must first explicitly promote the crossing (drop its virtualRef → free node) before attaching constraints. |
| snap target | Accepted. Subsequent tool clicks snap to virtualPoint like any other node; if a tool consumes this snap as an endpoint, ensureNodeWithPromotion reuses the virtual node id and the new primitive shares the crossing point. |
| fillet input | Allowed; the crossing's segments / arcs can be filleted. The crossing record is invalidated if the contributing primitive is consumed by the fillet trim. |
| trim | Trimming one source primitive deletes the crossing if its source-id disappears (reEvaluateCrossings drops records whose resolvePrim returns null — fillet.ts:1419+). |
Layer isolation (commands.ts:639, 681): construction-layer primitives only cross other construction primitives; active-layer primitives only cross active primitives. Drafting a construction centerline through an active part-outline produces no crossing — by design, but a frequent source of user confusion ("why doesn't my centerline snap to the boundary?"). Cross-layer snapping requires explicit promotion (e.g., projecting the construction line into active).
7. Failure modes
- Parallel sources —
intersectLineLinereturns null; no crossing emitted; no toast, no warning. Silent and correct. - Sources that don't overlap in their parametric range — the infinite-line intersection lies outside both bounded segments; emission code already filters by
t ∈ [0,1]so the crossing is suppressed. If the user later extends one source by dragging an endpoint past the other, the nextreEvaluateCrossingspass will detect the now-valid intersection only if a CrossingFeature already exists; brand-new crossings on drag are not retroactively created (gap). - One source deleted —
resolvePrimreturns null andreEvaluateCrossingsremoves theCrossingFeatureand freezes the orphaned virtual node viafreezeOrphanedVirtuals(drag.ts:17). The node persists as a free point at the last computed position. - Spline crossings without analytic intersection — sampled approximation, may miss tangential touches.
- Sources missing
sources?field (legacy records) — re-evaluation skips them; the crossing freezes at its initialvirtualPointand silently drifts as parents move. - Cross-layer intersections — intentionally invisible (see §6).
8. Figures
C
\
\
A----X----------B X = virtual node, glyph: hollow yellow circle
\ virtualRef.kind = 'crossing-point'
\ virtualNodeId references CrossingFeature.id
DFigure 1 (ASCII): Two construction segments AB and CD crossing at X. The virtual node X is rendered yellow (var(--snap-target), Canvas/index.tsx:3692) to indicate system-owned; promoted virtual nodes (with user constraints attached) recolor.
Figure 1: Two same-layer segments AB and CD cross at X. The virtual node (amber) carries virtualRef: { kind: 'crossing-point', crossingId } and acts as a snap target.
Figure 2: [] After dragging A's endpoint, the virtual node lost its virtualRef during moveNode and slid as a free node along CD instead of re-intersecting AB' and CD.
Figure 3: Layer isolation — an active segment (solid blue) and a construction segment (dashed) cross visually but no Crossing feature emits — intentional layer isolation (commands.ts:639, 681).
9. Known bugs
10. Class API
In the current model, crossings are class instances rather than plain objects.
- Class file:
app/src/lib/fea2d/model/CrossingFeature.ts - Constructor:
new CrossingFeature(id: CrossingId, layer: 'active' | 'construction', virtualPoint: { x, y }, virtualNodeId: NodeId, sources: [CrossingSource, CrossingSource] | null)— validates finitevirtualPoint, non-emptyvirtualNodeId.sources === nullis permitted for legacy persisted records (pre-source-tracking); phase-7 re-evaluation skips such features. - Snapshot type:
CrossingFeatureSnapshot(app/src/lib/fea2d/model/snapshots/Crossing.ts). - Class layer used:
CrossingSource(app/src/lib/fea2d/model/CrossingSource.ts) — the abstract source ref with concrete subclasses for each crossable primitive kind. - Lifecycle methods:
toJSON: CrossingFeatureSnapshotstatic fromJSON(snap, scene?): CrossingFeature— rehydratessourcesthroughCrossingSource.fromJSONwhen present.clone: CrossingFeatureequals(other: SceneEntity): boolean
- Polymorphic surface (
SceneFeature):referencesEntity(id: string): boolean— true whenidmatches the feature id, the virtual node id, or either source's referenced primitive id.isStale(_scene: unknown): boolean— phase-7 implementation compares the storedvirtualPointto a fresh intersection of the two sources. (Stubbedfalsein phases 3–6.)
id is readonly; layer, virtualPoint, virtualNodeId, sources are mutable so phase-7 recompute can update them in place.