@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.
- package/README.md +38 -0
- package/dist/docs/DocsRenderer.d.ts.map +1 -1
- package/dist/docs/DocsRenderer.js +12 -1
- package/dist/docs/DocsRenderer.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/playground/ReactCodePreview.d.ts.map +1 -1
- package/dist/playground/ReactCodePreview.js +7 -0
- package/dist/playground/ReactCodePreview.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/StrudelPlayer.d.ts +15 -0
- package/dist/strudel/StrudelPlayer.d.ts.map +1 -0
- package/dist/strudel/StrudelPlayer.js +178 -0
- package/dist/strudel/StrudelPlayer.js.map +1 -0
- package/dist/strudel/engine.d.ts +38 -0
- package/dist/strudel/engine.d.ts.map +1 -0
- package/dist/strudel/engine.js +65 -0
- package/dist/strudel/engine.js.map +1 -0
- package/dist/strudel/index.d.ts +5 -0
- package/dist/strudel/index.d.ts.map +1 -0
- package/dist/strudel/index.js +4 -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 +194 -0
- package/package.json +14 -1
- package/src/docs/DocsRenderer.tsx +12 -1
- package/src/env.d.ts +3 -0
- package/src/index.ts +4 -0
- package/src/playground/ReactCodePreview.tsx +8 -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/StrudelPlayer.tsx +246 -0
- package/src/strudel/engine.ts +98 -0
- package/src/strudel/index.ts +4 -0
- package/src/strudel/url.ts +9 -0
- 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.
|
|
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
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
|
+
}
|
package/src/stl/index.ts
ADDED