axstack 0.36.1 → 0.37.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 (130) hide show
  1. package/README.md +3 -2
  2. package/bin/axstack.js +4 -18
  3. package/docs/getting-started.md +6 -6
  4. package/docs/installation.md +20 -41
  5. package/docs/workflows.md +12 -9
  6. package/package.json +1 -1
  7. package/profiles/presets/claude-only.json +3 -3
  8. package/profiles/presets/codex-only.json +3 -3
  9. package/profiles/presets/mixed.json +5 -4
  10. package/skills/axstack/references/role-roster.md +5 -3
  11. package/skills/axstack/references/ui-verification.md +6 -10
  12. package/skills/axstack-anatomy/ANATOMY.md +172 -0
  13. package/skills/axstack-anatomy/CHANGELOG.md +70 -0
  14. package/skills/axstack-anatomy/LICENSE +21 -0
  15. package/skills/axstack-anatomy/PROVENANCE.md +21 -0
  16. package/skills/axstack-anatomy/README.md +316 -0
  17. package/skills/axstack-anatomy/SKILL.md +74 -0
  18. package/skills/axstack-anatomy/SOURCE-PROOF.json +107 -0
  19. package/skills/axstack-anatomy/examples/arcade-cabinet/arcade-cabinet.css +44 -0
  20. package/skills/axstack-anatomy/examples/arcade-cabinet/build.mjs +714 -0
  21. package/skills/axstack-anatomy/examples/arcade-cabinet/live.js +597 -0
  22. package/skills/axstack-anatomy/examples/arcade-cabinet/parts.mjs +89 -0
  23. package/skills/axstack-anatomy/examples/arcade-cabinet/screen.mjs +114 -0
  24. package/skills/axstack-anatomy/examples/desk-computer/build.mjs +435 -0
  25. package/skills/axstack-anatomy/examples/dial-indicator/build.mjs +275 -0
  26. package/skills/axstack-anatomy/examples/raptor-engine/build.mjs +938 -0
  27. package/skills/axstack-anatomy/examples/raptor-engine/live.js +907 -0
  28. package/skills/axstack-anatomy/examples/raptor-engine/raptor.css +359 -0
  29. package/skills/axstack-anatomy/examples/ripple-tank/build.mjs +558 -0
  30. package/skills/axstack-anatomy/examples/ripple-tank/live.js +579 -0
  31. package/skills/axstack-anatomy/examples/ripple-tank/model.mjs +131 -0
  32. package/skills/axstack-anatomy/examples/ripple-tank/ripple.css +103 -0
  33. package/skills/axstack-anatomy/examples/spot-plate/art.tsx +110 -0
  34. package/skills/axstack-anatomy/examples/spot-plate/drops.ts +541 -0
  35. package/skills/axstack-anatomy/examples/spot-plate/fan.ts +84 -0
  36. package/skills/axstack-anatomy/examples/spot-plate/figure.tsx +25 -0
  37. package/skills/axstack-anatomy/examples/spot-plate/geometry.ts +280 -0
  38. package/skills/axstack-anatomy/examples/spot-plate/live.tsx +339 -0
  39. package/skills/axstack-anatomy/examples/spot-plate/rig.ts +230 -0
  40. package/skills/axstack-anatomy/examples/spot-plate/spot-plate.css +92 -0
  41. package/skills/axstack-anatomy/examples/test-rig/art.tsx +196 -0
  42. package/skills/axstack-anatomy/examples/test-rig/figure.tsx +23 -0
  43. package/skills/axstack-anatomy/examples/test-rig/geometry.ts +305 -0
  44. package/skills/axstack-anatomy/examples/test-rig/live.tsx +511 -0
  45. package/skills/axstack-anatomy/examples/test-rig/pose.ts +107 -0
  46. package/skills/axstack-anatomy/examples/test-rig/rig.ts +170 -0
  47. package/skills/axstack-anatomy/examples/test-rig/stretch.css +80 -0
  48. package/skills/axstack-anatomy/examples/test-rig/view.ts +99 -0
  49. package/skills/axstack-anatomy/examples/turning-dial/build.mjs +479 -0
  50. package/skills/axstack-anatomy/examples/turning-dial/dial.css +10 -0
  51. package/skills/axstack-anatomy/examples/turning-dial/live.js +342 -0
  52. package/skills/axstack-anatomy/examples/turning-dial/stress.mjs +269 -0
  53. package/skills/axstack-anatomy/kit/audit.mjs +1913 -0
  54. package/skills/axstack-anatomy/kit/canvas-painter.mjs +722 -0
  55. package/skills/axstack-anatomy/kit/gl-shared.mjs +184 -0
  56. package/skills/axstack-anatomy/kit/gl.mjs +184 -0
  57. package/skills/axstack-anatomy/kit/iso-kit.mjs +674 -0
  58. package/skills/axstack-anatomy/kit/iso-kit.ts +842 -0
  59. package/skills/axstack-anatomy/kit/iso.css +106 -0
  60. package/skills/axstack-anatomy/kit/lathe.mjs +1184 -0
  61. package/skills/axstack-anatomy/kit/react/draw.tsx +205 -0
  62. package/skills/axstack-anatomy/kit/react/live.tsx +140 -0
  63. package/skills/axstack-anatomy/kit/tube.mjs +306 -0
  64. package/skills/axstack-anatomy/kit/turn-audit.mjs +1096 -0
  65. package/skills/axstack-anatomy/kit/turn-build.mjs +1355 -0
  66. package/skills/axstack-anatomy/kit/turn-fixed.mjs +3800 -0
  67. package/skills/axstack-anatomy/kit/turn.mjs +3610 -0
  68. package/skills/axstack-anatomy/references/3d.md +352 -0
  69. package/skills/axstack-anatomy/references/craft.md +325 -0
  70. package/skills/axstack-anatomy/references/kit.md +419 -0
  71. package/skills/axstack-anatomy/references/motion.md +165 -0
  72. package/skills/axstack-anatomy/references/react.md +42 -0
  73. package/skills/axstack-anatomy/references/verify.md +351 -0
  74. package/skills/axstack-anatomy/references/walkthrough.md +75 -0
  75. package/skills/axstack-anatomy/references/webgl.md +473 -0
  76. package/skills/axstack-anatomy/scripts/audit-affine-bounds.test.mjs +54 -0
  77. package/skills/axstack-anatomy/scripts/audit-cache.test.mjs +37 -0
  78. package/skills/axstack-anatomy/scripts/audit-clearance-envelope.test.mjs +39 -0
  79. package/skills/axstack-anatomy/scripts/audit-empty-clipping.test.mjs +38 -0
  80. package/skills/axstack-anatomy/scripts/audit-order-convergence.test.mjs +55 -0
  81. package/skills/axstack-anatomy/scripts/audit-path-origin.test.mjs +26 -0
  82. package/skills/axstack-anatomy/scripts/audit-profile.test.mjs +41 -0
  83. package/skills/axstack-anatomy/scripts/audit-projection.test.mjs +36 -0
  84. package/skills/axstack-anatomy/scripts/audit-slide-clearance.test.mjs +58 -0
  85. package/skills/axstack-anatomy/scripts/audit-svg-attributes.test.mjs +46 -0
  86. package/skills/axstack-anatomy/scripts/audit-taper.test.mjs +29 -0
  87. package/skills/axstack-anatomy/scripts/capture-selector.test.mjs +131 -0
  88. package/skills/axstack-anatomy/scripts/capture.mjs +228 -0
  89. package/skills/axstack-anatomy/scripts/capture.test.mjs +42 -0
  90. package/skills/axstack-anatomy/scripts/chrome-port.mjs +36 -0
  91. package/skills/axstack-anatomy/scripts/chrome-startup.test.mjs +38 -0
  92. package/skills/axstack-anatomy/scripts/drive-actions.test.mjs +46 -0
  93. package/skills/axstack-anatomy/scripts/drive.mjs +302 -0
  94. package/skills/axstack-anatomy/scripts/drive.test.mjs +72 -0
  95. package/skills/axstack-anatomy/scripts/enter-key.test.mjs +38 -0
  96. package/skills/axstack-anatomy/scripts/fidelity-cut.test.mjs +54 -0
  97. package/skills/axstack-anatomy/scripts/fidelity-tube-coverage.test.mjs +40 -0
  98. package/skills/axstack-anatomy/scripts/inline-kit.mjs +26 -0
  99. package/skills/axstack-anatomy/scripts/line-ends.mjs +306 -0
  100. package/skills/axstack-anatomy/scripts/lines-arcs.test.mjs +50 -0
  101. package/skills/axstack-anatomy/scripts/lines-bezier.test.mjs +28 -0
  102. package/skills/axstack-anatomy/scripts/lines-round.test.mjs +56 -0
  103. package/skills/axstack-anatomy/scripts/lines-rounded-rect.test.mjs +32 -0
  104. package/skills/axstack-anatomy/scripts/lines.mjs +31 -0
  105. package/skills/axstack-anatomy/scripts/lines.test.mjs +38 -0
  106. package/skills/axstack-anatomy/scripts/navigation.test.mjs +55 -0
  107. package/skills/axstack-anatomy/scripts/orbit-sampling.test.mjs +50 -0
  108. package/skills/axstack-anatomy/scripts/react-dots-opacity.test.mjs +51 -0
  109. package/skills/axstack-anatomy/scripts/react-readout-first-write.test.mjs +37 -0
  110. package/skills/axstack-anatomy/scripts/react-solid-seamless.test.mjs +47 -0
  111. package/skills/axstack-anatomy/scripts/react-visibility-target.test.mjs +38 -0
  112. package/skills/axstack-anatomy/scripts/turn-bench.mjs +74 -0
  113. package/skills/axstack-anatomy/scripts/turn-check.mjs +641 -0
  114. package/skills/axstack-anatomy/scripts/turn-fidelity.mjs +413 -0
  115. package/skills/axstack-anatomy/scripts/turn-group-reference.test.mjs +40 -0
  116. package/skills/axstack-anatomy/scripts/turn-split-owner.test.mjs +62 -0
  117. package/skills/axstack-anatomy/theme.js +56 -0
  118. package/skills/axstack-explain/SKILL.md +28 -15
  119. package/skills/axstack-explain/references/delivery.md +22 -0
  120. package/skills/{axstack-diagram → axstack-explain}/references/fidelity.md +1 -7
  121. package/skills/axstack-explain/references/inline-pages.md +8 -8
  122. package/skills/axstack-explain/references/visual-qa.md +3 -5
  123. package/skills/axstack-explain/references/whiteboard-defense.md +59 -0
  124. package/src/archify-legacy.js +70 -0
  125. package/src/capabilities.js +1 -29
  126. package/src/installer.js +11 -31
  127. package/skills/axstack-diagram/SKILL.md +0 -28
  128. package/skills/axstack-diagram/references/archify.md +0 -76
  129. package/src/archify-pin.js +0 -5
  130. package/src/archify.js +0 -151
