Appearance
Frame
1. Identity
A Frame is a local 2D coordinate system (translation (originX, originY) + rotation rotationDeg, optionally polar) that primitives reference via frameId so their stored (x, y) is interpreted in the frame's local axes and resolved to world coords by composing the chain up to root (frames.ts:97-142).
2. When to use it
- Build a rotor in its own frame and drop it into a global stator, eccentricity included.
- Parametrically offset a sub-assembly: edit the frame's origin/rotation and every primitive tagged with it moves en bloc.
- Mirror or copy a sub-assembly while keeping its primitives bound to a moving datum (frame survives clone — see §5).
- Nest sub-frames (a slot frame inside a rotor frame) for hierarchical assemblies.
3. Inputs
- Tool button: not yet wired — no toolbar slot.
- Keyboard shortcut: not yet wired.
- MotorScript builtin: not yet wired as a public builtin (no
frame(...)inexecutor/builtins). - Command Palette: not yet wired.
- Context menu: "Add frame here" on the canvas right-click menu (
Canvas/index.tsx:4502-4507) → opensNameDialogfor the name only; origin is the right-click world position; kind/rotation/parent default. Edit/delete: right-click within 8 px (screen) of a frame's origin indicator (Canvas/index.tsx:2466-2477) → opensFrameEditDialog(FrameEditDialog.tsx).
4. State machine
Add flow (context menu "Add frame here"):
- Right-click empty canvas / non-frame target: context menu opens; pick "Add frame here".
setFrameDialogPos(ctxWorld)records the world point.- Preview: no on-canvas preview; modal
NameDialogappears asking for a name. - Cancel options: Esc / Cancel button / click outside dialog → no frame created.
- Preview: no on-canvas preview; modal
- Finalize: type a name, press Enter (or click "Add").
addFrame({ name, originX, originY, rotationDeg: 0, parentFrame: null })runs (Canvas/index.tsx:4704-4713→frames.ts:180-195). Kind defaults to'cart',visible = true.- Preview after step 2: frame committed; see §9 — no axis-indicator glyph is drawn on canvas today, so the only confirmation is via the FrameEditDialog re-open path.
Edit flow (right-click frame origin):
- Right-click within 8 px screen of a frame's world origin: hit-test loop scans
scene.geometry.frames, finds the match, callssetEditingFrameId(f.id)(Canvas/index.tsx:2470-2476).FrameEditDialogopens pre-filled.- Preview: modal; form fields show current
name,kind,originX,originY,rotationDeg,parentFrame. - Cancel options: Esc / Cancel / click outside → no patch.
- Preview: modal; form fields show current
- Finalize: edit fields → "Save".
updateFrame(id, patch)runs (frames.ts:197-227). Non-finite numeric inputs are rejected at the dialog before commit (FrameEditDialog.tsx:80). A cycle-introducing parent reassignment is refused and the previous frame value is preserved (see §7). - Delete sub-flow: "Delete frame" → swaps to "Confirm delete" (
FrameEditDialog.tsx:168-184, G-07-4 two-step). Second click callsdeleteFrame(id); primitives bound to the frame are reconciled to world coords (frames.ts:230-238).
5. Committed state
After addFrame (frames.ts:180-195), one new Frame is appended to scene.geometry.frames:
Frame {
id, name, kind: 'cart' | 'polar',
originX, originY, rotationDeg,
parentFrame: string | null, // null = global frame (root)
visible: boolean, // axis indicators on/off
}The global frame is implicit: any primitive with frameId === null (or undefined) is in world coords; computeFrameTransform(null) returns the identity { tx: 0, ty: 0, rot: 0 } (frames.ts:104).
Frames carry no nodes/segments of their own. Primitives attach to a frame via an optional frameId field on:
Node.frameId(scene-model.ts:42) — see Node. Stored(x, y)are local; world is the resolved chain.Circle.frameId(scene-model.ts:311).BlockLabel.frameId(scene-model.ts:561).Segment/Arc/Splineinherit their frame transitively through their endpoint nodes (the segment/arc record itself stores noframeId; the per-primitive comment atscene-model.ts:716-719is aspirational and not yet realized on those records).
Frames are bulk-assigned to a current selection via the context-menu "Assign frame…" entry (Canvas/index.tsx:4508 → assignFrameOnSelected).
Reverse-codegen: not yet wired — no codegen.ts emitter writes a frame(...) MotorScript statement. Frame catalogue is currently scene-state only.
6. Constraints / interactions
| Surface | Behavior |
|---|---|
| Constraints | Frames are not constraint participants. Constraints operate on nodes/segments/arcs in their resolved world coords. |
Clone (applyClone) | frameId is preserved on every cloned node via patchClonedNodeAnchor (transforms.ts:650-685). Cloned primitives stay in the same local frame as their source. |
Mirror clone (applyCloneMirror) | Same as clone: mirror axes are world-frame, so a node tagged with frameId retains that exact frameId on the mirrored copy — mirroring never reassigns frames (transforms.ts:677-681). |
| Mesher / FEM export | Each primitive is resolved to world coords via resolveNode (frames.ts:170-174) before reaching the mesher. Frames are a pre-mesh abstraction; the meshed model sees only world coordinates. |
| Drag | Dragging a node tagged with frameId updates its local (x, y) (the drag delta is transformed into the frame's local axes first). Moving the frame itself (via dialog) moves every primitive bound to it. |
kind: 'polar' | Stored on the frame, surfaced in the dialog kind picker (FrameEditDialog.tsx:128-134). No rendering, mesher, or coordinate-resolution code path currently branches on kind — computeFrameTransform treats both kinds as rigid (translation + rotation). Polar interpretation of local (x, y) as (r, θ) is not yet wired. Cross-ref: future polar-frame work. |
Cross-refs: Node, Circle, BlockLabel (when authored), modify/mirror, modify/copy-and-array.
7. Failure modes
- Non-finite numeric input in FrameEditDialog (
originX,originY,rotationDegparse to NaN/Infinity): submit is a silent no-op (FrameEditDialog.tsx:80). Form stays open; no toast. - Rotation
NaNseeded by an external mutation (executor, undo merge):computeFrameTransformpropagatesNaNthrough the chain — every bound primitive resolves toNaNworld coords and renders off-screen. There is no defensive clamp; the contract is that the dialog is the only entry surface forrotationDegand it pre-validates. - Parent-chain cycle (self-reference, A→B→A, longer chains):
- At edit time:
updateFramerunsdetectFrameCycleon the proposed new frames list; if a cycle is found, the patch is rejected, the previous frames array is preserved, andsurfaceErrortoasts "Cannot set that parent — it would create a frame cycle." (frames.ts:215-225). - The dialog's parent picker already excludes
this frameand all its descendants (FrameEditDialog.tsx:91-101), so the rejection path is normally unreachable from the UI — it guards executor/undo paths. - At resolve time:
computeFrameTransformis cycle-safe — a malformed chain returns the world-origin transform{ 0, 0, 0 }and skips poisoning the cache for any frame on the offending stack (frames.ts:107-114, 135-140, V-05-4).
- At edit time:
- Deleted frame referenced by surviving primitives:
deleteFramebakes each bound primitive's local(x, y)into world coords via the removed frame's accumulated transform, then clearsframeId. Primitives stay visually pinned (frames.ts:230-238). - Stale
editingFrameId(dialog open against a frame deleted by undo / sibling tab edit):useEffectin the dialog closes itself (FrameEditDialog.tsx:54-56, G-07-1).
8. Figures
global frame local frame F (originX=4, originY=2, rotationDeg=30°)
y
│ y' x'
│ \ ╱
4 ┤ \ ╱
│ ●─── (4, 2) origin of F
2 ┤ ◯ (frameless node @ 6, 2) ╱ ╲
│ ╱ ╲
0 └────●────────────────► x ╱ ╲
(0,0) node N stored locally at (3, 0)
resolves to world ≈ (4+3·cos30°, 2+3·sin30°)
≈ (6.60, 3.50)Figure 1 (ASCII): A frame F rotated 30° about its origin at world (4, 2). Node N with frameId = F.id and local (3, 0) resolves to world (6.60, 3.50) via resolveNode. Moving F (dialog edit) updates every primitive tagged with F in lockstep.
Figure 1: Global frame at world origin (blue X/Y) versus a local frame F translated to (150, 100) and rotated 30° (purple X'/Y').
Figure 2: Node N is stored with frameId = F.id and local (50, 0). It is rendered at the world coordinate that local-(50, 0) maps to under F's translation + 30° rotation.
Figure 3: Parent-frame nesting — global → frame A → frame B. Each child's origin is offset from its parent's origin; computeFrameTransform composes the chain up to root.
9. Known bugs
- No on-canvas visible marker for frames. The
visibleflag is stored on every frame and exposed in the dialog, but no renderer draws axis indicators at the frame's world origin. Users can only locate an existing frame by remembering its origin position and right-clicking within 8 px of it (Canvas/index.tsx:2466-2477); on a fresh tab there is no way to see what frames exist or where they sit. Discoverability gap, not a correctness gap — bound primitives still resolve correctly.
Related specs: Node, Circle, Segment, Spline, Mirror, Copy & Array.
10. Class API
In the current model, frames are class instances rather than plain objects. Frame extends SceneEntity directly — a workplane / axis frame is not a Primitive and not an Annotation; it is a coordinate datum.
- Class file:
app/src/lib/fea2d/model/Frame.ts - Constructor:
new Frame(id: FrameId, name: string, kind: FrameKind, originX: number, originY: number, rotationDeg: number, parentFrame: FrameId | null, visible: boolean = true)— validates finiteoriginX/originY/rotationDeg. Acyclic parent-chain enforcement is a Scene-level invariant, not a Frame invariant —Scene.addFrame/Scene.updateFrameruns thedetectFrameCyclecheck. - Snapshot type:
FrameSnapshot(app/src/lib/fea2d/model/snapshots/Frame.ts). - Lifecycle methods:
toJSON: FrameSnapshotstatic fromJSON(snap): Frameclone: Frameequals(other: SceneEntity): boolean
- Geometry methods:
transformPoint(p: { x, y }): { x, y }— local → parent transform (single hop). Composition across the chain to root is the caller's responsibility today.inverseTransform(p: { x, y }): { x, y }— parent → local transform (single hop).axisX: { x, y },axisY: { x, y }— unit axis vectors expressed in the parent frame.
id is readonly. All other fields are public mutable so the Frame dialog can edit in place.