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

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