@weasel-js/core 1.15.1 → 1.16.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.
Files changed (128) hide show
  1. package/CHANGELOG.md +170 -0
  2. package/dist/animation/poseTarget.js +3 -3
  3. package/dist/animation/poseTarget.js.map +1 -1
  4. package/dist/animation/reflow.js +7 -7
  5. package/dist/animation/reflow.js.map +1 -1
  6. package/dist/animation/rig/rigNode.js +2 -2
  7. package/dist/animation/rig/rigNode.js.map +1 -1
  8. package/dist/barrel/input.js +2 -1
  9. package/dist/barrel/paint.js +2 -2
  10. package/dist/barrel/viewport.js +3 -3
  11. package/dist/canvas/NodeShape.js +23 -10
  12. package/dist/canvas/NodeShape.js.map +1 -1
  13. package/dist/canvas/SceneCanvas/PointerProviderIfRoot.js +17 -64
  14. package/dist/canvas/SceneCanvas/PointerProviderIfRoot.js.map +1 -1
  15. package/dist/canvas/SceneCanvas/props.js.map +1 -1
  16. package/dist/canvas/SceneCanvas/useSceneCanvasLayers.js +1 -1
  17. package/dist/canvas/SceneCanvas/useSceneCanvasLayers.js.map +1 -1
  18. package/dist/canvas/SceneCanvas/useSceneRedrawSources.js +4 -1
  19. package/dist/canvas/SceneCanvas/useSceneRedrawSources.js.map +1 -1
  20. package/dist/canvas/SceneCanvas/useSceneSlotLayers.js +3 -3
  21. package/dist/canvas/SceneCanvas/useSceneSlotLayers.js.map +1 -1
  22. package/dist/canvas/SceneCanvas.js +1 -1
  23. package/dist/canvas/SceneCanvas.js.map +1 -1
  24. package/dist/canvas/SceneViewCanvas.js +4 -1
  25. package/dist/canvas/SceneViewCanvas.js.map +1 -1
  26. package/dist/canvas/accentColor.js +3 -3
  27. package/dist/canvas/accentColor.js.map +1 -1
  28. package/dist/canvas/deps/enteredGroup.js +5 -4
  29. package/dist/canvas/deps/enteredGroup.js.map +1 -1
  30. package/dist/canvas/deps/selectionSnapshot.js +7 -2
  31. package/dist/canvas/deps/selectionSnapshot.js.map +1 -1
  32. package/dist/canvas/nodeShapes/derivedPainter.js +57 -8
  33. package/dist/canvas/nodeShapes/derivedPainter.js.map +1 -1
  34. package/dist/canvas/nodeShapes/nodePaint.js +33 -2
  35. package/dist/canvas/nodeShapes/nodePaint.js.map +1 -1
  36. package/dist/canvas/nodeShapes/pathPainter.js +5 -4
  37. package/dist/canvas/nodeShapes/pathPainter.js.map +1 -1
  38. package/dist/canvas/nodeShapes/pickOutline.js +48 -0
  39. package/dist/canvas/nodeShapes/pickOutline.js.map +1 -0
  40. package/dist/canvas/nodeShapes/shapePainter.js +6 -4
  41. package/dist/canvas/nodeShapes/shapePainter.js.map +1 -1
  42. package/dist/canvas/paintOverrides.js +43 -0
  43. package/dist/canvas/paintOverrides.js.map +1 -0
  44. package/dist/canvas/renderSceneToPixels.js +1 -1
  45. package/dist/canvas/renderSceneToPixels.js.map +1 -1
  46. package/dist/canvas/sceneSlotWiring.js +3 -3
  47. package/dist/canvas/sceneSlotWiring.js.map +1 -1
  48. package/dist/canvas/sceneViewRender.js +11 -7
  49. package/dist/canvas/sceneViewRender.js.map +1 -1
  50. package/dist/canvas/slopsDebugLayer.js +8 -2
  51. package/dist/canvas/slopsDebugLayer.js.map +1 -1
  52. package/dist/canvas/trimOverrides.js +18 -0
  53. package/dist/canvas/trimOverrides.js.map +1 -0
  54. package/dist/canvas/useViewRegistrySurface.js +6 -3
  55. package/dist/canvas/useViewRegistrySurface.js.map +1 -1
  56. package/dist/canvas/viewRegistry.js +5 -5
  57. package/dist/canvas/viewRegistry.js.map +1 -1
  58. package/dist/core/ops/reorder/index.js +2 -6
  59. package/dist/core/ops/reorder/index.js.map +1 -1
  60. package/dist/core/pages/fitViewToPage.js +2 -7
  61. package/dist/core/pages/fitViewToPage.js.map +1 -1
  62. package/dist/core/pages/pages.js +3 -9
  63. package/dist/core/pages/pages.js.map +1 -1
  64. package/dist/core/paint/meshKindLazy.js +13 -6
  65. package/dist/core/paint/meshKindLazy.js.map +1 -1
  66. package/dist/core/scene/pathEffects/blend.js +2 -2
  67. package/dist/core/scene/pathEffects/blend.js.map +1 -1
  68. package/dist/core/scene/pathEffects/textAlong.js +6 -5
  69. package/dist/core/scene/pathEffects/textAlong.js.map +1 -1
  70. package/dist/core/scene/scene.js +12 -1
  71. package/dist/core/scene/scene.js.map +1 -1
  72. package/dist/core/viewport/clientToWorld.js +7 -1
  73. package/dist/core/viewport/clientToWorld.js.map +1 -1
  74. package/dist/core/viewport/zoomAt.js +6 -6
  75. package/dist/core/viewport/zoomAt.js.map +1 -1
  76. package/dist/devtools/root.js +11 -4
  77. package/dist/devtools/root.js.map +1 -1
  78. package/dist/devtools/transport.js +12 -16
  79. package/dist/devtools/transport.js.map +1 -1
  80. package/dist/devtools-protocol.d.ts +0 -11
  81. package/dist/domains/measure/measureHit.js +2 -8
  82. package/dist/domains/measure/measureHit.js.map +1 -1
  83. package/dist/domains/paths/anchorMarkers.js +5 -11
  84. package/dist/domains/paths/anchorMarkers.js.map +1 -1
  85. package/dist/domains/paths/pathEditingOverlayLayer.js +5 -9
  86. package/dist/domains/paths/pathEditingOverlayLayer.js.map +1 -1
  87. package/dist/domains/paths/pathLayer.js +20 -6
  88. package/dist/domains/paths/pathLayer.js.map +1 -1
  89. package/dist/domains/paths/penPreviewLayer.js +9 -12
  90. package/dist/domains/paths/penPreviewLayer.js.map +1 -1
  91. package/dist/domains/paths/shapeGenerator.js +1 -2
  92. package/dist/domains/paths/shapeGenerator.js.map +1 -1
  93. package/dist/domains/snap/angle2d.js +2 -1
  94. package/dist/domains/snap/angle2d.js.map +1 -1
  95. package/dist/domains/snap/outcomeStore.js +5 -8
  96. package/dist/domains/snap/outcomeStore.js.map +1 -1
  97. package/dist/domains/text/useSceneTextEdit.js +9 -4
  98. package/dist/domains/text/useSceneTextEdit.js.map +1 -1
  99. package/dist/domains/viewports/aim.js +3 -8
  100. package/dist/domains/viewports/aim.js.map +1 -1
  101. package/dist/domains/viewports/viewResolver.js +5 -3
  102. package/dist/domains/viewports/viewResolver.js.map +1 -1
  103. package/dist/index.d.ts +66 -50
  104. package/dist/index.js +5 -4
  105. package/dist/input/usePublishPointer.js +59 -0
  106. package/dist/input/usePublishPointer.js.map +1 -0
  107. package/dist/interactions/actions/defaults/containerScroll.js +9 -3
  108. package/dist/interactions/actions/defaults/containerScroll.js.map +1 -1
  109. package/dist/interactions/actions/defaults/enterGroup.js +3 -3
  110. package/dist/interactions/actions/defaults/enterGroup.js.map +1 -1
  111. package/dist/interactions/actions/defaults/pathEditMarks.js +2 -5
  112. package/dist/interactions/actions/defaults/pathEditMarks.js.map +1 -1
  113. package/dist/interactions/actions/edit-anchors/handleDrag.js +4 -7
  114. package/dist/interactions/actions/edit-anchors/handleDrag.js.map +1 -1
  115. package/dist/interactions/actions/move/behaviors/clampMove.js +42 -0
  116. package/dist/interactions/actions/move/behaviors/clampMove.js.map +1 -0
  117. package/dist/interactions/actions/move/layout/container.js +3 -6
  118. package/dist/interactions/actions/move/layout/container.js.map +1 -1
  119. package/dist/interactions/actions/move/layout/exits.js +2 -2
  120. package/dist/interactions/actions/move/layout/exits.js.map +1 -1
  121. package/dist/interactions/actions/transform-again/memory.js +2 -2
  122. package/dist/interactions/actions/transform-again/memory.js.map +1 -1
  123. package/dist/move.d.ts +18 -3
  124. package/dist/move.js +2 -1
  125. package/dist/version.js +1 -1
  126. package/package.json +25 -25
  127. package/dist/canvas/colorOverrides.js +0 -23
  128. package/dist/canvas/colorOverrides.js.map +0 -1
