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

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