partforge 0.87.0 → 0.89.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.
- package/docs/AUTHORING-PARTS.md +39 -3
- package/package.json +1 -1
- package/src/framework/annotate/annotate-controls.js +15 -57
- package/src/framework/annotate/annotate-mode.js +547 -68
- package/src/framework/annotate/elements.js +464 -0
- package/src/framework/annotate/ink-canvas.js +217 -60
- package/src/framework/annotate/sketch-toolbar.js +202 -0
- package/src/framework/app.css +124 -29
- package/src/framework/chrome.css +4 -2
- package/src/framework/mount.js +30 -7
- package/src/framework/oracle/annotation-ray.js +92 -0
- package/src/framework/view-tabs.js +176 -12
- package/src/oracle.js +4 -0
- package/types/oracle.d.ts +3 -0
- package/types/testing.d.ts +15 -0
- package/src/framework/annotate/ink.js +0 -124
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
// The typed sketch-element model (spec 2026-08-27): store, per-type geometry,
|
|
2
|
+
// gesture builders, edit appliers, eraser, and semantics. Pure — no DOM, no
|
|
3
|
+
// three (the feature-dims.js stance). All coordinates are STAGE SPACE:
|
|
4
|
+
// y ∈ [0,1], x ∈ [0,aspect]; pixels exist only at the renderer/mode boundary,
|
|
5
|
+
// which is what keeps a circle circular regardless of viewport shape.
|
|
6
|
+
|
|
7
|
+
export const INK_COLORS = { red: "#d92d20", blue: "#1570ef", green: "#079455" };
|
|
8
|
+
// Stroke width as a fraction of the viewport short edge (unchanged from ink.js).
|
|
9
|
+
export const DEFAULT_STROKE_WIDTH = 0.004;
|
|
10
|
+
// An element erased below this visible fraction is dropped entirely.
|
|
11
|
+
export const MIN_VISIBLE = 0.02;
|
|
12
|
+
|
|
13
|
+
const GAP_TOUCH = 1e-4; // spans this close merge (half-sample slack)
|
|
14
|
+
|
|
15
|
+
export function mergeGaps(gaps) {
|
|
16
|
+
if (!gaps.length) return [];
|
|
17
|
+
const sorted = [...gaps].sort((a, b) => a[0] - b[0]);
|
|
18
|
+
const out = [sorted[0].slice()];
|
|
19
|
+
for (const [a, b] of sorted.slice(1)) {
|
|
20
|
+
const last = out[out.length - 1];
|
|
21
|
+
if (a <= last[1] + GAP_TOUCH) last[1] = Math.max(last[1], b);
|
|
22
|
+
else out.push([a, b]);
|
|
23
|
+
}
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const inGaps = (t, gaps) => gaps.some(([a, b]) => t >= a && t <= b);
|
|
28
|
+
|
|
29
|
+
export const visibleFraction = (el) =>
|
|
30
|
+
1 - el.gaps.reduce((sum, [a, b]) => sum + (b - a), 0);
|
|
31
|
+
|
|
32
|
+
// Sample cache: params are immutable per edit step, so outlines memoize on the
|
|
33
|
+
// element object; every mutation path goes through touch()/invalidateSample.
|
|
34
|
+
const sampleCache = new WeakMap();
|
|
35
|
+
export const invalidateSample = (el) => sampleCache.delete(el);
|
|
36
|
+
export const cachedSample = (el, compute) => {
|
|
37
|
+
let hit = sampleCache.get(el);
|
|
38
|
+
if (!hit) { hit = compute(el); sampleCache.set(el, hit); }
|
|
39
|
+
return hit;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export function createElementStore() {
|
|
43
|
+
let items = [];
|
|
44
|
+
const undoStack = [];
|
|
45
|
+
const listeners = new Set();
|
|
46
|
+
const notify = () => { for (const cb of [...listeners]) cb(); };
|
|
47
|
+
return {
|
|
48
|
+
snapshot() { undoStack.push(JSON.stringify(items)); },
|
|
49
|
+
add(el) { items.push(el); notify(); },
|
|
50
|
+
touch(el) { invalidateSample(el); notify(); },
|
|
51
|
+
setList(next) { items = next; notify(); },
|
|
52
|
+
list: () => items,
|
|
53
|
+
isEmpty: () => items.length === 0,
|
|
54
|
+
count: () => items.length,
|
|
55
|
+
canUndo: () => undoStack.length > 0,
|
|
56
|
+
undo() {
|
|
57
|
+
if (!undoStack.length) return;
|
|
58
|
+
items = JSON.parse(undoStack.pop());
|
|
59
|
+
notify();
|
|
60
|
+
},
|
|
61
|
+
clear() {
|
|
62
|
+
if (!items.length) return;
|
|
63
|
+
undoStack.push(JSON.stringify(items));
|
|
64
|
+
items = [];
|
|
65
|
+
notify();
|
|
66
|
+
},
|
|
67
|
+
// Exit-mode discard: unlike clear(), this takes no snapshot and drops the
|
|
68
|
+
// undo history outright — annotate-mode.js's setEnabled(false) uses this
|
|
69
|
+
// (never clear()) because ink must not survive a mode exit AND a
|
|
70
|
+
// re-entered mode must not carry a phantom "undo" back to stale strokes.
|
|
71
|
+
reset() {
|
|
72
|
+
items = [];
|
|
73
|
+
undoStack.length = 0;
|
|
74
|
+
notify();
|
|
75
|
+
},
|
|
76
|
+
onChange(cb) { listeners.add(cb); return () => listeners.delete(cb); },
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ---- rotation helpers ------------------------------------------------------
|
|
81
|
+
export const rot2 = (x, y, a) =>
|
|
82
|
+
[x * Math.cos(a) - y * Math.sin(a), x * Math.sin(a) + y * Math.cos(a)];
|
|
83
|
+
export const invRot2 = (x, y, a) => rot2(x, y, -a);
|
|
84
|
+
|
|
85
|
+
const SHAPE_SEGMENTS = 320;
|
|
86
|
+
const polylineSegments = (length) =>
|
|
87
|
+
Math.max(24, Math.min(600, Math.round(length / 0.004)));
|
|
88
|
+
|
|
89
|
+
function computeSample(el) {
|
|
90
|
+
const pts = [];
|
|
91
|
+
const push = (x, y, t) => pts.push({ x, y, t });
|
|
92
|
+
const p = el.params;
|
|
93
|
+
if (el.type === "freehand") {
|
|
94
|
+
const P = p.points;
|
|
95
|
+
if (P.length === 1) push(P[0][0], P[0][1], 0);
|
|
96
|
+
else {
|
|
97
|
+
const cum = [0];
|
|
98
|
+
for (let i = 1; i < P.length; i++) {
|
|
99
|
+
cum.push(cum[i - 1] + Math.hypot(P[i][0] - P[i - 1][0], P[i][1] - P[i - 1][1]));
|
|
100
|
+
}
|
|
101
|
+
const total = cum[cum.length - 1] || 1;
|
|
102
|
+
const N = polylineSegments(total);
|
|
103
|
+
let seg = 1;
|
|
104
|
+
for (let i = 0; i <= N; i++) {
|
|
105
|
+
const d = (i / N) * total;
|
|
106
|
+
while (seg < cum.length - 1 && cum[seg] < d) seg++;
|
|
107
|
+
const span = cum[seg] - cum[seg - 1] || 1;
|
|
108
|
+
const f = Math.min(1, Math.max(0, (d - cum[seg - 1]) / span));
|
|
109
|
+
push(
|
|
110
|
+
P[seg - 1][0] + (P[seg][0] - P[seg - 1][0]) * f,
|
|
111
|
+
P[seg - 1][1] + (P[seg][1] - P[seg - 1][1]) * f,
|
|
112
|
+
i / N,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
} else if (el.type === "line") {
|
|
117
|
+
const N = polylineSegments(Math.hypot(p.x2 - p.x1, p.y2 - p.y1));
|
|
118
|
+
for (let i = 0; i <= N; i++) {
|
|
119
|
+
const t = i / N;
|
|
120
|
+
push(p.x1 + (p.x2 - p.x1) * t, p.y1 + (p.y2 - p.y1) * t, t);
|
|
121
|
+
}
|
|
122
|
+
} else if (el.type === "rect") {
|
|
123
|
+
const { cx, cy, w, h, rot = 0 } = p;
|
|
124
|
+
const per = 2 * (w + h) || 1;
|
|
125
|
+
for (let i = 0; i <= SHAPE_SEGMENTS; i++) {
|
|
126
|
+
const t = i / SHAPE_SEGMENTS;
|
|
127
|
+
const d = t * per;
|
|
128
|
+
let lx, ly; // local frame, top-left origin, clockwise
|
|
129
|
+
if (d <= w) { lx = -w / 2 + d; ly = -h / 2; }
|
|
130
|
+
else if (d <= w + h) { lx = w / 2; ly = -h / 2 + (d - w); }
|
|
131
|
+
else if (d <= 2 * w + h) { lx = w / 2 - (d - w - h); ly = h / 2; }
|
|
132
|
+
else { lx = -w / 2; ly = h / 2 - (d - 2 * w - h); }
|
|
133
|
+
const [wx, wy] = rot2(lx, ly, rot);
|
|
134
|
+
push(cx + wx, cy + wy, t);
|
|
135
|
+
}
|
|
136
|
+
} else if (el.type === "ellipse") {
|
|
137
|
+
const { cx, cy, rx, ry, rot = 0 } = p;
|
|
138
|
+
for (let i = 0; i <= SHAPE_SEGMENTS; i++) {
|
|
139
|
+
const t = i / SHAPE_SEGMENTS;
|
|
140
|
+
const a = t * Math.PI * 2;
|
|
141
|
+
const [wx, wy] = rot2(rx * Math.cos(a), ry * Math.sin(a), rot);
|
|
142
|
+
push(cx + wx, cy + wy, t);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return { pts, closed: el.type === "rect" || el.type === "ellipse" };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export const sample = (el) => cachedSample(el, computeSample);
|
|
149
|
+
|
|
150
|
+
export function visibleRuns(el) {
|
|
151
|
+
const { pts } = sample(el);
|
|
152
|
+
const runs = [];
|
|
153
|
+
let run = null;
|
|
154
|
+
for (const p of pts) {
|
|
155
|
+
if (inGaps(p.t, el.gaps)) { run = null; continue; }
|
|
156
|
+
if (!run) { run = []; runs.push(run); }
|
|
157
|
+
run.push(p);
|
|
158
|
+
}
|
|
159
|
+
return runs;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function centerOf(el) {
|
|
163
|
+
const p = el.params;
|
|
164
|
+
if (el.type === "rect" || el.type === "ellipse") return [p.cx, p.cy];
|
|
165
|
+
if (el.type === "line") return [(p.x1 + p.x2) / 2, (p.y1 + p.y2) / 2];
|
|
166
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
167
|
+
for (const [x, y] of p.points) {
|
|
168
|
+
minX = Math.min(minX, x); maxX = Math.max(maxX, x);
|
|
169
|
+
minY = Math.min(minY, y); maxY = Math.max(maxY, y);
|
|
170
|
+
}
|
|
171
|
+
return [(minX + maxX) / 2, (minY + maxY) / 2];
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ---- draw-gesture builders -------------------------------------------------
|
|
175
|
+
const MIN_EXTENT = 0.002; // stage units; degenerate drags stay visible as slivers
|
|
176
|
+
|
|
177
|
+
// Square/circle magnet, measured in cursor DISTANCE like the line snap: the
|
|
178
|
+
// perpendicular distance from the dragged corner to the box diagonal is
|
|
179
|
+
// |w − h|/√2, so the drag snaps to 1:1 when the cursor sits within
|
|
180
|
+
// `snapDistance` (stage units) of where the square's corner would be. A big
|
|
181
|
+
// box snaps only when genuinely near-square; a small one stays forgiving.
|
|
182
|
+
function snappedBox(x0, y0, x, y, force, snapDistance) {
|
|
183
|
+
let w = Math.abs(x - x0), h = Math.abs(y - y0);
|
|
184
|
+
const near = snapDistance > 0 && Math.abs(w - h) / Math.SQRT2 <= snapDistance;
|
|
185
|
+
const snapped = force || near;
|
|
186
|
+
if (snapped) w = h = Math.max(w, h);
|
|
187
|
+
w = Math.max(w, MIN_EXTENT); h = Math.max(h, MIN_EXTENT);
|
|
188
|
+
// Center = drag origin + half the (possibly snapped) extent, signed by drag
|
|
189
|
+
// direction. This is corner-order independent — equivalent to (min+max)/2
|
|
190
|
+
// of the two corners when w/h are unchanged by snapping — and when
|
|
191
|
+
// snapping grows w/h past the raw drag delta, it hangs the box off the
|
|
192
|
+
// origin corner in the direction the user dragged.
|
|
193
|
+
const cx = x0 + Math.sign(x - x0 || 1) * w / 2;
|
|
194
|
+
const cy = y0 + Math.sign(y - y0 || 1) * h / 2;
|
|
195
|
+
return { cx, cy, w, h, snapped };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function rectFromDrag(x0, y0, x, y, { force = false, snapDistance = 0 } = {}) {
|
|
199
|
+
const { cx, cy, w, h, snapped } = snappedBox(x0, y0, x, y, force, snapDistance);
|
|
200
|
+
return { params: { cx, cy, w, h, rot: 0 }, snapped };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function ellipseFromDrag(x0, y0, x, y, { force = false, snapDistance = 0 } = {}) {
|
|
204
|
+
const { cx, cy, w, h, snapped } = snappedBox(x0, y0, x, y, force, snapDistance);
|
|
205
|
+
return { params: { cx, cy, rx: w / 2, ry: h / 2, rot: 0 }, snapped };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Magnetic 0/45/90° snap, measured in DISTANCE rather than angle: the drag
|
|
209
|
+
// snaps when the cursor sits within `snapDistance` (stage units — the caller
|
|
210
|
+
// converts its pixel threshold) of the nearest snapped line through the start
|
|
211
|
+
// point. Distance-based snapping self-scales the way angle-based cannot: a
|
|
212
|
+
// long line snaps only when it is genuinely close to flat, a short one stays
|
|
213
|
+
// forgiving. `force` (shift) snaps from any angle; length is preserved.
|
|
214
|
+
export function lineFromDrag(x0, y0, x, y, { force = false, snapDistance = 0 } = {}) {
|
|
215
|
+
let x2 = x, y2 = y;
|
|
216
|
+
const len = Math.hypot(x - x0, y - y0);
|
|
217
|
+
const a = Math.atan2(y - y0, x - x0);
|
|
218
|
+
const nearest = Math.round(a / (Math.PI / 4)) * (Math.PI / 4);
|
|
219
|
+
const perpDistance = len * Math.abs(Math.sin(a - nearest));
|
|
220
|
+
const snapped = force || (snapDistance > 0 && perpDistance <= snapDistance);
|
|
221
|
+
if (snapped) {
|
|
222
|
+
x2 = x0 + len * Math.cos(nearest);
|
|
223
|
+
y2 = y0 + len * Math.sin(nearest);
|
|
224
|
+
}
|
|
225
|
+
return { params: { x1: x0, y1: y0, x2, y2 }, snapped };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Freehand point thinning (the ink.js minDistance contract, Euclidean in
|
|
229
|
+
// stage space — stage space is already aspect-uniform).
|
|
230
|
+
export function appendThinned(points, x, y, minDistance) {
|
|
231
|
+
const last = points[points.length - 1];
|
|
232
|
+
if (Math.hypot(x - last[0], y - last[1]) < minDistance) return false;
|
|
233
|
+
points.push([x, y]);
|
|
234
|
+
return true;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ---- hand tool: handles, appliers, probe ----------------------------------
|
|
238
|
+
export function handlesOf(el) {
|
|
239
|
+
const p = el.params;
|
|
240
|
+
if (el.type === "line") return [
|
|
241
|
+
{ id: "p1", x: p.x1, y: p.y1 },
|
|
242
|
+
{ id: "p2", x: p.x2, y: p.y2 },
|
|
243
|
+
];
|
|
244
|
+
if (el.type === "rect") {
|
|
245
|
+
return [[-1, -1], [1, -1], [1, 1], [-1, 1]].map(([sx, sy]) => {
|
|
246
|
+
const [dx, dy] = rot2(sx * p.w / 2, sy * p.h / 2, p.rot || 0);
|
|
247
|
+
return { id: `corner${sx > 0 ? "R" : "L"}${sy > 0 ? "B" : "T"}`, sx, sy, x: p.cx + dx, y: p.cy + dy };
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
if (el.type === "ellipse") {
|
|
251
|
+
if (p.rx === p.ry) {
|
|
252
|
+
const [dx, dy] = rot2(p.rx, 0, p.rot || 0);
|
|
253
|
+
return [{ id: "r", x: p.cx + dx, y: p.cy + dy }];
|
|
254
|
+
}
|
|
255
|
+
const [ax, ay] = rot2(p.rx, 0, p.rot || 0);
|
|
256
|
+
const [bx, by] = rot2(0, p.ry, p.rot || 0);
|
|
257
|
+
return [
|
|
258
|
+
{ id: "rx", x: p.cx + ax, y: p.cy + ay },
|
|
259
|
+
{ id: "ry", x: p.cx + bx, y: p.cy + by },
|
|
260
|
+
];
|
|
261
|
+
}
|
|
262
|
+
return [];
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export function translateElement(el, dx, dy) {
|
|
266
|
+
const p = el.params;
|
|
267
|
+
if (el.type === "freehand") for (const q of p.points) { q[0] += dx; q[1] += dy; }
|
|
268
|
+
else if (el.type === "rect" || el.type === "ellipse") { p.cx += dx; p.cy += dy; }
|
|
269
|
+
else if (el.type === "line") { p.x1 += dx; p.y1 += dy; p.x2 += dx; p.y2 += dy; }
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export function rectAnchorFor(el, handle) {
|
|
273
|
+
const p = el.params;
|
|
274
|
+
const [dx, dy] = rot2(-handle.sx * p.w / 2, -handle.sy * p.h / 2, p.rot || 0);
|
|
275
|
+
return [p.cx + dx, p.cy + dy];
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const MIN_EDIT_EXTENT = 0.002;
|
|
279
|
+
|
|
280
|
+
export function resizeRectFromAnchor(el, ax, ay, rot, x, y, { force = false, snapDistance = 0 } = {}) {
|
|
281
|
+
const p = el.params;
|
|
282
|
+
let [dx, dy] = invRot2(x - ax, y - ay, rot);
|
|
283
|
+
const w = Math.abs(dx), h = Math.abs(dy);
|
|
284
|
+
// same cursor-distance magnet as snappedBox, in the rect's local frame
|
|
285
|
+
const near = snapDistance > 0 && Math.abs(w - h) / Math.SQRT2 <= snapDistance;
|
|
286
|
+
if (force || near) {
|
|
287
|
+
const m = Math.max(w, h);
|
|
288
|
+
dx = Math.sign(dx || 1) * m;
|
|
289
|
+
dy = Math.sign(dy || 1) * m;
|
|
290
|
+
}
|
|
291
|
+
p.w = Math.max(Math.abs(dx), MIN_EDIT_EXTENT);
|
|
292
|
+
p.h = Math.max(Math.abs(dy), MIN_EDIT_EXTENT);
|
|
293
|
+
const [ox, oy] = rot2(dx / 2, dy / 2, rot);
|
|
294
|
+
p.cx = ax + ox;
|
|
295
|
+
p.cy = ay + oy;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export function resizeEllipseHandle(el, handleId, x, y, { force = false, snapDistance = 0 } = {}) {
|
|
299
|
+
const p = el.params;
|
|
300
|
+
const [lx, ly] = invRot2(x - p.cx, y - p.cy, p.rot || 0);
|
|
301
|
+
if (handleId === "r") {
|
|
302
|
+
p.rx = p.ry = Math.max(MIN_EDIT_EXTENT, Math.hypot(lx, ly));
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
if (handleId === "rx") p.rx = Math.max(MIN_EDIT_EXTENT, Math.abs(lx));
|
|
306
|
+
else p.ry = Math.max(MIN_EDIT_EXTENT, Math.abs(ly));
|
|
307
|
+
// circle magnet by cursor distance: the dragged radius handle sits |rx − ry|
|
|
308
|
+
// from where the circle's rim would be
|
|
309
|
+
const near = snapDistance > 0 && Math.abs(p.rx - p.ry) <= snapDistance;
|
|
310
|
+
if (force || near) p.rx = p.ry = handleId === "rx" ? p.rx : p.ry;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export function applyRotation(el, origParams, center, totalAngle) {
|
|
314
|
+
const [cx, cy] = center;
|
|
315
|
+
const p = el.params;
|
|
316
|
+
if (el.type === "rect" || el.type === "ellipse") {
|
|
317
|
+
p.rot = (origParams.rot || 0) + totalAngle;
|
|
318
|
+
} else if (el.type === "line") {
|
|
319
|
+
const [ax, ay] = rot2(origParams.x1 - cx, origParams.y1 - cy, totalAngle);
|
|
320
|
+
const [bx, by] = rot2(origParams.x2 - cx, origParams.y2 - cy, totalAngle);
|
|
321
|
+
p.x1 = cx + ax; p.y1 = cy + ay; p.x2 = cx + bx; p.y2 = cy + by;
|
|
322
|
+
} else if (el.type === "freehand") {
|
|
323
|
+
p.points = origParams.points.map(([px, py]) => {
|
|
324
|
+
const [dx, dy] = rot2(px - cx, py - cy, totalAngle);
|
|
325
|
+
return [cx + dx, cy + dy];
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function minVisibleDistance(el, x, y) {
|
|
331
|
+
let min = Infinity;
|
|
332
|
+
for (const run of visibleRuns(el)) {
|
|
333
|
+
for (const q of run) min = Math.min(min, Math.hypot(q.x - x, q.y - y));
|
|
334
|
+
}
|
|
335
|
+
return min;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export function probe(list, x, y, { reach, handleR, band }) {
|
|
339
|
+
for (let i = list.length - 1; i >= 0; i--) {
|
|
340
|
+
for (const h of handlesOf(list[i])) {
|
|
341
|
+
if (Math.hypot(h.x - x, h.y - y) <= handleR) return { kind: "handle", el: list[i], handle: h };
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
// One distance scan per element serves both tests: topmost-first, an
|
|
345
|
+
// outline hit returns immediately (band membership of anything beneath is
|
|
346
|
+
// moot — outline wins), otherwise band candidates accumulate. Rotate only
|
|
347
|
+
// when "just outside" is unambiguous: exactly one element that close.
|
|
348
|
+
let nearCount = 0;
|
|
349
|
+
let nearEl = null;
|
|
350
|
+
for (let i = list.length - 1; i >= 0; i--) {
|
|
351
|
+
const d = minVisibleDistance(list[i], x, y);
|
|
352
|
+
if (d <= reach) return { kind: "outline", el: list[i] };
|
|
353
|
+
if (d <= reach + band) { nearCount += 1; nearEl = list[i]; }
|
|
354
|
+
}
|
|
355
|
+
return nearCount === 1 ? { kind: "rotate", el: nearEl } : null;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// ---- eraser ----------------------------------------------------------------
|
|
359
|
+
function distToSegment(px, py, ax, ay, bx, by) {
|
|
360
|
+
const dx = bx - ax, dy = by - ay;
|
|
361
|
+
const l2 = dx * dx + dy * dy;
|
|
362
|
+
const t = l2 ? Math.min(1, Math.max(0, ((px - ax) * dx + (py - ay) * dy) / l2)) : 0;
|
|
363
|
+
return Math.hypot(px - (ax + dx * t), py - (ay + dy * t));
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// One eraser sweep step: subtract the covered t-spans from every element it
|
|
367
|
+
// touches. Params are never modified — only `gaps` — which is the whole
|
|
368
|
+
// point: a half-erased circle is still "a circle, center c, radius r".
|
|
369
|
+
export function eraseSegment(list, ax, ay, bx, by, { radius, halfWidth }) {
|
|
370
|
+
let changed = false;
|
|
371
|
+
const next = list.filter((el) => {
|
|
372
|
+
const { pts } = sample(el);
|
|
373
|
+
const halfStep = (pts.length > 1 ? pts[1].t - pts[0].t : 1) / 2;
|
|
374
|
+
const hits = [];
|
|
375
|
+
for (const p of pts) {
|
|
376
|
+
if (distToSegment(p.x, p.y, ax, ay, bx, by) <= radius + halfWidth) {
|
|
377
|
+
hits.push([Math.max(0, p.t - halfStep), Math.min(1, p.t + halfStep)]);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
if (!hits.length) return true;
|
|
381
|
+
changed = true;
|
|
382
|
+
el.gaps = mergeGaps(el.gaps.concat(hits));
|
|
383
|
+
return visibleFraction(el) > MIN_VISIBLE;
|
|
384
|
+
});
|
|
385
|
+
return { changed, list: changed ? next : list };
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// ---- semantics -------------------------------------------------------------
|
|
389
|
+
function rotNote(rot) {
|
|
390
|
+
let d = Math.round((rot || 0) * 180 / Math.PI) % 360;
|
|
391
|
+
if (d > 180) d -= 360;
|
|
392
|
+
if (d <= -180) d += 360;
|
|
393
|
+
return d ? ` · rot ${d}°` : "";
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Where an erased span sits, in each type's own vocabulary: rects name the
|
|
397
|
+
// edges the gap crosses (the t-domain walks the perimeter clockwise from the
|
|
398
|
+
// top-left corner), ellipses give the arc in degrees (t=0 at 3 o'clock,
|
|
399
|
+
// increasing screen-clockwise, before rotation), open paths give a % range
|
|
400
|
+
// along their length. Raw t-spans stay in the payload's `erased`; this is the
|
|
401
|
+
// human/LLM-readable rendering of the same data.
|
|
402
|
+
const RECT_EDGES = ["top", "right", "bottom", "left"];
|
|
403
|
+
export function describeGap(el, [a, b]) {
|
|
404
|
+
if (el.type === "ellipse") {
|
|
405
|
+
return `arc ${Math.round(a * 360)}°–${Math.round(b * 360)}°`;
|
|
406
|
+
}
|
|
407
|
+
if (el.type === "rect") {
|
|
408
|
+
const { w, h } = el.params;
|
|
409
|
+
const per = 2 * (w + h) || 1;
|
|
410
|
+
const ends = [w / per, (w + h) / per, (2 * w + h) / per, 1];
|
|
411
|
+
const edges = RECT_EDGES.filter((_, i) =>
|
|
412
|
+
a < ends[i] - GAP_TOUCH && b > (i ? ends[i - 1] : 0) + GAP_TOUCH);
|
|
413
|
+
return edges.length === 1 ? `${edges[0]} edge` : `${edges.join("–")} edges`;
|
|
414
|
+
}
|
|
415
|
+
return `${Math.round(a * 100)}%–${Math.round(b * 100)}%`;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const GAP_NOTE_MAX = 3; // heavy freehand scrubbing can leave dozens of gaps
|
|
419
|
+
function gapNote(el) {
|
|
420
|
+
if (!el.gaps.length) return "";
|
|
421
|
+
const pct = Math.round(visibleFraction(el) * 100);
|
|
422
|
+
const noted = el.gaps.slice(0, GAP_NOTE_MAX).map((g) => describeGap(el, g));
|
|
423
|
+
const more = el.gaps.length - noted.length;
|
|
424
|
+
return ` · ${pct}% visible · erased ${noted.join(", ")}${more > 0 ? ` +${more} more` : ""}`;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
export function describeElement(el, aspect) {
|
|
428
|
+
const p = el.params;
|
|
429
|
+
const px = (v) => `${Math.round((v / aspect) * 100)}%`; // x-positions, widths
|
|
430
|
+
const py = (v) => `${Math.round(v * 100)}%`; // y-positions, heights
|
|
431
|
+
const pr = (v) => `${Math.round((v / Math.min(1, aspect)) * 100)}%`; // radii: short edge
|
|
432
|
+
let base;
|
|
433
|
+
if (el.type === "freehand") base = `freehand · ${p.points.length} pts`;
|
|
434
|
+
else if (el.type === "line") {
|
|
435
|
+
base = `line · (${px(p.x1)}, ${py(p.y1)}) → (${px(p.x2)}, ${py(p.y2)})`;
|
|
436
|
+
} else if (el.type === "rect") {
|
|
437
|
+
base = (p.w === p.h
|
|
438
|
+
? `square · c (${px(p.cx)}, ${py(p.cy)}) · ${px(p.w)}`
|
|
439
|
+
: `rect · c (${px(p.cx)}, ${py(p.cy)}) · ${px(p.w)} × ${py(p.h)}`) + rotNote(p.rot);
|
|
440
|
+
} else {
|
|
441
|
+
base = (p.rx === p.ry
|
|
442
|
+
? `circle · c (${px(p.cx)}, ${py(p.cy)}) · r ${pr(p.rx)}`
|
|
443
|
+
: `ellipse · c (${px(p.cx)}, ${py(p.cy)}) · rx ${pr(p.rx)} · ry ${pr(p.ry)}` + rotNote(p.rot));
|
|
444
|
+
}
|
|
445
|
+
return base + gapNote(el);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
export function elementAnchors(el) {
|
|
449
|
+
const out = [];
|
|
450
|
+
// `run` disambiguates anchors on a gapped element: a twice-erased circle has
|
|
451
|
+
// three visible fragments, each with its own start/mid/end. The center
|
|
452
|
+
// anchor belongs to the whole shape, not a fragment, so it carries no run.
|
|
453
|
+
visibleRuns(el).forEach((run, i) => {
|
|
454
|
+
const mid = run[Math.floor(run.length / 2)];
|
|
455
|
+
out.push({ at: "start", run: i, x: run[0].x, y: run[0].y });
|
|
456
|
+
out.push({ at: "mid", run: i, x: mid.x, y: mid.y });
|
|
457
|
+
out.push({ at: "end", run: i, x: run[run.length - 1].x, y: run[run.length - 1].y });
|
|
458
|
+
});
|
|
459
|
+
if (el.type === "rect" || el.type === "ellipse") {
|
|
460
|
+
const [x, y] = centerOf(el);
|
|
461
|
+
out.push({ at: "center", x, y });
|
|
462
|
+
}
|
|
463
|
+
return out;
|
|
464
|
+
}
|