axstack 0.36.1 → 0.38.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 (137) hide show
  1. package/README.md +5 -4
  2. package/bin/axstack.js +4 -18
  3. package/docs/getting-started.md +6 -6
  4. package/docs/host-operations.md +9 -5
  5. package/docs/installation.md +20 -41
  6. package/docs/workflows.md +12 -9
  7. package/package.json +1 -1
  8. package/profiles/presets/claude-only.json +3 -3
  9. package/profiles/presets/codex-only.json +3 -3
  10. package/profiles/presets/mixed.json +5 -4
  11. package/skills/axstack/references/automations.md +7 -2
  12. package/skills/axstack/references/lifecycle.md +2 -1
  13. package/skills/axstack/references/review-manager-prompt.md +44 -3
  14. package/skills/axstack/references/role-roster.md +13 -6
  15. package/skills/axstack/references/routing.md +2 -1
  16. package/skills/axstack/references/run-record.md +2 -0
  17. package/skills/axstack/references/ui-verification.md +6 -10
  18. package/skills/axstack-anatomy/ANATOMY.md +172 -0
  19. package/skills/axstack-anatomy/CHANGELOG.md +70 -0
  20. package/skills/axstack-anatomy/LICENSE +21 -0
  21. package/skills/axstack-anatomy/PROVENANCE.md +21 -0
  22. package/skills/axstack-anatomy/README.md +316 -0
  23. package/skills/axstack-anatomy/SKILL.md +74 -0
  24. package/skills/axstack-anatomy/SOURCE-PROOF.json +107 -0
  25. package/skills/axstack-anatomy/examples/arcade-cabinet/arcade-cabinet.css +44 -0
  26. package/skills/axstack-anatomy/examples/arcade-cabinet/build.mjs +714 -0
  27. package/skills/axstack-anatomy/examples/arcade-cabinet/live.js +597 -0
  28. package/skills/axstack-anatomy/examples/arcade-cabinet/parts.mjs +89 -0
  29. package/skills/axstack-anatomy/examples/arcade-cabinet/screen.mjs +114 -0
  30. package/skills/axstack-anatomy/examples/desk-computer/build.mjs +435 -0
  31. package/skills/axstack-anatomy/examples/dial-indicator/build.mjs +275 -0
  32. package/skills/axstack-anatomy/examples/raptor-engine/build.mjs +938 -0
  33. package/skills/axstack-anatomy/examples/raptor-engine/live.js +907 -0
  34. package/skills/axstack-anatomy/examples/raptor-engine/raptor.css +359 -0
  35. package/skills/axstack-anatomy/examples/ripple-tank/build.mjs +558 -0
  36. package/skills/axstack-anatomy/examples/ripple-tank/live.js +579 -0
  37. package/skills/axstack-anatomy/examples/ripple-tank/model.mjs +131 -0
  38. package/skills/axstack-anatomy/examples/ripple-tank/ripple.css +103 -0
  39. package/skills/axstack-anatomy/examples/spot-plate/art.tsx +110 -0
  40. package/skills/axstack-anatomy/examples/spot-plate/drops.ts +541 -0
  41. package/skills/axstack-anatomy/examples/spot-plate/fan.ts +84 -0
  42. package/skills/axstack-anatomy/examples/spot-plate/figure.tsx +25 -0
  43. package/skills/axstack-anatomy/examples/spot-plate/geometry.ts +280 -0
  44. package/skills/axstack-anatomy/examples/spot-plate/live.tsx +339 -0
  45. package/skills/axstack-anatomy/examples/spot-plate/rig.ts +230 -0
  46. package/skills/axstack-anatomy/examples/spot-plate/spot-plate.css +92 -0
  47. package/skills/axstack-anatomy/examples/test-rig/art.tsx +196 -0
  48. package/skills/axstack-anatomy/examples/test-rig/figure.tsx +23 -0
  49. package/skills/axstack-anatomy/examples/test-rig/geometry.ts +305 -0
  50. package/skills/axstack-anatomy/examples/test-rig/live.tsx +511 -0
  51. package/skills/axstack-anatomy/examples/test-rig/pose.ts +107 -0
  52. package/skills/axstack-anatomy/examples/test-rig/rig.ts +170 -0
  53. package/skills/axstack-anatomy/examples/test-rig/stretch.css +80 -0
  54. package/skills/axstack-anatomy/examples/test-rig/view.ts +99 -0
  55. package/skills/axstack-anatomy/examples/turning-dial/build.mjs +479 -0
  56. package/skills/axstack-anatomy/examples/turning-dial/dial.css +10 -0
  57. package/skills/axstack-anatomy/examples/turning-dial/live.js +342 -0
  58. package/skills/axstack-anatomy/examples/turning-dial/stress.mjs +269 -0
  59. package/skills/axstack-anatomy/kit/audit.mjs +1913 -0
  60. package/skills/axstack-anatomy/kit/canvas-painter.mjs +722 -0
  61. package/skills/axstack-anatomy/kit/gl-shared.mjs +184 -0
  62. package/skills/axstack-anatomy/kit/gl.mjs +184 -0
  63. package/skills/axstack-anatomy/kit/iso-kit.mjs +674 -0
  64. package/skills/axstack-anatomy/kit/iso-kit.ts +842 -0
  65. package/skills/axstack-anatomy/kit/iso.css +106 -0
  66. package/skills/axstack-anatomy/kit/lathe.mjs +1184 -0
  67. package/skills/axstack-anatomy/kit/react/draw.tsx +205 -0
  68. package/skills/axstack-anatomy/kit/react/live.tsx +140 -0
  69. package/skills/axstack-anatomy/kit/tube.mjs +306 -0
  70. package/skills/axstack-anatomy/kit/turn-audit.mjs +1096 -0
  71. package/skills/axstack-anatomy/kit/turn-build.mjs +1355 -0
  72. package/skills/axstack-anatomy/kit/turn-fixed.mjs +3800 -0
  73. package/skills/axstack-anatomy/kit/turn.mjs +3610 -0
  74. package/skills/axstack-anatomy/references/3d.md +352 -0
  75. package/skills/axstack-anatomy/references/craft.md +325 -0
  76. package/skills/axstack-anatomy/references/kit.md +419 -0
  77. package/skills/axstack-anatomy/references/motion.md +165 -0
  78. package/skills/axstack-anatomy/references/react.md +42 -0
  79. package/skills/axstack-anatomy/references/verify.md +351 -0
  80. package/skills/axstack-anatomy/references/walkthrough.md +75 -0
  81. package/skills/axstack-anatomy/references/webgl.md +473 -0
  82. package/skills/axstack-anatomy/scripts/audit-affine-bounds.test.mjs +54 -0
  83. package/skills/axstack-anatomy/scripts/audit-cache.test.mjs +37 -0
  84. package/skills/axstack-anatomy/scripts/audit-clearance-envelope.test.mjs +39 -0
  85. package/skills/axstack-anatomy/scripts/audit-empty-clipping.test.mjs +38 -0
  86. package/skills/axstack-anatomy/scripts/audit-order-convergence.test.mjs +55 -0
  87. package/skills/axstack-anatomy/scripts/audit-path-origin.test.mjs +26 -0
  88. package/skills/axstack-anatomy/scripts/audit-profile.test.mjs +41 -0
  89. package/skills/axstack-anatomy/scripts/audit-projection.test.mjs +36 -0
  90. package/skills/axstack-anatomy/scripts/audit-slide-clearance.test.mjs +58 -0
  91. package/skills/axstack-anatomy/scripts/audit-svg-attributes.test.mjs +46 -0
  92. package/skills/axstack-anatomy/scripts/audit-taper.test.mjs +29 -0
  93. package/skills/axstack-anatomy/scripts/capture-selector.test.mjs +131 -0
  94. package/skills/axstack-anatomy/scripts/capture.mjs +228 -0
  95. package/skills/axstack-anatomy/scripts/capture.test.mjs +42 -0
  96. package/skills/axstack-anatomy/scripts/chrome-port.mjs +36 -0
  97. package/skills/axstack-anatomy/scripts/chrome-startup.test.mjs +38 -0
  98. package/skills/axstack-anatomy/scripts/drive-actions.test.mjs +46 -0
  99. package/skills/axstack-anatomy/scripts/drive.mjs +302 -0
  100. package/skills/axstack-anatomy/scripts/drive.test.mjs +72 -0
  101. package/skills/axstack-anatomy/scripts/enter-key.test.mjs +38 -0
  102. package/skills/axstack-anatomy/scripts/fidelity-cut.test.mjs +54 -0
  103. package/skills/axstack-anatomy/scripts/fidelity-tube-coverage.test.mjs +40 -0
  104. package/skills/axstack-anatomy/scripts/inline-kit.mjs +26 -0
  105. package/skills/axstack-anatomy/scripts/line-ends.mjs +306 -0
  106. package/skills/axstack-anatomy/scripts/lines-arcs.test.mjs +50 -0
  107. package/skills/axstack-anatomy/scripts/lines-bezier.test.mjs +28 -0
  108. package/skills/axstack-anatomy/scripts/lines-round.test.mjs +56 -0
  109. package/skills/axstack-anatomy/scripts/lines-rounded-rect.test.mjs +32 -0
  110. package/skills/axstack-anatomy/scripts/lines.mjs +31 -0
  111. package/skills/axstack-anatomy/scripts/lines.test.mjs +38 -0
  112. package/skills/axstack-anatomy/scripts/navigation.test.mjs +55 -0
  113. package/skills/axstack-anatomy/scripts/orbit-sampling.test.mjs +50 -0
  114. package/skills/axstack-anatomy/scripts/react-dots-opacity.test.mjs +51 -0
  115. package/skills/axstack-anatomy/scripts/react-readout-first-write.test.mjs +37 -0
  116. package/skills/axstack-anatomy/scripts/react-solid-seamless.test.mjs +47 -0
  117. package/skills/axstack-anatomy/scripts/react-visibility-target.test.mjs +38 -0
  118. package/skills/axstack-anatomy/scripts/turn-bench.mjs +74 -0
  119. package/skills/axstack-anatomy/scripts/turn-check.mjs +641 -0
  120. package/skills/axstack-anatomy/scripts/turn-fidelity.mjs +413 -0
  121. package/skills/axstack-anatomy/scripts/turn-group-reference.test.mjs +40 -0
  122. package/skills/axstack-anatomy/scripts/turn-split-owner.test.mjs +62 -0
  123. package/skills/axstack-anatomy/theme.js +56 -0
  124. package/skills/axstack-explain/SKILL.md +28 -15
  125. package/skills/axstack-explain/references/delivery.md +22 -0
  126. package/skills/{axstack-diagram → axstack-explain}/references/fidelity.md +1 -7
  127. package/skills/axstack-explain/references/inline-pages.md +8 -8
  128. package/skills/axstack-explain/references/visual-qa.md +3 -5
  129. package/skills/axstack-explain/references/whiteboard-defense.md +59 -0
  130. package/skills/axstack-research/SKILL.md +18 -6
  131. package/src/archify-legacy.js +70 -0
  132. package/src/capabilities.js +1 -29
  133. package/src/installer.js +11 -31
  134. package/skills/axstack-diagram/SKILL.md +0 -28
  135. package/skills/axstack-diagram/references/archify.md +0 -76
  136. package/src/archify-pin.js +0 -5
  137. package/src/archify.js +0 -151
