@grida/hud 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,38 @@
1
+ import { i as cursorToCss, r as cursorEquals } from "./cursor-BieMVb71.mjs";
1
2
  import { auxiliary_line_xylr, guide_line_xylr } from "@grida/cmath/_measurement";
2
3
  import cmath from "@grida/cmath";
4
+ //#region primitives/pixel-grid.ts
5
+ const DEFAULT_PIXEL_GRID_COLOR = "rgba(150, 150, 150, 0.15)";
6
+ const DEFAULT_PIXEL_GRID_STEPS = [1, 1];
7
+ function drawPixelGrid(p) {
8
+ const { ctx, transform, width, height, dpr, color = DEFAULT_PIXEL_GRID_COLOR, steps = DEFAULT_PIXEL_GRID_STEPS } = p;
9
+ ctx.save();
10
+ const [[sx, , tx], [, sy, ty]] = transform;
11
+ ctx.setTransform(sx * dpr, 0, 0, sy * dpr, tx * dpr, ty * dpr);
12
+ ctx.strokeStyle = color;
13
+ ctx.lineWidth = 1 / Math.max(Math.abs(sx * dpr), Math.abs(sy * dpr));
14
+ const minUserX = (0 - tx * dpr) / (sx * dpr);
15
+ const maxUserX = (width * dpr - tx * dpr) / (sx * dpr);
16
+ const minUserY = (0 - ty * dpr) / (sy * dpr);
17
+ const maxUserY = (height * dpr - ty * dpr) / (sy * dpr);
18
+ const [stepX, stepY] = steps;
19
+ const startX = Math.floor(minUserX / stepX) * stepX - 2 * stepX;
20
+ const endX = Math.ceil(maxUserX / stepX) * stepX + 2 * stepX;
21
+ const startY = Math.floor(minUserY / stepY) * stepY - 2 * stepY;
22
+ const endY = Math.ceil(maxUserY / stepY) * stepY + 2 * stepY;
23
+ ctx.beginPath();
24
+ for (let x = startX; x <= endX; x += stepX) {
25
+ ctx.moveTo(x, startY);
26
+ ctx.lineTo(x, endY);
27
+ }
28
+ for (let y = startY; y <= endY; y += stepY) {
29
+ ctx.moveTo(startX, y);
30
+ ctx.lineTo(endX, y);
31
+ }
32
+ ctx.stroke();
33
+ ctx.restore();
34
+ }
35
+ //#endregion
3
36
  //#region primitives/canvas.ts
4
37
  const DEFAULT_COLOR = "#f44336";
5
38
  const DEFAULT_LABEL_FG = "#ffffff";
@@ -37,6 +70,7 @@ var HUDCanvas = class {
37
70
  ]];
38
71
  this.width = 0;
39
72
  this.height = 0;
73
+ this.pixelGrid = null;
40
74
  this.ctx = canvas.getContext("2d");
41
75
  this.dpr = typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1;
42
76
  this.color = options?.color ?? DEFAULT_COLOR;
@@ -59,6 +93,31 @@ var HUDCanvas = class {
59
93
  this.transform = transform;
60
94
  }
61
95
  /**
96
+ * Configure the back-most pixel-grid layer. Pass `null` to disable.
97
+ * Drawn before any HUD primitive, gated by `zoomThreshold`. See
98
+ * `PixelGridConfig.transform` for the two-transform contract.
99
+ */
100
+ setPixelGrid(config) {
101
+ if (config === null) {
102
+ this.pixelGrid = null;
103
+ return;
104
+ }
105
+ this.pixelGrid = {
106
+ ...config,
107
+ transform: config.transform ?? this.pixelGrid?.transform
108
+ };
109
+ }
110
+ /**
111
+ * Update only the pixel grid's transform, without replacing the rest of
112
+ * the config. Cheap to call per camera tick.
113
+ */
114
+ setPixelGridTransform(transform) {
115
+ if (this.pixelGrid) this.pixelGrid = {
116
+ ...this.pixelGrid,
117
+ transform
118
+ };
119
+ }
120
+ /**
62
121
  * Clear the canvas and draw all primitives in `commands`.
63
122
  * Pass `undefined` to clear without drawing (e.g. when no overlay is active).
64
123
  */
