@neta-art/cohub 3.1.0 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/README.md +33 -2
  2. package/dist/{board.d.ts → board/animation.d.ts} +4 -2
  3. package/dist/{chunks/board.js → board/animation.js} +5 -44
  4. package/dist/board/codec.d.ts +25 -0
  5. package/dist/board/codec.js +252 -0
  6. package/dist/board/core/bindings.d.ts +45 -0
  7. package/dist/board/core/bindings.js +162 -0
  8. package/dist/board/core/draw-geometry.d.ts +31 -0
  9. package/dist/board/core/draw-geometry.js +183 -0
  10. package/dist/board/core/export-assets.d.ts +14 -0
  11. package/dist/board/core/export-assets.js +22 -0
  12. package/dist/board/core/export-plan.d.ts +84 -0
  13. package/dist/board/core/export-plan.js +128 -0
  14. package/dist/board/core/file-preview.d.ts +176 -0
  15. package/dist/board/core/file-preview.js +271 -0
  16. package/dist/board/core/palette.d.ts +42 -0
  17. package/dist/board/core/palette.js +138 -0
  18. package/dist/board/core/shape-definition.d.ts +35 -0
  19. package/dist/board/core/shape-definition.js +64 -0
  20. package/dist/board/core/shape-types.d.ts +144 -0
  21. package/dist/board/core/shape-types.js +27 -0
  22. package/dist/board/core/text-metrics.d.ts +18 -0
  23. package/dist/board/core/text-metrics.js +43 -0
  24. package/dist/board/export/index.d.ts +56 -0
  25. package/dist/board/export/index.js +86 -0
  26. package/dist/board/export/scene.d.ts +35 -0
  27. package/dist/board/export/scene.js +69 -0
  28. package/dist/board/geometry.d.ts +131 -0
  29. package/dist/board/geometry.js +468 -0
  30. package/dist/board/headless/index.d.ts +69 -0
  31. package/dist/board/headless/index.js +150 -0
  32. package/dist/board/image-key.d.ts +19 -0
  33. package/dist/board/image-key.js +34 -0
  34. package/dist/board/index.d.ts +16 -0
  35. package/dist/board/index.js +16 -0
  36. package/dist/board/render/index.d.ts +6 -0
  37. package/dist/board/render/index.js +6 -0
  38. package/dist/board/render/palette.d.ts +5 -0
  39. package/dist/board/render/palette.js +30 -0
  40. package/dist/board/render/renderers/arrow-card-renderer.d.ts +5 -0
  41. package/dist/board/render/renderers/arrow-card-renderer.js +157 -0
  42. package/dist/board/render/renderers/base-card-renderer.d.ts +46 -0
  43. package/dist/board/render/renderers/base-card-renderer.js +153 -0
  44. package/dist/board/render/renderers/board-renderer-registry.d.ts +85 -0
  45. package/dist/board/render/renderers/board-renderer-registry.js +43 -0
  46. package/dist/board/render/renderers/draw-card-renderer.d.ts +5 -0
  47. package/dist/board/render/renderers/draw-card-renderer.js +82 -0
  48. package/dist/board/render/renderers/far-plate.d.ts +39 -0
  49. package/dist/board/render/renderers/far-plate.js +92 -0
  50. package/dist/board/render/renderers/file-card-renderer.d.ts +33 -0
  51. package/dist/board/render/renderers/file-card-renderer.js +371 -0
  52. package/dist/board/render/renderers/frame-card-renderer.d.ts +5 -0
  53. package/dist/board/render/renderers/frame-card-renderer.js +99 -0
  54. package/dist/board/render/renderers/geo-card-renderer.d.ts +5 -0
  55. package/dist/board/render/renderers/geo-card-renderer.js +128 -0
  56. package/dist/board/render/renderers/image-card-renderer.d.ts +8 -0
  57. package/dist/board/render/renderers/image-card-renderer.js +138 -0
  58. package/dist/board/render/renderers/note-card-renderer.d.ts +5 -0
  59. package/dist/board/render/renderers/note-card-renderer.js +112 -0
  60. package/dist/board/render/renderers/text-card-renderer.d.ts +7 -0
  61. package/dist/board/render/renderers/text-card-renderer.js +94 -0
  62. package/dist/board/render/renderers/unknown-card-renderer.d.ts +5 -0
  63. package/dist/board/render/renderers/unknown-card-renderer.js +102 -0
  64. package/dist/board/render/renderers/video-card-renderer.d.ts +7 -0
  65. package/dist/board/render/renderers/video-card-renderer.js +146 -0
  66. package/dist/board/render/text-measurement.d.ts +30 -0
  67. package/dist/board/render/text-measurement.js +57 -0
  68. package/dist/board/render/text-resolution.d.ts +16 -0
  69. package/dist/board/render/text-resolution.js +43 -0
  70. package/dist/board/render/themes/board-theme-registry.d.ts +20 -0
  71. package/dist/board/render/themes/board-theme-registry.js +13 -0
  72. package/dist/board/render/themes/clean-theme.d.ts +5 -0
  73. package/dist/board/render/themes/clean-theme.js +102 -0
  74. package/dist/chunks/http.d.ts +1 -2
  75. package/dist/chunks/websocket.d.ts +798 -352
  76. package/dist/http.d.ts +1 -2
  77. package/dist/index.d.ts +99 -4
  78. package/dist/index.js +391 -1
  79. package/dist/protocol/dist/board-constants.d.ts +23 -0
  80. package/dist/protocol/dist/board-constants.js +60 -0
  81. package/dist/protocol/dist/board-document.d.ts +1124 -0
  82. package/dist/protocol/dist/board-document.js +331 -0
  83. package/dist/protocol/dist/board.d.ts +186 -0
  84. package/dist/protocol/dist/board.js +207 -0
  85. package/dist/protocol/dist/index.d.ts +5 -0
  86. package/dist/protocol/dist/index.js +5 -0
  87. package/dist/protocol/dist/provenance.js +12 -0
  88. package/dist/protocol/dist/realtime/board-awareness.d.ts +1 -0
  89. package/dist/protocol/dist/realtime/board-awareness.js +117 -0
  90. package/dist/protocol/dist/realtime/types.d.ts +2 -0
  91. package/package.json +39 -4
  92. package/dist/board.js +0 -2
  93. package/dist/chunks/board.d.ts +0 -2589