@@ -1 +1 @@
1
- {"version":3,"file":"blend.js","names":[],"sources":["../../../../src/core/scene/pathEffects/blend.ts"],"sourcesContent":["/**\n * A blend's steps: where each sits between its two sources — evenly by\n * count, or a fixed distance apart — and, with a spine, laid along it.\n */\nimport {\n boundsOfPath, measurePath, multiply, prepareBlend, rotate, rotatePathAround, transformPath, translate,\n type BlendPathsOptions, type Path, type PathMeasure,\n} from '@weasel-js/geom';\nimport { lerpFill, lerpStroke } from '@weasel-js/paint';\nimport type { DerivedPart } from '../derivedParts';\nimport type { EffectSource, PathEffectOptions } from './source';\n\n/** Steps between two shapes. Its sources are `[from, to]`, or\n * `[from, to, spine]` to lay the steps along a third path. */\nexport interface BlendEffect extends BlendPathsOptions, PathEffectOptions {\n kind: 'blend';\n /** How many shapes stand between the two sources, spacing by count. */\n steps: number;\n /**\n * How the steps are spaced. `'count'` puts `steps` of them evenly between\n * the sources; `'distance'` puts one every `distance` along the run from\n * the first source, as many as fit short of the second, so the last gap is\n * whatever is left. Default `'count'`.\n */\n spaceBy?: 'count' | 'distance';\n /** Distance from one shape's middle to the next, spacing by distance —\n * along the spine where there is one. Spacing by count when unset. */\n distance?: number;\n /**\n * Interpolate each source's rotation as a turn about its middle, rather\n * than moving every point in a straight line — so a shape turned 90° at\n * one end turns through 45° halfway instead of shrinking there. Default\n * `true`; it applies where both sources are turned boxes.\n */\n rotate?: boolean;\n /** With a spine, turn each step by how far the spine's heading where it\n * sits differs from the straight run between the sources. Default\n * `false`, which only moves them. */\n orient?: boolean;\n}\n\nconst EPS = 1e-9;\n\n/** The fractions of the way from the first source to the second that the\n * steps sit at, over a run `length` long. */\nexport function blendStations(effect: BlendEffect, length: number): number[] {\n const out: number[] = [];\n const d = effect.distance;\n if (effect.spaceBy === 'distance' && d !== undefined && d > 0) {\n if (!(length > 0)) return out;\n for (let s = d; s < length - EPS * Math.max(length, 1); s += d) out.push(s / length);\n return out;\n }\n const steps = Math.max(Math.floor(effect.steps), 0);\n for (let i = 1; i <= steps; i++) out.push(i / (steps + 1));\n return out;\n}\n\n/** The middle a source's steps are interpolated about: its turn's center\n * where it is a turned box, else its outline's bounds' middle. */\nfunction middleOf(source: EffectSource): { x: number; y: number } {\n if (source.framed) return { x: source.framed.cx, y: source.framed.cy };\n const b = boundsOfPath(source.world);\n return { x: b.x + b.width / 2, y: b.y + b.height / 2 };\n}\n\n/** The parts of `effect` between `a` and `b`, along `spine` when given.\n * `null` when the spine has no length to measure. */\nexport function blendParts(effect: BlendEffect, a: EffectSource, b: EffectSource, spine?: Path): DerivedPart[] | null {\n const from = middleOf(a);\n const to = middleOf(b);\n const along: PathMeasure | null = spine === undefined ? null : measurePath(spine);\n if (spine !== undefined && along === null) return null;\n const stations = blendStations(effect, along ? along.length : Math.hypot(to.x - from.x, to.y - from.y));\n if (stations.length === 0) return [];\n\n const turned = (effect.rotate ?? true) && a.framed !== undefined && b.framed !== undefined\n ? { a: a.framed, b: b.framed }\n : null;\n const blend = turned\n ? prepareBlend(turned.a.path, turned.b.path, effect)\n : prepareBlend(a.world, b.world, effect);\n // The shorter way round, so a turn of 350° reads as -10°.\n const turn = turned\n ? Math.atan2(Math.sin(turned.b.rotation - turned.a.rotation), Math.cos(turned.b.rotation - turned.a.rotation))\n : 0;\n const chord = Math.atan2(to.y - from.y, to.x - from.x);\n return stations.map((t) => {\n let path: Path = blend.at(t);\n if (turned) {\n const cx = turned.a.cx + (turned.b.cx - turned.a.cx) * t;\n const cy = turned.a.cy + (turned.b.cy - turned.a.cy) * t;\n const rotation = turned.a.rotation + turn * t;\n if (rotation !== 0) path = rotatePathAround(path, cx, cy, rotation);\n }\n if (along) {\n const { point, tangent } = along.at(t * along.length);\n const angle = effect.orient ? Math.atan2(tangent.y, tangent.x) - chord : 0;\n const middle = { x: from.x + (to.x - from.x) * t, y: from.y + (to.y - from.y) * t };\n path = transformPath(path, multiply(translate(point.x, point.y), multiply(rotate(angle), translate(-middle.x, -middle.y))));\n }\n return { path, fill: lerpFill(a.fill, b.fill, t) ?? null, stroke: lerpStroke(a.stroke, b.stroke, t) ?? null };\n });\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"blend.js","names":[],"sources":["../../../../src/core/scene/pathEffects/blend.ts"],"sourcesContent":["/**\n * A blend's steps: where each sits between its two sources — evenly by\n * count, or a fixed distance apart — and, with a spine, laid along it.\n */\nimport {\n boundsOfPath, measurePath, multiply, prepareBlend, rotate, rotatePathAround, transformPath, translate, wrapAngle,\n type BlendPathsOptions, type Path, type PathMeasure,\n} from '@weasel-js/geom';\nimport { lerpFill, lerpStroke } from '@weasel-js/paint';\nimport type { DerivedPart } from '../derivedParts';\nimport type { EffectSource, PathEffectOptions } from './source';\n\n/** Steps between two shapes. Its sources are `[from, to]`, or\n * `[from, to, spine]` to lay the steps along a third path. */\nexport interface BlendEffect extends BlendPathsOptions, PathEffectOptions {\n kind: 'blend';\n /** How many shapes stand between the two sources, spacing by count. */\n steps: number;\n /**\n * How the steps are spaced. `'count'` puts `steps` of them evenly between\n * the sources; `'distance'` puts one every `distance` along the run from\n * the first source, as many as fit short of the second, so the last gap is\n * whatever is left. Default `'count'`.\n */\n spaceBy?: 'count' | 'distance';\n /** Distance from one shape's middle to the next, spacing by distance —\n * along the spine where there is one. Spacing by count when unset. */\n distance?: number;\n /**\n * Interpolate each source's rotation as a turn about its middle, rather\n * than moving every point in a straight line — so a shape turned 90° at\n * one end turns through 45° halfway instead of shrinking there. Default\n * `true`; it applies where both sources are turned boxes.\n */\n rotate?: boolean;\n /** With a spine, turn each step by how far the spine's heading where it\n * sits differs from the straight run between the sources. Default\n * `false`, which only moves them. */\n orient?: boolean;\n}\n\nconst EPS = 1e-9;\n\n/** The fractions of the way from the first source to the second that the\n * steps sit at, over a run `length` long. */\nexport function blendStations(effect: BlendEffect, length: number): number[] {\n const out: number[] = [];\n const d = effect.distance;\n if (effect.spaceBy === 'distance' && d !== undefined && d > 0) {\n if (!(length > 0)) return out;\n for (let s = d; s < length - EPS * Math.max(length, 1); s += d) out.push(s / length);\n return out;\n }\n const steps = Math.max(Math.floor(effect.steps), 0);\n for (let i = 1; i <= steps; i++) out.push(i / (steps + 1));\n return out;\n}\n\n/** The middle a source's steps are interpolated about: its turn's center\n * where it is a turned box, else its outline's bounds' middle. */\nfunction middleOf(source: EffectSource): { x: number; y: number } {\n if (source.framed) return { x: source.framed.cx, y: source.framed.cy };\n const b = boundsOfPath(source.world);\n return { x: b.x + b.width / 2, y: b.y + b.height / 2 };\n}\n\n/** The parts of `effect` between `a` and `b`, along `spine` when given.\n * `null` when the spine has no length to measure. */\nexport function blendParts(effect: BlendEffect, a: EffectSource, b: EffectSource, spine?: Path): DerivedPart[] | null {\n const from = middleOf(a);\n const to = middleOf(b);\n const along: PathMeasure | null = spine === undefined ? null : measurePath(spine);\n if (spine !== undefined && along === null) return null;\n const stations = blendStations(effect, along ? along.length : Math.hypot(to.x - from.x, to.y - from.y));\n if (stations.length === 0) return [];\n\n const turned = (effect.rotate ?? true) && a.framed !== undefined && b.framed !== undefined\n ? { a: a.framed, b: b.framed }\n : null;\n const blend = turned\n ? prepareBlend(turned.a.path, turned.b.path, effect)\n : prepareBlend(a.world, b.world, effect);\n // The shorter way round, so a turn of 350° reads as -10°.\n const turn = turned ? wrapAngle(turned.b.rotation - turned.a.rotation, 'sign') : 0;\n const chord = Math.atan2(to.y - from.y, to.x - from.x);\n return stations.map((t) => {\n let path: Path = blend.at(t);\n if (turned) {\n const cx = turned.a.cx + (turned.b.cx - turned.a.cx) * t;\n const cy = turned.a.cy + (turned.b.cy - turned.a.cy) * t;\n const rotation = turned.a.rotation + turn * t;\n if (rotation !== 0) path = rotatePathAround(path, cx, cy, rotation);\n }\n if (along) {\n const { point, tangent } = along.at(t * along.length);\n const angle = effect.orient ? Math.atan2(tangent.y, tangent.x) - chord : 0;\n const middle = { x: from.x + (to.x - from.x) * t, y: from.y + (to.y - from.y) * t };\n path = transformPath(path, multiply(translate(point.x, point.y), multiply(rotate(angle), translate(-middle.x, -middle.y))));\n }\n return { path, fill: lerpFill(a.fill, b.fill, t) ?? null, stroke: lerpStroke(a.stroke, b.stroke, t) ?? null };\n });\n}\n"],"mappings":"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"}
@@ -1,5 +1,5 @@
1
1
  import { bandParts } from "./textAlongBands.js";
2
- import { boundsOfPath, multiply, pathShape, placeAlongRun, rotate, runLength, trackRun, transformPath, translate } from "@weasel-js/geom";
2
+ import { DEG_TO_RAD, boundsOfPath, clamp01, multiply, pathShape, placeAlongRun, rotate, runLength, trackRun, transformPath, translate } from "@weasel-js/geom";
3
3
 
4
4
  // src/core/scene/pathEffects/textAlong.ts
5
5
  /**
@@ -19,11 +19,11 @@ function textAlongParts(effect, items, path) {
19
19
  const closed = shape.closed;
20
20
  const lengths = items.map((it) => it.length);
21
21
  const k = LEAD[effect.align ?? "start"];
22
- const at = clamp01(effect.start ?? 0) * L;
22
+ const at = unit(effect.start ?? 0) * L;
23
23
  const gap = effect.overflow === "fit" ? fitGap(lengths, roomAt(at, L, k, closed)) : 0;
24
24
  const total = runLength(lengths, gap);
25
25
  const c = at + (.5 - k) * total;
26
- const about = closed ? (c / L % 1 + 1) % 1 : clamp01(c / L);
26
+ const about = closed ? (c / L % 1 + 1) % 1 : unit(c / L);
27
27
  const run = trackRun(shape, 0, 1, {
28
28
  side: effect.side ?? "auto",
29
29
  about
@@ -97,10 +97,11 @@ function fitGap(lengths, room) {
97
97
  }
98
98
  /** Item frame to world: its middle on `place`, turned to it, its anchor line `lift` above y = 0 brought onto the path. */
99
99
  function itemFrame(place, length, lift) {
100
- const turn = multiply(rotate(place.angle * Math.PI / 180), translate(-length / 2, lift));
100
+ const turn = multiply(rotate(place.angle * DEG_TO_RAD), translate(-length / 2, lift));
101
101
  return multiply(translate(place.point.x, place.point.y), turn);
102
102
  }
103
- var clamp01 = (v) => v > 1 ? 1 : v > 0 ? v : 0;
103
+ /** `clamp01`, with `NaN` read as 0. */
104
+ var unit = (v) => clamp01(v) || 0;
104
105
  var finiteOr = (v, d) => v !== void 0 && Number.isFinite(v) ? v : d;
105
106
 
106
107
  export { textAlongParts, wholeItem };
@@ -1 +1 @@
1
- {"version":3,"file":"textAlong.js","names":[],"sources":["../../../../src/core/scene/pathEffects/textAlong.ts"],"sourcesContent":["/**\n * Text along a path: a source's items — a text node's grapheme clusters, or a\n * whole shape as one item — laid end to end along a path, each turned to it.\n */\nimport {\n boundsOfPath, multiply, pathShape, placeAlongRun, rotate, runLength, trackRun, transformPath, translate,\n type Affine, type Path, type RunAlign, type RunPlace, type TrackSide,\n} from '@weasel-js/geom';\nimport type { DerivedPart } from '../derivedParts';\nimport type { EffectItem, EffectSource, PathEffectOptions } from './source';\nimport { bandParts, type LaidItem } from './textAlongBands';\n\n/**\n * Text (or any shape) laid along a path, item by item. Its sources are `[text, path]`. Only the text's first laid line\n * follows the path; a color glyph keeps its advance and paints nothing. An item's bands — underline, strikethrough and\n * overline — join their alike neighbors' and bend with the path as one ribbon, while the glyphs stay rigid.\n * On an open path an item whose middle falls past either end is not drawn; on a closed one the text wraps past the\n * seam, and an item a whole lap on from the first item's start is not drawn.\n */\nexport interface TextAlongEffect extends PathEffectOptions {\n kind: 'textAlong';\n /** Which part of the text sits at `start`. Default `'start'`. */\n align?: RunAlign;\n /** Where the text sits, as a fraction 0..1 of the path's length from its first point. Default 0. */\n start?: number;\n /** How far the anchor line sits off the path, toward the glyphs' up, in world units. Default 0. */\n shift?: number;\n /** What sits on the path: the glyphs' baseline, or (`'center'`) their x-height middle. Default `'baseline'`. */\n anchor?: 'baseline' | 'center';\n /** Text longer than its room is cut at the path's end (`'clip'`), or has the gaps between items squeezed until it\n * fits (`'fit'`). Default `'clip'`. */\n overflow?: 'clip' | 'fit';\n /** Which side of the path the glyphs stand on; see `TrackSide`. Default `'auto'`, whichever reads upright. Turned over,\n * the text keeps the stretch of path it had, and on an open path drops whichever letters fall off it. */\n side?: TrackSide;\n}\n\nconst LEAD: Record<RunAlign, number> = { start: 0, center: 0.5, end: 1 };\n\n/** The parts `items` paint laid along `path` as `effect` says; `[]` when there is nothing to lay or nowhere to lay it. */\nexport function textAlongParts(effect: TextAlongEffect, items: readonly EffectItem[], path: Path): DerivedPart[] {\n const shape = pathShape(path);\n if (shape === null || !(shape.length > 0) || items.length === 0) return [];\n const L = shape.length;\n const closed = shape.closed;\n const lengths = items.map((it) => it.length);\n const k = LEAD[effect.align ?? 'start'];\n const at = clamp01(effect.start ?? 0) * L;\n const gap = effect.overflow === 'fit' ? fitGap(lengths, roomAt(at, L, k, closed)) : 0;\n const total = runLength(lengths, gap);\n const c = at + (0.5 - k) * total;\n const about = closed ? ((c / L) % 1 + 1) % 1 : clamp01(c / L);\n const run = trackRun(shape, 0, 1, { side: effect.side ?? 'auto', about });\n const shift = finiteOr(effect.shift, 0);\n const places = placeAlongRun(run, lengths, { gap, align: 'center', at: run.reversed ? L - c : c, shift, wrap: closed });\n const center = effect.anchor === 'center';\n const out: DerivedPart[] = [];\n const laid: (LaidItem | null)[] = [];\n const firstStart = places[0]!.center - lengths[0]! / 2;\n places.forEach((place, i) => {\n // By center (SVG textPath's rule), not by RunPlace.clipped, which judges by extent.\n if (dropped(place, closed ? firstStart : null, L)) {\n laid.push(null);\n return;\n }\n const item = items[i]!;\n const lift = center ? item.middle : 0;\n const m = itemFrame(place, item.length, lift);\n for (const part of item.parts) out.push({ ...part, path: transformPath(part.path, m) });\n laid.push({ item, start: place.center - item.length / 2, lift });\n });\n if (items.some((it) => it.bands !== undefined && it.bands.length > 0)) out.push(...bandParts(laid, run, shift));\n return out;\n}\n\n/** `source`'s whole outline as one item: its bounds' left at x = 0 and their vertical middle on the anchor line. */\nexport function wholeItem(source: EffectSource): EffectItem {\n const b = boundsOfPath(source.world);\n return {\n length: b.width,\n middle: 0,\n parts: [{\n path: transformPath(source.world, translate(-b.x, -(b.y + b.height / 2))),\n fill: source.fill,\n stroke: source.stroke,\n }],\n };\n}\n\n/** SVG `textPath`'s rule: an item is not drawn once its middle is past an open path's ends, or on a closed path a whole\n * lap or more on from the first item's start. Judged within a hair of rounding. */\nfunction dropped(place: RunPlace, firstStart: number | null, L: number): boolean {\n const hair = 1e-9 * Math.max(1, L);\n if (firstStart !== null) return place.center - firstStart >= L - hair;\n return place.center < -hair || place.center > L + hair;\n}\n\n/** How much path the text has before it runs off, from `at`, with `k` of it laid back behind `at`. */\nfunction roomAt(at: number, L: number, k: number, closed: boolean): number {\n if (closed) return L;\n return k === 0 ? L - at : k === 1 ? at : 2 * Math.min(at, L - at);\n}\n\n/** The gap that makes `lengths` fill `room`, or 0 when they fit already. Never so negative that an item's step back\n * from the one before it (its length plus the gap) goes below zero, so items keep their order in too short a room. */\nfunction fitGap(lengths: readonly number[], room: number): number {\n const total = runLength(lengths, 0);\n if (!(total > room) || lengths.length < 2) return 0;\n let floor = -Infinity;\n for (let i = 1; i < lengths.length; i++) floor = Math.max(floor, -(lengths[i - 1]! + lengths[i]!) / 2);\n return Math.max((room - total) / (lengths.length - 1), floor);\n}\n\n/** Item frame to world: its middle on `place`, turned to it, its anchor line `lift` above y = 0 brought onto the path. */\nfunction itemFrame(place: RunPlace, length: number, lift: number): Affine {\n const turn = multiply(rotate((place.angle * Math.PI) / 180), translate(-length / 2, lift));\n return multiply(translate(place.point.x, place.point.y), turn);\n}\n\nconst clamp01 = (v: number): number => (v > 1 ? 1 : v > 0 ? v : 0);\nconst finiteOr = (v: number | undefined, d: number): number => (v !== undefined && Number.isFinite(v) ? v : d);\n"],"mappings":"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"}
1
+ {"version":3,"file":"textAlong.js","names":[],"sources":["../../../../src/core/scene/pathEffects/textAlong.ts"],"sourcesContent":["/**\n * Text along a path: a source's items — a text node's grapheme clusters, or a\n * whole shape as one item — laid end to end along a path, each turned to it.\n */\nimport {\n DEG_TO_RAD, boundsOfPath, clamp01, multiply, pathShape, placeAlongRun, rotate, runLength, trackRun, transformPath, translate,\n type Affine, type Path, type RunAlign, type RunPlace, type TrackSide,\n} from '@weasel-js/geom';\nimport type { DerivedPart } from '../derivedParts';\nimport type { EffectItem, EffectSource, PathEffectOptions } from './source';\nimport { bandParts, type LaidItem } from './textAlongBands';\n\n/**\n * Text (or any shape) laid along a path, item by item. Its sources are `[text, path]`. Only the text's first laid line\n * follows the path; a color glyph keeps its advance and paints nothing. An item's bands — underline, strikethrough and\n * overline — join their alike neighbors' and bend with the path as one ribbon, while the glyphs stay rigid.\n * On an open path an item whose middle falls past either end is not drawn; on a closed one the text wraps past the\n * seam, and an item a whole lap on from the first item's start is not drawn.\n */\nexport interface TextAlongEffect extends PathEffectOptions {\n kind: 'textAlong';\n /** Which part of the text sits at `start`. Default `'start'`. */\n align?: RunAlign;\n /** Where the text sits, as a fraction 0..1 of the path's length from its first point. Default 0. */\n start?: number;\n /** How far the anchor line sits off the path, toward the glyphs' up, in world units. Default 0. */\n shift?: number;\n /** What sits on the path: the glyphs' baseline, or (`'center'`) their x-height middle. Default `'baseline'`. */\n anchor?: 'baseline' | 'center';\n /** Text longer than its room is cut at the path's end (`'clip'`), or has the gaps between items squeezed until it\n * fits (`'fit'`). Default `'clip'`. */\n overflow?: 'clip' | 'fit';\n /** Which side of the path the glyphs stand on; see `TrackSide`. Default `'auto'`, whichever reads upright. Turned over,\n * the text keeps the stretch of path it had, and on an open path drops whichever letters fall off it. */\n side?: TrackSide;\n}\n\nconst LEAD: Record<RunAlign, number> = { start: 0, center: 0.5, end: 1 };\n\n/** The parts `items` paint laid along `path` as `effect` says; `[]` when there is nothing to lay or nowhere to lay it. */\nexport function textAlongParts(effect: TextAlongEffect, items: readonly EffectItem[], path: Path): DerivedPart[] {\n const shape = pathShape(path);\n if (shape === null || !(shape.length > 0) || items.length === 0) return [];\n const L = shape.length;\n const closed = shape.closed;\n const lengths = items.map((it) => it.length);\n const k = LEAD[effect.align ?? 'start'];\n const at = unit(effect.start ?? 0) * L;\n const gap = effect.overflow === 'fit' ? fitGap(lengths, roomAt(at, L, k, closed)) : 0;\n const total = runLength(lengths, gap);\n const c = at + (0.5 - k) * total;\n const about = closed ? ((c / L) % 1 + 1) % 1 : unit(c / L);\n const run = trackRun(shape, 0, 1, { side: effect.side ?? 'auto', about });\n const shift = finiteOr(effect.shift, 0);\n const places = placeAlongRun(run, lengths, { gap, align: 'center', at: run.reversed ? L - c : c, shift, wrap: closed });\n const center = effect.anchor === 'center';\n const out: DerivedPart[] = [];\n const laid: (LaidItem | null)[] = [];\n const firstStart = places[0]!.center - lengths[0]! / 2;\n places.forEach((place, i) => {\n // By center (SVG textPath's rule), not by RunPlace.clipped, which judges by extent.\n if (dropped(place, closed ? firstStart : null, L)) {\n laid.push(null);\n return;\n }\n const item = items[i]!;\n const lift = center ? item.middle : 0;\n const m = itemFrame(place, item.length, lift);\n for (const part of item.parts) out.push({ ...part, path: transformPath(part.path, m) });\n laid.push({ item, start: place.center - item.length / 2, lift });\n });\n if (items.some((it) => it.bands !== undefined && it.bands.length > 0)) out.push(...bandParts(laid, run, shift));\n return out;\n}\n\n/** `source`'s whole outline as one item: its bounds' left at x = 0 and their vertical middle on the anchor line. */\nexport function wholeItem(source: EffectSource): EffectItem {\n const b = boundsOfPath(source.world);\n return {\n length: b.width,\n middle: 0,\n parts: [{\n path: transformPath(source.world, translate(-b.x, -(b.y + b.height / 2))),\n fill: source.fill,\n stroke: source.stroke,\n }],\n };\n}\n\n/** SVG `textPath`'s rule: an item is not drawn once its middle is past an open path's ends, or on a closed path a whole\n * lap or more on from the first item's start. Judged within a hair of rounding. */\nfunction dropped(place: RunPlace, firstStart: number | null, L: number): boolean {\n const hair = 1e-9 * Math.max(1, L);\n if (firstStart !== null) return place.center - firstStart >= L - hair;\n return place.center < -hair || place.center > L + hair;\n}\n\n/** How much path the text has before it runs off, from `at`, with `k` of it laid back behind `at`. */\nfunction roomAt(at: number, L: number, k: number, closed: boolean): number {\n if (closed) return L;\n return k === 0 ? L - at : k === 1 ? at : 2 * Math.min(at, L - at);\n}\n\n/** The gap that makes `lengths` fill `room`, or 0 when they fit already. Never so negative that an item's step back\n * from the one before it (its length plus the gap) goes below zero, so items keep their order in too short a room. */\nfunction fitGap(lengths: readonly number[], room: number): number {\n const total = runLength(lengths, 0);\n if (!(total > room) || lengths.length < 2) return 0;\n let floor = -Infinity;\n for (let i = 1; i < lengths.length; i++) floor = Math.max(floor, -(lengths[i - 1]! + lengths[i]!) / 2);\n return Math.max((room - total) / (lengths.length - 1), floor);\n}\n\n/** Item frame to world: its middle on `place`, turned to it, its anchor line `lift` above y = 0 brought onto the path. */\nfunction itemFrame(place: RunPlace, length: number, lift: number): Affine {\n const turn = multiply(rotate(place.angle * DEG_TO_RAD), translate(-length / 2, lift));\n return multiply(translate(place.point.x, place.point.y), turn);\n}\n\n/** `clamp01`, with `NaN` read as 0. */\nconst unit = (v: number): number => clamp01(v) || 0;\nconst finiteOr = (v: number | undefined, d: number): number => (v !== undefined && Number.isFinite(v) ? v : d);\n"],"mappings":";;;;;;;;AAqCA,IAAM,OAAiC;CAAE,OAAO;CAAG,QAAQ;CAAK,KAAK;AAAE;;AAGvE,SAAgB,eAAe,QAAyB,OAA8B,MAA2B;CAC/G,MAAM,QAAQ,UAAU,IAAI;CAC5B,IAAI,UAAU,QAAQ,EAAE,MAAM,SAAS,MAAM,MAAM,WAAW,GAAG,OAAO,CAAC;CACzE,MAAM,IAAI,MAAM;CAChB,MAAM,SAAS,MAAM;CACrB,MAAM,UAAU,MAAM,KAAK,OAAO,GAAG,MAAM;CAC3C,MAAM,IAAI,KAAK,OAAO,SAAS;CAC/B,MAAM,KAAK,KAAK,OAAO,SAAS,CAAC,IAAI;CACrC,MAAM,MAAM,OAAO,aAAa,QAAQ,OAAO,SAAS,OAAO,IAAI,GAAG,GAAG,MAAM,CAAC,IAAI;CACpF,MAAM,QAAQ,UAAU,SAAS,GAAG;CACpC,MAAM,IAAI,MAAM,KAAM,KAAK;CAC3B,MAAM,QAAQ,UAAW,IAAI,IAAK,IAAI,KAAK,IAAI,KAAK,IAAI,CAAC;CACzD,MAAM,MAAM,SAAS,OAAO,GAAG,GAAG;EAAE,MAAM,OAAO,QAAQ;EAAQ;CAAM,CAAC;CACxE,MAAM,QAAQ,SAAS,OAAO,OAAO,CAAC;CACtC,MAAM,SAAS,cAAc,KAAK,SAAS;EAAE;EAAK,OAAO;EAAU,IAAI,IAAI,WAAW,IAAI,IAAI;EAAG;EAAO,MAAM;CAAO,CAAC;CACtH,MAAM,SAAS,OAAO,WAAW;CACjC,MAAM,MAAqB,CAAC;CAC5B,MAAM,OAA4B,CAAC;CACnC,MAAM,aAAa,OAAO,EAAE,CAAE,SAAS,QAAQ,KAAM;CACrD,OAAO,SAAS,OAAO,MAAM;EAE3B,IAAI,QAAQ,OAAO,SAAS,aAAa,MAAM,CAAC,GAAG;GACjD,KAAK,KAAK,IAAI;GACd;EACF;EACA,MAAM,OAAO,MAAM;EACnB,MAAM,OAAO,SAAS,KAAK,SAAS;EACpC,MAAM,IAAI,UAAU,OAAO,KAAK,QAAQ,IAAI;EAC5C,KAAK,MAAM,QAAQ,KAAK,OAAO,IAAI,KAAK;GAAE,GAAG;GAAM,MAAM,cAAc,KAAK,MAAM,CAAC;EAAE,CAAC;EACtF,KAAK,KAAK;GAAE;GAAM,OAAO,MAAM,SAAS,KAAK,SAAS;GAAG;EAAK,CAAC;CACjE,CAAC;CACD,IAAI,MAAM,MAAM,OAAO,GAAG,UAAU,UAAa,GAAG,MAAM,SAAS,CAAC,GAAG,IAAI,KAAK,GAAG,UAAU,MAAM,KAAK,KAAK,CAAC;CAC9G,OAAO;AACT;;AAGA,SAAgB,UAAU,QAAkC;CAC1D,MAAM,IAAI,aAAa,OAAO,KAAK;CACnC,OAAO;EACL,QAAQ,EAAE;EACV,QAAQ;EACR,OAAO,CAAC;GACN,MAAM,cAAc,OAAO,OAAO,UAAU,CAAC,EAAE,GAAG,EAAE,EAAE,IAAI,EAAE,SAAS,EAAE,CAAC;GACxE,MAAM,OAAO;GACb,QAAQ,OAAO;EACjB,CAAC;CACH;AACF;;;AAIA,SAAS,QAAQ,OAAiB,YAA2B,GAAoB;CAC/E,MAAM,OAAO,OAAO,KAAK,IAAI,GAAG,CAAC;CACjC,IAAI,eAAe,MAAM,OAAO,MAAM,SAAS,cAAc,IAAI;CACjE,OAAO,MAAM,SAAS,CAAC,QAAQ,MAAM,SAAS,IAAI;AACpD;;AAGA,SAAS,OAAO,IAAY,GAAW,GAAW,QAAyB;CACzE,IAAI,QAAQ,OAAO;CACnB,OAAO,MAAM,IAAI,IAAI,KAAK,MAAM,IAAI,KAAK,IAAI,KAAK,IAAI,IAAI,IAAI,EAAE;AAClE;;;AAIA,SAAS,OAAO,SAA4B,MAAsB;CAChE,MAAM,QAAQ,UAAU,SAAS,CAAC;CAClC,IAAI,EAAE,QAAQ,SAAS,QAAQ,SAAS,GAAG,OAAO;CAClD,IAAI,QAAQ;CACZ,KAAK,IAAI,IAAI,GAAG,IAAI,QAAQ,QAAQ,KAAK,QAAQ,KAAK,IAAI,OAAO,EAAE,QAAQ,IAAI,KAAM,QAAQ,MAAO,CAAC;CACrG,OAAO,KAAK,KAAK,OAAO,UAAU,QAAQ,SAAS,IAAI,KAAK;AAC9D;;AAGA,SAAS,UAAU,OAAiB,QAAgB,MAAsB;CACxE,MAAM,OAAO,SAAS,OAAO,MAAM,QAAQ,UAAU,GAAG,UAAU,CAAC,SAAS,GAAG,IAAI,CAAC;CACpF,OAAO,SAAS,UAAU,MAAM,MAAM,GAAG,MAAM,MAAM,CAAC,GAAG,IAAI;AAC/D;;AAGA,IAAM,QAAQ,MAAsB,QAAQ,CAAC,KAAK;AAClD,IAAM,YAAY,GAAuB,MAAuB,MAAM,UAAa,OAAO,SAAS,CAAC,IAAI,IAAI"}
@@ -29,6 +29,17 @@ function shownNode(scene, id) {
29
29
  }
30
30
  var defaultIdCounter = 0;
31
31
  var defaultGenerateId = () => asNodeId(`n${(defaultIdCounter++).toString(36)}-${Math.random().toString(36).slice(2, 8)}`);
32
+ /** `mint`, passing over any id `taken` already holds: a document reloaded into a new session keeps the ids the last
33
+ * session minted, and a generator restarted with the session can mint them again. */
34
+ function freshIds(mint, taken) {
35
+ return () => {
36
+ for (let tries = 0; tries < 1e3; tries++) {
37
+ const id = mint();
38
+ if (!taken(id)) return id;
39
+ }
40
+ throw new Error("scene: generateId gave 1000 ids the scene already holds");
41
+ };
42
+ }
32
43
  /** Build a scene outside React. `useScene` is the usual entry point; reach for
33
44
  * this in tests, in headless rendering, or wherever a scene must outlive a
34
45
  * component. Throws if no system layers are declared. */
@@ -58,7 +69,7 @@ function buildScene(options, layers, initialNodes, initialDoc) {
58
69
  recordsBareEdits: () => !mode.open(),
59
70
  writePose
60
71
  });
61
- const generateId = options.generateId ?? defaultGenerateId;
72
+ const generateId = freshIds(options.generateId ?? defaultGenerateId, (id) => state.nodes.has(id));
62
73
  const selection = createSceneSelection(locks.reachable, (id) => state.nodes.get(id)?.parent);
63
74
  selection.subscribe(notifier.notify);
64
75
  /** Ephemeral, deliberately not part of `state`: not document content, never in `toJSON` or history. */
@@ -1 +1 @@
1
- {"version":3,"file":"scene.js","names":[],"sources":["../../../src/core/scene/scene.ts"],"sourcesContent":["import { withKitRegistry } from './kitRegistry';\nimport { createPoseOverrides } from './poseOverrides';\nimport { createScrollOffsets } from './scrollOffsets';\nimport { createArrivalWindow } from './arrivalWindow';\nimport { documentReads, sceneMutators } from './sceneMutators';\nimport { createNotifier } from './notifier';\nimport type { SerializedDoc } from './docValues';\nimport { sceneChanges } from './sceneChanges';\nimport { createPoseWriteLog } from './poseWrites';\nimport { effectivePose } from './effectivePose';\nimport { createEditMode } from './editMode';\nimport { createSceneEdits } from './sceneEdits';\nimport { createSceneRecording } from './sceneRecording';\nimport { createSceneSelection } from './sceneSelection';\nimport { createSceneState } from './sceneState';\nimport { systemLayerRecord } from './layerStack';\nimport { layerFromSerialized, specsFromSerialized, type SpecsFromSerializedOptions } from './serialization';\nimport { resolveLayoutFrame, runLayoutPass } from './layoutPass';\nimport type { PoseComposition } from './composeFrame';\nimport { RIGID_POSE_COMPOSITION } from './composePose';\nimport type { SceneOptions } from './useSceneOptions';\nimport {\n asNodeId,\n type AddNodeSpec,\n type LayerRecord,\n type NodeId,\n type RegisteredOp,\n type Scene,\n type SceneRegistry,\n type SavedScene,\n type UseSceneOptions,\n} from './types';\n\n/** A node still in `scene`, with the pose it is shown at. */\nfunction shownNode<TData, TLayer extends string, TPose>(scene: Scene<TData, TLayer, TPose>, id: NodeId) {\n const node = scene.get(id);\n return node && { node, pose: effectivePose(scene, node) };\n}\n\nlet defaultIdCounter = 0;\nconst defaultGenerateId = (): NodeId =>\n asNodeId(`n${(defaultIdCounter++).toString(36)}-${Math.random().toString(36).slice(2, 8)}`);\n\n/** Build a scene outside React. `useScene` is the usual entry point; reach for\n * this in tests, in headless rendering, or wherever a scene must outlive a\n * component. Throws if no system layers are declared. */\nexport function createScene<TData, TLayer extends string, TPose = import('./composePose').RectPose>(\n options: UseSceneOptions<TData, TLayer, TPose>,\n): Scene<TData, TLayer, TPose> {\n return buildScene(options, (options.systemLayers ?? []).map(systemLayerRecord), () => options.initial, options.doc);\n}\n\ntype SceneConfig<TData, TLayer extends string, TPose> =\n Omit<SceneOptions<TData, TLayer, TPose>, 'systemLayers' | 'initial' | 'doc'>;\n\n/** A scene holding `layers`, the document values `initialDoc`, and the nodes\n * `initialNodes` answers with once it is handed the registry their functions\n * resolve against. */\nfunction buildScene<TData, TLayer extends string, TPose>(\n options: SceneConfig<TData, TLayer, TPose>,\n layers: readonly LayerRecord<TLayer>[],\n initialNodes: (registry: SceneRegistry<TPose, TData, TLayer>) => readonly AddNodeSpec<TData, TLayer, TPose>[] | undefined,\n initialDoc: SerializedDoc | undefined,\n): Scene<TData, TLayer, TPose> {\n if (layers.length === 0) throw new Error('Scene: systemLayers must declare at least one layer');\n\n // The kit's own registry entries sit under the consumer's, so a snapshot carrying a kit-attached\n // function (a grouped container's derived pose) reloads in any scene, not only one that registered it.\n const registry = withKitRegistry(options.registry ?? {});\n\n const doc = createSceneState<TData, TLayer, TPose>(registry, layers, () => arrivals.layoutDeclared(), initialDoc);\n const { state, locks, reads } = doc;\n const notifier = createNotifier();\n const mode = createEditMode();\n const poseWrites = createPoseWriteLog({ untracked: mode.untracked, changes: notifier, shown: (id) => shownNode(scene, id) });\n const writePose = (id: string, pose: unknown): void => poseWrites.layout(() => scene.setPose(asNodeId(id), pose as TPose));\n\n const layoutFrame = resolveLayoutFrame(options.layoutFrame);\n const arrivals = createArrivalWindow<TData, TLayer, TPose>({\n nodes: state.nodes,\n layoutPass: (arrived, touched, departed) =>\n runLayoutPass(scene, reads.layoutOf, arrived, touched, layoutFrame, departed),\n recordsBareEdits: () => !mode.open(),\n writePose,\n });\n\n const generateId = options.generateId ?? defaultGenerateId;\n const selection = createSceneSelection(locks.reachable, (id) => state.nodes.get(id)?.parent);\n // A selection change is a scene change to every subscriber.\n selection.subscribe(notifier.notify);\n\n /** Ephemeral, deliberately not part of `state`: not document content, never in `toJSON` or history. */\n const overrides = createPoseOverrides<TPose, TData>((id) => state.nodes.get(id), doc.store.invalidateDependents);\n const scroll = createScrollOffsets();\n\n const recording = createSceneRecording({\n notifier,\n mode,\n passAdapter: () => ({ ...sceneMutators(scene), ...documentReads(scene) }),\n stale: doc.stale,\n lifted: locks.lifted,\n selection,\n arrivals,\n writePose,\n poseWrites,\n config: options,\n });\n\n const edits = createSceneEdits<TData, TLayer, TPose>({\n state,\n store: doc.store,\n locks,\n layers: doc.layers,\n invalidateOrder: doc.order.invalidate,\n fnFields: doc.fnFields,\n ops: { ...recording.ops, register: poseWrites.reporting(recording.ops.register) },\n recorder: recording.recorder,\n arrivals,\n layoutFrame,\n generateId,\n narrowSelection: selection.narrow,\n clearOverride: overrides.clear,\n });\n for (const [kind, handler] of Object.entries(options.ops ?? {})) recording.ops.register(kind, handler);\n\n const scene: Scene<TData, TLayer, TPose> = {\n get nodes() { return state.nodes; },\n get roots() { return state.roots; },\n get layers() { return state.layers; },\n registry,\n\n ...reads,\n\n mintId: generateId,\n composition: options.composition ?? (RIGID_POSE_COMPOSITION as PoseComposition<TPose>),\n\n ...edits.methods,\n ...recording.methods,\n\n unlocked: (fn) => locks.lifted(fn),\n\n setArrivalHandler: arrivals.setHandler,\n onReflow: arrivals.onReflow,\n onPoseWrite: poseWrites.subscribe,\n\n overrides,\n scroll,\n\n getSelection: selection.get,\n setSelection: (ids) => selection.set([...ids]),\n\n loadState(json, opts) {\n // Validate + map up front: throws on a bad version or registry key before live state is touched.\n const specs = specsFromSerialized(json, registry, { composition: scene.composition, ...opts });\n if (mode.open()) {\n throw new Error('Scene: cannot loadState inside another edit — it replaces the state that edit would revert to');\n }\n // Ephemeral, and every id is a new node after this.\n overrides.clearAll();\n scroll.clearAll();\n doc.reset(json.systemLayers.map(layerFromSerialized), json.doc);\n recording.clearHistory();\n poseWrites.quiet(() => edits.construct(specs));\n selection.replace(selection.legal(selection.get().filter((id) => state.nodes.has(id))));\n notifier.notify();\n },\n\n ...sceneChanges(notifier, selection),\n };\n\n const initial = initialNodes(registry);\n if (initial) {\n poseWrites.quiet(() => edits.construct(initial));\n notifier.notify();\n }\n\n return scene;\n}\n\n/** Reconstruct a Scene from a JSON snapshot produced by `scene.toJSON()`.\n * Function fields (e.g., `clipFromPose`) are resolved by string key via the\n * registry passed in `options`. Throws on unknown version, unknown registry\n * keys, or invalid scene shape (cross-layer subtrees, unknown layer ids).\n * The scene starts with empty history. */\nexport function sceneFromJSON<TData, TLayer extends string, TPose>(\n json: SavedScene<TData, TLayer, TPose>,\n options: {\n registry?: SceneRegistry<TPose, TData, TLayer>;\n historyLimit?: number;\n coalesceWindowMs?: number;\n generateId?: () => NodeId;\n ops?: Readonly<Record<string, RegisteredOp<unknown>>>;\n composition?: SceneOptions<TData, TLayer, TPose>['composition'];\n layoutFrame?: SceneOptions<TData, TLayer, TPose>['layoutFrame'];\n } & SpecsFromSerializedOptions<TData, TLayer, TPose> = {},\n): Scene<TData, TLayer, TPose> {\n return buildScene(\n options,\n json.systemLayers.map(layerFromSerialized),\n (registry) => specsFromSerialized(json, registry, options),\n json.doc,\n );\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"scene.js","names":[],"sources":["../../../src/core/scene/scene.ts"],"sourcesContent":["import { withKitRegistry } from './kitRegistry';\nimport { createPoseOverrides } from './poseOverrides';\nimport { createScrollOffsets } from './scrollOffsets';\nimport { createArrivalWindow } from './arrivalWindow';\nimport { documentReads, sceneMutators } from './sceneMutators';\nimport { createNotifier } from './notifier';\nimport type { SerializedDoc } from './docValues';\nimport { sceneChanges } from './sceneChanges';\nimport { createPoseWriteLog } from './poseWrites';\nimport { effectivePose } from './effectivePose';\nimport { createEditMode } from './editMode';\nimport { createSceneEdits } from './sceneEdits';\nimport { createSceneRecording } from './sceneRecording';\nimport { createSceneSelection } from './sceneSelection';\nimport { createSceneState } from './sceneState';\nimport { systemLayerRecord } from './layerStack';\nimport { layerFromSerialized, specsFromSerialized, type SpecsFromSerializedOptions } from './serialization';\nimport { resolveLayoutFrame, runLayoutPass } from './layoutPass';\nimport type { PoseComposition } from './composeFrame';\nimport { RIGID_POSE_COMPOSITION } from './composePose';\nimport type { SceneOptions } from './useSceneOptions';\nimport {\n asNodeId,\n type AddNodeSpec,\n type LayerRecord,\n type NodeId,\n type RegisteredOp,\n type Scene,\n type SceneRegistry,\n type SavedScene,\n type UseSceneOptions,\n} from './types';\n\n/** A node still in `scene`, with the pose it is shown at. */\nfunction shownNode<TData, TLayer extends string, TPose>(scene: Scene<TData, TLayer, TPose>, id: NodeId) {\n const node = scene.get(id);\n return node && { node, pose: effectivePose(scene, node) };\n}\n\nlet defaultIdCounter = 0;\nconst defaultGenerateId = (): NodeId =>\n asNodeId(`n${(defaultIdCounter++).toString(36)}-${Math.random().toString(36).slice(2, 8)}`);\n\n/** `mint`, passing over any id `taken` already holds: a document reloaded into a new session keeps the ids the last\n * session minted, and a generator restarted with the session can mint them again. */\nfunction freshIds(mint: () => NodeId, taken: (id: NodeId) => boolean): () => NodeId {\n return () => {\n for (let tries = 0; tries < 1000; tries++) {\n const id = mint();\n if (!taken(id)) return id;\n }\n throw new Error('scene: generateId gave 1000 ids the scene already holds');\n };\n}\n\n/** Build a scene outside React. `useScene` is the usual entry point; reach for\n * this in tests, in headless rendering, or wherever a scene must outlive a\n * component. Throws if no system layers are declared. */\nexport function createScene<TData, TLayer extends string, TPose = import('./composePose').RectPose>(\n options: UseSceneOptions<TData, TLayer, TPose>,\n): Scene<TData, TLayer, TPose> {\n return buildScene(options, (options.systemLayers ?? []).map(systemLayerRecord), () => options.initial, options.doc);\n}\n\ntype SceneConfig<TData, TLayer extends string, TPose> =\n Omit<SceneOptions<TData, TLayer, TPose>, 'systemLayers' | 'initial' | 'doc'>;\n\n/** A scene holding `layers`, the document values `initialDoc`, and the nodes\n * `initialNodes` answers with once it is handed the registry their functions\n * resolve against. */\nfunction buildScene<TData, TLayer extends string, TPose>(\n options: SceneConfig<TData, TLayer, TPose>,\n layers: readonly LayerRecord<TLayer>[],\n initialNodes: (registry: SceneRegistry<TPose, TData, TLayer>) => readonly AddNodeSpec<TData, TLayer, TPose>[] | undefined,\n initialDoc: SerializedDoc | undefined,\n): Scene<TData, TLayer, TPose> {\n if (layers.length === 0) throw new Error('Scene: systemLayers must declare at least one layer');\n\n // The kit's own registry entries sit under the consumer's, so a snapshot carrying a kit-attached\n // function (a grouped container's derived pose) reloads in any scene, not only one that registered it.\n const registry = withKitRegistry(options.registry ?? {});\n\n const doc = createSceneState<TData, TLayer, TPose>(registry, layers, () => arrivals.layoutDeclared(), initialDoc);\n const { state, locks, reads } = doc;\n const notifier = createNotifier();\n const mode = createEditMode();\n const poseWrites = createPoseWriteLog({ untracked: mode.untracked, changes: notifier, shown: (id) => shownNode(scene, id) });\n const writePose = (id: string, pose: unknown): void => poseWrites.layout(() => scene.setPose(asNodeId(id), pose as TPose));\n\n const layoutFrame = resolveLayoutFrame(options.layoutFrame);\n const arrivals = createArrivalWindow<TData, TLayer, TPose>({\n nodes: state.nodes,\n layoutPass: (arrived, touched, departed) =>\n runLayoutPass(scene, reads.layoutOf, arrived, touched, layoutFrame, departed),\n recordsBareEdits: () => !mode.open(),\n writePose,\n });\n\n const generateId = freshIds(options.generateId ?? defaultGenerateId, (id) => state.nodes.has(id));\n const selection = createSceneSelection(locks.reachable, (id) => state.nodes.get(id)?.parent);\n // A selection change is a scene change to every subscriber.\n selection.subscribe(notifier.notify);\n\n /** Ephemeral, deliberately not part of `state`: not document content, never in `toJSON` or history. */\n const overrides = createPoseOverrides<TPose, TData>((id) => state.nodes.get(id), doc.store.invalidateDependents);\n const scroll = createScrollOffsets();\n\n const recording = createSceneRecording({\n notifier,\n mode,\n passAdapter: () => ({ ...sceneMutators(scene), ...documentReads(scene) }),\n stale: doc.stale,\n lifted: locks.lifted,\n selection,\n arrivals,\n writePose,\n poseWrites,\n config: options,\n });\n\n const edits = createSceneEdits<TData, TLayer, TPose>({\n state,\n store: doc.store,\n locks,\n layers: doc.layers,\n invalidateOrder: doc.order.invalidate,\n fnFields: doc.fnFields,\n ops: { ...recording.ops, register: poseWrites.reporting(recording.ops.register) },\n recorder: recording.recorder,\n arrivals,\n layoutFrame,\n generateId,\n narrowSelection: selection.narrow,\n clearOverride: overrides.clear,\n });\n for (const [kind, handler] of Object.entries(options.ops ?? {})) recording.ops.register(kind, handler);\n\n const scene: Scene<TData, TLayer, TPose> = {\n get nodes() { return state.nodes; },\n get roots() { return state.roots; },\n get layers() { return state.layers; },\n registry,\n\n ...reads,\n\n mintId: generateId,\n composition: options.composition ?? (RIGID_POSE_COMPOSITION as PoseComposition<TPose>),\n\n ...edits.methods,\n ...recording.methods,\n\n unlocked: (fn) => locks.lifted(fn),\n\n setArrivalHandler: arrivals.setHandler,\n onReflow: arrivals.onReflow,\n onPoseWrite: poseWrites.subscribe,\n\n overrides,\n scroll,\n\n getSelection: selection.get,\n setSelection: (ids) => selection.set([...ids]),\n\n loadState(json, opts) {\n // Validate + map up front: throws on a bad version or registry key before live state is touched.\n const specs = specsFromSerialized(json, registry, { composition: scene.composition, ...opts });\n if (mode.open()) {\n throw new Error('Scene: cannot loadState inside another edit — it replaces the state that edit would revert to');\n }\n // Ephemeral, and every id is a new node after this.\n overrides.clearAll();\n scroll.clearAll();\n doc.reset(json.systemLayers.map(layerFromSerialized), json.doc);\n recording.clearHistory();\n poseWrites.quiet(() => edits.construct(specs));\n selection.replace(selection.legal(selection.get().filter((id) => state.nodes.has(id))));\n notifier.notify();\n },\n\n ...sceneChanges(notifier, selection),\n };\n\n const initial = initialNodes(registry);\n if (initial) {\n poseWrites.quiet(() => edits.construct(initial));\n notifier.notify();\n }\n\n return scene;\n}\n\n/** Reconstruct a Scene from a JSON snapshot produced by `scene.toJSON()`.\n * Function fields (e.g., `clipFromPose`) are resolved by string key via the\n * registry passed in `options`. Throws on unknown version, unknown registry\n * keys, or invalid scene shape (cross-layer subtrees, unknown layer ids).\n * The scene starts with empty history. */\nexport function sceneFromJSON<TData, TLayer extends string, TPose>(\n json: SavedScene<TData, TLayer, TPose>,\n options: {\n registry?: SceneRegistry<TPose, TData, TLayer>;\n historyLimit?: number;\n coalesceWindowMs?: number;\n generateId?: () => NodeId;\n ops?: Readonly<Record<string, RegisteredOp<unknown>>>;\n composition?: SceneOptions<TData, TLayer, TPose>['composition'];\n layoutFrame?: SceneOptions<TData, TLayer, TPose>['layoutFrame'];\n } & SpecsFromSerializedOptions<TData, TLayer, TPose> = {},\n): Scene<TData, TLayer, TPose> {\n return buildScene(\n options,\n json.systemLayers.map(layerFromSerialized),\n (registry) => specsFromSerialized(json, registry, options),\n json.doc,\n );\n}\n"],"mappings":"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"}
@@ -1,3 +1,5 @@
1
+ import { viewScreenToWorld } from "@weasel-js/geom";
2
+
1
3
  // src/core/viewport/clientToWorld.ts
2
4
  /**
3
5
  * Map a browser client coord pair to world coords.
@@ -11,7 +13,11 @@
11
13
  * @param view - current viewport transform
12
14
  */
13
15
  function clientToWorld(clientX, clientY, origin, view) {
14
- return [(clientX - origin.left) / view.scale.x + view.x, (clientY - origin.top) / view.scale.y + view.y];
16
+ const world = viewScreenToWorld(view, {
17
+ x: clientX - origin.left,
18
+ y: clientY - origin.top
19
+ });
20
+ return [world.x, world.y];
15
21
  }
16
22
 
17
23
  export { clientToWorld };
@@ -1 +1 @@
1
- {"version":3,"file":"clientToWorld.js","names":[],"sources":["../../../src/core/viewport/clientToWorld.ts"],"sourcesContent":["/**\n * Convert client coords directly to world coords by subtracting the canvas\n * rect origin and then applying the inverse view transform.\n *\n * This is the composition of `clientToCanvas` + the view-inverse half of\n * `screenToWorld`, but expressed against the `ViewLike` shape used by\n * `SceneCanvas` (`{ scale: { x, y }, x, y }`) rather than the\n * `ViewTransform` shape used by `screenToWorld` (`{ panX, panY, zoom }`).\n */\nexport interface ViewLike {\n scale: { x: number; y: number };\n x: number;\n y: number;\n}\n\n/**\n * Map a browser client coord pair to world coords.\n *\n * @param clientX - `event.clientX`\n * @param clientY - `event.clientY`\n * @param origin - client-space origin of the surface `view` is a camera on.\n * For a whole canvas that is its bounding rect. For a viewport node it is\n * the canvas rect offset by the viewport's own rect, so the same call maps\n * into that viewport's inner world.\n * @param view - current viewport transform\n */\nexport function clientToWorld(\n clientX: number,\n clientY: number,\n origin: { left: number; top: number },\n view: ViewLike,\n): [number, number] {\n return [\n (clientX - origin.left) / view.scale.x + view.x,\n (clientY - origin.top) / view.scale.y + view.y,\n ];\n}\n"],"mappings":";;;;;;;;;;;;AA0BA,SAAgB,cACd,SACA,SACA,QACA,MACkB;CAClB,OAAO,EACJ,UAAU,OAAO,QAAQ,KAAK,MAAM,IAAI,KAAK,IAC7C,UAAU,OAAO,OAAO,KAAK,MAAM,IAAI,KAAK,CAC/C;AACF"}
1
+ {"version":3,"file":"clientToWorld.js","names":[],"sources":["../../../src/core/viewport/clientToWorld.ts"],"sourcesContent":["import { viewScreenToWorld } from '@weasel-js/geom';\n\n/** The `View` shape: `clientToWorld` is `clientToCanvas` then `viewScreenToWorld`. */\nexport interface ViewLike {\n scale: { x: number; y: number };\n x: number;\n y: number;\n}\n\n/**\n * Map a browser client coord pair to world coords.\n *\n * @param clientX - `event.clientX`\n * @param clientY - `event.clientY`\n * @param origin - client-space origin of the surface `view` is a camera on.\n * For a whole canvas that is its bounding rect. For a viewport node it is\n * the canvas rect offset by the viewport's own rect, so the same call maps\n * into that viewport's inner world.\n * @param view - current viewport transform\n */\nexport function clientToWorld(\n clientX: number,\n clientY: number,\n origin: { left: number; top: number },\n view: ViewLike,\n): [number, number] {\n const world = viewScreenToWorld(view, { x: clientX - origin.left, y: clientY - origin.top });\n return [world.x, world.y];\n}\n"],"mappings":";;;;;;;;;;;;;;AAoBA,SAAgB,cACd,SACA,SACA,QACA,MACkB;CAClB,MAAM,QAAQ,kBAAkB,MAAM;EAAE,GAAG,UAAU,OAAO;EAAM,GAAG,UAAU,OAAO;CAAI,CAAC;CAC3F,OAAO,CAAC,MAAM,GAAG,MAAM,CAAC;AAC1B"}
@@ -1,12 +1,13 @@
1
1
  import { normalizeView } from "./view.js";
2
- import { DEFAULT_MAX_ZOOM, DEFAULT_MIN_ZOOM, normalizeZoom } from "./zoomBounds.js";
2
+ import { DEFAULT_MAX_ZOOM, DEFAULT_MIN_ZOOM, normalizeZoom as normalizeZoom$1 } from "./zoomBounds.js";
3
+ import { viewScreenToWorld } from "@weasel-js/geom";
3
4
 
4
5
  // src/core/viewport/zoomAt.ts
5
6
  /** Bound a scale's *magnitude*, keeping its direction. `scale.y < 0` is how a
6
7
  * `View` spells a y-up camera, so clamping the signed value against positive
7
8
  * bounds would flip the axis and collapse the zoom rather than limit it. */
8
9
  function clampScale(scale, min, max) {
9
- const magnitude = normalizeZoom(Math.min(max, Math.max(min, Math.abs(scale))));
10
+ const magnitude = normalizeZoom$1(Math.min(max, Math.max(min, Math.abs(scale))));
10
11
  return scale < 0 ? -magnitude : magnitude;
11
12
  }
12
13
  /** `factor` held to one axis: the other axis's factor is 1, so `zoomAt` leaves
@@ -42,15 +43,14 @@ function zoomAt(input, anchor, factor, opts) {
42
43
  const maxY = typeof opts?.max === "number" ? opts.max : opts?.max?.y ?? DEFAULT_MAX_ZOOM;
43
44
  const nextX = clampScale(view.scale.x * fx, minX, maxX);
44
45
  const nextY = clampScale(view.scale.y * fy, minY, maxY);
45
- const worldX = anchor.x / view.scale.x + view.x;
46
- const worldY = anchor.y / view.scale.y + view.y;
46
+ const world = viewScreenToWorld(view, anchor);
47
47
  return {
48
48
  scale: {
49
49
  x: nextX,
50
50
  y: nextY
51
51
  },
52
- x: worldX - anchor.x / nextX,
53
- y: worldY - anchor.y / nextY
52
+ x: world.x - anchor.x / nextX,
53
+ y: world.y - anchor.y / nextY
54
54
  };
55
55
  }
56
56
 
@@ -1 +1 @@
1
- {"version":3,"file":"zoomAt.js","names":[],"sources":["../../../src/core/viewport/zoomAt.ts"],"sourcesContent":["import { normalizeView, type View, type ZoomFactor, type ZoomBound } from './view';\nimport { DEFAULT_MIN_ZOOM, DEFAULT_MAX_ZOOM, normalizeZoom } from './zoomBounds';\n\n/** Optional clamp bounds for `zoomAt`. Defaults: {@link DEFAULT_MIN_ZOOM} /\n * {@link DEFAULT_MAX_ZOOM} (per axis). */\nexport interface ZoomClampOpts {\n min?: ZoomBound;\n max?: ZoomBound;\n}\n\n/** Bound a scale's *magnitude*, keeping its direction. `scale.y < 0` is how a\n * `View` spells a y-up camera, so clamping the signed value against positive\n * bounds would flip the axis and collapse the zoom rather than limit it. */\nfunction clampScale(scale: number, min: number, max: number): number {\n const magnitude = normalizeZoom(Math.min(max, Math.max(min, Math.abs(scale))));\n return scale < 0 ? -magnitude : magnitude;\n}\n\n/** Which axes a zoom scales. */\nexport type ZoomAxis = 'both' | 'x' | 'y';\n\n/** `factor` held to one axis: the other axis's factor is 1, so `zoomAt` leaves\n * its scale and its translation where they were. */\nexport function axisZoomFactor(factor: number, axis: ZoomAxis): ZoomFactor {\n if (axis === 'x') return { x: factor, y: 1 };\n if (axis === 'y') return { x: 1, y: factor };\n return factor;\n}\n\n/**\n * Pure zoom primitive. Returns a new `View` whose `scale` is multiplied\n * by `factor` (per-axis, clamped) and whose translation is adjusted so\n * that the world point currently under `anchor` (screen coords relative\n * to the canvas top-left) stays under the same screen pixel after the\n * zoom — independently on each axis.\n *\n * `factor: number` means \"apply uniformly to both axes\". `factor: {x, y}`\n * applies per-axis factors. Likewise for `opts.min` / `opts.max`.\n */\nexport function zoomAt(\n input: View,\n anchor: { x: number; y: number },\n factor: ZoomFactor,\n opts?: ZoomClampOpts,\n): View {\n const view = normalizeView(input);\n const fx = typeof factor === 'number' ? factor : factor.x;\n const fy = typeof factor === 'number' ? factor : factor.y;\n const minX = typeof opts?.min === 'number' ? opts.min : opts?.min?.x ?? DEFAULT_MIN_ZOOM;\n const minY = typeof opts?.min === 'number' ? opts.min : opts?.min?.y ?? DEFAULT_MIN_ZOOM;\n const maxX = typeof opts?.max === 'number' ? opts.max : opts?.max?.x ?? DEFAULT_MAX_ZOOM;\n const maxY = typeof opts?.max === 'number' ? opts.max : opts?.max?.y ?? DEFAULT_MAX_ZOOM;\n\n const nextX = clampScale(view.scale.x * fx, minX, maxX);\n const nextY = clampScale(view.scale.y * fy, minY, maxY);\n\n const worldX = anchor.x / view.scale.x + view.x;\n const worldY = anchor.y / view.scale.y + view.y;\n return {\n scale: { x: nextX, y: nextY },\n x: worldX - anchor.x / nextX,\n y: worldY - anchor.y / nextY,\n };\n}\n"],"mappings":";;;;;;;AAaA,SAAS,WAAW,OAAe,KAAa,KAAqB;CACnE,MAAM,YAAY,cAAc,KAAK,IAAI,KAAK,KAAK,IAAI,KAAK,KAAK,IAAI,KAAK,CAAC,CAAC,CAAC;CAC7E,OAAO,QAAQ,IAAI,CAAC,YAAY;AAClC;;;AAOA,SAAgB,eAAe,QAAgB,MAA4B;CACzE,IAAI,SAAS,KAAK,OAAO;EAAE,GAAG;EAAQ,GAAG;CAAE;CAC3C,IAAI,SAAS,KAAK,OAAO;EAAE,GAAG;EAAG,GAAG;CAAO;CAC3C,OAAO;AACT;;;;;;;;;;;AAYA,SAAgB,OACd,OACA,QACA,QACA,MACM;CACN,MAAM,OAAO,cAAc,KAAK;CAChC,MAAM,KAAK,OAAO,WAAW,WAAW,SAAS,OAAO;CACxD,MAAM,KAAK,OAAO,WAAW,WAAW,SAAS,OAAO;CACxD,MAAM,OAAO,OAAO,MAAM,QAAQ,WAAW,KAAK,MAAM,MAAM,KAAK,KAAK;CACxE,MAAM,OAAO,OAAO,MAAM,QAAQ,WAAW,KAAK,MAAM,MAAM,KAAK,KAAK;CACxE,MAAM,OAAO,OAAO,MAAM,QAAQ,WAAW,KAAK,MAAM,MAAM,KAAK,KAAK;CACxE,MAAM,OAAO,OAAO,MAAM,QAAQ,WAAW,KAAK,MAAM,MAAM,KAAK,KAAK;CAExE,MAAM,QAAQ,WAAW,KAAK,MAAM,IAAI,IAAI,MAAM,IAAI;CACtD,MAAM,QAAQ,WAAW,KAAK,MAAM,IAAI,IAAI,MAAM,IAAI;CAEtD,MAAM,SAAS,OAAO,IAAI,KAAK,MAAM,IAAI,KAAK;CAC9C,MAAM,SAAS,OAAO,IAAI,KAAK,MAAM,IAAI,KAAK;CAC9C,OAAO;EACL,OAAO;GAAE,GAAG;GAAO,GAAG;EAAM;EAC5B,GAAG,SAAS,OAAO,IAAI;EACvB,GAAG,SAAS,OAAO,IAAI;CACzB;AACF"}
1
+ {"version":3,"file":"zoomAt.js","names":["normalizeZoom"],"sources":["../../../src/core/viewport/zoomAt.ts"],"sourcesContent":["import { viewScreenToWorld } from '@weasel-js/geom';\nimport { normalizeView, type View, type ZoomFactor, type ZoomBound } from './view';\nimport { DEFAULT_MIN_ZOOM, DEFAULT_MAX_ZOOM, normalizeZoom } from './zoomBounds';\n\n/** Optional clamp bounds for `zoomAt`. Defaults: {@link DEFAULT_MIN_ZOOM} /\n * {@link DEFAULT_MAX_ZOOM} (per axis). */\nexport interface ZoomClampOpts {\n min?: ZoomBound;\n max?: ZoomBound;\n}\n\n/** Bound a scale's *magnitude*, keeping its direction. `scale.y < 0` is how a\n * `View` spells a y-up camera, so clamping the signed value against positive\n * bounds would flip the axis and collapse the zoom rather than limit it. */\nfunction clampScale(scale: number, min: number, max: number): number {\n const magnitude = normalizeZoom(Math.min(max, Math.max(min, Math.abs(scale))));\n return scale < 0 ? -magnitude : magnitude;\n}\n\n/** Which axes a zoom scales. */\nexport type ZoomAxis = 'both' | 'x' | 'y';\n\n/** `factor` held to one axis: the other axis's factor is 1, so `zoomAt` leaves\n * its scale and its translation where they were. */\nexport function axisZoomFactor(factor: number, axis: ZoomAxis): ZoomFactor {\n if (axis === 'x') return { x: factor, y: 1 };\n if (axis === 'y') return { x: 1, y: factor };\n return factor;\n}\n\n/**\n * Pure zoom primitive. Returns a new `View` whose `scale` is multiplied\n * by `factor` (per-axis, clamped) and whose translation is adjusted so\n * that the world point currently under `anchor` (screen coords relative\n * to the canvas top-left) stays under the same screen pixel after the\n * zoom — independently on each axis.\n *\n * `factor: number` means \"apply uniformly to both axes\". `factor: {x, y}`\n * applies per-axis factors. Likewise for `opts.min` / `opts.max`.\n */\nexport function zoomAt(\n input: View,\n anchor: { x: number; y: number },\n factor: ZoomFactor,\n opts?: ZoomClampOpts,\n): View {\n const view = normalizeView(input);\n const fx = typeof factor === 'number' ? factor : factor.x;\n const fy = typeof factor === 'number' ? factor : factor.y;\n const minX = typeof opts?.min === 'number' ? opts.min : opts?.min?.x ?? DEFAULT_MIN_ZOOM;\n const minY = typeof opts?.min === 'number' ? opts.min : opts?.min?.y ?? DEFAULT_MIN_ZOOM;\n const maxX = typeof opts?.max === 'number' ? opts.max : opts?.max?.x ?? DEFAULT_MAX_ZOOM;\n const maxY = typeof opts?.max === 'number' ? opts.max : opts?.max?.y ?? DEFAULT_MAX_ZOOM;\n\n const nextX = clampScale(view.scale.x * fx, minX, maxX);\n const nextY = clampScale(view.scale.y * fy, minY, maxY);\n\n const world = viewScreenToWorld(view, anchor);\n return {\n scale: { x: nextX, y: nextY },\n x: world.x - anchor.x / nextX,\n y: world.y - anchor.y / nextY,\n };\n}\n"],"mappings":";;;;;;;;AAcA,SAAS,WAAW,OAAe,KAAa,KAAqB;CACnE,MAAM,YAAYA,gBAAc,KAAK,IAAI,KAAK,KAAK,IAAI,KAAK,KAAK,IAAI,KAAK,CAAC,CAAC,CAAC;CAC7E,OAAO,QAAQ,IAAI,CAAC,YAAY;AAClC;;;AAOA,SAAgB,eAAe,QAAgB,MAA4B;CACzE,IAAI,SAAS,KAAK,OAAO;EAAE,GAAG;EAAQ,GAAG;CAAE;CAC3C,IAAI,SAAS,KAAK,OAAO;EAAE,GAAG;EAAG,GAAG;CAAO;CAC3C,OAAO;AACT;;;;;;;;;;;AAYA,SAAgB,OACd,OACA,QACA,QACA,MACM;CACN,MAAM,OAAO,cAAc,KAAK;CAChC,MAAM,KAAK,OAAO,WAAW,WAAW,SAAS,OAAO;CACxD,MAAM,KAAK,OAAO,WAAW,WAAW,SAAS,OAAO;CACxD,MAAM,OAAO,OAAO,MAAM,QAAQ,WAAW,KAAK,MAAM,MAAM,KAAK,KAAK;CACxE,MAAM,OAAO,OAAO,MAAM,QAAQ,WAAW,KAAK,MAAM,MAAM,KAAK,KAAK;CACxE,MAAM,OAAO,OAAO,MAAM,QAAQ,WAAW,KAAK,MAAM,MAAM,KAAK,KAAK;CACxE,MAAM,OAAO,OAAO,MAAM,QAAQ,WAAW,KAAK,MAAM,MAAM,KAAK,KAAK;CAExE,MAAM,QAAQ,WAAW,KAAK,MAAM,IAAI,IAAI,MAAM,IAAI;CACtD,MAAM,QAAQ,WAAW,KAAK,MAAM,IAAI,IAAI,MAAM,IAAI;CAEtD,MAAM,QAAQ,kBAAkB,MAAM,MAAM;CAC5C,OAAO;EACL,OAAO;GAAE,GAAG;GAAO,GAAG;EAAM;EAC5B,GAAG,MAAM,IAAI,OAAO,IAAI;EACxB,GAAG,MAAM,IAAI,OAAO,IAAI;CAC1B;AACF"}
@@ -1,4 +1,11 @@
1
+ import { createEmitter } from "@weasel-js/registry";
2
+
1
3
  // src/devtools/root.ts
4
+ /**
5
+ * A root: what one mounted `<SceneCanvas>` exposes to the devtools hook and to
6
+ * the test hook. The canvas supplies live getters; the root adds change
7
+ * versions and the probe registry.
8
+ */
2
9
  var nextId = 1;
3
10
  /** A root over `source`, with a fresh page-unique id. */
4
11
  function createDevtoolsRoot(source) {
@@ -12,10 +19,10 @@ function createDevtoolsRoot(source) {
12
19
  registry: 0,
13
20
  history: 0
14
21
  };
15
- const listeners = /* @__PURE__ */ new Set();
22
+ const listeners = createEmitter();
16
23
  const notify = (area) => {
17
24
  versions[area]++;
18
- for (const l of [...listeners]) l(area);
25
+ listeners.emit(area);
19
26
  };
20
27
  let detach = null;
21
28
  let watchedScene = null;
@@ -65,10 +72,10 @@ function createDevtoolsRoot(source) {
65
72
  notify(area);
66
73
  },
67
74
  subscribe(listener) {
68
- listeners.add(listener);
75
+ const off = listeners.subscribe(listener);
69
76
  if (!detach) attach();
70
77
  return () => {
71
- listeners.delete(listener);
78
+ off();
72
79
  if (listeners.size === 0) detach?.();
73
80
  };
74
81
  }
@@ -1 +1 @@
1
- {"version":3,"file":"root.js","names":[],"sources":["../../src/devtools/root.ts"],"sourcesContent":["/**\n * A root: what one mounted `<SceneCanvas>` exposes to the devtools hook and to\n * the test hook. The canvas supplies live getters; the root adds change\n * versions and the probe registry.\n */\nimport type { DispatchTrace, TraceRing } from '@weasel-js/routing';\nimport type { Scene } from 'core/scene/types';\nimport type { CoordTraceEntry } from '../canvas/SceneCanvas/coordTrace';\nimport type { DebugConfig } from '../debug/types';\nimport type {\n DevtoolsArea, DevtoolsHistoryRecord, DevtoolsOverlays, DevtoolsRegistry, DevtoolsRootInfo,\n} from './protocol';\n\n/** What a canvas hands {@link createDevtoolsRoot}. Every getter reads the latest committed value. */\nexport interface DevtoolsRootSource {\n label(): string;\n readonly dimension: '2d' | '3d';\n scene(): Scene<unknown, string, unknown> | null;\n selection(): readonly string[];\n select(ids: readonly string[]): void;\n view(): unknown;\n activeTool(): string | null;\n tools(): readonly string[];\n setTool(id: string | null): void;\n trace: DispatchTrace;\n coords: TraceRing<CoordTraceEntry>;\n /** The debug sink's stats for the last paint, or null while the frame panel is off. */\n frame(): unknown;\n /** The debug overlay config in effect, or null when the overlay is off. */\n overlays(): DebugConfig | null;\n /** Lay the panel's overlay flags and highlight over the canvas's own `debug`; null drops them. */\n setOverride(override: { overlays: DevtoolsOverlays; highlight: readonly string[] } | null): void;\n /** What the canvas has registered, described when asked. Absent, `get-registry` is refused. */\n registry?(): DevtoolsRegistry;\n /** Hear that what {@link DevtoolsRootSource.registry} describes may have changed. Returns the unsubscribe. */\n subscribeRegistry?(listener: () => void): () => void;\n /** The scene's history steps. Absent, `get-history` is refused. */\n history?: TraceRing<DevtoolsHistoryRecord>;\n}\n\n/** One canvas as the devtools hook holds it. */\nexport interface DevtoolsRoot {\n readonly id: string;\n readonly source: DevtoolsRootSource;\n info(): DevtoolsRootInfo;\n /** Name-keyed probes; the test hook's `registerProbe` writes here. */\n readonly probes: Map<string, () => unknown>;\n version(area: DevtoolsArea): number;\n /** Say that `area` changed. The scene and the trace say so themselves while anything listens. */\n notify(area: DevtoolsArea): void;\n /** Hear each change, with the area. Returns the unsubscribe. */\n subscribe(listener: (area: DevtoolsArea) => void): () => void;\n}\n\nlet nextId = 1;\n\n/** A root over `source`, with a fresh page-unique id. */\nexport function createDevtoolsRoot(source: DevtoolsRootSource): DevtoolsRoot {\n const id = `root-${nextId++}`;\n const versions: Record<DevtoolsArea, number> = {\n scene: 0, selection: 0, view: 0, tool: 0, trace: 0, registry: 0, history: 0,\n };\n const listeners = new Set<(area: DevtoolsArea) => void>();\n const notify = (area: DevtoolsArea): void => {\n versions[area]++;\n for (const l of [...listeners]) l(area);\n };\n\n // Attached only while something listens, so a root nobody inspects holds no\n // subscription on a scene that may outlive its canvas.\n let detach: (() => void) | null = null;\n let watchedScene: Scene<unknown, string, unknown> | null = null;\n let offScene: (() => void) | null = null;\n const followScene = (): void => {\n const s = detach ? source.scene() : null;\n if (s === watchedScene) return;\n offScene?.();\n watchedScene = s;\n if (!s) {\n offScene = null;\n return;\n }\n // A selection change notifies the scene's subscribers too; the canvas reports it as `selection`.\n let content = s.getContentVersion();\n offScene = s.subscribe(() => {\n const next = s.getContentVersion();\n if (next === content) return;\n content = next;\n notify('scene');\n });\n };\n const attach = (): void => {\n const offs = [\n source.trace.subscribe(() => notify('trace')),\n source.history?.subscribe(() => notify('history')),\n source.subscribeRegistry?.(() => notify('registry')),\n ];\n detach = () => {\n for (const off of offs) off?.();\n detach = null;\n followScene();\n };\n followScene();\n };\n\n return {\n id,\n source,\n info: () => ({ id, label: source.label() || id, dimension: source.dimension }),\n probes: new Map(),\n version: (area) => versions[area],\n notify(area) {\n if (area === 'scene') followScene();\n notify(area);\n },\n subscribe(listener) {\n listeners.add(listener);\n if (!detach) attach();\n return () => {\n listeners.delete(listener);\n if (listeners.size === 0) detach?.();\n };\n },\n };\n}\n"],"mappings":";AAsDA,IAAI,SAAS;;AAGb,SAAgB,mBAAmB,QAA0C;CAC3E,MAAM,KAAK,QAAQ;CACnB,MAAM,WAAyC;EAC7C,OAAO;EAAG,WAAW;EAAG,MAAM;EAAG,MAAM;EAAG,OAAO;EAAG,UAAU;EAAG,SAAS;CAC5E;CACA,MAAM,4BAAY,IAAI,IAAkC;CACxD,MAAM,UAAU,SAA6B;EAC3C,SAAS,KAAK;EACd,KAAK,MAAM,KAAK,CAAC,GAAG,SAAS,GAAG,EAAE,IAAI;CACxC;CAIA,IAAI,SAA8B;CAClC,IAAI,eAAuD;CAC3D,IAAI,WAAgC;CACpC,MAAM,oBAA0B;EAC9B,MAAM,IAAI,SAAS,OAAO,MAAM,IAAI;EACpC,IAAI,MAAM,cAAc;EACxB,WAAW;EACX,eAAe;EACf,IAAI,CAAC,GAAG;GACN,WAAW;GACX;EACF;EAEA,IAAI,UAAU,EAAE,kBAAkB;EAClC,WAAW,EAAE,gBAAgB;GAC3B,MAAM,OAAO,EAAE,kBAAkB;GACjC,IAAI,SAAS,SAAS;GACtB,UAAU;GACV,OAAO,OAAO;EAChB,CAAC;CACH;CACA,MAAM,eAAqB;EACzB,MAAM,OAAO;GACX,OAAO,MAAM,gBAAgB,OAAO,OAAO,CAAC;GAC5C,OAAO,SAAS,gBAAgB,OAAO,SAAS,CAAC;GACjD,OAAO,0BAA0B,OAAO,UAAU,CAAC;EACrD;EACA,eAAe;GACb,KAAK,MAAM,OAAO,MAAM,MAAM;GAC9B,SAAS;GACT,YAAY;EACd;EACA,YAAY;CACd;CAEA,OAAO;EACL;EACA;EACA,aAAa;GAAE;GAAI,OAAO,OAAO,MAAM,KAAK;GAAI,WAAW,OAAO;EAAU;EAC5E,wBAAQ,IAAI,IAAI;EAChB,UAAU,SAAS,SAAS;EAC5B,OAAO,MAAM;GACX,IAAI,SAAS,SAAS,YAAY;GAClC,OAAO,IAAI;EACb;EACA,UAAU,UAAU;GAClB,UAAU,IAAI,QAAQ;GACtB,IAAI,CAAC,QAAQ,OAAO;GACpB,aAAa;IACX,UAAU,OAAO,QAAQ;IACzB,IAAI,UAAU,SAAS,GAAG,SAAS;GACrC;EACF;CACF;AACF"}
1
+ {"version":3,"file":"root.js","names":[],"sources":["../../src/devtools/root.ts"],"sourcesContent":["/**\n * A root: what one mounted `<SceneCanvas>` exposes to the devtools hook and to\n * the test hook. The canvas supplies live getters; the root adds change\n * versions and the probe registry.\n */\nimport { createEmitter } from '@weasel-js/registry';\nimport type { DispatchTrace, TraceRing } from '@weasel-js/routing';\nimport type { Scene } from 'core/scene/types';\nimport type { CoordTraceEntry } from '../canvas/SceneCanvas/coordTrace';\nimport type { DebugConfig } from '../debug/types';\nimport type {\n DevtoolsArea, DevtoolsHistoryRecord, DevtoolsOverlays, DevtoolsRegistry, DevtoolsRootInfo,\n} from './protocol';\n\n/** What a canvas hands {@link createDevtoolsRoot}. Every getter reads the latest committed value. */\nexport interface DevtoolsRootSource {\n label(): string;\n readonly dimension: '2d' | '3d';\n scene(): Scene<unknown, string, unknown> | null;\n selection(): readonly string[];\n select(ids: readonly string[]): void;\n view(): unknown;\n activeTool(): string | null;\n tools(): readonly string[];\n setTool(id: string | null): void;\n trace: DispatchTrace;\n coords: TraceRing<CoordTraceEntry>;\n /** The debug sink's stats for the last paint, or null while the frame panel is off. */\n frame(): unknown;\n /** The debug overlay config in effect, or null when the overlay is off. */\n overlays(): DebugConfig | null;\n /** Lay the panel's overlay flags and highlight over the canvas's own `debug`; null drops them. */\n setOverride(override: { overlays: DevtoolsOverlays; highlight: readonly string[] } | null): void;\n /** What the canvas has registered, described when asked. Absent, `get-registry` is refused. */\n registry?(): DevtoolsRegistry;\n /** Hear that what {@link DevtoolsRootSource.registry} describes may have changed. Returns the unsubscribe. */\n subscribeRegistry?(listener: () => void): () => void;\n /** The scene's history steps. Absent, `get-history` is refused. */\n history?: TraceRing<DevtoolsHistoryRecord>;\n}\n\n/** One canvas as the devtools hook holds it. */\nexport interface DevtoolsRoot {\n readonly id: string;\n readonly source: DevtoolsRootSource;\n info(): DevtoolsRootInfo;\n /** Name-keyed probes; the test hook's `registerProbe` writes here. */\n readonly probes: Map<string, () => unknown>;\n version(area: DevtoolsArea): number;\n /** Say that `area` changed. The scene and the trace say so themselves while anything listens. */\n notify(area: DevtoolsArea): void;\n /** Hear each change, with the area. Returns the unsubscribe. */\n subscribe(listener: (area: DevtoolsArea) => void): () => void;\n}\n\nlet nextId = 1;\n\n/** A root over `source`, with a fresh page-unique id. */\nexport function createDevtoolsRoot(source: DevtoolsRootSource): DevtoolsRoot {\n const id = `root-${nextId++}`;\n const versions: Record<DevtoolsArea, number> = {\n scene: 0, selection: 0, view: 0, tool: 0, trace: 0, registry: 0, history: 0,\n };\n const listeners = createEmitter<[area: DevtoolsArea]>();\n const notify = (area: DevtoolsArea): void => {\n versions[area]++;\n listeners.emit(area);\n };\n\n // Attached only while something listens, so a root nobody inspects holds no\n // subscription on a scene that may outlive its canvas.\n let detach: (() => void) | null = null;\n let watchedScene: Scene<unknown, string, unknown> | null = null;\n let offScene: (() => void) | null = null;\n const followScene = (): void => {\n const s = detach ? source.scene() : null;\n if (s === watchedScene) return;\n offScene?.();\n watchedScene = s;\n if (!s) {\n offScene = null;\n return;\n }\n // A selection change notifies the scene's subscribers too; the canvas reports it as `selection`.\n let content = s.getContentVersion();\n offScene = s.subscribe(() => {\n const next = s.getContentVersion();\n if (next === content) return;\n content = next;\n notify('scene');\n });\n };\n const attach = (): void => {\n const offs = [\n source.trace.subscribe(() => notify('trace')),\n source.history?.subscribe(() => notify('history')),\n source.subscribeRegistry?.(() => notify('registry')),\n ];\n detach = () => {\n for (const off of offs) off?.();\n detach = null;\n followScene();\n };\n followScene();\n };\n\n return {\n id,\n source,\n info: () => ({ id, label: source.label() || id, dimension: source.dimension }),\n probes: new Map(),\n version: (area) => versions[area],\n notify(area) {\n if (area === 'scene') followScene();\n notify(area);\n },\n subscribe(listener) {\n const off = listeners.subscribe(listener);\n if (!detach) attach();\n return () => {\n off();\n if (listeners.size === 0) detach?.();\n };\n },\n };\n}\n"],"mappings":";;;;;;;;AAuDA,IAAI,SAAS;;AAGb,SAAgB,mBAAmB,QAA0C;CAC3E,MAAM,KAAK,QAAQ;CACnB,MAAM,WAAyC;EAC7C,OAAO;EAAG,WAAW;EAAG,MAAM;EAAG,MAAM;EAAG,OAAO;EAAG,UAAU;EAAG,SAAS;CAC5E;CACA,MAAM,YAAY,cAAoC;CACtD,MAAM,UAAU,SAA6B;EAC3C,SAAS,KAAK;EACd,UAAU,KAAK,IAAI;CACrB;CAIA,IAAI,SAA8B;CAClC,IAAI,eAAuD;CAC3D,IAAI,WAAgC;CACpC,MAAM,oBAA0B;EAC9B,MAAM,IAAI,SAAS,OAAO,MAAM,IAAI;EACpC,IAAI,MAAM,cAAc;EACxB,WAAW;EACX,eAAe;EACf,IAAI,CAAC,GAAG;GACN,WAAW;GACX;EACF;EAEA,IAAI,UAAU,EAAE,kBAAkB;EAClC,WAAW,EAAE,gBAAgB;GAC3B,MAAM,OAAO,EAAE,kBAAkB;GACjC,IAAI,SAAS,SAAS;GACtB,UAAU;GACV,OAAO,OAAO;EAChB,CAAC;CACH;CACA,MAAM,eAAqB;EACzB,MAAM,OAAO;GACX,OAAO,MAAM,gBAAgB,OAAO,OAAO,CAAC;GAC5C,OAAO,SAAS,gBAAgB,OAAO,SAAS,CAAC;GACjD,OAAO,0BAA0B,OAAO,UAAU,CAAC;EACrD;EACA,eAAe;GACb,KAAK,MAAM,OAAO,MAAM,MAAM;GAC9B,SAAS;GACT,YAAY;EACd;EACA,YAAY;CACd;CAEA,OAAO;EACL;EACA;EACA,aAAa;GAAE;GAAI,OAAO,OAAO,MAAM,KAAK;GAAI,WAAW,OAAO;EAAU;EAC5E,wBAAQ,IAAI,IAAI;EAChB,UAAU,SAAS,SAAS;EAC5B,OAAO,MAAM;GACX,IAAI,SAAS,SAAS,YAAY;GAClC,OAAO,IAAI;EACb;EACA,UAAU,UAAU;GAClB,MAAM,MAAM,UAAU,UAAU,QAAQ;GACxC,IAAI,CAAC,QAAQ,OAAO;GACpB,aAAa;IACX,IAAI;IACJ,IAAI,UAAU,SAAS,GAAG,SAAS;GACrC;EACF;CACF;AACF"}
@@ -1,4 +1,10 @@
1
+ import { createEmitter } from "@weasel-js/registry";
2
+
1
3
  // src/devtools/transport.ts
4
+ /**
5
+ * How devtools messages travel. A transport sends one side's messages and
6
+ * hears the other side's; the session neither knows nor cares which kind it has.
7
+ */
2
8
  /** Marks devtools traffic among whatever else a page posts to its window. */
3
9
  var DEVTOOLS_ENVELOPE = "weasel-devtools";
4
10
  /** Whether a posted message's `data` is devtools traffic. */
@@ -6,24 +12,14 @@ function isDevtoolsEnvelope(data) {
6
12
  return typeof data === "object" && data !== null && data.source === DEVTOOLS_ENVELOPE;
7
13
  }
8
14
  function pair() {
9
- const toA = /* @__PURE__ */ new Set();
10
- const toB = /* @__PURE__ */ new Set();
11
- const listen = (set) => (listener) => {
12
- set.add(listener);
13
- return () => {
14
- set.delete(listener);
15
- };
16
- };
15
+ const toA = createEmitter();
16
+ const toB = createEmitter();
17
17
  return [{
18
- send: (m) => {
19
- for (const l of [...toB]) l(m);
20
- },
21
- onMessage: listen(toA)
18
+ send: (m) => toB.emit(m),
19
+ onMessage: toA.subscribe
22
20
  }, {
23
- send: (m) => {
24
- for (const l of [...toA]) l(m);
25
- },
26
- onMessage: listen(toB)
21
+ send: (m) => toA.emit(m),
22
+ onMessage: toB.subscribe
27
23
  }];
28
24
  }
29
25
  /** Both ends of a connection within one page: delivery is a direct, synchronous call. */
@@ -1 +1 @@
1
- {"version":3,"file":"transport.js","names":[],"sources":["../../src/devtools/transport.ts"],"sourcesContent":["/**\n * How devtools messages travel. A transport sends one side's messages and\n * hears the other side's; the session neither knows nor cares which kind it has.\n */\nimport type { PageMessage, PanelMessage } from './protocol';\n\n/** One end of a connection: it sends `Out` and hears `In`. */\nexport interface DevtoolsTransport<Out, In> {\n send(message: Out): void;\n /** Hear the other side. Returns the unsubscribe. */\n onMessage(listener: (message: In) => void): () => void;\n}\n\n/** The page's end of a connection. */\nexport type PageTransport = DevtoolsTransport<PageMessage, PanelMessage>;\n/** The panel's end of a connection. */\nexport type PanelTransport = DevtoolsTransport<PanelMessage, PageMessage>;\n\n/** Marks devtools traffic among whatever else a page posts to its window. */\nexport const DEVTOOLS_ENVELOPE = 'weasel-devtools';\n\n/** A devtools message as posted to a window. */\nexport interface DevtoolsEnvelope {\n source: typeof DEVTOOLS_ENVELOPE;\n side: 'page' | 'panel';\n message: PageMessage | PanelMessage;\n}\n\n/** Whether a posted message's `data` is devtools traffic. */\nexport function isDevtoolsEnvelope(data: unknown): data is DevtoolsEnvelope {\n return typeof data === 'object' && data !== null\n && (data as { source?: unknown }).source === DEVTOOLS_ENVELOPE;\n}\n\nfunction pair<A, B>(): [DevtoolsTransport<A, B>, DevtoolsTransport<B, A>] {\n const toA = new Set<(m: B) => void>();\n const toB = new Set<(m: A) => void>();\n const listen = <M>(set: Set<(m: M) => void>) => (listener: (m: M) => void) => {\n set.add(listener);\n return () => { set.delete(listener); };\n };\n return [\n { send: (m) => { for (const l of [...toB]) l(m); }, onMessage: listen(toA) },\n { send: (m) => { for (const l of [...toA]) l(m); }, onMessage: listen(toB) },\n ];\n}\n\n/** Both ends of a connection within one page: delivery is a direct, synchronous call. */\nexport function createInPageTransports(): { page: PageTransport; panel: PanelTransport } {\n const [page, panel] = pair<PageMessage, PanelMessage>();\n return { page, panel };\n}\n\nfunction windowTransport<Out extends PageMessage | PanelMessage, In>(\n side: 'page' | 'panel',\n target: Window,\n): DevtoolsTransport<Out, In> {\n return {\n send(message) {\n const envelope: DevtoolsEnvelope = { source: DEVTOOLS_ENVELOPE, side, message };\n target.postMessage(envelope, '*');\n },\n onMessage(listener) {\n const onEvent = (e: MessageEvent): void => {\n if (isDevtoolsEnvelope(e.data) && e.data.side !== side) listener(e.data.message as In);\n };\n target.addEventListener('message', onEvent);\n return () => target.removeEventListener('message', onEvent);\n },\n };\n}\n\n/** The page's end over `window.postMessage`, for an extension's content script. */\nexport function createWindowPageTransport(target: Window = window): PageTransport {\n return windowTransport<PageMessage, PanelMessage>('page', target);\n}\n\n/** The panel's end over `window.postMessage`: what a content script uses to reach the page. */\nexport function createWindowPanelTransport(target: Window = window): PanelTransport {\n return windowTransport<PanelMessage, PageMessage>('panel', target);\n}\n"],"mappings":";;AAmBA,IAAa,oBAAoB;;AAUjC,SAAgB,mBAAmB,MAAyC;CAC1E,OAAO,OAAO,SAAS,YAAY,SAAS,QACtC,KAA8B,WAAW;AACjD;AAEA,SAAS,OAAiE;CACxE,MAAM,sBAAM,IAAI,IAAoB;CACpC,MAAM,sBAAM,IAAI,IAAoB;CACpC,MAAM,UAAa,SAA8B,aAA6B;EAC5E,IAAI,IAAI,QAAQ;EAChB,aAAa;GAAE,IAAI,OAAO,QAAQ;EAAG;CACvC;CACA,OAAO,CACL;EAAE,OAAO,MAAM;GAAE,KAAK,MAAM,KAAK,CAAC,GAAG,GAAG,GAAG,EAAE,CAAC;EAAG;EAAG,WAAW,OAAO,GAAG;CAAE,GAC3E;EAAE,OAAO,MAAM;GAAE,KAAK,MAAM,KAAK,CAAC,GAAG,GAAG,GAAG,EAAE,CAAC;EAAG;EAAG,WAAW,OAAO,GAAG;CAAE,CAC7E;AACF;;AAGA,SAAgB,yBAAyE;CACvF,MAAM,CAAC,MAAM,SAAS,KAAgC;CACtD,OAAO;EAAE;EAAM;CAAM;AACvB;AAEA,SAAS,gBACP,MACA,QAC4B;CAC5B,OAAO;EACL,KAAK,SAAS;GACZ,MAAM,WAA6B;IAAE,QAAQ;IAAmB;IAAM;GAAQ;GAC9E,OAAO,YAAY,UAAU,GAAG;EAClC;EACA,UAAU,UAAU;GAClB,MAAM,WAAW,MAA0B;IACzC,IAAI,mBAAmB,EAAE,IAAI,KAAK,EAAE,KAAK,SAAS,MAAM,SAAS,EAAE,KAAK,OAAa;GACvF;GACA,OAAO,iBAAiB,WAAW,OAAO;GAC1C,aAAa,OAAO,oBAAoB,WAAW,OAAO;EAC5D;CACF;AACF;;AAGA,SAAgB,0BAA0B,SAAiB,QAAuB;CAChF,OAAO,gBAA2C,QAAQ,MAAM;AAClE;;AAGA,SAAgB,2BAA2B,SAAiB,QAAwB;CAClF,OAAO,gBAA2C,SAAS,MAAM;AACnE"}
1
+ {"version":3,"file":"transport.js","names":[],"sources":["../../src/devtools/transport.ts"],"sourcesContent":["/**\n * How devtools messages travel. A transport sends one side's messages and\n * hears the other side's; the session neither knows nor cares which kind it has.\n */\nimport { createEmitter } from '@weasel-js/registry';\nimport type { PageMessage, PanelMessage } from './protocol';\n\n/** One end of a connection: it sends `Out` and hears `In`. */\nexport interface DevtoolsTransport<Out, In> {\n send(message: Out): void;\n /** Hear the other side. Returns the unsubscribe. */\n onMessage(listener: (message: In) => void): () => void;\n}\n\n/** The page's end of a connection. */\nexport type PageTransport = DevtoolsTransport<PageMessage, PanelMessage>;\n/** The panel's end of a connection. */\nexport type PanelTransport = DevtoolsTransport<PanelMessage, PageMessage>;\n\n/** Marks devtools traffic among whatever else a page posts to its window. */\nexport const DEVTOOLS_ENVELOPE = 'weasel-devtools';\n\n/** A devtools message as posted to a window. */\nexport interface DevtoolsEnvelope {\n source: typeof DEVTOOLS_ENVELOPE;\n side: 'page' | 'panel';\n message: PageMessage | PanelMessage;\n}\n\n/** Whether a posted message's `data` is devtools traffic. */\nexport function isDevtoolsEnvelope(data: unknown): data is DevtoolsEnvelope {\n return typeof data === 'object' && data !== null\n && (data as { source?: unknown }).source === DEVTOOLS_ENVELOPE;\n}\n\nfunction pair<A, B>(): [DevtoolsTransport<A, B>, DevtoolsTransport<B, A>] {\n const toA = createEmitter<[m: B]>();\n const toB = createEmitter<[m: A]>();\n return [\n { send: (m) => toB.emit(m), onMessage: toA.subscribe },\n { send: (m) => toA.emit(m), onMessage: toB.subscribe },\n ];\n}\n\n/** Both ends of a connection within one page: delivery is a direct, synchronous call. */\nexport function createInPageTransports(): { page: PageTransport; panel: PanelTransport } {\n const [page, panel] = pair<PageMessage, PanelMessage>();\n return { page, panel };\n}\n\nfunction windowTransport<Out extends PageMessage | PanelMessage, In>(\n side: 'page' | 'panel',\n target: Window,\n): DevtoolsTransport<Out, In> {\n return {\n send(message) {\n const envelope: DevtoolsEnvelope = { source: DEVTOOLS_ENVELOPE, side, message };\n target.postMessage(envelope, '*');\n },\n onMessage(listener) {\n const onEvent = (e: MessageEvent): void => {\n if (isDevtoolsEnvelope(e.data) && e.data.side !== side) listener(e.data.message as In);\n };\n target.addEventListener('message', onEvent);\n return () => target.removeEventListener('message', onEvent);\n },\n };\n}\n\n/** The page's end over `window.postMessage`, for an extension's content script. */\nexport function createWindowPageTransport(target: Window = window): PageTransport {\n return windowTransport<PageMessage, PanelMessage>('page', target);\n}\n\n/** The panel's end over `window.postMessage`: what a content script uses to reach the page. */\nexport function createWindowPanelTransport(target: Window = window): PanelTransport {\n return windowTransport<PanelMessage, PageMessage>('panel', target);\n}\n"],"mappings":";;;;;;;;AAoBA,IAAa,oBAAoB;;AAUjC,SAAgB,mBAAmB,MAAyC;CAC1E,OAAO,OAAO,SAAS,YAAY,SAAS,QACtC,KAA8B,WAAW;AACjD;AAEA,SAAS,OAAiE;CACxE,MAAM,MAAM,cAAsB;CAClC,MAAM,MAAM,cAAsB;CAClC,OAAO,CACL;EAAE,OAAO,MAAM,IAAI,KAAK,CAAC;EAAG,WAAW,IAAI;CAAU,GACrD;EAAE,OAAO,MAAM,IAAI,KAAK,CAAC;EAAG,WAAW,IAAI;CAAU,CACvD;AACF;;AAGA,SAAgB,yBAAyE;CACvF,MAAM,CAAC,MAAM,SAAS,KAAgC;CACtD,OAAO;EAAE;EAAM;CAAM;AACvB;AAEA,SAAS,gBACP,MACA,QAC4B;CAC5B,OAAO;EACL,KAAK,SAAS;GACZ,MAAM,WAA6B;IAAE,QAAQ;IAAmB;IAAM;GAAQ;GAC9E,OAAO,YAAY,UAAU,GAAG;EAClC;EACA,UAAU,UAAU;GAClB,MAAM,WAAW,MAA0B;IACzC,IAAI,mBAAmB,EAAE,IAAI,KAAK,EAAE,KAAK,SAAS,MAAM,SAAS,EAAE,KAAK,OAAa;GACvF;GACA,OAAO,iBAAiB,WAAW,OAAO;GAC1C,aAAa,OAAO,oBAAoB,WAAW,OAAO;EAC5D;CACF;AACF;;AAGA,SAAgB,0BAA0B,SAAiB,QAAuB;CAChF,OAAO,gBAA2C,QAAQ,MAAM;AAClE;;AAGA,SAAgB,2BAA2B,SAAiB,QAAwB;CAClF,OAAO,gBAA2C,SAAS,MAAM;AACnE"}
@@ -316,12 +316,6 @@ interface CoordTraceEntry {
316
316
  fallback: boolean;
317
317
  }
318
318
 
319
- /**
320
- * A root: what one mounted `<SceneCanvas>` exposes to the devtools hook and to
321
- * the test hook. The canvas supplies live getters; the root adds change
322
- * versions and the probe registry.
323
- */
324
-
325
319
  /** What a canvas hands {@link createDevtoolsRoot}. Every getter reads the latest committed value. */
326
320
  interface DevtoolsRootSource {
327
321
  label(): string;
@@ -372,11 +366,6 @@ declare function createDevtoolsRoot(source: DevtoolsRootSource): DevtoolsRoot;
372
366
  /** Run `flush` once, soon. The hook's default waits for the next animation frame. */
373
367
  type DevtoolsSchedule = (flush: () => void) => void;
374
368
 
375
- /**
376
- * How devtools messages travel. A transport sends one side's messages and
377
- * hears the other side's; the session neither knows nor cares which kind it has.
378
- */
379
-
380
369
  /** One end of a connection: it sends `Out` and hears `In`. */
381
370
  interface DevtoolsTransport<Out, In> {
382
371
  send(message: Out): void;
@@ -2,15 +2,9 @@ import { measureEndsOf } from "./measurement.js";
2
2
  import { measureIds } from "./measurements.js";
3
3
  import { nodesAsShown, shownLookup } from "../../core/scene/nodeAsShown.js";
4
4
  import { nodeGates } from "../../core/scene/nodeGates.js";
5
+ import { pointSegmentDist } from "@weasel-js/geom";
5
6
 
6
7
  // src/domains/measure/measureHit.ts
7
- function distanceToSegment(p, a, b) {
8
- const dx = b.x - a.x;
9
- const dy = b.y - a.y;
10
- const len2 = dx * dx + dy * dy;
11
- const t = len2 === 0 ? 0 : Math.max(0, Math.min(1, ((p.x - a.x) * dx + (p.y - a.y) * dy) / len2));
12
- return Math.hypot(p.x - (a.x + t * dx), p.y - (a.y + t * dy));
13
- }
14
8
  /**
15
9
  * The measurement under `p`, newest first: an end within `tolerance` world
16
10
  * units of it, else a line within the same distance. A hidden or locked
@@ -30,7 +24,7 @@ function measureAt(scene, ms, p, tolerance) {
30
24
  }
31
25
  for (const id of ids) {
32
26
  const ends = measureEndsOf(shown.get(id));
33
- if (ends && distanceToSegment(p, ends.start, ends.end) <= tolerance) return {
27
+ if (ends && pointSegmentDist(p.x, p.y, ends.start.x, ends.start.y, ends.end.x, ends.end.y) <= tolerance) return {
34
28
  id,
35
29
  part: "line"
36
30
  };
@@ -1 +1 @@
1
- {"version":3,"file":"measureHit.js","names":[],"sources":["../../../src/domains/measure/measureHit.ts"],"sourcesContent":["import type { NodeId, Scene } from 'core/scene/types';\nimport { measureEndsOf, type MeasureEnd, type MeasurePoint } from './measurement';\nimport { measureIds, type Measurements } from './measurements';\nimport { nodesAsShown, shownLookup } from 'core/scene/nodeAsShown';\nimport { nodeGates } from 'core/scene/nodeGates';\nimport type { SnapSource } from 'domains/snap/types';\n\n/** Which part of which measurement lies under a point. */\nexport interface MeasureHit {\n id: NodeId;\n part: MeasureEnd | 'line';\n}\n\n\nfunction distanceToSegment(p: MeasurePoint, a: MeasurePoint, b: MeasurePoint): number {\n const dx = b.x - a.x;\n const dy = b.y - a.y;\n const len2 = dx * dx + dy * dy;\n const t = len2 === 0 ? 0 : Math.max(0, Math.min(1, ((p.x - a.x) * dx + (p.y - a.y) * dy) / len2));\n return Math.hypot(p.x - (a.x + t * dx), p.y - (a.y + t * dy));\n}\n\n/**\n * The measurement under `p`, newest first: an end within `tolerance` world\n * units of it, else a line within the same distance. A hidden or locked\n * measurement (`nodeGates`) is passed over.\n */\nexport function measureAt(\n scene: Pick<Scene<unknown, string, unknown>, 'nodesOnLayer' | 'get' | 'layers' | 'overrides'>,\n ms: Pick<Measurements, 'layer'>,\n p: MeasurePoint,\n tolerance: number,\n): MeasureHit | null {\n const gates = nodeGates(scene);\n // Hit where the ends are shown: a gesture moving one publishes it as a data override.\n const shown = shownLookup(scene);\n const ids = measureIds(scene, ms).reverse().filter((id) => !gates.hidden(id) && !gates.locked(id));\n for (const id of ids) {\n const ends = measureEndsOf(shown.get(id));\n if (!ends) continue;\n for (const part of ['end', 'start'] as const) {\n if (Math.hypot(p.x - ends[part].x, p.y - ends[part].y) <= tolerance) return { id, part };\n }\n }\n for (const id of ids) {\n const ends = measureEndsOf(shown.get(id));\n if (ends && distanceToSegment(p, ends.start, ends.end) <= tolerance) return { id, part: 'line' };\n }\n return null;\n}\n\n/** Options for `measureEndSource`. */\nexport interface MeasureEndSourceOptions {\n /** Overrides the canvas's reach for measurement ends, in screen px. */\n reachPx?: number;\n}\n\n/** The ends of every shown measurement as snap targets for a pointer probe. */\nexport function measureEndSource(opts: MeasureEndSourceOptions = {}): SnapSource {\n return {\n id: 'measure-ends',\n roles: ['pointer'],\n ...(opts.reachPx !== undefined ? { reachPx: opts.reachPx } : {}),\n *collect(q) {\n if (!q.scene) return;\n const gates = nodeGates(q.scene);\n const skip = new Set(q.exclude);\n const reach = q.reachWorld(q.reachPx);\n for (const node of nodesAsShown(q.scene.overrides, q.scene.renderOrderNodes())) {\n const ends = measureEndsOf(node);\n if (!ends || skip.has(node.id) || gates.hidden(node.id)) continue;\n for (const end of [ends.start, ends.end]) {\n const at = { x: end.x, y: end.y };\n for (const p of q.probes) {\n if (Math.hypot(at.x - p.at.x, at.y - p.at.y) > reach) continue;\n yield { probe: p.id, locus: { at, dirs: [] }, mark: { kind: 'point', at, glyph: 'center' } };\n }\n }\n }\n },\n };\n}\n"],"mappings":";;;;;;AAcA,SAAS,kBAAkB,GAAiB,GAAiB,GAAyB;CACpF,MAAM,KAAK,EAAE,IAAI,EAAE;CACnB,MAAM,KAAK,EAAE,IAAI,EAAE;CACnB,MAAM,OAAO,KAAK,KAAK,KAAK;CAC5B,MAAM,IAAI,SAAS,IAAI,IAAI,KAAK,IAAI,GAAG,KAAK,IAAI,KAAK,EAAE,IAAI,EAAE,KAAK,MAAM,EAAE,IAAI,EAAE,KAAK,MAAM,IAAI,CAAC;CAChG,OAAO,KAAK,MAAM,EAAE,KAAK,EAAE,IAAI,IAAI,KAAK,EAAE,KAAK,EAAE,IAAI,IAAI,GAAG;AAC9D;;;;;;AAOA,SAAgB,UACd,OACA,IACA,GACA,WACmB;CACnB,MAAM,QAAQ,UAAU,KAAK;CAE7B,MAAM,QAAQ,YAAY,KAAK;CAC/B,MAAM,MAAM,WAAW,OAAO,EAAE,CAAC,CAAC,QAAQ,CAAC,CAAC,QAAQ,OAAO,CAAC,MAAM,OAAO,EAAE,KAAK,CAAC,MAAM,OAAO,EAAE,CAAC;CACjG,KAAK,MAAM,MAAM,KAAK;EACpB,MAAM,OAAO,cAAc,MAAM,IAAI,EAAE,CAAC;EACxC,IAAI,CAAC,MAAM;EACX,KAAK,MAAM,QAAQ,CAAC,OAAO,OAAO,GAChC,IAAI,KAAK,MAAM,EAAE,IAAI,KAAK,KAAK,CAAC,GAAG,EAAE,IAAI,KAAK,KAAK,CAAC,CAAC,KAAK,WAAW,OAAO;GAAE;GAAI;EAAK;CAE3F;CACA,KAAK,MAAM,MAAM,KAAK;EACpB,MAAM,OAAO,cAAc,MAAM,IAAI,EAAE,CAAC;EACxC,IAAI,QAAQ,kBAAkB,GAAG,KAAK,OAAO,KAAK,GAAG,KAAK,WAAW,OAAO;GAAE;GAAI,MAAM;EAAO;CACjG;CACA,OAAO;AACT;;AASA,SAAgB,iBAAiB,OAAgC,CAAC,GAAe;CAC/E,OAAO;EACL,IAAI;EACJ,OAAO,CAAC,SAAS;EACjB,GAAI,KAAK,YAAY,SAAY,EAAE,SAAS,KAAK,QAAQ,IAAI,CAAC;EAC9D,CAAC,QAAQ,GAAG;GACV,IAAI,CAAC,EAAE,OAAO;GACd,MAAM,QAAQ,UAAU,EAAE,KAAK;GAC/B,MAAM,OAAO,IAAI,IAAI,EAAE,OAAO;GAC9B,MAAM,QAAQ,EAAE,WAAW,EAAE,OAAO;GACpC,KAAK,MAAM,QAAQ,aAAa,EAAE,MAAM,WAAW,EAAE,MAAM,iBAAiB,CAAC,GAAG;IAC9E,MAAM,OAAO,cAAc,IAAI;IAC/B,IAAI,CAAC,QAAQ,KAAK,IAAI,KAAK,EAAE,KAAK,MAAM,OAAO,KAAK,EAAE,GAAG;IACzD,KAAK,MAAM,OAAO,CAAC,KAAK,OAAO,KAAK,GAAG,GAAG;KACxC,MAAM,KAAK;MAAE,GAAG,IAAI;MAAG,GAAG,IAAI;KAAE;KAChC,KAAK,MAAM,KAAK,EAAE,QAAQ;MACxB,IAAI,KAAK,MAAM,GAAG,IAAI,EAAE,GAAG,GAAG,GAAG,IAAI,EAAE,GAAG,CAAC,IAAI,OAAO;MACtD,MAAM;OAAE,OAAO,EAAE;OAAI,OAAO;QAAE;QAAI,MAAM,CAAC;OAAE;OAAG,MAAM;QAAE,MAAM;QAAS;QAAI,OAAO;OAAS;MAAE;KAC7F;IACF;GACF;EACF;CACF;AACF"}
1
+ {"version":3,"file":"measureHit.js","names":[],"sources":["../../../src/domains/measure/measureHit.ts"],"sourcesContent":["import { pointSegmentDist } from '@weasel-js/geom';\nimport type { NodeId, Scene } from 'core/scene/types';\nimport { measureEndsOf, type MeasureEnd, type MeasurePoint } from './measurement';\nimport { measureIds, type Measurements } from './measurements';\nimport { nodesAsShown, shownLookup } from 'core/scene/nodeAsShown';\nimport { nodeGates } from 'core/scene/nodeGates';\nimport type { SnapSource } from 'domains/snap/types';\n\n/** Which part of which measurement lies under a point. */\nexport interface MeasureHit {\n id: NodeId;\n part: MeasureEnd | 'line';\n}\n\n\n/**\n * The measurement under `p`, newest first: an end within `tolerance` world\n * units of it, else a line within the same distance. A hidden or locked\n * measurement (`nodeGates`) is passed over.\n */\nexport function measureAt(\n scene: Pick<Scene<unknown, string, unknown>, 'nodesOnLayer' | 'get' | 'layers' | 'overrides'>,\n ms: Pick<Measurements, 'layer'>,\n p: MeasurePoint,\n tolerance: number,\n): MeasureHit | null {\n const gates = nodeGates(scene);\n // Hit where the ends are shown: a gesture moving one publishes it as a data override.\n const shown = shownLookup(scene);\n const ids = measureIds(scene, ms).reverse().filter((id) => !gates.hidden(id) && !gates.locked(id));\n for (const id of ids) {\n const ends = measureEndsOf(shown.get(id));\n if (!ends) continue;\n for (const part of ['end', 'start'] as const) {\n if (Math.hypot(p.x - ends[part].x, p.y - ends[part].y) <= tolerance) return { id, part };\n }\n }\n for (const id of ids) {\n const ends = measureEndsOf(shown.get(id));\n if (ends && pointSegmentDist(p.x, p.y, ends.start.x, ends.start.y, ends.end.x, ends.end.y) <= tolerance) return { id, part: 'line' };\n }\n return null;\n}\n\n/** Options for `measureEndSource`. */\nexport interface MeasureEndSourceOptions {\n /** Overrides the canvas's reach for measurement ends, in screen px. */\n reachPx?: number;\n}\n\n/** The ends of every shown measurement as snap targets for a pointer probe. */\nexport function measureEndSource(opts: MeasureEndSourceOptions = {}): SnapSource {\n return {\n id: 'measure-ends',\n roles: ['pointer'],\n ...(opts.reachPx !== undefined ? { reachPx: opts.reachPx } : {}),\n *collect(q) {\n if (!q.scene) return;\n const gates = nodeGates(q.scene);\n const skip = new Set(q.exclude);\n const reach = q.reachWorld(q.reachPx);\n for (const node of nodesAsShown(q.scene.overrides, q.scene.renderOrderNodes())) {\n const ends = measureEndsOf(node);\n if (!ends || skip.has(node.id) || gates.hidden(node.id)) continue;\n for (const end of [ends.start, ends.end]) {\n const at = { x: end.x, y: end.y };\n for (const p of q.probes) {\n if (Math.hypot(at.x - p.at.x, at.y - p.at.y) > reach) continue;\n yield { probe: p.id, locus: { at, dirs: [] }, mark: { kind: 'point', at, glyph: 'center' } };\n }\n }\n }\n },\n };\n}\n"],"mappings":";;;;;;;;;;;;AAoBA,SAAgB,UACd,OACA,IACA,GACA,WACmB;CACnB,MAAM,QAAQ,UAAU,KAAK;CAE7B,MAAM,QAAQ,YAAY,KAAK;CAC/B,MAAM,MAAM,WAAW,OAAO,EAAE,CAAC,CAAC,QAAQ,CAAC,CAAC,QAAQ,OAAO,CAAC,MAAM,OAAO,EAAE,KAAK,CAAC,MAAM,OAAO,EAAE,CAAC;CACjG,KAAK,MAAM,MAAM,KAAK;EACpB,MAAM,OAAO,cAAc,MAAM,IAAI,EAAE,CAAC;EACxC,IAAI,CAAC,MAAM;EACX,KAAK,MAAM,QAAQ,CAAC,OAAO,OAAO,GAChC,IAAI,KAAK,MAAM,EAAE,IAAI,KAAK,KAAK,CAAC,GAAG,EAAE,IAAI,KAAK,KAAK,CAAC,CAAC,KAAK,WAAW,OAAO;GAAE;GAAI;EAAK;CAE3F;CACA,KAAK,MAAM,MAAM,KAAK;EACpB,MAAM,OAAO,cAAc,MAAM,IAAI,EAAE,CAAC;EACxC,IAAI,QAAQ,iBAAiB,EAAE,GAAG,EAAE,GAAG,KAAK,MAAM,GAAG,KAAK,MAAM,GAAG,KAAK,IAAI,GAAG,KAAK,IAAI,CAAC,KAAK,WAAW,OAAO;GAAE;GAAI,MAAM;EAAO;CACrI;CACA,OAAO;AACT;;AASA,SAAgB,iBAAiB,OAAgC,CAAC,GAAe;CAC/E,OAAO;EACL,IAAI;EACJ,OAAO,CAAC,SAAS;EACjB,GAAI,KAAK,YAAY,SAAY,EAAE,SAAS,KAAK,QAAQ,IAAI,CAAC;EAC9D,CAAC,QAAQ,GAAG;GACV,IAAI,CAAC,EAAE,OAAO;GACd,MAAM,QAAQ,UAAU,EAAE,KAAK;GAC/B,MAAM,OAAO,IAAI,IAAI,EAAE,OAAO;GAC9B,MAAM,QAAQ,EAAE,WAAW,EAAE,OAAO;GACpC,KAAK,MAAM,QAAQ,aAAa,EAAE,MAAM,WAAW,EAAE,MAAM,iBAAiB,CAAC,GAAG;IAC9E,MAAM,OAAO,cAAc,IAAI;IAC/B,IAAI,CAAC,QAAQ,KAAK,IAAI,KAAK,EAAE,KAAK,MAAM,OAAO,KAAK,EAAE,GAAG;IACzD,KAAK,MAAM,OAAO,CAAC,KAAK,OAAO,KAAK,GAAG,GAAG;KACxC,MAAM,KAAK;MAAE,GAAG,IAAI;MAAG,GAAG,IAAI;KAAE;KAChC,KAAK,MAAM,KAAK,EAAE,QAAQ;MACxB,IAAI,KAAK,MAAM,GAAG,IAAI,EAAE,GAAG,GAAG,GAAG,IAAI,EAAE,GAAG,CAAC,IAAI,OAAO;MACtD,MAAM;OAAE,OAAO,EAAE;OAAI,OAAO;QAAE;QAAI,MAAM,CAAC;OAAE;OAAG,MAAM;QAAE,MAAM;QAAS;QAAI,OAAO;OAAS;MAAE;KAC7F;IACF;GACF;EACF;CACF;AACF"}
@@ -1,4 +1,4 @@
1
- import { circlePath, linePath, pathToAnchors, resolveCorners, squarePath, worldCoords } from "@weasel-js/geom";
1
+ import { circlePath, linePath, pathToAnchors, resolveCorners, squarePath, viewWorldToScreen, worldCoords } from "@weasel-js/geom";
2
2
 
3
3
  // src/domains/paths/anchorMarkers.ts
4
4
  /** The path-edit overlay's look where a {@link PathEditingOverlayStyle} leaves a field out. */
@@ -16,12 +16,6 @@ var DEFAULT_OVERLAY_STYLE = {
16
16
  marqueeStroke: "#3478f6",
17
17
  marqueeFill: "rgba(52, 120, 246, 0.08)"
18
18
  };
19
- function worldToScreen(p, view) {
20
- return {
21
- x: (p.x - view.x) * view.scale.x,
22
- y: (p.y - view.y) * view.scale.y
23
- };
24
- }
25
19
  /** `path`, its corners expanded, with every coordinate carried to screen
26
20
  * through `view`: a radius left on it would stay in world units. */
27
21
  function onScreen(path, view) {
@@ -95,14 +89,14 @@ function anchorMarkerCommands(path, view, selected, style, markers = null) {
95
89
  const { anchors, closed } = pathToAnchors(path);
96
90
  let flat = 0;
97
91
  anchors.forEach((sub, s) => {
98
- const screen = sub.map((a) => worldToScreen(a, view));
92
+ const screen = sub.map((a) => viewWorldToScreen(view, a));
99
93
  const isClosed = closed[s];
100
94
  sub.forEach((a, i) => {
101
95
  const shape = markers?.[flat] ?? null;
102
96
  const isSelected = selected.has(flat++);
103
97
  const at = screen[i];
104
- if (a.inHandle) out.push(...handleCommands(at, worldToScreen(a.inHandle, view), style));
105
- if (a.outHandle) out.push(...handleCommands(at, worldToScreen(a.outHandle, view), style));
98
+ if (a.inHandle) out.push(...handleCommands(at, viewWorldToScreen(view, a.inHandle), style));
99
+ if (a.outHandle) out.push(...handleCommands(at, viewWorldToScreen(view, a.outHandle), style));
106
100
  if (!isSelected && crowded(screen, closed[s], i, style.anchorSizePx)) {
107
101
  out.push({
108
102
  kind: "path",
@@ -134,5 +128,5 @@ function anchorMarkerCommands(path, view, selected, style, markers = null) {
134
128
  return out;
135
129
  }
136
130
 
137
- export { DEFAULT_OVERLAY_STYLE, anchorMarkerCommands, worldToScreen };
131
+ export { DEFAULT_OVERLAY_STYLE, anchorMarkerCommands };
138
132
  //# sourceMappingURL=anchorMarkers.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"anchorMarkers.js","names":[],"sources":["../../../src/domains/paths/anchorMarkers.ts"],"sourcesContent":["/**\n * The draw commands path edit paints for the open path, in screen space: its\n * outline, then each anchor's handles and marker.\n */\nimport type { DrawCommand } from '@weasel-js/renderer';\nimport type { WorldPoint } from '@weasel-js/routing';\nimport {\n circlePath, linePath, pathToAnchors, resolveCorners, squarePath, worldCoords, type PenAnchor, type PolygonPath, type View, type Point,\n} from '@weasel-js/geom';\nimport type { AnchorMarkerShape } from './pathRepresentation';\n\n/** Appearance of the outline and of the anchor and handle markers. */\nexport interface PathEditingOverlayStyle {\n anchorSizePx?: number;\n handleDotRadiusPx?: number;\n anchorFill?: string;\n anchorStroke?: string;\n handleStroke?: string;\n /** Fill of the control-point circle. Defaults to the handleStroke color\n * at 50% alpha. */\n handleDotFill?: string;\n /** Fill of a selected anchor square. Defaults to the anchor stroke color. */\n anchorFillSelected?: string;\n /** The open path's outline, drawn over its own fill and stroke so the path\n * shows however its paint and its markers cover it. */\n outlineStroke?: string;\n outlineWidthPx?: number;\n /** Radius of the dot an unselected anchor shrinks to where anchors sit\n * closer together on screen than a marker is wide. */\n denseDotRadiusPx?: number;\n /** Stroke of the marquee rubber band. */\n marqueeStroke?: string;\n /** Fill of the marquee rubber band. */\n marqueeFill?: string;\n}\n\n/** The path-edit overlay's look where a {@link PathEditingOverlayStyle} leaves a field out. */\nexport const DEFAULT_OVERLAY_STYLE: Required<PathEditingOverlayStyle> = {\n anchorSizePx: 8,\n handleDotRadiusPx: 5,\n anchorFill: '#ffffff',\n anchorStroke: '#3478f6',\n handleStroke: '#7da7e8',\n // 50%-opacity handle-stroke color. #7da7e8 → rgba(125, 167, 232, 0.5).\n handleDotFill: 'rgba(125, 167, 232, 0.5)',\n anchorFillSelected: '#3478f6',\n outlineStroke: '#3478f6',\n outlineWidthPx: 1,\n denseDotRadiusPx: 1.5,\n marqueeStroke: '#3478f6',\n marqueeFill: 'rgba(52, 120, 246, 0.08)',\n};\n\nexport function worldToScreen(p: WorldPoint, view: View): Point {\n return { x: (p.x - view.x) * view.scale.x, y: (p.y - view.y) * view.scale.y };\n}\n\n/** `path`, its corners expanded, with every coordinate carried to screen\n * through `view`: a radius left on it would stay in world units. */\nfunction onScreen(path: PolygonPath, view: View): PolygonPath {\n const resolved = resolveCorners(path);\n const world = worldCoords(resolved);\n const { origin: _measuredFrom, ...drawn } = resolved;\n const coords = new Float32Array(world.length);\n for (let i = 0; i < coords.length; i += 2) {\n coords[i] = (world[i] - view.x) * view.scale.x;\n coords[i + 1] = (world[i + 1] - view.y) * view.scale.y;\n }\n return { ...drawn, coords };\n}\n\n/** Whether `sub[i]` sits closer to a neighbor along its subpath than `px` on screen. */\nfunction crowded(sub: readonly Point[], closed: boolean, i: number, px: number): boolean {\n const n = sub.length;\n const near = (j: number): boolean => {\n if (!closed && (j < 0 || j >= n)) return false;\n const q = sub[(j + n) % n];\n return q !== sub[i] && Math.hypot(q.x - sub[i].x, q.y - sub[i].y) < px;\n };\n return near(i - 1) || near(i + 1);\n}\n\n/** The on-screen neighbors of `sub[i]` along its subpath, null past an open end. */\nfunction neighbors(sub: readonly Point[], closed: boolean, i: number): [Point | null, Point | null] {\n const n = sub.length;\n const prev = i > 0 || closed ? sub[(i - 1 + n) % n] : null;\n const next = i < n - 1 || closed ? sub[(i + 1) % n] : null;\n return [prev, next];\n}\n\nfunction handleCommands(a: Point, handle: Point, style: Required<PathEditingOverlayStyle>): DrawCommand[] {\n return [\n { kind: 'path', path: linePath(a, handle), stroke: { paint: { kind: 'solid', color: style.handleStroke }, width: 1 } },\n {\n kind: 'path',\n path: circlePath(handle.x, handle.y, style.handleDotRadiusPx),\n fill: { paint: { kind: 'solid', color: style.handleDotFill } },\n },\n ];\n}\n\n/**\n * The open path's outline, then its anchors in walk order — each anchor's\n * handles under its marker. A selected anchor's marker is filled, an\n * unselected one's hollow, or a dot where its neighbors crowd it. The marker\n * is a square, or the shape `markers` gives the anchor's flat index. Flat\n * indices count the way `enumerateAnchors` does, as the selection does.\n */\nexport function anchorMarkerCommands(\n path: PolygonPath,\n view: View,\n selected: ReadonlySet<number>,\n style: Required<PathEditingOverlayStyle>,\n markers: readonly (AnchorMarkerShape | null)[] | null = null,\n): DrawCommand[] {\n const out: DrawCommand[] = [{\n kind: 'path',\n path: onScreen(path, view),\n stroke: { paint: { kind: 'solid', color: style.outlineStroke }, width: style.outlineWidthPx },\n }];\n const { anchors, closed } = pathToAnchors(path);\n let flat = 0;\n anchors.forEach((sub: PenAnchor[], s) => {\n const screen = sub.map((a) => worldToScreen(a, view));\n const isClosed = closed[s];\n sub.forEach((a, i) => {\n const shape = markers?.[flat] ?? null;\n const isSelected = selected.has(flat++);\n const at = screen[i];\n if (a.inHandle) out.push(...handleCommands(at, worldToScreen(a.inHandle, view), style));\n if (a.outHandle) out.push(...handleCommands(at, worldToScreen(a.outHandle, view), style));\n if (!isSelected && crowded(screen, closed[s], i, style.anchorSizePx)) {\n out.push({\n kind: 'path',\n path: circlePath(at.x, at.y, style.denseDotRadiusPx),\n fill: { paint: { kind: 'solid', color: style.anchorStroke } },\n });\n return;\n }\n out.push({\n kind: 'path',\n path: shape ? shape(at, style.anchorSizePx, ...neighbors(screen, isClosed, i)) : squarePath(at.x, at.y, style.anchorSizePx),\n fill: { paint: { kind: 'solid', color: isSelected ? style.anchorFillSelected : style.anchorFill } },\n stroke: { paint: { kind: 'solid', color: style.anchorStroke }, width: 1 },\n });\n });\n });\n return out;\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"anchorMarkers.js","names":[],"sources":["../../../src/domains/paths/anchorMarkers.ts"],"sourcesContent":["/**\n * The draw commands path edit paints for the open path, in screen space: its\n * outline, then each anchor's handles and marker.\n */\nimport type { DrawCommand } from '@weasel-js/renderer';\nimport {\n circlePath, linePath, pathToAnchors, resolveCorners, squarePath, worldCoords, type PenAnchor, type PolygonPath, type View, type Point, viewWorldToScreen,\n} from '@weasel-js/geom';\nimport type { AnchorMarkerShape } from './pathRepresentation';\n\n/** Appearance of the outline and of the anchor and handle markers. */\nexport interface PathEditingOverlayStyle {\n anchorSizePx?: number;\n handleDotRadiusPx?: number;\n anchorFill?: string;\n anchorStroke?: string;\n handleStroke?: string;\n /** Fill of the control-point circle. Defaults to the handleStroke color\n * at 50% alpha. */\n handleDotFill?: string;\n /** Fill of a selected anchor square. Defaults to the anchor stroke color. */\n anchorFillSelected?: string;\n /** The open path's outline, drawn over its own fill and stroke so the path\n * shows however its paint and its markers cover it. */\n outlineStroke?: string;\n outlineWidthPx?: number;\n /** Radius of the dot an unselected anchor shrinks to where anchors sit\n * closer together on screen than a marker is wide. */\n denseDotRadiusPx?: number;\n /** Stroke of the marquee rubber band. */\n marqueeStroke?: string;\n /** Fill of the marquee rubber band. */\n marqueeFill?: string;\n}\n\n/** The path-edit overlay's look where a {@link PathEditingOverlayStyle} leaves a field out. */\nexport const DEFAULT_OVERLAY_STYLE: Required<PathEditingOverlayStyle> = {\n anchorSizePx: 8,\n handleDotRadiusPx: 5,\n anchorFill: '#ffffff',\n anchorStroke: '#3478f6',\n handleStroke: '#7da7e8',\n // 50%-opacity handle-stroke color. #7da7e8 → rgba(125, 167, 232, 0.5).\n handleDotFill: 'rgba(125, 167, 232, 0.5)',\n anchorFillSelected: '#3478f6',\n outlineStroke: '#3478f6',\n outlineWidthPx: 1,\n denseDotRadiusPx: 1.5,\n marqueeStroke: '#3478f6',\n marqueeFill: 'rgba(52, 120, 246, 0.08)',\n};\n\n/** `path`, its corners expanded, with every coordinate carried to screen\n * through `view`: a radius left on it would stay in world units. */\nfunction onScreen(path: PolygonPath, view: View): PolygonPath {\n const resolved = resolveCorners(path);\n const world = worldCoords(resolved);\n const { origin: _measuredFrom, ...drawn } = resolved;\n const coords = new Float32Array(world.length);\n for (let i = 0; i < coords.length; i += 2) {\n coords[i] = (world[i] - view.x) * view.scale.x;\n coords[i + 1] = (world[i + 1] - view.y) * view.scale.y;\n }\n return { ...drawn, coords };\n}\n\n/** Whether `sub[i]` sits closer to a neighbor along its subpath than `px` on screen. */\nfunction crowded(sub: readonly Point[], closed: boolean, i: number, px: number): boolean {\n const n = sub.length;\n const near = (j: number): boolean => {\n if (!closed && (j < 0 || j >= n)) return false;\n const q = sub[(j + n) % n];\n return q !== sub[i] && Math.hypot(q.x - sub[i].x, q.y - sub[i].y) < px;\n };\n return near(i - 1) || near(i + 1);\n}\n\n/** The on-screen neighbors of `sub[i]` along its subpath, null past an open end. */\nfunction neighbors(sub: readonly Point[], closed: boolean, i: number): [Point | null, Point | null] {\n const n = sub.length;\n const prev = i > 0 || closed ? sub[(i - 1 + n) % n] : null;\n const next = i < n - 1 || closed ? sub[(i + 1) % n] : null;\n return [prev, next];\n}\n\nfunction handleCommands(a: Point, handle: Point, style: Required<PathEditingOverlayStyle>): DrawCommand[] {\n return [\n { kind: 'path', path: linePath(a, handle), stroke: { paint: { kind: 'solid', color: style.handleStroke }, width: 1 } },\n {\n kind: 'path',\n path: circlePath(handle.x, handle.y, style.handleDotRadiusPx),\n fill: { paint: { kind: 'solid', color: style.handleDotFill } },\n },\n ];\n}\n\n/**\n * The open path's outline, then its anchors in walk order — each anchor's\n * handles under its marker. A selected anchor's marker is filled, an\n * unselected one's hollow, or a dot where its neighbors crowd it. The marker\n * is a square, or the shape `markers` gives the anchor's flat index. Flat\n * indices count the way `enumerateAnchors` does, as the selection does.\n */\nexport function anchorMarkerCommands(\n path: PolygonPath,\n view: View,\n selected: ReadonlySet<number>,\n style: Required<PathEditingOverlayStyle>,\n markers: readonly (AnchorMarkerShape | null)[] | null = null,\n): DrawCommand[] {\n const out: DrawCommand[] = [{\n kind: 'path',\n path: onScreen(path, view),\n stroke: { paint: { kind: 'solid', color: style.outlineStroke }, width: style.outlineWidthPx },\n }];\n const { anchors, closed } = pathToAnchors(path);\n let flat = 0;\n anchors.forEach((sub: PenAnchor[], s) => {\n const screen = sub.map((a) => viewWorldToScreen(view, a));\n const isClosed = closed[s];\n sub.forEach((a, i) => {\n const shape = markers?.[flat] ?? null;\n const isSelected = selected.has(flat++);\n const at = screen[i];\n if (a.inHandle) out.push(...handleCommands(at, viewWorldToScreen(view, a.inHandle), style));\n if (a.outHandle) out.push(...handleCommands(at, viewWorldToScreen(view, a.outHandle), style));\n if (!isSelected && crowded(screen, closed[s], i, style.anchorSizePx)) {\n out.push({\n kind: 'path',\n path: circlePath(at.x, at.y, style.denseDotRadiusPx),\n fill: { paint: { kind: 'solid', color: style.anchorStroke } },\n });\n return;\n }\n out.push({\n kind: 'path',\n path: shape ? shape(at, style.anchorSizePx, ...neighbors(screen, isClosed, i)) : squarePath(at.x, at.y, style.anchorSizePx),\n fill: { paint: { kind: 'solid', color: isSelected ? style.anchorFillSelected : style.anchorFill } },\n stroke: { paint: { kind: 'solid', color: style.anchorStroke }, width: 1 },\n });\n });\n });\n return out;\n}\n"],"mappings":"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"}