@@ -66,6 +125,17 @@ var HUDCanvas = class {
66
125
  const ctx = this.ctx;
67
126
  ctx.setTransform(1, 0, 0, 1, 0, 0);
68
127
  ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
128
+ const pg = this.pixelGrid;
129
+ const pgTransform = pg?.transform ?? this.transform;
130
+ if (pg?.enabled && pgTransform[0][0] > pg.zoomThreshold) drawPixelGrid({
131
+ ctx,
132
+ transform: pgTransform,
133
+ width: this.width,
134
+ height: this.height,
135
+ dpr: this.dpr,
136
+ color: pg.color,
137
+ steps: pg.steps
138
+ });
69
139
  if (!commands) return;
70
140
  const { lines, rules, points, rects, polylines, screenRects } = commands;
71
141
  if (rules && rules.length > 0) this.drawRules(rules);
@@ -91,12 +161,12 @@ var HUDCanvas = class {
91
161
  drawRules(rules) {
92
162
  const ctx = this.ctx;
93
163
  this.applyScreenTransform();
94
- ctx.strokeStyle = this.color;
95
164
  ctx.lineWidth = DEFAULT_LINE_WIDTH;
96
- for (const { axis, offset } of rules) {
97
- const screenOffset = this.deltaToScreen(offset, axis);
165
+ for (const rule of rules) {
166
+ const screenOffset = this.deltaToScreen(rule.offset, rule.axis);
167
+ ctx.strokeStyle = rule.color ?? this.color;
98
168
  ctx.beginPath();
99
- if (axis === "x") {
169
+ if (rule.axis === "x") {
100
170
  ctx.moveTo(screenOffset, 0);
101
171
  ctx.lineTo(screenOffset, this.height);
102
172
  } else {
@@ -150,17 +220,29 @@ var HUDCanvas = class {
150
220
  const midY = (line.y1 + line.y2) / 2;
151
221
  const lx = sx * midX + tx;
152
222
  const ly = sy * midY + ty;
223
+ const angle = line.labelAngle ?? 0;
153
224
  const isVertical = Math.abs(line.x2 - line.x1) < Math.abs(line.y2 - line.y1);
154
- const labelX = isVertical ? lx + LABEL_OFFSET : lx;
155
- const labelY = isVertical ? ly : ly + LABEL_OFFSET;
225
+ const baseOffsetX = isVertical ? LABEL_OFFSET : 0;
226
+ const baseOffsetY = isVertical ? 0 : LABEL_OFFSET;
227
+ const cos = Math.cos(angle);
228
+ const sin = Math.sin(angle);
229
+ const labelX = lx + baseOffsetX * cos - baseOffsetY * sin;
230
+ const labelY = ly + baseOffsetX * sin + baseOffsetY * cos;
156
231
  const tw = ctx.measureText(line.label).width + LABEL_PADDING_X * 2;
157
232
  const th = LABEL_FONT_HEIGHT + LABEL_PADDING_Y * 2;
233
+ if (angle !== 0) {
234
+ ctx.save();
235
+ ctx.translate(labelX, labelY);
236
+ ctx.rotate(angle);
237
+ ctx.translate(-labelX, -labelY);
238
+ }
158
239
  ctx.fillStyle = line.color ?? this.color;
159
240
  ctx.beginPath();
160
241
  ctx.roundRect(labelX - tw / 2, labelY - th / 2, tw, th, LABEL_BORDER_RADIUS);
161
242
  ctx.fill();
162
243
  ctx.fillStyle = DEFAULT_LABEL_FG;
163
244
  ctx.fillText(line.label, labelX, labelY);
245
+ if (angle !== 0) ctx.restore();
164
246
  }
165
247
  }
166
248
  drawRects(rects) {
@@ -223,20 +305,29 @@ var HUDCanvas = class {
223
305
  drawPoints(points) {
224
306
  const ctx = this.ctx;
225
307
  this.applyScreenTransform();
226
- ctx.strokeStyle = this.color;
227
308
  ctx.lineWidth = DEFAULT_LINE_WIDTH;
228
309
  const half = CROSSHAIR_SIZE / 2;
229
310
  const [[sx, , tx], [, sy, ty]] = this.transform;
230
- ctx.beginPath();
231
- for (const [px, py] of points) {
232
- const scrX = sx * px + tx;
233
- const scrY = sy * py + ty;
234
- ctx.moveTo(scrX - half, scrY - half);
235
- ctx.lineTo(scrX + half, scrY + half);
236
- ctx.moveTo(scrX + half, scrY - half);
237
- ctx.lineTo(scrX - half, scrY + half);
311
+ const buckets = /* @__PURE__ */ new Map();
312
+ for (const p of points) {
313
+ const c = p.color ?? this.color;
314
+ const arr = buckets.get(c);
315
+ if (arr) arr.push(p);
316
+ else buckets.set(c, [p]);
317
+ }
318
+ for (const [color, group] of buckets) {
319
+ ctx.strokeStyle = color;
320
+ ctx.beginPath();
321
+ for (const p of group) {
322
+ const scrX = sx * p.x + tx;
323
+ const scrY = sy * p.y + ty;
324
+ ctx.moveTo(scrX - half, scrY - half);
325
+ ctx.lineTo(scrX + half, scrY + half);
326
+ ctx.moveTo(scrX + half, scrY - half);
327
+ ctx.lineTo(scrX - half, scrY + half);
328
+ }
329
+ ctx.stroke();
238
330
  }
239
- ctx.stroke();
240
331
  }
241
332
  /**
242
333
  * Draw rects whose **size is in screen-space** but whose **anchor is in
@@ -283,6 +374,15 @@ var HUDCanvas = class {
283
374
  }
284
375
  const doFill = r.fill !== false;
285
376
  const doStroke = r.stroke !== false;
377
+ const angle = r.angle ?? 0;
378
+ if (angle !== 0) {
379
+ const cx = x + w / 2;
380
+ const cy = y + h / 2;
381
+ ctx.save();
382
+ ctx.translate(cx, cy);
383
+ ctx.rotate(angle);
384
+ ctx.translate(-cx, -cy);
385
+ }
286
386
  if (doFill) {
287
387
  ctx.fillStyle = r.fillColor ?? this.color;
288
388
  ctx.fillRect(x, y, w, h);
@@ -291,28 +391,67 @@ var HUDCanvas = class {
291
391
  ctx.strokeStyle = r.strokeColor ?? this.color;
292
392
  ctx.strokeRect(x, y, w, h);
293
393
  }
394
+ if (angle !== 0) ctx.restore();
294
395
  }
295
396
  }
296
397
  };
297
398
  //#endregion
399
+ //#region primitives/draw.ts
400
+ /**
401
+ * Filter a draw command list by semantic group.
402
+ *
403
+ * Ungrouped primitives are always kept. The function is intentionally shallow:
404
+ * primitives are immutable command objects on the hot draw path, so preserving
405
+ * object identity keeps this as a visibility pass rather than a rewrite.
406
+ */
407
+ function filterHUDDrawByGroup(draw, filter) {
408
+ if (!draw) return void 0;
409
+ const hidden = new Set(filter.hidden ?? []);
410
+ if (hidden.size === 0) return draw;
411
+ const out = {};
412
+ out.lines = keepVisible(draw.lines, hidden);
413
+ out.rules = keepVisible(draw.rules, hidden);
414
+ out.points = keepVisible(draw.points, hidden);
415
+ out.rects = keepVisible(draw.rects, hidden);
416
+ out.polylines = keepVisible(draw.polylines, hidden);
417
+ out.screenRects = keepVisible(draw.screenRects, hidden);
418
+ return hasAny(out) ? out : void 0;
419
+ }
420
+ function keepVisible(items, hidden) {
421
+ if (!items || items.length === 0) return void 0;
422
+ const kept = items.filter((item) => !item.group || !hidden.has(item.group));
423
+ return kept.length > 0 ? kept : void 0;
424
+ }
425
+ function hasAny(draw) {
426
+ return (draw.lines?.length ?? 0) > 0 || (draw.rules?.length ?? 0) > 0 || (draw.points?.length ?? 0) > 0 || (draw.rects?.length ?? 0) > 0 || (draw.polylines?.length ?? 0) > 0 || (draw.screenRects?.length ?? 0) > 0;
427
+ }
428
+ //#endregion
298
429
  //#region primitives/snap-guide.ts
299
430
  /**
300
431
  * Convert a `guide.SnapGuide` (the output of `guide.plot()`) into a
301
432
  * generic {@link HUDDraw} command list.
302
433
  *
303
- * Lines pass through directly (HUDLine extends cmath.ui.Line).
304
- * Points pass through directly (both are cmath.Vector2).
305
- * Rules are destructured from tuples to objects.
434
+ * `color`, when supplied, is applied as the per-item stroke override
435
+ * for every emitted line, rule, and point. When absent, the HUD
436
+ * canvas's current color is used.
306
437
  */
307
- function snapGuideToHUDDraw(sg) {
438
+ function snapGuideToHUDDraw(sg, color) {
308
439
  if (!sg) return void 0;
309
440
  return {
310
- lines: sg.lines,
441
+ lines: sg.lines.map((l) => ({
442
+ ...l,
443
+ color
444
+ })),
311
445
  rules: sg.rules.map(([axis, offset]) => ({
312
446
  axis,
313
- offset
447
+ offset,
448
+ color
314
449
  })),
315
- points: sg.points
450
+ points: sg.points.map(([x, y]) => ({
451
+ x,
452
+ y,
453
+ color
454
+ }))
316
455
  };
317
456
  }
318
457
  //#endregion
@@ -449,15 +588,53 @@ function rectFromPoints(a, b) {
449
588
  return cmath.rect.fromPoints([a, b]);
450
589
  }
451
590
  /**
452
- * Apply a resize handle drag to an initial rect and return the new rect.
591
+ * Apply a resize-handle drag to a `SelectionShape` and return the new shape.
453
592
  *
454
- * `dx, dy` is the total drag delta in document-space, measured from
593
+ * `dx, dy` is the total drag delta in **document-space**, measured from
455
594
  * `anchor_doc` (the pointer-down point) to the current pointer.
456
595
  *
596
+ * - For `kind: "rect"` shapes, the delta applies directly to the doc-space
597
+ * bbox. Same math as the legacy `Rect`-only `applyResize`; no behavior
598
+ * change for axis-aligned hosts.
599
+ * - For `kind: "transformed"` shapes, the doc-space delta is rotated into
600
+ * the shape's **local** frame via the matrix's linear part (the rotation
601
+ * component, translation dropped), then applied to `local`. The matrix
602
+ * itself is preserved — only `local.x/y/width/height` change. Net effect:
603
+ * dragging a corner of a rotated rect extends the artwork along its
604
+ * rotation axis, not along world axes.
605
+ * - For `kind: "line"` and `kind: "unresolved"` the shape is returned
606
+ * unchanged (lines have endpoint-knob gestures, not corner-resize).
607
+ *
457
608
  * No constraints: width/height can go negative — host is responsible for
458
609
  * normalizing if it cares (most callers clamp to a min-size).
459
610
  */
460
611
  function applyResize(initial, direction, dx, dy) {
612
+ if (initial.kind === "rect") return {
613
+ kind: "rect",
614
+ rect: applyResizeRect(initial.rect, direction, dx, dy)
615
+ };
616
+ if (initial.kind === "transformed") {
617
+ const m = initial.matrix;
618
+ const linear = [[
619
+ m[0][0],
620
+ m[0][1],
621
+ 0
622
+ ], [
623
+ m[1][0],
624
+ m[1][1],
625
+ 0
626
+ ]];
627
+ const inv_linear = cmath.transform.invert(linear);
628
+ const [ldx, ldy] = cmath.vector2.transform([dx, dy], inv_linear);
629
+ return {
630
+ kind: "transformed",
631
+ local: applyResizeRect(initial.local, direction, ldx, ldy),
632
+ matrix: m
633
+ };
634
+ }
635
+ return initial;
636
+ }
637
+ function applyResizeRect(initial, direction, dx, dy) {
461
638
  let { x, y, width, height } = initial;
462
639
  switch (direction) {
463
640
  case "n":
@@ -503,16 +680,19 @@ function applyResize(initial, direction, dx, dy) {
503
680
  };
504
681
  }
505
682
  //#endregion
506
- //#region event/cursor.ts
507
- /** Cursor-equality used to detect changes without object allocations. */
508
- function cursorEquals(a, b) {
509
- if (typeof a === "string" && typeof b === "string") return a === b;
510
- if (typeof a !== "string" && typeof b !== "string") {
511
- if (a.kind === "resize" && b.kind === "resize") return a.direction === b.direction;
512
- if (a.kind === "rotate" && b.kind === "rotate") return a.corner === b.corner;
513
- return false;
514
- }
515
- return false;
683
+ //#region event/shape.ts
684
+ /**
685
+ * Compute the axis-aligned bounding box of a `SelectionShape` in doc-space.
686
+ * Used for layout math the chrome builder needs (e.g. handle positions).
687
+ *
688
+ * Throws on `kind: "unresolved"` the chrome builder must resolve those
689
+ * via `shapeOf` first.
690
+ */
691
+ function shapeBounds(shape) {
692
+ if (shape.kind === "rect") return shape.rect;
693
+ if (shape.kind === "transformed") return cmath.rect.transform(shape.local, shape.matrix);
694
+ if (shape.kind === "line") return cmath.rect.fromPoints([shape.p1, shape.p2]);
695
+ throw new Error(`shapeBounds: cannot bound unresolved shape (id=${shape.id})`);
516
696
  }
517
697
  //#endregion
518
698
  //#region event/click-tracker.ts
@@ -556,9 +736,10 @@ function nowMs() {
556
736
  /**
557
737
  * Registry of overlay UI hit regions.
558
738
  *
559
- * Regions are appended in draw order (back-to-front). `hitTest` iterates
560
- * in reverse so the topmost region wins — matching how the chrome is
561
- * visually layered.
739
+ * Regions are appended in declaration order. `hitTest` resolves by
740
+ * **lowest priority value** at the hit point; declaration order
741
+ * serves as tie-break only (later push wins on equal priorities,
742
+ * preserving the prior "topmost wins on overlap" feel).
562
743
  */
563
744
  var HitRegions = class {
564
745
  constructor() {
@@ -571,11 +752,18 @@ var HitRegions = class {
571
752
  this.regions.push(region);
572
753
  }
573
754
  hitTest(point) {
574
- for (let i = this.regions.length - 1; i >= 0; i--) {
575
- const r = this.regions[i];
576
- if (cmath.rect.containsPoint(r.rect, point)) return r.action;
755
+ return this.hitTestRegion(point)?.action ?? null;
756
+ }
757
+ /** Returns the full region (label + priority) used by tests and
758
+ * debug tooling that want to assert on `label`. `hitTest` delegates here. */
759
+ hitTestRegion(point) {
760
+ let best = null;
761
+ for (const r of this.regions) {
762
+ const test_point = r.inverse_transform ? cmath.vector2.transform(point, r.inverse_transform) : point;
763
+ if (!cmath.rect.containsPoint(r.rect, test_point)) continue;
764
+ if (best === null || r.priority <= best.priority) best = r;
577
765
  }
578
- return null;
766
+ return best;
579
767
  }
580
768
  isEmpty() {
581
769
  return this.regions.length === 0;
@@ -588,6 +776,44 @@ var HitRegions = class {
588
776
  //#endregion
589
777
  //#region event/decision.ts
590
778
  /**
779
+ * Selection-intent decision module — pure functions, ZERO side effects.
780
+ *
781
+ * Mental model:
782
+ *
783
+ * The HUD is an event router over real overlay layers. The host creates
784
+ * overlays (resize knob, rotate region, endpoint knob, translate body,
785
+ * etc.); the router decides what each one does on pointer-down by overlay
786
+ * type, falling back to a scene-content pick when no overlay claims.
787
+ * Pointer events are synthesized on top of raw pointer input — no native
788
+ * `click`, no DOM ordering.
789
+ *
790
+ * Architecture:
791
+ *
792
+ * PointerDownInput ─► classifyScenario() ─► Scenario ─► dispatch() ─► PointerDownDecision
793
+ * (recognizer) (declarative table)
794
+ *
795
+ * The `Scenario` enum is a **descriptive label** for each `(overlay hit,
796
+ * pick result, selection, modifiers)` combination — useful for tests and
797
+ * readable dispatch — NOT a contract the HUD imposes on hosts. The
798
+ * contract is per-overlay routing semantics (e.g. resize knob always
799
+ * starts a gesture; translate body always defers) and the Tier-1 → Tier-2
800
+ * fallback when no overlay claims the point.
801
+ *
802
+ * Adding a new UX rule:
803
+ *
804
+ * 1. Add a new `Scenario` constant (or reuse one).
805
+ * 2. Add/update the recognizer branch in {@link classifyScenario}.
806
+ * 3. Add/update the dispatch branch in {@link decidePointerDown}.
807
+ * 4. Add tests pinning the classification and the dispatch.
808
+ * 5. Update the working-group doc.
809
+ *
810
+ * Working-group spec (implementation-agnostic):
811
+ * https://grida.co/docs/wg/feat-editor/ux-surface/selection-intent
812
+ *
813
+ * UX-narrative sibling:
814
+ * https://grida.co/docs/wg/feat-editor/ux-surface/selection
815
+ */
816
+ /**
591
817
  * Recognize which scenario a pointer-down belongs to. Total over inputs.
592
818
  * Pure, no I/O. The single source of truth for "which atomic intent did the
593
819
  * user just express?"
@@ -654,7 +880,7 @@ function dispatch(scenario, input) {
654
880
  kind: "start_resize",
655
881
  ids: a.ids,
656
882
  direction: a.direction,
657
- initial_rect: a.initial_rect
883
+ initial_shape: a.initial_shape
658
884
  };
659
885
  }
660
886
  case "HandleRotate": {
@@ -663,7 +889,7 @@ function dispatch(scenario, input) {
663
889
  kind: "start_rotate",
664
890
  ids: a.ids,
665
891
  corner: a.corner,
666
- initial_rect: a.initial_rect
892
+ initial_shape: a.initial_shape
667
893
  };
668
894
  }
669
895
  case "HandleEndpoint": {
@@ -764,11 +990,13 @@ function decideIdleCursor(input) {
764
990
  if (ui_action) switch (ui_action.kind) {
765
991
  case "resize_handle": return {
766
992
  kind: "resize",
767
- direction: ui_action.direction
993
+ direction: ui_action.direction,
994
+ baseAngle: shape_screen_angle_rad(ui_action.initial_shape)
768
995
  };
769
996
  case "rotate_handle": return {
770
997
  kind: "rotate",
771
- corner: ui_action.corner
998
+ corner: ui_action.corner,
999
+ baseAngle: shape_screen_angle_rad(ui_action.initial_shape)
772
1000
  };
773
1001
  case "translate_handle": return "move";
774
1002
  case "select_node":
@@ -777,6 +1005,24 @@ function decideIdleCursor(input) {
777
1005
  if (hovered_id && selection_ids.includes(hovered_id)) return "move";
778
1006
  return "default";
779
1007
  }
1008
+ /**
1009
+ * Doc-space rotation of a `SelectionShape` in radians. For `transformed`
1010
+ * shapes this is the angle baked into `matrix`; for `rect`/`line` it's 0.
1011
+ *
1012
+ * Used by `decideIdleCursor` and by the rotate-gesture cursor compositor
1013
+ * so the resize / rotate cursor always tilts to match the selection's
1014
+ * orientation — without requiring the HUD's camera to be axis-aligned
1015
+ * for the doc-space angle to be the right thing to render (the renderer
1016
+ * draws the cursor in screen px, and in svg-editor the camera contributes
1017
+ * scale + translate only, so doc-space == screen-space rotation).
1018
+ *
1019
+ * For hosts that ROTATE the HUD camera, this should compose with the
1020
+ * camera's angle at consume time. Not relevant for svg-editor.
1021
+ */
1022
+ function shape_screen_angle_rad(shape) {
1023
+ if (shape.kind !== "transformed") return 0;
1024
+ return cmath.transform.angle(shape.matrix) * Math.PI / 180;
1025
+ }
780
1026
  //#endregion
781
1027
  //#region event/transform.ts
782
1028
  const IDENTITY = [[
@@ -981,16 +1227,17 @@ var SurfaceState = class {
981
1227
  const g = this.gesture;
982
1228
  const dx = point_doc[0] - g.anchor_doc[0];
983
1229
  const dy = point_doc[1] - g.anchor_doc[1];
984
- const next_rect = applyResize(g.initial_rect, g.direction, dx, dy);
1230
+ const next_shape = applyResize(g.initial_shape, g.direction, dx, dy);
985
1231
  this.gesture = {
986
1232
  ...g,
987
- current_rect: next_rect
1233
+ current_shape: next_shape
988
1234
  };
989
1235
  deps.emitIntent({
990
1236
  kind: "resize",
991
1237
  ids: g.ids,
992
1238
  anchor: g.direction,
993
- rect: next_rect,
1239
+ rect: shapeBounds(next_shape),
1240
+ shape: next_shape,
994
1241
  phase: "preview"
995
1242
  });
996
1243
  response.needsRedraw = true;
@@ -1003,12 +1250,18 @@ var SurfaceState = class {
1003
1250
  ...g,
1004
1251
  current_angle: angle
1005
1252
  };
1253
+ const delta = angle - g.anchor_angle;
1006
1254
  deps.emitIntent({
1007
1255
  kind: "rotate",
1008
1256
  ids: g.ids,
1009
- angle: angle - g.anchor_angle,
1257
+ angle: delta,
1010
1258
  phase: "preview"
1011
1259
  });
1260
+ this.setCursor({
1261
+ kind: "rotate",
1262
+ corner: g.corner,
1263
+ baseAngle: g.initial_cursor_angle + delta
1264
+ }, response);
1012
1265
  response.needsRedraw = true;
1013
1266
  return response;
1014
1267
  }
@@ -1060,23 +1313,30 @@ var SurfaceState = class {
1060
1313
  kind: "resize",
1061
1314
  ids: [...decision.ids],
1062
1315
  direction: decision.direction,
1063
- initial_rect: decision.initial_rect,
1316
+ initial_shape: decision.initial_shape,
1064
1317
  anchor_doc: point_doc,
1065
- current_rect: decision.initial_rect
1318
+ current_shape: decision.initial_shape
1066
1319
  };
1067
1320
  response.needsRedraw = true;
1068
1321
  return response;
1069
1322
  case "start_rotate": {
1070
- const [cx, cy] = cmath.rect.getCenter(decision.initial_rect);
1323
+ const [cx, cy] = cmath.rect.getCenter(shapeBounds(decision.initial_shape));
1071
1324
  const angle = Math.atan2(point_doc[1] - cy, point_doc[0] - cx);
1325
+ const initial_cursor_angle = decision.initial_shape.kind === "transformed" ? cmath.transform.angle(decision.initial_shape.matrix) * Math.PI / 180 : 0;
1072
1326
  this.gesture = {
1073
1327
  kind: "rotate",
1074
1328
  ids: [...decision.ids],
1075
1329
  corner: decision.corner,
1076
1330
  center_doc: [cx, cy],
1077
1331
  anchor_angle: angle,
1078
- current_angle: angle
1332
+ current_angle: angle,
1333
+ initial_cursor_angle
1079
1334
  };
1335
+ this.setCursor({
1336
+ kind: "rotate",
1337
+ corner: decision.corner,
1338
+ baseAngle: initial_cursor_angle
1339
+ }, response);
1080
1340
  response.needsRedraw = true;
1081
1341
  return response;
1082
1342
  }
@@ -1182,7 +1442,8 @@ var SurfaceState = class {
1182
1442
  kind: "resize",
1183
1443
  ids: g.ids,
1184
1444
  anchor: g.direction,
1185
- rect: g.current_rect,
1445
+ rect: shapeBounds(g.current_shape),
1446
+ shape: g.current_shape,
1186
1447
  phase: "commit"
1187
1448
  });
1188
1449
  this.gesture = IDLE;
@@ -1279,20 +1540,6 @@ function mergeStyle(base, partial) {
1279
1540
  };
1280
1541
  }
1281
1542
  //#endregion
1282
- //#region event/shape.ts
1283
- /**
1284
- * Compute the axis-aligned bounding box of a `SelectionShape` in doc-space.
1285
- * Used for layout math the chrome builder needs (e.g. handle positions).
1286
- *
1287
- * Throws on `kind: "unresolved"` — the chrome builder must resolve those
1288
- * via `shapeOf` first.
1289
- */
1290
- function shapeBounds(shape) {
1291
- if (shape.kind === "rect") return shape.rect;
1292
- if (shape.kind === "line") return cmath.rect.fromPoints([shape.p1, shape.p2]);
1293
- throw new Error(`shapeBounds: cannot bound unresolved shape (id=${shape.id})`);
1294
- }
1295
- //#endregion
1296
1543
  //#region event/overlay.ts
1297
1544
  /**
1298
1545
  * Minimum hit-target size in screen-px.
@@ -1308,25 +1555,237 @@ const MIN_HIT_SIZE = 16;
1308
1555
  */
1309
1556
  const MIN_CHROME_VISIBLE_SIZE = 12;
1310
1557
  //#endregion
1311
- //#region surface/chrome.ts
1312
- const CORNER_RESIZE_DIRECTIONS = [
1558
+ //#region event/selection-controls.ts
1559
+ const CORNER_DIRS = [
1313
1560
  "nw",
1314
1561
  "ne",
1315
1562
  "se",
1316
1563
  "sw"
1317
1564
  ];
1318
- const EDGE_RESIZE_DIRECTIONS = [
1565
+ const EDGE_DIRS = [
1319
1566
  "n",
1320
1567
  "e",
1321
1568
  "s",
1322
1569
  "w"
1323
1570
  ];
1324
- const ROTATION_CORNERS = [
1325
- "nw",
1326
- "ne",
1327
- "se",
1328
- "sw"
1329
- ];
1571
+ const CORNER_LABEL = {
1572
+ nw: "resize_handle:nw",
1573
+ ne: "resize_handle:ne",
1574
+ se: "resize_handle:se",
1575
+ sw: "resize_handle:sw"
1576
+ };
1577
+ const EDGE_LABEL = {
1578
+ n: "resize_edge:n",
1579
+ e: "resize_edge:e",
1580
+ s: "resize_edge:s",
1581
+ w: "resize_edge:w"
1582
+ };
1583
+ const ROTATE_LABEL = {
1584
+ nw: "rotate:nw",
1585
+ ne: "rotate:ne",
1586
+ se: "rotate:se",
1587
+ sw: "rotate:sw"
1588
+ };
1589
+ const HUDHitPriority = {
1590
+ ENDPOINT_HANDLE: 10,
1591
+ RESIZE_HANDLE_EDGE_SMALL: 22,
1592
+ TRANSLATE_BODY_SMALL: 25,
1593
+ RESIZE_HANDLE_EDGE: 30,
1594
+ RESIZE_HANDLE_CORNER: 31,
1595
+ TRANSLATE_BODY: 40,
1596
+ ROTATE_HANDLE: 50
1597
+ };
1598
+ /** The principle constant — the minimum guaranteed length for the body
1599
+ * interior on each axis AND for each side strip along its parallel
1600
+ * axis. Tunable; everything else derives. */
1601
+ const MIN_GUARANTEED_INTERACTIVE_DIM = 20;
1602
+ /** Below this axis dim, body promotes above corner. Derived from the
1603
+ * principle. */
1604
+ const BODY_FLIP_THRESHOLD = 36;
1605
+ /**
1606
+ * @returns { corner, edge } — lengths in screen-px summing to `total`
1607
+ * (corner * 2 + edge === total). Each is `>= 0`.
1608
+ *
1609
+ * Three phases:
1610
+ * - **comfortable** (`total >= 2 * corner_preferred + edge_min`):
1611
+ * corners at preferred, edge takes the surplus.
1612
+ * - **squeezed** (`total >= edge_min`): edge at its min, corners share
1613
+ * the remainder.
1614
+ * - **tiny** (`total < edge_min`): edge takes everything, corners 0.
1615
+ */
1616
+ function negotiateAxis(total, corner_preferred, edge_min) {
1617
+ if (total <= 0) return {
1618
+ corner: 0,
1619
+ edge: 0
1620
+ };
1621
+ if (total >= corner_preferred * 2 + edge_min) return {
1622
+ corner: corner_preferred,
1623
+ edge: total - corner_preferred * 2
1624
+ };
1625
+ if (total >= edge_min) return {
1626
+ corner: (total - edge_min) / 2,
1627
+ edge: edge_min
1628
+ };
1629
+ return {
1630
+ corner: 0,
1631
+ edge: total
1632
+ };
1633
+ }
1634
+ /**
1635
+ * Compute the selection control layout for a screen-space rect.
1636
+ *
1637
+ * Pure: no DOM, no global state. Same inputs → same zones.
1638
+ *
1639
+ * The perimeter ring straddles the bbox edge with `extension =
1640
+ * hit_size / 2` overhang outside. Along each axis the run of length
1641
+ * `axis_dim + 2 * extension` is split via {@link negotiateAxis} into
1642
+ * `[corner | edge | corner]`. The 4 corners and 4 edges in 2D then tile
1643
+ * the ring as a strict 3×3 grid of cells — **non-overlapping**. Body
1644
+ * sits at rect_screen and may overlap with the ring's inside-bbox half
1645
+ * in comfortable mode; priority resolves those overlaps.
1646
+ *
1647
+ * See the comment block in this file for the full principle.
1648
+ */
1649
+ function computeSelectionControlLayout(rect_screen, opts) {
1650
+ const zones = [];
1651
+ const w_violated = rect_screen.width < BODY_FLIP_THRESHOLD;
1652
+ const h_violated = rect_screen.height < BODY_FLIP_THRESHOLD;
1653
+ const small_mode = w_violated || h_violated;
1654
+ const controls_visible = rect_screen.width >= 12 && rect_screen.height >= 12;
1655
+ if (rect_screen.width >= 1 || rect_screen.height >= 1) zones.push({
1656
+ rect: rect_screen,
1657
+ priority: small_mode ? HUDHitPriority.TRANSLATE_BODY_SMALL : HUDHitPriority.TRANSLATE_BODY,
1658
+ role: { kind: "translate" },
1659
+ label: "translate"
1660
+ });
1661
+ if (!controls_visible) return {
1662
+ zones,
1663
+ controls_visible,
1664
+ small_mode
1665
+ };
1666
+ const hit_size = Math.max(opts.handle_size + 4, 16);
1667
+ const extension = hit_size / 2;
1668
+ const total_x = rect_screen.width + extension * 2;
1669
+ const total_y = rect_screen.height + extension * 2;
1670
+ const { corner: cx, edge: ex } = negotiateAxis(total_x, hit_size, 20);
1671
+ const { corner: cy, edge: ey } = negotiateAxis(total_y, hit_size, 20);
1672
+ const left = rect_screen.x - extension;
1673
+ const top = rect_screen.y - extension;
1674
+ const mid_x = left + cx;
1675
+ const mid_y = top + cy;
1676
+ const right_x = mid_x + ex;
1677
+ const right_y = mid_y + ey;
1678
+ const cornerRects = {
1679
+ nw: {
1680
+ x: left,
1681
+ y: top,
1682
+ width: cx,
1683
+ height: cy
1684
+ },
1685
+ ne: {
1686
+ x: right_x,
1687
+ y: top,
1688
+ width: cx,
1689
+ height: cy
1690
+ },
1691
+ sw: {
1692
+ x: left,
1693
+ y: right_y,
1694
+ width: cx,
1695
+ height: cy
1696
+ },
1697
+ se: {
1698
+ x: right_x,
1699
+ y: right_y,
1700
+ width: cx,
1701
+ height: cy
1702
+ }
1703
+ };
1704
+ const edgeRects = {
1705
+ n: {
1706
+ x: mid_x,
1707
+ y: top,
1708
+ width: ex,
1709
+ height: cy
1710
+ },
1711
+ s: {
1712
+ x: mid_x,
1713
+ y: right_y,
1714
+ width: ex,
1715
+ height: cy
1716
+ },
1717
+ w: {
1718
+ x: left,
1719
+ y: mid_y,
1720
+ width: cx,
1721
+ height: ey
1722
+ },
1723
+ e: {
1724
+ x: right_x,
1725
+ y: mid_y,
1726
+ width: cx,
1727
+ height: ey
1728
+ }
1729
+ };
1730
+ for (const dir of CORNER_DIRS) {
1731
+ const rect = cornerRects[dir];
1732
+ if (rect.width <= 0 || rect.height <= 0) continue;
1733
+ zones.push({
1734
+ rect,
1735
+ priority: HUDHitPriority.RESIZE_HANDLE_CORNER,
1736
+ role: {
1737
+ kind: "resize_corner",
1738
+ direction: dir
1739
+ },
1740
+ label: CORNER_LABEL[dir]
1741
+ });
1742
+ }
1743
+ const edge_priority = {
1744
+ n: w_violated ? HUDHitPriority.RESIZE_HANDLE_EDGE_SMALL : HUDHitPriority.RESIZE_HANDLE_EDGE,
1745
+ s: w_violated ? HUDHitPriority.RESIZE_HANDLE_EDGE_SMALL : HUDHitPriority.RESIZE_HANDLE_EDGE,
1746
+ e: h_violated ? HUDHitPriority.RESIZE_HANDLE_EDGE_SMALL : HUDHitPriority.RESIZE_HANDLE_EDGE,
1747
+ w: h_violated ? HUDHitPriority.RESIZE_HANDLE_EDGE_SMALL : HUDHitPriority.RESIZE_HANDLE_EDGE
1748
+ };
1749
+ for (const dir of EDGE_DIRS) {
1750
+ const rect = edgeRects[dir];
1751
+ if (rect.width <= 0 || rect.height <= 0) continue;
1752
+ zones.push({
1753
+ rect,
1754
+ priority: edge_priority[dir],
1755
+ role: {
1756
+ kind: "resize_edge",
1757
+ direction: dir
1758
+ },
1759
+ label: EDGE_LABEL[dir]
1760
+ });
1761
+ }
1762
+ if (opts.show_rotation) for (const dir of CORNER_DIRS) {
1763
+ const resize = cornerRects[dir];
1764
+ if (resize.width <= 0 || resize.height <= 0) continue;
1765
+ const [dx, dy] = cmath.compass.cardinal_direction_vector[dir];
1766
+ zones.push({
1767
+ rect: {
1768
+ x: resize.x + (dx > 0 ? 0 : -16),
1769
+ y: resize.y + (dy > 0 ? 0 : -16),
1770
+ width: resize.width + 16,
1771
+ height: resize.height + 16
1772
+ },
1773
+ priority: HUDHitPriority.ROTATE_HANDLE,
1774
+ role: {
1775
+ kind: "rotate",
1776
+ corner: dir
1777
+ },
1778
+ label: ROTATE_LABEL[dir]
1779
+ });
1780
+ }
1781
+ return {
1782
+ zones,
1783
+ controls_visible,
1784
+ small_mode
1785
+ };
1786
+ }
1787
+ //#endregion
1788
+ //#region surface/chrome.ts
1330
1789
  /**
1331
1790
  * Build the per-frame surface chrome.
1332
1791
  *
@@ -1336,34 +1795,44 @@ const ROTATION_CORNERS = [
1336
1795
  * - `decoration` — pure visual `HUDDraw` (selection outlines, hover outline,
1337
1796
  * marquee, line outlines). Not interactable.
1338
1797
  *
1798
+ * Priority is data, not iteration order. Each `OverlayElement` carries its
1799
+ * own `priority` (lower wins) and a stable `label`. The `HitRegions`
1800
+ * registry resolves overlapping regions by priority, not push order. See
1801
+ * `event/selection-controls.ts` for the canonical priority ladder.
1802
+ *
1339
1803
  * The Surface fans `overlays` into `HitRegions` (for events) and merges
1340
1804
  * their render shapes into `decoration` (for the canvas draw call).
1341
1805
  */
1342
1806
  function buildChrome(input) {
1343
- const { state, shapeOf, style } = input;
1807
+ const { state, shapeOf, style, groups } = input;
1344
1808
  const transform = state.getTransform();
1345
1809
  const overlays = [];
1346
1810
  const decoration_rects = [];
1347
1811
  const decoration_lines = [];
1348
- const in_gesture = state.gesture.kind !== "idle";
1349
- const hover_id = in_gesture ? null : state.getEffectiveHover();
1812
+ const decoration_polylines = [];
1813
+ const hover_id = state.gesture.kind === "idle" ? state.getEffectiveHover() : null;
1350
1814
  if (hover_id) {
1351
1815
  const shape = shapeOf(hover_id);
1352
- if (shape) pushShapeOutline(shape, decoration_rects, decoration_lines, {
1816
+ if (shape) pushShapeOutline(shape, decoration_rects, decoration_lines, decoration_polylines, {
1353
1817
  dashed: false,
1354
- strokeWidth: style.hoverOutlineWidth
1818
+ strokeWidth: style.hoverOutlineWidth,
1819
+ group: groups?.hover
1355
1820
  });
1356
1821
  }
1357
- if (!in_gesture) for (const group of state.getSelectionGroups()) {
1822
+ for (const group of state.getSelectionGroups()) {
1358
1823
  const shape = resolveGroupShape(group, shapeOf);
1359
1824
  if (!shape) continue;
1360
- pushShapeOutline(shape, decoration_rects, decoration_lines, {
1825
+ pushShapeOutline(shape, decoration_rects, decoration_lines, decoration_polylines, {
1361
1826
  dashed: false,
1362
- strokeWidth: style.selectionOutlineWidth
1827
+ strokeWidth: style.selectionOutlineWidth,
1828
+ group: groups?.selection
1363
1829
  });
1364
- pushBodyHandle(shape, group.ids, transform, overlays);
1365
- if (shape.kind === "rect") pushRectGroupHandles(shape.rect, group.ids, transform, style, overlays);
1366
- else if (shape.kind === "line") pushLineEndpoints(group.ids[0], shape.p1, shape.p2, style, overlays);
1830
+ if (shape.kind === "rect") pushRectChrome(shape.rect, group.ids, transform, style, groups?.selectionControls, overlays);
1831
+ else if (shape.kind === "transformed") pushTransformedChrome(shape.local, shape.matrix, group.ids, transform, style, groups?.selectionControls, overlays);
1832
+ else if (shape.kind === "line") {
1833
+ pushLineBody(shape, group.ids, transform, groups?.selectionControls, overlays);
1834
+ pushLineEndpoints(group.ids[0], shape.p1, shape.p2, style, groups?.selectionControls, overlays);
1835
+ }
1367
1836
  }
1368
1837
  if (state.gesture.kind === "marquee") {
1369
1838
  const g = state.gesture;
@@ -1372,20 +1841,35 @@ function buildChrome(input) {
1372
1841
  ...mr,
1373
1842
  stroke: true,
1374
1843
  fill: true,
1375
- fillOpacity: .15
1844
+ fillOpacity: .15,
1845
+ group: groups?.marquee
1846
+ });
1847
+ }
1848
+ if (state.gesture.kind === "resize") {
1849
+ const shape = state.gesture.current_shape;
1850
+ if (shape.kind === "transformed") {
1851
+ const corners = cmath.rect.toCorners(shape.local).map((p) => cmath.vector2.transform(p, shape.matrix));
1852
+ decoration_polylines.push({
1853
+ points: [...corners, corners[0]],
1854
+ stroke: true,
1855
+ fill: false,
1856
+ dashed: true,
1857
+ group: groups?.transformPreview
1858
+ });
1859
+ } else decoration_rects.push({
1860
+ ...shapeBounds(shape),
1861
+ stroke: true,
1862
+ fill: false,
1863
+ dashed: true,
1864
+ group: groups?.transformPreview
1376
1865
  });
1377
1866
  }
1378
- if (state.gesture.kind === "resize") decoration_rects.push({
1379
- ...state.gesture.current_rect,
1380
- stroke: true,
1381
- fill: false,
1382
- dashed: true
1383
- });
1384
1867
  return {
1385
1868
  overlays,
1386
1869
  decoration: {
1387
1870
  rects: decoration_rects.length > 0 ? decoration_rects : void 0,
1388
- lines: decoration_lines.length > 0 ? decoration_lines : void 0
1871
+ lines: decoration_lines.length > 0 ? decoration_lines : void 0,
1872
+ polylines: decoration_polylines.length > 0 ? decoration_polylines : void 0
1389
1873
  }
1390
1874
  };
1391
1875
  }
@@ -1393,168 +1877,262 @@ function resolveGroupShape(group, shapeOf) {
1393
1877
  if (group.shape.kind === "unresolved") return shapeOf(group.shape.id);
1394
1878
  return group.shape;
1395
1879
  }
1396
- function pushBodyHandle(shape, ids, transform, out) {
1397
- const bounds_doc = shapeBounds(shape);
1398
- const rect_screen = cmath.rect.transform(bounds_doc, transform);
1399
- if (rect_screen.width < 1 && rect_screen.height < 1) return;
1400
- out.push({
1401
- action: {
1402
- kind: "translate_handle",
1403
- ids
1404
- },
1405
- hit: {
1406
- kind: "screen_aabb",
1407
- rect: rect_screen
1408
- },
1409
- cursor: "move"
1880
+ function pushRectChrome(rect_doc, ids, transform, style, group, out) {
1881
+ const layout = computeSelectionControlLayout(cmath.rect.transform(rect_doc, transform), {
1882
+ handle_size: style.handleSize,
1883
+ show_rotation: style.showRotationHandles && ids.length >= 1
1410
1884
  });
1885
+ for (const zone of layout.zones) {
1886
+ const el = zoneToOverlay(zone, rect_doc, ids, style, group, layout.controls_visible);
1887
+ if (el) out.push(el);
1888
+ }
1411
1889
  }
1412
- function pushRectGroupHandles(rect_doc, ids, transform, style, out) {
1413
- const rect_screen = cmath.rect.transform(rect_doc, transform);
1414
- if (rect_screen.width < 12 && rect_screen.height < 12) return;
1415
- const size = style.handleSize;
1416
- const hit_size = Math.max(size + 4, 16);
1417
- const anchors_doc = cornerAnchors(rect_doc);
1418
- for (const dir of CORNER_RESIZE_DIRECTIONS) {
1419
- const anchor_doc = anchors_doc[dir];
1420
- out.push({
1890
+ function zoneToOverlay(zone, rect_doc, ids, style, group, controls_visible) {
1891
+ switch (zone.role.kind) {
1892
+ case "translate": return {
1893
+ label: zone.label,
1894
+ group,
1895
+ action: {
1896
+ kind: "translate_handle",
1897
+ ids
1898
+ },
1899
+ hit: {
1900
+ kind: "screen_aabb",
1901
+ rect: zone.rect
1902
+ },
1903
+ priority: zone.priority,
1904
+ cursor: "move"
1905
+ };
1906
+ case "resize_corner": {
1907
+ const dir = zone.role.direction;
1908
+ const size = style.handleSize;
1909
+ const anchor_doc = cmath.rect.getCardinalPoint(rect_doc, dir);
1910
+ return {
1911
+ label: zone.label,
1912
+ group,
1913
+ action: {
1914
+ kind: "resize_handle",
1915
+ direction: dir,
1916
+ ids,
1917
+ initial_shape: {
1918
+ kind: "rect",
1919
+ rect: rect_doc
1920
+ }
1921
+ },
1922
+ hit: {
1923
+ kind: "screen_aabb",
1924
+ rect: zone.rect
1925
+ },
1926
+ render: controls_visible ? {
1927
+ kind: "screen_rect",
1928
+ anchor_doc,
1929
+ width: size,
1930
+ height: size,
1931
+ placement: "center",
1932
+ fill: true,
1933
+ stroke: true,
1934
+ fillColor: style.handleFill,
1935
+ strokeColor: style.handleStroke
1936
+ } : void 0,
1937
+ priority: zone.priority,
1938
+ cursor: {
1939
+ kind: "resize",
1940
+ direction: dir
1941
+ }
1942
+ };
1943
+ }
1944
+ case "resize_edge": return {
1945
+ label: zone.label,
1946
+ group,
1421
1947
  action: {
1422
1948
  kind: "resize_handle",
1423
- direction: dir,
1949
+ direction: zone.role.direction,
1424
1950
  ids,
1425
- initial_rect: rect_doc
1951
+ initial_shape: {
1952
+ kind: "rect",
1953
+ rect: rect_doc
1954
+ }
1426
1955
  },
1427
1956
  hit: {
1428
- kind: "screen_rect_at_doc",
1429
- anchor_doc,
1430
- width: hit_size,
1431
- height: hit_size,
1432
- placement: "center"
1433
- },
1434
- render: {
1435
- kind: "screen_rect",
1436
- anchor_doc,
1437
- width: size,
1438
- height: size,
1439
- placement: "center",
1440
- fill: true,
1441
- stroke: true,
1442
- fillColor: style.handleFill,
1443
- strokeColor: style.handleStroke
1957
+ kind: "screen_aabb",
1958
+ rect: zone.rect
1444
1959
  },
1960
+ priority: zone.priority,
1445
1961
  cursor: {
1446
1962
  kind: "resize",
1447
- direction: dir
1963
+ direction: zone.role.direction
1448
1964
  }
1449
- });
1450
- }
1451
- const edge_strips = edgeStripsScreen(rect_screen, hit_size);
1452
- for (const dir of EDGE_RESIZE_DIRECTIONS) {
1453
- const strip = edge_strips[dir];
1454
- out.push({
1965
+ };
1966
+ case "rotate": return {
1967
+ label: zone.label,
1968
+ group,
1455
1969
  action: {
1456
- kind: "resize_handle",
1457
- direction: dir,
1970
+ kind: "rotate_handle",
1971
+ corner: zone.role.corner,
1458
1972
  ids,
1459
- initial_rect: rect_doc
1973
+ initial_shape: {
1974
+ kind: "rect",
1975
+ rect: rect_doc
1976
+ }
1460
1977
  },
1461
1978
  hit: {
1462
1979
  kind: "screen_aabb",
1463
- rect: strip
1980
+ rect: zone.rect
1464
1981
  },
1982
+ priority: zone.priority,
1465
1983
  cursor: {
1466
- kind: "resize",
1467
- direction: dir
1984
+ kind: "rotate",
1985
+ corner: zone.role.corner
1468
1986
  }
1469
- });
1987
+ };
1470
1988
  }
1471
- if (style.showRotationHandles && ids.length === 1) {
1472
- const rot_size = 16;
1473
- for (const corner of ROTATION_CORNERS) {
1474
- const anchor_doc = anchors_doc[corner];
1475
- const offset_screen = rotationOffsetScreen(corner);
1476
- const [ax, ay] = docToScreen(transform, anchor_doc[0], anchor_doc[1]);
1477
- out.push({
1478
- action: {
1479
- kind: "rotate_handle",
1480
- corner,
1481
- ids,
1482
- initial_rect: rect_doc
1483
- },
1484
- hit: {
1485
- kind: "screen_aabb",
1486
- rect: {
1487
- x: ax + offset_screen[0] - rot_size / 2,
1488
- y: ay + offset_screen[1] - rot_size / 2,
1489
- width: rot_size,
1490
- height: rot_size
1989
+ }
1990
+ function pushTransformedChrome(local, matrix, ids, camera, style, group, out) {
1991
+ const local_to_screen = cmath.transform.multiply(camera, matrix);
1992
+ const scale_xy = cmath.transform.getScale(local_to_screen);
1993
+ const angle_deg = cmath.transform.angle(local_to_screen);
1994
+ const angle_rad = angle_deg * Math.PI / 180;
1995
+ const screen_w = local.width * scale_xy[0];
1996
+ const screen_h = local.height * scale_xy[1];
1997
+ const local_center = [local.x + local.width / 2, local.y + local.height / 2];
1998
+ const screen_center = cmath.vector2.transform(local_center, local_to_screen);
1999
+ const layout = computeSelectionControlLayout({
2000
+ x: screen_center[0] - screen_w / 2,
2001
+ y: screen_center[1] - screen_h / 2,
2002
+ width: screen_w,
2003
+ height: screen_h
2004
+ }, {
2005
+ handle_size: style.handleSize,
2006
+ show_rotation: style.showRotationHandles && ids.length >= 1
2007
+ });
2008
+ const inverse_transform = cmath.transform.rotate(cmath.transform.identity, -angle_deg, screen_center);
2009
+ const initial_shape = {
2010
+ kind: "transformed",
2011
+ local,
2012
+ matrix
2013
+ };
2014
+ for (const zone of layout.zones) {
2015
+ const hit = {
2016
+ kind: "screen_obb",
2017
+ rect: zone.rect,
2018
+ inverse_transform
2019
+ };
2020
+ switch (zone.role.kind) {
2021
+ case "translate":
2022
+ out.push({
2023
+ label: zone.label,
2024
+ group,
2025
+ action: {
2026
+ kind: "translate_handle",
2027
+ ids
2028
+ },
2029
+ hit,
2030
+ priority: zone.priority,
2031
+ cursor: "move"
2032
+ });
2033
+ break;
2034
+ case "resize_corner": {
2035
+ const dir = zone.role.direction;
2036
+ const size = style.handleSize;
2037
+ const cardinal_local = cmath.rect.getCardinalPoint(local, dir);
2038
+ const anchor_doc = cmath.vector2.transform(cardinal_local, matrix);
2039
+ out.push({
2040
+ label: zone.label,
2041
+ group,
2042
+ action: {
2043
+ kind: "resize_handle",
2044
+ direction: dir,
2045
+ ids,
2046
+ initial_shape
2047
+ },
2048
+ hit,
2049
+ render: layout.controls_visible ? {
2050
+ kind: "screen_rect",
2051
+ anchor_doc,
2052
+ width: size,
2053
+ height: size,
2054
+ placement: "center",
2055
+ fill: true,
2056
+ stroke: true,
2057
+ fillColor: style.handleFill,
2058
+ strokeColor: style.handleStroke,
2059
+ angle: angle_rad
2060
+ } : void 0,
2061
+ priority: zone.priority,
2062
+ cursor: {
2063
+ kind: "resize",
2064
+ direction: dir
1491
2065
  }
1492
- },
1493
- cursor: {
1494
- kind: "rotate",
1495
- corner
1496
- }
1497
- });
2066
+ });
2067
+ break;
2068
+ }
2069
+ case "resize_edge":
2070
+ out.push({
2071
+ label: zone.label,
2072
+ group,
2073
+ action: {
2074
+ kind: "resize_handle",
2075
+ direction: zone.role.direction,
2076
+ ids,
2077
+ initial_shape
2078
+ },
2079
+ hit,
2080
+ priority: zone.priority,
2081
+ cursor: {
2082
+ kind: "resize",
2083
+ direction: zone.role.direction
2084
+ }
2085
+ });
2086
+ break;
2087
+ case "rotate":
2088
+ out.push({
2089
+ label: zone.label,
2090
+ group,
2091
+ action: {
2092
+ kind: "rotate_handle",
2093
+ corner: zone.role.corner,
2094
+ ids,
2095
+ initial_shape
2096
+ },
2097
+ hit,
2098
+ priority: zone.priority,
2099
+ cursor: {
2100
+ kind: "rotate",
2101
+ corner: zone.role.corner
2102
+ }
2103
+ });
2104
+ break;
1498
2105
  }
1499
2106
  }
1500
2107
  }
1501
- function cornerAnchors(r) {
1502
- return {
1503
- nw: [r.x, r.y],
1504
- n: [r.x + r.width / 2, r.y],
1505
- ne: [r.x + r.width, r.y],
1506
- e: [r.x + r.width, r.y + r.height / 2],
1507
- se: [r.x + r.width, r.y + r.height],
1508
- s: [r.x + r.width / 2, r.y + r.height],
1509
- sw: [r.x, r.y + r.height],
1510
- w: [r.x, r.y + r.height / 2]
2108
+ function pushLineBody(shape, ids, transform, group, out) {
2109
+ const bounds_doc = shapeBounds(shape);
2110
+ const rect_screen = cmath.rect.transform(bounds_doc, transform);
2111
+ if (rect_screen.width < 1 && rect_screen.height < 1) return;
2112
+ const hitW = Math.max(rect_screen.width, 16);
2113
+ const hitH = Math.max(rect_screen.height, 16);
2114
+ const hitRect = {
2115
+ x: rect_screen.x - (hitW - rect_screen.width) / 2,
2116
+ y: rect_screen.y - (hitH - rect_screen.height) / 2,
2117
+ width: hitW,
2118
+ height: hitH
1511
2119
  };
1512
- }
1513
- /**
1514
- * Compute the 4 screen-space AABB strips between corner knobs for virtual
1515
- * edge resize regions. Each strip is `thickness` thick, inset from the
1516
- * corners by half the strip thickness so it doesn't overlap corner hits.
1517
- */
1518
- function edgeStripsScreen(rect_screen, thickness) {
1519
- const { x, y, width, height } = rect_screen;
1520
- const inset = thickness / 2;
1521
- const half = thickness / 2;
1522
- return {
1523
- n: {
1524
- x: x + inset,
1525
- y: y - half,
1526
- width: Math.max(0, width - inset * 2),
1527
- height: thickness
1528
- },
1529
- s: {
1530
- x: x + inset,
1531
- y: y + height - half,
1532
- width: Math.max(0, width - inset * 2),
1533
- height: thickness
2120
+ out.push({
2121
+ label: "translate",
2122
+ group,
2123
+ action: {
2124
+ kind: "translate_handle",
2125
+ ids
1534
2126
  },
1535
- e: {
1536
- x: x + width - half,
1537
- y: y + inset,
1538
- width: thickness,
1539
- height: Math.max(0, height - inset * 2)
2127
+ hit: {
2128
+ kind: "screen_aabb",
2129
+ rect: hitRect
1540
2130
  },
1541
- w: {
1542
- x: x - half,
1543
- y: y + inset,
1544
- width: thickness,
1545
- height: Math.max(0, height - inset * 2)
1546
- }
1547
- };
1548
- }
1549
- function rotationOffsetScreen(corner) {
1550
- switch (corner) {
1551
- case "nw": return [-12, -12];
1552
- case "ne": return [12, -12];
1553
- case "se": return [12, 12];
1554
- case "sw": return [-12, 12];
1555
- }
2131
+ priority: HUDHitPriority.TRANSLATE_BODY,
2132
+ cursor: "move"
2133
+ });
1556
2134
  }
1557
- function pushLineEndpoints(id, p1, p2, style, out) {
2135
+ function pushLineEndpoints(id, p1, p2, style, group, out) {
1558
2136
  const size = style.handleSize;
1559
2137
  const hit_size = Math.max(size + 4, 16);
1560
2138
  const endpoints = [{
@@ -1565,6 +2143,8 @@ function pushLineEndpoints(id, p1, p2, style, out) {
1565
2143
  pos: p2
1566
2144
  }];
1567
2145
  for (const ep of endpoints) out.push({
2146
+ label: `endpoint:${ep.which}`,
2147
+ group,
1568
2148
  action: {
1569
2149
  kind: "endpoint_handle",
1570
2150
  endpoint: ep.which,
@@ -1590,38 +2170,57 @@ function pushLineEndpoints(id, p1, p2, style, out) {
1590
2170
  fillColor: style.handleFill,
1591
2171
  strokeColor: style.handleStroke
1592
2172
  },
2173
+ priority: HUDHitPriority.ENDPOINT_HANDLE,
1593
2174
  cursor: "pointer"
1594
2175
  });
1595
2176
  }
1596
- function pushShapeOutline(shape, rects, lines, opts) {
2177
+ function pushShapeOutline(shape, rects, lines, polylines, opts) {
1597
2178
  if (shape.kind === "rect") rects.push({
1598
2179
  ...shape.rect,
1599
2180
  stroke: true,
1600
2181
  fill: false,
1601
2182
  dashed: opts.dashed,
1602
- strokeWidth: opts.strokeWidth
2183
+ strokeWidth: opts.strokeWidth,
2184
+ group: opts.group
1603
2185
  });
1604
- else if (shape.kind === "line") lines.push({
2186
+ else if (shape.kind === "transformed") {
2187
+ const corners_doc = cmath.rect.toCorners(shape.local).map((p) => cmath.vector2.transform(p, shape.matrix));
2188
+ polylines.push({
2189
+ points: [...corners_doc, corners_doc[0]],
2190
+ stroke: true,
2191
+ fill: false,
2192
+ dashed: opts.dashed,
2193
+ group: opts.group
2194
+ });
2195
+ } else if (shape.kind === "line") lines.push({
1605
2196
  x1: shape.p1[0],
1606
2197
  y1: shape.p1[1],
1607
2198
  x2: shape.p2[0],
1608
2199
  y2: shape.p2[1],
1609
2200
  dashed: opts.dashed,
1610
- strokeWidth: opts.strokeWidth
2201
+ strokeWidth: opts.strokeWidth,
2202
+ group: opts.group
1611
2203
  });
1612
2204
  }
1613
2205
  /**
1614
2206
  * Fan a list of `OverlayElement`s into per-primitive render arrays and into
1615
2207
  * the hit-region registry. Returns the additional render primitives that
1616
2208
  * should be merged with the decoration `HUDDraw`.
2209
+ *
2210
+ * Priority and label are forwarded verbatim from each overlay element to
2211
+ * the registered HitRegion — the registry resolves overlaps by priority.
1617
2212
  */
1618
2213
  function fanOverlays(overlays, transform, regions) {
1619
2214
  regions.clear();
1620
2215
  const screenRects = [];
1621
2216
  for (const el of overlays) {
2217
+ const projected = projectHit(el.hit, transform);
1622
2218
  regions.push({
1623
- rect: projectHitAABB(el.hit, transform),
1624
- action: el.action
2219
+ rect: projected.rect,
2220
+ inverse_transform: projected.inverse_transform,
2221
+ action: el.action,
2222
+ priority: el.priority,
2223
+ label: el.label
1625
2224
  });
1626
2225
  if (!el.render) continue;
1627
2226
  if (el.render.kind === "screen_rect") screenRects.push({
@@ -1633,13 +2232,19 @@ function fanOverlays(overlays, transform, regions) {
1633
2232
  fill: el.render.fill,
1634
2233
  stroke: el.render.stroke,
1635
2234
  fillColor: el.render.fillColor,
1636
- strokeColor: el.render.strokeColor
2235
+ strokeColor: el.render.strokeColor,
2236
+ angle: el.render.angle,
2237
+ group: el.group
1637
2238
  });
1638
2239
  }
1639
2240
  return { screenRects };
1640
2241
  }
1641
- function projectHitAABB(hit, transform) {
1642
- if (hit.kind === "screen_aabb") return hit.rect;
2242
+ function projectHit(hit, transform) {
2243
+ if (hit.kind === "screen_aabb") return { rect: hit.rect };
2244
+ if (hit.kind === "screen_obb") return {
2245
+ rect: hit.rect,
2246
+ inverse_transform: hit.inverse_transform
2247
+ };
1643
2248
  const [sx, sy] = docToScreen(transform, hit.anchor_doc[0], hit.anchor_doc[1]);
1644
2249
  const placement = hit.placement ?? "center";
1645
2250
  let x = sx;
@@ -1666,12 +2271,12 @@ function projectHitAABB(hit, transform) {
1666
2271
  y = sy - hit.height;
1667
2272
  break;
1668
2273
  }
1669
- return {
2274
+ return { rect: {
1670
2275
  x,
1671
2276
  y,
1672
2277
  width: hit.width,
1673
2278
  height: hit.height
1674
- };
2279
+ } };
1675
2280
  }
1676
2281
  /**
1677
2282
  * Merge a base `HUDDraw` (chrome decoration) with optional host-fed extras
@@ -1710,11 +2315,25 @@ var Surface = class {
1710
2315
  constructor(canvas, options) {
1711
2316
  this.width = 0;
1712
2317
  this.height = 0;
2318
+ this.cursor_renderer = null;
1713
2319
  this.opts = options;
1714
2320
  this.style = mergeStyle(DEFAULT_STYLE, options.style);
1715
- this.hudCanvas = new HUDCanvas(canvas, { color: options.color ?? this.style.chromeColor });
2321
+ this.colorOverride = options.color;
2322
+ this.hudCanvas = new HUDCanvas(canvas, { color: this.colorOverride ?? this.style.chromeColor });
1716
2323
  this.state = new SurfaceState();
1717
2324
  if (options.readonly !== void 0) this.state.setReadonly(options.readonly);
2325
+ if (options.pixelGrid) this.hudCanvas.setPixelGrid(options.pixelGrid);
2326
+ }
2327
+ /** Configure / disable the back-most pixel-grid layer. */
2328
+ setPixelGrid(config) {
2329
+ this.hudCanvas.setPixelGrid(config);
2330
+ }
2331
+ /**
2332
+ * Update just the pixel grid's transform. Cheap to call per camera tick.
2333
+ * No-op when no pixel-grid config is set.
2334
+ */
2335
+ setPixelGridTransform(transform) {
2336
+ this.hudCanvas.setPixelGridTransform(transform);
1718
2337
  }
1719
2338
  setSize(w, h) {
1720
2339
  this.width = w;
@@ -1740,7 +2359,15 @@ var Surface = class {
1740
2359
  }
1741
2360
  setStyle(partial) {
1742
2361
  this.style = mergeStyle(this.style, partial);
1743
- this.hudCanvas.setColor(this.style.chromeColor);
2362
+ this.hudCanvas.setColor(this.colorOverride ?? this.style.chromeColor);
2363
+ }
2364
+ /**
2365
+ * Set or clear the host color override. `null` clears the override and
2366
+ * lets `style.chromeColor` win on the next paint.
2367
+ */
2368
+ setColor(color) {
2369
+ this.colorOverride = color ?? void 0;
2370
+ this.hudCanvas.setColor(this.colorOverride ?? this.style.chromeColor);
1744
2371
  }
1745
2372
  setReadonly(v) {
1746
2373
  this.state.setReadonly(v);
@@ -1777,11 +2404,13 @@ var Surface = class {
1777
2404
  state: this.state,
1778
2405
  shapeOf: this.opts.shapeOf,
1779
2406
  style: this.style,
2407
+ groups: this.opts.groups,
1780
2408
  width: this.width,
1781
2409
  height: this.height
1782
2410
  });
1783
- const { screenRects } = fanOverlays(overlays, this.state.getTransform(), this.state.hitRegions());
1784
- this.hudCanvas.draw(mergeDraws(decoration, extra, screenRects));
2411
+ const hidden = this.opts.visibility?.({ gesture: this.state.gesture })?.hidden;
2412
+ const { screenRects } = fanOverlays(filterOverlaysByGroup(overlays, hidden), this.state.getTransform(), this.state.hitRegions());
2413
+ this.hudCanvas.draw(filterHUDDrawByGroup(mergeDraws(decoration, extra, screenRects), { hidden }));
1785
2414
  }
1786
2415
  /** Convenience: clear the canvas (e.g. when the host stops the surface). */
1787
2416
  clear() {
@@ -1800,9 +2429,43 @@ var Surface = class {
1800
2429
  cursor() {
1801
2430
  return this.state.cursor;
1802
2431
  }
2432
+ /**
2433
+ * Resolve the current cursor to a CSS `cursor:` value. Runs the
2434
+ * installed renderer (or the built-in `cursorToCss` if none installed).
2435
+ *
2436
+ * Host wires it like:
2437
+ *
2438
+ * const r = surface.dispatch(event);
2439
+ * if (r.cursorChanged) el.style.cursor = surface.cursorCss();
2440
+ *
2441
+ * Saves the host from re-importing `cursorToCss` after every dispatch
2442
+ * and gives one place to change behavior when a renderer is swapped in.
2443
+ */
2444
+ cursorCss() {
2445
+ return (this.cursor_renderer ?? cursorToCss)(this.state.cursor);
2446
+ }
2447
+ /**
2448
+ * Install (or clear) a custom cursor renderer.
2449
+ *
2450
+ * `null` restores the built-in `cursorToCss` behavior (native CSS
2451
+ * keywords for every variant). Pass `cursors.defaultRenderer()` from
2452
+ * `@grida/hud/cursors` for the bundled SVG cursor set.
2453
+ *
2454
+ * Re-callable mid-session; the next `cursorCss()` reads the new value.
2455
+ */
2456
+ setCursorRenderer(fn) {
2457
+ this.cursor_renderer = fn;
2458
+ }
1803
2459
  modifiers() {
1804
2460
  return this.state.modifiers;
1805
2461
  }
1806
2462
  };
2463
+ function filterOverlaysByGroup(overlays, hidden) {
2464
+ const hidden_set = new Set(hidden ?? []);
2465
+ if (hidden_set.size === 0) return overlays;
2466
+ return overlays.filter((overlay) => {
2467
+ return !overlay.group || !hidden_set.has(overlay.group);
2468
+ });
2469
+ }
1807
2470
  //#endregion
1808
- export { lassoToHUDDraw as a, snapGuideToHUDDraw as c, NO_MODS as i, HUDCanvas as l, MIN_CHROME_VISIBLE_SIZE as n, marqueeToHUDDraw as o, MIN_HIT_SIZE as r, measurementToHUDDraw as s, Surface as t };
2471
+ export { DEFAULT_PIXEL_GRID_STEPS as _, computeSelectionControlLayout as a, MIN_HIT_SIZE as c, marqueeToHUDDraw as d, measurementToHUDDraw as f, DEFAULT_PIXEL_GRID_COLOR as g, HUDCanvas as h, MIN_GUARANTEED_INTERACTIVE_DIM as i, NO_MODS as l, filterHUDDrawByGroup as m, BODY_FLIP_THRESHOLD as n, negotiateAxis as o, snapGuideToHUDDraw as p, HUDHitPriority as r, MIN_CHROME_VISIBLE_SIZE as s, Surface as t, lassoToHUDDraw as u, drawPixelGrid as v };