package/README.md CHANGED
@@ -118,18 +118,49 @@ const stop = board.subscribe({
118
118
  stop();
119
119
  ```
120
120
 
121
- Import timeline compilation and extension registry helpers from the lightweight
122
- Board entry point:
121
+ Board is split by dependency: the model runs anywhere, drawing needs PixiJS.
122
+ `@neta-art/cohub/board` carries the document schema, geometry, the shape layer,
123
+ timeline compilation and export planning, with no renderer and no PixiJS — so
124
+ agents, servers and edge workers can read, write and measure boards without a
125
+ graphics stack:
123
126
 
124
127
  ```ts
125
128
  import {
129
+ BoardDocumentSchema,
126
130
  clip,
127
131
  compileSequence,
128
132
  createBoardExtensionRegistry,
133
+ itemBounds,
134
+ planBoardExport,
129
135
  timeline,
130
136
  } from "@neta-art/cohub/board";
131
137
  ```
132
138
 
139
+ Drawing pixels is where PixiJS enters. The card renderers and themes the editor
140
+ uses live behind `@neta-art/cohub/board/render`, and turning a plan into an
141
+ image has dedicated browser and Node.js entries:
142
+
143
+ ```ts
144
+ import { getBoardCardRenderer } from "@neta-art/cohub/board/render";
145
+ import { renderBoardExport } from "@neta-art/cohub/board/export";
146
+ import {
147
+ createBoardHeadlessRenderer,
148
+ exportBoardImageBytes,
149
+ } from "@neta-art/cohub/board/headless";
150
+ ```
151
+
152
+ Install `pixi.js` to use `board/render` or `board/export`, and add
153
+ `@napi-rs/canvas` as well for `board/headless`. Both are optional peers, and
154
+ `@neta-art/cohub/board` never reaches for either, so HTTP-only installations
155
+ stay lightweight.
156
+
157
+ Text metrics follow the same split. The renderers measure through a real canvas
158
+ and set that up themselves, so nothing extra is needed to draw or export. Called
159
+ straight off `@neta-art/cohub/board` with no renderer in play,
160
+ `measureBoardText` returns a per-character estimate instead — fine for laying
161
+ out a board on a server, but call `installBoardTextMeasurement` from
162
+ `board/render` first if the numbers have to match what the editor draws.
163
+
133
164
  ## Session subscriptions
134
165
 
135
166
  ```ts
@@ -1,5 +1,7 @@
1
- import { F as BoardCapability, I as BoardRenderCost, c as BoardEffect, i as BoardClip, s as BoardDiagnostic, t as BoardAssetRef, v as BoardSequence, x as BoardValidationResult, y as BoardTarget } from "./chunks/board.js";
2
- //#region src/board.d.ts
1
+ import { BoardCapability, BoardRenderCost } from "../protocol/dist/board-constants.js";
2
+ import { BoardAssetRef, BoardClip, BoardDiagnostic, BoardEffect, BoardSequence, BoardTarget, BoardValidationResult } from "../protocol/dist/board.js";
3
+ import "../protocol/dist/index.js";
4
+ //#region src/board/animation.d.ts
3
5
  type TimelineClipInput = Omit<BoardClip, "id" | "sequenceId" | "start" | "seed"> & {
4
6
  id?: string;
5
7
  seed?: string;
@@ -1,44 +1,5 @@
1
- //#region ../protocol/dist/board-constants.js
2
- const DEFAULT_BOARD_RENDER_LIMITS = {
3
- particles: 2e4,
4
- vertices: 5e5,
5
- dynamicVertices: 15e4,
6
- drawCalls: 400,
7
- filterPasses: 24,
8
- renderTexturePixels: 16777216,
9
- textureBytes: 512 * 1024 * 1024,
10
- bufferBytes: 256 * 1024 * 1024,
11
- simulationSteps: 1e5
12
- };
13
- const BOARD_BUILTIN_CLIP_KINDS = [
14
- "motion.keyframes",
15
- "motion.path",
16
- "draw.reveal",
17
- "draw.handwrite",
18
- "text.reveal",
19
- "effects.particles",
20
- "effects.trail",
21
- "effects.impact",
22
- "effects.flash",
23
- "effects.color",
24
- "camera.pan",
25
- "camera.zoom",
26
- "camera.shake"
27
- ];
28
- const BOARD_BUILTIN_EFFECT_KINDS = ["effects.pulse", "effects.float"];
29
- const BOARD_BUILTIN_CAPABILITIES = [...BOARD_BUILTIN_CLIP_KINDS.map((id) => ({
30
- kind: "clip",
31
- id,
32
- version: 1,
33
- renderers: ["webgpu", "webgl"]
34
- })), ...BOARD_BUILTIN_EFFECT_KINDS.map((id) => ({
35
- kind: "effect",
36
- id,
37
- version: 1,
38
- renderers: ["webgpu", "webgl"]
39
- }))];
40
- //#endregion
41
- //#region src/board.ts
1
+ import { BOARD_BUILTIN_CAPABILITIES, DEFAULT_BOARD_RENDER_LIMITS } from "../protocol/dist/board-constants.js";
2
+ //#region src/board/animation.ts
42
3
  const ZERO_COST = {
43
4
  particles: 0,
44
5
  vertices: 0,
@@ -266,9 +227,9 @@ function flatten(input, offset, sequenceSeed, path) {
266
227
  }
267
228
  const clips = [];
268
229
  let duration = 0;
269
- for (let index = 0; index < input.children.length; index += 1) {
230
+ for (const [index, child] of input.children.entries()) {
270
231
  const childOffset = input.type === "sequence" ? duration : input.type === "stagger" ? input.each * index : 0;
271
- const nested = flatten(input.children[index], offset + childOffset, sequenceSeed, `${path}.${index}`);
232
+ const nested = flatten(child, offset + childOffset, sequenceSeed, `${path}.${index}`);
272
233
  clips.push(...nested.clips);
273
234
  duration = input.type === "sequence" ? duration + nested.duration : Math.max(duration, childOffset + nested.duration);
274
235
  }
@@ -389,4 +350,4 @@ function createBoardExtensionRegistry(input = {}) {
389
350
  return registry;
390
351
  }
391
352
  //#endregion
392
- export { createBoardExtensionRegistry as a, compileSequence as i, DEFAULT_BOARD_LIMITS as n, timeline as o, clip as r, BoardExtensionRegistry as t };
353
+ export { BoardExtensionRegistry, DEFAULT_BOARD_LIMITS, clip, compileSequence, createBoardExtensionRegistry, timeline };
@@ -0,0 +1,25 @@
1
+ import { BoardManifest, BoardNodeInput, BoardNodeRecord, BoardRecord, InvalidBoardFileError, isBoardPath, parseBoardManifest, serializeBoardManifest } from "../protocol/dist/board.js";
2
+ import "../protocol/dist/index.js";
3
+ import { BoardAppearance, BoardDocument, BoardItem } from "../protocol/dist/board-document.js";
4
+ //#region src/board/codec.d.ts
5
+ declare const DEFAULT_BOARD_APPEARANCE: BoardAppearance;
6
+ declare const ITEM_BASE_KEYS: readonly ["id", "type", "frame", "locked", "style", "metadata"];
7
+ /**
8
+ * Original wire data follows an item through immutable object spreads while
9
+ * remaining invisible to JSON serialization. Codecs can then patch only the
10
+ * fields this client understands instead of rebuilding and truncating a node.
11
+ */
12
+ declare const BOARD_NODE_SOURCE: unique symbol;
13
+ type WireBackedBoardItem = BoardItem & {
14
+ [BOARD_NODE_SOURCE]?: BoardNodeInput;
15
+ };
16
+ declare function nodeInputFromRecord(node: BoardNodeRecord): BoardNodeInput;
17
+ declare function sourceForItem(item: BoardItem): BoardNodeInput | undefined;
18
+ declare function isRecord(value: unknown): value is Record<string, unknown>;
19
+ declare function boardNodeToItem(node: BoardNodeRecord): BoardItem;
20
+ declare function boardBootstrapToDocument(input: {
21
+ board: BoardRecord;
22
+ nodes: BoardNodeRecord[];
23
+ }): BoardDocument;
24
+ //#endregion
25
+ export { BOARD_NODE_SOURCE, type BoardManifest, type BoardNodeRecord, type BoardRecord, DEFAULT_BOARD_APPEARANCE, ITEM_BASE_KEYS, InvalidBoardFileError, WireBackedBoardItem, boardBootstrapToDocument, boardNodeToItem, isBoardPath, isRecord, nodeInputFromRecord, parseBoardManifest, serializeBoardManifest, sourceForItem };
@@ -0,0 +1,252 @@
1
+ import { BOARD_DOCUMENT_KIND, InvalidBoardFileError, isBoardPath, parseBoardManifest, serializeBoardManifest } from "../protocol/dist/board.js";
2
+ import { BoardAppearanceSchema, BoardDocumentSchema, BoardFileSnapshotSchema, UNKNOWN_BOARD_ITEM_TYPE } from "../protocol/dist/board-document.js";
3
+ import { TEXT_FONT_SIZE, clampBoardTextFontSize } from "./core/text-metrics.js";
4
+ import "../protocol/dist/index.js";
5
+ //#region src/board/codec.ts
6
+ const DEFAULT_BOARD_APPEARANCE = BoardAppearanceSchema.parse({
7
+ theme: "clean",
8
+ background: { kind: "solid" },
9
+ grid: {
10
+ visible: false,
11
+ size: 24,
12
+ opacity: .12
13
+ },
14
+ mood: "clean"
15
+ });
16
+ const ITEM_BASE_KEYS = [
17
+ "id",
18
+ "type",
19
+ "frame",
20
+ "locked",
21
+ "style",
22
+ "metadata"
23
+ ];
24
+ /**
25
+ * Original wire data follows an item through immutable object spreads while
26
+ * remaining invisible to JSON serialization. Codecs can then patch only the
27
+ * fields this client understands instead of rebuilding and truncating a node.
28
+ */
29
+ const BOARD_NODE_SOURCE = Symbol("board-node-source");
30
+ function nodeInputFromRecord(node) {
31
+ return {
32
+ nodeId: node.nodeId,
33
+ type: node.type,
34
+ parentId: node.parentId ?? null,
35
+ orderKey: node.orderKey ?? null,
36
+ x: node.x,
37
+ y: node.y,
38
+ width: node.width,
39
+ height: node.height,
40
+ rotation: node.rotation,
41
+ refKind: node.refKind ?? null,
42
+ refPath: node.refPath ?? null,
43
+ refUrl: node.refUrl ?? null,
44
+ view: node.view ?? {},
45
+ style: node.style ?? {},
46
+ data: node.data ?? {}
47
+ };
48
+ }
49
+ function sourceForItem(item) {
50
+ return item[BOARD_NODE_SOURCE];
51
+ }
52
+ function isRecord(value) {
53
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
54
+ }
55
+ function frameFromNode(node) {
56
+ return {
57
+ x: node.x,
58
+ y: node.y,
59
+ width: node.width,
60
+ height: node.height,
61
+ rotation: node.rotation
62
+ };
63
+ }
64
+ function lockedFromData(data) {
65
+ return data.locked === true ? true : void 0;
66
+ }
67
+ function styleFromNode(node) {
68
+ return Object.keys(node.style ?? {}).length ? node.style : void 0;
69
+ }
70
+ function spaceFilePathFromNode(node) {
71
+ if (node.refKind === "space_file" && node.refPath) return node.refPath;
72
+ if (typeof node.refPath === "string" && node.refPath) return node.refPath;
73
+ return null;
74
+ }
75
+ function boardNodeToItemValue(node) {
76
+ const frame = frameFromNode(node);
77
+ const style = styleFromNode(node);
78
+ const data = node.data ?? {};
79
+ const locked = lockedFromData(data);
80
+ switch (node.type) {
81
+ case "text": return {
82
+ id: node.nodeId,
83
+ type: "text",
84
+ text: typeof data.text === "string" ? data.text : "",
85
+ color: typeof data.color === "string" ? data.color : "neutral",
86
+ fontSize: clampBoardTextFontSize(typeof data.fontSize === "number" ? data.fontSize : TEXT_FONT_SIZE),
87
+ frame,
88
+ ...locked ? { locked } : {},
89
+ style
90
+ };
91
+ case "note": return {
92
+ id: node.nodeId,
93
+ type: "note",
94
+ text: typeof data.text === "string" ? data.text : "",
95
+ color: typeof data.color === "string" ? data.color : "amber",
96
+ frame,
97
+ ...locked ? { locked } : {},
98
+ style
99
+ };
100
+ case "geo": return {
101
+ id: node.nodeId,
102
+ type: "geo",
103
+ geo: typeof data.geo === "string" ? data.geo : "rectangle",
104
+ text: typeof data.text === "string" ? data.text : "",
105
+ color: typeof data.color === "string" ? data.color : "brand",
106
+ fillOpacity: typeof data.fillOpacity === "number" ? data.fillOpacity : 0,
107
+ frame,
108
+ ...locked ? { locked } : {},
109
+ style
110
+ };
111
+ case "draw": return {
112
+ id: node.nodeId,
113
+ type: "draw",
114
+ points: Array.isArray(data.points) ? data.points : [],
115
+ color: typeof data.color === "string" ? data.color : "brand",
116
+ size: typeof data.size === "number" ? data.size : 4,
117
+ frame,
118
+ ...locked ? { locked } : {},
119
+ style
120
+ };
121
+ case "arrow": return {
122
+ id: node.nodeId,
123
+ type: "arrow",
124
+ start: data.start ?? {
125
+ kind: "point",
126
+ x: 0,
127
+ y: 0
128
+ },
129
+ end: data.end ?? {
130
+ kind: "point",
131
+ x: 0,
132
+ y: 0
133
+ },
134
+ bend: typeof data.bend === "number" ? data.bend : 0,
135
+ color: typeof data.color === "string" ? data.color : "brand",
136
+ size: typeof data.size === "number" ? data.size : 2.5,
137
+ arrowStart: Boolean(data.arrowStart),
138
+ arrowEnd: data.arrowEnd === void 0 ? true : Boolean(data.arrowEnd),
139
+ label: typeof data.label === "string" ? data.label : "",
140
+ frame,
141
+ ...locked ? { locked } : {},
142
+ style
143
+ };
144
+ case "frame": return {
145
+ id: node.nodeId,
146
+ type: "frame",
147
+ label: typeof data.label === "string" ? data.label : "Frame",
148
+ color: typeof data.color === "string" ? data.color : "neutral",
149
+ frame,
150
+ ...locked ? { locked } : {},
151
+ style
152
+ };
153
+ case "image": {
154
+ const path = spaceFilePathFromNode(node) ?? "missing";
155
+ return {
156
+ id: node.nodeId,
157
+ type: "image",
158
+ ref: {
159
+ kind: "space-file",
160
+ path
161
+ },
162
+ snapshot: node.view,
163
+ ...data.crop && typeof data.crop === "object" ? { crop: data.crop } : {},
164
+ frame,
165
+ ...locked ? { locked } : {},
166
+ style
167
+ };
168
+ }
169
+ case "video": {
170
+ const path = spaceFilePathFromNode(node) ?? "missing";
171
+ return {
172
+ id: node.nodeId,
173
+ type: "video",
174
+ ref: {
175
+ kind: "space-file",
176
+ path
177
+ },
178
+ snapshot: node.view,
179
+ frame,
180
+ ...locked ? { locked } : {},
181
+ style
182
+ };
183
+ }
184
+ case "file": {
185
+ const path = spaceFilePathFromNode(node) ?? "missing";
186
+ const parsed = BoardFileSnapshotSchema.safeParse(node.view ?? {});
187
+ return {
188
+ id: node.nodeId,
189
+ type: "file",
190
+ ref: {
191
+ kind: "space-file",
192
+ path
193
+ },
194
+ ...parsed.success ? { snapshot: parsed.data } : {},
195
+ frame,
196
+ ...locked ? { locked } : {},
197
+ style
198
+ };
199
+ }
200
+ default: {
201
+ const raw = {
202
+ ...data,
203
+ id: node.nodeId,
204
+ type: node.type,
205
+ frame
206
+ };
207
+ if (style) raw.style = style;
208
+ if (locked) raw.locked = true;
209
+ return {
210
+ id: node.nodeId,
211
+ type: UNKNOWN_BOARD_ITEM_TYPE,
212
+ frame,
213
+ ...locked ? { locked } : {},
214
+ style,
215
+ raw
216
+ };
217
+ }
218
+ }
219
+ }
220
+ function boardNodeToItem(node) {
221
+ const item = boardNodeToItemValue(node);
222
+ const metadata = isRecord(node.data?.metadata) ? node.data.metadata : void 0;
223
+ return {
224
+ ...item,
225
+ ...metadata ? { metadata } : {},
226
+ [BOARD_NODE_SOURCE]: nodeInputFromRecord(node)
227
+ };
228
+ }
229
+ function boardBootstrapToDocument(input) {
230
+ const appearance = BoardAppearanceSchema.safeParse(input.board.metadata.appearance);
231
+ const document = BoardDocumentSchema.parse({
232
+ kind: BOARD_DOCUMENT_KIND,
233
+ version: 1,
234
+ appearance: appearance.success ? appearance.data : DEFAULT_BOARD_APPEARANCE,
235
+ viewport: {
236
+ x: 0,
237
+ y: 0,
238
+ zoom: 1
239
+ },
240
+ items: input.nodes.map(boardNodeToItem)
241
+ });
242
+ const sources = new Map(input.nodes.map((node) => [node.nodeId, nodeInputFromRecord(node)]));
243
+ return {
244
+ ...document,
245
+ items: document.items.map((item) => ({
246
+ ...item,
247
+ [BOARD_NODE_SOURCE]: sources.get(item.id)
248
+ }))
249
+ };
250
+ }
251
+ //#endregion
252
+ export { BOARD_NODE_SOURCE, DEFAULT_BOARD_APPEARANCE, ITEM_BASE_KEYS, InvalidBoardFileError, boardBootstrapToDocument, boardNodeToItem, isBoardPath, isRecord, nodeInputFromRecord, parseBoardManifest, serializeBoardManifest, sourceForItem };
@@ -0,0 +1,45 @@
1
+ import { ArrowEndpoint, BoardArrowItem, BoardFrame } from "../../protocol/dist/board-document.js";
2
+ import { Rect, WorldPoint } from "../geometry.js";
3
+ //#region src/board/core/bindings.d.ts
4
+ type FrameLookup = (id: string) => BoardFrame | undefined;
5
+ /** Denormalize a binding anchor to a world point on the (rotated) frame. */
6
+ declare function anchorToWorld(frame: BoardFrame, nx: number, ny: number): WorldPoint;
7
+ /** Normalize a world point to a binding anchor on the (rotated) frame, clamped. */
8
+ declare function worldToAnchor(frame: BoardFrame, point: WorldPoint): {
9
+ nx: number;
10
+ ny: number;
11
+ };
12
+ /** Resolve an endpoint to a world point, or null if a binding target is gone. */
13
+ declare function resolveEndpoint(endpoint: ArrowEndpoint, getFrame: FrameLookup): WorldPoint | null;
14
+ type ResolvedArrow = {
15
+ start: WorldPoint;
16
+ end: WorldPoint;
17
+ /** Quadratic control point (already bent); equals midpoint when bend is 0. */
18
+ control: WorldPoint;
19
+ };
20
+ /** Resolve both endpoints and compute the bent control point. */
21
+ declare function resolveArrow(item: BoardArrowItem, getFrame: FrameLookup): ResolvedArrow | null;
22
+ /** World-space bounds of an arrow, sampling the curve for accuracy. */
23
+ declare function arrowBounds(item: BoardArrowItem, getFrame: FrameLookup): Rect | null;
24
+ /** Sample the arrow's quadratic curve into world points (for render/hit-test). */
25
+ declare function sampleQuadratic(resolved: ResolvedArrow, segments: number): WorldPoint[];
26
+ /** Distance from a world point to the arrow curve (hit testing). */
27
+ declare function distanceToArrow(item: BoardArrowItem, getFrame: FrameLookup, point: WorldPoint): number;
28
+ /**
29
+ * Turn a free endpoint into a binding if a candidate target frame contains the
30
+ * point; otherwise keep it free. Used while dragging an arrow handle so the
31
+ * arrow "connects" to shapes it lands on.
32
+ */
33
+ declare function bindEndpointAt(point: WorldPoint, target: {
34
+ id: string;
35
+ frame: BoardFrame;
36
+ } | null): ArrowEndpoint;
37
+ /**
38
+ * Translate an arrow by a delta: free endpoints move; bindings stay attached to
39
+ * their targets (so a bound end anchors while the rest of the arrow moves). The
40
+ * frame — a derived bounding box, not a transform — is recomputed from the new
41
+ * endpoint geometry.
42
+ */
43
+ declare function translateArrow(item: BoardArrowItem, dx: number, dy: number, getFrame: FrameLookup): BoardArrowItem;
44
+ //#endregion
45
+ export { FrameLookup, ResolvedArrow, anchorToWorld, arrowBounds, bindEndpointAt, distanceToArrow, resolveArrow, resolveEndpoint, sampleQuadratic, translateArrow, worldToAnchor };
@@ -0,0 +1,162 @@
1
+ import { degToRad, rectCenter, rotatePointAround, worldPoint } from "../geometry.js";
2
+ //#region src/board/core/bindings.ts
3
+ /**
4
+ * Arrow bindings — pure geometry for connecting arrows to shapes.
5
+ *
6
+ * An arrow endpoint is either a free world point or a *binding*: a normalized
7
+ * anchor (0..1) on a target shape's frame. Because the anchor is relative, the
8
+ * arrow tracks the target through move and resize with no stored absolute
9
+ * coordinates — the binding never goes stale. Resolution to world space happens
10
+ * here, given a frame lookup, so it is fully testable and renderer-independent.
11
+ */
12
+ /** Denormalize a binding anchor to a world point on the (rotated) frame. */
13
+ function anchorToWorld(frame, nx, ny) {
14
+ const unrotated = worldPoint(frame.x + nx * frame.width, frame.y + ny * frame.height);
15
+ if (!frame.rotation) return unrotated;
16
+ return rotatePointAround(unrotated, rectCenter(frame), degToRad(frame.rotation));
17
+ }
18
+ /** Normalize a world point to a binding anchor on the (rotated) frame, clamped. */
19
+ function worldToAnchor(frame, point) {
20
+ let local = point;
21
+ if (frame.rotation) local = rotatePointAround(point, rectCenter(frame), -degToRad(frame.rotation));
22
+ return {
23
+ nx: clamp01((local.x - frame.x) / frame.width),
24
+ ny: clamp01((local.y - frame.y) / frame.height)
25
+ };
26
+ }
27
+ function clamp01(value) {
28
+ return Math.min(1, Math.max(0, value));
29
+ }
30
+ /** Resolve an endpoint to a world point, or null if a binding target is gone. */
31
+ function resolveEndpoint(endpoint, getFrame) {
32
+ if (endpoint.kind === "point") return worldPoint(endpoint.x, endpoint.y);
33
+ const frame = getFrame(endpoint.target);
34
+ if (!frame) return null;
35
+ return anchorToWorld(frame, endpoint.nx, endpoint.ny);
36
+ }
37
+ /** Resolve both endpoints and compute the bent control point. */
38
+ function resolveArrow(item, getFrame) {
39
+ const start = resolveEndpoint(item.start, getFrame);
40
+ const end = resolveEndpoint(item.end, getFrame);
41
+ if (!start || !end) return null;
42
+ const mid = worldPoint((start.x + end.x) / 2, (start.y + end.y) / 2);
43
+ if (!item.bend) return {
44
+ start,
45
+ end,
46
+ control: mid
47
+ };
48
+ const dx = end.x - start.x;
49
+ const dy = end.y - start.y;
50
+ const length = Math.hypot(dx, dy) || 1;
51
+ const offset = item.bend * length;
52
+ return {
53
+ start,
54
+ end,
55
+ control: worldPoint(mid.x + -dy / length * offset, mid.y + dx / length * offset)
56
+ };
57
+ }
58
+ /** World-space bounds of an arrow, sampling the curve for accuracy. */
59
+ function arrowBounds(item, getFrame) {
60
+ const resolved = resolveArrow(item, getFrame);
61
+ if (!resolved) return null;
62
+ const samples = sampleQuadratic(resolved, 12);
63
+ let minX = Number.POSITIVE_INFINITY;
64
+ let minY = Number.POSITIVE_INFINITY;
65
+ let maxX = Number.NEGATIVE_INFINITY;
66
+ let maxY = Number.NEGATIVE_INFINITY;
67
+ for (const p of samples) {
68
+ minX = Math.min(minX, p.x);
69
+ minY = Math.min(minY, p.y);
70
+ maxX = Math.max(maxX, p.x);
71
+ maxY = Math.max(maxY, p.y);
72
+ }
73
+ const pad = Math.max(8, item.size * 2);
74
+ return {
75
+ x: minX - pad,
76
+ y: minY - pad,
77
+ width: Math.max(1, maxX - minX + pad * 2),
78
+ height: Math.max(1, maxY - minY + pad * 2)
79
+ };
80
+ }
81
+ /** Sample the arrow's quadratic curve into world points (for render/hit-test). */
82
+ function sampleQuadratic(resolved, segments) {
83
+ const { start, control, end } = resolved;
84
+ const out = [];
85
+ for (let i = 0; i <= segments; i += 1) {
86
+ const t = i / segments;
87
+ const mt = 1 - t;
88
+ out.push(worldPoint(mt * mt * start.x + 2 * mt * t * control.x + t * t * end.x, mt * mt * start.y + 2 * mt * t * control.y + t * t * end.y));
89
+ }
90
+ return out;
91
+ }
92
+ /** Distance from a world point to the arrow curve (hit testing). */
93
+ function distanceToArrow(item, getFrame, point) {
94
+ const resolved = resolveArrow(item, getFrame);
95
+ if (!resolved) return Number.POSITIVE_INFINITY;
96
+ const samples = sampleQuadratic(resolved, 16);
97
+ let min = Number.POSITIVE_INFINITY;
98
+ for (let i = 0; i < samples.length - 1; i += 1) {
99
+ const from = samples[i];
100
+ const to = samples[i + 1];
101
+ if (!from || !to) continue;
102
+ const d = segmentDistance(point, from, to);
103
+ if (d < min) min = d;
104
+ }
105
+ return min;
106
+ }
107
+ function segmentDistance(p, a, b) {
108
+ const dx = b.x - a.x;
109
+ const dy = b.y - a.y;
110
+ const lengthSq = dx * dx + dy * dy;
111
+ if (lengthSq === 0) return Math.hypot(p.x - a.x, p.y - a.y);
112
+ const t = Math.min(1, Math.max(0, ((p.x - a.x) * dx + (p.y - a.y) * dy) / lengthSq));
113
+ return Math.hypot(p.x - (a.x + t * dx), p.y - (a.y + t * dy));
114
+ }
115
+ /**
116
+ * Turn a free endpoint into a binding if a candidate target frame contains the
117
+ * point; otherwise keep it free. Used while dragging an arrow handle so the
118
+ * arrow "connects" to shapes it lands on.
119
+ */
120
+ function bindEndpointAt(point, target) {
121
+ if (!target) return {
122
+ kind: "point",
123
+ x: point.x,
124
+ y: point.y
125
+ };
126
+ const { nx, ny } = worldToAnchor(target.frame, point);
127
+ return {
128
+ kind: "binding",
129
+ target: target.id,
130
+ nx,
131
+ ny,
132
+ precise: true
133
+ };
134
+ }
135
+ /**
136
+ * Translate an arrow by a delta: free endpoints move; bindings stay attached to
137
+ * their targets (so a bound end anchors while the rest of the arrow moves). The
138
+ * frame — a derived bounding box, not a transform — is recomputed from the new
139
+ * endpoint geometry.
140
+ */
141
+ function translateArrow(item, dx, dy, getFrame) {
142
+ const move = (endpoint) => endpoint.kind === "point" ? {
143
+ kind: "point",
144
+ x: endpoint.x + dx,
145
+ y: endpoint.y + dy
146
+ } : endpoint;
147
+ const next = {
148
+ ...item,
149
+ start: move(item.start),
150
+ end: move(item.end)
151
+ };
152
+ const bounds = arrowBounds(next, getFrame);
153
+ return bounds ? {
154
+ ...next,
155
+ frame: {
156
+ ...bounds,
157
+ rotation: 0
158
+ }
159
+ } : next;
160
+ }
161
+ //#endregion
162
+ export { anchorToWorld, arrowBounds, bindEndpointAt, distanceToArrow, resolveArrow, resolveEndpoint, sampleQuadratic, translateArrow, worldToAnchor };
@@ -0,0 +1,31 @@
1
+ import { DrawPoint } from "../../protocol/dist/board-document.js";
2
+ import { Rect, WorldPoint } from "../geometry.js";
3
+ //#region src/board/core/draw-geometry.d.ts
4
+ /** Radius of a sample in world units given the stroke size and pressure. */
5
+ declare function sampleRadius(size: number, pressure: number): number;
6
+ /** Axis-aligned bounds of a stroke in its local space, padded by stroke width. */
7
+ declare function computeDrawBounds(points: DrawPoint[], size: number): Rect;
8
+ /**
9
+ * Ramer–Douglas–Peucker simplification. Reduces point count for low-zoom
10
+ * rendering without touching the persisted raw samples. Returns indices into
11
+ * the input so callers can keep pressure alongside the simplified path.
12
+ */
13
+ declare function simplifyDrawIndices(points: DrawPoint[], tolerance: number): number[];
14
+ /**
15
+ * Build a closed outline polygon for a variable-width stroke. The left side is
16
+ * offset outward along each segment normal by the sample radius, the right side
17
+ * mirrored, producing a smooth ribbon that widens with pressure. Suitable for a
18
+ * filled Pixi polygon. Returns an empty array for degenerate input.
19
+ */
20
+ declare function buildStrokeOutline(points: DrawPoint[], size: number): Array<{
21
+ x: number;
22
+ y: number;
23
+ }>;
24
+ /**
25
+ * Distance from a world point to the stroke's polyline, in the shape's local
26
+ * space. Used for hit testing: a hit registers within half the stroke width plus
27
+ * a small tolerance. `local` is the point expressed in the draw item's frame.
28
+ */
29
+ declare function distanceToStroke(points: DrawPoint[], local: WorldPoint): number;
30
+ //#endregion
31
+ export { buildStrokeOutline, computeDrawBounds, distanceToStroke, sampleRadius, simplifyDrawIndices };