@brett_lamy/docstream 1.9.1 → 1.11.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 (84) hide show
  1. package/README.md +54 -0
  2. package/dist/docs/DocsRenderer.d.ts.map +1 -1
  3. package/dist/docs/DocsRenderer.js +13 -1
  4. package/dist/docs/DocsRenderer.js.map +1 -1
  5. package/dist/index.d.ts +5 -0
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +3 -0
  8. package/dist/index.js.map +1 -1
  9. package/dist/stl/StlViewer.d.ts +13 -0
  10. package/dist/stl/StlViewer.d.ts.map +1 -0
  11. package/dist/stl/StlViewer.js +277 -0
  12. package/dist/stl/StlViewer.js.map +1 -0
  13. package/dist/stl/index.d.ts +5 -0
  14. package/dist/stl/index.d.ts.map +1 -0
  15. package/dist/stl/index.js +3 -0
  16. package/dist/stl/index.js.map +1 -0
  17. package/dist/stl/parse.d.ts +13 -0
  18. package/dist/stl/parse.d.ts.map +1 -0
  19. package/dist/stl/parse.js +69 -0
  20. package/dist/stl/parse.js.map +1 -0
  21. package/dist/strudel/StepGrid.d.ts +10 -0
  22. package/dist/strudel/StepGrid.d.ts.map +1 -0
  23. package/dist/strudel/StepGrid.js +78 -0
  24. package/dist/strudel/StepGrid.js.map +1 -0
  25. package/dist/strudel/StrudelPlayer.d.ts +21 -0
  26. package/dist/strudel/StrudelPlayer.d.ts.map +1 -0
  27. package/dist/strudel/StrudelPlayer.js +207 -0
  28. package/dist/strudel/StrudelPlayer.js.map +1 -0
  29. package/dist/strudel/StrudelRack.d.ts +24 -0
  30. package/dist/strudel/StrudelRack.d.ts.map +1 -0
  31. package/dist/strudel/StrudelRack.js +123 -0
  32. package/dist/strudel/StrudelRack.js.map +1 -0
  33. package/dist/strudel/ast/edit.d.ts +41 -0
  34. package/dist/strudel/ast/edit.d.ts.map +1 -0
  35. package/dist/strudel/ast/edit.js +78 -0
  36. package/dist/strudel/ast/edit.js.map +1 -0
  37. package/dist/strudel/ast/index.d.ts +7 -0
  38. package/dist/strudel/ast/index.d.ts.map +1 -0
  39. package/dist/strudel/ast/index.js +4 -0
  40. package/dist/strudel/ast/index.js.map +1 -0
  41. package/dist/strudel/ast/mini.d.ts +86 -0
  42. package/dist/strudel/ast/mini.d.ts.map +1 -0
  43. package/dist/strudel/ast/mini.js +269 -0
  44. package/dist/strudel/ast/mini.js.map +1 -0
  45. package/dist/strudel/ast/program.d.ts +93 -0
  46. package/dist/strudel/ast/program.d.ts.map +1 -0
  47. package/dist/strudel/ast/program.js +288 -0
  48. package/dist/strudel/ast/program.js.map +1 -0
  49. package/dist/strudel/controls.d.ts +19 -0
  50. package/dist/strudel/controls.d.ts.map +1 -0
  51. package/dist/strudel/controls.js +109 -0
  52. package/dist/strudel/controls.js.map +1 -0
  53. package/dist/strudel/engine.d.ts +43 -0
  54. package/dist/strudel/engine.d.ts.map +1 -0
  55. package/dist/strudel/engine.js +79 -0
  56. package/dist/strudel/engine.js.map +1 -0
  57. package/dist/strudel/index.d.ts +13 -0
  58. package/dist/strudel/index.d.ts.map +1 -0
  59. package/dist/strudel/index.js +8 -0
  60. package/dist/strudel/index.js.map +1 -0
  61. package/dist/strudel/url.d.ts +5 -0
  62. package/dist/strudel/url.d.ts.map +1 -0
  63. package/dist/strudel/url.js +9 -0
  64. package/dist/strudel/url.js.map +1 -0
  65. package/dist/styles.css +564 -0
  66. package/package.json +15 -1
  67. package/src/docs/DocsRenderer.tsx +13 -1
  68. package/src/env.d.ts +3 -0
  69. package/src/index.ts +5 -0
  70. package/src/stl/StlViewer.tsx +334 -0
  71. package/src/stl/index.ts +4 -0
  72. package/src/stl/parse.ts +72 -0
  73. package/src/strudel/StepGrid.tsx +130 -0
  74. package/src/strudel/StrudelPlayer.tsx +317 -0
  75. package/src/strudel/StrudelRack.tsx +312 -0
  76. package/src/strudel/ast/edit.ts +95 -0
  77. package/src/strudel/ast/index.ts +19 -0
  78. package/src/strudel/ast/mini.ts +319 -0
  79. package/src/strudel/ast/program.ts +348 -0
  80. package/src/strudel/controls.tsx +145 -0
  81. package/src/strudel/engine.ts +112 -0
  82. package/src/strudel/index.ts +20 -0
  83. package/src/strudel/url.ts +9 -0
  84. package/src/styles.css +564 -0
