@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.
- package/README.md +54 -0
- package/dist/docs/DocsRenderer.d.ts.map +1 -1
- package/dist/docs/DocsRenderer.js +13 -1
- package/dist/docs/DocsRenderer.js.map +1 -1
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/stl/StlViewer.d.ts +13 -0
- package/dist/stl/StlViewer.d.ts.map +1 -0
- package/dist/stl/StlViewer.js +277 -0
- package/dist/stl/StlViewer.js.map +1 -0
- package/dist/stl/index.d.ts +5 -0
- package/dist/stl/index.d.ts.map +1 -0
- package/dist/stl/index.js +3 -0
- package/dist/stl/index.js.map +1 -0
- package/dist/stl/parse.d.ts +13 -0
- package/dist/stl/parse.d.ts.map +1 -0
- package/dist/stl/parse.js +69 -0
- package/dist/stl/parse.js.map +1 -0
- package/dist/strudel/StepGrid.d.ts +10 -0
- package/dist/strudel/StepGrid.d.ts.map +1 -0
- package/dist/strudel/StepGrid.js +78 -0
- package/dist/strudel/StepGrid.js.map +1 -0
- package/dist/strudel/StrudelPlayer.d.ts +21 -0
- package/dist/strudel/StrudelPlayer.d.ts.map +1 -0
- package/dist/strudel/StrudelPlayer.js +207 -0
- package/dist/strudel/StrudelPlayer.js.map +1 -0
- package/dist/strudel/StrudelRack.d.ts +24 -0
- package/dist/strudel/StrudelRack.d.ts.map +1 -0
- package/dist/strudel/StrudelRack.js +123 -0
- package/dist/strudel/StrudelRack.js.map +1 -0
- package/dist/strudel/ast/edit.d.ts +41 -0
- package/dist/strudel/ast/edit.d.ts.map +1 -0
- package/dist/strudel/ast/edit.js +78 -0
- package/dist/strudel/ast/edit.js.map +1 -0
- package/dist/strudel/ast/index.d.ts +7 -0
- package/dist/strudel/ast/index.d.ts.map +1 -0
- package/dist/strudel/ast/index.js +4 -0
- package/dist/strudel/ast/index.js.map +1 -0
- package/dist/strudel/ast/mini.d.ts +86 -0
- package/dist/strudel/ast/mini.d.ts.map +1 -0
- package/dist/strudel/ast/mini.js +269 -0
- package/dist/strudel/ast/mini.js.map +1 -0
- package/dist/strudel/ast/program.d.ts +93 -0
- package/dist/strudel/ast/program.d.ts.map +1 -0
- package/dist/strudel/ast/program.js +288 -0
- package/dist/strudel/ast/program.js.map +1 -0
- package/dist/strudel/controls.d.ts +19 -0
- package/dist/strudel/controls.d.ts.map +1 -0
- package/dist/strudel/controls.js +109 -0
- package/dist/strudel/controls.js.map +1 -0
- package/dist/strudel/engine.d.ts +43 -0
- package/dist/strudel/engine.d.ts.map +1 -0
- package/dist/strudel/engine.js +79 -0
- package/dist/strudel/engine.js.map +1 -0
- package/dist/strudel/index.d.ts +13 -0
- package/dist/strudel/index.d.ts.map +1 -0
- package/dist/strudel/index.js +8 -0
- package/dist/strudel/index.js.map +1 -0
- package/dist/strudel/url.d.ts +5 -0
- package/dist/strudel/url.d.ts.map +1 -0
- package/dist/strudel/url.js +9 -0
- package/dist/strudel/url.js.map +1 -0
- package/dist/styles.css +564 -0
- package/package.json +15 -1
- package/src/docs/DocsRenderer.tsx +13 -1
- package/src/env.d.ts +3 -0
- package/src/index.ts +5 -0
- package/src/stl/StlViewer.tsx +334 -0
- package/src/stl/index.ts +4 -0
- package/src/stl/parse.ts +72 -0
- package/src/strudel/StepGrid.tsx +130 -0
- package/src/strudel/StrudelPlayer.tsx +317 -0
- package/src/strudel/StrudelRack.tsx +312 -0
- package/src/strudel/ast/edit.ts +95 -0
- package/src/strudel/ast/index.ts +19 -0
- package/src/strudel/ast/mini.ts +319 -0
- package/src/strudel/ast/program.ts +348 -0
- package/src/strudel/controls.tsx +145 -0
- package/src/strudel/engine.ts +112 -0
- package/src/strudel/index.ts +20 -0
- package/src/strudel/url.ts +9 -0
- 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
|
+
}
|
package/src/stl/index.ts
ADDED
package/src/stl/parse.ts
ADDED
|
@@ -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
|
+
}
|