@grida/svg-editor 1.0.0-alpha.13 → 1.0.0-alpha.15

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,3729 @@
1
+ import cmath from "@grida/cmath";
2
+ import { svg_parse } from "@grida/svg/parse";
3
+ import { SVGPathData, SVGPathDataTransformer, encodeSVGPath } from "@grida/svg/pathdata";
4
+ import vn from "@grida/vn";
5
+ //#region src/util/dom.ts
6
+ /**
7
+ * `true` when the document's active element is a text-input-like control
8
+ * (input / textarea / contentEditable). Used by keymap + gesture defaults
9
+ * to avoid hijacking keystrokes while the user is typing.
10
+ */
11
+ function is_text_input_focused() {
12
+ if (typeof document === "undefined") return false;
13
+ const el = document.activeElement;
14
+ if (!el) return false;
15
+ const tag = el.tagName;
16
+ if (tag === "INPUT" || tag === "TEXTAREA") return true;
17
+ if (el.isContentEditable) return true;
18
+ return false;
19
+ }
20
+ //#endregion
21
+ //#region src/util/equal.ts
22
+ function array_shallow_equal(a, b) {
23
+ if (a === b) return true;
24
+ if (a.length !== b.length) return false;
25
+ for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
26
+ return true;
27
+ }
28
+ //#endregion
29
+ //#region src/core/group.ts
30
+ let group;
31
+ (function(_group) {
32
+ const STRUCTURAL_GRAPHICS = _group.STRUCTURAL_GRAPHICS = new Set([
33
+ "g",
34
+ "defs",
35
+ "svg",
36
+ "use",
37
+ "image",
38
+ "switch",
39
+ "foreignObject",
40
+ "path",
41
+ "rect",
42
+ "circle",
43
+ "ellipse",
44
+ "line",
45
+ "polyline",
46
+ "polygon",
47
+ "text",
48
+ "a"
49
+ ]);
50
+ const CONSTRAINED_PARENT = _group.CONSTRAINED_PARENT = new Set([
51
+ "text",
52
+ "tspan",
53
+ "defs",
54
+ "clipPath",
55
+ "mask",
56
+ "pattern",
57
+ "marker",
58
+ "symbol",
59
+ "filter",
60
+ "linearGradient",
61
+ "radialGradient",
62
+ "animateMotion",
63
+ "switch"
64
+ ]);
65
+ function plan(doc, ids) {
66
+ if (ids.length === 0) return null;
67
+ const parent = doc.parent_of(ids[0]);
68
+ if (parent === null) return null;
69
+ for (const id of ids) {
70
+ if (doc.parent_of(id) !== parent) return null;
71
+ if (!STRUCTURAL_GRAPHICS.has(doc.tag_of(id))) return null;
72
+ }
73
+ if (CONSTRAINED_PARENT.has(doc.tag_of(parent))) return null;
74
+ const siblings = doc.element_children_of(parent);
75
+ const sibling_index = /* @__PURE__ */ new Map();
76
+ for (let i = 0; i < siblings.length; i++) sibling_index.set(siblings[i], i);
77
+ const indices = [];
78
+ for (const id of ids) {
79
+ const i = sibling_index.get(id);
80
+ if (i === void 0) return null;
81
+ indices.push(i);
82
+ }
83
+ const sorted = Array.from(new Set(indices)).sort((a, b) => a - b);
84
+ for (let i = 1; i < sorted.length; i++) if (sorted[i] !== sorted[i - 1] + 1) return null;
85
+ const children = sorted.map((i) => siblings[i]);
86
+ const last_index = sorted[sorted.length - 1];
87
+ const original_positions = /* @__PURE__ */ new Map();
88
+ for (const i of sorted) original_positions.set(siblings[i], siblings[i + 1] ?? null);
89
+ return {
90
+ parent,
91
+ insert_before: siblings[last_index + 1] ?? null,
92
+ children,
93
+ original_positions
94
+ };
95
+ }
96
+ _group.plan = plan;
97
+ })(group || (group = {}));
98
+ //#endregion
99
+ //#region src/core/transform.ts
100
+ let transform;
101
+ (function(_transform) {
102
+ /** SVG `<number>` production (spec-aligned subset). */
103
+ const SVG_NUMBER_SRC = "[+-]?(?:\\d+\\.?\\d*|\\.\\d+)(?:[eE][+-]?\\d+)?";
104
+ /** Recognized function names. Names are case-sensitive per SVG 1.1 §7.6. */
105
+ const FUNCTION_RE = /\s*([A-Za-z]+)\s*\(([^)]*)\)\s*/y;
106
+ const NUMBER_GLOBAL_RE = new RegExp(SVG_NUMBER_SRC, "g");
107
+ /** SVG 2 §11.6.1: `transform="none"` ≡ identity. CSS-wide keywords are
108
+ * normalized at parse time per SVG 2 cascade. */
109
+ const IDENTITY_RE = /^\s*(?:none|inherit|unset|initial|revert|revert-layer)?\s*$/;
110
+ function parse_args(args) {
111
+ const tokens = args.match(NUMBER_GLOBAL_RE);
112
+ if (!tokens) return [];
113
+ const out = [];
114
+ for (const t of tokens) {
115
+ const n = parseFloat(t);
116
+ if (!Number.isFinite(n)) return null;
117
+ out.push(n);
118
+ }
119
+ return out;
120
+ }
121
+ function build_op(name, a) {
122
+ switch (name) {
123
+ case "matrix":
124
+ if (a.length !== 6) return null;
125
+ return {
126
+ type: "matrix",
127
+ a: a[0],
128
+ b: a[1],
129
+ c: a[2],
130
+ d: a[3],
131
+ e: a[4],
132
+ f: a[5]
133
+ };
134
+ case "translate":
135
+ if (a.length !== 1 && a.length !== 2) return null;
136
+ return {
137
+ type: "translate",
138
+ tx: a[0],
139
+ ty: a.length === 2 ? a[1] : 0
140
+ };
141
+ case "rotate":
142
+ if (a.length !== 1 && a.length !== 3) return null;
143
+ return {
144
+ type: "rotate",
145
+ angle: a[0],
146
+ cx: a.length === 3 ? a[1] : 0,
147
+ cy: a.length === 3 ? a[2] : 0,
148
+ explicit_pivot: a.length === 3
149
+ };
150
+ case "scale":
151
+ if (a.length !== 1 && a.length !== 2) return null;
152
+ return {
153
+ type: "scale",
154
+ sx: a[0],
155
+ sy: a.length === 2 ? a[1] : a[0]
156
+ };
157
+ case "skewX":
158
+ if (a.length !== 1) return null;
159
+ return {
160
+ type: "skewX",
161
+ angle: a[0]
162
+ };
163
+ case "skewY":
164
+ if (a.length !== 1) return null;
165
+ return {
166
+ type: "skewY",
167
+ angle: a[0]
168
+ };
169
+ default: return null;
170
+ }
171
+ }
172
+ function parse(input) {
173
+ if (input === null) return [];
174
+ if (IDENTITY_RE.test(input)) return [];
175
+ const ops = [];
176
+ FUNCTION_RE.lastIndex = 0;
177
+ let pos = 0;
178
+ while (pos < input.length) {
179
+ FUNCTION_RE.lastIndex = pos;
180
+ const m = FUNCTION_RE.exec(input);
181
+ if (!m || m.index !== pos) return null;
182
+ const name = m[1];
183
+ const args = parse_args(m[2]);
184
+ if (args === null) return null;
185
+ const op = build_op(name, args);
186
+ if (!op) return null;
187
+ ops.push(op);
188
+ pos = FUNCTION_RE.lastIndex;
189
+ while (pos < input.length && /[\s,]/.test(input[pos])) pos++;
190
+ }
191
+ return ops;
192
+ }
193
+ _transform.parse = parse;
194
+ function n(x) {
195
+ return String(x);
196
+ }
197
+ function emit_op(op) {
198
+ switch (op.type) {
199
+ case "matrix": return `matrix(${n(op.a)} ${n(op.b)} ${n(op.c)} ${n(op.d)} ${n(op.e)} ${n(op.f)})`;
200
+ case "translate": return `translate(${n(op.tx)} ${n(op.ty)})`;
201
+ case "rotate": return `rotate(${n(op.angle)} ${n(op.cx)} ${n(op.cy)})`;
202
+ case "scale": return `scale(${n(op.sx)} ${n(op.sy)})`;
203
+ case "skewX": return `skewX(${n(op.angle)})`;
204
+ case "skewY": return `skewY(${n(op.angle)})`;
205
+ }
206
+ }
207
+ _transform.emit_op = emit_op;
208
+ function emit(ops) {
209
+ return ops.map(emit_op).join(" ");
210
+ }
211
+ _transform.emit = emit;
212
+ function is_identity_translate(op) {
213
+ return op.type === "translate" && op.tx === 0 && op.ty === 0;
214
+ }
215
+ function is_identity_rotate(op) {
216
+ return op.type === "rotate" && op.angle === 0;
217
+ }
218
+ function classify(ops) {
219
+ const trimmed = ops.filter((op) => !is_identity_translate(op) && !is_identity_rotate(op));
220
+ if (trimmed.length === 0) return "identity";
221
+ if (trimmed.length === 1) {
222
+ if (trimmed[0].type === "translate") return "leading_translate_only";
223
+ if (trimmed[0].type === "rotate") return "single_rotate_only";
224
+ return "mixed";
225
+ }
226
+ if (trimmed.length === 2 && trimmed[0].type === "translate" && trimmed[1].type === "rotate") return "leading_translate_then_single_rotate";
227
+ return "mixed";
228
+ }
229
+ _transform.classify = classify;
230
+ function op_matrix(op) {
231
+ switch (op.type) {
232
+ case "matrix": return [[
233
+ op.a,
234
+ op.c,
235
+ op.e
236
+ ], [
237
+ op.b,
238
+ op.d,
239
+ op.f
240
+ ]];
241
+ case "translate": return [[
242
+ 1,
243
+ 0,
244
+ op.tx
245
+ ], [
246
+ 0,
247
+ 1,
248
+ op.ty
249
+ ]];
250
+ case "rotate": {
251
+ const t = op.angle * Math.PI / 180;
252
+ const cos = Math.cos(t);
253
+ const sin = Math.sin(t);
254
+ return [[
255
+ cos,
256
+ -sin,
257
+ op.cx - op.cx * cos + op.cy * sin
258
+ ], [
259
+ sin,
260
+ cos,
261
+ op.cy - op.cx * sin - op.cy * cos
262
+ ]];
263
+ }
264
+ case "scale": return [[
265
+ op.sx,
266
+ 0,
267
+ 0
268
+ ], [
269
+ 0,
270
+ op.sy,
271
+ 0
272
+ ]];
273
+ case "skewX": {
274
+ const t = op.angle * Math.PI / 180;
275
+ return [[
276
+ 1,
277
+ Math.tan(t),
278
+ 0
279
+ ], [
280
+ 0,
281
+ 1,
282
+ 0
283
+ ]];
284
+ }
285
+ case "skewY": {
286
+ const t = op.angle * Math.PI / 180;
287
+ return [[
288
+ 1,
289
+ 0,
290
+ 0
291
+ ], [
292
+ Math.tan(t),
293
+ 1,
294
+ 0
295
+ ]];
296
+ }
297
+ }
298
+ }
299
+ /** Compose a transform-list into a single 2×3 affine. Ops compose
300
+ * source-order = left-to-right multiplication: `transform="A B C"`
301
+ * maps a column-vector point `p` as `A · B · C · p` (per SVG 1.1
302
+ * §7.5). */
303
+ function compose(ops) {
304
+ let m = cmath.transform.identity;
305
+ for (const op of ops) m = cmath.transform.multiply(m, op_matrix(op));
306
+ return m;
307
+ }
308
+ function project(local, transform_str) {
309
+ if (!transform_str) return local;
310
+ const ops = parse(transform_str);
311
+ if (ops === null || ops.length === 0) return local;
312
+ const m = compose(ops);
313
+ const projected = [
314
+ [local.x, local.y],
315
+ [local.x + local.width, local.y],
316
+ [local.x + local.width, local.y + local.height],
317
+ [local.x, local.y + local.height]
318
+ ].map((p) => cmath.vector2.transform(p, m));
319
+ return cmath.rect.fromPointsOrZero(projected);
320
+ }
321
+ _transform.project = project;
322
+ function recompose(ops, new_cx, new_cy) {
323
+ const tx_op = ops.find((op) => op.type === "translate");
324
+ const tx = tx_op?.type === "translate" ? tx_op.tx : 0;
325
+ const ty = tx_op?.type === "translate" ? tx_op.ty : 0;
326
+ return emit(ops.map((op) => op.type === "rotate" ? {
327
+ type: "rotate",
328
+ angle: op.angle,
329
+ cx: new_cx - tx,
330
+ cy: new_cy - ty,
331
+ explicit_pivot: true
332
+ } : op));
333
+ }
334
+ _transform.recompose = recompose;
335
+ })(transform || (transform = {}));
336
+ //#endregion
337
+ //#region src/core/translate-pipeline/translate-pipeline.ts
338
+ let translate_pipeline;
339
+ (function(_translate_pipeline) {
340
+ let intent;
341
+ (function(_intent) {
342
+ function num(doc, id, name, fallback = 0) {
343
+ return svg_parse.parse_number(doc.get_attr(id, name), fallback);
344
+ }
345
+ function capture_baseline(doc, id) {
346
+ const tag = doc.tag_of(id);
347
+ const own_transform = doc.get_attr(id, "transform");
348
+ if (own_transform !== null || tag === "g") return {
349
+ type: "viaTransform",
350
+ transform: own_transform
351
+ };
352
+ switch (tag) {
353
+ case "rect": return {
354
+ type: "rect",
355
+ x: num(doc, id, "x"),
356
+ y: num(doc, id, "y")
357
+ };
358
+ case "circle": return {
359
+ type: "circle",
360
+ cx: num(doc, id, "cx"),
361
+ cy: num(doc, id, "cy")
362
+ };
363
+ case "ellipse": return {
364
+ type: "ellipse",
365
+ cx: num(doc, id, "cx"),
366
+ cy: num(doc, id, "cy")
367
+ };
368
+ case "line": return {
369
+ type: "line",
370
+ x1: num(doc, id, "x1"),
371
+ y1: num(doc, id, "y1"),
372
+ x2: num(doc, id, "x2"),
373
+ y2: num(doc, id, "y2")
374
+ };
375
+ case "polyline": return {
376
+ type: "polyline",
377
+ points: doc.get_attr(id, "points") ?? ""
378
+ };
379
+ case "polygon": return {
380
+ type: "polygon",
381
+ points: doc.get_attr(id, "points") ?? ""
382
+ };
383
+ case "path": return {
384
+ type: "path",
385
+ d: doc.get_attr(id, "d") ?? ""
386
+ };
387
+ case "text": return {
388
+ type: "text",
389
+ x: num(doc, id, "x"),
390
+ y: num(doc, id, "y")
391
+ };
392
+ case "tspan": {
393
+ const dx_attr = doc.get_attr(id, "dx");
394
+ const dy_attr = doc.get_attr(id, "dy");
395
+ return {
396
+ type: "tspan",
397
+ dx: svg_parse.parse_number(dx_attr),
398
+ dy: svg_parse.parse_number(dy_attr),
399
+ dx_attr,
400
+ dy_attr
401
+ };
402
+ }
403
+ case "image": return {
404
+ type: "image",
405
+ x: num(doc, id, "x"),
406
+ y: num(doc, id, "y")
407
+ };
408
+ case "use": return {
409
+ type: "use",
410
+ x: num(doc, id, "x"),
411
+ y: num(doc, id, "y")
412
+ };
413
+ default: return { type: "unsupported" };
414
+ }
415
+ }
416
+ _intent.capture_baseline = capture_baseline;
417
+ function capture_baselines(doc, ids) {
418
+ const out = /* @__PURE__ */ new Map();
419
+ for (const id of ids) out.set(id, capture_baseline(doc, id));
420
+ return out;
421
+ }
422
+ _intent.capture_baselines = capture_baselines;
423
+ function baseline_anchor(b) {
424
+ switch (b.type) {
425
+ case "rect":
426
+ case "text":
427
+ case "image":
428
+ case "use": return {
429
+ x: b.x,
430
+ y: b.y
431
+ };
432
+ case "tspan": return null;
433
+ case "circle":
434
+ case "ellipse": return {
435
+ x: b.cx,
436
+ y: b.cy
437
+ };
438
+ case "line": return {
439
+ x: Math.min(b.x1, b.x2),
440
+ y: Math.min(b.y1, b.y2)
441
+ };
442
+ case "polyline":
443
+ case "polygon": return svg_parse.points_top_left(svg_parse.parse_points(b.points));
444
+ case "path": return svg_parse.parse_path_first_move(b.d);
445
+ case "viaTransform": {
446
+ const ops = transform.parse(b.transform);
447
+ if (ops === null) return null;
448
+ for (const op of ops) {
449
+ if (op.type === "translate") return {
450
+ x: op.tx,
451
+ y: op.ty
452
+ };
453
+ break;
454
+ }
455
+ return null;
456
+ }
457
+ case "unsupported": return null;
458
+ }
459
+ }
460
+ _intent.baseline_anchor = baseline_anchor;
461
+ function baseline_union_top_left(baselines) {
462
+ const anchors = [];
463
+ for (const b of baselines.values()) {
464
+ const p = baseline_anchor(b);
465
+ if (p) anchors.push(p);
466
+ }
467
+ return svg_parse.points_top_left(anchors);
468
+ }
469
+ _intent.baseline_union_top_left = baseline_union_top_left;
470
+ function shift_points_string(points, dx, dy) {
471
+ if (dx === 0 && dy === 0) return points;
472
+ return svg_parse.parse_points(points).map((p) => `${p.x + dx},${p.y + dy}`).join(" ");
473
+ }
474
+ function compose_leading_translate(existing, dx, dy) {
475
+ if (dx === 0 && dy === 0) return existing ? existing : null;
476
+ if (!existing) return `translate(${dx} ${dy})`;
477
+ const lead = svg_parse.parse_leading_translate(existing);
478
+ if (lead) {
479
+ const tx = lead.tx + dx;
480
+ const ty = lead.ty + dy;
481
+ return lead.rest ? `translate(${tx} ${ty}) ${lead.rest}` : `translate(${tx} ${ty})`;
482
+ }
483
+ return `translate(${dx} ${dy}) ${existing}`;
484
+ }
485
+ _intent.compose_leading_translate = compose_leading_translate;
486
+ /** Rewrite the leading value of a `dx`/`dy` offset list to `value`,
487
+ * preserving any per-glyph kerning tail (`compose("3 1 2", 9)` →
488
+ * `"9 1 2"`). Empty separators are dropped, so a stray leading comma
489
+ * doesn't lose the tail. With no original attribute (or a single
490
+ * value), emit just `value`. The leading value shifts the whole run. */
491
+ function compose_leading_offset(attr, value) {
492
+ if (attr === null) return String(value);
493
+ const tokens = attr.trim().split(/[\s,]+/).filter((t) => t !== "");
494
+ if (tokens.length <= 1) return String(value);
495
+ tokens[0] = String(value);
496
+ return tokens.join(" ");
497
+ }
498
+ function shift_path_d(d, dx, dy) {
499
+ if (dx === 0 && dy === 0) return d;
500
+ try {
501
+ return new SVGPathData(d).transform(SVGPathDataTransformer.TRANSLATE(dx, dy)).encode();
502
+ } catch {
503
+ return d;
504
+ }
505
+ }
506
+ function apply(doc, id, baseline, dx, dy) {
507
+ switch (baseline.type) {
508
+ case "viaTransform":
509
+ doc.set_attr(id, "transform", compose_leading_translate(baseline.transform ?? "", dx, dy));
510
+ return;
511
+ case "rect":
512
+ case "image":
513
+ case "use":
514
+ case "text":
515
+ doc.set_attr(id, "x", String(baseline.x + dx));
516
+ doc.set_attr(id, "y", String(baseline.y + dy));
517
+ return;
518
+ case "tspan":
519
+ doc.set_attr(id, "dx", compose_leading_offset(baseline.dx_attr, baseline.dx + dx));
520
+ doc.set_attr(id, "dy", compose_leading_offset(baseline.dy_attr, baseline.dy + dy));
521
+ return;
522
+ case "circle":
523
+ case "ellipse":
524
+ doc.set_attr(id, "cx", String(baseline.cx + dx));
525
+ doc.set_attr(id, "cy", String(baseline.cy + dy));
526
+ return;
527
+ case "line":
528
+ doc.set_attr(id, "x1", String(baseline.x1 + dx));
529
+ doc.set_attr(id, "y1", String(baseline.y1 + dy));
530
+ doc.set_attr(id, "x2", String(baseline.x2 + dx));
531
+ doc.set_attr(id, "y2", String(baseline.y2 + dy));
532
+ return;
533
+ case "polyline":
534
+ case "polygon":
535
+ doc.set_attr(id, "points", shift_points_string(baseline.points, dx, dy));
536
+ return;
537
+ case "path":
538
+ doc.set_attr(id, "d", shift_path_d(baseline.d, dx, dy));
539
+ return;
540
+ case "unsupported": return;
541
+ }
542
+ }
543
+ _intent.apply = apply;
544
+ function revert(doc, id, baseline) {
545
+ if (baseline.type === "tspan") {
546
+ doc.set_attr(id, "dx", baseline.dx_attr);
547
+ doc.set_attr(id, "dy", baseline.dy_attr);
548
+ return;
549
+ }
550
+ apply(doc, id, baseline, 0, 0);
551
+ }
552
+ _intent.revert = revert;
553
+ })(intent || (intent = _translate_pipeline.intent || (_translate_pipeline.intent = {})));
554
+ let stages;
555
+ (function(_stages) {
556
+ const axis_lock = _stages.axis_lock = {
557
+ name: "axis_lock",
558
+ run(plan, ctx) {
559
+ const m = ctx.input.movement;
560
+ const locked = ctx.modifiers.axis_lock === "by_dominance" ? cmath.ext.movement.axisLockedByDominance(m) : m;
561
+ const [x, y] = cmath.ext.movement.normalize(locked);
562
+ return { plan: {
563
+ ...plan,
564
+ delta: {
565
+ x,
566
+ y
567
+ }
568
+ } };
569
+ }
570
+ };
571
+ const snap = _stages.snap = {
572
+ name: "snap",
573
+ run(plan, ctx) {
574
+ if (ctx.modifiers.force_disable_snap) return { plan };
575
+ if (!ctx.snap_session) return { plan };
576
+ if (!ctx.options.snap_enabled) return { plan };
577
+ const r = ctx.snap_session.snap(plan.delta, {
578
+ enabled: true,
579
+ threshold_px: ctx.options.snap_threshold_px
580
+ }, ctx.snap_policy);
581
+ return {
582
+ plan: {
583
+ ...plan,
584
+ delta: r.delta
585
+ },
586
+ emit: r.guide ? { guide: r.guide } : void 0
587
+ };
588
+ }
589
+ };
590
+ const pixel_grid = _stages.pixel_grid = {
591
+ name: "pixel_grid",
592
+ run(plan, ctx) {
593
+ const q = ctx.options.pixel_grid_quantum;
594
+ if (q === null || q <= 0) return { plan };
595
+ const anchor = ctx.snap_session?.baseline_union_readonly ?? intent.baseline_union_top_left(plan.baselines);
596
+ if (!anchor) return { plan };
597
+ const qx = Math.round((anchor.x + plan.delta.x) / q) * q - anchor.x;
598
+ const qy = Math.round((anchor.y + plan.delta.y) / q) * q - anchor.y;
599
+ return { plan: {
600
+ ...plan,
601
+ delta: {
602
+ x: qx,
603
+ y: qy
604
+ }
605
+ } };
606
+ }
607
+ };
608
+ _stages.DEFAULT = Object.freeze([
609
+ axis_lock,
610
+ snap,
611
+ pixel_grid
612
+ ]);
613
+ _stages.NUDGE = Object.freeze([axis_lock, pixel_grid]);
614
+ _stages.RPC = Object.freeze([axis_lock]);
615
+ })(stages || (stages = _translate_pipeline.stages || (_translate_pipeline.stages = {})));
616
+ function run(init, stages, ctx) {
617
+ let plan = init;
618
+ const guides = [];
619
+ for (const stage of stages) {
620
+ const out = stage.run(plan, ctx);
621
+ plan = out.plan;
622
+ if (out.emit?.guide) guides.push(out.emit.guide);
623
+ }
624
+ return {
625
+ plan,
626
+ guides
627
+ };
628
+ }
629
+ _translate_pipeline.run = run;
630
+ function apply(doc, plan, project) {
631
+ for (const id of plan.ids) {
632
+ const baseline = plan.baselines.get(id);
633
+ if (!baseline) continue;
634
+ const d = project ? project(id, plan.delta) : plan.delta;
635
+ intent.apply(doc, id, baseline, d.x, d.y);
636
+ }
637
+ }
638
+ _translate_pipeline.apply = apply;
639
+ function revert(doc, plan) {
640
+ for (const id of plan.ids) {
641
+ const baseline = plan.baselines.get(id);
642
+ if (!baseline) continue;
643
+ intent.revert(doc, id, baseline);
644
+ }
645
+ }
646
+ _translate_pipeline.revert = revert;
647
+ function prepare_rpc(args) {
648
+ const { doc, ids, delta, options, emit, stages: stage_list = stages.RPC, project } = args;
649
+ const filtered_ids = doc.prune_nested_nodes(ids);
650
+ const plan0 = {
651
+ ids: filtered_ids,
652
+ baselines: intent.capture_baselines(doc, filtered_ids),
653
+ delta: {
654
+ x: 0,
655
+ y: 0
656
+ }
657
+ };
658
+ const { plan } = run(plan0, stage_list, {
659
+ input: {
660
+ ids: plan0.ids,
661
+ movement: [delta.x, delta.y]
662
+ },
663
+ modifiers: {
664
+ axis_lock: "off",
665
+ force_disable_snap: true
666
+ },
667
+ options,
668
+ snap_session: null,
669
+ snap_policy: "engine"
670
+ });
671
+ return {
672
+ plan,
673
+ apply: () => {
674
+ apply(doc, plan, project);
675
+ emit();
676
+ },
677
+ revert: () => {
678
+ revert(doc, plan);
679
+ emit();
680
+ }
681
+ };
682
+ }
683
+ _translate_pipeline.prepare_rpc = prepare_rpc;
684
+ })(translate_pipeline || (translate_pipeline = {}));
685
+ //#endregion
686
+ //#region src/core/translate-pipeline/orchestrator.ts
687
+ const PROVIDER_ID$2 = "svg-editor";
688
+ var TranslateOrchestrator = class {
689
+ constructor(deps) {
690
+ this.deps = deps;
691
+ this.active = null;
692
+ this._last_guides = [];
693
+ }
694
+ /** Guides emitted by the most recent pipeline run. Cleared on
695
+ * cancel/dispose. HUD compositors read this to draw snap chrome. */
696
+ get last_guides() {
697
+ return this._last_guides;
698
+ }
699
+ /** True while a gesture session is open. */
700
+ has_active_session() {
701
+ return this.active !== null;
702
+ }
703
+ /** Per-frame drive: lazily opens a session on first call, runs the
704
+ * pipeline, writes apply/revert into the preview, and commits when
705
+ * `opts.phase === "commit"`. */
706
+ drive(input, modifiers, opts) {
707
+ if (this.active === null) this.active = this.open(input.ids, opts.snap, opts.label ?? "move");
708
+ const session = this.active;
709
+ const stages = opts.stages ?? translate_pipeline.stages.DEFAULT;
710
+ const result = this.run_pass(session, input.movement, modifiers, opts.policy, stages);
711
+ session.last_movement = input.movement;
712
+ session.last_policy = opts.policy;
713
+ session.last_stages = stages;
714
+ this.write_preview_delta(session, result.plan);
715
+ if (opts.phase === "commit") {
716
+ session.preview.commit();
717
+ this.dispose_session();
718
+ }
719
+ return result;
720
+ }
721
+ /** Re-run the current preview frame with new modifiers, reusing the
722
+ * last-known movement / policy / stages. Used when a modifier key
723
+ * changes between pointer-move events (Shift down/up mid-drag).
724
+ * No-op when no session is active. */
725
+ redrive_modifiers(modifiers) {
726
+ if (!this.active) return null;
727
+ const session = this.active;
728
+ const result = this.run_pass(session, session.last_movement, modifiers, session.last_policy, session.last_stages);
729
+ this.write_preview_delta(session, result.plan);
730
+ return result;
731
+ }
732
+ /** Cancel an in-flight gesture (Escape, programmatic abort). */
733
+ cancel() {
734
+ if (!this.active) return;
735
+ this.active.preview.discard();
736
+ this.dispose_session();
737
+ }
738
+ /** Build a plan + context, run the pipeline, stash guides. Pure
739
+ * computation — does not touch the preview. */
740
+ run_pass(session, movement, modifiers, policy, stages) {
741
+ const plan0 = {
742
+ ids: session.ids,
743
+ baselines: session.baselines,
744
+ delta: {
745
+ x: 0,
746
+ y: 0
747
+ }
748
+ };
749
+ const ctx = {
750
+ input: {
751
+ ids: session.ids,
752
+ movement
753
+ },
754
+ modifiers,
755
+ options: this.deps.options(),
756
+ snap_session: session.snap,
757
+ snap_policy: policy
758
+ };
759
+ const result = translate_pipeline.run(plan0, stages, ctx);
760
+ this._last_guides = result.guides;
761
+ return result;
762
+ }
763
+ open(ids, snap, label) {
764
+ const doc = this.deps.get_doc();
765
+ const filtered = doc.prune_nested_nodes(ids);
766
+ return {
767
+ ids: filtered,
768
+ baselines: translate_pipeline.intent.capture_baselines(doc, filtered),
769
+ snap: snap ? this.deps.open_snap(filtered) : null,
770
+ preview: this.deps.open_preview(label),
771
+ last_movement: [0, 0],
772
+ last_policy: "engine",
773
+ last_stages: translate_pipeline.stages.DEFAULT
774
+ };
775
+ }
776
+ /** Bind a fresh apply/revert pair (closure over `plan`) into the
777
+ * preview slot. Called from both `drive` (per pointer frame) and
778
+ * `redrive_modifiers` (on modifier flip). */
779
+ write_preview_delta(session, plan) {
780
+ const doc = this.deps.get_doc();
781
+ const emit = this.deps.emit;
782
+ const project = this.deps.project_delta;
783
+ session.preview.set({
784
+ providerId: PROVIDER_ID$2,
785
+ apply: () => {
786
+ translate_pipeline.apply(doc, plan, project);
787
+ emit();
788
+ },
789
+ revert: () => {
790
+ translate_pipeline.revert(doc, plan);
791
+ emit();
792
+ }
793
+ });
794
+ }
795
+ dispose_session() {
796
+ if (!this.active) return;
797
+ this.active.snap?.dispose();
798
+ this.active = null;
799
+ this._last_guides = [];
800
+ }
801
+ };
802
+ //#endregion
803
+ //#region src/core/translate-pipeline/nudge-dwell-watcher.ts
804
+ /** Hold-time after the last firing detection. Show is immediate (next
805
+ * frame); only the hide edge is delayed. */
806
+ const HIDE_MS = 500;
807
+ var NudgeDwellWatcher = class {
808
+ constructor(deps) {
809
+ this.deps = deps;
810
+ this._guides = [];
811
+ this.raf_id = null;
812
+ this.hide_timer = null;
813
+ this.unsubscribe = deps.editor.subscribe_translate_commit(() => this.schedule_detect());
814
+ }
815
+ /** Currently-published dwell guides. Empty between detections. */
816
+ get guides() {
817
+ return this._guides;
818
+ }
819
+ /** Drop any pending detection / held guide. Idempotent. */
820
+ cancel_pending() {
821
+ this.clear_raf();
822
+ this.clear_hide();
823
+ this.publish_guides([]);
824
+ }
825
+ dispose() {
826
+ this.unsubscribe();
827
+ this.cancel_pending();
828
+ }
829
+ schedule_detect() {
830
+ if (this.raf_id !== null) return;
831
+ this.raf_id = this.deps.window.requestAnimationFrame(() => {
832
+ this.raf_id = null;
833
+ this.detect();
834
+ });
835
+ }
836
+ detect() {
837
+ const ids = this.deps.editor.state.selection;
838
+ if (ids.length === 0) {
839
+ this.publish_guides([]);
840
+ this.clear_hide();
841
+ return;
842
+ }
843
+ const snap = this.deps.open_snap(ids);
844
+ if (!snap) {
845
+ this.publish_guides([]);
846
+ this.clear_hide();
847
+ return;
848
+ }
849
+ try {
850
+ const plan0 = {
851
+ ids: [...ids],
852
+ baselines: translate_pipeline.intent.capture_baselines(this.deps.editor.document, ids),
853
+ delta: {
854
+ x: 0,
855
+ y: 0
856
+ }
857
+ };
858
+ const ctx = {
859
+ input: {
860
+ ids: plan0.ids,
861
+ movement: [0, 0]
862
+ },
863
+ modifiers: {
864
+ axis_lock: "off",
865
+ force_disable_snap: false
866
+ },
867
+ options: this.deps.options(),
868
+ snap_session: snap,
869
+ snap_policy: "aligned"
870
+ };
871
+ const result = translate_pipeline.run(plan0, translate_pipeline.stages.DEFAULT, ctx);
872
+ if (result.guides.length === 0) {
873
+ this.publish_guides([]);
874
+ this.clear_hide();
875
+ return;
876
+ }
877
+ this.publish_guides(result.guides);
878
+ this.arm_hide();
879
+ } finally {
880
+ snap.dispose();
881
+ }
882
+ }
883
+ arm_hide() {
884
+ this.clear_hide();
885
+ this.hide_timer = this.deps.window.setTimeout(() => {
886
+ this.hide_timer = null;
887
+ this.publish_guides([]);
888
+ }, HIDE_MS);
889
+ }
890
+ publish_guides(next) {
891
+ if (next.length === 0 && this._guides.length === 0) return;
892
+ this._guides = next;
893
+ this.deps.on_guides_change();
894
+ }
895
+ clear_raf() {
896
+ if (this.raf_id === null) return;
897
+ this.deps.window.cancelAnimationFrame(this.raf_id);
898
+ this.raf_id = null;
899
+ }
900
+ clear_hide() {
901
+ if (this.hide_timer === null) return;
902
+ this.deps.window.clearTimeout(this.hide_timer);
903
+ this.hide_timer = null;
904
+ }
905
+ };
906
+ //#endregion
907
+ //#region src/core/rotate-pipeline/rotate-pipeline.ts
908
+ let rotate_pipeline;
909
+ (function(_rotate_pipeline) {
910
+ let intent;
911
+ (function(_intent) {
912
+ function is_rotatable(doc, id) {
913
+ const own_transform = doc.get_attr(id, "transform");
914
+ const ops = transform.parse(own_transform);
915
+ if (ops === null) return {
916
+ kind: "refuse",
917
+ reason: "non-trivial-transform"
918
+ };
919
+ if (transform.classify(ops) === "mixed") return {
920
+ kind: "refuse",
921
+ reason: "non-trivial-transform"
922
+ };
923
+ if (doc.has_glyph_rotate(id)) return {
924
+ kind: "refuse",
925
+ reason: "text-with-glyph-rotate"
926
+ };
927
+ if (doc.has_inline_css_transform(id)) return {
928
+ kind: "refuse",
929
+ reason: "css-property-transform"
930
+ };
931
+ if (doc.has_animate_transform_child(id)) return {
932
+ kind: "refuse",
933
+ reason: "animated-transform"
934
+ };
935
+ return { kind: "yes" };
936
+ }
937
+ _intent.is_rotatable = is_rotatable;
938
+ function find_op(ops, type) {
939
+ for (const op of ops) if (op.type === type) return op;
940
+ return null;
941
+ }
942
+ function capture_baseline(doc, id, pivot) {
943
+ const transform_str = doc.get_attr(id, "transform");
944
+ const ops = transform.parse(transform_str) ?? [];
945
+ const lead = find_op(ops, "translate");
946
+ const rot = find_op(ops, "rotate");
947
+ return {
948
+ transform: transform_str,
949
+ leading_translate: lead ? {
950
+ x: lead.tx,
951
+ y: lead.ty
952
+ } : null,
953
+ current_rotation_deg: rot?.angle ?? 0,
954
+ pivot
955
+ };
956
+ }
957
+ _intent.capture_baseline = capture_baseline;
958
+ function capture_baselines(doc, ids, pivot) {
959
+ const out = /* @__PURE__ */ new Map();
960
+ for (const id of ids) out.set(id, capture_baseline(doc, id, pivot));
961
+ return out;
962
+ }
963
+ _intent.capture_baselines = capture_baselines;
964
+ const RAD_TO_DEG = 180 / Math.PI;
965
+ /** Snap trailing FP noise off the 10th decimal place. The rad→deg
966
+ * conversion + addition produce e.g. `29.999999999999996` for
967
+ * what the user dragged as "30°"; rounding to 1e-9 absorbs the
968
+ * noise without losing any user-meaningful precision (SVG
969
+ * coordinates rarely go past 4 decimals in authored content).
970
+ *
971
+ * Limits drift accumulation across a long gesture: each frame's
972
+ * emit re-renders from `current_rotation_deg + delta_deg`, where
973
+ * the delta is recomputed against the gesture-start anchor — not
974
+ * accumulated. So noise stays bounded per-frame. */
975
+ function fmt_angle(n) {
976
+ return String(Math.round(n * 1e9) / 1e9);
977
+ }
978
+ function apply(doc, id, baseline, angle_radians) {
979
+ const angle_deg = angle_radians * RAD_TO_DEG;
980
+ const total = baseline.current_rotation_deg + angle_deg;
981
+ if (total === 0 && angle_deg === 0) {
982
+ doc.set_attr(id, "transform", baseline.transform);
983
+ return;
984
+ }
985
+ const tx = baseline.leading_translate?.x ?? 0;
986
+ const ty = baseline.leading_translate?.y ?? 0;
987
+ const cx = baseline.pivot.x - tx;
988
+ const cy = baseline.pivot.y - ty;
989
+ const rotate_token = `rotate(${fmt_angle(total)} ${cx} ${cy})`;
990
+ const str = baseline.leading_translate ? `translate(${tx} ${ty}) ${rotate_token}` : rotate_token;
991
+ doc.set_attr(id, "transform", str);
992
+ }
993
+ _intent.apply = apply;
994
+ })(intent || (intent = _rotate_pipeline.intent || (_rotate_pipeline.intent = {})));
995
+ let stages;
996
+ (function(_stages) {
997
+ const angle_snap = _stages.angle_snap = {
998
+ name: "angle_snap",
999
+ run(plan, ctx) {
1000
+ if (ctx.modifiers.force_disable_snap) return { plan };
1001
+ if (ctx.modifiers.angle_snap !== "step") return { plan };
1002
+ const step = ctx.options.angle_snap_step_radians;
1003
+ if (step === null || step <= 0) return { plan };
1004
+ const snapped = Math.round(plan.angle_radians / step) * step;
1005
+ return { plan: {
1006
+ ...plan,
1007
+ angle_radians: snapped
1008
+ } };
1009
+ }
1010
+ };
1011
+ _stages.DEFAULT = Object.freeze([angle_snap]);
1012
+ _stages.RPC = Object.freeze([]);
1013
+ })(stages || (stages = _rotate_pipeline.stages || (_rotate_pipeline.stages = {})));
1014
+ function run(init, stages, ctx) {
1015
+ let plan = init;
1016
+ for (const stage of stages) plan = stage.run(plan, ctx).plan;
1017
+ return { plan };
1018
+ }
1019
+ _rotate_pipeline.run = run;
1020
+ function apply(doc, plan) {
1021
+ for (const m of plan.members) intent.apply(doc, m.id, m.baseline, plan.angle_radians);
1022
+ }
1023
+ _rotate_pipeline.apply = apply;
1024
+ function revert(doc, plan) {
1025
+ for (const m of plan.members) intent.apply(doc, m.id, m.baseline, 0);
1026
+ }
1027
+ _rotate_pipeline.revert = revert;
1028
+ function prepare_rpc(args) {
1029
+ const { doc, ids, pivot, angle_radians, options, emit, stages: stage_list = stages.RPC } = args;
1030
+ const filtered_ids = doc.prune_nested_nodes(ids);
1031
+ const baselines = intent.capture_baselines(doc, filtered_ids, pivot);
1032
+ const members = filtered_ids.map((id) => ({
1033
+ id,
1034
+ baseline: baselines.get(id)
1035
+ }));
1036
+ const verdicts = /* @__PURE__ */ new Map();
1037
+ for (const id of filtered_ids) verdicts.set(id, intent.is_rotatable(doc, id));
1038
+ const { plan } = run({
1039
+ members,
1040
+ pivot,
1041
+ angle_radians
1042
+ }, stage_list, {
1043
+ input: {
1044
+ ids: filtered_ids,
1045
+ angle_radians
1046
+ },
1047
+ modifiers: {
1048
+ angle_snap: "off",
1049
+ force_disable_snap: true
1050
+ },
1051
+ options
1052
+ });
1053
+ return {
1054
+ plan,
1055
+ verdicts,
1056
+ apply: () => {
1057
+ apply(doc, plan);
1058
+ emit();
1059
+ },
1060
+ revert: () => {
1061
+ revert(doc, plan);
1062
+ emit();
1063
+ }
1064
+ };
1065
+ }
1066
+ _rotate_pipeline.prepare_rpc = prepare_rpc;
1067
+ })(rotate_pipeline || (rotate_pipeline = {}));
1068
+ //#endregion
1069
+ //#region src/core/rotate-pipeline/orchestrator.ts
1070
+ const PROVIDER_ID$1 = "svg-editor";
1071
+ function ids_key$1(ids) {
1072
+ return [...ids].sort().join("\0");
1073
+ }
1074
+ var RotateOrchestrator = class {
1075
+ constructor(deps) {
1076
+ this.deps = deps;
1077
+ this.active = null;
1078
+ }
1079
+ has_active_session() {
1080
+ return this.active !== null;
1081
+ }
1082
+ is_active_for(ids) {
1083
+ return this.active !== null && this.active.ids_key === ids_key$1(ids);
1084
+ }
1085
+ /** Per-frame drive. Opens a session lazily on the first call. Returns
1086
+ * `null` when `ids` is empty. On commit, returns a `RotateCommitOutcome`
1087
+ * the caller uses to surface refusal chips. */
1088
+ drive(input, modifiers, opts) {
1089
+ if (input.ids.length === 0) return null;
1090
+ const key = ids_key$1(input.ids);
1091
+ if (this.active && this.active.ids_key !== key) {
1092
+ this.active.preview.discard();
1093
+ this.dispose_session();
1094
+ }
1095
+ if (this.active === null) this.active = this.open(input.ids, opts.label ?? "rotate");
1096
+ const session = this.active;
1097
+ const stages = opts.stages ?? rotate_pipeline.stages.DEFAULT;
1098
+ const result = this.run_pass(session, input.angle_radians, modifiers, stages);
1099
+ session.last_angle = input.angle_radians;
1100
+ session.last_stages = stages;
1101
+ this.write_preview(session, result.plan);
1102
+ if (opts.phase === "commit") {
1103
+ let outcome;
1104
+ let refused = false;
1105
+ for (const v of session.verdicts.values()) if (v.kind === "refuse") {
1106
+ refused = true;
1107
+ break;
1108
+ }
1109
+ if (refused) {
1110
+ session.preview.discard();
1111
+ outcome = {
1112
+ kind: "refused",
1113
+ verdicts: session.verdicts
1114
+ };
1115
+ } else {
1116
+ session.preview.commit();
1117
+ outcome = {
1118
+ kind: "committed",
1119
+ plan: result.plan
1120
+ };
1121
+ }
1122
+ this.dispose_session();
1123
+ return {
1124
+ result,
1125
+ outcome
1126
+ };
1127
+ }
1128
+ return {
1129
+ result,
1130
+ outcome: null
1131
+ };
1132
+ }
1133
+ redrive_modifiers(modifiers) {
1134
+ if (!this.active) return null;
1135
+ const session = this.active;
1136
+ const result = this.run_pass(session, session.last_angle, modifiers, session.last_stages);
1137
+ this.write_preview(session, result.plan);
1138
+ return result;
1139
+ }
1140
+ cancel() {
1141
+ if (!this.active) return;
1142
+ this.active.preview.discard();
1143
+ this.dispose_session();
1144
+ }
1145
+ run_pass(session, angle_radians, modifiers, stages) {
1146
+ const plan0 = {
1147
+ members: session.members,
1148
+ pivot: session.pivot,
1149
+ angle_radians
1150
+ };
1151
+ const ctx = {
1152
+ input: {
1153
+ ids: session.members.map((m) => m.id),
1154
+ angle_radians
1155
+ },
1156
+ modifiers,
1157
+ options: this.deps.options()
1158
+ };
1159
+ return rotate_pipeline.run(plan0, stages, ctx);
1160
+ }
1161
+ open(ids, label) {
1162
+ const doc = this.deps.get_doc();
1163
+ const filtered = doc.prune_nested_nodes(ids);
1164
+ const pivot = compute_union_center(filtered, this.deps.bbox_world);
1165
+ const baselines = rotate_pipeline.intent.capture_baselines(doc, filtered, pivot);
1166
+ const members = filtered.map((id) => ({
1167
+ id,
1168
+ baseline: baselines.get(id)
1169
+ }));
1170
+ const verdicts = /* @__PURE__ */ new Map();
1171
+ for (const id of filtered) verdicts.set(id, rotate_pipeline.intent.is_rotatable(doc, id));
1172
+ return {
1173
+ ids_key: ids_key$1(ids),
1174
+ members,
1175
+ pivot,
1176
+ verdicts,
1177
+ preview: this.deps.open_preview(label),
1178
+ last_angle: 0,
1179
+ last_stages: rotate_pipeline.stages.DEFAULT
1180
+ };
1181
+ }
1182
+ write_preview(session, plan) {
1183
+ const doc = this.deps.get_doc();
1184
+ const emit = this.deps.emit;
1185
+ session.preview.set({
1186
+ providerId: PROVIDER_ID$1,
1187
+ apply: () => {
1188
+ rotate_pipeline.apply(doc, plan);
1189
+ emit();
1190
+ },
1191
+ revert: () => {
1192
+ rotate_pipeline.revert(doc, plan);
1193
+ emit();
1194
+ }
1195
+ });
1196
+ }
1197
+ dispose_session() {
1198
+ this.active = null;
1199
+ }
1200
+ };
1201
+ function compute_union_center(ids, bbox_world) {
1202
+ const rects = ids.map(bbox_world);
1203
+ const u = cmath.rect.union(rects);
1204
+ return {
1205
+ x: u.x + u.width / 2,
1206
+ y: u.y + u.height / 2
1207
+ };
1208
+ }
1209
+ //#endregion
1210
+ //#region src/core/hit-shape-svg.ts
1211
+ let hit_shape_svg;
1212
+ (function(_hit_shape_svg) {
1213
+ /** Tags that never participate in picking. Containers and non-
1214
+ * rendering metadata. The root `<svg>` is in this set — root
1215
+ * pickability is a host decision (measurement HUD wants it,
1216
+ * selection doesn't), gated by an explicit `allow_root` flag at the
1217
+ * caller. */
1218
+ const TRANSPARENT_TAGS = new Set([
1219
+ "g",
1220
+ "svg",
1221
+ "defs",
1222
+ "symbol",
1223
+ "clipPath",
1224
+ "mask",
1225
+ "marker",
1226
+ "pattern",
1227
+ "linearGradient",
1228
+ "radialGradient",
1229
+ "stop",
1230
+ "filter",
1231
+ "title",
1232
+ "desc",
1233
+ "metadata",
1234
+ "style",
1235
+ "script"
1236
+ ]);
1237
+ function is_transparent_tag(tag) {
1238
+ return TRANSPARENT_TAGS.has(tag);
1239
+ }
1240
+ _hit_shape_svg.is_transparent_tag = is_transparent_tag;
1241
+ function num(doc, id, name, fallback = 0) {
1242
+ return svg_parse.parse_number(doc.get_attr(id, name), fallback);
1243
+ }
1244
+ function of_doc(doc, id) {
1245
+ const tag = doc.tag_of(id);
1246
+ const ops = transform.parse(doc.get_attr(id, "transform"));
1247
+ if (ops === null) return null;
1248
+ if (transform.classify(ops) === "mixed") return null;
1249
+ const xform = pick_affine(ops);
1250
+ const has_rotation = xform.angle_rad !== 0;
1251
+ switch (tag) {
1252
+ case "rect": {
1253
+ const x = num(doc, id, "x");
1254
+ const y = num(doc, id, "y");
1255
+ const w = num(doc, id, "width");
1256
+ const h = num(doc, id, "height");
1257
+ if (!has_rotation) {
1258
+ const t = xform.translate;
1259
+ return {
1260
+ kind: "rect",
1261
+ x: x + t.x,
1262
+ y: y + t.y,
1263
+ width: w,
1264
+ height: h
1265
+ };
1266
+ }
1267
+ return {
1268
+ kind: "polygon",
1269
+ pts: [
1270
+ {
1271
+ x,
1272
+ y
1273
+ },
1274
+ {
1275
+ x: x + w,
1276
+ y
1277
+ },
1278
+ {
1279
+ x: x + w,
1280
+ y: y + h
1281
+ },
1282
+ {
1283
+ x,
1284
+ y: y + h
1285
+ }
1286
+ ].map((p) => transform_point(p, xform)),
1287
+ closed: true
1288
+ };
1289
+ }
1290
+ case "circle": {
1291
+ const cx = num(doc, id, "cx");
1292
+ const cy = num(doc, id, "cy");
1293
+ const r = num(doc, id, "r");
1294
+ const tc = transform_point({
1295
+ x: cx,
1296
+ y: cy
1297
+ }, xform);
1298
+ return {
1299
+ kind: "ellipse",
1300
+ cx: tc.x,
1301
+ cy: tc.y,
1302
+ rx: r,
1303
+ ry: r
1304
+ };
1305
+ }
1306
+ case "ellipse": {
1307
+ const cx = num(doc, id, "cx");
1308
+ const cy = num(doc, id, "cy");
1309
+ const rx = num(doc, id, "rx");
1310
+ const ry = num(doc, id, "ry");
1311
+ if (has_rotation) return null;
1312
+ const tc = transform_point({
1313
+ x: cx,
1314
+ y: cy
1315
+ }, xform);
1316
+ return {
1317
+ kind: "ellipse",
1318
+ cx: tc.x,
1319
+ cy: tc.y,
1320
+ rx,
1321
+ ry
1322
+ };
1323
+ }
1324
+ case "line": return {
1325
+ kind: "segment",
1326
+ a: transform_point({
1327
+ x: num(doc, id, "x1"),
1328
+ y: num(doc, id, "y1")
1329
+ }, xform),
1330
+ b: transform_point({
1331
+ x: num(doc, id, "x2"),
1332
+ y: num(doc, id, "y2")
1333
+ }, xform)
1334
+ };
1335
+ case "polyline": {
1336
+ const pts = svg_parse.parse_points(doc.get_attr(id, "points") ?? "");
1337
+ if (pts.length === 0) return null;
1338
+ return {
1339
+ kind: "polyline",
1340
+ pts: pts.map((q) => transform_point({
1341
+ x: q.x,
1342
+ y: q.y
1343
+ }, xform)),
1344
+ closed: false
1345
+ };
1346
+ }
1347
+ case "polygon": {
1348
+ const pts = svg_parse.parse_points(doc.get_attr(id, "points") ?? "");
1349
+ if (pts.length === 0) return null;
1350
+ return {
1351
+ kind: "polygon",
1352
+ pts: pts.map((q) => transform_point({
1353
+ x: q.x,
1354
+ y: q.y
1355
+ }, xform)),
1356
+ closed: true
1357
+ };
1358
+ }
1359
+ case "path": {
1360
+ const d = doc.get_attr(id, "d") ?? "";
1361
+ const pts = path_control_polyline(d);
1362
+ if (pts.length === 0) return null;
1363
+ return {
1364
+ kind: "path",
1365
+ pts: pts.map((p) => transform_point(p, xform)),
1366
+ closed: /[zZ]\s*$/.test(d)
1367
+ };
1368
+ }
1369
+ default: return null;
1370
+ }
1371
+ }
1372
+ _hit_shape_svg.of_doc = of_doc;
1373
+ function pick_affine(ops) {
1374
+ let tx = 0;
1375
+ let ty = 0;
1376
+ let pivot = {
1377
+ x: 0,
1378
+ y: 0
1379
+ };
1380
+ let angle_rad = 0;
1381
+ for (const op of ops) if (op.type === "translate") {
1382
+ tx = op.tx;
1383
+ ty = op.ty;
1384
+ } else if (op.type === "rotate") {
1385
+ pivot = {
1386
+ x: op.cx,
1387
+ y: op.cy
1388
+ };
1389
+ angle_rad = op.angle * Math.PI / 180;
1390
+ }
1391
+ return {
1392
+ translate: {
1393
+ x: tx,
1394
+ y: ty
1395
+ },
1396
+ pivot,
1397
+ angle_rad
1398
+ };
1399
+ }
1400
+ /** Apply (rotate around pivot by angle_rad, then translate). Mirrors
1401
+ * the SVG transform-list semantic order: `translate(...)
1402
+ * rotate(...)` means matrix product T·R applied to P → translation
1403
+ * moves the *rotated* point. */
1404
+ function transform_point(p, x) {
1405
+ let q = p;
1406
+ if (x.angle_rad !== 0) {
1407
+ const c = Math.cos(x.angle_rad);
1408
+ const s = Math.sin(x.angle_rad);
1409
+ const dx = q.x - x.pivot.x;
1410
+ const dy = q.y - x.pivot.y;
1411
+ q = {
1412
+ x: x.pivot.x + dx * c - dy * s,
1413
+ y: x.pivot.y + dx * s + dy * c
1414
+ };
1415
+ }
1416
+ if (x.translate.x !== 0 || x.translate.y !== 0) q = {
1417
+ x: q.x + x.translate.x,
1418
+ y: q.y + x.translate.y
1419
+ };
1420
+ return q;
1421
+ }
1422
+ function path_control_polyline(d) {
1423
+ if (!d) return [];
1424
+ let path;
1425
+ try {
1426
+ path = new SVGPathData(d).toAbs();
1427
+ } catch {
1428
+ return [];
1429
+ }
1430
+ const out = [];
1431
+ for (const cmd of path.commands) {
1432
+ const c = cmd;
1433
+ if (typeof c.x1 === "number" && typeof c.y1 === "number") out.push({
1434
+ x: c.x1,
1435
+ y: c.y1
1436
+ });
1437
+ if (typeof c.x2 === "number" && typeof c.y2 === "number") out.push({
1438
+ x: c.x2,
1439
+ y: c.y2
1440
+ });
1441
+ if (typeof c.x === "number" && typeof c.y === "number") out.push({
1442
+ x: c.x,
1443
+ y: c.y
1444
+ });
1445
+ }
1446
+ return out;
1447
+ }
1448
+ _hit_shape_svg.path_control_polyline = path_control_polyline;
1449
+ })(hit_shape_svg || (hit_shape_svg = {}));
1450
+ //#endregion
1451
+ //#region src/core/policy-class/types.ts
1452
+ /**
1453
+ * The four top-level intents — distinguished as their own constant for
1454
+ * tables that only declare top-level intent cells.
1455
+ */
1456
+ const TOP_LEVEL_INTENTS = [
1457
+ "resize",
1458
+ "translate",
1459
+ "rotate",
1460
+ "enter-vector-edit"
1461
+ ];
1462
+ /**
1463
+ * The vector-edit sub-intents — the atomic operations exposed inside
1464
+ * vector-editing mode. See the v1 intent coverage section of the
1465
+ * glossary doc for the per-class table.
1466
+ */
1467
+ const VECTOR_EDIT_SUB_INTENTS = [
1468
+ "translate-vertex",
1469
+ "insert-vertex",
1470
+ "delete-vertex",
1471
+ "close-shape",
1472
+ "open-shape",
1473
+ "insert-tangent",
1474
+ "adjust-tangent",
1475
+ "convert-segment-type",
1476
+ "adjust-arc-radii",
1477
+ "split-sub-path"
1478
+ ];
1479
+ [...TOP_LEVEL_INTENTS, ...VECTOR_EDIT_SUB_INTENTS];
1480
+ //#endregion
1481
+ //#region src/core/policy-class/index.ts
1482
+ let policy_class;
1483
+ (function(_policy_class) {
1484
+ function of(tag) {
1485
+ switch (tag) {
1486
+ case "line":
1487
+ case "polyline":
1488
+ case "polygon": return "vertex-chain";
1489
+ case "rect":
1490
+ case "image":
1491
+ case "use": return "vertex-box";
1492
+ case "circle": return "circle";
1493
+ case "ellipse": return "ellipse";
1494
+ case "path": return "path";
1495
+ case "text":
1496
+ case "tspan": return "text";
1497
+ case "g": return "group";
1498
+ default: return "none";
1499
+ }
1500
+ }
1501
+ _policy_class.of = of;
1502
+ const SOLUTION_SPACE = {
1503
+ "vertex-chain": {
1504
+ resize: ["bake"],
1505
+ translate: ["bake", "via-transform"],
1506
+ rotate: ["via-transform"],
1507
+ "enter-vector-edit": ["bake"],
1508
+ "translate-vertex": ["bake"],
1509
+ "insert-vertex": ["bake"],
1510
+ "delete-vertex": ["bake", "restrict"],
1511
+ "close-shape": ["promote"],
1512
+ "open-shape": ["promote"]
1513
+ },
1514
+ "vertex-box": {
1515
+ resize: ["bake"],
1516
+ translate: ["bake", "via-transform"],
1517
+ rotate: ["via-transform"]
1518
+ },
1519
+ circle: {
1520
+ resize: [
1521
+ "restrict",
1522
+ "promote",
1523
+ "via-transform"
1524
+ ],
1525
+ translate: ["bake"],
1526
+ rotate: ["via-transform"]
1527
+ },
1528
+ ellipse: {
1529
+ resize: ["bake", "via-transform"],
1530
+ translate: ["bake"],
1531
+ rotate: ["via-transform"]
1532
+ },
1533
+ path: {
1534
+ resize: ["bake", "via-transform"],
1535
+ translate: ["bake", "via-transform"],
1536
+ rotate: ["bake", "via-transform"],
1537
+ "enter-vector-edit": ["bake"],
1538
+ "translate-vertex": ["bake"],
1539
+ "insert-vertex": ["bake"],
1540
+ "delete-vertex": ["bake"],
1541
+ "close-shape": ["bake"],
1542
+ "open-shape": ["bake"],
1543
+ "insert-tangent": ["bake"],
1544
+ "adjust-tangent": ["bake"],
1545
+ "convert-segment-type": ["bake"],
1546
+ "adjust-arc-radii": ["bake"],
1547
+ "split-sub-path": ["bake"]
1548
+ },
1549
+ text: {},
1550
+ group: {
1551
+ translate: ["via-transform"],
1552
+ rotate: ["via-transform"]
1553
+ },
1554
+ none: {}
1555
+ };
1556
+ const CHOSEN_POLICY = {
1557
+ "vertex-chain": {
1558
+ resize: "bake",
1559
+ translate: "bake",
1560
+ rotate: "via-transform",
1561
+ "enter-vector-edit": "bake",
1562
+ "translate-vertex": "bake",
1563
+ "insert-vertex": "bake",
1564
+ "delete-vertex": "restrict",
1565
+ "close-shape": "promote",
1566
+ "open-shape": "promote"
1567
+ },
1568
+ "vertex-box": {
1569
+ resize: "bake",
1570
+ translate: "bake",
1571
+ rotate: "via-transform"
1572
+ },
1573
+ circle: {
1574
+ resize: "restrict",
1575
+ translate: "bake",
1576
+ rotate: "via-transform"
1577
+ },
1578
+ ellipse: {
1579
+ resize: "bake",
1580
+ translate: "bake",
1581
+ rotate: "via-transform"
1582
+ },
1583
+ path: {
1584
+ resize: "bake",
1585
+ translate: "bake",
1586
+ rotate: "via-transform"
1587
+ },
1588
+ text: {},
1589
+ group: {
1590
+ translate: "via-transform",
1591
+ rotate: "via-transform"
1592
+ },
1593
+ none: {}
1594
+ };
1595
+ /** Shared empty solution-space — returned on misses to avoid per-call
1596
+ * array allocation. */
1597
+ const EMPTY = Object.freeze([]);
1598
+ function legal_solutions(cls, intent) {
1599
+ return SOLUTION_SPACE[cls][intent] ?? EMPTY;
1600
+ }
1601
+ _policy_class.legal_solutions = legal_solutions;
1602
+ function chosen_policy(cls, intent) {
1603
+ return CHOSEN_POLICY[cls][intent];
1604
+ }
1605
+ _policy_class.chosen_policy = chosen_policy;
1606
+ function accepts(cls, intent) {
1607
+ return legal_solutions(cls, intent).length > 0;
1608
+ }
1609
+ _policy_class.accepts = accepts;
1610
+ function fork_count(cls, intent) {
1611
+ return legal_solutions(cls, intent).length;
1612
+ }
1613
+ _policy_class.fork_count = fork_count;
1614
+ _policy_class._internal_SOLUTION_SPACE = SOLUTION_SPACE;
1615
+ _policy_class._internal_CHOSEN_POLICY = CHOSEN_POLICY;
1616
+ })(policy_class || (policy_class = {}));
1617
+ //#endregion
1618
+ //#region src/core/policy-class/handlers/resize.ts
1619
+ function scale_points_string(points, origin, sx, sy) {
1620
+ return svg_parse.parse_points(points).map((p) => {
1621
+ return `${origin.x + (p.x - origin.x) * sx},${origin.y + (p.y - origin.y) * sy}`;
1622
+ }).join(" ");
1623
+ }
1624
+ function scale_path_d(d, origin, sx, sy) {
1625
+ try {
1626
+ const e = origin.x * (1 - sx);
1627
+ const f = origin.y * (1 - sy);
1628
+ return new SVGPathData(d).transform(SVGPathDataTransformer.MATRIX(sx, 0, 0, sy, e, f)).encode();
1629
+ } catch {
1630
+ return d;
1631
+ }
1632
+ }
1633
+ /**
1634
+ * VertexChain × resize — vertex transport in local space.
1635
+ * Line carries its own (x1, y1, x2, y2); polyline / polygon share `points`.
1636
+ * Result type is preserved.
1637
+ */
1638
+ function resize_vertex_chain(doc, id, baseline, sx, sy, origin) {
1639
+ const a = baseline.attrs;
1640
+ if (a.kind === "line") {
1641
+ doc.set_attr(id, "x1", String(origin.x + (a.x1 - origin.x) * sx));
1642
+ doc.set_attr(id, "y1", String(origin.y + (a.y1 - origin.y) * sy));
1643
+ doc.set_attr(id, "x2", String(origin.x + (a.x2 - origin.x) * sx));
1644
+ doc.set_attr(id, "y2", String(origin.y + (a.y2 - origin.y) * sy));
1645
+ return;
1646
+ }
1647
+ if (a.kind === "polyline" || a.kind === "polygon") {
1648
+ doc.set_attr(id, "points", scale_points_string(a.points, origin, sx, sy));
1649
+ return;
1650
+ }
1651
+ }
1652
+ /**
1653
+ * VertexBox × resize — axis-aligned bounding-box transport (rect / image / use).
1654
+ * Width/height clamp at 0.001 prevents inverted boxes and degenerate writes.
1655
+ */
1656
+ function resize_vertex_box(doc, id, baseline, sx, sy, origin) {
1657
+ const a = baseline.attrs;
1658
+ if (a.kind !== "rect" && a.kind !== "image" && a.kind !== "use") return;
1659
+ doc.set_attr(id, "x", String(origin.x + (a.x - origin.x) * sx));
1660
+ doc.set_attr(id, "y", String(origin.y + (a.y - origin.y) * sy));
1661
+ doc.set_attr(id, "width", String(Math.max(.001, a.w * sx)));
1662
+ doc.set_attr(id, "height", String(Math.max(.001, a.h * sy)));
1663
+ }
1664
+ /**
1665
+ * Circle × resize — the canonical Policy Class fork.
1666
+ *
1667
+ * v1 picks `restrict`: clamp the gesture onto the `rx = ry` constraint
1668
+ * surface (`s = min(sx, sy)`) and bake the projection. The circle stays
1669
+ * a circle. `promote` and `via-transform` are declared legal but
1670
+ * unimplemented; the exhaustive switch surfaces any future swap.
1671
+ */
1672
+ function resize_circle(doc, id, baseline, sx, sy, origin) {
1673
+ const a = baseline.attrs;
1674
+ if (a.kind !== "circle") return;
1675
+ const policy = policy_class.chosen_policy("circle", "resize");
1676
+ switch (policy) {
1677
+ case "restrict": {
1678
+ const s = Math.min(sx, sy);
1679
+ doc.set_attr(id, "cx", String(origin.x + (a.cx - origin.x) * s));
1680
+ doc.set_attr(id, "cy", String(origin.y + (a.cy - origin.y) * s));
1681
+ doc.set_attr(id, "r", String(Math.max(.001, a.r * s)));
1682
+ return;
1683
+ }
1684
+ case "promote":
1685
+ case "via-transform":
1686
+ case "bake": throw new Error(`Circle resize policy '${policy}' is legal per Policy Class but not implemented in v1`);
1687
+ case void 0: throw new Error("Circle resize has no chosen policy declared in Policy Class");
1688
+ default: throw new Error("unreachable");
1689
+ }
1690
+ }
1691
+ /**
1692
+ * Ellipse × resize — independent rx, ry. No constraint to enforce
1693
+ * (ellipse is already the general axis-aligned-radii form).
1694
+ */
1695
+ function resize_ellipse(doc, id, baseline, sx, sy, origin) {
1696
+ const a = baseline.attrs;
1697
+ if (a.kind !== "ellipse") return;
1698
+ doc.set_attr(id, "cx", String(origin.x + (a.cx - origin.x) * sx));
1699
+ doc.set_attr(id, "cy", String(origin.y + (a.cy - origin.y) * sy));
1700
+ doc.set_attr(id, "rx", String(Math.max(.001, a.rx * sx)));
1701
+ doc.set_attr(id, "ry", String(Math.max(.001, a.ry * sy)));
1702
+ }
1703
+ /**
1704
+ * Path × resize — bake the affine into every segment of `d` via
1705
+ * svg-pathdata's MATRIX transformer. Curve handles, arc radii, and
1706
+ * segment endpoints scale together; segment types are preserved.
1707
+ */
1708
+ function resize_path(doc, id, baseline, sx, sy, origin) {
1709
+ const a = baseline.attrs;
1710
+ if (a.kind !== "path") return;
1711
+ doc.set_attr(id, "d", scale_path_d(a.d, origin, sx, sy));
1712
+ }
1713
+ /**
1714
+ * Text × resize.
1715
+ *
1716
+ * **Unnatural — see UNNATURAL.md.** Text class is "deferred" in Policy
1717
+ * Class (no resize cells declared) but accepted by the legacy
1718
+ * `is_resizable` capability gate. This handler preserves legacy
1719
+ * behavior verbatim:
1720
+ *
1721
+ * - Edge drags (one of sx, sy is 1) are refused.
1722
+ * - Corner drags scale (x, y, font-size) uniformly by min(sx, sy).
1723
+ *
1724
+ * When Text class is properly declared in Policy Class, the policy
1725
+ * switch above will gain a `text` branch and this handler's body will
1726
+ * either re-route through `chosen_policy("text", "resize")` or split
1727
+ * by sub-class (font-resize vs. container-box-resize is a design
1728
+ * question for the Text class work).
1729
+ */
1730
+ function resize_text(doc, id, baseline, sx, sy, origin) {
1731
+ const a = baseline.attrs;
1732
+ if (a.kind !== "text") return;
1733
+ if (!(sx !== 1 && sy !== 1)) return;
1734
+ const s = Math.min(sx, sy);
1735
+ doc.set_attr(id, "x", String(origin.x + (a.x - origin.x) * s));
1736
+ doc.set_attr(id, "y", String(origin.y + (a.y - origin.y) * s));
1737
+ doc.set_attr(id, "font-size", String(Math.max(1, a.fontSize * s)));
1738
+ }
1739
+ /**
1740
+ * Resize dispatch through Policy Class.
1741
+ *
1742
+ * Replaces the nine-arm tag switch in `intents.ts:apply_resize`. The
1743
+ * caller (`apply_resize`) wraps this with the commit-phase pivot
1744
+ * recomposition; this function does **only** the geometry write.
1745
+ *
1746
+ * Classes rejected by Policy Class (`group`, `none`) noop silently.
1747
+ * Text is carved out as the one known gap (see Text handler doc).
1748
+ */
1749
+ function dispatch_resize(doc, id, baseline, sx, sy, origin) {
1750
+ switch (policy_class.of(doc.tag_of(id))) {
1751
+ case "vertex-chain": return resize_vertex_chain(doc, id, baseline, sx, sy, origin);
1752
+ case "vertex-box": return resize_vertex_box(doc, id, baseline, sx, sy, origin);
1753
+ case "circle": return resize_circle(doc, id, baseline, sx, sy, origin);
1754
+ case "ellipse": return resize_ellipse(doc, id, baseline, sx, sy, origin);
1755
+ case "path": return resize_path(doc, id, baseline, sx, sy, origin);
1756
+ case "text": return resize_text(doc, id, baseline, sx, sy, origin);
1757
+ case "group":
1758
+ case "none": return;
1759
+ default: return;
1760
+ }
1761
+ }
1762
+ //#endregion
1763
+ //#region src/core/resize-capability.ts
1764
+ let resize_capability;
1765
+ (function(_resize_capability) {
1766
+ function direction_mask(dir) {
1767
+ const has_n = dir === "n" || dir === "ne" || dir === "nw";
1768
+ const has_s = dir === "s" || dir === "se" || dir === "sw";
1769
+ const has_e = dir === "e" || dir === "ne" || dir === "se";
1770
+ const has_w = dir === "w" || dir === "nw" || dir === "sw";
1771
+ return {
1772
+ affects_x: has_e || has_w,
1773
+ affects_y: has_n || has_s,
1774
+ x_edge: has_e ? "right" : has_w ? "left" : null,
1775
+ y_edge: has_n ? "top" : has_s ? "bottom" : null
1776
+ };
1777
+ }
1778
+ _resize_capability.direction_mask = direction_mask;
1779
+ function is_corner(dir) {
1780
+ return dir === "nw" || dir === "ne" || dir === "se" || dir === "sw";
1781
+ }
1782
+ _resize_capability.is_corner = is_corner;
1783
+ function constraint(baseline, dir, sx_gesture, sy_gesture) {
1784
+ switch (baseline.attrs.kind) {
1785
+ case "rect":
1786
+ case "image":
1787
+ case "use":
1788
+ case "ellipse":
1789
+ case "line":
1790
+ case "polyline":
1791
+ case "polygon":
1792
+ case "path": return {
1793
+ sx: sx_gesture,
1794
+ sy: sy_gesture,
1795
+ no_op: false,
1796
+ uniform: false
1797
+ };
1798
+ case "circle": {
1799
+ const s = Math.min(sx_gesture, sy_gesture);
1800
+ return {
1801
+ sx: s,
1802
+ sy: s,
1803
+ no_op: false,
1804
+ uniform: true
1805
+ };
1806
+ }
1807
+ case "text": {
1808
+ if (!is_corner(dir)) return {
1809
+ sx: 1,
1810
+ sy: 1,
1811
+ no_op: true,
1812
+ uniform: true
1813
+ };
1814
+ const s = Math.min(sx_gesture, sy_gesture);
1815
+ return {
1816
+ sx: s,
1817
+ sy: s,
1818
+ no_op: false,
1819
+ uniform: true
1820
+ };
1821
+ }
1822
+ case "unsupported": return {
1823
+ sx: 1,
1824
+ sy: 1,
1825
+ no_op: true,
1826
+ uniform: false
1827
+ };
1828
+ }
1829
+ }
1830
+ _resize_capability.constraint = constraint;
1831
+ function corner_of_rect(r, dir) {
1832
+ switch (dir) {
1833
+ case "nw": return {
1834
+ x: r.x,
1835
+ y: r.y
1836
+ };
1837
+ case "n": return {
1838
+ x: r.x + r.width / 2,
1839
+ y: r.y
1840
+ };
1841
+ case "ne": return {
1842
+ x: r.x + r.width,
1843
+ y: r.y
1844
+ };
1845
+ case "e": return {
1846
+ x: r.x + r.width,
1847
+ y: r.y + r.height / 2
1848
+ };
1849
+ case "se": return {
1850
+ x: r.x + r.width,
1851
+ y: r.y + r.height
1852
+ };
1853
+ case "s": return {
1854
+ x: r.x + r.width / 2,
1855
+ y: r.y + r.height
1856
+ };
1857
+ case "sw": return {
1858
+ x: r.x,
1859
+ y: r.y + r.height
1860
+ };
1861
+ case "w": return {
1862
+ x: r.x,
1863
+ y: r.y + r.height / 2
1864
+ };
1865
+ }
1866
+ }
1867
+ _resize_capability.corner_of_rect = corner_of_rect;
1868
+ function origin_of_direction(r, dir) {
1869
+ switch (dir) {
1870
+ case "nw": return {
1871
+ x: r.x + r.width,
1872
+ y: r.y + r.height
1873
+ };
1874
+ case "n": return {
1875
+ x: r.x + r.width / 2,
1876
+ y: r.y + r.height
1877
+ };
1878
+ case "ne": return {
1879
+ x: r.x,
1880
+ y: r.y + r.height
1881
+ };
1882
+ case "e": return {
1883
+ x: r.x,
1884
+ y: r.y + r.height / 2
1885
+ };
1886
+ case "se": return {
1887
+ x: r.x,
1888
+ y: r.y
1889
+ };
1890
+ case "s": return {
1891
+ x: r.x + r.width / 2,
1892
+ y: r.y
1893
+ };
1894
+ case "sw": return {
1895
+ x: r.x + r.width,
1896
+ y: r.y
1897
+ };
1898
+ case "w": return {
1899
+ x: r.x + r.width,
1900
+ y: r.y + r.height / 2
1901
+ };
1902
+ }
1903
+ }
1904
+ _resize_capability.origin_of_direction = origin_of_direction;
1905
+ function effective(baseline, dir, sx_gesture, sy_gesture) {
1906
+ const bbox = baseline.bbox;
1907
+ const origin = origin_of_direction(bbox, dir);
1908
+ const c = constraint(baseline, dir, sx_gesture, sy_gesture);
1909
+ const mask = direction_mask(dir);
1910
+ const rect = {
1911
+ x: origin.x + (bbox.x - origin.x) * c.sx,
1912
+ y: origin.y + (bbox.y - origin.y) * c.sy,
1913
+ width: bbox.width * c.sx,
1914
+ height: bbox.height * c.sy
1915
+ };
1916
+ const moving_corner = corner_of_rect(rect, dir);
1917
+ return {
1918
+ rect,
1919
+ sx: c.sx,
1920
+ sy: c.sy,
1921
+ moving_corner,
1922
+ origin,
1923
+ no_op: c.no_op,
1924
+ uniform: c.uniform,
1925
+ mask
1926
+ };
1927
+ }
1928
+ _resize_capability.effective = effective;
1929
+ })(resize_capability || (resize_capability = {}));
1930
+ //#endregion
1931
+ //#region src/core/resize-pipeline/resize-pipeline.ts
1932
+ const XLINK_NS = "http://www.w3.org/1999/xlink";
1933
+ let resize_pipeline;
1934
+ (function(_resize_pipeline) {
1935
+ let intent;
1936
+ (function(_intent) {
1937
+ function num(doc, id, name, fallback = 0) {
1938
+ return svg_parse.parse_number(doc.get_attr(id, name), fallback);
1939
+ }
1940
+ function is_resizable(tag) {
1941
+ switch (tag) {
1942
+ case "rect":
1943
+ case "image":
1944
+ case "use":
1945
+ case "circle":
1946
+ case "ellipse":
1947
+ case "line":
1948
+ case "polyline":
1949
+ case "polygon":
1950
+ case "path":
1951
+ case "text": return true;
1952
+ default: return false;
1953
+ }
1954
+ }
1955
+ _intent.is_resizable = is_resizable;
1956
+ function capture_baseline(doc, id, bbox) {
1957
+ const tag = doc.tag_of(id);
1958
+ let attrs;
1959
+ switch (tag) {
1960
+ case "rect":
1961
+ attrs = {
1962
+ kind: "rect",
1963
+ x: num(doc, id, "x"),
1964
+ y: num(doc, id, "y"),
1965
+ w: num(doc, id, "width", bbox.width),
1966
+ h: num(doc, id, "height", bbox.height)
1967
+ };
1968
+ break;
1969
+ case "image":
1970
+ attrs = {
1971
+ kind: "image",
1972
+ x: num(doc, id, "x"),
1973
+ y: num(doc, id, "y"),
1974
+ w: num(doc, id, "width", bbox.width),
1975
+ h: num(doc, id, "height", bbox.height)
1976
+ };
1977
+ break;
1978
+ case "use":
1979
+ attrs = {
1980
+ kind: "use",
1981
+ x: num(doc, id, "x"),
1982
+ y: num(doc, id, "y"),
1983
+ w: num(doc, id, "width", bbox.width),
1984
+ h: num(doc, id, "height", bbox.height)
1985
+ };
1986
+ break;
1987
+ case "circle":
1988
+ attrs = {
1989
+ kind: "circle",
1990
+ cx: num(doc, id, "cx"),
1991
+ cy: num(doc, id, "cy"),
1992
+ r: num(doc, id, "r")
1993
+ };
1994
+ break;
1995
+ case "ellipse":
1996
+ attrs = {
1997
+ kind: "ellipse",
1998
+ cx: num(doc, id, "cx"),
1999
+ cy: num(doc, id, "cy"),
2000
+ rx: num(doc, id, "rx"),
2001
+ ry: num(doc, id, "ry")
2002
+ };
2003
+ break;
2004
+ case "line":
2005
+ attrs = {
2006
+ kind: "line",
2007
+ x1: num(doc, id, "x1"),
2008
+ y1: num(doc, id, "y1"),
2009
+ x2: num(doc, id, "x2"),
2010
+ y2: num(doc, id, "y2")
2011
+ };
2012
+ break;
2013
+ case "polyline":
2014
+ attrs = {
2015
+ kind: "polyline",
2016
+ points: doc.get_attr(id, "points") ?? ""
2017
+ };
2018
+ break;
2019
+ case "polygon":
2020
+ attrs = {
2021
+ kind: "polygon",
2022
+ points: doc.get_attr(id, "points") ?? ""
2023
+ };
2024
+ break;
2025
+ case "path":
2026
+ attrs = {
2027
+ kind: "path",
2028
+ d: doc.get_attr(id, "d") ?? ""
2029
+ };
2030
+ break;
2031
+ case "text":
2032
+ attrs = {
2033
+ kind: "text",
2034
+ x: num(doc, id, "x"),
2035
+ y: num(doc, id, "y"),
2036
+ fontSize: parseFloat(doc.get_attr(id, "font-size") ?? "16") || 16
2037
+ };
2038
+ break;
2039
+ default: attrs = { kind: "unsupported" };
2040
+ }
2041
+ return {
2042
+ bbox,
2043
+ attrs
2044
+ };
2045
+ }
2046
+ _intent.capture_baseline = capture_baseline;
2047
+ function compute_factors(baseline, dir, dx, dy, shift) {
2048
+ const b = baseline.bbox;
2049
+ let anchorX = 0;
2050
+ let anchorY = 0;
2051
+ let baseHX = 0;
2052
+ let baseHY = 0;
2053
+ let affectsX = true;
2054
+ let affectsY = true;
2055
+ switch (dir) {
2056
+ case "nw":
2057
+ anchorX = b.x + b.width;
2058
+ anchorY = b.y + b.height;
2059
+ baseHX = b.x;
2060
+ baseHY = b.y;
2061
+ break;
2062
+ case "n":
2063
+ anchorX = b.x + b.width / 2;
2064
+ anchorY = b.y + b.height;
2065
+ baseHX = b.x + b.width / 2;
2066
+ baseHY = b.y;
2067
+ affectsX = false;
2068
+ break;
2069
+ case "ne":
2070
+ anchorX = b.x;
2071
+ anchorY = b.y + b.height;
2072
+ baseHX = b.x + b.width;
2073
+ baseHY = b.y;
2074
+ break;
2075
+ case "e":
2076
+ anchorX = b.x;
2077
+ anchorY = b.y + b.height / 2;
2078
+ baseHX = b.x + b.width;
2079
+ baseHY = b.y + b.height / 2;
2080
+ affectsY = false;
2081
+ break;
2082
+ case "se":
2083
+ anchorX = b.x;
2084
+ anchorY = b.y;
2085
+ baseHX = b.x + b.width;
2086
+ baseHY = b.y + b.height;
2087
+ break;
2088
+ case "s":
2089
+ anchorX = b.x + b.width / 2;
2090
+ anchorY = b.y;
2091
+ baseHX = b.x + b.width / 2;
2092
+ baseHY = b.y + b.height;
2093
+ affectsX = false;
2094
+ break;
2095
+ case "sw":
2096
+ anchorX = b.x + b.width;
2097
+ anchorY = b.y;
2098
+ baseHX = b.x;
2099
+ baseHY = b.y + b.height;
2100
+ break;
2101
+ case "w":
2102
+ anchorX = b.x + b.width;
2103
+ anchorY = b.y + b.height / 2;
2104
+ baseHX = b.x;
2105
+ baseHY = b.y + b.height / 2;
2106
+ affectsY = false;
2107
+ break;
2108
+ }
2109
+ const newHX = baseHX + (affectsX ? dx : 0);
2110
+ const newHY = baseHY + (affectsY ? dy : 0);
2111
+ const denomX = baseHX - anchorX;
2112
+ const denomY = baseHY - anchorY;
2113
+ let sx = affectsX && denomX !== 0 ? (newHX - anchorX) / denomX : 1;
2114
+ let sy = affectsY && denomY !== 0 ? (newHY - anchorY) / denomY : 1;
2115
+ if (shift && affectsX && affectsY) {
2116
+ const mag = Math.max(Math.abs(sx), Math.abs(sy));
2117
+ sx = sx >= 0 ? mag : -mag;
2118
+ sy = sy >= 0 ? mag : -mag;
2119
+ }
2120
+ sx = Math.max(.001, sx);
2121
+ sy = Math.max(.001, sy);
2122
+ return {
2123
+ sx,
2124
+ sy,
2125
+ origin: {
2126
+ x: anchorX,
2127
+ y: anchorY
2128
+ }
2129
+ };
2130
+ }
2131
+ _intent.compute_factors = compute_factors;
2132
+ function bbox_center(points) {
2133
+ if (points.length === 0) return null;
2134
+ const r = cmath.rect.fromPointsOrZero(points.map((p) => [p.x, p.y]));
2135
+ return {
2136
+ cx: r.x + r.width / 2,
2137
+ cy: r.y + r.height / 2
2138
+ };
2139
+ }
2140
+ function new_local_center(doc, id) {
2141
+ switch (doc.tag_of(id)) {
2142
+ case "rect":
2143
+ case "image":
2144
+ case "use": {
2145
+ const x = num(doc, id, "x");
2146
+ const y = num(doc, id, "y");
2147
+ return {
2148
+ cx: x + num(doc, id, "width") / 2,
2149
+ cy: y + num(doc, id, "height") / 2
2150
+ };
2151
+ }
2152
+ case "circle":
2153
+ case "ellipse": return {
2154
+ cx: num(doc, id, "cx"),
2155
+ cy: num(doc, id, "cy")
2156
+ };
2157
+ case "line": {
2158
+ const x1 = num(doc, id, "x1");
2159
+ const y1 = num(doc, id, "y1");
2160
+ const x2 = num(doc, id, "x2");
2161
+ const y2 = num(doc, id, "y2");
2162
+ return {
2163
+ cx: (x1 + x2) / 2,
2164
+ cy: (y1 + y2) / 2
2165
+ };
2166
+ }
2167
+ case "polyline":
2168
+ case "polygon": {
2169
+ const points = doc.get_attr(id, "points");
2170
+ if (!points) return null;
2171
+ return bbox_center(svg_parse.parse_points(points));
2172
+ }
2173
+ case "path": {
2174
+ const d = doc.get_attr(id, "d");
2175
+ if (!d) return null;
2176
+ return bbox_center(hit_shape_svg.path_control_polyline(d));
2177
+ }
2178
+ default: return null;
2179
+ }
2180
+ }
2181
+ function shift_points_string(points, dx, dy) {
2182
+ if (dx === 0 && dy === 0) return points;
2183
+ return svg_parse.parse_points(points).map((p) => `${p.x + dx},${p.y + dy}`).join(" ");
2184
+ }
2185
+ function shift_path_d(d, dx, dy) {
2186
+ if (dx === 0 && dy === 0) return d;
2187
+ try {
2188
+ return new SVGPathData(d).transform(SVGPathDataTransformer.TRANSLATE(dx, dy)).encode();
2189
+ } catch {
2190
+ return d;
2191
+ }
2192
+ }
2193
+ /** Translate every local-frame coord of `id`'s geometry by (dx,
2194
+ * dy). The primitive arms write intrinsic attrs directly — we
2195
+ * can't route through `translate_pipeline.intent.apply` here
2196
+ * because `translate_pipeline.intent.capture_baseline` returns
2197
+ * `viaTransform` whenever the node has a `transform=`, and that
2198
+ * branch prepends a `translate()` to the transform string, which
2199
+ * would clobber the pivot rewrite the caller is about to do. */
2200
+ function shift_geometry(doc, id, dx, dy) {
2201
+ switch (doc.tag_of(id)) {
2202
+ case "rect":
2203
+ case "image":
2204
+ case "use":
2205
+ doc.set_attr(id, "x", String(num(doc, id, "x") + dx));
2206
+ doc.set_attr(id, "y", String(num(doc, id, "y") + dy));
2207
+ return;
2208
+ case "circle":
2209
+ case "ellipse":
2210
+ doc.set_attr(id, "cx", String(num(doc, id, "cx") + dx));
2211
+ doc.set_attr(id, "cy", String(num(doc, id, "cy") + dy));
2212
+ return;
2213
+ case "line":
2214
+ doc.set_attr(id, "x1", String(num(doc, id, "x1") + dx));
2215
+ doc.set_attr(id, "y1", String(num(doc, id, "y1") + dy));
2216
+ doc.set_attr(id, "x2", String(num(doc, id, "x2") + dx));
2217
+ doc.set_attr(id, "y2", String(num(doc, id, "y2") + dy));
2218
+ return;
2219
+ case "polyline":
2220
+ case "polygon": {
2221
+ const points = doc.get_attr(id, "points");
2222
+ if (points) doc.set_attr(id, "points", shift_points_string(points, dx, dy));
2223
+ return;
2224
+ }
2225
+ case "path": {
2226
+ const d = doc.get_attr(id, "d");
2227
+ if (d) doc.set_attr(id, "d", shift_path_d(d, dx, dy));
2228
+ return;
2229
+ }
2230
+ }
2231
+ }
2232
+ function find_rotate_op(ops) {
2233
+ for (const op of ops) if (op.type === "rotate") return op;
2234
+ return null;
2235
+ }
2236
+ /**
2237
+ * Commit-only. Moves the rotate pivot to the new local center and
2238
+ * shifts geometry by δ = (R − I) · Δc so the doc-space rendering
2239
+ * is unchanged. Without the shift, changing the pivot offsets the
2240
+ * rect in doc space and the HUD's stable-matrix chrome falls out
2241
+ * of alignment with the element.
2242
+ */
2243
+ function renormalize_rotate_pivot(doc, id) {
2244
+ const existing = doc.get_attr(id, "transform");
2245
+ if (existing === null || existing.indexOf("rotate") === -1) return;
2246
+ const ops = transform.parse(existing);
2247
+ if (ops === null) return;
2248
+ const cls = transform.classify(ops);
2249
+ if (cls !== "single_rotate_only" && cls !== "leading_translate_then_single_rotate") return;
2250
+ const rot = find_rotate_op(ops);
2251
+ if (!rot || rot.explicit_pivot !== true) return;
2252
+ const c_pre = new_local_center(doc, id);
2253
+ if (!c_pre) return;
2254
+ const dc_x = c_pre.cx - rot.cx;
2255
+ const dc_y = c_pre.cy - rot.cy;
2256
+ if (dc_x === 0 && dc_y === 0) return;
2257
+ const theta = rot.angle * Math.PI / 180;
2258
+ const cos = Math.cos(theta);
2259
+ const sin = Math.sin(theta);
2260
+ const dx = (cos - 1) * dc_x - sin * dc_y;
2261
+ const dy = sin * dc_x + (cos - 1) * dc_y;
2262
+ shift_geometry(doc, id, dx, dy);
2263
+ const next = transform.recompose(ops, c_pre.cx + dx, c_pre.cy + dy);
2264
+ if (next === existing) return;
2265
+ doc.set_attr(id, "transform", next);
2266
+ }
2267
+ function apply(doc, id, baseline, sx, sy, origin, phase = "commit") {
2268
+ dispatch_resize(doc, id, baseline, sx, sy, origin);
2269
+ if (phase === "commit") renormalize_rotate_pivot(doc, id);
2270
+ }
2271
+ _intent.apply = apply;
2272
+ function replace_href(doc, id, value) {
2273
+ const old_href = doc.get_attr(id, "href");
2274
+ const old_xlink = doc.get_attr(id, "href", XLINK_NS);
2275
+ const write_href = old_href !== null || old_xlink === null;
2276
+ const write_xlink = old_xlink !== null;
2277
+ if (write_href) doc.set_attr(id, "href", value);
2278
+ if (write_xlink) doc.set_attr(id, "href", value, XLINK_NS);
2279
+ return {
2280
+ old_href,
2281
+ old_xlink
2282
+ };
2283
+ }
2284
+ _intent.replace_href = replace_href;
2285
+ function is_resizable_node(doc, id) {
2286
+ if (!is_resizable(doc.tag_of(id))) return false;
2287
+ const ops = transform.parse(doc.get_attr(id, "transform"));
2288
+ if (ops === null) return false;
2289
+ const cls = transform.classify(ops);
2290
+ if (cls === "identity" || cls === "leading_translate_only") return true;
2291
+ if (cls === "single_rotate_only" || cls === "leading_translate_then_single_rotate") return find_rotate_op(ops)?.explicit_pivot === true;
2292
+ return false;
2293
+ }
2294
+ _intent.is_resizable_node = is_resizable_node;
2295
+ })(intent || (intent = _resize_pipeline.intent || (_resize_pipeline.intent = {})));
2296
+ let stages;
2297
+ (function(_stages) {
2298
+ function pipeline_baseline(plan) {
2299
+ return plan.baseline;
2300
+ }
2301
+ function corner_x_of(r, dir) {
2302
+ switch (dir) {
2303
+ case "nw":
2304
+ case "w":
2305
+ case "sw": return r.x;
2306
+ case "ne":
2307
+ case "e":
2308
+ case "se": return r.x + r.width;
2309
+ case "n":
2310
+ case "s": return r.x + r.width / 2;
2311
+ }
2312
+ }
2313
+ function corner_y_of(r, dir) {
2314
+ switch (dir) {
2315
+ case "nw":
2316
+ case "n":
2317
+ case "ne": return r.y;
2318
+ case "sw":
2319
+ case "s":
2320
+ case "se": return r.y + r.height;
2321
+ case "e":
2322
+ case "w": return r.y + r.height / 2;
2323
+ }
2324
+ }
2325
+ const aspect_lock = _stages.aspect_lock = {
2326
+ name: "aspect_lock",
2327
+ run(plan, ctx) {
2328
+ if (ctx.modifiers.aspect_lock !== "uniform") return { plan };
2329
+ if (!resize_capability.is_corner(plan.direction)) return { plan };
2330
+ const pbase = pipeline_baseline(plan);
2331
+ const locked = intent.compute_factors(pbase, plan.direction, plan.dx, plan.dy, true);
2332
+ const bbox = pbase.bbox;
2333
+ const Hx_base = corner_x_of(bbox, plan.direction);
2334
+ const Hy_base = corner_y_of(bbox, plan.direction);
2335
+ const new_Hx = locked.origin.x + (Hx_base - locked.origin.x) * locked.sx;
2336
+ const new_Hy = locked.origin.y + (Hy_base - locked.origin.y) * locked.sy;
2337
+ return { plan: {
2338
+ ...plan,
2339
+ dx: new_Hx - Hx_base,
2340
+ dy: new_Hy - Hy_base
2341
+ } };
2342
+ }
2343
+ };
2344
+ const snap = _stages.snap = {
2345
+ name: "snap",
2346
+ run(plan, ctx) {
2347
+ if (ctx.modifiers.force_disable_snap) return { plan };
2348
+ if (!ctx.snap_session) return { plan };
2349
+ if (!ctx.options.snap_enabled) return { plan };
2350
+ const pbase = pipeline_baseline(plan);
2351
+ const f = intent.compute_factors(pbase, plan.direction, plan.dx, plan.dy, false);
2352
+ const eff = resize_capability.effective(pbase, plan.direction, f.sx, f.sy);
2353
+ if (eff.no_op) return { plan };
2354
+ const r = ctx.snap_session.snap_resize(eff.rect, {
2355
+ x: eff.mask.x_edge,
2356
+ y: eff.mask.y_edge
2357
+ }, {
2358
+ enabled: true,
2359
+ threshold_px: ctx.options.snap_threshold_px
2360
+ });
2361
+ if (r.dx === 0 && r.dy === 0) return {
2362
+ plan,
2363
+ emit: r.guide ? { guide: r.guide } : void 0
2364
+ };
2365
+ if (eff.uniform) {
2366
+ const bbox = pbase.bbox;
2367
+ const new_Hx = eff.moving_corner.x + r.dx;
2368
+ const new_Hy = eff.moving_corner.y + r.dy;
2369
+ const sx_from_x = eff.mask.x_edge !== null && r.dx !== 0 && bbox.width !== 0 ? (new_Hx - eff.origin.x) / (eff.moving_corner.x - eff.origin.x) * eff.sx : null;
2370
+ const sy_from_y = eff.mask.y_edge !== null && r.dy !== 0 && bbox.height !== 0 ? (new_Hy - eff.origin.y) / (eff.moving_corner.y - eff.origin.y) * eff.sy : null;
2371
+ let s = eff.sx;
2372
+ if (sx_from_x !== null && sy_from_y !== null) s = Math.min(sx_from_x, sy_from_y);
2373
+ else if (sx_from_x !== null) s = sx_from_x;
2374
+ else if (sy_from_y !== null) s = sy_from_y;
2375
+ const Hx_base = corner_x_of(bbox, plan.direction);
2376
+ const Hy_base = corner_y_of(bbox, plan.direction);
2377
+ const target_Hx = eff.origin.x + (Hx_base - eff.origin.x) * s;
2378
+ const target_Hy = eff.origin.y + (Hy_base - eff.origin.y) * s;
2379
+ return {
2380
+ plan: {
2381
+ ...plan,
2382
+ dx: eff.mask.affects_x ? target_Hx - Hx_base : 0,
2383
+ dy: eff.mask.affects_y ? target_Hy - Hy_base : 0
2384
+ },
2385
+ emit: r.guide ? { guide: r.guide } : void 0
2386
+ };
2387
+ }
2388
+ return {
2389
+ plan: {
2390
+ ...plan,
2391
+ dx: eff.mask.affects_x ? plan.dx + r.dx : plan.dx,
2392
+ dy: eff.mask.affects_y ? plan.dy + r.dy : plan.dy
2393
+ },
2394
+ emit: r.guide ? { guide: r.guide } : void 0
2395
+ };
2396
+ }
2397
+ };
2398
+ const pixel_grid = _stages.pixel_grid = {
2399
+ name: "pixel_grid",
2400
+ run(plan, ctx) {
2401
+ const q = ctx.options.pixel_grid_quantum;
2402
+ if (q === null || q <= 0) return { plan };
2403
+ const pbase = pipeline_baseline(plan);
2404
+ const f = intent.compute_factors(pbase, plan.direction, plan.dx, plan.dy, false);
2405
+ const eff = resize_capability.effective(pbase, plan.direction, f.sx, f.sy);
2406
+ if (eff.no_op) return { plan };
2407
+ const target_Hx = eff.mask.affects_x ? Math.round(eff.moving_corner.x / q) * q : eff.moving_corner.x;
2408
+ const target_Hy = eff.mask.affects_y ? Math.round(eff.moving_corner.y / q) * q : eff.moving_corner.y;
2409
+ const bbox = pbase.bbox;
2410
+ const Hx_base = corner_x_of(bbox, plan.direction);
2411
+ const Hy_base = corner_y_of(bbox, plan.direction);
2412
+ return { plan: {
2413
+ ...plan,
2414
+ dx: eff.mask.affects_x ? target_Hx - Hx_base : 0,
2415
+ dy: eff.mask.affects_y ? target_Hy - Hy_base : 0
2416
+ } };
2417
+ }
2418
+ };
2419
+ _stages.DEFAULT = Object.freeze([
2420
+ aspect_lock,
2421
+ snap,
2422
+ pixel_grid
2423
+ ]);
2424
+ })(stages || (stages = _resize_pipeline.stages || (_resize_pipeline.stages = {})));
2425
+ function run(init, stages, ctx) {
2426
+ let plan = init;
2427
+ const guides = [];
2428
+ for (const stage of stages) {
2429
+ const out = stage.run(plan, ctx);
2430
+ plan = out.plan;
2431
+ if (out.emit?.guide) guides.push(out.emit.guide);
2432
+ }
2433
+ return {
2434
+ plan,
2435
+ guides
2436
+ };
2437
+ }
2438
+ _resize_pipeline.run = run;
2439
+ function apply(doc, plan, phase = "commit") {
2440
+ const f = intent.compute_factors(plan.baseline, plan.direction, plan.dx, plan.dy, false);
2441
+ const members = plan.members ?? [{
2442
+ id: plan.id,
2443
+ baseline: plan.baseline
2444
+ }];
2445
+ for (const m of members) intent.apply(doc, m.id, m.baseline, f.sx, f.sy, f.origin, phase);
2446
+ }
2447
+ _resize_pipeline.apply = apply;
2448
+ function revert(doc, plan) {
2449
+ const f = intent.compute_factors(plan.baseline, plan.direction, 0, 0, false);
2450
+ const members = plan.members ?? [{
2451
+ id: plan.id,
2452
+ baseline: plan.baseline
2453
+ }];
2454
+ for (const m of members) intent.apply(doc, m.id, m.baseline, 1, 1, f.origin, "preview");
2455
+ }
2456
+ _resize_pipeline.revert = revert;
2457
+ function synthesize_group_baseline(union) {
2458
+ return {
2459
+ bbox: {
2460
+ x: union.x,
2461
+ y: union.y,
2462
+ width: union.width,
2463
+ height: union.height
2464
+ },
2465
+ attrs: {
2466
+ kind: "rect",
2467
+ x: union.x,
2468
+ y: union.y,
2469
+ w: union.width,
2470
+ h: union.height
2471
+ }
2472
+ };
2473
+ }
2474
+ _resize_pipeline.synthesize_group_baseline = synthesize_group_baseline;
2475
+ })(resize_pipeline || (resize_pipeline = {}));
2476
+ //#endregion
2477
+ //#region src/core/resize-pipeline/orchestrator.ts
2478
+ const PROVIDER_ID = "svg-editor";
2479
+ /** West/north-anchor flips invert the corresponding world delta so a
2480
+ * positive value always grows the moving edge outward — the convention
2481
+ * `compute_resize_factors` consumes. */
2482
+ function sign_adjust(dir, dx_world, dy_world) {
2483
+ return {
2484
+ dx: dir === "w" || dir === "nw" || dir === "sw" ? -dx_world : dx_world,
2485
+ dy: dir === "n" || dir === "ne" || dir === "nw" ? -dy_world : dy_world
2486
+ };
2487
+ }
2488
+ /** Stable, order-independent key for an id set — used by `is_active_for`
2489
+ * to decide whether the current session targets the same group. */
2490
+ function ids_key(ids) {
2491
+ return [...ids].sort().join("\0");
2492
+ }
2493
+ var ResizeOrchestrator = class {
2494
+ constructor(deps) {
2495
+ this.deps = deps;
2496
+ this.active = null;
2497
+ this._last_guides = [];
2498
+ }
2499
+ /** Guides emitted by the most recent pipeline run. Cleared on
2500
+ * cancel/dispose. */
2501
+ get last_guides() {
2502
+ return this._last_guides;
2503
+ }
2504
+ has_active_session() {
2505
+ return this.active !== null;
2506
+ }
2507
+ /** Is the gesture currently targeting `ids` with `direction`? Used by
2508
+ * the HUD dispatch to decide whether to reset the session on a new
2509
+ * handle / target. Order-independent. */
2510
+ is_active_for(ids, direction) {
2511
+ return this.active !== null && this.active.direction === direction && this.active.ids_key === ids_key(ids);
2512
+ }
2513
+ /** Per-frame drive. Opens a session lazily on the first call. The
2514
+ * HUD passes its gesture-target rect dimensions in **world space**;
2515
+ * the orchestrator derives the signed world-frame delta against its
2516
+ * captured `baseline.bbox`. The DOM adapter is responsible for the
2517
+ * CSS-px → world conversion at the intent boundary. */
2518
+ drive(input, modifiers, opts) {
2519
+ if (input.ids.length === 0) return null;
2520
+ const doc = this.deps.get_doc();
2521
+ for (const id of input.ids) if (!resize_pipeline.intent.is_resizable_node(doc, id)) return null;
2522
+ const key = ids_key(input.ids);
2523
+ if (this.active && (this.active.ids_key !== key || this.active.direction !== input.direction)) {
2524
+ this.active.preview.discard();
2525
+ this.dispose_session();
2526
+ }
2527
+ if (this.active === null) this.active = this.open(input.ids, input.direction, opts.snap, opts.label ?? "resize");
2528
+ const session = this.active;
2529
+ const bbox = session.baseline.bbox;
2530
+ const dx_world = input.target_width - bbox.width;
2531
+ const dy_world = input.target_height - bbox.height;
2532
+ const d = sign_adjust(input.direction, dx_world, dy_world);
2533
+ const stages = opts.stages ?? resize_pipeline.stages.DEFAULT;
2534
+ const result = this.run_pass(session, d.dx, d.dy, modifiers, stages);
2535
+ session.last_dx = d.dx;
2536
+ session.last_dy = d.dy;
2537
+ session.last_stages = stages;
2538
+ this.write_preview(session, result.plan, opts.phase);
2539
+ if (opts.phase === "commit") {
2540
+ session.preview.commit();
2541
+ this.dispose_session();
2542
+ }
2543
+ return result;
2544
+ }
2545
+ /** Re-run the current preview frame with new modifiers. */
2546
+ redrive_modifiers(modifiers) {
2547
+ if (!this.active) return null;
2548
+ const session = this.active;
2549
+ const result = this.run_pass(session, session.last_dx, session.last_dy, modifiers, session.last_stages);
2550
+ this.write_preview(session, result.plan, "preview");
2551
+ return result;
2552
+ }
2553
+ cancel() {
2554
+ if (!this.active) return;
2555
+ this.active.preview.discard();
2556
+ this.dispose_session();
2557
+ }
2558
+ run_pass(session, dx, dy, modifiers, stages) {
2559
+ const plan0 = {
2560
+ id: session.primary_id,
2561
+ baseline: session.baseline,
2562
+ members: session.members,
2563
+ direction: session.direction,
2564
+ dx,
2565
+ dy
2566
+ };
2567
+ const ctx = {
2568
+ input: {
2569
+ id: session.primary_id,
2570
+ direction: session.direction,
2571
+ dx,
2572
+ dy
2573
+ },
2574
+ modifiers,
2575
+ options: this.deps.options(),
2576
+ snap_session: session.snap
2577
+ };
2578
+ const result = resize_pipeline.run(plan0, stages, ctx);
2579
+ this._last_guides = result.guides;
2580
+ return result;
2581
+ }
2582
+ open(ids, direction, snap, label) {
2583
+ const doc = this.deps.get_doc();
2584
+ const members = ids.map((id) => ({
2585
+ id,
2586
+ baseline: resize_pipeline.intent.capture_baseline(doc, id, this.deps.bbox_world(id))
2587
+ }));
2588
+ const baseline = members.length === 1 ? members[0].baseline : resize_pipeline.synthesize_group_baseline(cmath.rect.union(members.map((m) => m.baseline.bbox)));
2589
+ return {
2590
+ ids_key: ids_key(ids),
2591
+ primary_id: members[0].id,
2592
+ direction,
2593
+ members,
2594
+ baseline,
2595
+ snap: snap ? this.deps.open_snap(ids) : null,
2596
+ preview: this.deps.open_preview(label),
2597
+ last_dx: 0,
2598
+ last_dy: 0,
2599
+ last_stages: resize_pipeline.stages.DEFAULT
2600
+ };
2601
+ }
2602
+ write_preview(session, plan, phase) {
2603
+ const doc = this.deps.get_doc();
2604
+ const emit = this.deps.emit;
2605
+ session.preview.set({
2606
+ providerId: PROVIDER_ID,
2607
+ apply: () => {
2608
+ resize_pipeline.apply(doc, plan, phase);
2609
+ emit();
2610
+ },
2611
+ revert: () => {
2612
+ resize_pipeline.revert(doc, plan);
2613
+ emit();
2614
+ }
2615
+ });
2616
+ }
2617
+ dispose_session() {
2618
+ if (!this.active) return;
2619
+ this.active.snap?.dispose();
2620
+ this.active = null;
2621
+ this._last_guides = [];
2622
+ }
2623
+ };
2624
+ //#endregion
2625
+ //#region src/core/paint.ts
2626
+ let paint;
2627
+ (function(_paint) {
2628
+ function parse(declared) {
2629
+ if (declared === null || declared === "") return null;
2630
+ const trimmed = declared.trim();
2631
+ if (trimmed === "") return null;
2632
+ if (trimmed === "inherit" || trimmed === "initial" || trimmed === "unset" || trimmed === "revert" || trimmed === "revert-layer") return null;
2633
+ if (/^var\s*\(/i.test(trimmed)) return {
2634
+ error: "invalid_at_computed_value_time",
2635
+ reason: "var() substitution requires a cascade engine (not implemented)"
2636
+ };
2637
+ if (trimmed === "none") return { kind: "none" };
2638
+ if (trimmed === "context-fill" || trimmed === "contextFill") return { kind: "context_fill" };
2639
+ if (trimmed === "context-stroke" || trimmed === "contextStroke") return { kind: "context_stroke" };
2640
+ const url_match = trimmed.match(/^url\(\s*(["']?)#([^)"']+)\1\s*\)\s*(.*)$/i);
2641
+ if (url_match) {
2642
+ const id = url_match[2];
2643
+ const rest = url_match[3].trim();
2644
+ let fallback;
2645
+ if (rest !== "") {
2646
+ const f = parse(rest);
2647
+ if (f && f.kind === "none") fallback = { kind: "none" };
2648
+ else if (f && f.kind === "color") fallback = {
2649
+ kind: "color",
2650
+ value: f.value
2651
+ };
2652
+ }
2653
+ return fallback ? {
2654
+ kind: "ref",
2655
+ id,
2656
+ fallback
2657
+ } : {
2658
+ kind: "ref",
2659
+ id
2660
+ };
2661
+ }
2662
+ if (/^currentcolor$/i.test(trimmed)) return {
2663
+ kind: "color",
2664
+ value: { kind: "current_color" }
2665
+ };
2666
+ return {
2667
+ kind: "color",
2668
+ value: {
2669
+ kind: "rgb",
2670
+ value: trimmed
2671
+ }
2672
+ };
2673
+ }
2674
+ _paint.parse = parse;
2675
+ function serialize(p) {
2676
+ switch (p.kind) {
2677
+ case "none": return "none";
2678
+ case "context_fill": return "context-fill";
2679
+ case "context_stroke": return "context-stroke";
2680
+ case "color": return p.value.kind === "current_color" ? "currentColor" : p.value.value;
2681
+ case "ref":
2682
+ if (p.fallback) {
2683
+ const f = p.fallback.kind === "none" ? "none" : p.fallback.value.kind === "current_color" ? "currentColor" : p.fallback.value.value;
2684
+ return `url(#${p.id}) ${f}`;
2685
+ }
2686
+ return `url(#${p.id})`;
2687
+ }
2688
+ }
2689
+ _paint.serialize = serialize;
2690
+ function value_equals(a, b) {
2691
+ if (a === b) return true;
2692
+ if (a.declared !== b.declared) return false;
2693
+ if (a.provenance.carrier !== b.provenance.carrier) return false;
2694
+ if (a.provenance.origin !== b.provenance.origin) return false;
2695
+ return computed_equals(a.computed, b.computed);
2696
+ }
2697
+ _paint.value_equals = value_equals;
2698
+ function computed_equals(a, b) {
2699
+ if (a === b) return true;
2700
+ if (a == null || b == null) return false;
2701
+ if ("error" in a || "error" in b) return "error" in a && "error" in b && a.error === b.error && a.reason === b.reason;
2702
+ if (a.kind !== b.kind) return false;
2703
+ if (a.kind === "color" && b.kind === "color") {
2704
+ if (a.value.kind !== b.value.kind) return false;
2705
+ if (a.value.kind === "rgb" && b.value.kind === "rgb") return a.value.value === b.value.value;
2706
+ return true;
2707
+ }
2708
+ if (a.kind === "ref" && b.kind === "ref") return a.id === b.id;
2709
+ if (a.kind === "none" && b.kind === "none") return true;
2710
+ if (a.kind === "context_fill" && b.kind === "context_fill") return true;
2711
+ if (a.kind === "context_stroke" && b.kind === "context_stroke") return true;
2712
+ return false;
2713
+ }
2714
+ })(paint || (paint = {}));
2715
+ //#endregion
2716
+ //#region src/types.ts
2717
+ const TOOL_CURSOR = { type: "cursor" };
2718
+ const DEFAULT_STYLE = {
2719
+ chrome_color: "#2563eb",
2720
+ handle_size: 8,
2721
+ handle_fill: "#ffffff",
2722
+ handle_stroke: "#2563eb",
2723
+ endpoint_dot_radius: 5,
2724
+ selection_outline_width: 2,
2725
+ measurement_color: "#ff3a30",
2726
+ show_size_meter: true,
2727
+ snap_enabled: true,
2728
+ snap_threshold_px: 6,
2729
+ hit_tolerance_px: 0,
2730
+ snap_to_pixel_grid: false,
2731
+ pixel_grid_size: 1,
2732
+ pixel_grid: true,
2733
+ angle_snap_step_radians: Math.PI / 12
2734
+ };
2735
+ //#endregion
2736
+ //#region src/core/insertions.ts
2737
+ let insertions;
2738
+ (function(_insertions) {
2739
+ const DEFAULT_SIZE = _insertions.DEFAULT_SIZE = 100;
2740
+ const DEFAULT_FILL = _insertions.DEFAULT_FILL = "#D9D9D9";
2741
+ function initial_attrs(tag, point) {
2742
+ switch (tag) {
2743
+ case "rect": return {
2744
+ x: fmt(point.x),
2745
+ y: fmt(point.y),
2746
+ width: "0",
2747
+ height: "0"
2748
+ };
2749
+ case "ellipse": return {
2750
+ cx: fmt(point.x),
2751
+ cy: fmt(point.y),
2752
+ rx: "0",
2753
+ ry: "0"
2754
+ };
2755
+ case "line": return {
2756
+ x1: fmt(point.x),
2757
+ y1: fmt(point.y),
2758
+ x2: fmt(point.x),
2759
+ y2: fmt(point.y)
2760
+ };
2761
+ }
2762
+ }
2763
+ _insertions.initial_attrs = initial_attrs;
2764
+ function default_attrs(tag, point, size = DEFAULT_SIZE) {
2765
+ switch (tag) {
2766
+ case "rect": return {
2767
+ x: fmt(point.x - size / 2),
2768
+ y: fmt(point.y - size / 2),
2769
+ width: fmt(size),
2770
+ height: fmt(size)
2771
+ };
2772
+ case "ellipse": return {
2773
+ cx: fmt(point.x),
2774
+ cy: fmt(point.y),
2775
+ rx: fmt(size / 2),
2776
+ ry: fmt(size / 2)
2777
+ };
2778
+ case "line": return {
2779
+ x1: fmt(point.x - size / 2),
2780
+ y1: fmt(point.y),
2781
+ x2: fmt(point.x + size / 2),
2782
+ y2: fmt(point.y)
2783
+ };
2784
+ }
2785
+ }
2786
+ _insertions.default_attrs = default_attrs;
2787
+ function compute_drag_attrs(tag, anchor, current, modifiers) {
2788
+ switch (tag) {
2789
+ case "rect": return rect_attrs(anchor, current, modifiers);
2790
+ case "ellipse": return ellipse_attrs(anchor, current, modifiers);
2791
+ case "line": return line_attrs(anchor, current, modifiers);
2792
+ }
2793
+ }
2794
+ _insertions.compute_drag_attrs = compute_drag_attrs;
2795
+ function default_paint_attrs(tag) {
2796
+ switch (tag) {
2797
+ case "rect":
2798
+ case "ellipse": return { fill: DEFAULT_FILL };
2799
+ case "line": return {
2800
+ stroke: "#000000",
2801
+ "stroke-width": "1"
2802
+ };
2803
+ }
2804
+ }
2805
+ _insertions.default_paint_attrs = default_paint_attrs;
2806
+ const DEFAULT_TEXT_FONT_SIZE = _insertions.DEFAULT_TEXT_FONT_SIZE = 16;
2807
+ const DEFAULT_TEXT_FONT_FAMILY = _insertions.DEFAULT_TEXT_FONT_FAMILY = "sans-serif";
2808
+ const DEFAULT_TEXT_FILL = _insertions.DEFAULT_TEXT_FILL = "#000000";
2809
+ function default_text_attrs(point) {
2810
+ return {
2811
+ x: fmt(point.x),
2812
+ y: fmt(point.y),
2813
+ "font-size": String(DEFAULT_TEXT_FONT_SIZE),
2814
+ "font-family": DEFAULT_TEXT_FONT_FAMILY,
2815
+ fill: DEFAULT_TEXT_FILL
2816
+ };
2817
+ }
2818
+ _insertions.default_text_attrs = default_text_attrs;
2819
+ function rect_attrs(anchor, current, mods) {
2820
+ let dx = current.x - anchor.x;
2821
+ let dy = current.y - anchor.y;
2822
+ if (mods.shift) {
2823
+ const m = Math.max(Math.abs(dx), Math.abs(dy));
2824
+ dx = dx < 0 ? -m : m;
2825
+ dy = dy < 0 ? -m : m;
2826
+ }
2827
+ let x;
2828
+ let y;
2829
+ let w;
2830
+ let h;
2831
+ if (mods.alt) {
2832
+ x = anchor.x - Math.abs(dx);
2833
+ y = anchor.y - Math.abs(dy);
2834
+ w = Math.abs(dx) * 2;
2835
+ h = Math.abs(dy) * 2;
2836
+ } else {
2837
+ x = Math.min(anchor.x, anchor.x + dx);
2838
+ y = Math.min(anchor.y, anchor.y + dy);
2839
+ w = Math.abs(dx);
2840
+ h = Math.abs(dy);
2841
+ }
2842
+ return {
2843
+ x: fmt(x),
2844
+ y: fmt(y),
2845
+ width: fmt(w),
2846
+ height: fmt(h)
2847
+ };
2848
+ }
2849
+ function ellipse_attrs(anchor, current, mods) {
2850
+ let dx = current.x - anchor.x;
2851
+ let dy = current.y - anchor.y;
2852
+ if (mods.shift) {
2853
+ const m = Math.max(Math.abs(dx), Math.abs(dy));
2854
+ dx = dx < 0 ? -m : m;
2855
+ dy = dy < 0 ? -m : m;
2856
+ }
2857
+ let cx;
2858
+ let cy;
2859
+ let rx;
2860
+ let ry;
2861
+ if (mods.alt) {
2862
+ cx = anchor.x;
2863
+ cy = anchor.y;
2864
+ rx = Math.abs(dx);
2865
+ ry = Math.abs(dy);
2866
+ } else {
2867
+ cx = anchor.x + dx / 2;
2868
+ cy = anchor.y + dy / 2;
2869
+ rx = Math.abs(dx) / 2;
2870
+ ry = Math.abs(dy) / 2;
2871
+ }
2872
+ return {
2873
+ cx: fmt(cx),
2874
+ cy: fmt(cy),
2875
+ rx: fmt(rx),
2876
+ ry: fmt(ry)
2877
+ };
2878
+ }
2879
+ function line_attrs(anchor, current, mods) {
2880
+ let dx = current.x - anchor.x;
2881
+ let dy = current.y - anchor.y;
2882
+ if (mods.shift) {
2883
+ const len = Math.hypot(dx, dy);
2884
+ if (len > 0) {
2885
+ const angle = Math.atan2(dy, dx);
2886
+ const step = Math.PI / 4;
2887
+ const quantized = Math.round(angle / step) * step;
2888
+ dx = Math.cos(quantized) * len;
2889
+ dy = Math.sin(quantized) * len;
2890
+ }
2891
+ }
2892
+ let x1;
2893
+ let y1;
2894
+ let x2;
2895
+ let y2;
2896
+ if (mods.alt) {
2897
+ x1 = anchor.x - dx;
2898
+ y1 = anchor.y - dy;
2899
+ x2 = anchor.x + dx;
2900
+ y2 = anchor.y + dy;
2901
+ } else {
2902
+ x1 = anchor.x;
2903
+ y1 = anchor.y;
2904
+ x2 = anchor.x + dx;
2905
+ y2 = anchor.y + dy;
2906
+ }
2907
+ return {
2908
+ x1: fmt(x1),
2909
+ y1: fmt(y1),
2910
+ x2: fmt(x2),
2911
+ y2: fmt(y2)
2912
+ };
2913
+ }
2914
+ /** Format a numeric value for SVG attr output. Rounds to 4 decimals
2915
+ * to suppress IEEE-754 noise (`0.30000000000000004` → `0.3`);
2916
+ * `String()` drops trailing zeros and the decimal point for
2917
+ * integers. */
2918
+ function fmt(n) {
2919
+ return String(Math.round(n * 1e4) / 1e4);
2920
+ }
2921
+ })(insertions || (insertions = {}));
2922
+ //#endregion
2923
+ //#region src/core/vector-edit/model.ts
2924
+ /**
2925
+ * Canonical vector-network model for a single SVG path's `d` string.
2926
+ *
2927
+ * `PathModel` is a self-contained geometry primitive — it parses an SVG
2928
+ * path `d` into a vertex/segment graph (with verb hints preserved for
2929
+ * round-trip honesty), exposes POJO observers, and serializes back to
2930
+ * `d`. It does not hold or reference an `SvgDocument`, an editor
2931
+ * instance, the DOM, or any host. It is safe to construct in any
2932
+ * environment that can run the package.
2933
+ *
2934
+ * Public re-exported as a top-level Layer-A primitive from
2935
+ * `@grida/svg-editor` for callers that want canonical path geometry
2936
+ * without mounting an editor. The full mutation surface (translate /
2937
+ * bend / set-tangent / split, etc.) is package-internal and may shift;
2938
+ * the publicly-stable contract for external callers is the construction
2939
+ * + serialization + observation methods documented at the entry point.
2940
+ *
2941
+ * @experimental Surface shape is v0; signatures may change before the
2942
+ * package reaches semver stability.
2943
+ */
2944
+ var PathModel = class PathModel {
2945
+ constructor(network, meta) {
2946
+ if (network.segments.length !== meta.length) throw new Error(`PathModel invariant violated: segments(${network.segments.length}) !== meta(${meta.length})`);
2947
+ this._network = network;
2948
+ this._meta = meta;
2949
+ }
2950
+ static fromSvgPathD(d) {
2951
+ const { network, meta } = parseWithVerbs(d);
2952
+ return new PathModel(network, meta);
2953
+ }
2954
+ /** Construct from a vn network with no verb info (every segment defaults to undefined verb). */
2955
+ static fromVectorNetwork(network) {
2956
+ const meta = network.segments.map(() => ({}));
2957
+ return new PathModel(cloneNetwork(network), meta);
2958
+ }
2959
+ toSvgPathD() {
2960
+ return emitWithVerbs(this._network, this._meta);
2961
+ }
2962
+ snapshot() {
2963
+ return {
2964
+ vertices: this._network.vertices,
2965
+ segments: this._network.segments.map((seg, i) => ({
2966
+ a: seg.a,
2967
+ b: seg.b,
2968
+ ta: seg.ta,
2969
+ tb: seg.tb,
2970
+ source_verb: this._meta[i]?.source_verb
2971
+ }))
2972
+ };
2973
+ }
2974
+ bbox() {
2975
+ return new vn.VectorNetworkEditor(this._network).getBBox();
2976
+ }
2977
+ vertexCount() {
2978
+ return this._network.vertices.length;
2979
+ }
2980
+ segmentCount() {
2981
+ return this._network.segments.length;
2982
+ }
2983
+ /**
2984
+ * If the model's current geometry is still expressible in the source
2985
+ * SVG tag's native attribute form, return the equivalent
2986
+ * `VectorEditSource` (which is also the writeable shape) — else `null`.
2987
+ *
2988
+ * This is the decider that gates per-gesture native-attrs writeback in
2989
+ * `VectorEditSession.apply_d`. `null` means "the user's edit cannot be
2990
+ * faithfully written back to the source tag" — in v1 with no
2991
+ * promotion, the gesture is refused; in v1.1+ with promotion, the
2992
+ * element is rewritten to `<path d="…">`.
2993
+ *
2994
+ * v1 expressibility (all source kinds require every segment's `ta` and
2995
+ * `tb` to be exactly zero — any tangent edit forces promotion):
2996
+ *
2997
+ * - **path** — always `null` (no native fallback; the canonical form
2998
+ * IS `<path d>`, so callers should just write `d` directly).
2999
+ * - **line** — exactly two vertices joined by one straight segment
3000
+ * `0→1`. (Topology after a 2-point `vn.fromPolyline` and any sequence
3001
+ * of endpoint translates.)
3002
+ * - **polyline** — segments form the canonical open chain
3003
+ * `0→1, 1→2, …, (n-2)→(n-1)`. (Topology after `vn.fromPolyline` and
3004
+ * any sequence of vertex translates.)
3005
+ * - **polygon** — segments form the canonical closed chain
3006
+ * `0→1, 1→2, …, (n-1)→0`. (Topology after `vn.fromPolygon` and any
3007
+ * sequence of vertex translates.)
3008
+ * - **rect / circle / ellipse** — always `null`. These geometry
3009
+ * primitives have no native writeback target; any vector gesture on
3010
+ * them re-types the element to `<path>` (see `vector_apply` /
3011
+ * `SvgDocument.retype_to_path`), so they never round-trip through here.
3012
+ *
3013
+ * Anything that changes segment topology (insert-vertex, delete-vertex,
3014
+ * close/open shape) or introduces a curve leaves the canonical chain and
3015
+ * returns `null` here; the caller re-types the element to `<path>`.
3016
+ */
3017
+ toNativeAttrs(source_tag) {
3018
+ if (source_tag !== "line" && source_tag !== "polyline" && source_tag !== "polygon") return null;
3019
+ const { vertices, segments } = this._network;
3020
+ for (const s of segments) {
3021
+ if (s.ta[0] !== 0 || s.ta[1] !== 0) return null;
3022
+ if (s.tb[0] !== 0 || s.tb[1] !== 0) return null;
3023
+ }
3024
+ const n = vertices.length;
3025
+ if (source_tag === "line") {
3026
+ if (n !== 2 || segments.length !== 1) return null;
3027
+ const s = segments[0];
3028
+ if (s.a !== 0 || s.b !== 1) return null;
3029
+ const [x1, y1] = vertices[0];
3030
+ const [x2, y2] = vertices[1];
3031
+ return {
3032
+ kind: "line",
3033
+ x1,
3034
+ y1,
3035
+ x2,
3036
+ y2
3037
+ };
3038
+ }
3039
+ if (source_tag === "polyline") {
3040
+ if (segments.length !== n - 1 || n < 2) return null;
3041
+ for (let i = 0; i < segments.length; i++) {
3042
+ const s = segments[i];
3043
+ if (s.a !== i || s.b !== i + 1) return null;
3044
+ }
3045
+ return {
3046
+ kind: "polyline",
3047
+ points: vertices.map((v) => [v[0], v[1]])
3048
+ };
3049
+ }
3050
+ if (source_tag === "polygon") {
3051
+ if (segments.length !== n || n < 2) return null;
3052
+ for (let i = 0; i < segments.length - 1; i++) {
3053
+ const s = segments[i];
3054
+ if (s.a !== i || s.b !== i + 1) return null;
3055
+ }
3056
+ const closer = segments[segments.length - 1];
3057
+ if (closer.a !== n - 1 || closer.b !== 0) return null;
3058
+ return {
3059
+ kind: "polygon",
3060
+ points: vertices.map((v) => [v[0], v[1]])
3061
+ };
3062
+ }
3063
+ return null;
3064
+ }
3065
+ /** Translate one vertex by `delta`. Connected segments follow because
3066
+ * tangents are stored relative to vertices. Verb metadata is preserved
3067
+ * as-is; emit-time honesty handles cases where the shape no longer
3068
+ * matches the recorded verb (e.g. an H whose endpoint y-coord drifts). */
3069
+ translateVertex(v, delta) {
3070
+ if (v < 0 || v >= this._network.vertices.length) throw new Error(`PathModel.translateVertex: invalid vertex ${v}`);
3071
+ const next_network = cloneNetwork(this._network);
3072
+ const vne = new vn.VectorNetworkEditor(next_network);
3073
+ vne.translateVertex(v, [delta[0], delta[1]]);
3074
+ return new PathModel(vne.value, this._meta);
3075
+ }
3076
+ /** Bulk-translate a set of vertices by the same delta. Atomic — either
3077
+ * every move succeeds or none (input is validated up-front). */
3078
+ translateVertices(indices, delta) {
3079
+ if (indices.length === 0) return this;
3080
+ for (const v of indices) if (v < 0 || v >= this._network.vertices.length) throw new Error(`PathModel.translateVertices: invalid vertex ${v}`);
3081
+ const next_network = cloneNetwork(this._network);
3082
+ const vne = new vn.VectorNetworkEditor(next_network);
3083
+ for (const v of indices) vne.translateVertex(v, [delta[0], delta[1]]);
3084
+ return new PathModel(vne.value, this._meta);
3085
+ }
3086
+ /** Translate one segment by `delta` — moves both endpoints, dragging
3087
+ * their tangents along (tangents are stored relative to vertices, so
3088
+ * this is automatic). Other segments connected to the moved endpoints
3089
+ * also follow at the shared vertex. */
3090
+ translateSegment(seg, delta) {
3091
+ if (seg < 0 || seg >= this._network.segments.length) throw new Error(`PathModel.translateSegment: invalid segment ${seg}`);
3092
+ const s = this._network.segments[seg];
3093
+ const unique = s.a === s.b ? [s.a] : [s.a, s.b];
3094
+ return this.translateVertices(unique, delta);
3095
+ }
3096
+ /**
3097
+ * Bend a curve segment by dragging a point at parameter `ca` to `cb`
3098
+ * (cb is in absolute doc-space). Delegates to vn's `bendSegment` —
3099
+ * which solves for the new ta/tb that put `B(ca) === cb`, holding the
3100
+ * endpoints fixed.
3101
+ *
3102
+ * The "frozen" snapshot of the segment at gesture start is the caller's
3103
+ * responsibility. Convention: call this from a preview session where
3104
+ * each frame replays from the baseline (same pattern as translate).
3105
+ */
3106
+ bendSegment(seg, ca, cb, frozen) {
3107
+ if (seg < 0 || seg >= this._network.segments.length) throw new Error(`PathModel.bendSegment: invalid segment ${seg}`);
3108
+ const next_network = cloneNetwork(this._network);
3109
+ const vne = new vn.VectorNetworkEditor(next_network);
3110
+ vne.bendSegment(seg, ca, [cb[0], cb[1]], {
3111
+ a: [frozen.a[0], frozen.a[1]],
3112
+ b: [frozen.b[0], frozen.b[1]],
3113
+ ta: [frozen.ta[0], frozen.ta[1]],
3114
+ tb: [frozen.tb[0], frozen.tb[1]]
3115
+ });
3116
+ return new PathModel(vne.value, this._meta);
3117
+ }
3118
+ /**
3119
+ * Move one tangent control point to a new absolute position. Mirror
3120
+ * policy follows vn's `updateTangent`. The other tangent at the same
3121
+ * vertex is updated according to the policy.
3122
+ *
3123
+ * Returns a new PathModel; verb metadata is preserved verbatim.
3124
+ * `toSvgPathD` will demote (e.g. L → C) if the new tangents make the
3125
+ * recorded verb no longer match the geometry.
3126
+ */
3127
+ setTangent(t, abs_pos, mirror = "auto") {
3128
+ const located = this._locateTangent(t);
3129
+ if (!located) throw new Error(`PathModel.setTangent: no segment found for tangent [${t[0]}, ${t[1]}]`);
3130
+ const { seg_index, control } = located;
3131
+ const seg = this._network.segments[seg_index];
3132
+ const anchor_idx = control === "ta" ? seg.a : seg.b;
3133
+ const anchor = this._network.vertices[anchor_idx];
3134
+ const value = [abs_pos[0] - anchor[0], abs_pos[1] - anchor[1]];
3135
+ const next_network = cloneNetwork(this._network);
3136
+ const vne = new vn.VectorNetworkEditor(next_network);
3137
+ vne.updateTangent(seg_index, control, value, mirror);
3138
+ return new PathModel(vne.value, this._meta);
3139
+ }
3140
+ /**
3141
+ * Split segment `seg` at parametric position `t ∈ [0,1]`, inserting a
3142
+ * new vertex. Returns the new model and the **canonical (path-order)**
3143
+ * index of the inserted vertex.
3144
+ *
3145
+ * Verb metadata for the split: the original segment's verb propagates
3146
+ * to BOTH halves if it was a curve type (`C`/`S`/`Q`/`T`/`A`); for
3147
+ * straight verbs (`L`/`H`/`V`), the split halves stay straight (their
3148
+ * tangents are zero from vn's `preserveZero` path when both originals
3149
+ * were zero). Arc-group identity is dropped from the halves — the
3150
+ * arc is broken once split (the emitter will fall back to `C`/`L`).
3151
+ *
3152
+ * **Index space contract.** `VectorNetworkEditor.splitSegment` APPENDS
3153
+ * the new vertex at the end of the network's vertices array — its
3154
+ * index is the in-memory insertion order. But `toSvgPathD` / `fromSvgPathD`
3155
+ * canonicalize vertices in path order, so the same vertex gets a
3156
+ * DIFFERENT index in the d-derived model that consumers re-parse each
3157
+ * frame (e.g., the host's `handle_translate_vertices`). Returning the
3158
+ * insertion-order index causes the classic split-and-drag bug: the
3159
+ * surface holds index N (insertion-order) but the live model has
3160
+ * index M (path-order) at that position — drag moves the wrong vertex
3161
+ * and the user sees "split happened but the new vertex doesn't move".
3162
+ *
3163
+ * To prevent that, we round-trip the post-split model through
3164
+ * `toSvgPathD` → `fromSvgPathD` and return the canonical (path-order)
3165
+ * index of the new vertex. The returned `model` is the canonical
3166
+ * one, so any subsequent op on it uses the same index space the d
3167
+ * roundtrip exposes. See `__tests__/README.md` §"index identity
3168
+ * across the `d` round-trip" for the test pattern that pins this.
3169
+ */
3170
+ splitSegment(seg, t) {
3171
+ if (seg < 0 || seg >= this._network.segments.length) throw new Error(`PathModel.splitSegment: invalid segment ${seg}`);
3172
+ const next_network = cloneNetwork(this._network);
3173
+ const vne = new vn.VectorNetworkEditor(next_network);
3174
+ const in_memory_new_vertex = vne.splitSegment({
3175
+ segment: seg,
3176
+ t
3177
+ });
3178
+ const orig = this._meta[seg];
3179
+ const half = { source_verb: orig?.source_verb };
3180
+ const half_first = { ...half };
3181
+ const half_second = {
3182
+ ...half,
3183
+ is_close_segment: orig?.is_close_segment
3184
+ };
3185
+ const next_meta = [
3186
+ ...this._meta.slice(0, seg),
3187
+ half_first,
3188
+ half_second,
3189
+ ...this._meta.slice(seg + 1)
3190
+ ];
3191
+ const new_vertex_pos = vne.value.vertices[in_memory_new_vertex];
3192
+ const target_d = new PathModel(vne.value, next_meta).toSvgPathD();
3193
+ const canonical_model = PathModel.fromSvgPathD(target_d);
3194
+ const canonical_vertices = canonical_model._network.vertices;
3195
+ let canonical_new_vertex = -1;
3196
+ for (let i = 0; i < canonical_vertices.length; i++) {
3197
+ const v = canonical_vertices[i];
3198
+ if (Math.abs(v[0] - new_vertex_pos[0]) < 1e-9 && Math.abs(v[1] - new_vertex_pos[1]) < 1e-9) {
3199
+ canonical_new_vertex = i;
3200
+ break;
3201
+ }
3202
+ }
3203
+ if (canonical_new_vertex < 0) canonical_new_vertex = in_memory_new_vertex;
3204
+ return {
3205
+ model: canonical_model,
3206
+ new_vertex: canonical_new_vertex
3207
+ };
3208
+ }
3209
+ /**
3210
+ * Doc-space position of a tangent control point. `t` references a
3211
+ * segment and which end (`a` or `b`) the tangent belongs to; the
3212
+ * result is `vertex + tangent_value + origin`. Returns null if no
3213
+ * segment has this tangent (e.g. the vertex is isolated).
3214
+ */
3215
+ tangentAbsolute(t, origin) {
3216
+ const located = this._locateTangent(t);
3217
+ if (!located) return null;
3218
+ const { seg_index, control } = located;
3219
+ const seg = this._network.segments[seg_index];
3220
+ const anchor_idx = control === "ta" ? seg.a : seg.b;
3221
+ const anchor = this._network.vertices[anchor_idx];
3222
+ const value = control === "ta" ? seg.ta : seg.tb;
3223
+ return [anchor[0] + value[0] + origin[0], anchor[1] + value[1] + origin[1]];
3224
+ }
3225
+ /**
3226
+ * Vertices "neighbouring" the current selection — these are the
3227
+ * vertices whose tangent handles should render in chrome.
3228
+ *
3229
+ * Two-phase, mirrors `editor/grida-canvas/reducers/methods/vector.ts`
3230
+ * `getUXNeighbouringVertices`:
3231
+ *
3232
+ * 1. Collect "active" vertices:
3233
+ * - every selected vertex
3234
+ * - every tangent-owning vertex
3235
+ * - both endpoints of every selected segment
3236
+ * 2. Expand uniformly to 1-hop neighbours (vertices sharing a segment
3237
+ * with any active vertex).
3238
+ *
3239
+ * Without phase 2 for tangent / segment selections, selecting only a
3240
+ * tangent would hide neighbouring-vertex tangents — the user loses
3241
+ * spatial context. Phase 2 makes the affordance symmetric: whatever
3242
+ * triggered selection, the 1-hop ring of tangent handles is visible.
3243
+ *
3244
+ * Sorted ascending; deduped.
3245
+ */
3246
+ neighbouringVertices(sel) {
3247
+ const { vertices, segments } = this._network;
3248
+ const active = /* @__PURE__ */ new Set();
3249
+ const add_if_valid = (v) => {
3250
+ if (v >= 0 && v < vertices.length) active.add(v);
3251
+ };
3252
+ for (const v of sel.vertices) add_if_valid(v);
3253
+ for (const seg_idx of sel.segments) {
3254
+ if (seg_idx < 0 || seg_idx >= segments.length) continue;
3255
+ const s = segments[seg_idx];
3256
+ add_if_valid(s.a);
3257
+ add_if_valid(s.b);
3258
+ }
3259
+ for (const t of sel.tangents) {
3260
+ const located = this._locateTangent(t);
3261
+ if (!located) continue;
3262
+ add_if_valid(t[0]);
3263
+ const s = segments[located.seg_index];
3264
+ add_if_valid(s.a);
3265
+ add_if_valid(s.b);
3266
+ }
3267
+ const out = new Set(active);
3268
+ if (active.size > 0) {
3269
+ const vne = new vn.VectorNetworkEditor(this._network);
3270
+ for (const v of active) for (const n of vne.getNeighboringVerticies(v)) if (n >= 0 && n < vertices.length) out.add(n);
3271
+ }
3272
+ return Array.from(out).sort((x, y) => x - y);
3273
+ }
3274
+ /**
3275
+ * True iff segment `seg`'s curve is entirely contained in the rect.
3276
+ * Delegates to `cmath.bezier.containedByRect`.
3277
+ */
3278
+ segmentContainedByRect(seg, rect, origin = [0, 0]) {
3279
+ if (seg < 0 || seg >= this._network.segments.length) return false;
3280
+ const s = this._network.segments[seg];
3281
+ const a = this._network.vertices[s.a];
3282
+ const b = this._network.vertices[s.b];
3283
+ const local_rect = {
3284
+ x: rect.x - origin[0],
3285
+ y: rect.y - origin[1],
3286
+ width: rect.width,
3287
+ height: rect.height
3288
+ };
3289
+ return cmath.bezier.containedByRect([a[0], a[1]], [b[0], b[1]], [s.ta[0], s.ta[1]], [s.tb[0], s.tb[1]], local_rect);
3290
+ }
3291
+ /** @internal */
3292
+ _rawNetwork() {
3293
+ return this._network;
3294
+ }
3295
+ /** @internal */
3296
+ _rawMeta() {
3297
+ return this._meta;
3298
+ }
3299
+ /**
3300
+ * Map a `TangentRef` to a concrete `(segment_index, control)` pair.
3301
+ *
3302
+ * `[v, 0]` → first segment whose `a === v` (its `ta`).
3303
+ * `[v, 1]` → first segment whose `b === v` (its `tb`).
3304
+ *
3305
+ * Y-junctions (multi-outgoing or multi-incoming) are uncommon for SVG
3306
+ * `<path>` content; v1 picks the first match. If we ever support those
3307
+ * cleanly, extend `TangentRef` to carry the segment id explicitly.
3308
+ */
3309
+ _locateTangent(t) {
3310
+ const [vertex_idx, end] = t;
3311
+ const segs = this._network.segments;
3312
+ for (let i = 0; i < segs.length; i++) {
3313
+ const s = segs[i];
3314
+ if (end === 0 && s.a === vertex_idx) return {
3315
+ seg_index: i,
3316
+ control: "ta"
3317
+ };
3318
+ if (end === 1 && s.b === vertex_idx) return {
3319
+ seg_index: i,
3320
+ control: "tb"
3321
+ };
3322
+ }
3323
+ return null;
3324
+ }
3325
+ };
3326
+ function cloneNetwork(net) {
3327
+ return {
3328
+ vertices: net.vertices.map((v) => [v[0], v[1]]),
3329
+ segments: net.segments.map((s) => ({
3330
+ a: s.a,
3331
+ b: s.b,
3332
+ ta: [s.ta[0], s.ta[1]],
3333
+ tb: [s.tb[0], s.tb[1]]
3334
+ }))
3335
+ };
3336
+ }
3337
+ /**
3338
+ * Walks the SVG path commands once, building the vn network AND a parallel
3339
+ * `meta` array. Mirrors the structure of `vn.fromSVGPathData` but tags each
3340
+ * emitted segment with its originating verb. The two stay logically in lock-
3341
+ * step: every segment vn would push, we push one meta entry for.
3342
+ */
3343
+ function parseWithVerbs(d) {
3344
+ const commands = new SVGPathData(d).toAbs().commands;
3345
+ const vne = new vn.VectorNetworkEditor();
3346
+ const meta = [];
3347
+ let last_point = null;
3348
+ let last_quadratic_control = null;
3349
+ /** Vertex index of the current point. Tracked explicitly because
3350
+ * `vne.addVertex` reuses an existing vertex when the coordinate matches,
3351
+ * so `vne.vertices.length - 1` is NOT reliable for "current vertex." */
3352
+ let current_idx = -1;
3353
+ /** Vertex index of the current subpath's start (set on M). */
3354
+ let subpath_start_idx = -1;
3355
+ /** Monotonic counter for arc groups. */
3356
+ let arc_group_seq = 0;
3357
+ const pushSegmentMeta = (entry) => {
3358
+ meta.push(entry);
3359
+ };
3360
+ for (const command of commands) {
3361
+ const { type } = command;
3362
+ switch (type) {
3363
+ case SVGPathData.MOVE_TO: {
3364
+ const { x, y } = command;
3365
+ current_idx = vne.addVertex([x, y]);
3366
+ subpath_start_idx = current_idx;
3367
+ last_point = [x, y];
3368
+ last_quadratic_control = null;
3369
+ break;
3370
+ }
3371
+ case SVGPathData.LINE_TO: {
3372
+ const { x, y } = command;
3373
+ if (last_point) {
3374
+ current_idx = vne.addVertex([x, y], current_idx);
3375
+ pushSegmentMeta({ source_verb: "L" });
3376
+ }
3377
+ last_point = [x, y];
3378
+ last_quadratic_control = null;
3379
+ break;
3380
+ }
3381
+ case SVGPathData.HORIZ_LINE_TO: {
3382
+ const { x } = command;
3383
+ if (last_point) {
3384
+ current_idx = vne.addVertex([x, last_point[1]], current_idx);
3385
+ pushSegmentMeta({ source_verb: "H" });
3386
+ }
3387
+ last_point = [x, last_point ? last_point[1] : 0];
3388
+ last_quadratic_control = null;
3389
+ break;
3390
+ }
3391
+ case SVGPathData.VERT_LINE_TO: {
3392
+ const { y } = command;
3393
+ if (last_point) {
3394
+ current_idx = vne.addVertex([last_point[0], y], current_idx);
3395
+ pushSegmentMeta({ source_verb: "V" });
3396
+ }
3397
+ last_point = [last_point ? last_point[0] : 0, y];
3398
+ last_quadratic_control = null;
3399
+ break;
3400
+ }
3401
+ case SVGPathData.CURVE_TO: {
3402
+ const { x, y } = command;
3403
+ if (last_point) {
3404
+ const ta = [command.x1 - last_point[0], command.y1 - last_point[1]];
3405
+ const tb = [command.x2 - x, command.y2 - y];
3406
+ current_idx = vne.addVertex([x, y], current_idx, ta, tb);
3407
+ pushSegmentMeta({ source_verb: "C" });
3408
+ }
3409
+ last_point = [x, y];
3410
+ last_quadratic_control = null;
3411
+ break;
3412
+ }
3413
+ case SVGPathData.SMOOTH_CURVE_TO: {
3414
+ const { x, y, x2, y2 } = command;
3415
+ if (last_point) {
3416
+ const ta = vne.getNextMirroredTangent(current_idx);
3417
+ const tb = [x2 - x, y2 - y];
3418
+ current_idx = vne.addVertex([x, y], current_idx, ta, tb);
3419
+ pushSegmentMeta({ source_verb: "S" });
3420
+ }
3421
+ last_point = [x, y];
3422
+ last_quadratic_control = null;
3423
+ break;
3424
+ }
3425
+ case SVGPathData.QUAD_TO:
3426
+ if (last_point) {
3427
+ const control = [command.x1, command.y1];
3428
+ const end = [command.x, command.y];
3429
+ const ta = [2 / 3 * (control[0] - last_point[0]), 2 / 3 * (control[1] - last_point[1])];
3430
+ const tb = [2 / 3 * (control[0] - end[0]), 2 / 3 * (control[1] - end[1])];
3431
+ current_idx = vne.addVertex(end, current_idx, ta, tb);
3432
+ pushSegmentMeta({ source_verb: "Q" });
3433
+ last_point = end;
3434
+ last_quadratic_control = control;
3435
+ }
3436
+ break;
3437
+ case SVGPathData.SMOOTH_QUAD_TO:
3438
+ if (last_point) {
3439
+ const end = [command.x, command.y];
3440
+ const control = last_quadratic_control ? [2 * last_point[0] - last_quadratic_control[0], 2 * last_point[1] - last_quadratic_control[1]] : [last_point[0], last_point[1]];
3441
+ const ta = [2 / 3 * (control[0] - last_point[0]), 2 / 3 * (control[1] - last_point[1])];
3442
+ const tb = [2 / 3 * (control[0] - end[0]), 2 / 3 * (control[1] - end[1])];
3443
+ current_idx = vne.addVertex(end, current_idx, ta, tb);
3444
+ pushSegmentMeta({ source_verb: "T" });
3445
+ last_point = end;
3446
+ last_quadratic_control = control;
3447
+ }
3448
+ break;
3449
+ case SVGPathData.ARC: {
3450
+ const { rX, rY, xRot, lArcFlag, sweepFlag, x, y } = command;
3451
+ if (last_point) {
3452
+ const [x1, y1] = last_point;
3453
+ const curves = cmath.bezier.a2c(x1, y1, rX, rY, xRot, lArcFlag, sweepFlag, x, y);
3454
+ const seg_count = curves.length / 6;
3455
+ const group_id = ++arc_group_seq;
3456
+ let current_point = last_point;
3457
+ let seq = 0;
3458
+ for (let i = 0; i < curves.length; i += 6) {
3459
+ const [cx1, cy1, cx2, cy2, ex, ey] = curves.slice(i, i + 6);
3460
+ const end_point = [ex, ey];
3461
+ const ta = [cx1 - current_point[0], cy1 - current_point[1]];
3462
+ const tb = [cx2 - end_point[0], cy2 - end_point[1]];
3463
+ current_idx = vne.addVertex(end_point, current_idx, ta, tb);
3464
+ const is_last = seq === seg_count - 1;
3465
+ pushSegmentMeta({
3466
+ source_verb: "A",
3467
+ arc: {
3468
+ group_id,
3469
+ rx: rX,
3470
+ ry: rY,
3471
+ x_rot: xRot,
3472
+ large_arc_flag: lArcFlag,
3473
+ sweep_flag: sweepFlag,
3474
+ baseline_ta: [ta[0], ta[1]],
3475
+ baseline_tb: [tb[0], tb[1]],
3476
+ baseline_b_abs: [end_point[0], end_point[1]],
3477
+ seq,
3478
+ count: seg_count,
3479
+ original_end: is_last ? [x, y] : void 0
3480
+ }
3481
+ });
3482
+ current_point = end_point;
3483
+ seq++;
3484
+ }
3485
+ last_point = current_point;
3486
+ }
3487
+ last_quadratic_control = null;
3488
+ break;
3489
+ }
3490
+ case SVGPathData.CLOSE_PATH:
3491
+ if (current_idx !== -1 && subpath_start_idx !== -1 && current_idx !== subpath_start_idx) {
3492
+ vne.addSegment(current_idx, subpath_start_idx);
3493
+ pushSegmentMeta({
3494
+ source_verb: "Z",
3495
+ is_close_segment: true
3496
+ });
3497
+ current_idx = subpath_start_idx;
3498
+ last_point = vne.vertices[subpath_start_idx];
3499
+ } else if (current_idx !== -1 && current_idx === subpath_start_idx && meta.length > 0) {
3500
+ const last_seg = vne.segments[vne.segments.length - 1];
3501
+ if (last_seg && last_seg.b === subpath_start_idx) meta[meta.length - 1] = {
3502
+ ...meta[meta.length - 1],
3503
+ is_close_segment: true
3504
+ };
3505
+ }
3506
+ last_quadratic_control = null;
3507
+ break;
3508
+ default: throw new Error(`Unsupported path command type: ${type}`);
3509
+ }
3510
+ }
3511
+ return {
3512
+ network: vne.value,
3513
+ meta
3514
+ };
3515
+ }
3516
+ const EPSILON = 1e-9;
3517
+ function approxEqual(a, b, eps = EPSILON) {
3518
+ return Math.abs(a - b) <= eps;
3519
+ }
3520
+ function vec2Equal(a, b, eps = EPSILON) {
3521
+ return approxEqual(a[0], b[0], eps) && approxEqual(a[1], b[1], eps);
3522
+ }
3523
+ function isZeroTangent(t) {
3524
+ return approxEqual(t[0], 0) && approxEqual(t[1], 0);
3525
+ }
3526
+ /**
3527
+ * Returns true iff the segment's tangents are consistent with a single
3528
+ * quadratic Bézier control point at ratio 2/3 (i.e. the segment was emitted
3529
+ * from a `Q` or `T` command and has not been edited).
3530
+ *
3531
+ * For a quadratic with start=A, end=B, control=C:
3532
+ * ta = 2/3 * (C - A) => C = A + (3/2) * ta
3533
+ * tb = 2/3 * (C - B) => C = B + (3/2) * tb
3534
+ * Both must yield the same C.
3535
+ */
3536
+ function tangentsRepresentQuadratic(a, b, ta, tb) {
3537
+ const c_from_a = [a[0] + 1.5 * ta[0], a[1] + 1.5 * ta[1]];
3538
+ if (vec2Equal(c_from_a, [b[0] + 1.5 * tb[0], b[1] + 1.5 * tb[1]], 1e-6)) return {
3539
+ ok: true,
3540
+ control: c_from_a
3541
+ };
3542
+ return { ok: false };
3543
+ }
3544
+ /**
3545
+ * Returns true iff `ta` mirrors the previous segment's `tb` (i.e. the
3546
+ * vertex is a smooth join — what `S`/`T` commands require).
3547
+ *
3548
+ * For `ta` at vertex V (segment a=V) to mirror previous segment's `tb`
3549
+ * (whose b=V), we need: ta == -prev.tb (both expressed relative to V).
3550
+ */
3551
+ function isSmoothJoin(prev_tb, curr_ta) {
3552
+ return vec2Equal([curr_ta[0], curr_ta[1]], [-prev_tb[0], -prev_tb[1]], 1e-6);
3553
+ }
3554
+ /**
3555
+ * Determines whether an arc-group's segments are byte-equal to their parse-
3556
+ * time baselines. If any segment in the group has been edited (vertex moved,
3557
+ * tangent changed), the entire arc must be promoted to C.
3558
+ */
3559
+ function isArcGroupUnchanged(segments, meta, vertices, group_id) {
3560
+ for (let i = 0; i < segments.length; i++) {
3561
+ const m = meta[i];
3562
+ if (m?.arc?.group_id !== group_id) continue;
3563
+ const seg = segments[i];
3564
+ if (!vec2Equal(seg.ta, m.arc.baseline_ta)) return false;
3565
+ if (!vec2Equal(seg.tb, m.arc.baseline_tb)) return false;
3566
+ if (!vec2Equal(vertices[seg.b], m.arc.baseline_b_abs)) return false;
3567
+ }
3568
+ return true;
3569
+ }
3570
+ function emitWithVerbs(network, meta) {
3571
+ const { vertices, segments } = network;
3572
+ if (segments.length === 0) return "";
3573
+ const commands = [];
3574
+ const arc_unchanged = /* @__PURE__ */ new Map();
3575
+ const arcStillValid = (group_id) => {
3576
+ const cached = arc_unchanged.get(group_id);
3577
+ if (cached !== void 0) return cached;
3578
+ const ok = isArcGroupUnchanged(segments, meta, vertices, group_id);
3579
+ arc_unchanged.set(group_id, ok);
3580
+ return ok;
3581
+ };
3582
+ let current_start = null;
3583
+ let previous_end = null;
3584
+ let prev_segment_tb = null;
3585
+ /** When true, the previous segment was emitted as a quadratic (Q or T) and `prev_quad_control` is valid. */
3586
+ let prev_quad_control = null;
3587
+ /** Skip the next K iterations because we already emitted an arc for them. */
3588
+ let skip_to_index = -1;
3589
+ for (let i = 0; i < segments.length; i++) {
3590
+ if (i < skip_to_index) continue;
3591
+ const segment = segments[i];
3592
+ const m = meta[i] ?? {};
3593
+ const { a, b, ta, tb } = segment;
3594
+ const start = vertices[a];
3595
+ const end = vertices[b];
3596
+ if (previous_end !== a) {
3597
+ commands.push({
3598
+ type: SVGPathData.MOVE_TO,
3599
+ x: start[0],
3600
+ y: start[1],
3601
+ relative: false
3602
+ });
3603
+ current_start = a;
3604
+ prev_segment_tb = null;
3605
+ prev_quad_control = null;
3606
+ }
3607
+ const is_straight = isZeroTangent(ta) && isZeroTangent(tb);
3608
+ const is_closing = m.is_close_segment === true && current_start !== null && b === current_start;
3609
+ if (m.arc && m.source_verb === "A" && arcStillValid(m.arc.group_id)) {
3610
+ let last_idx = i;
3611
+ while (last_idx + 1 < segments.length && meta[last_idx + 1]?.arc?.group_id === m.arc.group_id) last_idx++;
3612
+ const last_seg = segments[last_idx];
3613
+ const last_end = meta[last_idx]?.arc?.original_end ?? vertices[last_seg.b];
3614
+ commands.push({
3615
+ type: SVGPathData.ARC,
3616
+ rX: m.arc.rx,
3617
+ rY: m.arc.ry,
3618
+ xRot: m.arc.x_rot,
3619
+ lArcFlag: m.arc.large_arc_flag,
3620
+ sweepFlag: m.arc.sweep_flag,
3621
+ x: last_end[0],
3622
+ y: last_end[1],
3623
+ relative: false
3624
+ });
3625
+ previous_end = last_seg.b;
3626
+ prev_segment_tb = last_seg.tb;
3627
+ prev_quad_control = null;
3628
+ skip_to_index = last_idx + 1;
3629
+ continue;
3630
+ }
3631
+ if (is_closing && is_straight) {
3632
+ commands.push({ type: SVGPathData.CLOSE_PATH });
3633
+ previous_end = null;
3634
+ current_start = null;
3635
+ prev_segment_tb = null;
3636
+ prev_quad_control = null;
3637
+ continue;
3638
+ }
3639
+ if (is_straight) {
3640
+ if (m.source_verb === "H" && approxEqual(end[1], start[1])) commands.push({
3641
+ type: SVGPathData.HORIZ_LINE_TO,
3642
+ x: end[0],
3643
+ relative: false
3644
+ });
3645
+ else if (m.source_verb === "V" && approxEqual(end[0], start[0])) commands.push({
3646
+ type: SVGPathData.VERT_LINE_TO,
3647
+ y: end[1],
3648
+ relative: false
3649
+ });
3650
+ else commands.push({
3651
+ type: SVGPathData.LINE_TO,
3652
+ x: end[0],
3653
+ y: end[1],
3654
+ relative: false
3655
+ });
3656
+ previous_end = b;
3657
+ prev_segment_tb = tb;
3658
+ prev_quad_control = null;
3659
+ if (current_start !== null && b === current_start) {}
3660
+ continue;
3661
+ }
3662
+ let emitted = false;
3663
+ if (!emitted && (m.source_verb === "Q" || m.source_verb === "T")) {
3664
+ const quad = tangentsRepresentQuadratic(start, end, ta, tb);
3665
+ if (quad.ok) {
3666
+ if (m.source_verb === "T" && prev_quad_control !== null && vec2Equal([start[0] - prev_quad_control[0], start[1] - prev_quad_control[1]], [quad.control[0] - start[0], quad.control[1] - start[1]], 1e-6)) commands.push({
3667
+ type: SVGPathData.SMOOTH_QUAD_TO,
3668
+ x: end[0],
3669
+ y: end[1],
3670
+ relative: false
3671
+ });
3672
+ else commands.push({
3673
+ type: SVGPathData.QUAD_TO,
3674
+ x1: quad.control[0],
3675
+ y1: quad.control[1],
3676
+ x: end[0],
3677
+ y: end[1],
3678
+ relative: false
3679
+ });
3680
+ previous_end = b;
3681
+ prev_segment_tb = tb;
3682
+ prev_quad_control = quad.control;
3683
+ emitted = true;
3684
+ }
3685
+ }
3686
+ if (!emitted && m.source_verb === "S" && prev_segment_tb !== null && isSmoothJoin(prev_segment_tb, ta)) {
3687
+ const c2 = [end[0] + tb[0], end[1] + tb[1]];
3688
+ commands.push({
3689
+ type: SVGPathData.SMOOTH_CURVE_TO,
3690
+ x2: c2[0],
3691
+ y2: c2[1],
3692
+ x: end[0],
3693
+ y: end[1],
3694
+ relative: false
3695
+ });
3696
+ previous_end = b;
3697
+ prev_segment_tb = tb;
3698
+ prev_quad_control = null;
3699
+ emitted = true;
3700
+ }
3701
+ if (!emitted) {
3702
+ const c1 = [start[0] + ta[0], start[1] + ta[1]];
3703
+ const c2 = [end[0] + tb[0], end[1] + tb[1]];
3704
+ commands.push({
3705
+ type: SVGPathData.CURVE_TO,
3706
+ x1: c1[0],
3707
+ y1: c1[1],
3708
+ x2: c2[0],
3709
+ y2: c2[1],
3710
+ x: end[0],
3711
+ y: end[1],
3712
+ relative: false
3713
+ });
3714
+ previous_end = b;
3715
+ prev_segment_tb = tb;
3716
+ prev_quad_control = null;
3717
+ }
3718
+ if (is_closing && !is_straight) {
3719
+ commands.push({ type: SVGPathData.CLOSE_PATH });
3720
+ previous_end = null;
3721
+ current_start = null;
3722
+ prev_segment_tb = null;
3723
+ prev_quad_control = null;
3724
+ }
3725
+ }
3726
+ return encodeSVGPath(commands);
3727
+ }
3728
+ //#endregion
3729
+ export { is_text_input_focused as _, paint as a, hit_shape_svg as c, NudgeDwellWatcher as d, TranslateOrchestrator as f, array_shallow_equal as g, group as h, TOOL_CURSOR as i, RotateOrchestrator as l, transform as m, insertions as n, ResizeOrchestrator as o, translate_pipeline as p, DEFAULT_STYLE as r, resize_pipeline as s, PathModel as t, rotate_pipeline as u };