partforge 0.87.0 → 0.89.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,464 @@
1
+ // The typed sketch-element model (spec 2026-08-27): store, per-type geometry,
2
+ // gesture builders, edit appliers, eraser, and semantics. Pure — no DOM, no
3
+ // three (the feature-dims.js stance). All coordinates are STAGE SPACE:
4
+ // y ∈ [0,1], x ∈ [0,aspect]; pixels exist only at the renderer/mode boundary,
5
+ // which is what keeps a circle circular regardless of viewport shape.
6
+
7
+ export const INK_COLORS = { red: "#d92d20", blue: "#1570ef", green: "#079455" };
8
+ // Stroke width as a fraction of the viewport short edge (unchanged from ink.js).
9
+ export const DEFAULT_STROKE_WIDTH = 0.004;
10
+ // An element erased below this visible fraction is dropped entirely.
11
+ export const MIN_VISIBLE = 0.02;
12
+
13
+ const GAP_TOUCH = 1e-4; // spans this close merge (half-sample slack)
14
+
15
+ export function mergeGaps(gaps) {
16
+ if (!gaps.length) return [];
17
+ const sorted = [...gaps].sort((a, b) => a[0] - b[0]);
18
+ const out = [sorted[0].slice()];
19
+ for (const [a, b] of sorted.slice(1)) {
20
+ const last = out[out.length - 1];
21
+ if (a <= last[1] + GAP_TOUCH) last[1] = Math.max(last[1], b);
22
+ else out.push([a, b]);
23
+ }
24
+ return out;
25
+ }
26
+
27
+ export const inGaps = (t, gaps) => gaps.some(([a, b]) => t >= a && t <= b);
28
+
29
+ export const visibleFraction = (el) =>
30
+ 1 - el.gaps.reduce((sum, [a, b]) => sum + (b - a), 0);
31
+
32
+ // Sample cache: params are immutable per edit step, so outlines memoize on the
33
+ // element object; every mutation path goes through touch()/invalidateSample.
34
+ const sampleCache = new WeakMap();
35
+ export const invalidateSample = (el) => sampleCache.delete(el);
36
+ export const cachedSample = (el, compute) => {
37
+ let hit = sampleCache.get(el);
38
+ if (!hit) { hit = compute(el); sampleCache.set(el, hit); }
39
+ return hit;
40
+ };
41
+
42
+ export function createElementStore() {
43
+ let items = [];
44
+ const undoStack = [];
45
+ const listeners = new Set();
46
+ const notify = () => { for (const cb of [...listeners]) cb(); };
47
+ return {
48
+ snapshot() { undoStack.push(JSON.stringify(items)); },
49
+ add(el) { items.push(el); notify(); },
50
+ touch(el) { invalidateSample(el); notify(); },
51
+ setList(next) { items = next; notify(); },
52
+ list: () => items,
53
+ isEmpty: () => items.length === 0,
54
+ count: () => items.length,
55
+ canUndo: () => undoStack.length > 0,
56
+ undo() {
57
+ if (!undoStack.length) return;
58
+ items = JSON.parse(undoStack.pop());
59
+ notify();
60
+ },
61
+ clear() {
62
+ if (!items.length) return;
63
+ undoStack.push(JSON.stringify(items));
64
+ items = [];
65
+ notify();
66
+ },
67
+ // Exit-mode discard: unlike clear(), this takes no snapshot and drops the
68
+ // undo history outright — annotate-mode.js's setEnabled(false) uses this
69
+ // (never clear()) because ink must not survive a mode exit AND a
70
+ // re-entered mode must not carry a phantom "undo" back to stale strokes.
71
+ reset() {
72
+ items = [];
73
+ undoStack.length = 0;
74
+ notify();
75
+ },
76
+ onChange(cb) { listeners.add(cb); return () => listeners.delete(cb); },
77
+ };
78
+ }
79
+
80
+ // ---- rotation helpers ------------------------------------------------------
81
+ export const rot2 = (x, y, a) =>
82
+ [x * Math.cos(a) - y * Math.sin(a), x * Math.sin(a) + y * Math.cos(a)];
83
+ export const invRot2 = (x, y, a) => rot2(x, y, -a);
84
+
85
+ const SHAPE_SEGMENTS = 320;
86
+ const polylineSegments = (length) =>
87
+ Math.max(24, Math.min(600, Math.round(length / 0.004)));
88
+
89
+ function computeSample(el) {
90
+ const pts = [];
91
+ const push = (x, y, t) => pts.push({ x, y, t });
92
+ const p = el.params;
93
+ if (el.type === "freehand") {
94
+ const P = p.points;
95
+ if (P.length === 1) push(P[0][0], P[0][1], 0);
96
+ else {
97
+ const cum = [0];
98
+ for (let i = 1; i < P.length; i++) {
99
+ cum.push(cum[i - 1] + Math.hypot(P[i][0] - P[i - 1][0], P[i][1] - P[i - 1][1]));
100
+ }
101
+ const total = cum[cum.length - 1] || 1;
102
+ const N = polylineSegments(total);
103
+ let seg = 1;
104
+ for (let i = 0; i <= N; i++) {
105
+ const d = (i / N) * total;
106
+ while (seg < cum.length - 1 && cum[seg] < d) seg++;
107
+ const span = cum[seg] - cum[seg - 1] || 1;
108
+ const f = Math.min(1, Math.max(0, (d - cum[seg - 1]) / span));
109
+ push(
110
+ P[seg - 1][0] + (P[seg][0] - P[seg - 1][0]) * f,
111
+ P[seg - 1][1] + (P[seg][1] - P[seg - 1][1]) * f,
112
+ i / N,
113
+ );
114
+ }
115
+ }
116
+ } else if (el.type === "line") {
117
+ const N = polylineSegments(Math.hypot(p.x2 - p.x1, p.y2 - p.y1));
118
+ for (let i = 0; i <= N; i++) {
119
+ const t = i / N;
120
+ push(p.x1 + (p.x2 - p.x1) * t, p.y1 + (p.y2 - p.y1) * t, t);
121
+ }
122
+ } else if (el.type === "rect") {
123
+ const { cx, cy, w, h, rot = 0 } = p;
124
+ const per = 2 * (w + h) || 1;
125
+ for (let i = 0; i <= SHAPE_SEGMENTS; i++) {
126
+ const t = i / SHAPE_SEGMENTS;
127
+ const d = t * per;
128
+ let lx, ly; // local frame, top-left origin, clockwise
129
+ if (d <= w) { lx = -w / 2 + d; ly = -h / 2; }
130
+ else if (d <= w + h) { lx = w / 2; ly = -h / 2 + (d - w); }
131
+ else if (d <= 2 * w + h) { lx = w / 2 - (d - w - h); ly = h / 2; }
132
+ else { lx = -w / 2; ly = h / 2 - (d - 2 * w - h); }
133
+ const [wx, wy] = rot2(lx, ly, rot);
134
+ push(cx + wx, cy + wy, t);
135
+ }
136
+ } else if (el.type === "ellipse") {
137
+ const { cx, cy, rx, ry, rot = 0 } = p;
138
+ for (let i = 0; i <= SHAPE_SEGMENTS; i++) {
139
+ const t = i / SHAPE_SEGMENTS;
140
+ const a = t * Math.PI * 2;
141
+ const [wx, wy] = rot2(rx * Math.cos(a), ry * Math.sin(a), rot);
142
+ push(cx + wx, cy + wy, t);
143
+ }
144
+ }
145
+ return { pts, closed: el.type === "rect" || el.type === "ellipse" };
146
+ }
147
+
148
+ export const sample = (el) => cachedSample(el, computeSample);
149
+
150
+ export function visibleRuns(el) {
151
+ const { pts } = sample(el);
152
+ const runs = [];
153
+ let run = null;
154
+ for (const p of pts) {
155
+ if (inGaps(p.t, el.gaps)) { run = null; continue; }
156
+ if (!run) { run = []; runs.push(run); }
157
+ run.push(p);
158
+ }
159
+ return runs;
160
+ }
161
+
162
+ export function centerOf(el) {
163
+ const p = el.params;
164
+ if (el.type === "rect" || el.type === "ellipse") return [p.cx, p.cy];
165
+ if (el.type === "line") return [(p.x1 + p.x2) / 2, (p.y1 + p.y2) / 2];
166
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
167
+ for (const [x, y] of p.points) {
168
+ minX = Math.min(minX, x); maxX = Math.max(maxX, x);
169
+ minY = Math.min(minY, y); maxY = Math.max(maxY, y);
170
+ }
171
+ return [(minX + maxX) / 2, (minY + maxY) / 2];
172
+ }
173
+
174
+ // ---- draw-gesture builders -------------------------------------------------
175
+ const MIN_EXTENT = 0.002; // stage units; degenerate drags stay visible as slivers
176
+
177
+ // Square/circle magnet, measured in cursor DISTANCE like the line snap: the
178
+ // perpendicular distance from the dragged corner to the box diagonal is
179
+ // |w − h|/√2, so the drag snaps to 1:1 when the cursor sits within
180
+ // `snapDistance` (stage units) of where the square's corner would be. A big
181
+ // box snaps only when genuinely near-square; a small one stays forgiving.
182
+ function snappedBox(x0, y0, x, y, force, snapDistance) {
183
+ let w = Math.abs(x - x0), h = Math.abs(y - y0);
184
+ const near = snapDistance > 0 && Math.abs(w - h) / Math.SQRT2 <= snapDistance;
185
+ const snapped = force || near;
186
+ if (snapped) w = h = Math.max(w, h);
187
+ w = Math.max(w, MIN_EXTENT); h = Math.max(h, MIN_EXTENT);
188
+ // Center = drag origin + half the (possibly snapped) extent, signed by drag
189
+ // direction. This is corner-order independent — equivalent to (min+max)/2
190
+ // of the two corners when w/h are unchanged by snapping — and when
191
+ // snapping grows w/h past the raw drag delta, it hangs the box off the
192
+ // origin corner in the direction the user dragged.
193
+ const cx = x0 + Math.sign(x - x0 || 1) * w / 2;
194
+ const cy = y0 + Math.sign(y - y0 || 1) * h / 2;
195
+ return { cx, cy, w, h, snapped };
196
+ }
197
+
198
+ export function rectFromDrag(x0, y0, x, y, { force = false, snapDistance = 0 } = {}) {
199
+ const { cx, cy, w, h, snapped } = snappedBox(x0, y0, x, y, force, snapDistance);
200
+ return { params: { cx, cy, w, h, rot: 0 }, snapped };
201
+ }
202
+
203
+ export function ellipseFromDrag(x0, y0, x, y, { force = false, snapDistance = 0 } = {}) {
204
+ const { cx, cy, w, h, snapped } = snappedBox(x0, y0, x, y, force, snapDistance);
205
+ return { params: { cx, cy, rx: w / 2, ry: h / 2, rot: 0 }, snapped };
206
+ }
207
+
208
+ // Magnetic 0/45/90° snap, measured in DISTANCE rather than angle: the drag
209
+ // snaps when the cursor sits within `snapDistance` (stage units — the caller
210
+ // converts its pixel threshold) of the nearest snapped line through the start
211
+ // point. Distance-based snapping self-scales the way angle-based cannot: a
212
+ // long line snaps only when it is genuinely close to flat, a short one stays
213
+ // forgiving. `force` (shift) snaps from any angle; length is preserved.
214
+ export function lineFromDrag(x0, y0, x, y, { force = false, snapDistance = 0 } = {}) {
215
+ let x2 = x, y2 = y;
216
+ const len = Math.hypot(x - x0, y - y0);
217
+ const a = Math.atan2(y - y0, x - x0);
218
+ const nearest = Math.round(a / (Math.PI / 4)) * (Math.PI / 4);
219
+ const perpDistance = len * Math.abs(Math.sin(a - nearest));
220
+ const snapped = force || (snapDistance > 0 && perpDistance <= snapDistance);
221
+ if (snapped) {
222
+ x2 = x0 + len * Math.cos(nearest);
223
+ y2 = y0 + len * Math.sin(nearest);
224
+ }
225
+ return { params: { x1: x0, y1: y0, x2, y2 }, snapped };
226
+ }
227
+
228
+ // Freehand point thinning (the ink.js minDistance contract, Euclidean in
229
+ // stage space — stage space is already aspect-uniform).
230
+ export function appendThinned(points, x, y, minDistance) {
231
+ const last = points[points.length - 1];
232
+ if (Math.hypot(x - last[0], y - last[1]) < minDistance) return false;
233
+ points.push([x, y]);
234
+ return true;
235
+ }
236
+
237
+ // ---- hand tool: handles, appliers, probe ----------------------------------
238
+ export function handlesOf(el) {
239
+ const p = el.params;
240
+ if (el.type === "line") return [
241
+ { id: "p1", x: p.x1, y: p.y1 },
242
+ { id: "p2", x: p.x2, y: p.y2 },
243
+ ];
244
+ if (el.type === "rect") {
245
+ return [[-1, -1], [1, -1], [1, 1], [-1, 1]].map(([sx, sy]) => {
246
+ const [dx, dy] = rot2(sx * p.w / 2, sy * p.h / 2, p.rot || 0);
247
+ return { id: `corner${sx > 0 ? "R" : "L"}${sy > 0 ? "B" : "T"}`, sx, sy, x: p.cx + dx, y: p.cy + dy };
248
+ });
249
+ }
250
+ if (el.type === "ellipse") {
251
+ if (p.rx === p.ry) {
252
+ const [dx, dy] = rot2(p.rx, 0, p.rot || 0);
253
+ return [{ id: "r", x: p.cx + dx, y: p.cy + dy }];
254
+ }
255
+ const [ax, ay] = rot2(p.rx, 0, p.rot || 0);
256
+ const [bx, by] = rot2(0, p.ry, p.rot || 0);
257
+ return [
258
+ { id: "rx", x: p.cx + ax, y: p.cy + ay },
259
+ { id: "ry", x: p.cx + bx, y: p.cy + by },
260
+ ];
261
+ }
262
+ return [];
263
+ }
264
+
265
+ export function translateElement(el, dx, dy) {
266
+ const p = el.params;
267
+ if (el.type === "freehand") for (const q of p.points) { q[0] += dx; q[1] += dy; }
268
+ else if (el.type === "rect" || el.type === "ellipse") { p.cx += dx; p.cy += dy; }
269
+ else if (el.type === "line") { p.x1 += dx; p.y1 += dy; p.x2 += dx; p.y2 += dy; }
270
+ }
271
+
272
+ export function rectAnchorFor(el, handle) {
273
+ const p = el.params;
274
+ const [dx, dy] = rot2(-handle.sx * p.w / 2, -handle.sy * p.h / 2, p.rot || 0);
275
+ return [p.cx + dx, p.cy + dy];
276
+ }
277
+
278
+ const MIN_EDIT_EXTENT = 0.002;
279
+
280
+ export function resizeRectFromAnchor(el, ax, ay, rot, x, y, { force = false, snapDistance = 0 } = {}) {
281
+ const p = el.params;
282
+ let [dx, dy] = invRot2(x - ax, y - ay, rot);
283
+ const w = Math.abs(dx), h = Math.abs(dy);
284
+ // same cursor-distance magnet as snappedBox, in the rect's local frame
285
+ const near = snapDistance > 0 && Math.abs(w - h) / Math.SQRT2 <= snapDistance;
286
+ if (force || near) {
287
+ const m = Math.max(w, h);
288
+ dx = Math.sign(dx || 1) * m;
289
+ dy = Math.sign(dy || 1) * m;
290
+ }
291
+ p.w = Math.max(Math.abs(dx), MIN_EDIT_EXTENT);
292
+ p.h = Math.max(Math.abs(dy), MIN_EDIT_EXTENT);
293
+ const [ox, oy] = rot2(dx / 2, dy / 2, rot);
294
+ p.cx = ax + ox;
295
+ p.cy = ay + oy;
296
+ }
297
+
298
+ export function resizeEllipseHandle(el, handleId, x, y, { force = false, snapDistance = 0 } = {}) {
299
+ const p = el.params;
300
+ const [lx, ly] = invRot2(x - p.cx, y - p.cy, p.rot || 0);
301
+ if (handleId === "r") {
302
+ p.rx = p.ry = Math.max(MIN_EDIT_EXTENT, Math.hypot(lx, ly));
303
+ return;
304
+ }
305
+ if (handleId === "rx") p.rx = Math.max(MIN_EDIT_EXTENT, Math.abs(lx));
306
+ else p.ry = Math.max(MIN_EDIT_EXTENT, Math.abs(ly));
307
+ // circle magnet by cursor distance: the dragged radius handle sits |rx − ry|
308
+ // from where the circle's rim would be
309
+ const near = snapDistance > 0 && Math.abs(p.rx - p.ry) <= snapDistance;
310
+ if (force || near) p.rx = p.ry = handleId === "rx" ? p.rx : p.ry;
311
+ }
312
+
313
+ export function applyRotation(el, origParams, center, totalAngle) {
314
+ const [cx, cy] = center;
315
+ const p = el.params;
316
+ if (el.type === "rect" || el.type === "ellipse") {
317
+ p.rot = (origParams.rot || 0) + totalAngle;
318
+ } else if (el.type === "line") {
319
+ const [ax, ay] = rot2(origParams.x1 - cx, origParams.y1 - cy, totalAngle);
320
+ const [bx, by] = rot2(origParams.x2 - cx, origParams.y2 - cy, totalAngle);
321
+ p.x1 = cx + ax; p.y1 = cy + ay; p.x2 = cx + bx; p.y2 = cy + by;
322
+ } else if (el.type === "freehand") {
323
+ p.points = origParams.points.map(([px, py]) => {
324
+ const [dx, dy] = rot2(px - cx, py - cy, totalAngle);
325
+ return [cx + dx, cy + dy];
326
+ });
327
+ }
328
+ }
329
+
330
+ function minVisibleDistance(el, x, y) {
331
+ let min = Infinity;
332
+ for (const run of visibleRuns(el)) {
333
+ for (const q of run) min = Math.min(min, Math.hypot(q.x - x, q.y - y));
334
+ }
335
+ return min;
336
+ }
337
+
338
+ export function probe(list, x, y, { reach, handleR, band }) {
339
+ for (let i = list.length - 1; i >= 0; i--) {
340
+ for (const h of handlesOf(list[i])) {
341
+ if (Math.hypot(h.x - x, h.y - y) <= handleR) return { kind: "handle", el: list[i], handle: h };
342
+ }
343
+ }
344
+ // One distance scan per element serves both tests: topmost-first, an
345
+ // outline hit returns immediately (band membership of anything beneath is
346
+ // moot — outline wins), otherwise band candidates accumulate. Rotate only
347
+ // when "just outside" is unambiguous: exactly one element that close.
348
+ let nearCount = 0;
349
+ let nearEl = null;
350
+ for (let i = list.length - 1; i >= 0; i--) {
351
+ const d = minVisibleDistance(list[i], x, y);
352
+ if (d <= reach) return { kind: "outline", el: list[i] };
353
+ if (d <= reach + band) { nearCount += 1; nearEl = list[i]; }
354
+ }
355
+ return nearCount === 1 ? { kind: "rotate", el: nearEl } : null;
356
+ }
357
+
358
+ // ---- eraser ----------------------------------------------------------------
359
+ function distToSegment(px, py, ax, ay, bx, by) {
360
+ const dx = bx - ax, dy = by - ay;
361
+ const l2 = dx * dx + dy * dy;
362
+ const t = l2 ? Math.min(1, Math.max(0, ((px - ax) * dx + (py - ay) * dy) / l2)) : 0;
363
+ return Math.hypot(px - (ax + dx * t), py - (ay + dy * t));
364
+ }
365
+
366
+ // One eraser sweep step: subtract the covered t-spans from every element it
367
+ // touches. Params are never modified — only `gaps` — which is the whole
368
+ // point: a half-erased circle is still "a circle, center c, radius r".
369
+ export function eraseSegment(list, ax, ay, bx, by, { radius, halfWidth }) {
370
+ let changed = false;
371
+ const next = list.filter((el) => {
372
+ const { pts } = sample(el);
373
+ const halfStep = (pts.length > 1 ? pts[1].t - pts[0].t : 1) / 2;
374
+ const hits = [];
375
+ for (const p of pts) {
376
+ if (distToSegment(p.x, p.y, ax, ay, bx, by) <= radius + halfWidth) {
377
+ hits.push([Math.max(0, p.t - halfStep), Math.min(1, p.t + halfStep)]);
378
+ }
379
+ }
380
+ if (!hits.length) return true;
381
+ changed = true;
382
+ el.gaps = mergeGaps(el.gaps.concat(hits));
383
+ return visibleFraction(el) > MIN_VISIBLE;
384
+ });
385
+ return { changed, list: changed ? next : list };
386
+ }
387
+
388
+ // ---- semantics -------------------------------------------------------------
389
+ function rotNote(rot) {
390
+ let d = Math.round((rot || 0) * 180 / Math.PI) % 360;
391
+ if (d > 180) d -= 360;
392
+ if (d <= -180) d += 360;
393
+ return d ? ` · rot ${d}°` : "";
394
+ }
395
+
396
+ // Where an erased span sits, in each type's own vocabulary: rects name the
397
+ // edges the gap crosses (the t-domain walks the perimeter clockwise from the
398
+ // top-left corner), ellipses give the arc in degrees (t=0 at 3 o'clock,
399
+ // increasing screen-clockwise, before rotation), open paths give a % range
400
+ // along their length. Raw t-spans stay in the payload's `erased`; this is the
401
+ // human/LLM-readable rendering of the same data.
402
+ const RECT_EDGES = ["top", "right", "bottom", "left"];
403
+ export function describeGap(el, [a, b]) {
404
+ if (el.type === "ellipse") {
405
+ return `arc ${Math.round(a * 360)}°–${Math.round(b * 360)}°`;
406
+ }
407
+ if (el.type === "rect") {
408
+ const { w, h } = el.params;
409
+ const per = 2 * (w + h) || 1;
410
+ const ends = [w / per, (w + h) / per, (2 * w + h) / per, 1];
411
+ const edges = RECT_EDGES.filter((_, i) =>
412
+ a < ends[i] - GAP_TOUCH && b > (i ? ends[i - 1] : 0) + GAP_TOUCH);
413
+ return edges.length === 1 ? `${edges[0]} edge` : `${edges.join("–")} edges`;
414
+ }
415
+ return `${Math.round(a * 100)}%–${Math.round(b * 100)}%`;
416
+ }
417
+
418
+ const GAP_NOTE_MAX = 3; // heavy freehand scrubbing can leave dozens of gaps
419
+ function gapNote(el) {
420
+ if (!el.gaps.length) return "";
421
+ const pct = Math.round(visibleFraction(el) * 100);
422
+ const noted = el.gaps.slice(0, GAP_NOTE_MAX).map((g) => describeGap(el, g));
423
+ const more = el.gaps.length - noted.length;
424
+ return ` · ${pct}% visible · erased ${noted.join(", ")}${more > 0 ? ` +${more} more` : ""}`;
425
+ }
426
+
427
+ export function describeElement(el, aspect) {
428
+ const p = el.params;
429
+ const px = (v) => `${Math.round((v / aspect) * 100)}%`; // x-positions, widths
430
+ const py = (v) => `${Math.round(v * 100)}%`; // y-positions, heights
431
+ const pr = (v) => `${Math.round((v / Math.min(1, aspect)) * 100)}%`; // radii: short edge
432
+ let base;
433
+ if (el.type === "freehand") base = `freehand · ${p.points.length} pts`;
434
+ else if (el.type === "line") {
435
+ base = `line · (${px(p.x1)}, ${py(p.y1)}) → (${px(p.x2)}, ${py(p.y2)})`;
436
+ } else if (el.type === "rect") {
437
+ base = (p.w === p.h
438
+ ? `square · c (${px(p.cx)}, ${py(p.cy)}) · ${px(p.w)}`
439
+ : `rect · c (${px(p.cx)}, ${py(p.cy)}) · ${px(p.w)} × ${py(p.h)}`) + rotNote(p.rot);
440
+ } else {
441
+ base = (p.rx === p.ry
442
+ ? `circle · c (${px(p.cx)}, ${py(p.cy)}) · r ${pr(p.rx)}`
443
+ : `ellipse · c (${px(p.cx)}, ${py(p.cy)}) · rx ${pr(p.rx)} · ry ${pr(p.ry)}` + rotNote(p.rot));
444
+ }
445
+ return base + gapNote(el);
446
+ }
447
+
448
+ export function elementAnchors(el) {
449
+ const out = [];
450
+ // `run` disambiguates anchors on a gapped element: a twice-erased circle has
451
+ // three visible fragments, each with its own start/mid/end. The center
452
+ // anchor belongs to the whole shape, not a fragment, so it carries no run.
453
+ visibleRuns(el).forEach((run, i) => {
454
+ const mid = run[Math.floor(run.length / 2)];
455
+ out.push({ at: "start", run: i, x: run[0].x, y: run[0].y });
456
+ out.push({ at: "mid", run: i, x: mid.x, y: mid.y });
457
+ out.push({ at: "end", run: i, x: run[run.length - 1].x, y: run[run.length - 1].y });
458
+ });
459
+ if (el.type === "rect" || el.type === "ellipse") {
460
+ const [x, y] = centerOf(el);
461
+ out.push({ at: "center", x, y });
462
+ }
463
+ return out;
464
+ }