Skip to content

Copy and Array (Linear / Radial)

1. Identity

A copy is a modify-op that emits cloned copies of a selection — translated (linear array), rotated about a centre (radial array), or reflected across an axis (mirror copy, covered in mirror.md) — leaving the originals in place and routing each cloned primitive through the standard adders so auto-split fires.

2. When to use it

  • Replicate a stator slot N times around the bore centre (radial, count = slotCount).
  • Stamp out a row of rotor magnets along the airgap (linear, dx/dy set by pole pitch).
  • Duplicate a parametric RectFeature or PolygonFeature and keep its shape constraints intact on every copy (replication).
  • Mirror-copy a coil-end half across the d-axis to fill both phases (see mirror.md §3 clone mode).

3. Inputs

  • Tool button: Modify toolbar → Copy icon (linear and radial share the same toolbar entry with a kind preset). Sets activeTool = "copy" and transformMode = "copy" (Canvas/index.tsx:461,507,1296,4525,4529,4530,4797).
  • Keyboard shortcut: not yet wired.
  • MotorScript builtins (executor/builtins/transforms.ts):
    • radial(entity, count, center):69-130. Lines only; throws on non-integer/<1/non-line/missing-PointRef/count > MAX_PATTERN_COUNT (5000) / cumulative scene cap.
    • linearArray(entity, dx, dy, count):172-210. Lines only; same guards on count.
    • Caps: per-call MAX_PATTERN_COUNT = 5000 plus cumulative MAX_SCRIPT_ENTITIES_LOCAL = 5000 projected check before any allocation.
  • Command Palette: not yet wired.
  • Context menu: right-click on a non-empty selection → "Copy…", "Linear Array…", "Radial Array…" (entries calling onStartCopy/onStartLinearArray/onStartCopyRadial, Canvas/index.tsx:4525-4530). The two array entries pre-set transformCopyKindPreset so the shared dialog opens on the right tab.
  • Dialog: shared TransformDialog with mode="copy"; arrayKind of 'linear' or 'radial', plus count, (dx,dy) for linear, (cx,cy) + angleDeg for radial (TransformDialog.tsx:19-32,69-70). Confirm dispatches to copySelectedLinear or copySelectedRadial (Canvas/index.tsx:4797-4802).

4. State machine

  1. Click 1 / Action: pick the reference point. Linear → first point of the (dx,dy) vector. Radial → centre of rotation (snap to a node for "rotate around this node" intent).
    • Preview after step 1: live-ghost of the selection at one-step offset/rotation following the cursor.
    • Cancel options: Esc aborts; switching tools aborts.
  2. Click 2 / Action: linear → second point fixing (dx,dy); radial → second point fixing the angle from centre (or the dialog can override).
    • Preview after step 2: dialog opens with count, arrayKind, and (dx,dy) or (cx,cy,angleDeg) prefilled.
    • Cancel options: dialog CancelsetTransformMode(null), setActiveTool("select") (Canvas/index.tsx:4811-4814).
  3. Finalize: dialog Confirm dispatches:
    • Linear: transformCopyArray(scene, selection, dx, dy, count) (transforms.ts:560-586).
    • Radial: transformCopyRadial(scene, selection, cx, cy, angleDeg, count) (transforms.ts:592-615).
    • Both reset transformMode and activeTool to select.

5. Committed state

For each i = 1..count the cloner emits via applyClone (transforms.ts:687-826):

  • New Segments via addSegment (which auto-splits against existing geometry); new endpoint Nodes are created or deduped at TOL_POINT.
  • New Arcs via addArc preserving the source arcLength (sign unchanged for translate/rotate; flipped only in mirror, see mirror.md).
  • New Circles via addCircle (radius invariant).
  • New Splines via addSpline with materialised CP nodes carrying splineCpRef back-pointers.
  • New BlockLabels via addBlockLabel, with blockType/inCircuit/turns/magDir/fillFactor/inGroup/maxArea patched in via applyLabelProps (transforms.ts:1251-1269).
  • Lone selected Nodes (not endpoints of any cloned segment/arc, not virtualRef) cloned via inlineAddNode.

