@brett_lamy/docstream 1.9.0 → 1.10.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 (53) hide show
  1. package/README.md +38 -0
  2. package/dist/docs/DocsRenderer.d.ts.map +1 -1
  3. package/dist/docs/DocsRenderer.js +12 -1
  4. package/dist/docs/DocsRenderer.js.map +1 -1
  5. package/dist/index.d.ts +4 -0
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +2 -0
  8. package/dist/index.js.map +1 -1
  9. package/dist/playground/ReactCodePreview.d.ts.map +1 -1
  10. package/dist/playground/ReactCodePreview.js +7 -0
  11. package/dist/playground/ReactCodePreview.js.map +1 -1
  12. package/dist/stl/StlViewer.d.ts +13 -0
  13. package/dist/stl/StlViewer.d.ts.map +1 -0
  14. package/dist/stl/StlViewer.js +277 -0
  15. package/dist/stl/StlViewer.js.map +1 -0
  16. package/dist/stl/index.d.ts +5 -0
  17. package/dist/stl/index.d.ts.map +1 -0
  18. package/dist/stl/index.js +3 -0
  19. package/dist/stl/index.js.map +1 -0
  20. package/dist/stl/parse.d.ts +13 -0
  21. package/dist/stl/parse.d.ts.map +1 -0
  22. package/dist/stl/parse.js +69 -0
  23. package/dist/stl/parse.js.map +1 -0
  24. package/dist/strudel/StrudelPlayer.d.ts +15 -0
  25. package/dist/strudel/StrudelPlayer.d.ts.map +1 -0
  26. package/dist/strudel/StrudelPlayer.js +178 -0
  27. package/dist/strudel/StrudelPlayer.js.map +1 -0
  28. package/dist/strudel/engine.d.ts +38 -0
  29. package/dist/strudel/engine.d.ts.map +1 -0
  30. package/dist/strudel/engine.js +65 -0
  31. package/dist/strudel/engine.js.map +1 -0
  32. package/dist/strudel/index.d.ts +5 -0
  33. package/dist/strudel/index.d.ts.map +1 -0
  34. package/dist/strudel/index.js +4 -0
  35. package/dist/strudel/index.js.map +1 -0
  36. package/dist/strudel/url.d.ts +5 -0
  37. package/dist/strudel/url.d.ts.map +1 -0
  38. package/dist/strudel/url.js +9 -0
  39. package/dist/strudel/url.js.map +1 -0
  40. package/dist/styles.css +194 -0
  41. package/package.json +14 -1
  42. package/src/docs/DocsRenderer.tsx +12 -1
  43. package/src/env.d.ts +3 -0
  44. package/src/index.ts +4 -0
  45. package/src/playground/ReactCodePreview.tsx +8 -0
  46. package/src/stl/StlViewer.tsx +334 -0
  47. package/src/stl/index.ts +4 -0
  48. package/src/stl/parse.ts +72 -0
  49. package/src/strudel/StrudelPlayer.tsx +246 -0
  50. package/src/strudel/engine.ts +98 -0
  51. package/src/strudel/index.ts +4 -0
  52. package/src/strudel/url.ts +9 -0
  53. package/src/styles.css +194 -0
package/dist/styles.css CHANGED
@@ -450,6 +450,200 @@
450
450
  border-radius: 12px;
451
451
  }
452
452
 
