Appearance
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/dyset by pole pitch). - Duplicate a parametric
RectFeatureorPolygonFeatureand 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"andtransformMode = "copy"(Canvas/index.tsx:461,507,1296,4525,4529,4530,4797). - Keyboard shortcut: not yet wired.
- MotorScript builtins (parametric array features — the count stays editable):
arr = arrayRadial(refs, count, center, angleDeg)—refsis one entity or a list like[l1, l2];countincludes the original; copy k is rotatedk · angleDegaboutcenter, so a full ring is360/count.arr = arrayLinear(refs, count, dx, dy)— copy k is offset byk · (dx, dy).- Cap:
countmust be a positive integer no larger thanMAX_ARRAY_COUNT= 256 (input-unit/features.ts). A count written as a number over the cap is refused: the line gets an error, the script is not rebuilt, and the drawing stays as it was. Editing the script and pressing Run refuse it the same way. A count that comes from a parameter is clamped to 256 copies when drawn, with a diagnostic. - Retired verbs:
radial(entity, count, center)andlinearArray(entity, dx, dy, count)are refused the same way, and the error names the rewrite —radial(E, n, c)→arrayRadial([E], n, c, 360/n),linearArray(E, dx, dy, n)→arrayLinear([E], n, dx, dy). The rewrite draws the same geometry.
- 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-settransformCopyKindPresetso the shared dialog opens on the right tab. - Dialog: shared
TransformDialogwithmode="copy";arrayKindof'linear'or'radial', pluscount,(dx,dy)for linear,(cx,cy)+angleDegfor radial (TransformDialog.tsx:19-32,69-70). Confirm dispatches tocopySelectedLinearorcopySelectedRadial(Canvas/index.tsx:4797-4802).
4. State machine
- 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:
Escaborts; switching tools aborts.
- 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
Cancel→setTransformMode(null),setActiveTool("select")(Canvas/index.tsx:4811-4814).
- Preview after step 2: dialog opens with
- Finalize: dialog
Confirmdispatches:- 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
transformModeandactiveTooltoselect.
- Linear:
5. Committed state
For each i = 1..count the cloner emits via applyClone (transforms.ts:687-826):
- New
Segments viaaddSegment(which auto-splits against existing geometry); new endpointNodes are created or deduped atTOL_POINT. - New
Arcs viaaddArcpreserving the sourcearcLength(sign unchanged for translate/rotate; flipped only in mirror, see mirror.md). - New
Circles viaaddCircle(radius invariant). - New
Splines viaaddSplinewith materialised CP nodes carryingsplineCpRefback-pointers. - New
BlockLabels viaaddBlockLabel, withblockType/inCircuit/turns/magDir/fillFactor/inGroup/maxAreapatched in viaapplyLabelProps(transforms.ts:1251-1269). - Lone selected
Nodes (not endpoints of any cloned segment/arc, notvirtualRef) cloned viainlineAddNode.
Plus the patch-preservation chain — applied in this order at every clone step:
- anchor (
patchClonedNodeAnchor,transforms.ts:650-685): snapshotspreExistingIdsbefore eachaddSegment/addArc/addSpline/inlineAddNode, then copiesanchorandanchoredfrom the source node onto the cloned node located at the transformed position. The snapshot guard prevents stomping whenaddSegmentdedupes to a pre-existing node. - spline tangent locks (
transforms.ts:762-773):tangentLockStart/tangentLockEndpropagated onto the just-added spline (identified as the one whose id isn't inpreSplineIds). - RectFeature/PolygonFeature replication (
replicateRectstransforms.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'sparallel+parallel+perpendicularconstraint trio (encoding) or the polygon's(sides-1)equal+sidesdistance(centre,vertex)constraints. Polygon centre construction node is materialised viaaddNode(..., 'construction')if owned. Partial selection cleanly degrades to "loose primitives, no feature". - frameId (
transforms.ts:681): cloned nodes inheritframeIdverbatim — every copy in a linear/radial pattern stays in the same local frame as the source. - Fillet replication (
replicateFillets,transforms.ts:1079-1112): anyFilletFeaturewhosesourceCornerId ∈ nodeIdsis re-created at the cloned corner viaapplyFillet(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 arrayLinear / arrayRadial directly.
6. Constraints / interactions
| Aspect | Behavior |
|---|---|
node.anchor | Source-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 node | Lone-node loop skips it (transforms.ts:798, 1213); the upstream feature owns the position and the cloned fillet/crossing materialises from cloned parents. |
frameId | Preserved verbatim across linear/radial/mirror clone (transforms.ts:681). |
RectFeature | All 4 backing nodes selected → cloned with fresh parallel/parallel/perpendicular constraint trio (replicateRects). |
PolygonFeature | All N vertices selected → cloned with (sides-1) equal-length + sides distance(centre→vertex) constraints (replicatePolygons); centre node materialised. |
FilletFeature | sourceCornerId ∈ nodeIds → re-emitted at copied corner with same radius (replicateFillets). Partial parent selection at copied corner → applyFillet bails, no fillet on clone. |
Crossing | Not 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-pull | A 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". |
| Count cap (script) | arrayRadial / arrayLinear refuse a literal count > 256 (MAX_ARRAY_COUNT); the script is not rebuilt and the drawing is kept, on a script edit and on Run alike. A parameter-driven count is clamped to 256. |
7. Failure modes
count < 1: UI path returns scene unchanged (transforms.ts:567, 600). Script path refuses the line:array count must be a positive integer (got <n>).count > MAX_PATTERN_COUNT (5000): UI path throwstransformCopyArray: count <n> exceeds the per-pattern limit of 5000(transforms.ts:573-577, 603-607). Scripts stop earlier: above 256 the line is refused witharray count capped at 256 (got <n>), the script is not rebuilt and the drawing is kept.(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 refuses the line (
array count must be a positive integer); UI path'scountfield usesparseDialogNumericand silently rounds — BACKLOG to surface a dialog-side validator. radial()/linearArray()in a script: refused with the rewrite named (see §3); the script is not rebuilt and the drawing is kept.- Empty selection:
expandSelectionreturns 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.*
Figure 1 (single copy): original (dashed) and a single clone (solid) offset by Δx/Δy.
Figure 2 (linear array N=4): original (dashed) plus 3 clones translated along a vector.
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
transformCopyRadialloopsi=1..count(emitscountclones) while scriptradialloopsi=1..count-1(emitscount-1clones, treating source as i=0). Not yet catalog-numbered.
linearArraysilent empty on non-line entity —builtins/transforms.ts:200returns[]instead of throwing the domain-specific error its siblingradialthrows. 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 (soinstanceof <Class>survives) but keep the same id. The array path remaps to a fresh id by reading the snapshot viatoJSON, allocating a new id fromScene.mintXxxId, and constructing viafromJSON({ ...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.