Plus the patch-preservation chain — applied in this order at every clone step:

  • anchor (patchClonedNodeAnchor, transforms.ts:650-685): snapshots preExistingIds before each addSegment/addArc/addSpline/inlineAddNode, then copies anchor and anchored from the source node onto the cloned node located at the transformed position. The snapshot guard prevents stomping when addSegment dedupes to a pre-existing node.
  • spline tangent locks (transforms.ts:762-773): tangentLockStart/tangentLockEnd propagated onto the just-added spline (identified as the one whose id isn't in preSplineIds).
  • RectFeature/PolygonFeature replication (replicateRects transforms.ts:871-959, replicatePolygons :961-1077): when all backing nodes are in the selection, a fresh feature record is emitted with cloned segment/node ids, plus the rectangle's parallel+parallel+perpendicular constraint trio (encoding) or the polygon's (sides-1) equal + sides distance(centre,vertex) constraints. Polygon centre construction node is materialised via addNode(..., 'construction') if owned. Partial selection cleanly degrades to "loose primitives, no feature".
  • frameId (transforms.ts:681): cloned nodes inherit frameId verbatim — every copy in a linear/radial pattern stays in the same local frame as the source.
  • Fillet replication (replicateFillets, transforms.ts:1079-1112): any FilletFeature whose sourceCornerId ∈ nodeIds is re-created at the cloned corner via applyFillet(scene, copiedCorner.id, f.radius). The rect / polygon replicators above follow the same pattern.

MotorScript reverse-codegen is not yet wired for the UI clone path; scripts must call linearArray / radial directly.

6. Constraints / interactions

AspectBehavior
node.anchorSource-anchor flags copy to clone via patchClonedNodeAnchor. Clone path does NOT consult applyAnchor to gate the move — the clone is at a new world position by definition.
virtualRef source nodeLone-node loop skips it (transforms.ts:798, 1213); the upstream feature owns the position and the cloned fillet/crossing materialises from cloned parents.
frameIdPreserved verbatim across linear/radial/mirror clone (transforms.ts:681).
RectFeatureAll 4 backing nodes selected → cloned with fresh parallel/parallel/perpendicular constraint trio (replicateRects).
PolygonFeatureAll N vertices selected → cloned with (sides-1) equal-length + sides distance(centre→vertex) constraints (replicatePolygons); centre node materialised.
FilletFeaturesourceCornerId ∈ nodeIds → re-emitted at copied corner with same radius (replicateFillets). Partial parent selection at copied corner → applyFillet bails, no fillet on clone.
CrossingNot in selection language (transforms.ts:34); ignored by expandSelection. Cloned segments that intersect existing geometry re-derive crossings via addSegment's auto-split.
Segment auto-pullA segment whose n0 AND n1 are in nodeIds is cloned even when not explicitly selected (transforms.ts:706-710) — matches user intuition for "select two corners, copy the connecting edge".
Cumulative cap (script)Each call projects (count-1)*3 entities and fast-fails if existing + projected > 5000 (builtins/transforms.ts:104-112, 191-198).

7. Failure modes

  • count < 1: UI path returns scene unchanged (transforms.ts:567, 600). Script path throws linearArray: count must be a positive integer (got <n>) / radial: count must be a positive integer (builtins/transforms.ts:75-77, 176-178).
  • count > MAX_PATTERN_COUNT (5000): UI path throws transformCopyArray: count <n> exceeds the per-pattern limit of 5000 (transforms.ts:573-577, 603-607). Script path throws the equivalent linearArray / radial message and additionally fast-fails on cumulative cap (builtins/transforms.ts:93-112, 182-198).
  • (dx,dy) ≈ (0,0) (linear) or |angleDeg| < TOL.SOLVER (radial): UI path returns scene unchanged (transforms.ts:579, 608).
  • Non-integer / NaN / Infinity count: script path throws (builtins/transforms.ts:75-77, 176-178); UI path's count field uses parseDialogNumeric and silently rounds — BACKLOG to surface a dialog-side validator.
  • Non-line MotorScript entity: throws radial: currently supports only line entities / linearArray returns [] (note asymmetry — linearArray silently returns empty for non-line; radial throws). BACKLOG.
  • Radial centre missing/invalid in script: radial: center must be a PointRef.
  • Empty selection: expandSelection returns empty sets; every loop is a no-op; no toast.

8. Figure

       •───•   (source)

   step 1:•───• (clone i=1, rotated by 120°)

   step 2:•───• (clone i=2, rotated by 240°)

   centre ✱       3-element radial pattern about ✱ (count=3, angleDeg=120)

ASCII: 3-element radial array of a segment around centre ✱; source plus two clones positioned at angleDegi for i=1..2. For count=3 the executor emits count-1 = 2 clones (builtins/transforms.ts:116), while the UI transformCopyRadial emits count clones (transforms.ts:611) — see "source-code surprises" in the audit notes.*

(Δx,Δy)

Figure 1 (single copy): original (dashed) and a single clone (solid) offset by Δx/Δy.

sourcei=3

Figure 2 (linear array N=4): original (dashed) plus 3 clones translated along a vector.

centre

Figure 3 (radial array N=6): source (dashed) plus 5 clones placed at 60° intervals around the centre pivot.

9. Known bugs

UI/script clone-count asymmetry — UI transformCopyRadial loops i=1..count (emits count clones) while script radial loops i=1..count-1 (emits count-1 clones, treating source as i=0). Not yet catalog-numbered.

linearArray silent empty on non-line entitybuiltins/transforms.ts:200 returns [] instead of throwing the domain-specific error its sibling radial throws. Not yet catalog-numbered.

No other bugs known.

10. Class API

In the current model, the selection-aware copy pipelines still live as the free functions transformCopyArray(...), transformCopyRadial(...), transformCopyMirror(...) in app/src/lib/fea2d/transforms.ts — the transforms.ts dissolution into class methods is deferred to .

Cloning machinery available on the class layer:

  • Node.clone / Segment.clone / ArcSegment.clone / Circle.clone / Spline.clone / BlockLabel.clone / Frame.clone — structural copies that preserve the prototype (so instanceof <Class> survives) but keep the same id. The array path remaps to a fresh id by reading the snapshot via toJSON, allocating a new id from Scene.mintXxxId, and constructing via fromJSON({ ...snap, id: newId }).
  • Scene.addNode / addSegment / ... — duplicate-id-rejecting adders so the array path can't accidentally clobber an existing entity.

The virtualRef rewire (each cloned VirtualPoint must reference the COPIED upstream feature, not the original) is unchanged behaviour from pre-refactor transforms.ts; preserves the exact rewire semantics when the op moves onto class methods.

motordevs studio — geometry editor specification