453
+ /* StlViewer: a WebGL preview of an .stl model ({% embed url="part.stl" %}). The model takes
454
+ its color from `color`, so it follows the theme's primary. */
455
+ .docstream-stl,
456
+ .docstream-strudel {
457
+ margin: 0;
458
+ display: flex;
459
+ flex-direction: column;
460
+ gap: 6px;
461
+ }
462
+
463
+ .docstream-stl__stage {
464
+ position: relative;
465
+ aspect-ratio: 16 / 9;
466
+ border: 1px solid var(--gb-border);
467
+ border-radius: 12px;
468
+ overflow: hidden;
469
+ color: var(--gb-primary, #6366f1);
470
+ background:
471
+ radial-gradient(circle at 50% 40%, color-mix(in srgb, var(--gb-muted, #f4f4f5) 40%, transparent), transparent 70%),
472
+ var(--gb-muted, #f4f4f5);
473
+ }
474
+
475
+ .docstream-stl__canvas {
476
+ display: block;
477
+ width: 100%;
478
+ height: 100%;
479
+ cursor: grab;
480
+ touch-action: none;
481
+ }
482
+
483
+ .docstream-stl__canvas:active {
484
+ cursor: grabbing;
485
+ }
486
+
487
+ .docstream-stl__canvas:focus-visible {
488
+ outline: 2px solid var(--gb-primary, #6366f1);
489
+ outline-offset: -2px;
490
+ border-radius: 12px;
491
+ }
492
+
493
+ .docstream-stl__status {
494
+ display: grid;
495
+ place-items: center;
496
+ height: 100%;
497
+ padding: 16px;
498
+ text-align: center;
499
+ font-size: 13px;
500
+ color: var(--gb-muted-foreground);
501
+ }
502
+
503
+ .docstream-stl__reset {
504
+ position: absolute;
505
+ top: 8px;
506
+ right: 8px;
507
+ padding: 3px 10px;
508
+ border: 1px solid var(--gb-border);
509
+ border-radius: 999px;
510
+ background: var(--gb-panel, #fff);
511
+ color: var(--gb-panel-foreground, inherit);
512
+ font: inherit;
513
+ font-size: 12px;
514
+ cursor: pointer;
515
+ opacity: 0.85;
516
+ }
517
+
518
+ .docstream-stl__reset:hover {
519
+ opacity: 1;
520
+ background: var(--gb-hover, var(--gb-accent));
521
+ }
522
+
523
+ .docstream-stl__caption {
524
+ display: flex;
525
+ flex-wrap: wrap;
526
+ justify-content: space-between;
527
+ gap: 4px 12px;
528
+ font-size: 12px;
529
+ color: var(--gb-muted-foreground);
530
+ }
531
+
532
+ .docstream-stl__caption a {
533
+ color: inherit;
534
+ font-weight: 500;
535
+ }
536
+
537
+ /* StrudelPlayer: a Strudel pattern as an audio player (```strudel fences, .strudel embeds). */
538
+ .docstream-strudel__bar {
539
+ display: flex;
540
+ align-items: center;
541
+ gap: 12px;
542
+ padding: 10px 12px;
543
+ border: 1px solid var(--gb-border);
544
+ border-radius: 12px;
545
+ background: var(--gb-panel, #fff);
546
+ color: var(--gb-panel-foreground, inherit);
547
+ }
548
+
549
+ .docstream-strudel__play {
550
+ flex: none;
551
+ display: grid;
552
+ place-items: center;
553
+ width: 40px;
554
+ height: 40px;
555
+ border: 0;
556
+ border-radius: 999px;
557
+ background: var(--gb-primary, #6366f1);
558
+ color: var(--gb-primary-foreground, #fff);
559
+ cursor: pointer;
560
+ }
561
+
562
+ .docstream-strudel__play:disabled {
563
+ opacity: 0.5;
564
+ cursor: default;
565
+ }
566
+
567
+ .docstream-strudel__play:focus-visible,
568
+ .docstream-strudel__toggle:focus-visible {
569
+ outline: 2px solid var(--gb-primary, #6366f1);
570
+ outline-offset: 2px;
571
+ }
572
+
573
+ .docstream-strudel__spin {
574
+ animation: docstream-strudel-spin 0.9s linear infinite;
575
+ }
576
+
577
+ @keyframes docstream-strudel-spin {
578
+ to {
579
+ transform: rotate(360deg);
580
+ }
581
+ }
582
+
583
+ .docstream-strudel__meta {
584
+ display: flex;
585
+ flex-direction: column;
586
+ min-width: 0;
587
+ flex: 0 1 auto;
588
+ max-width: 40%;
589
+ }
590
+
591
+ .docstream-strudel__title {
592
+ font-weight: 600;
593
+ font-size: 14px;
594
+ overflow: hidden;
595
+ text-overflow: ellipsis;
596
+ white-space: nowrap;
597
+ }
598
+
599
+ .docstream-strudel__sub {
600
+ font-size: 12px;
601
+ color: var(--gb-muted-foreground);
602
+ font-variant-numeric: tabular-nums;
603
+ }
604
+
605
+ .docstream-strudel__roll {
606
+ flex: 1 1 auto;
607
+ min-width: 60px;
608
+ height: 40px;
609
+ color: var(--gb-primary, #6366f1);
610
+ }
611
+
612
+ .docstream-strudel__toggle {
613
+ flex: none;
614
+ display: grid;
615
+ place-items: center;
616
+ width: 32px;
617
+ height: 32px;
618
+ border: 1px solid var(--gb-border);
619
+ border-radius: 8px;
620
+ background: transparent;
621
+ color: var(--gb-muted-foreground);
622
+ cursor: pointer;
623
+ }
624
+
625
+ .docstream-strudel__toggle[aria-expanded="true"],
626
+ .docstream-strudel__toggle:hover {
627
+ background: var(--gb-hover, var(--gb-accent));
628
+ color: inherit;
629
+ }
630
+
631
+ .docstream-strudel__error {
632
+ font-size: 12px;
633
+ color: var(--gb-danger, #dc2626);
634
+ white-space: pre-wrap;
635
+ }
636
+
637
+ .docstream-strudel__code {
638
+ border: 1px solid var(--gb-border);
639
+ border-radius: 12px;
640
+ background: var(--gb-code-bg);
641
+ color: var(--gb-code-fg);
642
+ padding: 12px 14px;
643
+ overflow-x: auto;
644
+ font-size: 13px;
645
+ }
646
+
453
647
  .docs-content-ref {
454
648
  display: flex !important;
455
649
  align-items: center;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brett_lamy/docstream",
3
- "version": "1.9.0",
3
+ "version": "1.10.0",
4
4
  "description": "GitBook-aware readonly markdown and AI stream renderer.",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -94,6 +94,18 @@
94
94
  "import": "./dist/video/index.js",
95
95
  "default": "./dist/video/index.js"
96
96
  },
97
+ "./stl": {
98
+ "source": "./src/stl/index.ts",
99
+ "types": "./dist/stl/index.d.ts",
100
+ "import": "./dist/stl/index.js",
101
+ "default": "./dist/stl/index.js"
102
+ },
103
+ "./strudel": {
104
+ "source": "./src/strudel/index.ts",
105
+ "types": "./dist/strudel/index.d.ts",
106
+ "import": "./dist/strudel/index.js",
107
+ "default": "./dist/strudel/index.js"
108
+ },
97
109
  "./viz": {
98
110
  "source": "./src/viz/index.ts",
99
111
  "types": "./dist/viz/index.d.ts",
@@ -127,6 +139,7 @@
127
139
  "./styles.css": "./dist/styles.css"
128
140
  },
129
141
  "dependencies": {
142
+ "@strudel/web": "1.3.0",
130
143
  "framer-motion": "^13.1.1",
131
144
  "gpu-lexer": "^0.0.2",
132
145
  "katex": "^0.18.5",
@@ -27,6 +27,8 @@ import { useSyncedTab } from "./tabs-sync"
27
27
  import { ReplayPreview } from "../replay/ReplayPreview"
28
28
  import { isReplayQaUrl } from "../replay/url"
29
29
  import { VideoEmbed } from "../video"
30
+ import { StlViewer, isStlUrl } from "../stl"
31
+ import { StrudelPlayer, isStrudelFile, isStrudelLanguage } from "../strudel"
30
32
  import { OpenApiOperation } from "../openapi/OpenApiOperation"
31
33
  import { Mermaid } from "./Mermaid"
32
34
  import { HighlightedCode } from "./HighlightedCode"
@@ -153,7 +155,7 @@ function codeOnlyTabs(block: Extract<Block, { type: "tabs" }>): CodeBlockNode[]
153
155
  const codes: CodeBlockNode[] = []
154
156
  for (const tab of block.tabs) {
155
157
  const only = tab.children.length === 1 ? tab.children[0] : null
156
- if (!only || only.type !== "code" || only.live || only.language === "mermaid") return null
158
+ if (!only || only.type !== "code" || only.live || only.language === "mermaid" || isStrudelLanguage(only.language)) return null
157
159
  codes.push(only)
158
160
  }
159
161
  return codes
@@ -342,6 +344,9 @@ function BlockView({ block, liveRenderer, sourceRenderer }: { block: Block } & R
342
344
  />
343
345
  )
344
346
  }
347
+ if (isStrudelLanguage(block.language)) {
348
+ return <StrudelPlayer code={block.code} {...(block.title ? { title: block.title } : {})} />
349
+ }
345
350
  if (block.live && isReactLanguage(block.language) && liveRenderer) {
346
351
  const entry = block.entry ?? "/src/main.jsx"
347
352
  return liveRenderer({
@@ -419,6 +424,12 @@ function BlockView({ block, liveRenderer, sourceRenderer }: { block: Block } & R
419
424
  if (isReplayQaUrl(block.url)) {
420
425
  return <ReplayPreview source={block.url} title="Replay preview" />
421
426
  }
427
+ if (isStlUrl(block.url)) {
428
+ return <StlViewer src={resolveAsset(block.url)} {...(block.title ? { title: block.title } : {})} />
429
+ }
430
+ if (isStrudelFile(block.url)) {
431
+ return <StrudelPlayer src={resolveAsset(block.url)} {...(block.title ? { title: block.title } : {})} />
432
+ }
422
433
  const src = embedSrc(block.url)
423
434
  return src || isDirectVideo(block.url) ? (
424
435
  <VideoEmbed
package/src/env.d.ts CHANGED
@@ -2,3 +2,6 @@ declare module "*.css" {
2
2
  const href: string
3
3
  export default href
4
4
  }
5
+
6
+ // @strudel/web ships no types; src/strudel/engine.ts types the slice it uses.
7
+ declare module "@strudel/web"
package/src/index.ts CHANGED
@@ -88,6 +88,10 @@ export type { ReplayEvent } from "./replay/types"
88
88
  export { isReplayQaUrl, normalizeReplayEmbedUrl } from "./replay/url"
89
89
  export { VideoEmbed } from "./video"
90
90
  export type { VideoEmbedProps } from "./video"
91
+ export { StlViewer, isStlUrl, parseStl } from "./stl"
92
+ export type { StlMesh, StlViewerProps } from "./stl"
93
+ export { StrudelPlayer, isStrudelFile, isStrudelLanguage } from "./strudel"
94
+ export type { StrudelPlayerProps } from "./strudel"
91
95
  export { OpenApiOperation } from "./openapi/OpenApiOperation"
92
96
  export { createInlineDemoProject, createReactDemoFiles } from "./playground/project"
93
97
  export type {
@@ -128,6 +128,14 @@ export function useAlmostNodeServer({ files, port = 4173, run, workspaceOptions,
128
128
  },
129
129
  })
130
130
 
131
+ // The iframe's virtual URL only works once the host page is controlled. Do not
132
+ // reload the host when a reader opens its first live example.
133
+ await workspace.container.serverBridge.ensureServiceWorkerReady({ reloadOnFirstControl: false })
134
+ if (cancelled) {
135
+ workspace.dispose()
136
+ return
137
+ }
138
+
131
139
  const result = await workspace.container.run(`vite --port ${port}`, {
132
140
  env: DETACHED_SERVER_ENV,
133
141
  })
@@ -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"