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.
@@ -1,124 +0,0 @@
1
- // Pure stroke model for annotation mode: normalized-coordinate polylines with
2
- // point thinning while drawing, undo/clear, closed-stroke detection and anchor
3
- // selection. No DOM, no three — unit-testable directly (the feature-dims.js
4
- // stance). Points are [nx, ny] normalized 0..1 per viewport axis; distances
5
- // are measured in viewport-DIAGONAL units so thresholds mean the same thing
6
- // horizontally and vertically regardless of aspect.
7
-
8
- // Stroke width as a fraction of the viewport's short edge (spec: payload
9
- // carries this unit so any re-render can reproduce line weight).
10
- export const DEFAULT_STROKE_WIDTH = 0.004;
11
- // Spec: endpoints within 5% of the viewport diagonal = closed stroke.
12
- const CLOSED_THRESHOLD = 0.05;
13
- // pointermove fires per-pixel; keep only points this far (in diagonal units)
14
- // from the previous kept point. ~2px at 1080p.
15
- const MIN_POINT_DISTANCE = 0.0015;
16
-
17
- export function diagDistance(a, b, aspect = 1) {
18
- const dx = (a[0] - b[0]) * aspect;
19
- const dy = a[1] - b[1];
20
- return Math.hypot(dx, dy) / Math.hypot(aspect, 1);
21
- }
22
-
23
- export function createInkStore({ minDistance = MIN_POINT_DISTANCE } = {}) {
24
- const strokes = [];
25
- let active = null;
26
- const listeners = new Set();
27
- const notify = () => { for (const cb of [...listeners]) cb(); };
28
- return {
29
- begin(nx, ny, { width = DEFAULT_STROKE_WIDTH, aspect = 1 } = {}) {
30
- active = { points: [[nx, ny]], width, aspect };
31
- strokes.push(active);
32
- notify();
33
- },
34
- extend(nx, ny) {
35
- if (!active) return;
36
- const last = active.points[active.points.length - 1];
37
- if (diagDistance([nx, ny], last, active.aspect) < minDistance) return;
38
- active.points.push([nx, ny]);
39
- notify();
40
- },
41
- end() {
42
- if (!active) return;
43
- active = null; // one-point strokes stay: a click leaves a visible dot
44
- notify();
45
- },
46
- strokes: () => strokes.map((s) => ({ points: s.points.map((p) => [...p]), width: s.width })),
47
- isEmpty: () => strokes.length === 0,
48
- strokeCount: () => strokes.length,
49
- undo() {
50
- if (!strokes.length) return;
51
- strokes.pop();
52
- active = null;
53
- notify();
54
- },
55
- clear() {
56
- if (!strokes.length && !active) return;
57
- strokes.length = 0;
58
- active = null;
59
- notify();
60
- },
61
- onChange(cb) { listeners.add(cb); return () => listeners.delete(cb); },
62
- };
63
- }
64
-
65
- export function pointAt(points, t, aspect = 1) {
66
- if (points.length === 1) return [...points[0]];
67
- const lengths = [0];
68
- for (let i = 1; i < points.length; i++) {
69
- lengths.push(lengths[i - 1] + diagDistance(points[i], points[i - 1], aspect));
70
- }
71
- const total = lengths[lengths.length - 1];
72
- if (total === 0) return [...points[0]];
73
- const target = t * total;
74
- let i = 1;
75
- while (i < lengths.length - 1 && lengths[i] < target) i++;
76
- const span = lengths[i] - lengths[i - 1];
77
- const f = span === 0 ? 0 : (target - lengths[i - 1]) / span;
78
- const [ax, ay] = points[i - 1];
79
- const [bx, by] = points[i];
80
- return [ax + (bx - ax) * f, ay + (by - ay) * f];
81
- }
82
-
83
- export function isClosedStroke(points, aspect = 1) {
84
- if (points.length < 3) return false;
85
- return diagDistance(points[0], points[points.length - 1], aspect) <= CLOSED_THRESHOLD;
86
- }
87
-
88
- // Area-weighted polygon centroid (shoelace); for a degenerate (near-zero-area)
89
- // point set, fall back to the plain point average.
90
- export function strokeCentroid(points) {
91
- let area2 = 0, cx = 0, cy = 0;
92
- for (let i = 0; i < points.length; i++) {
93
- const [x0, y0] = points[i];
94
- const [x1, y1] = points[(i + 1) % points.length];
95
- const cross = x0 * y1 - x1 * y0;
96
- area2 += cross;
97
- cx += (x0 + x1) * cross;
98
- cy += (y0 + y1) * cross;
99
- }
100
- if (Math.abs(area2) < 1e-9) {
101
- let sx = 0, sy = 0;
102
- for (const [x, y] of points) { sx += x; sy += y; }
103
- return [sx / points.length, sy / points.length];
104
- }
105
- return [cx / (3 * area2), cy / (3 * area2)];
106
- }
107
-
108
- // Anchor sample points for one stroke: start / arc-length-midpoint / end, plus
109
- // the enclosed-region centroid when the stroke closes on itself ("what did
110
- // they circle"). A one-point dot gets a single anchor. The orchestrator turns
111
- // each spec's normalized `screen` point into a raycast.
112
- export function anchorSpecs(points, aspect = 1) {
113
- if (points.length === 0) return [];
114
- if (points.length === 1) return [{ t: 0, screen: [...points[0]] }];
115
- const specs = [
116
- { t: 0, screen: [...points[0]] },
117
- { t: 0.5, screen: pointAt(points, 0.5, aspect) },
118
- { t: 1, screen: [...points[points.length - 1]] },
119
- ];
120
- if (isClosedStroke(points, aspect)) {
121
- specs.push({ kind: "centroid", screen: strokeCentroid(points) });
122
- }
123
- return specs;
124
- }