@@ -0,0 +1,334 @@
1
+ import { useCallback, useEffect, useRef, useState, type KeyboardEvent, type PointerEvent } from "react"
2
+ import { parseStl, type StlMesh } from "./parse"
3
+
4
+ export interface StlViewerProps {
5
+ /** URL of a binary or ASCII `.stl` file. */
6
+ src: string
7
+ title?: string
8
+ className?: string
9
+ /** Model color; any CSS color. Defaults to the element's `color` (the theme's primary). */
10
+ color?: string
11
+ /** Turn slowly until the reader interacts. Ignored under prefers-reduced-motion. Defaults to true. */
12
+ autoRotate?: boolean
13
+ }
14
+
15
+ const VERTEX = `
16
+ attribute vec3 aPosition;
17
+ attribute vec3 aNormal;
18
+ uniform mat4 uModel;
19
+ uniform mat4 uProjection;
20
+ uniform float uDistance;
21
+ varying vec3 vNormal;
22
+ void main() {
23
+ vec4 p = uModel * vec4(aPosition, 1.0);
24
+ vNormal = mat3(uModel) * aNormal;
25
+ gl_Position = uProjection * vec4(p.xyz - vec3(0.0, 0.0, uDistance), 1.0);
26
+ }`
27
+
28
+ const FRAGMENT = `
29
+ precision mediump float;
30
+ uniform vec3 uColor;
31
+ varying vec3 vNormal;
32
+ void main() {
33
+ vec3 n = normalize(vNormal);
34
+ if (!gl_FrontFacing) n = -n;
35
+ float key = max(dot(n, normalize(vec3(0.45, 0.65, 0.6))), 0.0);
36
+ float fill = max(dot(n, normalize(vec3(-0.6, -0.2, 0.4))), 0.0);
37
+ float sky = 0.5 + 0.5 * n.y;
38
+ vec3 c = uColor * (0.28 + 0.14 * sky + 0.62 * key + 0.18 * fill);
39
+ float spec = pow(max(dot(n, normalize(vec3(0.45, 0.65, 0.6) + vec3(0.0, 0.0, 1.0))), 0.0), 48.0);
40
+ gl_FragColor = vec4(c + vec3(0.12 * spec), 1.0);
41
+ }`
42
+
43
+ const FOV = (35 * Math.PI) / 180
44
+ const HOME = { yaw: -0.6, pitch: 0.45, zoom: 1 }
45
+
46
+ type View = typeof HOME
47
+
48
+ // Column-major 4×4: rotate (pitch about X, then yaw about Y) a model that was centered,
49
+ // scaled to a unit sphere and turned Z-up → Y-up (the STL convention → the screen's).
50
+ function modelMatrix(mesh: StlMesh, { yaw, pitch }: View): Float32Array {
51
+ const c = mesh.min.map((v, i) => (v + mesh.max[i]!) / 2)
52
+ const r = Math.hypot(...mesh.max.map((v, i) => (v - mesh.min[i]!) / 2)) || 1
53
+ const s = 1 / r
54
+ const cy = Math.cos(yaw), sy = Math.sin(yaw), cp = Math.cos(pitch), sp = Math.sin(pitch)
55
+ // R = Rx(pitch) · Ry(yaw) · Rx(-90°); rows of R written as columns below.
56
+ // Rx(-90°) maps (x, y, z) → (x, z, -y).
57
+ const ry = [
58
+ [cy, 0, sy],
59
+ [0, 1, 0],
60
+ [-sy, 0, cy],
61
+ ]
62
+ const rx = [
63
+ [1, 0, 0],
64
+ [0, cp, -sp],
65
+ [0, sp, cp],
66
+ ]
67
+ const up = [
68
+ [1, 0, 0],
69
+ [0, 0, 1],
70
+ [0, -1, 0],
71
+ ]
72
+ const mul = (a: number[][], b: number[][]) =>
73
+ a.map((row) => [0, 1, 2].map((j) => row[0]! * b[0]![j]! + row[1]! * b[1]![j]! + row[2]! * b[2]![j]!))
74
+ const R = mul(mul(rx, ry), up)
75
+ const m = new Float32Array(16)
76
+ for (let col = 0; col < 3; col++) for (let row = 0; row < 3; row++) m[col * 4 + row] = R[row]![col]! * s
77
+ for (let row = 0; row < 3; row++) m[12 + row] = -s * (R[row]![0]! * c[0]! + R[row]![1]! * c[1]! + R[row]![2]! * c[2]!)
78
+ m[15] = 1
79
+ return m
80
+ }
81
+
82
+ function perspective(aspect: number): Float32Array {
83
+ const f = 1 / Math.tan(FOV / 2), near = 0.05, far = 100
84
+ const m = new Float32Array(16)
85
+ m[0] = f / aspect
86
+ m[5] = f
87
+ m[10] = (far + near) / (near - far)
88
+ m[11] = -1
89
+ m[14] = (2 * far * near) / (near - far)
90
+ return m
91
+ }
92
+
93
+ // Resolve any CSS color (oklch, var(), named…) to linear-ish RGB floats via a 1×1 canvas.
94
+ function cssColor(color: string): [number, number, number] {
95
+ const ctx = document.createElement("canvas").getContext("2d")
96
+ if (!ctx) return [0.4, 0.45, 0.95]
97
+ ctx.fillStyle = "#6670f2"
98
+ ctx.fillStyle = color
99
+ ctx.fillRect(0, 0, 1, 1)
100
+ const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data
101
+ return [r! / 255, g! / 255, b! / 255]
102
+ }
103
+
104
+ function compile(gl: WebGLRenderingContext, type: number, source: string): WebGLShader {
105
+ const shader = gl.createShader(type)!
106
+ gl.shaderSource(shader, source)
107
+ gl.compileShader(shader)
108
+ if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(shader) ?? "shader")
109
+ return shader
110
+ }
111
+
112
+ function formatSize(mesh: StlMesh): string {
113
+ const d = mesh.max.map((v, i) => v - mesh.min[i]!)
114
+ const fmt = (n: number) => (n >= 100 ? n.toFixed(0) : n >= 10 ? n.toFixed(1) : n.toFixed(2))
115
+ return d.map(fmt).join(" × ")
116
+ }
117
+
118
+ type Status = { kind: "loading" } | { kind: "error"; message: string } | { kind: "ready"; mesh: StlMesh }
119
+
120
+ /** Interactive WebGL preview of an `.stl` model: drag to orbit, pinch / ⌘-scroll or +/− to zoom, double-click to reset. */
121
+ export function StlViewer({ src, title, className, color, autoRotate = true }: StlViewerProps) {
122
+ const canvasRef = useRef<HTMLCanvasElement>(null)
123
+ const [status, setStatus] = useState<Status>({ kind: "loading" })
124
+ const view = useRef<View>({ ...HOME })
125
+ const spinning = useRef(autoRotate)
126
+ const draw = useRef<() => void>(() => {})
127
+ const drag = useRef<{ x: number; y: number; id: number } | null>(null)
128
+
129
+ useEffect(() => {
130
+ let live = true
131
+ setStatus({ kind: "loading" })
132
+ fetch(src)
133
+ .then((res) => {
134
+ if (!res.ok) throw new Error(`${res.status} ${res.statusText}`.trim())
135
+ return res.arrayBuffer()
136
+ })
137
+ .then((buf) => live && setStatus({ kind: "ready", mesh: parseStl(buf) }))
138
+ .catch((err: unknown) => live && setStatus({ kind: "error", message: err instanceof Error ? err.message : String(err) }))
139
+ return () => {
140
+ live = false
141
+ }
142
+ }, [src])
143
+
144
+ const mesh = status.kind === "ready" ? status.mesh : null
145
+
146
+ useEffect(() => {
147
+ const canvas = canvasRef.current
148
+ if (!canvas || !mesh) return
149
+ const gl = canvas.getContext("webgl", { antialias: true, alpha: true, premultipliedAlpha: true })
150
+ if (!gl) {
151
+ setStatus({ kind: "error", message: "WebGL is unavailable" })
152
+ return
153
+ }
154
+ const program = gl.createProgram()!
155
+ gl.attachShader(program, compile(gl, gl.VERTEX_SHADER, VERTEX))
156
+ gl.attachShader(program, compile(gl, gl.FRAGMENT_SHADER, FRAGMENT))
157
+ gl.linkProgram(program)
158
+ gl.useProgram(program)
159
+ const buffer = (data: Float32Array, name: string) => {
160
+ const b = gl.createBuffer()
161
+ gl.bindBuffer(gl.ARRAY_BUFFER, b)
162
+ gl.bufferData(gl.ARRAY_BUFFER, data, gl.STATIC_DRAW)
163
+ const loc = gl.getAttribLocation(program, name)
164
+ gl.enableVertexAttribArray(loc)
165
+ gl.vertexAttribPointer(loc, 3, gl.FLOAT, false, 0, 0)
166
+ return b
167
+ }
168
+ const buffers = [buffer(mesh.positions, "aPosition"), buffer(mesh.normals, "aNormal")]
169
+ const uModel = gl.getUniformLocation(program, "uModel")
170
+ const uProjection = gl.getUniformLocation(program, "uProjection")
171
+ const uDistance = gl.getUniformLocation(program, "uDistance")
172
+ const uColor = gl.getUniformLocation(program, "uColor")
173
+ gl.enable(gl.DEPTH_TEST)
174
+
175
+ const resolveColor = () => cssColor(color ?? getComputedStyle(canvas).color)
176
+ let rgb = resolveColor()
177
+ let frame = 0
178
+ const render = () => {
179
+ frame = 0
180
+ const dpr = Math.min(window.devicePixelRatio || 1, 2)
181
+ const w = Math.max(1, Math.round(canvas.clientWidth * dpr))
182
+ const h = Math.max(1, Math.round(canvas.clientHeight * dpr))
183
+ if (canvas.width !== w || canvas.height !== h) {
184
+ canvas.width = w
185
+ canvas.height = h
186
+ }
187
+ gl.viewport(0, 0, w, h)
188
+ gl.clearColor(0, 0, 0, 0)
189
+ gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT)
190
+ gl.uniformMatrix4fv(uModel, false, modelMatrix(mesh, view.current))
191
+ gl.uniformMatrix4fv(uProjection, false, perspective(w / h))
192
+ // Fit the unit sphere with a little margin, then apply zoom.
193
+ gl.uniform1f(uDistance, (1.12 / Math.sin(FOV / 2)) / view.current.zoom)
194
+ gl.uniform3fv(uColor, rgb)
195
+ gl.drawArrays(gl.TRIANGLES, 0, mesh.triangles * 3)
196
+ if (spinning.current) {
197
+ view.current.yaw += 0.006
198
+ frame = requestAnimationFrame(render)
199
+ }
200
+ }
201
+ draw.current = () => {
202
+ if (!frame) frame = requestAnimationFrame(render)
203
+ }
204
+ if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) spinning.current = false
205
+ draw.current()
206
+ const resize = new ResizeObserver(() => draw.current())
207
+ resize.observe(canvas)
208
+ // Theme switches change `color`; redraw so the model follows.
209
+ const theme = new MutationObserver(() => {
210
+ rgb = resolveColor()
211
+ draw.current()
212
+ })
213
+ theme.observe(document.documentElement, { attributes: true, attributeFilter: ["class", "data-theme", "style"] })
214
+ return () => {
215
+ cancelAnimationFrame(frame)
216
+ resize.disconnect()
217
+ theme.disconnect()
218
+ draw.current = () => {}
219
+ buffers.forEach((b) => gl.deleteBuffer(b))
220
+ gl.deleteProgram(program)
221
+ }
222
+ }, [mesh, color])
223
+
224
+ const stop = () => {
225
+ spinning.current = false
226
+ }
227
+ const update = useCallback((next: Partial<View>) => {
228
+ const v = view.current
229
+ Object.assign(v, next)
230
+ v.pitch = Math.max(-1.5, Math.min(1.5, v.pitch))
231
+ v.zoom = Math.max(0.3, Math.min(8, v.zoom))
232
+ draw.current()
233
+ }, [])
234
+ const reset = () => {
235
+ stop()
236
+ update({ ...HOME })
237
+ }
238
+
239
+ const onPointerDown = (e: PointerEvent<HTMLCanvasElement>) => {
240
+ stop()
241
+ e.currentTarget.setPointerCapture(e.pointerId)
242
+ drag.current = { x: e.clientX, y: e.clientY, id: e.pointerId }
243
+ }
244
+ const onPointerMove = (e: PointerEvent<HTMLCanvasElement>) => {
245
+ const d = drag.current
246
+ if (!d || d.id !== e.pointerId) return
247
+ const v = view.current
248
+ update({ yaw: v.yaw + (e.clientX - d.x) * 0.01, pitch: v.pitch + (e.clientY - d.y) * 0.01 })
249
+ d.x = e.clientX
250
+ d.y = e.clientY
251
+ }
252
+ const onPointerUp = () => {
253
+ drag.current = null
254
+ }
255
+ // Plain scrolling keeps scrolling the page; pinch (ctrlKey) and ⌘/Ctrl-scroll zoom.
256
+ const onWheel = (e: WheelEvent) => {
257
+ if (!e.ctrlKey && !e.metaKey) return
258
+ e.preventDefault()
259
+ stop()
260
+ update({ zoom: view.current.zoom * Math.exp(-e.deltaY * 0.01) })
261
+ }
262
+ const onKeyDown = (e: KeyboardEvent<HTMLCanvasElement>) => {
263
+ const v = view.current
264
+ const step = 0.15
265
+ const next: Record<string, Partial<View>> = {
266
+ ArrowLeft: { yaw: v.yaw - step },
267
+ ArrowRight: { yaw: v.yaw + step },
268
+ ArrowUp: { pitch: v.pitch - step },
269
+ ArrowDown: { pitch: v.pitch + step },
270
+ "+": { zoom: v.zoom * 1.2 },
271
+ "=": { zoom: v.zoom * 1.2 },
272
+ "-": { zoom: v.zoom / 1.2 },
273
+ "0": { ...HOME },
274
+ }
275
+ const change = next[e.key]
276
+ if (!change) return
277
+ e.preventDefault()
278
+ stop()
279
+ update(change)
280
+ }
281
+
282
+ // React attaches wheel listeners passively; zoom needs preventDefault, so bind natively.
283
+ const wheelRef = useRef(onWheel)
284
+ wheelRef.current = onWheel
285
+ useEffect(() => {
286
+ const canvas = canvasRef.current
287
+ if (!canvas) return
288
+ const listener = (e: WheelEvent) => wheelRef.current(e)
289
+ canvas.addEventListener("wheel", listener, { passive: false })
290
+ return () => canvas.removeEventListener("wheel", listener)
291
+ }, [mesh])
292
+
293
+ const label = title ?? src.split(/[?#]/)[0]!.split("/").pop() ?? "STL model"
294
+ return (
295
+ <figure className={`docstream-stl ${className ?? ""}`.trim()} data-state={status.kind}>
296
+ <div className="docstream-stl__stage">
297
+ {mesh ? (
298
+ <canvas
299
+ ref={canvasRef}
300
+ className="docstream-stl__canvas"
301
+ tabIndex={0}
302
+ role="img"
303
+ aria-label={`${label}: 3D model. Drag or use arrow keys to rotate, plus and minus to zoom, 0 to reset.`}
304
+ onPointerDown={onPointerDown}
305
+ onPointerMove={onPointerMove}
306
+ onPointerUp={onPointerUp}
307
+ onPointerCancel={onPointerUp}
308
+ onDoubleClick={reset}
309
+ onKeyDown={onKeyDown}
310
+ />
311
+ ) : (
312
+ <div className="docstream-stl__status" role={status.kind === "error" ? "alert" : "status"}>
313
+ {status.kind === "error" ? `Couldn't load ${label}: ${status.message}` : `Loading ${label}…`}
314
+ </div>
315
+ )}
316
+ {mesh && (
317
+ <button type="button" className="docstream-stl__reset" onClick={reset} aria-label="Reset view">
318
+ Reset
319
+ </button>
320
+ )}
321
+ </div>
322
+ <figcaption className="docstream-stl__caption">
323
+ <a href={src} target="_blank" rel="noreferrer" download>
324
+ {label}
325
+ </a>
326
+ {mesh && (
327
+ <span>
328
+ {mesh.triangles.toLocaleString()} triangles · {formatSize(mesh)}
329
+ </span>
330
+ )}
331
+ </figcaption>
332
+ </figure>
333
+ )
334
+ }
@@ -0,0 +1,4 @@
1
+ export { StlViewer } from "./StlViewer"
2
+ export type { StlViewerProps } from "./StlViewer"
3
+ export { isStlUrl, parseStl } from "./parse"
4
+ export type { StlMesh } from "./parse"
@@ -0,0 +1,72 @@
1
+ /** A triangle soup ready for `drawArrays`: three vertices (and three copies of the face normal) per triangle. */
2
+ export interface StlMesh {
3
+ positions: Float32Array
4
+ normals: Float32Array
5
+ triangles: number
6
+ min: [number, number, number]
7
+ max: [number, number, number]
8
+ }
9
+
10
+ /** True for a URL whose path ends in `.stl` (query string and hash ignored). */
11
+ export function isStlUrl(url: string): boolean {
12
+ return /\.stl(?:[?#]|$)/i.test(url.trim())
13
+ }
14
+
15
+ // A binary STL is an 80-byte header, a uint32 triangle count, then 50 bytes per triangle. ASCII
16
+ // files start with "solid", but so do some binary headers, so the byte length decides.
17
+ function isBinary(view: DataView): boolean {
18
+ if (view.byteLength < 84) return false
19
+ return 84 + view.getUint32(80, true) * 50 === view.byteLength
20
+ }
21
+
22
+ function faceNormal(p: Float32Array, o: number, out: Float32Array) {
23
+ const ux = p[o + 3]! - p[o]!, uy = p[o + 4]! - p[o + 1]!, uz = p[o + 5]! - p[o + 2]!
24
+ const vx = p[o + 6]! - p[o]!, vy = p[o + 7]! - p[o + 1]!, vz = p[o + 8]! - p[o + 2]!
25
+ let nx = uy * vz - uz * vy, ny = uz * vx - ux * vz, nz = ux * vy - uy * vx
26
+ const len = Math.hypot(nx, ny, nz) || 1
27
+ nx /= len, ny /= len, nz /= len
28
+ for (let k = 0; k < 3; k++) {
29
+ out[o + k * 3] = nx
30
+ out[o + k * 3 + 1] = ny
31
+ out[o + k * 3 + 2] = nz
32
+ }
33
+ }
34
+
35
+ function finish(positions: Float32Array, triangles: number): StlMesh {
36
+ // Stored normals are often zero or wrong; the winding is what slicers trust, so recompute.
37
+ const normals = new Float32Array(positions.length)
38
+ for (let t = 0; t < triangles; t++) faceNormal(positions, t * 9, normals)
39
+ const min: [number, number, number] = [Infinity, Infinity, Infinity]
40
+ const max: [number, number, number] = [-Infinity, -Infinity, -Infinity]
41
+ for (let i = 0; i < positions.length; i += 3) {
42
+ for (let k = 0; k < 3; k++) {
43
+ const v = positions[i + k]!
44
+ if (v < min[k]!) min[k] = v
45
+ if (v > max[k]!) max[k] = v
46
+ }
47
+ }
48
+ if (!triangles) min.fill(0), max.fill(0)
49
+ return { positions, normals, triangles, min, max }
50
+ }
51
+
52
+ /** Parse a binary or ASCII STL file. Throws when the data is neither. */
53
+ export function parseStl(data: ArrayBuffer): StlMesh {
54
+ const view = new DataView(data)
55
+ if (isBinary(view)) {
56
+ const triangles = view.getUint32(80, true)
57
+ const positions = new Float32Array(triangles * 9)
58
+ for (let t = 0; t < triangles; t++) {
59
+ const base = 84 + t * 50 + 12 // skip the stored normal
60
+ for (let k = 0; k < 9; k++) positions[t * 9 + k] = view.getFloat32(base + k * 4, true)
61
+ }
62
+ return finish(positions, triangles)
63
+ }
64
+ const text = new TextDecoder().decode(data)
65
+ if (!/^\s*solid\b/i.test(text)) throw new Error("Not an STL file")
66
+ const values: number[] = []
67
+ const vertex = /vertex\s+(\S+)\s+(\S+)\s+(\S+)/gi
68
+ for (let m = vertex.exec(text); m; m = vertex.exec(text)) values.push(+m[1]!, +m[2]!, +m[3]!)
69
+ const triangles = Math.floor(values.length / 9)
70
+ if (!triangles && /facet/i.test(text)) throw new Error("Malformed ASCII STL")
71
+ return finish(Float32Array.from(values.slice(0, triangles * 9)), triangles)
72
+ }
@@ -0,0 +1,130 @@
1
+ /* Mini-notation drawn as a step sequencer: sequences are rows whose steps share the width by weight, `[ ]`
2
+ nests a row inside a step, `< >` shows its one-per-cycle choices, `,` stacks rows. Click a step to toggle
3
+ it with a rest; double-click to type a new value. Operators show as badges (×2, (3,8), ?, !3). */
4
+ import { useState, type ReactNode } from "react"
5
+ import { miniAtoms, type MiniAtom, type MiniGroup, type MiniOp, type MiniSequence, type MiniStep } from "./ast/mini"
6
+
7
+ export interface StepGridProps {
8
+ mini: MiniGroup
9
+ /** Called with the atom and its new text. */
10
+ onAtomChange: (atom: MiniAtom, text: string) => void
11
+ /** Accessible name for the grid. */
12
+ label?: string
13
+ }
14
+
15
+ function stepWeight(step: MiniStep): number {
16
+ let w = 1
17
+ for (const op of step.ops) {
18
+ if (op.op === "weight") w += (op.amount ?? 2) - 1
19
+ if (op.op === "replicate") w += (op.count ?? 2) - 1
20
+ }
21
+ return w
22
+ }
23
+
24
+ const text = (s: MiniStep | null): string => (s && s.slice.type === "atom" ? s.slice.value : "…")
25
+
26
+ function badge(op: MiniOp): string | null {
27
+ switch (op.op) {
28
+ case "fast":
29
+ return `×${text(op.amount)}`
30
+ case "slow":
31
+ return `÷${text(op.amount)}`
32
+ case "replicate":
33
+ return `!${op.count ?? 2}`
34
+ case "degrade":
35
+ return op.amount === null ? "?" : `?${op.amount}`
36
+ case "euclid":
37
+ return `(${text(op.pulses)},${text(op.steps)}${op.rotation ? `,${text(op.rotation)}` : ""})`
38
+ case "tail":
39
+ return `:${text(op.value)}`
40
+ case "range":
41
+ return `..${text(op.to)}`
42
+ case "weight":
43
+ return null
44
+ }
45
+ }
46
+
47
+ function AtomCell({ atom, fallback, onAtomChange }: { atom: MiniAtom; fallback: string; onAtomChange: StepGridProps["onAtomChange"] }) {
48
+ const [editing, setEditing] = useState(false)
49
+ if (editing) {
50
+ return (
51
+ <input
52
+ className="strudel-step__input"
53
+ defaultValue={atom.value}
54
+ aria-label="Step value"
55
+ autoFocus
56
+ onBlur={(e) => {
57
+ setEditing(false)
58
+ const v = e.currentTarget.value.trim()
59
+ if (v && v !== atom.value && /^[\p{L}\p{Nl}0-9~\-#.^_]+$/u.test(v)) onAtomChange(atom, v)
60
+ }}
61
+ onKeyDown={(e) => {
62
+ if (e.key === "Enter") e.currentTarget.blur()
63
+ if (e.key === "Escape") setEditing(false)
64
+ }}
65
+ />
66
+ )
67
+ }
68
+ return (
69
+ <button
70
+ type="button"
71
+ className="strudel-step__cell"
72
+ data-rest={atom.rest || undefined}
73
+ aria-pressed={!atom.rest}
74
+ aria-label={atom.rest ? "Rest" : atom.value}
75
+ title={atom.rest ? "Rest — click to fill, double-click to type" : `${atom.value} — click to rest, double-click to type`}
76
+ onClick={() => onAtomChange(atom, atom.rest ? fallback : "~")}
77
+ onDoubleClick={() => setEditing(true)}
78
+ >
79
+ {atom.rest ? "" : atom.value}
80
+ </button>
81
+ )
82
+ }
83
+
84
+ export function StepGrid({ mini, onAtomChange, label }: StepGridProps) {
85
+ // A rest that's filled back in becomes the row's most common sound/value.
86
+ const counts = new Map<string, number>()
87
+ for (const a of miniAtoms(mini, { ops: false })) if (!a.rest) counts.set(a.value, (counts.get(a.value) ?? 0) + 1)
88
+ const fallback = [...counts.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? "x"
89
+
90
+ const row = (seq: MiniSequence, key: number | string): ReactNode => (
91
+ <div key={key} className="strudel-step__row">
92
+ {seq.steps.map((s, i) => step(s, i))}
93
+ </div>
94
+ )
95
+
96
+ const group = (g: MiniGroup): ReactNode =>
97
+ g.type === "sequence" ? (
98
+ row(g, 0)
99
+ ) : (
100
+ <div className="strudel-step__layers" data-kind={g.type}>
101
+ {g.layers.map((l, i) => row(l, i))}
102
+ </div>
103
+ )
104
+
105
+ const step = (s: MiniStep, key: number): ReactNode => {
106
+ const badges = s.ops.map(badge).filter((b): b is string => !!b)
107
+ const slice = s.slice
108
+ let body: ReactNode
109
+ if (slice.type === "atom") body = <AtomCell atom={slice} fallback={fallback} onAtomChange={onAtomChange} />
110
+ else if (slice.type === "subcycle") body = <div className="strudel-step__nest">{group(slice.body)}</div>
111
+ else
112
+ body = (
113
+ <div className="strudel-step__nest" data-kind={slice.type} title={slice.type === "alternation" ? "One per cycle" : "Polymeter"}>
114
+ <div className="strudel-step__layers">{slice.layers.map((l, i) => row(l, i))}</div>
115
+ </div>
116
+ )
117
+ return (
118
+ <div key={key} className="strudel-step" style={{ flexGrow: stepWeight(s) }}>
119
+ {body}
120
+ {badges.length > 0 && <span className="strudel-step__badge">{badges.join(" ")}</span>}
121
+ </div>
122
+ )
123
+ }
124
+
125
+ return (
126
+ <div className="strudel-steps" role="group" aria-label={label ?? "Steps"}>
127
+ {group(mini)}
128
+ </div>
129
+ )
130
+ }