Skip to content

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(...) in executor/builtins).
  • Command Palette: not yet wired.
  • Context menu: "Add frame here" on the canvas right-click menu (Canvas/index.tsx:4502-4507) → opens NameDialog for 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) → opens FrameEditDialog (FrameEditDialog.tsx).

4. State machine

Add flow (context menu "Add frame here"):

  1. 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 NameDialog appears asking for a name.
    • Cancel options: Esc / Cancel button / click outside dialog → no frame created.
  2. Finalize: type a name, press Enter (or click "Add"). addFrame({ name, originX, originY, rotationDeg: 0, parentFrame: null }) runs (Canvas/index.tsx:4704-4713frames.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):

  1. Right-click within 8 px screen of a frame's world origin: hit-test loop scans scene.geometry.frames, finds the match, calls setEditingFrameId(f.id) (Canvas/index.tsx:2470-2476). FrameEditDialog opens pre-filled.
    • Preview: modal; form fields show current name, kind, originX, originY, rotationDeg, parentFrame.
    • Cancel options: Esc / Cancel / click outside → no patch.
  2. 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).
  3. Delete sub-flow: "Delete frame" → swaps to "Confirm delete" (FrameEditDialog.tsx:168-184, G-07-4 two-step). Second click calls deleteFrame(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 / Spline inherit their frame transitively through their endpoint nodes (the segment/arc record itself stores no frameId; the per-primitive comment at scene-model.ts:716-719 is 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:4508assignFrameOnSelected).

Reverse-codegen: not yet wired — no codegen.ts emitter writes a frame(...) MotorScript statement. Frame catalogue is currently scene-state only.

6. Constraints / interactions

SurfaceBehavior
ConstraintsFrames 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 exportEach 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.
DragDragging 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 kindcomputeFrameTransform 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, rotationDeg parse to NaN/Infinity): submit is a silent no-op (FrameEditDialog.tsx:80). Form stays open; no toast.
  • Rotation NaN seeded by an external mutation (executor, undo merge): computeFrameTransform propagates NaN through the chain — every bound primitive resolves to NaN world coords and renders off-screen. There is no defensive clamp; the contract is that the dialog is the only entry surface for rotationDeg and it pre-validates.
  • Parent-chain cycle (self-reference, A→B→A, longer chains):
    • At edit time: updateFrame runs detectFrameCycle on the proposed new frames list; if a cycle is found, the patch is rejected, the previous frames array is preserved, and surfaceError toasts "Cannot set that parent — it would create a frame cycle." (frames.ts:215-225).
    • The dialog's parent picker already excludes this frame and 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: computeFrameTransform is 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).
  • Deleted frame referenced by surviving primitives: deleteFrame bakes each bound primitive's local (x, y) into world coords via the removed frame's accumulated transform, then clears frameId. Primitives stay visually pinned (frames.ts:230-238).
  • Stale editingFrameId (dialog open against a frame deleted by undo / sibling tab edit): useEffect in 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.

XYglobal (0, 0)X'Y'local F (150, 100), rot 30°30°

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').

XYX'Y'frame F originNlocal (50, 0)frame origin offset

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.

XYglobalXaYaframe A (parent: global)XbYbframe B (parent: A)parent linkparent link

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 visible flag 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 finite originX / originY / rotationDeg. Acyclic parent-chain enforcement is a Scene-level invariant, not a Frame invariant — Scene.addFrame / Scene.updateFrame runs the detectFrameCycle check.
  • Snapshot type: FrameSnapshot (app/src/lib/fea2d/model/snapshots/Frame.ts).
  • Lifecycle methods:
    • toJSON: FrameSnapshot
    • static fromJSON(snap): Frame
    • clone: Frame
    • equals(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.

motordevs studio — geometry editor specification