@@ -0,0 +1,280 @@
1
+ import { circleRing, extrude, insetPlan, onTop, pathOf, type Plan, type Plane, planOutline, type Point3, ringOf, type SolidPaths, topMatrix } from "../../kit/iso-kit";
2
+ import {
3
+ BASE,
4
+ BASE_H,
5
+ BOTTLE,
6
+ DATUM_Y,
7
+ DECK,
8
+ DECK_H,
9
+ FOOT_H,
10
+ GAPS,
11
+ MERGE_MOST,
12
+ P,
13
+ PIVOT,
14
+ SCALE_R,
15
+ STACK_TOP,
16
+ STOP_R,
17
+ SWEEP,
18
+ TRAY,
19
+ TRAY_H,
20
+ FLOOR,
21
+ VIEW,
22
+ WASHER_H,
23
+ WELL_D,
24
+ WELL_HALF,
25
+ WELLS,
26
+ BORDER,
27
+ angleOf,
28
+ at,
29
+ rowX,
30
+ rowY,
31
+ ROW_RIM,
32
+ } from "./rig";
33
+
34
+ const RADIANS = Math.PI / 180;
35
+ const VIEW_ANGLE = Math.atan2(VIEW[1], VIEW[0]);
36
+
37
+ const line = (points: Point3[]) => pathOf(points.map(at));
38
+ const lines = (runs: Point3[][]) => runs.map(line).join("");
39
+
40
+ function clean(ring: Plane[]): Plane[] {
41
+ return ring.filter(([x, y], index) => {
42
+ const [nx, ny] = ring[(index + 1) % ring.length]!;
43
+ return Math.hypot(nx - x, ny - y) > 1e-6;
44
+ });
45
+ }
46
+
47
+ const solid = (plan: Plan, z: number, height: number, steps = 6, bevel = 0): SolidPaths =>
48
+ extrude(clean(ringOf(plan, steps)), z, height, P, { convex: true, bevel });
49
+
50
+ const cyl = (x: number, y: number, r: number, z: number, h: number, steps = 28): SolidPaths => extrude(circleRing(x, y, r, steps), z, h, P);
51
+
52
+ const ring = (x: number, y: number, r: number, z: number, steps = 24) => onTop(circleRing(x, y, r, steps), z, P, true);
53
+
54
+ function knurl(x: number, y: number, r: number, z0: number, z1: number, count = 9) {
55
+ const runs: Point3[][] = [];
56
+ for (let index = 0; index < count; index++) {
57
+ const angle = VIEW_ANGLE - Math.PI * 0.42 + (Math.PI * 0.84 * index) / (count - 1);
58
+ const px = x + r * Math.cos(angle);
59
+ const py = y + r * Math.sin(angle);
60
+ runs.push([
61
+ [px, py, z0 + 0.4],
62
+ [px, py, z1 - 0.4],
63
+ ]);
64
+ }
65
+ return lines(runs);
66
+ }
67
+
68
+ function normalsOf(points: Plane[]) {
69
+ const count = points.length;
70
+ let area = 0;
71
+ for (let index = 0; index < count; index++) {
72
+ const [ax, ay] = points[index]!;
73
+ const [bx, by] = points[(index + 1) % count]!;
74
+ area += ax * by - bx * ay;
75
+ }
76
+ const counter = area > 0;
77
+ return points.map(([ax, ay], index) => {
78
+ const [bx, by] = points[(index + 1) % count]!;
79
+ const dx = bx - ax;
80
+ const dy = by - ay;
81
+ const length = Math.hypot(dx, dy) || 1;
82
+ return (counter ? [dy / length, -dx / length] : [-dy / length, dx / length]) as Plane;
83
+ });
84
+ }
85
+
86
+ function runOf(points: Plane[], want: boolean) {
87
+ const count = points.length;
88
+ const facing = normalsOf(points).map(([nx, ny]) => nx * VIEW[0] + ny * VIEW[1] > 1e-9);
89
+ const start = facing.findIndex((flag, index) => flag === want && facing[(index + count - 1) % count] !== want);
90
+ if (start < 0) return [] as Plane[];
91
+ const run: Plane[] = [points[start]!];
92
+ for (let step = 0; step < count && facing[(start + step) % count] === want; step++) run.push(points[(start + step + 1) % count]!);
93
+ return run;
94
+ }
95
+
96
+ const visibleRun = (plan: Plan, z: number, steps = 8) => onTop(runOf(clean(ringOf(plan, steps)), true), z, P);
97
+
98
+ const corners = ({ x, y, w, d }: Plan, inset: number): Plane[] => [
99
+ [x + inset, y + inset],
100
+ [x + w - inset, y + inset],
101
+ [x + w - inset, y + d - inset],
102
+ [x + inset, y + d - inset],
103
+ ];
104
+
105
+ const FEET = corners(BASE, 18);
106
+ const BASE_SCREWS = corners(BASE, 6);
107
+
108
+ export const BASE_ART = {
109
+ halo: solid(insetPlan(BASE, 6), -BASE_H - FOOT_H, 0.4, 8).fill,
110
+ feet: FEET.map(([x, y]) => [cyl(x, y, 3.6, -BASE_H - FOOT_H, FOOT_H - 1.2, 24), cyl(x, y, 4.8, -BASE_H - 1.2, 1.2, 28)]),
111
+ slab: solid(BASE, -BASE_H, BASE_H, 10, 2.2),
112
+ groove: visibleRun(BASE, -BASE_H * 0.45, 10),
113
+ inlay: planOutline(insetPlan(BASE, 4), 0, P, 8),
114
+ screws: BASE_SCREWS.map(([x, y]) => at([x, y, 0])),
115
+ screwRings: BASE_SCREWS.map(([x, y]) => ring(x, y, 1.6, 0, 18)).join(""),
116
+ };
117
+
118
+ function wellOf(plan: Plan) {
119
+ const points = clean(ringOf(plan, 12));
120
+ const back = runOf(points, false);
121
+ const wall = back.length > 1 ? pathOf([...back.map(([x, y]) => at([x, y, TRAY_H])), ...[...back].reverse().map(([x, y]) => at([x, y, FLOOR]))], true) : "";
122
+ return {
123
+ opening: onTop(points, TRAY_H, P, true),
124
+ wall,
125
+ foot: onTop(back, FLOOR, P),
126
+ };
127
+ }
128
+
129
+ const RULE_X = WELL_HALF + 2.2;
130
+ const RULE_MINOR = 2.2;
131
+ const RULE_MAJOR = 4.6;
132
+ const RULE_REACH = MERGE_MOST;
133
+
134
+ function rulerOf(row: number) {
135
+ const x0 = rowX(row) + RULE_X;
136
+ const minor: Point3[][] = [];
137
+ const major: Point3[][] = [];
138
+ for (let step = 0; step <= RULE_REACH; step += 2) {
139
+ const y = rowY(row) + DATUM_Y + step;
140
+ (step % 8 === 0 ? major : minor).push([
141
+ [x0, y, TRAY_H],
142
+ [x0 + (step % 8 === 0 ? RULE_MAJOR : RULE_MINOR), y, TRAY_H],
143
+ ]);
144
+ }
145
+ return { minor, major };
146
+ }
147
+
148
+ const RULERS = GAPS.map((_, row) => rulerOf(row));
149
+
150
+ const countY = (row: number) => rowY(row) + WELL_D + (row < 3 ? ROW_RIM : BORDER) / 2;
151
+
152
+ const TRAY_SCREWS = corners(TRAY, 7);
153
+
154
+ export const TRAY_ART = {
155
+ screws: TRAY_SCREWS.map(([x, y]) => at([x, y, TRAY_H])),
156
+ screwRings: TRAY_SCREWS.map(([x, y]) => ring(x, y, 1.7, TRAY_H, 18)).join(""),
157
+ slab: solid(TRAY, 0, TRAY_H, 10, 2.4),
158
+ groove: visibleRun(TRAY, TRAY_H * 0.42, 10),
159
+ inlay: planOutline(insetPlan(TRAY, 4.2), TRAY_H, P, 10),
160
+ wells: WELLS.map(wellOf),
161
+ minor: lines(RULERS.flatMap((ruler) => ruler.minor)),
162
+ major: lines(RULERS.flatMap((ruler) => ruler.major)),
163
+ datum: lines(
164
+ GAPS.map((_, row) => [
165
+ [rowX(row) - WELL_HALF - 3.4, rowY(row) + DATUM_Y, TRAY_H],
166
+ [rowX(row) - WELL_HALF - 0.8, rowY(row) + DATUM_Y, TRAY_H],
167
+ ]),
168
+ ),
169
+ gapMarks: GAPS.map((gap, row) => at([rowX(row) + RULE_X + RULE_MAJOR + 2.4, rowY(row) + DATUM_Y + gap, TRAY_H])),
170
+ counts: GAPS.flatMap((_, row) =>
171
+ Array.from({ length: row + 1 }, (__, dot) => at([rowX(row) + (dot - row / 2) * 3.2, countY(row), TRAY_H])),
172
+ ),
173
+ };
174
+
175
+ function sector(r0: number, r1: number, from: number, to: number, steps = 40): Plane[] {
176
+ const out: Plane[] = [];
177
+ const point = (r: number, degrees: number): Plane => [PIVOT[0] + r * Math.sin(degrees * RADIANS), PIVOT[1] - r * Math.cos(degrees * RADIANS)];
178
+ for (let step = 0; step <= steps; step++) out.push(point(r1, from + ((to - from) * step) / steps));
179
+ for (let step = steps; step >= 0; step--) out.push(point(r0, from + ((to - from) * step) / steps));
180
+ return out;
181
+ }
182
+
183
+ const polar = (r: number, degrees: number, z: number): Point3 => [PIVOT[0] + r * Math.sin(degrees * RADIANS), PIVOT[1] - r * Math.cos(degrees * RADIANS), z];
184
+
185
+ const BAND_IN = SCALE_R - 2;
186
+ const BAND_OUT = SCALE_R + 10;
187
+ const BAND_FROM = -7;
188
+ const BAND_TO = SWEEP + 7;
189
+ const BAND_SPLIT = (Math.atan2(VIEW[1], VIEW[0]) * 180) / Math.PI;
190
+ const BAND_TOP = STACK_TOP;
191
+
192
+ function scaleTicks() {
193
+ const minor: Point3[][] = [];
194
+ const major: Point3[][] = [];
195
+ for (let merge = 0; merge <= MERGE_MOST; merge += 2) {
196
+ const angle = angleOf(merge);
197
+ const long = merge % 8 === 0;
198
+ (long ? major : minor).push([polar(BAND_IN + 1.2, angle, BAND_TOP), polar(BAND_IN + (long ? 6 : 3.6), angle, BAND_TOP)]);
199
+ }
200
+ return { minor: lines(minor), major: lines(major) };
201
+ }
202
+
203
+ const TICKS = scaleTicks();
204
+
205
+ const DECK_SCREWS = corners(DECK, 6);
206
+
207
+ const STOPS = GAPS.map((gap) => polar(STOP_R, angleOf(gap), DECK_H));
208
+
209
+ export const DECK_ART = {
210
+ slab: solid(DECK, 0, DECK_H, 8, 1.6),
211
+ inlay: planOutline(insetPlan(DECK, 3.4), DECK_H, P, 8),
212
+ screws: DECK_SCREWS.map(([x, y]) => at([x, y, DECK_H])),
213
+ screwRings: DECK_SCREWS.map(([x, y]) => ring(x, y, 1.5, DECK_H, 18)).join(""),
214
+ guide: onTop(
215
+ Array.from({ length: 61 }, (_, step) => {
216
+ const [x, y] = polar(STOP_R + 3, (SWEEP * step) / 60, DECK_H);
217
+ return [x, y] as Plane;
218
+ }),
219
+ DECK_H,
220
+ P,
221
+ ),
222
+ stops: STOPS.map(([x, y]) => cyl(x, y, 1.3, DECK_H, 1.8, 16)),
223
+ stopTops: STOPS.map(([x, y]) => at([x, y, DECK_H + 1.8])),
224
+ bandBack: extrude(sector(BAND_IN, BAND_OUT, BAND_FROM, BAND_SPLIT, 24), DECK_H, BAND_TOP - DECK_H, P, { convex: false }),
225
+ bandFront: extrude(sector(BAND_IN, BAND_OUT, BAND_SPLIT, BAND_TO, 64), DECK_H, BAND_TOP - DECK_H, P, { convex: false }),
226
+ seam: at(polar((BAND_IN + BAND_OUT) / 2, BAND_SPLIT, BAND_TOP)),
227
+ bandLine: onTop(sector(BAND_OUT - 1.6, BAND_OUT - 1.6, BAND_FROM + 1.2, BAND_TO - 1.2, 96).slice(0, 97), BAND_TOP, P),
228
+ minor: TICKS.minor,
229
+ major: TICKS.major,
230
+ gapDots: GAPS.map((gap) => at(polar(BAND_OUT - 3.6, angleOf(gap), BAND_TOP))),
231
+ ends: [0, SWEEP].map((angle) => at(polar(BAND_OUT - 3.6, angle, BAND_TOP))),
232
+ label: topMatrix([PIVOT[0] - 13, PIVOT[1] + 150], DECK_H, P, "x"),
233
+ washer: cyl(PIVOT[0], PIVOT[1], 9.4, DECK_H, WASHER_H, 32),
234
+ cap: cyl(PIVOT[0], PIVOT[1], 8.2, STACK_TOP, 0.8, 32),
235
+ nut: cyl(PIVOT[0], PIVOT[1], 5.8, STACK_TOP + 0.8, 4.2, 28),
236
+ nutKnurl: knurl(PIVOT[0], PIVOT[1], 5.8, STACK_TOP + 0.8, STACK_TOP + 5, 13),
237
+ nutRing: ring(PIVOT[0], PIVOT[1], 3.2, STACK_TOP + 5, 20),
238
+ pin: at([PIVOT[0], PIVOT[1], STACK_TOP + 5]),
239
+ };
240
+
241
+ const [BX, BY] = BOTTLE;
242
+ const COASTER_TOP = DECK_H + 2;
243
+ const BODY_R = 19;
244
+ const BODY_TOP = COASTER_TOP + 32;
245
+ const SHOULDER_TOP = BODY_TOP + 2.2;
246
+ const NECK_TOP = SHOULDER_TOP + 2.2;
247
+ const COLLAR_TOP = NECK_TOP + 2.6;
248
+ const CAP_TOP = COLLAR_TOP + 11;
249
+ const BULB_TOP = CAP_TOP + 10;
250
+ const SHINE_ANGLE = VIEW_ANGLE + 0.95;
251
+ const shineAt = (r: number, z0: number, z1: number) =>
252
+ line([
253
+ [BX + r * Math.cos(SHINE_ANGLE), BY + r * Math.sin(SHINE_ANGLE), z0],
254
+ [BX + r * Math.cos(SHINE_ANGLE), BY + r * Math.sin(SHINE_ANGLE), z1],
255
+ ]);
256
+
257
+ export const BOTTLE_ART = {
258
+ coaster: cyl(BX, BY, 25, DECK_H, COASTER_TOP - DECK_H, 44),
259
+ coasterRing: ring(BX, BY, 22.6, COASTER_TOP, 40),
260
+ coasterDots: [0, 1, 2, 3].map((index) => {
261
+ const angle = Math.PI / 4 + (Math.PI / 2) * index;
262
+ return at([BX + 23.8 * Math.cos(angle), BY + 23.8 * Math.sin(angle), COASTER_TOP]);
263
+ }),
264
+ body: cyl(BX, BY, BODY_R, COASTER_TOP, BODY_TOP - COASTER_TOP, 44),
265
+ level: onTop(runOf(clean(circleRing(BX, BY, BODY_R, 44)), true), COASTER_TOP + 21, P),
266
+ foot: onTop(runOf(clean(circleRing(BX, BY, BODY_R, 44)), true), COASTER_TOP + 2.4, P),
267
+ shine: shineAt(BODY_R, COASTER_TOP + 5, BODY_TOP - 4),
268
+ shoulder: cyl(BX, BY, 15.5, BODY_TOP, SHOULDER_TOP - BODY_TOP, 40),
269
+ neck: cyl(BX, BY, 11, SHOULDER_TOP, NECK_TOP - SHOULDER_TOP, 36),
270
+ collar: cyl(BX, BY, 9.4, NECK_TOP, COLLAR_TOP - NECK_TOP, 36),
271
+ cap: cyl(BX, BY, 10, COLLAR_TOP, CAP_TOP - COLLAR_TOP, 36),
272
+ capKnurl: knurl(BX, BY, 10, COLLAR_TOP + 1, CAP_TOP - 1, 15),
273
+ capRing: ring(BX, BY, 7.2, CAP_TOP, 32),
274
+ bulb: cyl(BX, BY, 6, CAP_TOP, BULB_TOP - CAP_TOP, 32),
275
+ bulbShine: shineAt(6, CAP_TOP + 2, BULB_TOP - 2),
276
+ bulbRing: ring(BX, BY, 3.4, BULB_TOP, 24),
277
+ tip: at([BX, BY, BULB_TOP]),
278
+ };
279
+
280
+ export const LABEL = `A lab spot plate on a bench. The plate has six channels, each holding a pair of glass drops, R30 and R22, at gaps of ${GAPS.join(", ")} px, drawn to scale against a ruler on the rim. On the right, beside a dropper bottle, a fan of six feeler blades, one for each gap, turns on a pivot over a protractor marked 0 to ${MERGE_MOST} px; the top blade is merge. As it opens, each blade stops at its own gap, and the pair with that gap runs together into one piece of glass.`;
@@ -0,0 +1,339 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+ import { IsoFigure } from "../../kit/react/draw";
5
+ import { asksForStillness, useReadout } from "../../kit/react/live";
6
+ import { keyOf, rowsOf, type GlassShape, type RowShape } from "./drops";
7
+ import { fanOf, type FanPose } from "./fan";
8
+ import { HEIGHT, MERGE, MERGE_LEAST, MERGE_MOST, MERGE_STEP, ROWS, WIDTH, mergeAtX, readoutOf, roundMerge, spokenOf } from "./rig";
9
+
10
+ const FOLLOW = 150;
11
+ const GLIDE = 560;
12
+ const SETTLED = 0.004;
13
+ const TOUR_FIRST = 1800;
14
+ const TOUR_BACK = 4200;
15
+ const TOUR_TRAVEL = 1400;
16
+ const TOUR: { merge: number; hold: number }[] = [
17
+ { merge: MERGE_MOST, hold: 2400 },
18
+ { merge: MERGE_LEAST, hold: 2200 },
19
+ { merge: MERGE, hold: 3800 },
20
+ ];
21
+ const BLADES = ROWS + 1;
22
+
23
+ function Glass({ shape, joined }: { shape: GlassShape; joined: boolean }) {
24
+ const quiet = joined ? "lo" : "faint";
25
+ return (
26
+ <g className="iso-solid ml-glass" data-joined={joined ? "" : undefined}>
27
+ <path className="iso-fill" d={shape.fill} />
28
+ {shape.shades.map((shade, index) => (
29
+ <path key={index} className="iso-shade" data-shade={shade.shade} d={shade.d} />
30
+ ))}
31
+ <path className="iso-top" d={shape.top} />
32
+ <path className="iso-line" data-tone={quiet} d={shape.crease} />
33
+ {shape.bevel ? <path className="iso-line iso-bevel" data-tone={quiet} d={shape.bevel} /> : null}
34
+ <path className="iso-line iso-edge" data-tone={joined ? "hi" : "mid"} d={shape.outline} />
35
+ {shape.shine ? <path className="iso-line ml-shine" data-tone="mid" d={shape.shine} /> : null}
36
+ </g>
37
+ );
38
+ }
39
+
40
+ const Row = React.memo(function Row({ shape }: { shape: RowShape }) {
41
+ return (
42
+ <>
43
+ <g>
44
+ <g>
45
+ {shape.glass.map((glass, index) => (
46
+ <Glass key={index} shape={glass} joined={shape.joined} />
47
+ ))}
48
+ </g>
49
+ </g>
50
+ {shape.neck.length ? (
51
+ <g>
52
+ <g className="ml-neck">
53
+ {shape.neck.map((glint, index) => (
54
+ <path key={index} className="iso-line" data-tone="lit" strokeOpacity={glint.alpha < 1 ? glint.alpha : undefined} d={glint.d} />
55
+ ))}
56
+ </g>
57
+ </g>
58
+ ) : null}
59
+ </>
60
+ );
61
+ });
62
+
63
+ interface BladeNodes {
64
+ group: SVGGElement | null;
65
+ fill: SVGPathElement | null;
66
+ shades: (SVGPathElement | null)[];
67
+ top: SVGPathElement | null;
68
+ crease: SVGPathElement | null;
69
+ outline: SVGPathElement | null;
70
+ line: SVGPathElement | null;
71
+ }
72
+
73
+ const emptyBlade = (): BladeNodes => ({ group: null, fill: null, shades: [null, null, null, null], top: null, crease: null, outline: null, line: null });
74
+
75
+ function apply(nodes: BladeNodes[], halo: SVGPathElement | null, pose: FanPose) {
76
+ halo?.setAttribute("d", pose.halo);
77
+ pose.blades.forEach((blade, index) => {
78
+ const node = nodes[index];
79
+ if (!node) return;
80
+ node.fill?.setAttribute("d", blade.fill);
81
+ blade.shades.forEach((d, shade) => node.shades[shade]?.setAttribute("d", d));
82
+ node.top?.setAttribute("d", blade.top);
83
+ node.crease?.setAttribute("d", blade.crease);
84
+ node.outline?.setAttribute("d", blade.outline);
85
+ node.line?.setAttribute("d", blade.line);
86
+ if (index < ROWS) {
87
+ if (pose.out[index]) node.group?.setAttribute("data-out", "");
88
+ else node.group?.removeAttribute("data-out");
89
+ }
90
+ });
91
+ }
92
+
93
+ interface Props {
94
+ label: string;
95
+ back: React.ReactNode;
96
+ deck: React.ReactNode;
97
+ front: React.ReactNode;
98
+ }
99
+
100
+ export function LiquidLive({ label, back, deck, front }: Props) {
101
+ const { anchor, say } = useReadout<HTMLDivElement>();
102
+ const root = React.useRef<HTMLDivElement | null>(null);
103
+ const blades = React.useRef<BladeNodes[]>(Array.from({ length: BLADES }, emptyBlade));
104
+ const halo = React.useRef<SVGPathElement | null>(null);
105
+ const rest = React.useMemo(() => fanOf(MERGE), []);
106
+ const goal = React.useRef(MERGE);
107
+ const lead = React.useRef(MERGE);
108
+ const shown = React.useRef(MERGE);
109
+ const ease = React.useRef(FOLLOW);
110
+ const frame = React.useRef(0);
111
+ const last = React.useRef(0);
112
+ const [quant, setQuant] = React.useState(keyOf(MERGE) / 4);
113
+ const [hovering, setHovering] = React.useState(false);
114
+ const [focused, setFocused] = React.useState(false);
115
+ const [visible, setVisible] = React.useState(false);
116
+ const [idle, setIdle] = React.useState(true);
117
+ const [poke, setPoke] = React.useState(0);
118
+
119
+ const rows = React.useMemo(() => rowsOf(quant), [quant]);
120
+ const value = roundMerge(quant);
121
+
122
+ const draw = React.useCallback((merge: number) => {
123
+ apply(blades.current, halo.current, fanOf(merge));
124
+ setQuant(keyOf(merge) / 4);
125
+ }, []);
126
+
127
+ const stop = React.useCallback(() => {
128
+ cancelAnimationFrame(frame.current);
129
+ frame.current = 0;
130
+ }, []);
131
+
132
+ const run = React.useCallback(() => {
133
+ if (frame.current) return;
134
+ last.current = 0;
135
+ const tick = (now: number) => {
136
+ const dt = last.current ? Math.min(now - last.current, 64) : 16;
137
+ last.current = now;
138
+ const blend = 1 - Math.exp(-dt / ease.current);
139
+ lead.current += (goal.current - lead.current) * blend;
140
+ shown.current += (lead.current - shown.current) * blend;
141
+ const done = Math.abs(goal.current - lead.current) < SETTLED && Math.abs(goal.current - shown.current) < SETTLED;
142
+ if (done) {
143
+ lead.current = goal.current;
144
+ shown.current = goal.current;
145
+ }
146
+ draw(shown.current);
147
+ frame.current = done ? 0 : requestAnimationFrame(tick);
148
+ };
149
+ frame.current = requestAnimationFrame(tick);
150
+ }, [draw]);
151
+
152
+ const aim = React.useCallback(
153
+ (merge: number, pace = FOLLOW) => {
154
+ const next = Math.min(MERGE_MOST, Math.max(MERGE_LEAST, merge));
155
+ ease.current = pace;
156
+ goal.current = next;
157
+ if (asksForStillness()) {
158
+ stop();
159
+ lead.current = next;
160
+ shown.current = next;
161
+ draw(next);
162
+ return;
163
+ }
164
+ run();
165
+ },
166
+ [draw, run, stop],
167
+ );
168
+
169
+ React.useEffect(() => stop, [stop]);
170
+
171
+ React.useEffect(() => {
172
+ say(readoutOf(value));
173
+ }, [value, say]);
174
+
175
+ React.useEffect(() => {
176
+ const node = root.current;
177
+ if (!node) return;
178
+ const observer = new IntersectionObserver((entries) => setVisible(Boolean(entries.at(-1)?.isIntersecting)));
179
+ observer.observe(node);
180
+ return () => observer.disconnect();
181
+ }, []);
182
+
183
+ React.useEffect(() => {
184
+ if (!visible) stop();
185
+ }, [visible, stop]);
186
+
187
+ const engaged = hovering || focused;
188
+
189
+ React.useEffect(() => {
190
+ if (engaged) {
191
+ setIdle(false);
192
+ return;
193
+ }
194
+ const timer = window.setTimeout(() => setIdle(true), TOUR_BACK);
195
+ return () => window.clearTimeout(timer);
196
+ }, [engaged, poke]);
197
+
198
+ const touring = visible && idle && !engaged;
199
+
200
+ React.useEffect(() => {
201
+ if (!touring || asksForStillness()) return;
202
+ let timer = 0;
203
+ let step = 0;
204
+ const next = () => {
205
+ const stop = TOUR[step % TOUR.length]!;
206
+ aim(stop.merge, GLIDE);
207
+ step += 1;
208
+ timer = window.setTimeout(next, stop.hold + TOUR_TRAVEL);
209
+ };
210
+ timer = window.setTimeout(next, TOUR_FIRST);
211
+ return () => window.clearTimeout(timer);
212
+ }, [touring, aim]);
213
+
214
+ const mergeAt = (clientX: number) => {
215
+ const svg = root.current?.querySelector("svg");
216
+ const box = svg?.getBoundingClientRect();
217
+ if (!box || !box.width) return null;
218
+ return mergeAtX(((clientX - box.left) / box.width) * WIDTH);
219
+ };
220
+
221
+ const hold = () => {
222
+ setIdle(false);
223
+ setPoke((count) => count + 1);
224
+ };
225
+
226
+ const follow = (clientX: number) => {
227
+ const merge = mergeAt(clientX);
228
+ if (merge !== null) aim(merge, FOLLOW);
229
+ };
230
+
231
+ const stepBy = (by: number) => roundMerge(goal.current + by);
232
+
233
+ const bindBlade = (index: number) => ({
234
+ group: (node: SVGGElement | null) => {
235
+ blades.current[index]!.group = node;
236
+ },
237
+ path: (key: "fill" | "top" | "crease" | "outline" | "line") => (node: SVGPathElement | null) => {
238
+ blades.current[index]![key] = node;
239
+ },
240
+ shade: (shade: number) => (node: SVGPathElement | null) => {
241
+ blades.current[index]!.shades[shade] = node;
242
+ },
243
+ });
244
+
245
+ return (
246
+ <div
247
+ ref={(node) => {
248
+ root.current = node;
249
+ anchor.current = node;
250
+ }}
251
+ className="ml"
252
+ role="slider"
253
+ tabIndex={0}
254
+ aria-label="Merge, in pixels"
255
+ aria-valuemin={MERGE_LEAST}
256
+ aria-valuemax={MERGE_MOST}
257
+ aria-valuenow={value}
258
+ aria-valuetext={spokenOf(value)}
259
+ onPointerEnter={(event) => {
260
+ if (event.pointerType === "mouse") setHovering(true);
261
+ }}
262
+ onPointerMove={(event) => {
263
+ if (event.pointerType === "mouse" || event.currentTarget.hasPointerCapture(event.pointerId)) {
264
+ hold();
265
+ follow(event.clientX);
266
+ }
267
+ }}
268
+ onPointerDown={(event) => {
269
+ if (event.pointerType !== "mouse") event.currentTarget.setPointerCapture(event.pointerId);
270
+ hold();
271
+ follow(event.clientX);
272
+ }}
273
+ onPointerUp={(event) => {
274
+ if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
275
+ }}
276
+ onPointerLeave={(event) => {
277
+ if (event.pointerType !== "mouse") return;
278
+ setHovering(false);
279
+ aim(MERGE, GLIDE);
280
+ }}
281
+ onFocus={() => {
282
+ setFocused(true);
283
+ aim(roundMerge(shown.current), FOLLOW);
284
+ }}
285
+ onBlur={() => setFocused(false)}
286
+ onKeyDown={(event) => {
287
+ let next: number | null = null;
288
+ const big = event.shiftKey ? 4 : 1;
289
+ if (event.key === "ArrowLeft" || event.key === "ArrowDown") next = stepBy(-MERGE_STEP * big);
290
+ else if (event.key === "ArrowRight" || event.key === "ArrowUp") next = stepBy(MERGE_STEP * big);
291
+ else if (event.key === "PageDown") next = stepBy(-MERGE_STEP * 8);
292
+ else if (event.key === "PageUp") next = stepBy(MERGE_STEP * 8);
293
+ else if (event.key === "Home") next = MERGE_LEAST;
294
+ else if (event.key === "End") next = MERGE_MOST;
295
+ if (next === null) return;
296
+ event.preventDefault();
297
+ hold();
298
+ aim(next, FOLLOW);
299
+ }}
300
+ >
301
+ <IsoFigure width={WIDTH} height={HEIGHT} label={label}>
302
+ {back}
303
+ {rows.map((shape) => (
304
+ <Row key={shape.row} shape={shape} />
305
+ ))}
306
+ {deck}
307
+ <g>
308
+ <g>
309
+ <path ref={(node) => void (halo.current = node)} className="iso-halo ml-fan-halo" d={rest.halo} />
310
+ {rest.blades.map((blade, index) => {
311
+ const bind = bindBlade(index);
312
+ const isLead = index === ROWS;
313
+ return (
314
+ <g
315
+ key={index}
316
+ ref={bind.group}
317
+ className="iso-solid ml-blade"
318
+ data-lead={isLead ? "" : undefined}
319
+ data-lit={isLead ? "" : undefined}
320
+ data-out={!isLead && rest.out[index] ? "" : undefined}
321
+ >
322
+ <path ref={bind.path("fill")} className="iso-fill" d={blade.fill} />
323
+ {[0, 1, 2, 3].map((shade) => (
324
+ <path key={shade} ref={bind.shade(shade)} className="iso-shade" data-shade={shade} d={blade.shades[shade] ?? ""} />
325
+ ))}
326
+ <path ref={bind.path("top")} className="iso-top" d={blade.top} />
327
+ <path ref={bind.path("crease")} className="iso-line" data-tone="faint" d={blade.crease} />
328
+ <path ref={bind.path("outline")} className="iso-line iso-edge" data-tone={isLead ? "hi" : "mid"} d={blade.outline} />
329
+ <path ref={bind.path("line")} className="iso-line ml-blade-line" data-tone={isLead ? "lit" : "lo"} d={blade.line} />
330
+ </g>
331
+ );
332
+ })}
333
+ </g>
334
+ </g>
335
+ {front}
336
+ </IsoFigure>
337
+ </div>
338
+ );
339
+ }