@@ -0,0 +1,511 @@
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 { frameOf, type Frame } from "./pose";
7
+ import { HALF, REST, memberAt, one, poseOf, readoutOf, settled, sizeText, step, type Member, type Pose } from "./rig";
8
+ import { EX, FINGER_MOST, GRAB_MOST, HEIGHT, HOVER, PROBE_HOME, PROBE_MAX, PROBE_MIN, WIDTH, at } from "./view";
9
+
10
+ const FOLLOW = 0.09;
11
+ const DOWN = 0.07;
12
+ const UP = 0.16;
13
+ const NUDGE = 10;
14
+ const TOUR_FIRST = 1.4;
15
+ const TOUR_BACK = 3600;
16
+ const LOOP = 7.6;
17
+ const MOST_DT = 1 / 30;
18
+
19
+ interface Hand {
20
+ held: boolean;
21
+ grab: number;
22
+ finger: number;
23
+ }
24
+
25
+ const clamp = (value: number, low: number, high: number) => Math.min(high, Math.max(low, value));
26
+ const ease = (t: number) => {
27
+ const u = clamp(t, 0, 1);
28
+ return u * u * (3 - 2 * u);
29
+ };
30
+
31
+ function tour(t: number): Hand {
32
+ const at = t % LOOP;
33
+ if (at < 0.6) return { held: false, grab: PROBE_HOME, finger: PROBE_HOME };
34
+ if (at < 3.7) return { held: true, grab: PROBE_HOME, finger: PROBE_HOME - 80 * ease((at - 1.2) / 1.2) };
35
+ return { held: false, grab: PROBE_HOME, finger: PROBE_HOME - 80 + 80 * ease((at - 4.4) / 1.2) };
36
+ }
37
+
38
+ interface Nodes {
39
+ glass: SVGGElement | null;
40
+ fill: SVGPathElement | null;
41
+ shades: (SVGPathElement | null)[];
42
+ top: SVGPathElement | null;
43
+ crease: SVGPathElement | null;
44
+ bevel: SVGPathElement | null;
45
+ edge: SVGPathElement | null;
46
+ rim: SVGPathElement | null;
47
+ shine: SVGPathElement | null;
48
+ coilsBack: SVGPathElement | null;
49
+ coilsLeft: SVGPathElement | null;
50
+ coilsRightBack: SVGPathElement | null;
51
+ coilsRightFront: SVGPathElement | null;
52
+ carriage: SVGGElement | null;
53
+ plunger: SVGGElement | null;
54
+ needle: SVGPathElement | null;
55
+ pull: SVGPathElement | null;
56
+ grab: SVGCircleElement | null;
57
+ touch: SVGCircleElement | null;
58
+ rider: SVGGElement | null;
59
+ probe: SVGGElement | null;
60
+ arm: SVGGElement | null;
61
+ tip: SVGGElement | null;
62
+ rest: SVGGElement | null;
63
+ }
64
+
65
+ function apply(nodes: Nodes, frame: Frame, held: boolean) {
66
+ const { glass } = frame;
67
+ nodes.fill?.setAttribute("d", glass.fill);
68
+ glass.shades?.forEach((d, index) => nodes.shades[index]?.setAttribute("d", d));
69
+ nodes.top?.setAttribute("d", glass.top);
70
+ nodes.crease?.setAttribute("d", glass.crease);
71
+ nodes.bevel?.setAttribute("d", glass.bevel ?? "");
72
+ nodes.edge?.setAttribute("d", glass.outline);
73
+ nodes.rim?.setAttribute("d", glass.rim);
74
+ nodes.shine?.setAttribute("d", glass.shine);
75
+ if (nodes.glass) {
76
+ if (held) nodes.glass.dataset.lit = "";
77
+ else delete nodes.glass.dataset.lit;
78
+ }
79
+ nodes.coilsBack?.setAttribute("d", frame.coilsBack);
80
+ nodes.coilsLeft?.setAttribute("d", frame.coilsLeft);
81
+ nodes.coilsRightBack?.setAttribute("d", frame.coilsRightBack);
82
+ nodes.coilsRightFront?.setAttribute("d", frame.coilsRightFront);
83
+ nodes.carriage?.setAttribute("transform", frame.carriage);
84
+ nodes.plunger?.setAttribute("transform", frame.carriage);
85
+ nodes.needle?.setAttribute("d", frame.needle);
86
+ nodes.pull?.setAttribute("d", frame.pull);
87
+ for (const [node, point] of [
88
+ [nodes.grab, frame.grab],
89
+ [nodes.touch, frame.finger],
90
+ ] as const) {
91
+ if (!node) continue;
92
+ node.setAttribute("cx", `${point[0]}`);
93
+ node.setAttribute("cy", `${point[1]}`);
94
+ node.style.opacity = frame.pull ? "" : "0";
95
+ }
96
+ nodes.rider?.setAttribute("transform", frame.rider);
97
+ nodes.probe?.setAttribute("transform", frame.probe);
98
+ nodes.arm?.setAttribute("transform", frame.probe);
99
+ nodes.tip?.setAttribute("transform", frame.tip);
100
+ }
101
+
102
+ function restShown(pose: Pose) {
103
+ const off = Math.max(Math.abs(pose.mid - pose.half[0] + HALF[0]), Math.abs(pose.mid + pose.half[0] - HALF[0]), Math.abs(pose.half[1] - HALF[1]));
104
+ return `${Math.round(clamp((off - 0.4) / 1.6, 0, 1) * 100) / 100}`;
105
+ }
106
+
107
+ function spokenOf(member: Member, pull: number, held: boolean) {
108
+ const pose = poseOf(member);
109
+ const size = sizeText(pose).replace(" × ", " by ");
110
+ const state = held ? "Pressed" : member.swell.at === 0 && member.lean.at === 0 ? "At rest" : "Springing back";
111
+ return `${state}: ${size} pixels, pulled ${one(Math.abs(pull))} px, leaning ${one(Math.abs(pose.lean))} px`;
112
+ }
113
+
114
+ const RESTING_TEXT = spokenOf(memberAt(), 0, false);
115
+
116
+ interface Props {
117
+ label: string;
118
+ base: React.ReactNode;
119
+ rail: React.ReactNode;
120
+ rider: React.ReactNode;
121
+ block: React.ReactNode;
122
+ frameBack: React.ReactNode;
123
+ rods: React.ReactNode;
124
+ carriage: React.ReactNode;
125
+ plunger: React.ReactNode;
126
+ rest: React.ReactNode;
127
+ frameFront: React.ReactNode;
128
+ dial: React.ReactNode;
129
+ finger: React.ReactNode;
130
+ arm: React.ReactNode;
131
+ }
132
+
133
+ export function StretchLive(props: Props) {
134
+ const { anchor, say } = useReadout<HTMLDivElement>();
135
+ const root = React.useRef<HTMLDivElement | null>(null);
136
+ const nodes = React.useRef<Nodes>({
137
+ glass: null,
138
+ fill: null,
139
+ shades: [null, null, null, null],
140
+ top: null,
141
+ crease: null,
142
+ bevel: null,
143
+ edge: null,
144
+ rim: null,
145
+ shine: null,
146
+ coilsBack: null,
147
+ coilsLeft: null,
148
+ coilsRightBack: null,
149
+ coilsRightFront: null,
150
+ carriage: null,
151
+ plunger: null,
152
+ needle: null,
153
+ pull: null,
154
+ grab: null,
155
+ touch: null,
156
+ rider: null,
157
+ probe: null,
158
+ arm: null,
159
+ tip: null,
160
+ rest: null,
161
+ });
162
+ const member = React.useRef<Member>(memberAt());
163
+ const hand = React.useRef<Hand>({ held: false, grab: PROBE_HOME, finger: PROBE_HOME });
164
+ const local = React.useRef(PROBE_HOME);
165
+ const finger = React.useRef(PROBE_HOME);
166
+ const lift = React.useRef(1);
167
+ const frame = React.useRef(0);
168
+ const last = React.useRef(0);
169
+ const clock = React.useRef(0);
170
+ const script = React.useRef<((t: number) => Hand) | null>(null);
171
+ const [pressing, setPressing] = React.useState(false);
172
+ const user = React.useRef(false);
173
+ const [hovering, setHovering] = React.useState(false);
174
+ const [focused, setFocused] = React.useState(false);
175
+ const [visible, setVisible] = React.useState(false);
176
+ const [idle, setIdle] = React.useState(true);
177
+ const [poke, setPoke] = React.useState(0);
178
+
179
+ const rest = React.useMemo(() => frameOf(REST, 0, PROBE_HOME, PROBE_HOME, HOVER, PROBE_HOME), []);
180
+
181
+ const grip = React.useCallback((next: Hand) => {
182
+ const was = hand.current;
183
+ if (next.held && !was.held) {
184
+ const pose = poseOf(member.current);
185
+ local.current = (next.grab - pose.mid) / (pose.half[0] / HALF[0]);
186
+ }
187
+ hand.current = next;
188
+ }, []);
189
+
190
+ const draw = React.useCallback(() => {
191
+ const pose = poseOf(member.current);
192
+ const now = hand.current;
193
+ apply(nodes.current, frameOf(pose, member.current.lean.at, local.current, finger.current, HOVER * lift.current, PROBE_HOME, now.held), now.held);
194
+ say(readoutOf(pose));
195
+ const restNode = nodes.current.rest;
196
+ const shownRest = restShown(pose);
197
+ if (restNode && restNode.style.opacity !== shownRest) restNode.style.opacity = shownRest;
198
+ const pulled = now.held ? finger.current - now.grab : 0;
199
+ const node = root.current;
200
+ if (node) {
201
+ const value = `${Math.round(pulled)}`;
202
+ if (node.getAttribute("aria-valuenow") !== value) node.setAttribute("aria-valuenow", value);
203
+ const spoken = spokenOf(member.current, pulled, now.held);
204
+ if (node.getAttribute("aria-valuetext") !== spoken) node.setAttribute("aria-valuetext", spoken);
205
+ }
206
+ }, [say]);
207
+
208
+ const stop = React.useCallback(() => {
209
+ cancelAnimationFrame(frame.current);
210
+ frame.current = 0;
211
+ }, []);
212
+
213
+ const run = React.useCallback(() => {
214
+ if (frame.current) return;
215
+ last.current = 0;
216
+ const tick = (now: number) => {
217
+ const dt = last.current ? Math.min((now - last.current) / 1000, MOST_DT) : 1 / 60;
218
+ last.current = now;
219
+ const calm = asksForStillness();
220
+ if (script.current) {
221
+ clock.current += dt;
222
+ grip(script.current(clock.current));
223
+ }
224
+ const want = hand.current;
225
+ const follow = calm ? 1 : 1 - Math.exp(-dt / FOLLOW);
226
+ finger.current += (want.finger - finger.current) * follow;
227
+ if (Math.abs(want.finger - finger.current) < 0.02) finger.current = want.finger;
228
+ const goal = want.held ? 0 : 1;
229
+ const drop = calm ? 1 : 1 - Math.exp(-dt / (want.held ? DOWN : UP));
230
+ lift.current += (goal - lift.current) * drop;
231
+ if (Math.abs(goal - lift.current) < 0.002) lift.current = goal;
232
+ const pulled = want.held ? finger.current - want.grab : 0;
233
+ step(member.current, want.held, pulled, dt, calm);
234
+ draw();
235
+ const done = !script.current && settled(member.current, want.held, pulled) && finger.current === want.finger && lift.current === goal;
236
+ frame.current = done ? 0 : requestAnimationFrame(tick);
237
+ };
238
+ frame.current = requestAnimationFrame(tick);
239
+ }, [draw, grip]);
240
+
241
+ const steer = React.useCallback(
242
+ (next: Hand) => {
243
+ script.current = null;
244
+ grip(next);
245
+ run();
246
+ },
247
+ [grip, run],
248
+ );
249
+
250
+ React.useEffect(() => {
251
+ const node = root.current;
252
+ if (!node) return;
253
+ const observer = new IntersectionObserver((entries) => setVisible(Boolean(entries.at(-1)?.isIntersecting)));
254
+ observer.observe(node);
255
+ return () => {
256
+ observer.disconnect();
257
+ stop();
258
+ };
259
+ }, [stop]);
260
+
261
+ const engaged = hovering || focused || pressing;
262
+
263
+ React.useEffect(() => {
264
+ if (engaged) {
265
+ setIdle(false);
266
+ return;
267
+ }
268
+ const timer = window.setTimeout(() => setIdle(true), TOUR_BACK);
269
+ return () => window.clearTimeout(timer);
270
+ }, [engaged, poke]);
271
+
272
+ const touring = visible && idle && !engaged;
273
+
274
+ React.useEffect(() => {
275
+ if (!visible) {
276
+ script.current = null;
277
+ stop();
278
+ return;
279
+ }
280
+ if (touring && !asksForStillness()) {
281
+ const timer = window.setTimeout(
282
+ () => {
283
+ clock.current = 0;
284
+ script.current = tour;
285
+ run();
286
+ },
287
+ TOUR_FIRST * 1000,
288
+ );
289
+ return () => {
290
+ window.clearTimeout(timer);
291
+ script.current = null;
292
+ };
293
+ }
294
+ script.current = null;
295
+ if (user.current) {
296
+ run();
297
+ return;
298
+ }
299
+ steer({ held: false, grab: hand.current.grab, finger: hovering ? hand.current.finger : PROBE_HOME });
300
+ }, [visible, touring, hovering, run, steer, stop]);
301
+
302
+ const xAt = (clientX: number) => {
303
+ const svg = root.current?.querySelector("svg");
304
+ const box = svg?.getBoundingClientRect();
305
+ if (!box || !box.width) return null;
306
+ const sx = ((clientX - box.left) / box.width) * WIDTH;
307
+ const origin = at([0, 0, 0]);
308
+ return (sx - origin[0]) / EX[0];
309
+ };
310
+
311
+ const wake = () => {
312
+ setIdle(false);
313
+ setPoke((count) => count + 1);
314
+ };
315
+
316
+ const hoverTo = (x: number) => steer({ held: false, grab: hand.current.grab, finger: clamp(x, PROBE_MIN, PROBE_MAX) });
317
+
318
+ const hold = (on: boolean) => {
319
+ user.current = on;
320
+ setPressing(on);
321
+ };
322
+
323
+ const pressAt = (x: number) => {
324
+ hold(true);
325
+ const grab = clamp(x, -GRAB_MOST, GRAB_MOST);
326
+ steer({ held: true, grab, finger: grab });
327
+ };
328
+
329
+ const pullTo = (x: number) => steer({ held: true, grab: hand.current.grab, finger: clamp(x, -FINGER_MOST, FINGER_MOST) });
330
+
331
+ const release = () => {
332
+ hold(false);
333
+ steer({ held: false, grab: hand.current.grab, finger: hand.current.finger });
334
+ };
335
+
336
+ const bind = <K extends keyof Nodes>(key: K) => (node: Nodes[K]) => {
337
+ nodes.current[key] = node;
338
+ };
339
+
340
+ const shown = rest.glass;
341
+
342
+ return (
343
+ <div
344
+ ref={(node) => {
345
+ root.current = node;
346
+ anchor.current = node;
347
+ }}
348
+ className="ms"
349
+ role="slider"
350
+ tabIndex={0}
351
+ aria-label="Glass button on a test rig: Space presses or lets go, arrow keys move the finger and pull"
352
+ aria-valuemin={-FINGER_MOST - GRAB_MOST}
353
+ aria-valuemax={FINGER_MOST + GRAB_MOST}
354
+ aria-valuenow={0}
355
+ aria-valuetext={RESTING_TEXT}
356
+ data-held={pressing ? "" : undefined}
357
+ onPointerEnter={(event) => {
358
+ if (event.pointerType === "mouse") setHovering(true);
359
+ }}
360
+ onPointerMove={(event) => {
361
+ const x = xAt(event.clientX);
362
+ if (x === null) return;
363
+ if (user.current && event.currentTarget.hasPointerCapture(event.pointerId)) {
364
+ wake();
365
+ pullTo(x);
366
+ } else if (event.pointerType === "mouse" && !user.current) {
367
+ wake();
368
+ hoverTo(x);
369
+ }
370
+ }}
371
+ onPointerDown={(event) => {
372
+ const x = xAt(event.clientX);
373
+ if (x === null) return;
374
+ event.currentTarget.setPointerCapture(event.pointerId);
375
+ wake();
376
+ pressAt(x);
377
+ }}
378
+ onPointerUp={(event) => {
379
+ if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
380
+ if (user.current) release();
381
+ }}
382
+ onPointerCancel={() => {
383
+ if (user.current) release();
384
+ }}
385
+ onPointerLeave={(event) => {
386
+ if (event.pointerType !== "mouse" || user.current) return;
387
+ setHovering(false);
388
+ hoverTo(PROBE_HOME);
389
+ }}
390
+ onFocus={() => setFocused(true)}
391
+ onBlur={() => {
392
+ setFocused(false);
393
+ if (user.current) release();
394
+ }}
395
+ onKeyDown={(event) => {
396
+ const now = hand.current;
397
+ const by = event.shiftKey ? NUDGE * 3 : NUDGE;
398
+ if (event.key === " " || event.key === "Enter") {
399
+ event.preventDefault();
400
+ if (event.repeat) return;
401
+ wake();
402
+ if (user.current) release();
403
+ else pressAt(now.finger);
404
+ return;
405
+ }
406
+ let x: number | null = null;
407
+ if (event.key === "ArrowLeft" || event.key === "ArrowDown") x = now.finger - by;
408
+ else if (event.key === "ArrowRight" || event.key === "ArrowUp") x = now.finger + by;
409
+ else if (event.key === "Home") x = -Infinity;
410
+ else if (event.key === "End") x = Infinity;
411
+ else if (event.key === "Escape") {
412
+ if (user.current) release();
413
+ return;
414
+ }
415
+ if (x === null) return;
416
+ event.preventDefault();
417
+ wake();
418
+ if (user.current) pullTo(x);
419
+ else hoverTo(x);
420
+ }}
421
+ >
422
+ <IsoFigure width={WIDTH} height={HEIGHT} label={props.label}>
423
+ {props.base}
424
+ <g>
425
+ <g>
426
+ {props.rail}
427
+ <g ref={bind("rider")} transform={rest.rider}>
428
+ {props.rider}
429
+ </g>
430
+ <g ref={bind("probe")} transform={rest.probe}>
431
+ {props.block}
432
+ </g>
433
+ </g>
434
+ </g>
435
+ {props.frameBack}
436
+ <g>
437
+ <g>
438
+ <path ref={bind("coilsBack")} className="iso-line ms-coil" data-tone="lo" d={rest.coilsBack} />
439
+ {props.rods}
440
+ <path ref={bind("coilsLeft")} className="iso-line ms-coil" data-tone="mid" d={rest.coilsLeft} />
441
+ <g ref={bind("carriage")} transform={rest.carriage}>
442
+ {props.carriage}
443
+ </g>
444
+ </g>
445
+ </g>
446
+ <g>
447
+ <g ref={bind("glass")} className="iso-solid ms-glass">
448
+ <path ref={bind("fill")} className="iso-fill" d={shown.fill} />
449
+ {[0, 1, 2, 3].map((index) => (
450
+ <path
451
+ key={index}
452
+ ref={(node) => {
453
+ nodes.current.shades[index] = node;
454
+ }}
455
+ className="iso-shade"
456
+ data-shade={index}
457
+ d={shown.shades?.[index] ?? ""}
458
+ />
459
+ ))}
460
+ <path ref={bind("top")} className="iso-top" d={shown.top} />
461
+ <path ref={bind("crease")} className="iso-line" data-tone="lo" d={shown.crease} />
462
+ <path ref={bind("bevel")} className="iso-line iso-bevel" data-tone="lo" d={shown.bevel ?? ""} />
463
+ <path ref={bind("rim")} className="iso-line ms-rim" data-tone="mid" d={shown.rim} />
464
+ <path ref={bind("shine")} className="iso-line ms-shine" data-tone="hi" d={shown.shine} />
465
+ <path ref={bind("edge")} className="iso-line iso-edge" data-tone="hi" d={shown.outline} />
466
+ </g>
467
+ </g>
468
+ <g>
469
+ <g ref={bind("rest")} style={{ opacity: 0 }}>
470
+ {props.rest}
471
+ </g>
472
+ </g>
473
+ <g>
474
+ <g className="iso-wire ms-pull" data-tone="hi">
475
+ <path ref={bind("pull")} className="iso-line" data-tone="lit" d={rest.pull} />
476
+ <circle ref={bind("grab")} className="iso-wire-end" cx={rest.grab[0]} cy={rest.grab[1]} r={1.2} style={{ opacity: 0 }} />
477
+ <circle ref={bind("touch")} className="iso-wire-end" cx={rest.finger[0]} cy={rest.finger[1]} r={1.2} style={{ opacity: 0 }} />
478
+ </g>
479
+ </g>
480
+ <g>
481
+ <path ref={bind("coilsRightBack")} className="iso-line ms-coil" data-tone="mid" d={rest.coilsRightBack} />
482
+ </g>
483
+ <g>
484
+ <g ref={bind("plunger")} transform={rest.carriage}>
485
+ {props.plunger}
486
+ </g>
487
+ </g>
488
+ <g>
489
+ <path ref={bind("coilsRightFront")} className="iso-line ms-coil" data-tone="mid" d={rest.coilsRightFront} />
490
+ </g>
491
+ {props.frameFront}
492
+ <g>
493
+ <g>
494
+ {props.dial}
495
+ <path ref={bind("needle")} className="iso-line ms-needle" data-tone="hi" d={rest.needle} />
496
+ </g>
497
+ </g>
498
+ <g>
499
+ <g ref={bind("tip")} transform={rest.tip}>
500
+ {props.finger}
501
+ </g>
502
+ </g>
503
+ <g>
504
+ <g ref={bind("arm")} transform={rest.probe}>
505
+ {props.arm}
506
+ </g>
507
+ </g>
508
+ </IsoFigure>
509
+ </div>
510
+ );
511
+ }
@@ -0,0 +1,107 @@
1
+ import { insetPlan, onTop, pathOf, planOutline, slabOf, type Flat, type SolidPaths } from "../../kit/iso-kit";
2
+ import { carried, type Pose } from "./rig";
3
+ import { AZIMUTH, BUSH_END, COIL_R, COIL_TURNS, DIAL, DIAL_SPAN, DIAL_TOP, DIAL_R, FRAME_X, GLASS_H, GLASS_TOP, GLASS_Z, P, ROD_Y, ROD_Z, at, shift } from "./view";
4
+
5
+ export interface GlassPaths extends SolidPaths {
6
+ rim: string;
7
+ shine: string;
8
+ }
9
+
10
+ export interface Frame {
11
+ glass: GlassPaths;
12
+ coilsBack: string;
13
+ coilsLeft: string;
14
+ coilsRightBack: string;
15
+ coilsRightFront: string;
16
+ carriage: string;
17
+ needle: string;
18
+ grab: Flat;
19
+ finger: Flat;
20
+ pull: string;
21
+ rider: string;
22
+ probe: string;
23
+ tip: string;
24
+ }
25
+
26
+ const RADIANS = Math.PI / 180;
27
+ const SIN_A = Math.sin(AZIMUTH * RADIANS);
28
+ const COS_E = Math.cos(30 * RADIANS);
29
+ const SIN_E = Math.sin(30 * RADIANS);
30
+ const PER_TURN = 16;
31
+ const tenth = (value: number) => Math.round(value * 10) / 10;
32
+
33
+ export function glassOf(pose: Pose): GlassPaths {
34
+ const [hx, hy] = pose.half;
35
+ const plan = { x: pose.mid - hx, y: -hy, w: hx * 2, d: hy * 2, r: hy };
36
+ const paths = slabOf(plan, GLASS_Z, GLASS_H, P, 10, 1.2);
37
+ const inner = insetPlan(plan, 3.2);
38
+ const shine = [
39
+ onTop([[pose.mid - hx * 0.62, -hy + 5.4], [pose.mid + hx * 0.18, -hy + 5.4]], GLASS_TOP, P),
40
+ onTop([[pose.mid + hx * 0.3, -hy + 5.4], [pose.mid + hx * 0.42, -hy + 5.4]], GLASS_TOP, P),
41
+ ].join("");
42
+ return { ...paths, rim: planOutline(inner, GLASS_TOP, P, 10), shine };
43
+ }
44
+
45
+ function coil(from: number, to: number, y: number) {
46
+ const count = COIL_TURNS * PER_TURN;
47
+ const back: Flat[][] = [];
48
+ const front: Flat[][] = [];
49
+ let run: Flat[] = [];
50
+ let facing: boolean | null = null;
51
+ for (let index = 0; index <= count; index++) {
52
+ const share = index / count;
53
+ const angle = share * COIL_TURNS * Math.PI * 2;
54
+ const cy = Math.cos(angle);
55
+ const cz = Math.sin(angle);
56
+ const point = at([from + (to - from) * share, y + COIL_R * cy, ROD_Z + COIL_R * cz]);
57
+ const near = cy * SIN_A * COS_E + cz * SIN_E > 0;
58
+ if (facing === null) facing = near;
59
+ if (near !== facing) {
60
+ run.push(point);
61
+ (facing ? front : back).push(run);
62
+ run = [point];
63
+ facing = near;
64
+ continue;
65
+ }
66
+ run.push(point);
67
+ }
68
+ if (run.length > 1) (facing ? front : back).push(run);
69
+ return { back: back.map((points) => pathOf(points)).join(""), front: front.map((points) => pathOf(points)).join("") };
70
+ }
71
+
72
+ function needleOf(lean: number) {
73
+ const angle = (-lean / DIAL_SPAN) * Math.PI * 2;
74
+ const ux = Math.sin(angle);
75
+ const uy = -Math.cos(angle);
76
+ const [cx, cy] = DIAL;
77
+ const point = (along: number, across: number) => at([cx + ux * along - uy * across, cy + uy * along + ux * across, DIAL_TOP + 0.1]);
78
+ return pathOf([point(DIAL_R - 3.4, 0), point(0, 0.75), point(-3, 0.35), point(-3, -0.35), point(0, -0.75)], true);
79
+ }
80
+
81
+ export function frameOf(pose: Pose, lean: number, grab: number, finger: number, lift: number, home: number, held = false): Frame {
82
+ const left = ROD_Y;
83
+ const coils = [
84
+ coil(-FRAME_X, -BUSH_END + lean, -left),
85
+ coil(-FRAME_X, -BUSH_END + lean, left),
86
+ coil(BUSH_END + lean, FRAME_X, -left),
87
+ coil(BUSH_END + lean, FRAME_X, left),
88
+ ];
89
+ const grabX = carried(pose, grab);
90
+ const grabAt = at([grabX, 0, GLASS_TOP]);
91
+ const fingerAt = at([finger, 0, GLASS_TOP + lift]);
92
+ return {
93
+ glass: glassOf(pose),
94
+ coilsBack: coils.map((one) => one.back).join(""),
95
+ coilsLeft: coils[0]!.front + coils[1]!.front,
96
+ coilsRightBack: coils[2]!.front,
97
+ coilsRightFront: coils[3]!.front,
98
+ carriage: shift(lean),
99
+ needle: needleOf(lean),
100
+ grab: [tenth(grabAt[0]), tenth(grabAt[1])],
101
+ finger: [tenth(fingerAt[0]), tenth(fingerAt[1])],
102
+ pull: !held || Math.abs(grabX - finger) < 0.6 ? "" : pathOf([grabAt, fingerAt]),
103
+ rider: shift(grab),
104
+ probe: shift(finger - home),
105
+ tip: shift(finger - home, lift),
106
+ };
107
+ }