Public: anyone can read this; the crew can change it.

Files render in the browser: STL (and OBJ/GLB) as an interactive 3D view #10317

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

mason/forge:mason/7335-files-render-in-the-browser-stl-and-obj → main 24 commits fork 4 threads · 4 unresolved

  • Merged. mason/7335-files-render-in-the-browser-stl-and-obj was merged into main as 7ae3986.

Review

Sign in to review this change.
43 files changed +90800 −32
docs/design/areas/model3d.md added +7
@@ -0,0 +1,7 @@
1 # 3D models
2
3 The blob page's 3D view (`src/web/render/model3d.ts`), drawn for an STL, OBJ or glTF file.
4
5 - `.model3d` — the view, with no box: a `.model3d-head` under a hairline carries a `dl` row (format, file size, then triangles and box once drawn) and the Wireframe and Axes toggles.
6 - `.model3d-tools` — the two toggles at the end of the head: `btn` with `pressed` (`aria-pressed`) and `js-only`. Hidden until the viewer has drawn the part.
7 - `.model3d-stage` — the drawing area, held at 4:3 (and at most 70% of the screen's height) before the viewer arrives, so nothing shifts when it does. It carries the empty state (`empty()`, the file icon) with Download until the canvas replaces it, and keeps it when there is no JavaScript or WebGL.
docs/features/file-media.md +56 −3
@@ -71,9 +71,13 @@ largest file each kind draws inline; over it, the page offers Download.
7171 `kinds.ts` is plain data with no web dependency: the editor and the
7272 terminal ask it whether a path is Markdown, as the blob page does.
7373
74 `renderBlob` asks `render/renderers.ts`. A kind in `RENDERERS` is drawn by
75 its module; a kind with none yet keeps the source or binary view it always
76 had. Where a renderer exists the header offers `Rendered | Source`; every
74 `renderBlob` asks `rendererFor` in `render/renderers.ts`. A kind in
75 `RENDERERS` is drawn by its module; a kind with none yet keeps the source or
76 binary view it always had. A file too large for the page to read is drawn
77 only by a media kind (`MEDIA_KINDS`), whose renderer never reads the body
78 (the browser fetches the media route), and only up to that kind's cap. A
79 page with no media route (a live browse of another forge, `/origins/…`)
80 draws no media kind. Where a renderer exists the header offers `Rendered | Source`; every
7781 file's header has the **Raw** and **Download** buttons.
7882
7983 ## Audio and video (#7333)
@@ -127,6 +131,55 @@ name it once in `RENDERERS` (`render/renderers.ts`); `rendererFor` picks it. Poi
127131 that needs the bytes at `input.media`, never at an inline copy, so
128132 repository content only ever runs under the media route's sandbox. `render/markdown.ts` is the example.
129133
134 ## 3D models
135
136 An `.stl` (ASCII or binary), `.obj`, `.glb` or `.gltf` file's page shows the
137 part, ready to turn (`src/web/render/model3d.ts`, #7335).
138
139 - **Turn it:** drag to orbit, scroll or pinch to zoom, right-drag or two
140 fingers to pan; with the view focused, the arrow keys pan.
141 - **The head** says the triangle count and the bounding box in millimetres,
142 in the file's own axes. STL and OBJ units are taken as millimetres; glTF
143 is metres by its spec, so its box is multiplied by 1000.
144 - **Wireframe** and **Axes** toggle in the head. The camera fits the part,
145 a grid sits under it, and every part wears one neutral material. An STL
146 is stood Z-up, the way a printer reads it.
147 - **Rendered | Source** stays, and **Raw** and **Download** are on every
148 file's bar. Up to 100 MB (`RENDER_CAP.model3d`) a part is drawn, even one
149 too large for the page to read whole; over it, the page offers Download.
150 - **On a live browse of another forge** (`/origins/…`) there is no media
151 route to fetch from, so a model keeps its source or binary view there.
152 - **Without JavaScript or WebGL** the frame keeps its note and Download
153 button. A file that is not its format says why in the head and keeps them
154 too.
155 - **Where the work happens:** the server renders only the frame. The bytes
156 come from the media route with `fetch()`, and STL and OBJ are parsed in
157 the browser by `render/mesh.ts`; glTF goes through three.js's loader. A
158 `.gltf` whose buffers are separate files finds them beside it on the media
159 route. Only those are fetched: every URI a glTF names goes through
160 `withinModelDir` (`render/gltf-uri.ts`), and one that is not same-origin
161 with a path under the model's own directory (`../`, an encoded dot or
162 slash, an absolute path, another host, `data:` or `blob:`) is sent to
163 `about:blank` and never requested, so a public model cannot make its
164 readers' browsers call anything else on the forge.
165 - **In the terminal** there is no 3D screen, by design: a terminal cannot draw
166 the part, so the file view of a binary or too-large model says
167 what the page shows: `3D model · STL` over `12 triangles · 20 × 20 × 20 mm`,
168 and under it `Download: <forge>/<owner>/<repo>/media/<ref>/<path>?download=1`
169 (`render/model-facts.ts`, the same parsers). Up to 2 MB (`FACTS_CAP`) the
170 bytes are read; over it, the model is named and sized. A glTF's facts come
171 from its JSON (vertex counts, position bounds in metres, before node
172 transforms). An ASCII STL or OBJ is text, so it opens as its source, as
173 the web's Source view does.
174 - **Loaded only where needed:** three.js 0.186.1 is vendored, unedited, with
175 its MIT licence, in `src/web/render/vendor/three/` (its README lists the
176 files and their hashes). `model3d-client.ts` is bundled with it into
177 `/assets/model3d-<hash>.js` (648 KB, 166 KB gzipped, immutable), named by
178 a hash of its sources so the page knows the address without a build. The
179 frame names it in `data-module`, and the shell loads it with its own nonce
180 only when a figure asks, on a full load or when the code browser swaps the
181 file in.
182
130183 ## Images and SVG (#7331)
131184
132185 `render/image.ts` draws the `image` and `svg` kinds as an `<img>` at the media
src/http/server.ts +6
@@ -192,6 +192,7 @@ import {
192192 treePage,
193193 } from "../web/pages/repo.ts";
194194 import { mediaBlob } from "../web/pages/media.ts";
195 import { model3dAsset } from "../web/render/model3d.ts";
195196 import {
196197 branchCreate,
197198 branchDelete,
@@ -770,6 +771,11 @@ export function buildRouter(): Router<Handler> {
770771 const term = await machineTermAsset();
771772 return term !== null && ctx.params.name === term.name ? assetResponse(ctx.request, term) : notFound();
772773 }
774 if (ctx.params.name!.startsWith("model3d-")) {
775 const viewer = await model3dAsset();
776 if (viewer === null || ctx.params.name !== viewer.name) return notFound();
777 return assetResponse(ctx.request, viewer);
778 }
773779 const asset = await highlightAsset();
774780 if (asset === null || ctx.params.name !== asset.name) return notFound();
775781 return assetResponse(ctx.request, asset);
src/tui/screens/workspace.ts +35 −4
@@ -50,6 +50,8 @@ import type { TreeEntry } from "../../git/read.ts";
5050 import { parseUnifiedDiff, type Diff } from "../../git/diff.ts";
5151 import { renderDiff as renderPatch } from "../ui/diff.ts";
5252 import { fileKind, NO_BYTES } from "../../web/render/kinds.ts";
53 import { downloadHref } from "../../web/pages/media.ts";
54 import { isModelPath, modelFacts } from "../../web/render/model-facts.ts";
5355 import type { RepoFacts, RepoSource } from "../../view/source.ts";
5456 import { detectLanguage, highlightLines } from "../../web/highlight.ts";
5557 import { MAX_COLUMNS, parseCsv, type Table } from "../../text/csv.ts";
@@ -349,6 +351,8 @@ class Tab {
349351 loading = true;
350352 /** Non-empty when there is a reason the bytes are not on screen. */
351353 note = "";
354 noteTitle = "Not shown";
355 noteDetail = "";
352356 raw: Uint8Array | null = null;
353357 plain: string[] = [];
354358 lines: Span[][] = [];
@@ -528,6 +532,10 @@ class Tab {
528532 /** One blob, as the workspace keeps it: text, a picture, or a reason. */
529533 interface CacheEntry {
530534 readonly note: string;
535 /** The note's heading; "Not shown" unless the file is something we name. */
536 readonly noteTitle: string;
537 /** A last line under the note: where to get the file. */
538 readonly noteDetail: string;
531539 readonly raw: Uint8Array | null;
532540 readonly picture: Uint8Array | null;
533541 }
@@ -606,6 +614,11 @@ export function workspaceScreen(
606614 // arriving from a search hit means "show me *that*", not "show me where I
607615 // was". Everything else is restored.
608616 let ref = o.ref ?? memory?.ref ?? knownDefaultRef(src) ?? "";
617 /** Where a file downloads from, said in words the terminal can print. */
618 const downloadAt = (path: string): string => {
619 const web = mediaWeb();
620 return web === undefined ? "Download it from its web page." : `Download: ${downloadHref(web, ref, path)}`;
621 };
609622 let facts: RepoFacts | undefined;
610623 let roots: Node[] = memory?.roots ?? [];
611624 let loadedTree = roots.length > 0;
@@ -800,6 +813,15 @@ export function workspaceScreen(
800813 const read = await src.blob(ref, t.path);
801814 if (stale(mine)) return;
802815 let note = "";
816 let noteTitle = "Not shown";
817 let noteDetail = "";
818 // A model is named and measured here, as the page draws it (#7335).
819 const model = (bytes: Uint8Array | null, size: number): void => {
820 const facts = modelFacts(t.path, bytes, size);
821 noteTitle = facts.title;
822 note = facts.facts;
823 noteDetail = downloadAt(t.path);
824 };
803825 let raw: Uint8Array | null = null;
804826 let picture: Uint8Array | null = null;
805827 if (!read.ok)
@@ -808,12 +830,15 @@ export function workspaceScreen(
808830 ? "No such file at this revision."
809831 : errorText(read.error);
810832 else {
811 // Audio and video are not played here; the note says where they are.
833 // Audio and video are not played here, and a model is not drawn
834 // here: each note says what the file is and where it is.
812835 const media =
813836 read.value.kind === "text"
814837 ? undefined
815838 : mediaNote(t.path, formatSize(read.value.size), mediaWeb(), ref);
816 if (media !== undefined) note = media;
839 if (read.value.kind !== "text" && isModelPath(t.path))
840 model(read.value.kind === "binary" ? read.value.body : null, read.value.size);
841 else if (media !== undefined) note = media;
817842 else if (read.value.kind === "too_large")
818843 note = `${formatSize(read.value.size)} — too large to display.`;
819844 else if (read.value.kind === "binary") {
@@ -827,12 +852,14 @@ export function workspaceScreen(
827852 `Binary file, ${formatSize(read.value.size)}.`;
828853 } else raw = read.value.body;
829854 }
830 entry = { note, raw, picture };
855 entry = { note, noteTitle, noteDetail, raw, picture };
831856 remember(key, entry);
832857 }
833858 if (stale(mine)) return;
834859 t.loading = false;
835860 t.note = entry.note;
861 t.noteTitle = entry.noteTitle;
862 t.noteDetail = entry.noteDetail;
836863 t.raw = entry.raw;
837864 t.image =
838865 entry.picture === null
@@ -2345,7 +2372,11 @@ export function workspaceScreen(
23452372 return;
23462373 }
23472374 if (t.note !== "") {
2348 emptyState(s, r, c, { title: "Not shown", body: t.note });
2375 emptyState(s, r, c, {
2376 title: t.noteTitle,
2377 body: t.note,
2378 ...(t.noteDetail === "" ? {} : { detail: t.noteDetail }),
2379 });
23492380 return;
23502381 }
23512382 if (t.mode === "edit") {
src/tui/ui/widgets.ts +4 −1
@@ -915,7 +915,7 @@ export function emptyState(
915915 s: Screen,
916916 r: Rect,
917917 c: Tokens,
918 o: { title: string; body?: string; action?: string },
918 o: { title: string; body?: string; detail?: string; action?: string },
919919 ): void {
920920 if (r.w <= 0 || r.h < 3) return;
921921 const w = Math.min(r.w, 44);
@@ -924,6 +924,9 @@ export function emptyState(
924924 s.line(inner, 2, truncate(o.title, w), style(c.fg, c.bg, BOLD), "center");
925925 if (o.body !== undefined && inner.h > 3)
926926 s.line(inner, 3, truncate(o.body, w), style(c.mutedFg, c.bg), "center");
927 // A detail (an address, say) may use the whole width: it is cut last.
928 if (o.detail !== undefined && inner.h > 4)
929 s.line({ ...r, y: inner.y }, 4, truncate(o.detail, r.w), style(c.faint, c.bg), "center");
927930 if (o.action !== undefined && inner.h > 5)
928931 button(
929932 s,
src/web/client.ts +31
@@ -1274,6 +1274,32 @@ export const editorJs = /* js */ `
12741274 })();
12751275 `;
12761276
1277 /**
1278 * A rendered file whose viewer needs a library (the 3D view) names its
1279 * bundle in `data-module`. `loadModules` loads each one once, with the
1280 * shell's own nonce, and only when a figure on the page asks: on the first
1281 * paint, and when the code browser swaps such a file in. Only a first-party
1282 * asset's exact shape is loaded (`/assets/<name>.js`), so nothing a page
1283 * carries can point it elsewhere. The module mounts its figures itself.
1284 * Its own string so a test can run it against a stand-in document.
1285 */
1286 export const moduleLoaderJs = /* js */ `
1287 var shellNonce = doc.currentScript ? doc.currentScript.nonce : '';
1288 var modules = {};
1289 function loadModules() {
1290 doc.querySelectorAll('[data-module]').forEach(function (el) {
1291 var src = el.getAttribute('data-module') || '';
1292 if (!/^\\/assets\\/[a-z0-9-]+\\.js$/.test(src) || modules[src]) return;
1293 modules[src] = true;
1294 var s = doc.createElement('script');
1295 s.type = 'module';
1296 s.nonce = shellNonce;
1297 s.src = src;
1298 doc.head.appendChild(s);
1299 });
1300 }
1301 `;
1302
12771303 const shellCore = /* js */ `
12781304 (function () {
12791305 var doc = document;
@@ -1418,6 +1444,11 @@ const shellCore = /* js */ `
14181444 paint();
14191445 }
14201446
1447 // ---- a heavy viewer, loaded only by the page that shows one ----
1448 ${moduleLoaderJs}
1449 doc.addEventListener('code:swap', loadModules);
1450 loadModules();
1451
14211452 // ---- settings: the section list narrows as you type ----
14221453 doc.addEventListener('input', function (e) {
14231454 var f = e.target;
src/web/pages/media.ts +4
@@ -50,6 +50,10 @@ const FULL_OID = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
5050 export const mediaHref = (base: string, ref: string, path: string): string =>
5151 `${base}/media/${encodeURIComponent(ref)}/${path}`;
5252
53 /** The file saved under its own name, streamed at any size. */
54 export const downloadHref = (base: string, ref: string, path: string): string =>
55 `${mediaHref(base, ref, path)}?download=1`;
56
5357 export async function mediaBlob(ctx: Ctx): Promise<Response> {
5458 // Server-Timing: `session` as the router measured it, `git` the object
5559 // reads, `stream` the time to have a body to send (the stream opened). A phase
src/web/pages/repo.ts +3 −6
@@ -90,7 +90,7 @@ import { fontJs } from "../render/font.ts";
9090 import { hasTable } from "../render/sqlite.ts";
9191 import { fileKind, HEAD_BYTES, RENDER_CAP } from "../render/kinds.ts";
9292 import { RENDERERS, rendererFor } from "../render/renderers.ts";
93 import { mediaHref } from "./media.ts";
93 import { downloadHref, mediaHref } from "./media.ts";
9494 import { browseBase, localScope, type RepoScope } from "../scope.ts";
9595 import { repoForkOf } from "../../core/origins.ts";
9696 import { recentMove } from "../../core/transfer.ts";
@@ -685,10 +685,6 @@ export async function readmeFor(
685685 */
outdated

Stray double blank line left behind by the removed downloadHref.

686686 export type BlobView = "source" | "rendered";
687687
688 /** The file saved under its own name, streamed at any size. */
689 const downloadHref = (base: string, ref: string, path: string): string =>
690 `${mediaHref(base, ref, path)}?download=1`;
691
692688 export function blobView(value: string | null): BlobView {
693689 return value === "source" ? "source" : "rendered";
694690 }
@@ -1095,7 +1091,7 @@ export function editAction(scope: RepoScope, ref: string, path: string): Html {
10951091 * a repository on this forge has.
10961092 */
10971093 function blobRenderer(scope: RepoScope, path: string, blob: Blob): ReturnType<typeof rendererFor> {
1098 const found = rendererFor(path, blob);
1094 const found = rendererFor(path, blob, RENDERERS, { media: scope.kind === "local" });
10991095 return found?.render === RENDERERS.pdf && scope.kind !== "local" ? undefined : found;
11001096 }
11011097
@@ -1158,6 +1154,7 @@ export function renderBlob(
11581154 size: blob.size,
11591155 body: renderer.body,
11601156 media: mediaHref(base, o.ref, o.path),
1157 download: downloadHref(base, o.ref, o.path),
11611158 pathHref: `${base}/blob/${encoded}/${o.path}`,
11621159 all: o.all === true,
11631160 ...(o.table !== undefined ? { table: o.table } : {}),
src/web/render/gltf-uri.ts added +37
@@ -0,0 +1,37 @@
1 /**
2 * Which URIs a `.gltf` may make the reader's browser fetch. A glTF names its
3 * buffers and images by URI, and the file is anyone's: left alone, a public
4 * model could say `../../../settings/tokens` or `/api/v1/…` and every reader
5 * would send a credentialed request there. So the viewer's loader asks this
6 * first, and only a file beside the model, or below it, on the same media
7 * route, is fetched (`model3d-client.ts`).
8 *
9 * Plain strings in, a boolean out: the tests ask it what the browser would.
10 */
11
12 /** Where a refused URI is sent: nowhere, and no request leaves the page. */
13 export const REFUSED_URI = "about:blank#refused";
14
15 /**
16 * Whether `uri`, resolved the way the browser will (against `dir`, itself
17 * resolved against `page`), is a same-origin http(s) address whose path lies
18 * under `dir`'s. An encoded dot, slash or backslash is refused outright, so
19 * no server's decoding can walk it back out.
20 */
21 export function withinModelDir(uri: string, dir: string, page: string): boolean {
22 let base: URL;
23 let target: URL;
24 try {
25 base = new URL(dir, page);
26 target = new URL(uri, base);
27 } catch {
28 return false;
29 }
30 if (target.protocol !== "http:" && target.protocol !== "https:") return false;
31 if (target.origin !== base.origin) return false;
32 if (target.username !== "" || target.password !== "") return false;
33 const path = target.pathname;
34 if (/%(2e|2f|5c)/i.test(path) || path.includes("\\")) return false;
35 const root = base.pathname.endsWith("/") ? base.pathname : base.pathname.slice(0, base.pathname.lastIndexOf("/") + 1);
36 return path.startsWith(root) && path.length > root.length;
37 }
src/web/render/kinds.ts +11
@@ -146,6 +146,7 @@ const TEXTUAL: Readonly<Record<string, FileKind>> = {
146146 jsonl: "csv",
147147 ndjson: "csv",
148148 gltf: "model3d",
149 obj: "model3d",
149150 json: "config",
150151 jsonc: "config",
151152 yaml: "config",
@@ -219,6 +220,16 @@ export function fileKind(path: string, head: Uint8Array): FileKind {
219220 return TEXTUAL[extension(path)] ?? "text";
220221 }
221222
223 /**
224 * What a file's name says it is, for a file too large to have been read:
225 * the bytes are not here to confirm it. Only a renderer that fetches the
226 * media route acts on this, and the media route confirms the type itself.
227 */
228 export function kindByName(path: string): FileKind {
229 const ext = extension(path);
230 return MEDIA[ext]?.kind ?? TEXTUAL[ext] ?? "text";
231 }
232
222233 /**
223234 * What a file's name alone claims to be, unconfirmed: for a page that never
224235 * read the bytes (a long video) and only points an element at the media
src/web/render/mesh.ts added +129
@@ -0,0 +1,129 @@
1 /**
2 * STL and OBJ, read into one plain triangle soup: nine floats a triangle, no
3 * index, no library. The 3D viewer (`model3d-client.ts`) runs these in the
4 * browser, so a model is parsed on the reader's machine and never on the
5 * request path; the server only ever streams the bytes.
6 *
7 * Plain data in, plain data out, and no DOM: the tests read the same
8 * fixtures the browser would.
9 */
10
11 export interface Mesh {
12 /** x, y, z for each corner, three corners a triangle. */
13 readonly positions: Float32Array;
14 readonly triangles: number;
15 }
16
17 export interface Bounds {
18 readonly min: readonly [number, number, number];
19 readonly max: readonly [number, number, number];
20 /** max − min on each axis. */
21 readonly size: readonly [number, number, number];
22 }
23
24 /** A file that is not the format it claims to be. */
25 export class MeshError extends Error {
26 override readonly name = "MeshError";
27 }
28
29 const HEADER = 80;
30 const FACET = 50;
31
32 /**
33 * Binary STL is an 80-byte header, a count, then 50 bytes a facet. Its
34 * length says so exactly, which is how a binary file whose header happens
35 * to open with `solid` is still read as binary.
36 */
37 export function isBinaryStl(bytes: Uint8Array): boolean {
38 if (bytes.length < HEADER + 4) return false;
39 const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
40 return HEADER + 4 + view.getUint32(HEADER, true) * FACET === bytes.length;
41 }
42
43 /** An STL in either form. */
44 export function parseStl(bytes: Uint8Array): Mesh {
45 return isBinaryStl(bytes) ? parseBinaryStl(bytes) : parseAsciiStl(new TextDecoder().decode(bytes));
46 }
47
48 function parseBinaryStl(bytes: Uint8Array): Mesh {
49 const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
50 const triangles = view.getUint32(HEADER, true);
51 const positions = new Float32Array(triangles * 9);
52 for (let t = 0; t < triangles; t++) {
53 // Skip the stored normal (12 bytes); the viewer computes its own.
54 const at = HEADER + 4 + t * FACET + 12;
55 for (let i = 0; i < 9; i++) positions[t * 9 + i] = view.getFloat32(at + i * 4, true);
56 }
57 return { positions, triangles };
58 }
59
60 const NUMBER = String.raw`([-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?)`;
61 const VERTEX = new RegExp(String.raw`\bvertex\s+${NUMBER}\s+${NUMBER}\s+${NUMBER}`, "g");
62
63 function parseAsciiStl(text: string): Mesh {
64 if (!/^\s*solid\b/.test(text)) throw new MeshError("not an STL file");
65 const out: number[] = [];
66 for (const m of text.matchAll(VERTEX)) out.push(Number(m[1]), Number(m[2]), Number(m[3]));
67 if (out.length % 9 !== 0) throw new MeshError("a facet without three vertices");
68 return { positions: Float32Array.from(out), triangles: out.length / 9 };
69 }
70
71 /**
72 * Wavefront OBJ: `v` lines and `f` lines; a face of more than three corners
73 * is a fan. Indices are 1-based, and a negative one counts back from the last
74 * vertex read. Normals, texture coordinates, groups and materials are skipped.
75 */
76 export function parseObj(text: string): Mesh {
77 const vertices: number[] = [];
78 const out: number[] = [];
79 const corner = (token: string): number => {
80 const n = Number.parseInt(token, 10);
81 const count = vertices.length / 3;
82 const index = n < 0 ? count + n : n - 1;
83 if (!Number.isInteger(n) || n === 0 || index < 0 || index >= count)
84 throw new MeshError(`a face names vertex ${token}, which is not there`);
85 return index;
86 };
87 const push = (index: number): void => {
88 out.push(vertices[index * 3]!, vertices[index * 3 + 1]!, vertices[index * 3 + 2]!);
89 };
90 for (const line of text.split("\n")) {
91 const parts = line.trim().split(/\s+/);
92 if (parts[0] === "v") {
93 const [x, y, z] = [Number(parts[1]), Number(parts[2]), Number(parts[3])];
94 if (![x, y, z].every(Number.isFinite)) throw new MeshError("a vertex that is not three numbers");
95 vertices.push(x, y, z);
96 } else if (parts[0] === "f") {
97 const corners = parts.slice(1).map((p) => corner(p.split("/")[0] ?? ""));
98 if (corners.length < 3) throw new MeshError("a face with fewer than three corners");
99 for (let i = 1; i + 1 < corners.length; i++) {
100 push(corners[0]!);
101 push(corners[i]!);
102 push(corners[i + 1]!);
103 }
104 }
105 }
106 return { positions: Float32Array.from(out), triangles: out.length / 9 };
107 }
108
109 /** The axis-aligned box around every corner; all zeros for an empty mesh. */
110 export function bounds(positions: Float32Array): Bounds {
111 if (positions.length < 3) return { min: [0, 0, 0], max: [0, 0, 0], size: [0, 0, 0] };
112 const min = [Infinity, Infinity, Infinity];
113 const max = [-Infinity, -Infinity, -Infinity];
114 for (let i = 0; i < positions.length; i += 3)
115 for (let a = 0; a < 3; a++) {
116 const v = positions[i + a]!;
117 if (v < min[a]!) min[a] = v;
118 if (v > max[a]!) max[a] = v;
119 }
120 const [x0, y0, z0] = min as [number, number, number];
121 const [x1, y1, z1] = max as [number, number, number];
122 return { min: [x0, y0, z0], max: [x1, y1, z1], size: [x1 - x0, y1 - y0, z1 - z0] };
123 }
124
125 /** `20 × 20 × 20 mm`: a box's size, rounded to what a printer can hold. */
126 export function sizeText(size: readonly [number, number, number], unit = "mm"): string {
127 const round = (v: number): string => String(Math.round(v * 100) / 100);
128 return `${size.map(round).join(" × ")} ${unit}`;
129 }
src/web/render/model-facts.ts added +115
@@ -0,0 +1,115 @@
1 /**
2 * A 3D model's facts, for a door with no WebGL: the terminal's file view
3 * says `3D model · STL` over `12 triangles · 20 × 20 × 20 mm` where the page draws
4 * the part. Plain data in, plain data out.
5 *
6 * STL and OBJ go through the same parsers the browser runs (`mesh.ts`). A
7 * glTF is not parsed into geometry: its JSON already says each primitive's
8 * vertex count and each position accessor's bounds, so those are read, in
9 * metres by the spec, before any node transform.
10 */
11
12 import { kindByName } from "./kinds.ts";
13 import { bounds, parseObj, parseStl, sizeText } from "./mesh.ts";
14
15 /** Over this a model is named and sized, not read. */
16 export const FACTS_CAP = 2_000_000;
17
18 interface Facts {
19 readonly triangles: number;
20 readonly size: readonly [number, number, number];
21 }
22
23 const extension = (path: string): string => path.slice(path.lastIndexOf(".") + 1).toLowerCase();
24
25 /** Whether the registry reads this name as a 3D model. */
26 export const isModelPath = (path: string): boolean => kindByName(path) === "model3d";
27
28 /** What a model is: `3D model · STL`, then what its bytes say. */
29 export interface ModelFacts {
30 readonly title: string;
31 /** `12 triangles · 20 × 20 × 20 mm`, its size over the cap, or why not. */
32 readonly facts: string;
33 }
34
35 /**
36 * A model file's facts, from its bytes when it has them under the cap;
37 * `size` is the whole file's. A file that is not its format says so.
38 */
39 export function modelFacts(path: string, bytes: Uint8Array | null, size: number): ModelFacts {
40 const format = extension(path).toUpperCase();
41 const title = `3D model · ${format}`;
42 if (bytes === null || size > FACTS_CAP) return { title, facts: megabytes(size) };
43 let facts: Facts;
44 try {
45 facts = read(extension(path), bytes);
46 } catch {
47 return { title, facts: `not a readable ${format} file` };
48 }
49 const count = `${facts.triangles.toLocaleString("en")} triangle${facts.triangles === 1 ? "" : "s"}`;
50 return { title, facts: `${count} · ${sizeText(facts.size)}` };
51 }
52

modelLine is exported and never used. Remove it.

53 function read(ext: string, bytes: Uint8Array): Facts {
54 if (ext === "stl" || ext === "obj") {
55 const mesh = ext === "stl" ? parseStl(bytes) : parseObj(new TextDecoder().decode(bytes));
56 return { triangles: mesh.triangles, size: bounds(mesh.positions).size };
57 }
58 return gltfFacts(ext === "glb" ? glbJson(bytes) : new TextDecoder().decode(bytes));
59 }
60
61 /** A GLB's first chunk, which the spec says is its JSON. */
62 function glbJson(bytes: Uint8Array): string {
63 const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
64 if (bytes.length < 20 || view.getUint32(0, true) !== 0x46546c67) throw new Error("not a GLB");
65 const length = view.getUint32(12, true);
66 if (view.getUint32(16, true) !== 0x4e4f534a || 20 + length > bytes.length) throw new Error("no JSON chunk");
67 return new TextDecoder().decode(bytes.subarray(20, 20 + length));
68 }
69
70 interface Accessor {
71 readonly count?: unknown;
72 readonly min?: unknown;
73 readonly max?: unknown;
74 }
75 interface Primitive {
76 readonly mode?: unknown;
77 readonly indices?: unknown;
78 readonly attributes?: { readonly POSITION?: unknown };
79 }
80
81 const TRIANGLES = 4;
82 const STRIP = 5;
83 const FAN = 6;
84
85 function gltfFacts(json: string): Facts {
86 const doc = JSON.parse(json) as { accessors?: Accessor[]; meshes?: { primitives?: Primitive[] }[] };
87 const accessors = Array.isArray(doc.accessors) ? doc.accessors : [];
88 const count = (index: unknown): number => {
89 const n = typeof index === "number" ? accessors[index]?.count : undefined;
90 if (typeof n !== "number" || !Number.isInteger(n) || n < 0) throw new Error("a primitive names no accessor");
91 return n;
92 };
93 const min = [Infinity, Infinity, Infinity];
94 const max = [-Infinity, -Infinity, -Infinity];
95 let triangles = 0;
96 for (const mesh of Array.isArray(doc.meshes) ? doc.meshes : [])
97 for (const p of Array.isArray(mesh.primitives) ? mesh.primitives : []) {
98 const mode = p.mode ?? TRIANGLES;
99 if (mode !== TRIANGLES && mode !== STRIP && mode !== FAN) continue;
100 const corners = count(p.indices ?? p.attributes?.POSITION);
101 triangles += mode === TRIANGLES ? Math.floor(corners / 3) : Math.max(0, corners - 2);
102 const position = typeof p.attributes?.POSITION === "number" ? accessors[p.attributes.POSITION] : undefined;
103 if (Array.isArray(position?.min) && Array.isArray(position?.max))
104 for (let a = 0; a < 3; a++) {
105 min[a] = Math.min(min[a]!, Number(position.min[a]));
106 max[a] = Math.max(max[a]!, Number(position.max[a]));
107 }
108 }
109 const size = [0, 1, 2].map((a) => (max[a]! >= min[a]! ? (max[a]! - min[a]!) * 1000 : 0));
110 if (size.some((v) => !Number.isFinite(v))) throw new Error("bounds that are not numbers");
111 return { triangles, size: size as [number, number, number] };
112 }
113
114 const megabytes = (bytes: number): string =>
115 bytes >= 1_000_000 ? `${(bytes / 1_000_000).toFixed(1)} MB` : `${Math.max(1, Math.round(bytes / 1000))} KB`;
src/web/render/model3d-client.ts added +277
@@ -0,0 +1,277 @@
1 /**
2 * The 3D viewer, in the browser. `model3d.ts` bundles this file with the
3 * vendored three.js into `/assets/model3d-<hash>.js`, and the shell loads it
4 * only when a page carries a `.model3d` figure, so no other page pays for it.
5 *
6 * Each figure names its file's media-route address (`data-src`) and format
7 * (`data-format`). The bytes are fetched and parsed here, on the reader's
8 * machine; the server never parses a model. A figure this cannot draw — no
9 * WebGL, a fetch that fails, bytes that are not the format — keeps the
10 * server-rendered note with its Download button, and its header says why.
11 *
12 * Drawing is on demand, never a loop: a frame is rendered when the camera
13 * moves, the stage resizes or a toggle flips.
14 */
15
16 import {
17 AxesHelper,
18 Box3,
19 BufferGeometry,
20 Color,
21 DirectionalLight,
22 DoubleSide,
23 Float32BufferAttribute,
24 GridHelper,
25 HemisphereLight,
26 LoadingManager,
27 Mesh,
28 MeshStandardMaterial,
29 type Object3D,
30 PerspectiveCamera,
31 Scene,
32 Vector3,
33 WebGLRenderer,
34 } from "three";
35 import { OrbitControls } from "three/addons/controls/OrbitControls.js";
36 import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
37 import { REFUSED_URI, withinModelDir } from "./gltf-uri.ts";
38 import { bounds, parseObj, parseStl, sizeText, type Bounds } from "./mesh.ts";
39
40 /**
41 * The little of the DOM this touches. The checker runs without the DOM
42 * library, which would retype the whole server, so it is named here.
43 */
44 interface Element {
45 readonly dataset: Record<string, string | undefined>;
46 readonly isConnected: boolean;
47 textContent: string | null;
48 hidden: boolean;
49 tabIndex: number;
50 readonly value?: string;
51 querySelector(selector: string): Element | null;
52 querySelectorAll(selector: string): Iterable<Element>;
53 getAttribute(name: string): string | null;
54 setAttribute(name: string, value: string): void;
55 addEventListener(type: string, listener: () => void): void;
56 replaceChildren(...nodes: Element[]): void;
57 getBoundingClientRect(): { width: number; height: number };
58 }
59 interface Browser {
60 readonly document: Element & { createElement(tag: string): Element };
61 readonly devicePixelRatio: number;
62 readonly location: { readonly href: string };
63 getComputedStyle(element: Element): { color: string };
64 ResizeObserver: new (callback: () => void) => { observe(element: Element): void; disconnect(): void };
65 }
66 const { document, devicePixelRatio, location, getComputedStyle, ResizeObserver } = globalThis as unknown as Browser;
67
68 interface Loaded {
69 readonly object: Object3D;
70 readonly triangles: number;
71 /** In the file's own axes and the unit below. */
72 readonly box: Bounds;
73 }
74
75 interface Viewer {
76 readonly figure: Element;
77 dispose(): void;
78 }
79
80 const live: Viewer[] = [];
81
82 /** A part reads best in one flat, neutral colour, lit from where you stand. */
83 const material = (): MeshStandardMaterial =>
84 new MeshStandardMaterial({ color: 0x9aa3ad, roughness: 0.7, metalness: 0.05, flatShading: true, side: DoubleSide });
85
86 function soup(positions: Float32Array): Mesh {
87 const geometry = new BufferGeometry();
88 geometry.setAttribute("position", new Float32BufferAttribute(positions, 3));
89 geometry.computeVertexNormals();
90 return new Mesh(geometry, material());
91 }
92
93 async function load(src: string, format: string): Promise<Loaded> {
94 const response = await fetch(src, { credentials: "same-origin" });
95 if (!response.ok) throw new Error(`the file answered ${response.status}`);
96 if (format === "stl" || format === "obj") {
97 const parsed =
98 format === "stl"
99 ? parseStl(new Uint8Array(await response.arrayBuffer()))
100 : parseObj(await response.text());
101 const mesh = soup(parsed.positions);
102 // A printed part is modelled Z-up; three.js stands Y-up.
103 if (format === "stl") mesh.rotation.x = -Math.PI / 2;
104 return { object: mesh, triangles: parsed.triangles, box: bounds(parsed.positions) };
105 }
106 // glTF: binary, or JSON whose buffers sit beside it on the media route.
107 const data = format === "glb" ? await response.arrayBuffer() : await response.text();
108 const base = src.slice(0, src.lastIndexOf("/") + 1);
109 // The file names its buffers and images; only those beside it are fetched.
110 const manager = new LoadingManager().setURLModifier((url) =>
111 withinModelDir(url, base, location.href) ? url : REFUSED_URI,
112 );
113 const gltf = await new Promise<{ scene: Object3D }>((resolve, reject) =>
114 new GLTFLoader(manager).parse(data, base, resolve, reject),
115 );
116 let triangles = 0;
117 gltf.scene.traverse((node) => {
118 if (!(node instanceof Mesh)) return;
119 const geometry = node.geometry;
120 triangles += Math.floor((geometry.index ?? geometry.getAttribute("position"))!.count / 3);
121 node.material = material();
122 });
123 const box = new Box3().setFromObject(gltf.scene);
124 // glTF is in metres by its spec; the header speaks millimetres.
125 const mm = (v: Vector3): [number, number, number] => [v.x * 1000, v.y * 1000, v.z * 1000];
126 const min = mm(box.min);
127 const max = mm(box.max);
128 return {
129 object: gltf.scene,
130 triangles,
131 box: { min, max, size: [max[0] - min[0], max[1] - min[1], max[2] - min[2]] },
132 };
133 }
134
135 /** A round grid step: 1, 2 or 5 times a power of ten. */
136 function niceStep(span: number): number {
137 const raw = span / 10;
138 const power = 10 ** Math.floor(Math.log10(raw));
139 const unit = raw / power;
140 return (unit < 1.5 ? 1 : unit < 3.5 ? 2 : unit < 7.5 ? 5 : 10) * power;
141 }
142
143 function mount(figure: Element): void {
144 if (figure.dataset.mounted !== undefined) return;
145 figure.dataset.mounted = "";
146 const stage = figure.querySelector("[data-stage]");
147 const stats = figure.querySelector("[data-stats]");
148 const tools = figure.querySelector("[data-tools]");
149 const src = figure.dataset.src;
150 const format = figure.dataset.format ?? "";
151 if (stage === null || stats === null || tools === null || src === undefined) return;
152
153 const canvas = document.createElement("canvas");
154 canvas.tabIndex = 0;
155 canvas.setAttribute("role", "img");
156 canvas.setAttribute("aria-label", figure.dataset.label ?? "3D view");
157 let renderer: WebGLRenderer;
158 try {
159 renderer = new WebGLRenderer({ canvas, antialias: true, alpha: true });
160 } catch {
161 // No WebGL here: the note and its Download button stay as served.
162 return;
163 }
164 // The note speaks for a page that cannot draw; this one can, until the
165 // file says otherwise.
166 const note = stage.querySelector(".empty");
167 if (note !== null) note.hidden = true;
168 stats.textContent = "Loading…";
169 const fact = (selector: string, value: string): void => {
170 const row = figure.querySelector(selector);
171 const cell = row?.querySelector("dd");
172 if (row === null || row === undefined || cell === null || cell === undefined) return;
173 cell.textContent = value;
174 row.hidden = false;
175 };
176
177 load(src, format).then(
178 ({ object, triangles, box }) => {
179 if (!figure.isConnected) return renderer.dispose();
180 const scene = new Scene();
181 scene.add(object);
182 // Stand the part on the grid, centred under the camera.
183 object.updateMatrixWorld(true);
184 const fit = new Box3().setFromObject(object);
185 const size = fit.getSize(new Vector3());
186 const centre = fit.getCenter(new Vector3());
187 object.position.set(-centre.x, -fit.min.y, -centre.z);
188 const span = Math.max(size.x, size.y, size.z) || 1;
189
190 const ink = new Color(getComputedStyle(figure).color);
191 const step = niceStep(span * 2);
192 const extent = Math.ceil((span * 2) / step) * step;
193 const grid = new GridHelper(extent, Math.round(extent / step), ink, ink);
194 grid.material.transparent = true;
195 grid.material.opacity = 0.2;
196 // Just under the part, so its bottom face never flickers with the grid.
197 grid.position.y = -span / 1000;
198 const axes = new AxesHelper(span * 0.75);
199 axes.visible = false;
200 scene.add(grid, axes, new HemisphereLight(0xffffff, 0x666666, 1.6));
201
202 const camera = new PerspectiveCamera(40, 1, span / 100, span * 100);
203 const light = new DirectionalLight(0xffffff, 1.4);
204 light.position.set(1, 2, 3);
205 camera.add(light);
206 scene.add(camera);
207 const target = new Vector3(0, size.y / 2, 0);
208 const radius = size.length() / 2 || span;
209 const distance = radius / Math.sin((camera.fov * Math.PI) / 360) * 1.1;
210 camera.position.copy(target).add(new Vector3(1, 0.8, 1.2).normalize().multiplyScalar(distance));
211
212 const controls = new OrbitControls(camera, canvas);
213 controls.target.copy(target);
214 controls.listenToKeyEvents(canvas);
215 const draw = (): void => renderer.render(scene, camera);
216 controls.addEventListener("change", draw);
217 controls.update();
218
219 renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
220 const resize = (): void => {
221 const { width, height } = stage.getBoundingClientRect();
222 if (width === 0 || height === 0) return;
223 renderer.setSize(width, height, false);
224 camera.aspect = width / height;
225 camera.updateProjectionMatrix();
226 draw();
227 };
228 const observer = new ResizeObserver(resize);
229 observer.observe(stage);
230 stage.replaceChildren(canvas);
231 resize();
232
233 stats.textContent = "";
234 fact("[data-triangles]", triangles.toLocaleString("en"));
235 fact("[data-box]", sizeText(box.size));
236 for (const button of tools.querySelectorAll("button[value]")) {
237 button.addEventListener("click", () => {
238 const on = button.getAttribute("aria-pressed") !== "true";
239 button.setAttribute("aria-pressed", String(on));
240 if (button.value === "axes") axes.visible = on;
241 else
242 object.traverse((node) => {
243 if (node instanceof Mesh)
244 for (const m of [node.material].flat()) m.wireframe = on;
245 });
246 draw();
247 });
248 }
249 tools.hidden = false;
250 live.push({
251 figure,
252 dispose() {
253 observer.disconnect();
254 controls.dispose();
255 renderer.dispose();
256 renderer.forceContextLoss();
257 },
258 });
259 },
260 (error: unknown) => {
261 renderer.dispose();
262 if (note !== null) note.hidden = false;
263 stats.textContent = `Could not draw this file: ${error instanceof Error ? error.message : String(error)}`;
264 },
265 );
266 }
267
268 /** Mount every figure on the page, and let go of any a swap took away. */
269 function mountAll(): void {
270 for (let i = live.length - 1; i >= 0; i--)
271 if (!live[i]!.figure.isConnected) live.splice(i, 1)[0]!.dispose();
272 for (const figure of document.querySelectorAll(".model3d[data-src]")) mount(figure);
273 }
274
275 mountAll();
276 // The code browser swaps files in place (`client.ts`); a module runs once.
277 document.addEventListener("code:swap", mountAll);
src/web/render/model3d-css.ts added +13
@@ -0,0 +1,13 @@
1 /** The 3D frame's styles, apart from `model3d.ts` so `style.ts` imports no renderer. */
2
3 /** The frame holds its height before the viewer arrives: no layout shift. */
4 export const model3dCss = /* css */ `
5 @layer content {
6 .model3d { margin: 0; }
7 .model3d-head { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 16px; padding: 8px 0; border-bottom: 1px solid var(--border); }
8 .model3d-head .row-facts { display: flex; flex-wrap: wrap; gap: 4px 16px; margin: 0; }
9 .model3d-tools { display: inline-flex; gap: 6px; }
10 .model3d-stage { position: relative; aspect-ratio: 4 / 3; max-height: 70dvh; width: 100%; display: grid; place-items: center; touch-action: none; }
11 .model3d-stage > canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
12 }
13 `;
src/web/render/model3d.ts added +137
@@ -0,0 +1,137 @@
1 /**
2 * An STL, OBJ or glTF file as a part you can turn: a server-rendered frame,
3 * and the viewer (`model3d-client.ts`, with the vendored three.js) loaded
4 * only by the pages that carry one.
5 *
6 * The server never reads a model's bytes to draw it. The figure names the
7 * media route (`input.media`), and the viewer fetches and parses there, in
8 * the browser; so a 100 MB part costs the blob page nothing but its frame,
9 * and a file too big to read whole is drawn all the same. With no
10 * JavaScript, or no WebGL, the frame's note and Download button are what
11 * stays.
12 *
13 * The bundle is named by a hash of its sources, not of its output, so the
14 * page knows the address without waiting for a build; the first request for
15 * it builds once per process, as the highlighter's does (`web/asset.ts`).
16 */
17
18 import { readdirSync, readFileSync } from "node:fs";
19 import { join } from "node:path";
20 import { sha256 } from "../../kernel/hash.ts";
21 import { createLog } from "../../kernel/log.ts";
22 import type { BuiltAsset } from "../asset.ts";
23 import { html, type Html } from "../html.ts";
24 import { btn, empty, fileSize } from "../ui.ts";
25 import type { Rendered, RenderInput } from "./renderers.ts";
26
27 const log = createLog("web");
28
29 const ENTRY = Bun.fileURLToPath(new URL("./model3d-client.ts", import.meta.url));
30 const VENDOR = join(import.meta.dir, "vendor", "three");
31
32 /** What the viewer reads each extension as. */
33 const FORMATS: Readonly<Record<string, string>> = { stl: "stl", obj: "obj", glb: "glb", gltf: "gltf" };
34
35 const formatOf = (path: string): string =>
36 FORMATS[path.slice(path.lastIndexOf(".") + 1).toLowerCase()] ?? "stl";
37
38 export function model3d(input: RenderInput): Rendered {
39 const name = input.path.slice(input.path.lastIndexOf("/") + 1);
40 const toggle = (label: string, value: string): Html =>
41 btn(label, { type: "button", size: "sm", variant: "outline", extra: "js-only", pressed: false, value });
42 return {
43 lines: 0,
44 content: html`<figure
45 class="model3d"
46 data-src="${input.media}"
47 data-format="${formatOf(input.path)}"
48 data-label="3D view of ${name}"
49 data-module="${model3dAssetPath()}"
50 >
51 <figcaption class="model3d-head">
outdated

Principles 6 and 7: card model3d boxes the view. Use a hairline under the head, not a card, and put the stats in a dl row.

52 <dl class="row-facts">
53 <div><dt>Format</dt><dd>${formatOf(input.path).toUpperCase()}</dd></div>
54 <div><dt>File</dt><dd>${fileSize(input.size)}</dd></div>
55 <div data-triangles hidden><dt>Triangles</dt><dd></dd></div>
56 <div data-box hidden><dt>Box</dt><dd></dd></div>
57 </dl>
58 <span class="muted" data-stats aria-live="polite"></span>
59 <span class="spacer"></span>
60 <span class="model3d-tools" data-tools hidden>
61 ${toggle("Wireframe", "wireframe")} ${toggle("Axes", "axes")}
62 </span>
outdated

Principle 1: this note explains the same thing the title and the Download button already say. Cut it to one plain sentence.

63 </figcaption>
64 <div class="model3d-stage" data-stage>
65 ${empty({
66 icon: "file",
67 title: "A 3D model",
68 note: "Turning it needs JavaScript and WebGL; Download opens it elsewhere.",
69 action: btn("Download", { href: input.download, variant: "primary", icon: "download" }),
70 })}
71 </div>
72 </figure>`,
73 };
74 }
75
76 /** Every source the bundle is built from, in a stable order. */
77 function sources(): string[] {
78 const walk = (dir: string): string[] =>
79 readdirSync(dir, { withFileTypes: true })
80 .flatMap((e) => (e.isDirectory() ? walk(join(dir, e.name)) : [join(dir, e.name)]))
81 .sort();
82 return [ENTRY, join(import.meta.dir, "mesh.ts"), ...walk(VENDOR)];
83 }
84
85 let path: string | null = null;
86
87 /** `/assets/model3d-<hash>.js`, from the sources alone: no build is waited on. */
88 export function model3dAssetPath(): string {
89 if (path === null) {
90 const hash = sha256(sources().map((file) => sha256(readFileSync(file))).join("\n"));
91 path = `/assets/model3d-${hash.slice(0, 16)}.js`;
92 }
93 return path;
94 }
95
96 let pending: Promise<BuiltAsset | null> | null = null;
97
98 /** The viewer bundle, built once per process; null when the build fails. */
99 export function model3dAsset(): Promise<BuiltAsset | null> {
100 pending ??= build();
101 return pending;
102 }
103
104 async function build(): Promise<BuiltAsset | null> {
105 const name = model3dAssetPath().slice("/assets/".length, -".js".length);
106 try {
107 const built = await Bun.build({
108 entrypoints: [Bun.fileURLToPath(new URL("./model3d-client.ts", import.meta.url))],
109 target: "browser",
110 format: "esm",
111 minify: true,
112 plugins: [
113 {
114 name: "vendored-three",
115 setup(b) {
116 // The vendored files are three's own, unedited: they import
117 // `three`, which is the vendored build.
118 b.onResolve({ filter: /^three$/ }, () => ({ path: join(VENDOR, "build", "three.module.js") }));
119 b.onResolve({ filter: /^three\/addons\// }, (args) => ({
120 path: join(VENDOR, "examples", "jsm", args.path.slice("three/addons/".length)),
121 }));
122 },
123 },
124 ],
125 });
126 const first = built.outputs[0];
127 if (!built.success || first === undefined) {
128 log.warn("web.model3d_asset_failed", { logs: built.logs.length });
129 return null;
130 }
131 const code = await first.text();
132 return { path: `/assets/${name}.js`, name, code, etag: `"${sha256(code)}"` };
133 } catch (error) {
134 log.warn("web.model3d_asset_failed", { error: String(error) });
135 return null;
136 }
137 }
src/web/render/renderers.ts +17 −4
@@ -14,9 +14,10 @@ import { csv } from "./csv.ts";
1414 import { font } from "./font.ts";
1515 import { ics } from "./ics.ts";
1616 import { image } from "./image.ts";
17 import { claimedMedia, fileKind, HEAD_BYTES, NO_BYTES, RENDER_CAP, type FileKind } from "./kinds.ts";
17 import { fileKind, HEAD_BYTES, kindByName, NO_BYTES, RENDER_CAP, type FileKind } from "./kinds.ts";
1818 import { log, hasEscape } from "./log.ts";
1919 import { markdown } from "./markdown.ts";
20 import { model3d } from "./model3d.ts";
2021 import { patch } from "./patch.ts";
2122 import { pdf } from "./pdf.ts";
2223 import { sqlite } from "./sqlite.ts";
@@ -34,6 +35,8 @@ export interface RenderInput {
3435 readonly body: Uint8Array;
3536 /** `/:owner/:name/media/:ref/*path`, for `<img>`, `<video>` and `fetch()`. */
3637 readonly media: string;
38 /** The same, saved under the file's own name (`downloadHref`). */
39 readonly download: string;
3740 /** The file page's own address, for a link back to it (`?all=1`). */
3841 readonly pathHref?: string;
3942 /** `?all=1`: draw past the default window, up to the renderer's cap. */
@@ -63,6 +66,7 @@ export const RENDERERS: Partial<Readonly<Record<FileKind, Renderer>>> = {
6366 sqlite,
6467 audio,
6568 video,
69 model3d,
6670 config,
6771 patch,
6872 log,
@@ -88,19 +92,28 @@ const MEDIA_KINDS: ReadonlySet<FileKind> = new Set<FileKind>([
8892 "model3d",
8993 ]);
9094
91 /** The renderer for a file and the bytes it is handed, or none: the source view. */
95 /**
96 * The renderer for a file and the bytes it is handed, or none: the source
97 * view. `media` says whether the page has a media route: a live browse of
98 * another forge (`/origins/…`) has none, so a media kind (`MEDIA_KINDS`)
99 * keeps its source or binary view there.
100 */
92101 export function rendererFor(
93102 path: string,
94103 blob: Blob,
95104 renderers: Partial<Readonly<Record<FileKind, Renderer>>> = RENDERERS,
105 where: { readonly media: boolean } = { media: true },
96106 ): { render: Renderer; body: Uint8Array } | undefined {
107 // A file the source gave no bytes of is taken at its name: STL, OBJ and
108 // glTF have no signature to read anyway.
97109 const kind =
98110 blob.kind !== "too_large"
99111 ? fileKind(path, blob.body.subarray(0, HEAD_BYTES))
100112 : blob.head !== undefined
101113 ? fileKind(path, blob.head)
102 : claimedMedia(path)?.kind;
103 if (kind === undefined || blob.size > RENDER_CAP[kind]) return undefined;
114 : kindByName(path);
115 if (blob.size > RENDER_CAP[kind]) return undefined;
116 if (MEDIA_KINDS.has(kind) && !where.media) return undefined;
104117 if (blob.kind === "too_large" && !MEDIA_KINDS.has(kind)) return undefined;
105118 // A kind may draw only some of its files: `config` draws JSON, YAML and
106119 // TOML as a form, and leaves the rest (INI, .env …) their source.
src/web/render/vendor/three.d.ts added +130
@@ -0,0 +1,130 @@
1 /**
2 * The slice of three.js (vendored beside this file, see README.md) the 3D
3 * viewer uses, typed by hand so the checker sees it without `@types/three`.
4 * Page elements are `object`: the checker runs without the DOM library,
5 * which would retype the server. `model3d.ts` maps these module names onto
6 * the vendored files when it bundles the viewer; nothing on the server
7 * imports them.
8 */
9
10 declare module "three" {
11 export class Vector3 {
12 constructor(x?: number, y?: number, z?: number);
13 x: number;
14 y: number;
15 z: number;
16 set(x: number, y: number, z: number): this;
17 copy(v: Vector3): this;
18 add(v: Vector3): this;
19 multiplyScalar(s: number): this;
20 normalize(): this;
21 length(): number;
22 }
23 export class Color {
24 constructor(color: string | number);
25 }
26 export class Box3 {
27 min: Vector3;
28 max: Vector3;
29 setFromObject(object: Object3D): this;
30 getSize(target: Vector3): Vector3;
31 getCenter(target: Vector3): Vector3;
32 }
33 export class Object3D {
34 position: Vector3;
35 rotation: { x: number; y: number; z: number };
36 visible: boolean;
37 add(...objects: Object3D[]): this;
38 traverse(callback: (object: Object3D) => void): void;
39 updateMatrixWorld(force?: boolean): void;
40 }
41 export class Scene extends Object3D {}
42 export class Group extends Object3D {}
43 export class Material {
44 wireframe: boolean;
45 transparent: boolean;
46 opacity: number;
47 }
48 export class MeshStandardMaterial extends Material {
49 constructor(parameters?: {
50 color?: Color | string | number;
51 roughness?: number;
52 metalness?: number;
53 flatShading?: boolean;
54 side?: number;
55 });
56 }
57 export const DoubleSide: number;
58 export class BufferAttribute {
59 count: number;
60 }
61 export class Float32BufferAttribute extends BufferAttribute {
62 constructor(array: Float32Array, itemSize: number);
63 }
64 export class BufferGeometry {
65 index: BufferAttribute | null;
66 setAttribute(name: string, attribute: BufferAttribute): this;
67 getAttribute(name: string): BufferAttribute | undefined;
68 computeVertexNormals(): void;
69 }
70 export class Mesh extends Object3D {
71 constructor(geometry?: BufferGeometry, material?: Material);
72 geometry: BufferGeometry;
73 material: Material | Material[];
74 }
75 export class PerspectiveCamera extends Object3D {
76 constructor(fov: number, aspect: number, near: number, far: number);
77 fov: number;
78 aspect: number;
79 updateProjectionMatrix(): void;
80 }
81 export class HemisphereLight extends Object3D {
82 constructor(sky: number, ground: number, intensity: number);
83 }
84 export class DirectionalLight extends Object3D {
85 constructor(color: number, intensity: number);
86 }
87 export class GridHelper extends Object3D {
88 constructor(size: number, divisions: number, center: Color, grid: Color);
89 material: Material;
90 }
91 export class AxesHelper extends Object3D {
92 constructor(size: number);
93 }
94 export class LoadingManager {
95 setURLModifier(transform: (url: string) => string): this;
96 }
97 export class WebGLRenderer {
98 constructor(parameters: { canvas: object; antialias?: boolean; alpha?: boolean });
99 setPixelRatio(ratio: number): void;
100 setSize(width: number, height: number, updateStyle?: boolean): void;
101 render(scene: Object3D, camera: PerspectiveCamera): void;
102 dispose(): void;
103 forceContextLoss(): void;
104 }
105 }
106
107 declare module "three/addons/controls/OrbitControls.js" {
108 import type { PerspectiveCamera, Vector3 } from "three";
109 export class OrbitControls {
110 constructor(camera: PerspectiveCamera, element: object);
111 target: Vector3;
112 update(): boolean;
113 listenToKeyEvents(element: object): void;
114 addEventListener(type: "change", listener: () => void): void;
115 dispose(): void;
116 }
117 }
118
119 declare module "three/addons/loaders/GLTFLoader.js" {
120 import type { Group, LoadingManager } from "three";
121 export class GLTFLoader {
122 constructor(manager?: LoadingManager);
123 parse(
124 data: ArrayBuffer | string,
125 path: string,
126 onLoad: (gltf: { scene: Group }) => void,
127 onError: (error: unknown) => void,
128 ): void;
129 }
130 }
src/web/render/vendor/three/LICENSE added +21
@@ -0,0 +1,21 @@
1 The MIT License
2
3 Copyright © 2010-2026 three.js authors
4
5 Permission is hereby granted, free of charge, to any person obtaining a copy
6 of this software and associated documentation files (the "Software"), to deal
7 in the Software without restriction, including without limitation the rights
8 to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 copies of the Software, and to permit persons to whom the Software is
10 furnished to do so, subject to the following conditions:
11
12 The above copyright notice and this permission notice shall be included in
13 all copies or substantial portions of the Software.
14
15 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21 THE SOFTWARE.
src/web/render/vendor/three/README.md added +27
@@ -0,0 +1,27 @@
1 # three.js, vendored
2
3 [three.js](https://threejs.org) **0.186.1**, from the npm package `[email protected]`,
4 MIT licensed (`LICENSE`, beside this file). It draws the blob page's 3D view
5 (`src/web/render/model3d-client.ts`) and nothing else: it is bundled into
6 `/assets/model3d-<hash>.js`, which the shell loads only on a page that shows
7 a model. No CDN, no remote script, no runtime dependency.
8
9 Why a library at all: a WebGL renderer, orbit controls and a glTF loader are
10 thousands of lines each to write and to get right. STL and OBJ are parsed by
11 `src/web/render/mesh.ts`, our own, since they are simple and tested.
12
13 The files are upstream's, byte for byte, at upstream's own paths; they import
14 `three`, which `model3d.ts` resolves to `build/three.module.js` when it
15 bundles. `three.d.ts` (one level up) types the slice the viewer uses. To
16 upgrade, copy the same files from the new package, check the hashes it
17 prints, and update this list:
18
19 ```
20 8b378ebe60e2fe500158cb0ac71cb5e8b7d92953c2abcc63a0eb90499653b5bc ./LICENSE
21 9edde002b066a9a05676a6127f67735b62baf399bdea529f2f7e31657da769e6 ./build/three.core.js
22 9052042d676cb0fdc1ddfefe193053f34b7ac0513a616fdac4535d49987812ea ./build/three.module.js
23 3d79d07ecb686b4e5d93232eedab255331c1beef711e13164eaa1f68655a5f2b ./examples/jsm/controls/OrbitControls.js
24 131c0f78c01d19368ae495caa65b3adaa10487810a36a05bb5901b769a35ac16 ./examples/jsm/loaders/GLTFLoader.js
25 9fb63427ce6641fa14fd0baff9cc4d1b5f9c3d85fd084bf2e90e803c44ec1797 ./examples/jsm/utils/BufferGeometryUtils.js
26 b1632a703206c3d830de9fcbe515696770d04b71a15ee6b50afa6d2c3298c86f ./examples/jsm/utils/SkeletonUtils.js
27 ```
src/web/render/vendor/three/build/three.core.js added +60586

Diff not shown — 60,586 changed lines (limit 5,000). +60586

View file
src/web/render/vendor/three/build/three.module.js added +19719

Diff not shown — 19,719 changed lines (limit 5,000). +19719

View file
src/web/render/vendor/three/examples/jsm/controls/OrbitControls.js added +1972
@@ -0,0 +1,1972 @@
1 import {
2 Controls,
3 MOUSE,
4 Quaternion,
5 Spherical,
6 TOUCH,
7 Vector2,
8 Vector3,
9 Plane,
10 Ray,
11 MathUtils
12 } from 'three';
13
14 /**
15 * Fires when the camera has been transformed by the controls.
16 *
17 * @event OrbitControls#change
18 * @type {Object}
19 */
20 const _changeEvent = { type: 'change' };
21
22 /**
23 * Fires when an interaction was initiated.
24 *
25 * @event OrbitControls#start
26 * @type {Object}
27 */
28 const _startEvent = { type: 'start' };
29
30 /**
31 * Fires when an interaction has finished.
32 *
33 * @event OrbitControls#end
34 * @type {Object}
35 */
36 const _endEvent = { type: 'end' };
37
38 const _ray = new Ray();
39 const _plane = new Plane();
40 const _TILT_LIMIT = Math.cos( 70 * MathUtils.DEG2RAD );
41
42 const _v = new Vector3();
43 const _twoPI = 2 * Math.PI;
44
45 const _STATE = {
46 NONE: - 1,
47 ROTATE: 0,
48 DOLLY: 1,
49 PAN: 2,
50 TOUCH_ROTATE: 3,
51 TOUCH_PAN: 4,
52 TOUCH_DOLLY_PAN: 5,
53 TOUCH_DOLLY_ROTATE: 6
54 };
55 const _EPS = 0.000001;
56
57
58 /**
59 * Orbit controls allow the camera to orbit around a target.
60 *
61 * OrbitControls performs orbiting, dollying (zooming), and panning. Unlike {@link TrackballControls},
62 * it maintains the "up" direction `object.up` (+Y by default).
63 *
64 * - Orbit: Left mouse / touch: one-finger move.
65 * - Zoom: Middle mouse, or mousewheel / touch: two-finger spread or squish.
66 * - Pan: Right mouse, or left mouse + ctrl/meta/shiftKey, or arrow keys / touch: two-finger move.
67 *
68 * ```js
69 * const controls = new OrbitControls( camera, renderer.domElement );
70 *
71 * // controls.update() must be called after any manual changes to the camera's transform
72 * camera.position.set( 0, 20, 100 );
73 * controls.update();
74 *
75 * function animate() {
76 *
77 * // required if controls.enableDamping or controls.autoRotate are set to true
78 * controls.update();
79 *
80 * renderer.render( scene, camera );
81 *
82 * }
83 * ```
84 *
85 * @augments Controls
86 * @three_import import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
87 */
88 class OrbitControls extends Controls {
89
90 /**
91 * Constructs a new controls instance.
92 *
93 * @param {Object3D} object - The object that is managed by the controls.
94 * @param {?HTMLElement} domElement - The HTML element used for event listeners.
95 */
96 constructor( object, domElement = null ) {
97
98 super( object, domElement );
99
100 this.state = _STATE.NONE;
101
102 /**
103 * The focus point of the controls, the `object` orbits around this.
104 * It can be updated manually at any point to change the focus of the controls.
105 *
106 * @type {Vector3}
107 */
108 this.target = new Vector3();
109
110 /**
111 * The focus point of the `minTargetRadius` and `maxTargetRadius` limits.
112 * It can be updated manually at any point to change the center of interest
113 * for the `target`.
114 *
115 * @type {Vector3}
116 */
117 this.cursor = new Vector3();
118
119 /**
120 * How far you can dolly in (perspective camera only).
121 *
122 * @type {number}
123 * @default 0
124 */
125 this.minDistance = 0;
126
127 /**
128 * How far you can dolly out (perspective camera only).
129 *
130 * @type {number}
131 * @default Infinity
132 */
133 this.maxDistance = Infinity;
134
135 /**
136 * How far you can zoom in (orthographic camera only).
137 *
138 * @type {number}
139 * @default 0
140 */
141 this.minZoom = 0;
142
143 /**
144 * How far you can zoom out (orthographic camera only).
145 *
146 * @type {number}
147 * @default Infinity
148 */
149 this.maxZoom = Infinity;
150
151 /**
152 * How close you can get the target to the 3D `cursor`.
153 *
154 * @type {number}
155 * @default 0
156 */
157 this.minTargetRadius = 0;
158
159 /**
160 * How far you can move the target from the 3D `cursor`.
161 *
162 * @type {number}
163 * @default Infinity
164 */
165 this.maxTargetRadius = Infinity;
166
167 /**
168 * How far you can orbit vertically, lower limit. Range is `[0, Math.PI]` radians.
169 *
170 * @type {number}
171 * @default 0
172 */
173 this.minPolarAngle = 0;
174
175 /**
176 * How far you can orbit vertically, upper limit. Range is `[0, Math.PI]` radians.
177 *
178 * @type {number}
179 * @default Math.PI
180 */
181 this.maxPolarAngle = Math.PI;
182
183 /**
184 * How far you can orbit horizontally, lower limit. If set, the interval `[ min, max ]`
185 * must be a sub-interval of `[ - 2 PI, 2 PI ]`, with `( max - min < 2 PI )`.
186 *
187 * @type {number}
188 * @default -Infinity
189 */
190 this.minAzimuthAngle = - Infinity;
191
192 /**
193 * How far you can orbit horizontally, upper limit. If set, the interval `[ min, max ]`
194 * must be a sub-interval of `[ - 2 PI, 2 PI ]`, with `( max - min < 2 PI )`.
195 *
196 * @type {number}
197 * @default -Infinity
198 */
199 this.maxAzimuthAngle = Infinity;
200
201 /**
202 * Set to `true` to enable damping (inertia), which can be used to give a sense of weight
203 * to the controls. Note that if this is enabled, you must call `update()` in your animation
204 * loop.
205 *
206 * @type {boolean}
207 * @default false
208 */
209 this.enableDamping = false;
210
211 /**
212 * The damping inertia used if `enableDamping` is set to `true`.
213 *
214 * Note that for this to work, you must call `update()` in your animation loop.
215 *
216 * @type {number}
217 * @default 0.05
218 */
219 this.dampingFactor = 0.05;
220
221 /**
222 * Enable or disable zooming (dollying) of the camera.
223 *
224 * @type {boolean}
225 * @default true
226 */
227 this.enableZoom = true;
228
229 /**
230 * Speed of zooming / dollying.
231 *
232 * @type {number}
233 * @default 1
234 */
235 this.zoomSpeed = 1.0;
236
237 /**
238 * Enable or disable horizontal and vertical rotation of the camera.
239 *
240 * Note that it is possible to disable a single axis by setting the min and max of the
241 * `minPolarAngle` or `minAzimuthAngle` to the same value, which will cause the vertical
242 * or horizontal rotation to be fixed at that value.
243 *
244 * @type {boolean}
245 * @default true
246 */
247 this.enableRotate = true;
248
249 /**
250 * Speed of rotation.
251 *
252 * @type {number}
253 * @default 1
254 */
255 this.rotateSpeed = 1.0;
256
257 /**
258 * How fast to rotate the camera when the keyboard is used.
259 *
260 * @type {number}
261 * @default 1
262 */
263 this.keyRotateSpeed = 1.0;
264
265 /**
266 * Enable or disable camera panning.
267 *
268 * @type {boolean}
269 * @default true
270 */
271 this.enablePan = true;
272
273 /**
274 * Speed of panning.
275 *
276 * @type {number}
277 * @default 1
278 */
279 this.panSpeed = 1.0;
280
281 /**
282 * Defines how the camera's position is translated when panning. If `true`, the camera pans
283 * in screen space. Otherwise, the camera pans in the plane orthogonal to the camera's up
284 * direction.
285 *
286 * @type {boolean}
287 * @default true
288 */
289 this.screenSpacePanning = true;
290
291 /**
292 * How fast to pan the camera when the keyboard is used in
293 * pixels per keypress.
294 *
295 * @type {number}
296 * @default 7
297 */
298 this.keyPanSpeed = 7.0;
299
300 /**
301 * Setting this property to `true` allows to zoom to the cursor's position.
302 *
303 * @type {boolean}
304 * @default false
305 */
306 this.zoomToCursor = false;
307
308 /**
309 * Set to true to automatically rotate around the target
310 *
311 * Note that if this is enabled, you must call `update()` in your animation loop.
312 * If you want the auto-rotate speed to be independent of the frame rate (the refresh
313 * rate of the display), you must pass the time `deltaTime`, in seconds, to `update()`.
314 *
315 * @type {boolean}
316 * @default false
317 */
318 this.autoRotate = false;
319
320 /**
321 * How fast to rotate around the target if `autoRotate` is `true`. The default equates to 30 seconds
322 * per orbit at 60fps.
323 *
324 * Note that if `autoRotate` is enabled, you must call `update()` in your animation loop.
325 *
326 * @type {number}
327 * @default 2
328 */
329 this.autoRotateSpeed = 2.0;
330
331 /**
332 * This object contains references to the keycodes for controlling camera panning.
333 *
334 * ```js
335 * controls.keys = {
336 * LEFT: 'ArrowLeft', //left arrow
337 * UP: 'ArrowUp', // up arrow
338 * RIGHT: 'ArrowRight', // right arrow
339 * BOTTOM: 'ArrowDown' // down arrow
340 * }
341 * ```
342 * @type {Object}
343 */
344 this.keys = { LEFT: 'ArrowLeft', UP: 'ArrowUp', RIGHT: 'ArrowRight', BOTTOM: 'ArrowDown' };
345
346 /**
347 * This object contains references to the mouse actions used by the controls.
348 *
349 * ```js
350 * controls.mouseButtons = {
351 * LEFT: THREE.MOUSE.ROTATE,
352 * MIDDLE: THREE.MOUSE.DOLLY,
353 * RIGHT: THREE.MOUSE.PAN
354 * }
355 * ```
356 * @type {Object}
357 */
358 this.mouseButtons = { LEFT: MOUSE.ROTATE, MIDDLE: MOUSE.DOLLY, RIGHT: MOUSE.PAN };
359
360 /**
361 * This object contains references to the touch actions used by the controls.
362 *
363 * ```js
364 * controls.mouseButtons = {
365 * ONE: THREE.TOUCH.ROTATE,
366 * TWO: THREE.TOUCH.DOLLY_PAN
367 * }
368 * ```
369 * @type {Object}
370 */
371 this.touches = { ONE: TOUCH.ROTATE, TWO: TOUCH.DOLLY_PAN };
372
373 /**
374 * Used internally by `saveState()` and `reset()`.
375 *
376 * @type {Vector3}
377 */
378 this.target0 = this.target.clone();
379
380 /**
381 * Used internally by `saveState()` and `reset()`.
382 *
383 * @type {Vector3}
384 */
385 this.position0 = this.object.position.clone();
386
387 /**
388 * Used internally by `saveState()` and `reset()`.
389 *
390 * @type {number}
391 */
392 this.zoom0 = this.object.zoom;
393
394 this._cursorStyle = 'auto';
395
396 // the target DOM element for key events
397 this._domElementKeyEvents = null;
398
399 // internals
400
401 this._lastPosition = new Vector3();
402 this._lastQuaternion = new Quaternion();
403 this._lastTargetPosition = new Vector3();
404
405 // so camera.up is the orbit axis
406 this._quat = new Quaternion().setFromUnitVectors( object.up, new Vector3( 0, 1, 0 ) );
407 this._quatInverse = this._quat.clone().invert();
408
409 // current position in spherical coordinates
410 this._spherical = new Spherical();
411 this._sphericalDelta = new Spherical();
412
413 this._scale = 1;
414 this._panOffset = new Vector3();
415
416 this._rotateStart = new Vector2();
417 this._rotateEnd = new Vector2();
418 this._rotateDelta = new Vector2();
419
420 this._panStart = new Vector2();
421 this._panEnd = new Vector2();
422 this._panDelta = new Vector2();
423
424 this._dollyStart = new Vector2();
425 this._dollyEnd = new Vector2();
426 this._dollyDelta = new Vector2();
427
428 this._dollyDirection = new Vector3();
429 this._mouse = new Vector2();
430 this._performCursorZoom = false;
431
432 this._pointers = [];
433 this._pointerPositions = {};
434
435 this._controlActive = false;
436
437 // event listeners
438
439 this._onPointerMove = onPointerMove.bind( this );
440 this._onPointerDown = onPointerDown.bind( this );
441 this._onPointerUp = onPointerUp.bind( this );
442 this._onContextMenu = onContextMenu.bind( this );
443 this._onMouseWheel = onMouseWheel.bind( this );
444 this._onKeyDown = onKeyDown.bind( this );
445
446 this._onTouchStart = onTouchStart.bind( this );
447 this._onTouchMove = onTouchMove.bind( this );
448
449 this._onMouseDown = onMouseDown.bind( this );
450 this._onMouseMove = onMouseMove.bind( this );
451
452 this._interceptControlDown = interceptControlDown.bind( this );
453 this._interceptControlUp = interceptControlUp.bind( this );
454
455 //
456
457 if ( this.domElement !== null ) {
458
459 this.connect( this.domElement );
460
461 }
462
463 this.update();
464
465 }
466
467 /**
468 * Defines the visual representation of the cursor.
469 *
470 * @type {('auto'|'grab')}
471 * @default 'auto'
472 */
473 set cursorStyle( type ) {
474
475 this._cursorStyle = type;
476
477 if ( type === 'grab' ) {
478
479 this.domElement.style.cursor = 'grab';
480
481 } else {
482
483 this.domElement.style.cursor = 'auto';
484
485 }
486
487 }
488
489 get cursorStyle() {
490
491 return this._cursorStyle;
492
493 }
494
495 connect( element ) {
496
497 super.connect( element );
498
499 this.domElement.addEventListener( 'pointerdown', this._onPointerDown );
500 this.domElement.addEventListener( 'pointercancel', this._onPointerUp );
501
502 this.domElement.addEventListener( 'contextmenu', this._onContextMenu );
503 this.domElement.addEventListener( 'wheel', this._onMouseWheel, { passive: false } );
504
505 const document = this.domElement.getRootNode(); // offscreen canvas compatibility
506 document.addEventListener( 'keydown', this._interceptControlDown, { passive: true, capture: true } );
507
508 this.domElement.style.touchAction = 'none'; // Disable touch scroll
509
510 }
511
512 disconnect() {
513
514 this.state = _STATE.NONE;
515
516 this.domElement.removeEventListener( 'pointerdown', this._onPointerDown );
517 this.domElement.ownerDocument.removeEventListener( 'pointermove', this._onPointerMove );
518 this.domElement.ownerDocument.removeEventListener( 'pointerup', this._onPointerUp );
519 this.domElement.removeEventListener( 'pointercancel', this._onPointerUp );
520
521 this.domElement.removeEventListener( 'wheel', this._onMouseWheel );
522 this.domElement.removeEventListener( 'contextmenu', this._onContextMenu );
523
524 this.stopListenToKeyEvents();
525
526 const document = this.domElement.getRootNode(); // offscreen canvas compatibility
527 document.removeEventListener( 'keydown', this._interceptControlDown, { capture: true } );
528 document.removeEventListener( 'keyup', this._interceptControlUp, { capture: true } );
529
530 this._controlActive = false;
531
532 this._pointers.length = 0;
533 this._pointerPositions = {};
534
535 this.domElement.style.touchAction = ''; // Restore touch scroll
536 this.domElement.style.cursor = 'auto';
537
538 }
539
540 dispose() {
541
542 this.disconnect();
543
544 }
545
546 /**
547 * Get the current vertical rotation, in radians.
548 *
549 * @return {number} The current vertical rotation, in radians.
550 */
551 getPolarAngle() {
552
553 return this._spherical.phi;
554
555 }
556
557 /**
558 * Get the current horizontal rotation, in radians.
559 *
560 * @return {number} The current horizontal rotation, in radians.
561 */
562 getAzimuthalAngle() {
563
564 return this._spherical.theta;
565
566 }
567
568 /**
569 * Returns the distance from the camera to the target.
570 *
571 * @return {number} The distance from the camera to the target.
572 */
573 getDistance() {
574
575 return this.object.position.distanceTo( this.target );
576
577 }
578
579 /**
580 * Adds key event listeners to the given DOM element.
581 * `window` is a recommended argument for using this method.
582 *
583 * @param {HTMLElement} domElement - The DOM element
584 */
585 listenToKeyEvents( domElement ) {
586
587 domElement.addEventListener( 'keydown', this._onKeyDown );
588 this._domElementKeyEvents = domElement;
589
590 }
591
592 /**
593 * Removes the key event listener previously defined with `listenToKeyEvents()`.
594 */
595 stopListenToKeyEvents() {
596
597 if ( this._domElementKeyEvents !== null ) {
598
599 this._domElementKeyEvents.removeEventListener( 'keydown', this._onKeyDown );
600 this._domElementKeyEvents = null;
601
602 }
603
604 }
605
606 /**
607 * Save the current state of the controls. This can later be recovered with `reset()`.
608 */
609 saveState() {
610
611 this.target0.copy( this.target );
612 this.position0.copy( this.object.position );
613 this.zoom0 = this.object.zoom;
614
615 }
616
617 /**
618 * Reset the controls to their state from either the last time the `saveState()`
619 * was called, or the initial state.
620 */
621 reset() {
622
623 this.target.copy( this.target0 );
624 this.object.position.copy( this.position0 );
625 this.object.zoom = this.zoom0;
626
627 this.object.updateProjectionMatrix();
628 this.dispatchEvent( _changeEvent );
629
630 this.update();
631
632 this.state = _STATE.NONE;
633
634 }
635
636 /**
637 * Programmatically pan the camera.
638 *
639 * @param {number} deltaX - The horizontal pan amount in pixels.
640 * @param {number} deltaY - The vertical pan amount in pixels.
641 */
642 pan( deltaX, deltaY ) {
643
644 this._pan( deltaX, deltaY );
645 this.update();
646
647 }
648
649 /**
650 * Programmatically dolly in (zoom in for perspective camera).
651 *
652 * @param {number} dollyScale - The dolly scale factor.
653 */
654 dollyIn( dollyScale ) {
655
656 this._dollyIn( dollyScale );
657 this.update();
658
659 }
660
661 /**
662 * Programmatically dolly out (zoom out for perspective camera).
663 *
664 * @param {number} dollyScale - The dolly scale factor.
665 */
666 dollyOut( dollyScale ) {
667
668 this._dollyOut( dollyScale );
669 this.update();
670
671 }
672
673 /**
674 * Programmatically rotate the camera left (around the vertical axis).
675 *
676 * @param {number} angle - The rotation angle in radians.
677 */
678 rotateLeft( angle ) {
679
680 this._rotateLeft( angle );
681 this.update();
682
683 }
684
685 /**
686 * Programmatically rotate the camera up (around the horizontal axis).
687 *
688 * @param {number} angle - The rotation angle in radians.
689 */
690 rotateUp( angle ) {
691
692 this._rotateUp( angle );
693 this.update();
694
695 }
696
697 update( deltaTime = null ) {
698
699 const position = this.object.position;
700
701 _v.copy( position ).sub( this.target );
702
703 // rotate offset to "y-axis-is-up" space
704 _v.applyQuaternion( this._quat );
705
706 // angle from z-axis around y-axis
707 this._spherical.setFromVector3( _v );
708
709 if ( this.autoRotate && this.state === _STATE.NONE ) {
710
711 this._rotateLeft( this._getAutoRotationAngle( deltaTime ) );
712
713 }
714
715 if ( this.enableDamping ) {
716
717 this._spherical.theta += this._sphericalDelta.theta * this.dampingFactor;
718 this._spherical.phi += this._sphericalDelta.phi * this.dampingFactor;
719
720 } else {
721
722 this._spherical.theta += this._sphericalDelta.theta;
723 this._spherical.phi += this._sphericalDelta.phi;
724
725 }
726
727 // restrict theta to be between desired limits
728
729 let min = this.minAzimuthAngle;
730 let max = this.maxAzimuthAngle;
731
732 if ( isFinite( min ) && isFinite( max ) ) {
733
734 if ( min < - Math.PI ) min += _twoPI; else if ( min > Math.PI ) min -= _twoPI;
735
736 if ( max < - Math.PI ) max += _twoPI; else if ( max > Math.PI ) max -= _twoPI;
737
738 if ( min <= max ) {
739
740 this._spherical.theta = Math.max( min, Math.min( max, this._spherical.theta ) );
741
742 } else {
743
744 this._spherical.theta = ( this._spherical.theta > ( min + max ) / 2 ) ?
745 Math.max( min, this._spherical.theta ) :
746 Math.min( max, this._spherical.theta );
747
748 }
749
750 }
751
752 // restrict phi to be between desired limits
753 this._spherical.phi = Math.max( this.minPolarAngle, Math.min( this.maxPolarAngle, this._spherical.phi ) );
754
755 this._spherical.makeSafe();
756
757
758 // move target to panned location
759
760 if ( this.enableDamping === true ) {
761
762 this.target.addScaledVector( this._panOffset, this.dampingFactor );
763
764 } else {
765
766 this.target.add( this._panOffset );
767
768 }
769
770 // Limit the target distance from the cursor to create a sphere around the center of interest
771 this.target.sub( this.cursor );
772 this.target.clampLength( this.minTargetRadius, this.maxTargetRadius );
773 this.target.add( this.cursor );
774
775 let zoomChanged = false;
776 // adjust the camera position based on zoom only if we're not zooming to the cursor or if it's an ortho camera
777 // we adjust zoom later in these cases
778 if ( this.zoomToCursor && this._performCursorZoom || this.object.isOrthographicCamera ) {
779
780 this._spherical.radius = this._clampDistance( this._spherical.radius );
781
782 } else {
783
784 const prevRadius = this._spherical.radius;
785 this._spherical.radius = this._clampDistance( this._spherical.radius * this._scale );
786 zoomChanged = prevRadius != this._spherical.radius;
787
788 }
789
790 _v.setFromSpherical( this._spherical );
791
792 // rotate offset back to "camera-up-vector-is-up" space
793 _v.applyQuaternion( this._quatInverse );
794
795 position.copy( this.target ).add( _v );
796
797 this.object.lookAt( this.target );
798
799 if ( this.enableDamping === true ) {
800
801 this._sphericalDelta.theta *= ( 1 - this.dampingFactor );
802 this._sphericalDelta.phi *= ( 1 - this.dampingFactor );
803
804 this._panOffset.multiplyScalar( 1 - this.dampingFactor );
805
806 } else {
807
808 this._sphericalDelta.set( 0, 0, 0 );
809
810 this._panOffset.set( 0, 0, 0 );
811
812 }
813
814 // adjust camera position
815 if ( this.zoomToCursor && this._performCursorZoom ) {
816
817 let newRadius = null;
818 if ( this.object.isPerspectiveCamera ) {
819
820 // move the camera down the pointer ray
821 // this method avoids floating point error
822 const prevRadius = _v.length();
823 newRadius = this._clampDistance( prevRadius * this._scale );
824
825 const radiusDelta = prevRadius - newRadius;
826 this.object.position.addScaledVector( this._dollyDirection, radiusDelta );
827 this.object.updateMatrixWorld();
828
829 zoomChanged = !! radiusDelta;
830
831 } else if ( this.object.isOrthographicCamera ) {
832
833 // adjust the ortho camera position based on zoom changes
834 const mouseBefore = new Vector3( this._mouse.x, this._mouse.y, 0 );
835 mouseBefore.unproject( this.object );
836
837 const prevZoom = this.object.zoom;
838 this.object.zoom = Math.max( this.minZoom, Math.min( this.maxZoom, this.object.zoom / this._scale ) );
839 this.object.updateProjectionMatrix();
840
841 zoomChanged = prevZoom !== this.object.zoom;
842
843 const mouseAfter = new Vector3( this._mouse.x, this._mouse.y, 0 );
844 mouseAfter.unproject( this.object );
845
846 this.object.position.sub( mouseAfter ).add( mouseBefore );
847 this.object.updateMatrixWorld();
848
849 newRadius = _v.length();
850
851 } else {
852
853 console.warn( 'WARNING: OrbitControls.js encountered an unknown camera type - zoom to cursor disabled.' );
854 this.zoomToCursor = false;
855
856 }
857
858 // handle the placement of the target
859 if ( newRadius !== null ) {
860
861 if ( this.screenSpacePanning ) {
862
863 // position the orbit target in front of the new camera position
864 this.target.set( 0, 0, - 1 )
865 .transformDirection( this.object.matrix )
866 .multiplyScalar( newRadius )
867 .add( this.object.position );
868
869 } else {
870
871 // get the ray and translation plane to compute target
872 _ray.origin.copy( this.object.position );
873 _ray.direction.set( 0, 0, - 1 ).transformDirection( this.object.matrix );
874
875 // if the camera is 20 degrees above the horizon then don't adjust the focus target to avoid
876 // extremely large values
877 if ( Math.abs( this.object.up.dot( _ray.direction ) ) < _TILT_LIMIT ) {
878
879 this.object.lookAt( this.target );
880
881 } else {
882
883 _plane.setFromNormalAndCoplanarPoint( this.object.up, this.target );
884 _ray.intersectPlane( _plane, this.target );
885
886 }
887
888 }
889
890 }
891
892 } else if ( this.object.isOrthographicCamera ) {
893
894 const prevZoom = this.object.zoom;
895 this.object.zoom = Math.max( this.minZoom, Math.min( this.maxZoom, this.object.zoom / this._scale ) );
896
897 if ( prevZoom !== this.object.zoom ) {
898
899 this.object.updateProjectionMatrix();
900 zoomChanged = true;
901
902 }
903
904 }
905
906 this._scale = 1;
907 this._performCursorZoom = false;
908
909 // update condition is:
910 // min(camera displacement, camera rotation in radians)^2 > EPS
911 // using small-angle approximation cos(x/2) = 1 - x^2 / 8
912
913 if ( zoomChanged ||
914 this._lastPosition.distanceToSquared( this.object.position ) > _EPS ||
915 8 * ( 1 - this._lastQuaternion.dot( this.object.quaternion ) ) > _EPS ||
916 this._lastTargetPosition.distanceToSquared( this.target ) > _EPS ) {
917
918 this.dispatchEvent( _changeEvent );
919
920 this._lastPosition.copy( this.object.position );
921 this._lastQuaternion.copy( this.object.quaternion );
922 this._lastTargetPosition.copy( this.target );
923
924 return true;
925
926 }
927
928 return false;
929
930 }
931
932 _getAutoRotationAngle( deltaTime ) {
933
934 if ( deltaTime !== null ) {
935
936 return ( _twoPI / 60 * this.autoRotateSpeed ) * deltaTime;
937
938 } else {
939
940 return _twoPI / 60 / 60 * this.autoRotateSpeed;
941
942 }
943
944 }
945
946 _getZoomScale( delta ) {
947
948 const normalizedDelta = Math.abs( delta * 0.01 );
949 return Math.pow( 0.95, this.zoomSpeed * normalizedDelta );
950
951 }
952
953 _rotateLeft( angle ) {
954
955 this._sphericalDelta.theta -= angle;
956
957 }
958
959 _rotateUp( angle ) {
960
961 this._sphericalDelta.phi -= angle;
962
963 }
964
965 _panLeft( distance, objectMatrix ) {
966
967 _v.setFromMatrixColumn( objectMatrix, 0 ); // get X column of objectMatrix
968 _v.multiplyScalar( - distance );
969
970 this._panOffset.add( _v );
971
972 }
973
974 _panUp( distance, objectMatrix ) {
975
976 if ( this.screenSpacePanning === true ) {
977
978 _v.setFromMatrixColumn( objectMatrix, 1 );
979
980 } else {
981
982 _v.setFromMatrixColumn( objectMatrix, 0 );
983 _v.crossVectors( this.object.up, _v );
984
985 }
986
987 _v.multiplyScalar( distance );
988
989 this._panOffset.add( _v );
990
991 }
992
993 // deltaX and deltaY are in pixels; right and down are positive
994 _pan( deltaX, deltaY ) {
995
996 const element = this.domElement;
997
998 if ( this.object.isPerspectiveCamera ) {
999
1000 // perspective
1001 const position = this.object.position;
1002 _v.copy( position ).sub( this.target );
1003 let targetDistance = _v.length();
1004
1005 // half of the fov is center to top of screen
1006 targetDistance *= Math.tan( ( this.object.fov / 2 ) * Math.PI / 180.0 );
1007
1008 // we use only clientHeight here so aspect ratio does not distort speed
1009 this._panLeft( 2 * deltaX * targetDistance / element.clientHeight, this.object.matrix );
1010 this._panUp( 2 * deltaY * targetDistance / element.clientHeight, this.object.matrix );
1011
1012 } else if ( this.object.isOrthographicCamera ) {
1013
1014 // orthographic
1015 this._panLeft( deltaX * ( this.object.right - this.object.left ) / this.object.zoom / element.clientWidth, this.object.matrix );
1016 this._panUp( deltaY * ( this.object.top - this.object.bottom ) / this.object.zoom / element.clientHeight, this.object.matrix );
1017
1018 } else {
1019
1020 // camera neither orthographic nor perspective
1021 console.warn( 'WARNING: OrbitControls.js encountered an unknown camera type - pan disabled.' );
1022 this.enablePan = false;
1023
1024 }
1025
1026 }
1027
1028 _dollyOut( dollyScale ) {
1029
1030 if ( this.object.isPerspectiveCamera || this.object.isOrthographicCamera ) {
1031
1032 this._scale /= dollyScale;
1033
1034 } else {
1035
1036 console.warn( 'WARNING: OrbitControls.js encountered an unknown camera type - dolly/zoom disabled.' );
1037 this.enableZoom = false;
1038
1039 }
1040
1041 }
1042
1043 _dollyIn( dollyScale ) {
1044
1045 if ( this.object.isPerspectiveCamera || this.object.isOrthographicCamera ) {
1046
1047 this._scale *= dollyScale;
1048
1049 } else {
1050
1051 console.warn( 'WARNING: OrbitControls.js encountered an unknown camera type - dolly/zoom disabled.' );
1052 this.enableZoom = false;
1053
1054 }
1055
1056 }
1057
1058 _updateZoomParameters( x, y ) {
1059
1060 if ( ! this.zoomToCursor ) {
1061
1062 return;
1063
1064 }
1065
1066 this._performCursorZoom = true;
1067
1068 const rect = this.domElement.getBoundingClientRect();
1069 const dx = x - rect.left;
1070 const dy = y - rect.top;
1071 const w = rect.width;
1072 const h = rect.height;
1073
1074 this._mouse.x = ( dx / w ) * 2 - 1;
1075 this._mouse.y = - ( dy / h ) * 2 + 1;
1076
1077 this._dollyDirection.set( this._mouse.x, this._mouse.y, 1 ).unproject( this.object ).sub( this.object.position ).normalize();
1078
1079 }
1080
1081 _clampDistance( dist ) {
1082
1083 return Math.max( this.minDistance, Math.min( this.maxDistance, dist ) );
1084
1085 }
1086
1087 //
1088 // event callbacks - update the object state
1089 //
1090
1091 _handleMouseDownRotate( event ) {
1092
1093 this._rotateStart.set( event.clientX, event.clientY );
1094
1095 }
1096
1097 _handleMouseDownDolly( event ) {
1098
1099 this._updateZoomParameters( event.clientX, event.clientX );
1100 this._dollyStart.set( event.clientX, event.clientY );
1101
1102 }
1103
1104 _handleMouseDownPan( event ) {
1105
1106 this._panStart.set( event.clientX, event.clientY );
1107
1108 }
1109
1110 _handleMouseMoveRotate( event ) {
1111
1112 this._rotateEnd.set( event.clientX, event.clientY );
1113
1114 this._rotateDelta.subVectors( this._rotateEnd, this._rotateStart ).multiplyScalar( this.rotateSpeed );
1115
1116 const element = this.domElement;
1117
1118 this._rotateLeft( _twoPI * this._rotateDelta.x / element.clientHeight ); // yes, height
1119
1120 this._rotateUp( _twoPI * this._rotateDelta.y / element.clientHeight );
1121
1122 this._rotateStart.copy( this._rotateEnd );
1123
1124 this.update();
1125
1126 }
1127
1128 _handleMouseMoveDolly( event ) {
1129
1130 this._dollyEnd.set( event.clientX, event.clientY );
1131
1132 this._dollyDelta.subVectors( this._dollyEnd, this._dollyStart );
1133
1134 if ( this._dollyDelta.y > 0 ) {
1135
1136 this._dollyOut( this._getZoomScale( this._dollyDelta.y ) );
1137
1138 } else if ( this._dollyDelta.y < 0 ) {
1139
1140 this._dollyIn( this._getZoomScale( this._dollyDelta.y ) );
1141
1142 }
1143
1144 this._dollyStart.copy( this._dollyEnd );
1145
1146 this.update();
1147
1148 }
1149
1150 _handleMouseMovePan( event ) {
1151
1152 this._panEnd.set( event.clientX, event.clientY );
1153
1154 this._panDelta.subVectors( this._panEnd, this._panStart ).multiplyScalar( this.panSpeed );
1155
1156 this._pan( this._panDelta.x, this._panDelta.y );
1157
1158 this._panStart.copy( this._panEnd );
1159
1160 this.update();
1161
1162 }
1163
1164 _handleMouseWheel( event ) {
1165
1166 this._updateZoomParameters( event.clientX, event.clientY );
1167
1168 if ( event.deltaY < 0 ) {
1169
1170 this._dollyIn( this._getZoomScale( event.deltaY ) );
1171
1172 } else if ( event.deltaY > 0 ) {
1173
1174 this._dollyOut( this._getZoomScale( event.deltaY ) );
1175
1176 }
1177
1178 this.update();
1179
1180 }
1181
1182 _handleKeyDown( event ) {
1183
1184 let needsUpdate = false;
1185
1186 switch ( event.code ) {
1187
1188 case this.keys.UP:
1189
1190 if ( event.ctrlKey || event.metaKey || event.shiftKey ) {
1191
1192 if ( this.enableRotate ) {
1193
1194 this._rotateUp( _twoPI * this.keyRotateSpeed / this.domElement.clientHeight );
1195
1196 }
1197
1198 } else {
1199
1200 if ( this.enablePan ) {
1201
1202 this._pan( 0, this.keyPanSpeed );
1203
1204 }
1205
1206 }
1207
1208 needsUpdate = true;
1209 break;
1210
1211 case this.keys.BOTTOM:
1212
1213 if ( event.ctrlKey || event.metaKey || event.shiftKey ) {
1214
1215 if ( this.enableRotate ) {
1216
1217 this._rotateUp( - _twoPI * this.keyRotateSpeed / this.domElement.clientHeight );
1218
1219 }
1220
1221 } else {
1222
1223 if ( this.enablePan ) {
1224
1225 this._pan( 0, - this.keyPanSpeed );
1226
1227 }
1228
1229 }
1230
1231 needsUpdate = true;
1232 break;
1233
1234 case this.keys.LEFT:
1235
1236 if ( event.ctrlKey || event.metaKey || event.shiftKey ) {
1237
1238 if ( this.enableRotate ) {
1239
1240 this._rotateLeft( _twoPI * this.keyRotateSpeed / this.domElement.clientHeight );
1241
1242 }
1243
1244 } else {
1245
1246 if ( this.enablePan ) {
1247
1248 this._pan( this.keyPanSpeed, 0 );
1249
1250 }
1251
1252 }
1253
1254 needsUpdate = true;
1255 break;
1256
1257 case this.keys.RIGHT:
1258
1259 if ( event.ctrlKey || event.metaKey || event.shiftKey ) {
1260
1261 if ( this.enableRotate ) {
1262
1263 this._rotateLeft( - _twoPI * this.keyRotateSpeed / this.domElement.clientHeight );
1264
1265 }
1266
1267 } else {
1268
1269 if ( this.enablePan ) {
1270
1271 this._pan( - this.keyPanSpeed, 0 );
1272
1273 }
1274
1275 }
1276
1277 needsUpdate = true;
1278 break;
1279
1280 }
1281
1282 if ( needsUpdate ) {
1283
1284 // prevent the browser from scrolling on cursor keys
1285 event.preventDefault();
1286
1287 this.update();
1288
1289 }
1290
1291
1292 }
1293
1294 _handleTouchStartRotate( event ) {
1295
1296 if ( this._pointers.length === 1 ) {
1297
1298 this._rotateStart.set( event.pageX, event.pageY );
1299
1300 } else {
1301
1302 const position = this._getSecondPointerPosition( event );
1303
1304 const x = 0.5 * ( event.pageX + position.x );
1305 const y = 0.5 * ( event.pageY + position.y );
1306
1307 this._rotateStart.set( x, y );
1308
1309 }
1310
1311 }
1312
1313 _handleTouchStartPan( event ) {
1314
1315 if ( this._pointers.length === 1 ) {
1316
1317 this._panStart.set( event.pageX, event.pageY );
1318
1319 } else {
1320
1321 const position = this._getSecondPointerPosition( event );
1322
1323 const x = 0.5 * ( event.pageX + position.x );
1324 const y = 0.5 * ( event.pageY + position.y );
1325
1326 this._panStart.set( x, y );
1327
1328 }
1329
1330 }
1331
1332 _handleTouchStartDolly( event ) {
1333
1334 const position = this._getSecondPointerPosition( event );
1335
1336 const dx = event.pageX - position.x;
1337 const dy = event.pageY - position.y;
1338
1339 const distance = Math.sqrt( dx * dx + dy * dy );
1340
1341 this._dollyStart.set( 0, distance );
1342
1343 }
1344
1345 _handleTouchStartDollyPan( event ) {
1346
1347 if ( this.enableZoom ) this._handleTouchStartDolly( event );
1348
1349 if ( this.enablePan ) this._handleTouchStartPan( event );
1350
1351 }
1352
1353 _handleTouchStartDollyRotate( event ) {
1354
1355 if ( this.enableZoom ) this._handleTouchStartDolly( event );
1356
1357 if ( this.enableRotate ) this._handleTouchStartRotate( event );
1358
1359 }
1360
1361 _handleTouchMoveRotate( event ) {
1362
1363 if ( this._pointers.length == 1 ) {
1364
1365 this._rotateEnd.set( event.pageX, event.pageY );
1366
1367 } else {
1368
1369 const position = this._getSecondPointerPosition( event );
1370
1371 const x = 0.5 * ( event.pageX + position.x );
1372 const y = 0.5 * ( event.pageY + position.y );
1373
1374 this._rotateEnd.set( x, y );
1375
1376 }
1377
1378 this._rotateDelta.subVectors( this._rotateEnd, this._rotateStart ).multiplyScalar( this.rotateSpeed );
1379
1380 const element = this.domElement;
1381
1382 this._rotateLeft( _twoPI * this._rotateDelta.x / element.clientHeight ); // yes, height
1383
1384 this._rotateUp( _twoPI * this._rotateDelta.y / element.clientHeight );
1385
1386 this._rotateStart.copy( this._rotateEnd );
1387
1388 }
1389
1390 _handleTouchMovePan( event ) {
1391
1392 if ( this._pointers.length === 1 ) {
1393
1394 this._panEnd.set( event.pageX, event.pageY );
1395
1396 } else {
1397
1398 const position = this._getSecondPointerPosition( event );
1399
1400 const x = 0.5 * ( event.pageX + position.x );
1401 const y = 0.5 * ( event.pageY + position.y );
1402
1403 this._panEnd.set( x, y );
1404
1405 }
1406
1407 this._panDelta.subVectors( this._panEnd, this._panStart ).multiplyScalar( this.panSpeed );
1408
1409 this._pan( this._panDelta.x, this._panDelta.y );
1410
1411 this._panStart.copy( this._panEnd );
1412
1413 }
1414
1415 _handleTouchMoveDolly( event ) {
1416
1417 const position = this._getSecondPointerPosition( event );
1418
1419 const dx = event.pageX - position.x;
1420 const dy = event.pageY - position.y;
1421
1422 const distance = Math.sqrt( dx * dx + dy * dy );
1423
1424 this._dollyEnd.set( 0, distance );
1425
1426 this._dollyDelta.set( 0, Math.pow( this._dollyEnd.y / this._dollyStart.y, this.zoomSpeed ) );
1427
1428 this._dollyOut( this._dollyDelta.y );
1429
1430 this._dollyStart.copy( this._dollyEnd );
1431
1432 const centerX = ( event.pageX + position.x ) * 0.5;
1433 const centerY = ( event.pageY + position.y ) * 0.5;
1434
1435 this._updateZoomParameters( centerX, centerY );
1436
1437 }
1438
1439 _handleTouchMoveDollyPan( event ) {
1440
1441 if ( this.enableZoom ) this._handleTouchMoveDolly( event );
1442
1443 if ( this.enablePan ) this._handleTouchMovePan( event );
1444
1445 }
1446
1447 _handleTouchMoveDollyRotate( event ) {
1448
1449 if ( this.enableZoom ) this._handleTouchMoveDolly( event );
1450
1451 if ( this.enableRotate ) this._handleTouchMoveRotate( event );
1452
1453 }
1454
1455 // pointers
1456
1457 _addPointer( event ) {
1458
1459 this._pointers.push( event.pointerId );
1460
1461 }
1462
1463 _removePointer( event ) {
1464
1465 delete this._pointerPositions[ event.pointerId ];
1466
1467 for ( let i = 0; i < this._pointers.length; i ++ ) {
1468
1469 if ( this._pointers[ i ] == event.pointerId ) {
1470
1471 this._pointers.splice( i, 1 );
1472 return;
1473
1474 }
1475
1476 }
1477
1478 }
1479
1480 _isTrackingPointer( event ) {
1481
1482 for ( let i = 0; i < this._pointers.length; i ++ ) {
1483
1484 if ( this._pointers[ i ] == event.pointerId ) return true;
1485
1486 }
1487
1488 return false;
1489
1490 }
1491
1492 _trackPointer( event ) {
1493
1494 let position = this._pointerPositions[ event.pointerId ];
1495
1496 if ( position === undefined ) {
1497
1498 position = new Vector2();
1499 this._pointerPositions[ event.pointerId ] = position;
1500
1501 }
1502
1503 position.set( event.pageX, event.pageY );
1504
1505 }
1506
1507 _getSecondPointerPosition( event ) {
1508
1509 const pointerId = ( event.pointerId === this._pointers[ 0 ] ) ? this._pointers[ 1 ] : this._pointers[ 0 ];
1510
1511 return this._pointerPositions[ pointerId ];
1512
1513 }
1514
1515 //
1516
1517 _customWheelEvent( event ) {
1518
1519 const mode = event.deltaMode;
1520
1521 // minimal wheel event altered to meet delta-zoom demand
1522 const newEvent = {
1523 clientX: event.clientX,
1524 clientY: event.clientY,
1525 deltaY: event.deltaY,
1526 };
1527
1528 switch ( mode ) {
1529
1530 case 1: // LINE_MODE
1531 newEvent.deltaY *= 16;
1532 break;
1533
1534 case 2: // PAGE_MODE
1535 newEvent.deltaY *= 100;
1536 break;
1537
1538 }
1539
1540 // detect if event was triggered by pinching
1541 if ( event.ctrlKey && ! this._controlActive ) {
1542
1543 newEvent.deltaY *= 10;
1544
1545 }
1546
1547 return newEvent;
1548
1549 }
1550
1551 }
1552
1553 function onPointerDown( event ) {
1554
1555 if ( this.enabled === false ) return;
1556
1557 if ( this._pointers.length === 0 ) {
1558
1559 this.domElement.setPointerCapture( event.pointerId );
1560
1561 this.domElement.ownerDocument.addEventListener( 'pointermove', this._onPointerMove );
1562 this.domElement.ownerDocument.addEventListener( 'pointerup', this._onPointerUp );
1563
1564 }
1565
1566 //
1567
1568 if ( this._isTrackingPointer( event ) ) return;
1569
1570 //
1571
1572 this._addPointer( event );
1573
1574 if ( event.pointerType === 'touch' ) {
1575
1576 this._onTouchStart( event );
1577
1578 } else {
1579
1580 this._onMouseDown( event );
1581
1582 }
1583
1584 if ( this._cursorStyle === 'grab' ) {
1585
1586 this.domElement.style.cursor = 'grabbing';
1587
1588 }
1589
1590 }
1591
1592 function onPointerMove( event ) {
1593
1594 if ( this.enabled === false ) return;
1595
1596 if ( event.pointerType === 'touch' ) {
1597
1598 this._onTouchMove( event );
1599
1600 } else {
1601
1602 this._onMouseMove( event );
1603
1604 }
1605
1606 }
1607
1608 function onPointerUp( event ) {
1609
1610 this._removePointer( event );
1611
1612 switch ( this._pointers.length ) {
1613
1614 case 0:
1615
1616 this.domElement.releasePointerCapture( event.pointerId );
1617
1618 this.domElement.ownerDocument.removeEventListener( 'pointermove', this._onPointerMove );
1619 this.domElement.ownerDocument.removeEventListener( 'pointerup', this._onPointerUp );
1620
1621 this.dispatchEvent( _endEvent );
1622
1623 this.state = _STATE.NONE;
1624
1625 if ( this._cursorStyle === 'grab' ) {
1626
1627 this.domElement.style.cursor = 'grab';
1628
1629 }
1630
1631 break;
1632
1633 case 1:
1634
1635 const pointerId = this._pointers[ 0 ];
1636 const position = this._pointerPositions[ pointerId ];
1637
1638 // minimal placeholder event - allows state correction on pointer-up
1639 this._onTouchStart( { pointerId: pointerId, pageX: position.x, pageY: position.y } );
1640
1641 break;
1642
1643 }
1644
1645 }
1646
1647 function onMouseDown( event ) {
1648
1649 let mouseAction;
1650
1651 switch ( event.button ) {
1652
1653 case 0:
1654
1655 mouseAction = this.mouseButtons.LEFT;
1656 break;
1657
1658 case 1:
1659
1660 mouseAction = this.mouseButtons.MIDDLE;
1661 break;
1662
1663 case 2:
1664
1665 mouseAction = this.mouseButtons.RIGHT;
1666 break;
1667
1668 default:
1669
1670 mouseAction = - 1;
1671
1672 }
1673
1674 switch ( mouseAction ) {
1675
1676 case MOUSE.DOLLY:
1677
1678 if ( this.enableZoom === false ) return;
1679
1680 this._handleMouseDownDolly( event );
1681
1682 this.state = _STATE.DOLLY;
1683
1684 break;
1685
1686 case MOUSE.ROTATE:
1687
1688 if ( event.ctrlKey || event.metaKey || event.shiftKey ) {
1689
1690 if ( this.enablePan === false ) return;
1691
1692 this._handleMouseDownPan( event );
1693
1694 this.state = _STATE.PAN;
1695
1696 } else {
1697
1698 if ( this.enableRotate === false ) return;
1699
1700 this._handleMouseDownRotate( event );
1701
1702 this.state = _STATE.ROTATE;
1703
1704 }
1705
1706 break;
1707
1708 case MOUSE.PAN:
1709
1710 if ( event.ctrlKey || event.metaKey || event.shiftKey ) {
1711
1712 if ( this.enableRotate === false ) return;
1713
1714 this._handleMouseDownRotate( event );
1715
1716 this.state = _STATE.ROTATE;
1717
1718 } else {
1719
1720 if ( this.enablePan === false ) return;
1721
1722 this._handleMouseDownPan( event );
1723
1724 this.state = _STATE.PAN;
1725
1726 }
1727
1728 break;
1729
1730 default:
1731
1732 this.state = _STATE.NONE;
1733
1734 }
1735
1736 if ( this.state !== _STATE.NONE ) {
1737
1738 this.dispatchEvent( _startEvent );
1739
1740 }
1741
1742 }
1743
1744 function onMouseMove( event ) {
1745
1746 switch ( this.state ) {
1747
1748 case _STATE.ROTATE:
1749
1750 if ( this.enableRotate === false ) return;
1751
1752 this._handleMouseMoveRotate( event );
1753
1754 break;
1755
1756 case _STATE.DOLLY:
1757
1758 if ( this.enableZoom === false ) return;
1759
1760 this._handleMouseMoveDolly( event );
1761
1762 break;
1763
1764 case _STATE.PAN:
1765
1766 if ( this.enablePan === false ) return;
1767
1768 this._handleMouseMovePan( event );
1769
1770 break;
1771
1772 }
1773
1774 }
1775
1776 function onMouseWheel( event ) {
1777
1778 if ( this.enabled === false || this.enableZoom === false || this.state !== _STATE.NONE ) return;
1779
1780 event.preventDefault();
1781
1782 this.dispatchEvent( _startEvent );
1783
1784 this._handleMouseWheel( this._customWheelEvent( event ) );
1785
1786 this.dispatchEvent( _endEvent );
1787
1788 }
1789
1790 function onKeyDown( event ) {
1791
1792 if ( this.enabled === false ) return;
1793
1794 this._handleKeyDown( event );
1795
1796 }
1797
1798 function onTouchStart( event ) {
1799
1800 this._trackPointer( event );
1801
1802 switch ( this._pointers.length ) {
1803
1804 case 1:
1805
1806 switch ( this.touches.ONE ) {
1807
1808 case TOUCH.ROTATE:
1809
1810 if ( this.enableRotate === false ) return;
1811
1812 this._handleTouchStartRotate( event );
1813
1814 this.state = _STATE.TOUCH_ROTATE;
1815
1816 break;
1817
1818 case TOUCH.PAN:
1819
1820 if ( this.enablePan === false ) return;
1821
1822 this._handleTouchStartPan( event );
1823
1824 this.state = _STATE.TOUCH_PAN;
1825
1826 break;
1827
1828 default:
1829
1830 this.state = _STATE.NONE;
1831
1832 }
1833
1834 break;
1835
1836 case 2:
1837
1838 switch ( this.touches.TWO ) {
1839
1840 case TOUCH.DOLLY_PAN:
1841
1842 if ( this.enableZoom === false && this.enablePan === false ) return;
1843
1844 this._handleTouchStartDollyPan( event );
1845
1846 this.state = _STATE.TOUCH_DOLLY_PAN;
1847
1848 break;
1849
1850 case TOUCH.DOLLY_ROTATE:
1851
1852 if ( this.enableZoom === false && this.enableRotate === false ) return;
1853
1854 this._handleTouchStartDollyRotate( event );
1855
1856 this.state = _STATE.TOUCH_DOLLY_ROTATE;
1857
1858 break;
1859
1860 default:
1861
1862 this.state = _STATE.NONE;
1863
1864 }
1865
1866 break;
1867
1868 default:
1869
1870 this.state = _STATE.NONE;
1871
1872 }
1873
1874 if ( this.state !== _STATE.NONE ) {
1875
1876 this.dispatchEvent( _startEvent );
1877
1878 }
1879
1880 }
1881
1882 function onTouchMove( event ) {
1883
1884 this._trackPointer( event );
1885
1886 switch ( this.state ) {
1887
1888 case _STATE.TOUCH_ROTATE:
1889
1890 if ( this.enableRotate === false ) return;
1891
1892 this._handleTouchMoveRotate( event );
1893
1894 this.update();
1895
1896 break;
1897
1898 case _STATE.TOUCH_PAN:
1899
1900 if ( this.enablePan === false ) return;
1901
1902 this._handleTouchMovePan( event );
1903
1904 this.update();
1905
1906 break;
1907
1908 case _STATE.TOUCH_DOLLY_PAN:
1909
1910 if ( this.enableZoom === false && this.enablePan === false ) return;
1911
1912 this._handleTouchMoveDollyPan( event );
1913
1914 this.update();
1915
1916 break;
1917
1918 case _STATE.TOUCH_DOLLY_ROTATE:
1919
1920 if ( this.enableZoom === false && this.enableRotate === false ) return;
1921
1922 this._handleTouchMoveDollyRotate( event );
1923
1924 this.update();
1925
1926 break;
1927
1928 default:
1929
1930 this.state = _STATE.NONE;
1931
1932 }
1933
1934 }
1935
1936 function onContextMenu( event ) {
1937
1938 if ( this.enabled === false ) return;
1939
1940 event.preventDefault();
1941
1942 }
1943
1944 function interceptControlDown( event ) {
1945
1946 if ( event.key === 'Control' ) {
1947
1948 this._controlActive = true;
1949
1950 const document = this.domElement.getRootNode(); // offscreen canvas compatibility
1951
1952 document.addEventListener( 'keyup', this._interceptControlUp, { passive: true, capture: true } );
1953
1954 }
1955
1956 }
1957
1958 function interceptControlUp( event ) {
1959
1960 if ( event.key === 'Control' ) {
1961
1962 this._controlActive = false;
1963
1964 const document = this.domElement.getRootNode(); // offscreen canvas compatibility
1965
1966 document.removeEventListener( 'keyup', this._interceptControlUp, { passive: true, capture: true } );
1967
1968 }
1969
1970 }
1971
1972 export { OrbitControls };
src/web/render/vendor/three/examples/jsm/loaders/GLTFLoader.js added +4925

Diff not shown — 112,645 bytes of diff (limit 100,000). +4925

View file
src/web/render/vendor/three/examples/jsm/utils/BufferGeometryUtils.js added +1501
@@ -0,0 +1,1501 @@
1 import {
2 BufferAttribute,
3 BufferGeometry,
4 Float32BufferAttribute,
5 InstancedBufferAttribute,
6 InterleavedBuffer,
7 InterleavedBufferAttribute,
8 TriangleFanDrawMode,
9 TriangleStripDrawMode,
10 TrianglesDrawMode,
11 Vector3,
12 } from 'three';
13
14 /**
15 * @module BufferGeometryUtils
16 * @three_import import * as BufferGeometryUtils from 'three/addons/utils/BufferGeometryUtils.js';
17 */
18
19 /**
20 * Computes vertex tangents using the MikkTSpace algorithm. MikkTSpace generates the same tangents consistently,
21 * and is used in most modelling tools and normal map bakers. Use MikkTSpace for materials with normal maps,
22 * because inconsistent tangents may lead to subtle visual issues in the normal map, particularly around mirrored
23 * UV seams.
24 *
25 * In comparison to this method, {@link BufferGeometry#computeTangents} (a custom algorithm) generates tangents that
26 * probably will not match the tangents in other software. The custom algorithm is sufficient for general use with a
27 * custom material, and may be faster than MikkTSpace.
28 *
29 * Returns the original BufferGeometry. Indexed geometries will be de-indexed. Requires position, normal, and uv attributes.
30 *
31 * @param {BufferGeometry} geometry - The geometry to compute tangents for.
32 * @param {Object} MikkTSpace - Instance of `examples/jsm/libs/mikktspace.module.js`, or `mikktspace` npm package.
33 * Await `MikkTSpace.ready` before use.
34 * @param {boolean} [negateSign=true] - Whether to negate the sign component (.w) of each tangent.
35 * Required for normal map conventions in some formats, including glTF.
36 * @return {BufferGeometry} The updated geometry.
37 */
38 function computeMikkTSpaceTangents( geometry, MikkTSpace, negateSign = true ) {
39
40 if ( ! MikkTSpace || ! MikkTSpace.isReady ) {
41
42 throw new Error( 'THREE.BufferGeometryUtils: Initialized MikkTSpace library required.' );
43
44 }
45
46 if ( ! geometry.hasAttribute( 'position' ) || ! geometry.hasAttribute( 'normal' ) || ! geometry.hasAttribute( 'uv' ) ) {
47
48 throw new Error( 'THREE.BufferGeometryUtils: Tangents require "position", "normal", and "uv" attributes.' );
49
50 }
51
52 function getAttributeArray( attribute ) {
53
54 if ( attribute.normalized || attribute.isInterleavedBufferAttribute ) {
55
56 const dstArray = new Float32Array( attribute.count * attribute.itemSize );
57
58 for ( let i = 0, j = 0; i < attribute.count; i ++ ) {
59
60 dstArray[ j ++ ] = attribute.getX( i );
61 dstArray[ j ++ ] = attribute.getY( i );
62
63 if ( attribute.itemSize > 2 ) {
64
65 dstArray[ j ++ ] = attribute.getZ( i );
66
67 }
68
69 }
70
71 return dstArray;
72
73 }
74
75 if ( attribute.array instanceof Float32Array ) {
76
77 return attribute.array;
78
79 }
80
81 return new Float32Array( attribute.array );
82
83 }
84
85 // MikkTSpace algorithm requires non-indexed input.
86
87 const _geometry = geometry.index ? geometry.toNonIndexed() : geometry;
88
89 // Compute vertex tangents.
90
91 const tangents = MikkTSpace.generateTangents(
92
93 getAttributeArray( _geometry.attributes.position ),
94 getAttributeArray( _geometry.attributes.normal ),
95 getAttributeArray( _geometry.attributes.uv )
96
97 );
98
99 // Texture coordinate convention of glTF differs from the apparent
100 // default of the MikkTSpace library; .w component must be flipped.
101
102 if ( negateSign ) {
103
104 for ( let i = 3; i < tangents.length; i += 4 ) {
105
106 tangents[ i ] *= - 1;
107
108 }
109
110 }
111
112 //
113
114 _geometry.setAttribute( 'tangent', new BufferAttribute( tangents, 4 ) );
115
116 if ( geometry !== _geometry ) {
117
118 geometry.copy( _geometry );
119
120 }
121
122 return geometry;
123
124 }
125
126 /**
127 * Merges a set of geometries into a single instance. All geometries must have compatible attributes.
128 *
129 * @param {Array<BufferGeometry>} geometries - The geometries to merge.
130 * @param {boolean} [useGroups=false] - Whether to use groups or not.
131 * @return {?BufferGeometry} The merged geometry. Returns `null` if the merge does not succeed.
132 */
133 function mergeGeometries( geometries, useGroups = false ) {
134
135 const isIndexed = geometries[ 0 ].index !== null;
136
137 const attributesUsed = new Set( Object.keys( geometries[ 0 ].attributes ) );
138 const morphAttributesUsed = new Set( Object.keys( geometries[ 0 ].morphAttributes ) );
139
140 const attributes = {};
141 const morphAttributes = {};
142
143 const morphTargetsRelative = geometries[ 0 ].morphTargetsRelative;
144
145 const mergedGeometry = new BufferGeometry();
146
147 let offset = 0;
148
149 for ( let i = 0; i < geometries.length; ++ i ) {
150
151 const geometry = geometries[ i ];
152 let attributesCount = 0;
153
154 // ensure that all geometries are indexed, or none
155
156 if ( isIndexed !== ( geometry.index !== null ) ) {
157
158 console.error( 'THREE.BufferGeometryUtils: .mergeGeometries() failed with geometry at index ' + i + '. All geometries must have compatible attributes; make sure index attribute exists among all geometries, or in none of them.' );
159 return null;
160
161 }
162
163 // gather attributes, exit early if they're different
164
165 for ( const name in geometry.attributes ) {
166
167 if ( ! attributesUsed.has( name ) ) {
168
169 console.error( 'THREE.BufferGeometryUtils: .mergeGeometries() failed with geometry at index ' + i + '. All geometries must have compatible attributes; make sure "' + name + '" attribute exists among all geometries, or in none of them.' );
170 return null;
171
172 }
173
174 if ( attributes[ name ] === undefined ) attributes[ name ] = [];
175
176 attributes[ name ].push( geometry.attributes[ name ] );
177
178 attributesCount ++;
179
180 }
181
182 // ensure geometries have the same number of attributes
183
184 if ( attributesCount !== attributesUsed.size ) {
185
186 console.error( 'THREE.BufferGeometryUtils: .mergeGeometries() failed with geometry at index ' + i + '. Make sure all geometries have the same number of attributes.' );
187 return null;
188
189 }
190
191 // gather morph attributes, exit early if they're different
192
193 if ( morphTargetsRelative !== geometry.morphTargetsRelative ) {
194
195 console.error( 'THREE.BufferGeometryUtils: .mergeGeometries() failed with geometry at index ' + i + '. .morphTargetsRelative must be consistent throughout all geometries.' );
196 return null;
197
198 }
199
200 for ( const name in geometry.morphAttributes ) {
201
202 if ( ! morphAttributesUsed.has( name ) ) {
203
204 console.error( 'THREE.BufferGeometryUtils: .mergeGeometries() failed with geometry at index ' + i + '. .morphAttributes must be consistent throughout all geometries.' );
205 return null;
206
207 }
208
209 if ( morphAttributes[ name ] === undefined ) morphAttributes[ name ] = [];
210
211 morphAttributes[ name ].push( geometry.morphAttributes[ name ] );
212
213 }
214
215 if ( useGroups ) {
216
217 let count;
218
219 if ( isIndexed ) {
220
221 count = geometry.index.count;
222
223 } else if ( geometry.attributes.position !== undefined ) {
224
225 count = geometry.attributes.position.count;
226
227 } else {
228
229 console.error( 'THREE.BufferGeometryUtils: .mergeGeometries() failed with geometry at index ' + i + '. The geometry must have either an index or a position attribute' );
230 return null;
231
232 }
233
234 mergedGeometry.addGroup( offset, count, i );
235
236 offset += count;
237
238 }
239
240 }
241
242 // merge indices
243
244 if ( isIndexed ) {
245
246 let indexOffset = 0;
247 const mergedIndex = [];
248
249 for ( let i = 0; i < geometries.length; ++ i ) {
250
251 const index = geometries[ i ].index;
252
253 for ( let j = 0; j < index.count; ++ j ) {
254
255 mergedIndex.push( index.getX( j ) + indexOffset );
256
257 }
258
259 indexOffset += geometries[ i ].attributes.position.count;
260
261 }
262
263 mergedGeometry.setIndex( mergedIndex );
264
265 }
266
267 // merge attributes
268
269 for ( const name in attributes ) {
270
271 const mergedAttribute = mergeAttributes( attributes[ name ] );
272
273 if ( ! mergedAttribute ) {
274
275 console.error( 'THREE.BufferGeometryUtils: .mergeGeometries() failed while trying to merge the ' + name + ' attribute.' );
276 return null;
277
278 }
279
280 mergedGeometry.setAttribute( name, mergedAttribute );
281
282 }
283
284 // merge morph attributes
285
286 for ( const name in morphAttributes ) {
287
288 const numMorphTargets = morphAttributes[ name ][ 0 ].length;
289 if ( numMorphTargets === 0 ) continue;
290
291 mergedGeometry.morphAttributes = mergedGeometry.morphAttributes || {};
292 mergedGeometry.morphAttributes[ name ] = [];
293
294 for ( let i = 0; i < numMorphTargets; ++ i ) {
295
296 const morphAttributesToMerge = [];
297
298 for ( let j = 0; j < morphAttributes[ name ].length; ++ j ) {
299
300 morphAttributesToMerge.push( morphAttributes[ name ][ j ][ i ] );
301
302 }
303
304 const mergedMorphAttribute = mergeAttributes( morphAttributesToMerge );
305
306 if ( ! mergedMorphAttribute ) {
307
308 console.error( 'THREE.BufferGeometryUtils: .mergeGeometries() failed while trying to merge the ' + name + ' morphAttribute.' );
309 return null;
310
311 }
312
313 mergedGeometry.morphAttributes[ name ].push( mergedMorphAttribute );
314
315 }
316
317 }
318
319 return mergedGeometry;
320
321 }
322
323 /**
324 * Merges a set of attributes into a single instance. All attributes must have compatible properties and types.
325 * Instances of {@link InterleavedBufferAttribute} are not supported.
326 *
327 * @param {Array<BufferAttribute>} attributes - The attributes to merge.
328 * @return {?BufferAttribute} The merged attribute. Returns `null` if the merge does not succeed.
329 */
330 function mergeAttributes( attributes ) {
331
332 let TypedArray;
333 let itemSize;
334 let normalized;
335 let gpuType = - 1;
336 let arrayLength = 0;
337
338 for ( let i = 0; i < attributes.length; ++ i ) {
339
340 const attribute = attributes[ i ];
341
342 if ( TypedArray === undefined ) TypedArray = attribute.array.constructor;
343 if ( TypedArray !== attribute.array.constructor ) {
344
345 console.error( 'THREE.BufferGeometryUtils: .mergeAttributes() failed. BufferAttribute.array must be of consistent array types across matching attributes.' );
346 return null;
347
348 }
349
350 if ( itemSize === undefined ) itemSize = attribute.itemSize;
351 if ( itemSize !== attribute.itemSize ) {
352
353 console.error( 'THREE.BufferGeometryUtils: .mergeAttributes() failed. BufferAttribute.itemSize must be consistent across matching attributes.' );
354 return null;
355
356 }
357
358 if ( normalized === undefined ) normalized = attribute.normalized;
359 if ( normalized !== attribute.normalized ) {
360
361 console.error( 'THREE.BufferGeometryUtils: .mergeAttributes() failed. BufferAttribute.normalized must be consistent across matching attributes.' );
362 return null;
363
364 }
365
366 if ( gpuType === - 1 ) gpuType = attribute.gpuType;
367 if ( gpuType !== attribute.gpuType ) {
368
369 console.error( 'THREE.BufferGeometryUtils: .mergeAttributes() failed. BufferAttribute.gpuType must be consistent across matching attributes.' );
370 return null;
371
372 }
373
374 arrayLength += attribute.count * itemSize;
375
376 }
377
378 const array = new TypedArray( arrayLength );
379 const result = new BufferAttribute( array, itemSize, normalized );
380 let offset = 0;
381
382 for ( let i = 0; i < attributes.length; ++ i ) {
383
384 const attribute = attributes[ i ];
385 if ( attribute.isInterleavedBufferAttribute ) {
386
387 const tupleOffset = offset / itemSize;
388 for ( let j = 0, l = attribute.count; j < l; j ++ ) {
389
390 for ( let c = 0; c < itemSize; c ++ ) {
391
392 const value = attribute.getComponent( j, c );
393 result.setComponent( j + tupleOffset, c, value );
394
395 }
396
397 }
398
399 } else {
400
401 array.set( attribute.array, offset );
402
403 }
404
405 offset += attribute.count * itemSize;
406
407 }
408
409 if ( gpuType !== undefined ) {
410
411 result.gpuType = gpuType;
412
413 }
414
415 return result;
416
417 }
418
419 /**
420 * Performs a deep clone of the given buffer attribute.
421 *
422 * @param {BufferAttribute} attribute - The attribute to clone.
423 * @return {BufferAttribute} The cloned attribute.
424 */
425 function deepCloneAttribute( attribute ) {
426
427 if ( attribute.isInstancedInterleavedBufferAttribute || attribute.isInterleavedBufferAttribute ) {
428
429 return deinterleaveAttribute( attribute );
430
431 }
432
433 if ( attribute.isInstancedBufferAttribute ) {
434
435 return new InstancedBufferAttribute().copy( attribute );
436
437 }
438
439 return new BufferAttribute().copy( attribute );
440
441 }
442
443 /**
444 * Interleaves a set of attributes and returns a new array of corresponding attributes that share a
445 * single {@link InterleavedBuffer} instance. All attributes must have compatible types.
446 *
447 * @param {Array<BufferAttribute>} attributes - The attributes to interleave.
448 * @return {?Array<InterleavedBufferAttribute>} An array of interleaved attributes. If interleave does not succeed, the method returns `null`.
449 */
450 function interleaveAttributes( attributes ) {
451
452 // Interleaves the provided attributes into an InterleavedBuffer and returns
453 // a set of InterleavedBufferAttributes for each attribute
454 let TypedArray;
455 let arrayLength = 0;
456 let stride = 0;
457
458 // calculate the length and type of the interleavedBuffer
459 for ( let i = 0, l = attributes.length; i < l; ++ i ) {
460
461 const attribute = attributes[ i ];
462
463 if ( TypedArray === undefined ) TypedArray = attribute.array.constructor;
464 if ( TypedArray !== attribute.array.constructor ) {
465
466 console.error( 'AttributeBuffers of different types cannot be interleaved' );
467 return null;
468
469 }
470
471 arrayLength += attribute.array.length;
472 stride += attribute.itemSize;
473
474 }
475
476 // Create the set of buffer attributes
477 const interleavedBuffer = new InterleavedBuffer( new TypedArray( arrayLength ), stride );
478 let offset = 0;
479 const res = [];
480 const getters = [ 'getX', 'getY', 'getZ', 'getW' ];
481 const setters = [ 'setX', 'setY', 'setZ', 'setW' ];
482
483 for ( let j = 0, l = attributes.length; j < l; j ++ ) {
484
485 const attribute = attributes[ j ];
486 const itemSize = attribute.itemSize;
487 const count = attribute.count;
488 const iba = new InterleavedBufferAttribute( interleavedBuffer, itemSize, offset, attribute.normalized );
489 res.push( iba );
490
491 offset += itemSize;
492
493 // Move the data for each attribute into the new interleavedBuffer
494 // at the appropriate offset
495 for ( let c = 0; c < count; c ++ ) {
496
497 for ( let k = 0; k < itemSize; k ++ ) {
498
499 iba[ setters[ k ] ]( c, attribute[ getters[ k ] ]( c ) );
500
501 }
502
503 }
504
505 }
506
507 return res;
508
509 }
510
511 /**
512 * Returns a new, non-interleaved version of the given attribute.
513 *
514 * @param {InterleavedBufferAttribute} attribute - The interleaved attribute.
515 * @return {BufferAttribute} The non-interleaved attribute.
516 */
517 function deinterleaveAttribute( attribute ) {
518
519 const cons = attribute.data.array.constructor;
520 const count = attribute.count;
521 const itemSize = attribute.itemSize;
522 const normalized = attribute.normalized;
523
524 const array = new cons( count * itemSize );
525 let newAttribute;
526 if ( attribute.isInstancedInterleavedBufferAttribute ) {
527
528 newAttribute = new InstancedBufferAttribute( array, itemSize, normalized, attribute.meshPerAttribute );
529
530 } else {
531
532 newAttribute = new BufferAttribute( array, itemSize, normalized );
533
534 }
535
536 for ( let i = 0; i < count; i ++ ) {
537
538 newAttribute.setX( i, attribute.getX( i ) );
539
540 if ( itemSize >= 2 ) {
541
542 newAttribute.setY( i, attribute.getY( i ) );
543
544 }
545
546 if ( itemSize >= 3 ) {
547
548 newAttribute.setZ( i, attribute.getZ( i ) );
549
550 }
551
552 if ( itemSize >= 4 ) {
553
554 newAttribute.setW( i, attribute.getW( i ) );
555
556 }
557
558 }
559
560 return newAttribute;
561
562 }
563
564 /**
565 * Deinterleaves all attributes on the given geometry.
566 *
567 * @param {BufferGeometry} geometry - The geometry to deinterleave.
568 */
569 function deinterleaveGeometry( geometry ) {
570
571 const attributes = geometry.attributes;
572 const morphTargets = geometry.morphTargets;
573 const attrMap = new Map();
574
575 for ( const key in attributes ) {
576
577 const attr = attributes[ key ];
578 if ( attr.isInterleavedBufferAttribute ) {
579
580 if ( ! attrMap.has( attr ) ) {
581
582 attrMap.set( attr, deinterleaveAttribute( attr ) );
583
584 }
585
586 attributes[ key ] = attrMap.get( attr );
587
588 }
589
590 }
591
592 for ( const key in morphTargets ) {
593
594 const attr = morphTargets[ key ];
595 if ( attr.isInterleavedBufferAttribute ) {
596
597 if ( ! attrMap.has( attr ) ) {
598
599 attrMap.set( attr, deinterleaveAttribute( attr ) );
600
601 }
602
603 morphTargets[ key ] = attrMap.get( attr );
604
605 }
606
607 }
608
609 }
610
611 /**
612 * Returns the amount of bytes used by all attributes to represent the geometry.
613 *
614 * @param {BufferGeometry} geometry - The geometry.
615 * @return {number} The estimate bytes used.
616 */
617 function estimateBytesUsed( geometry ) {
618
619 // Return the estimated memory used by this geometry in bytes
620 // Calculate using itemSize, count, and BYTES_PER_ELEMENT to account
621 // for InterleavedBufferAttributes.
622 let mem = 0;
623 for ( const name in geometry.attributes ) {
624
625 const attr = geometry.getAttribute( name );
626 mem += attr.count * attr.itemSize * attr.array.BYTES_PER_ELEMENT;
627
628 }
629
630 const indices = geometry.getIndex();
631 mem += indices ? indices.count * indices.itemSize * indices.array.BYTES_PER_ELEMENT : 0;
632 return mem;
633
634 }
635
636 /**
637 * Returns a new geometry with vertices for which all similar vertex attributes (within tolerance) are merged.
638 *
639 * @param {BufferGeometry} geometry - The geometry to merge vertices for.
640 * @param {number} [tolerance=1e-4] - The tolerance value.
641 * @return {BufferGeometry} - The new geometry with merged vertices.
642 */
643 function mergeVertices( geometry, tolerance = 1e-4 ) {
644
645 tolerance = Math.max( tolerance, Number.EPSILON );
646
647 // Generate an index buffer if the geometry doesn't have one, or optimize it
648 // if it's already available.
649 const hashToIndex = {};
650 const indices = geometry.getIndex();
651 const positions = geometry.getAttribute( 'position' );
652 const vertexCount = indices ? indices.count : positions.count;
653
654 // next value for triangle indices
655 let nextIndex = 0;
656
657 // attributes and new attribute arrays
658 const attributeNames = Object.keys( geometry.attributes );
659 const tmpAttributes = {};
660 const tmpMorphAttributes = {};
661 const newIndices = [];
662 const getters = [ 'getX', 'getY', 'getZ', 'getW' ];
663 const setters = [ 'setX', 'setY', 'setZ', 'setW' ];
664
665 // Initialize the arrays, allocating space conservatively. Extra
666 // space will be trimmed in the last step.
667 for ( let i = 0, l = attributeNames.length; i < l; i ++ ) {
668
669 const name = attributeNames[ i ];
670 const attr = geometry.attributes[ name ];
671
672 tmpAttributes[ name ] = new attr.constructor(
673 new attr.array.constructor( attr.count * attr.itemSize ),
674 attr.itemSize,
675 attr.normalized
676 );
677
678 const morphAttributes = geometry.morphAttributes[ name ];
679 if ( morphAttributes ) {
680
681 if ( ! tmpMorphAttributes[ name ] ) tmpMorphAttributes[ name ] = [];
682 morphAttributes.forEach( ( morphAttr, i ) => {
683
684 const array = new morphAttr.array.constructor( morphAttr.count * morphAttr.itemSize );
685 tmpMorphAttributes[ name ][ i ] = new morphAttr.constructor( array, morphAttr.itemSize, morphAttr.normalized );
686
687 } );
688
689 }
690
691 }
692
693 // convert the error tolerance to an amount of decimal places to truncate to
694 const halfTolerance = tolerance * 0.5;
695 const exponent = Math.log10( 1 / tolerance );
696 const hashMultiplier = Math.pow( 10, exponent );
697 const hashAdditive = halfTolerance * hashMultiplier;
698 for ( let i = 0; i < vertexCount; i ++ ) {
699
700 const index = indices ? indices.getX( i ) : i;
701
702 // Generate a hash for the vertex attributes at the current index 'i'
703 let hash = '';
704 for ( let j = 0, l = attributeNames.length; j < l; j ++ ) {
705
706 const name = attributeNames[ j ];
707 const attribute = geometry.getAttribute( name );
708 const itemSize = attribute.itemSize;
709
710 for ( let k = 0; k < itemSize; k ++ ) {
711
712 // Math.trunc() preserves the full Number range, ~~ would wrap to int32
713 hash += `${ Math.trunc( attribute[ getters[ k ] ]( index ) * hashMultiplier + hashAdditive ) },`;
714
715 }
716
717 }
718
719 // Add another reference to the vertex if it's already
720 // used by another index
721 if ( hash in hashToIndex ) {
722
723 newIndices.push( hashToIndex[ hash ] );
724
725 } else {
726
727 // copy data to the new index in the temporary attributes
728 for ( let j = 0, l = attributeNames.length; j < l; j ++ ) {
729
730 const name = attributeNames[ j ];
731 const attribute = geometry.getAttribute( name );
732 const morphAttributes = geometry.morphAttributes[ name ];
733 const itemSize = attribute.itemSize;
734 const newArray = tmpAttributes[ name ];
735 const newMorphArrays = tmpMorphAttributes[ name ];
736
737 for ( let k = 0; k < itemSize; k ++ ) {
738
739 const getterFunc = getters[ k ];
740 const setterFunc = setters[ k ];
741 newArray[ setterFunc ]( nextIndex, attribute[ getterFunc ]( index ) );
742
743 if ( morphAttributes ) {
744
745 for ( let m = 0, ml = morphAttributes.length; m < ml; m ++ ) {
746
747 newMorphArrays[ m ][ setterFunc ]( nextIndex, morphAttributes[ m ][ getterFunc ]( index ) );
748
749 }
750
751 }
752
753 }
754
755 }
756
757 hashToIndex[ hash ] = nextIndex;
758 newIndices.push( nextIndex );
759 nextIndex ++;
760
761 }
762
763 }
764
765 // generate result BufferGeometry
766 const result = geometry.clone();
767 for ( const name in geometry.attributes ) {
768
769 const tmpAttribute = tmpAttributes[ name ];
770
771 result.setAttribute( name, new tmpAttribute.constructor(
772 tmpAttribute.array.slice( 0, nextIndex * tmpAttribute.itemSize ),
773 tmpAttribute.itemSize,
774 tmpAttribute.normalized,
775 ) );
776
777 if ( ! ( name in tmpMorphAttributes ) ) continue;
778
779 for ( let j = 0; j < tmpMorphAttributes[ name ].length; j ++ ) {
780
781 const tmpMorphAttribute = tmpMorphAttributes[ name ][ j ];
782
783 result.morphAttributes[ name ][ j ] = new tmpMorphAttribute.constructor(
784 tmpMorphAttribute.array.slice( 0, nextIndex * tmpMorphAttribute.itemSize ),
785 tmpMorphAttribute.itemSize,
786 tmpMorphAttribute.normalized,
787 );
788
789 }
790
791 }
792
793 // indices
794
795 result.setIndex( newIndices );
796
797 return result;
798
799 }
800
801 /**
802 * Converts the given geometry to the `TrianglesDrawMode` draw mode, which
803 * corresponds to the `gl.TRIANGLES` primitive in WebGL. The conversion only
804 * rewrites the index, so the geometry is modified in place and returned.
805 *
806 * @param {BufferGeometry} geometry - The geometry to convert.
807 * @param {number} drawMode - The current draw mode.
808 * @return {BufferGeometry} The converted geometry using `TrianglesDrawMode`.
809 */
810 function toTrianglesDrawMode( geometry, drawMode ) {
811
812 if ( drawMode === TrianglesDrawMode ) {
813
814 console.warn( 'THREE.BufferGeometryUtils.toTrianglesDrawMode(): Geometry already defined as triangles.' );
815 return geometry;
816
817 }
818
819 if ( drawMode === TriangleFanDrawMode || drawMode === TriangleStripDrawMode ) {
820
821 let index = geometry.getIndex();
822
823 // generate index if not present
824
825 if ( index === null ) {
826
827 const indices = [];
828
829 const position = geometry.getAttribute( 'position' );
830
831 if ( position !== undefined ) {
832
833 for ( let i = 0; i < position.count; i ++ ) {
834
835 indices.push( i );
836
837 }
838
839 geometry.setIndex( indices );
840 index = geometry.getIndex();
841
842 } else {
843
844 console.error( 'THREE.BufferGeometryUtils.toTrianglesDrawMode(): Undefined position attribute. Processing not possible.' );
845 return geometry;
846
847 }
848
849 }
850
851 //
852
853 const numberOfTriangles = index.count - 2;
854 const newIndices = [];
855
856 if ( drawMode === TriangleFanDrawMode ) {
857
858 // gl.TRIANGLE_FAN
859
860 for ( let i = 1; i <= numberOfTriangles; i ++ ) {
861
862 newIndices.push( index.getX( 0 ) );
863 newIndices.push( index.getX( i ) );
864 newIndices.push( index.getX( i + 1 ) );
865
866 }
867
868 } else {
869
870 // gl.TRIANGLE_STRIP
871
872 for ( let i = 0; i < numberOfTriangles; i ++ ) {
873
874 if ( i % 2 === 0 ) {
875
876 newIndices.push( index.getX( i ) );
877 newIndices.push( index.getX( i + 1 ) );
878 newIndices.push( index.getX( i + 2 ) );
879
880 } else {
881
882 newIndices.push( index.getX( i + 2 ) );
883 newIndices.push( index.getX( i + 1 ) );
884 newIndices.push( index.getX( i ) );
885
886 }
887
888 }
889
890 }
891
892 if ( ( newIndices.length / 3 ) !== numberOfTriangles ) {
893
894 console.error( 'THREE.BufferGeometryUtils.toTrianglesDrawMode(): Unable to generate correct amount of triangles.' );
895
896 }
897
898 // updated indices
899
900 geometry.setIndex( newIndices );
901 geometry.clearGroups();
902
903 return geometry;
904
905 } else {
906
907 console.error( 'THREE.BufferGeometryUtils.toTrianglesDrawMode(): Unknown draw mode:', drawMode );
908 return geometry;
909
910 }
911
912 }
913
914 /**
915 * Calculates the morphed attributes of a morphed/skinned BufferGeometry.
916 *
917 * Helpful for Raytracing or Decals (i.e. a `DecalGeometry` applied to a morphed Object with a `BufferGeometry`
918 * will use the original `BufferGeometry`, not the morphed/skinned one, generating an incorrect result.
919 * Using this function to create a shadow `Object3`D the `DecalGeometry` can be correctly generated).
920 *
921 * @param {Mesh|Line|Points} object - The 3D object to compute morph attributes for.
922 * @return {Object} An object with original position/normal attributes and morphed ones.
923 */
924 function computeMorphedAttributes( object ) {
925
926 const _vA = new Vector3();
927 const _vB = new Vector3();
928 const _vC = new Vector3();
929
930 const _tempA = new Vector3();
931 const _tempB = new Vector3();
932 const _tempC = new Vector3();
933
934 const _morphA = new Vector3();
935 const _morphB = new Vector3();
936 const _morphC = new Vector3();
937
938 function _calculateMorphedAttributeData(
939 object,
940 attribute,
941 morphAttribute,
942 morphTargetsRelative,
943 a,
944 b,
945 c,
946 modifiedAttributeArray
947 ) {
948
949 _vA.fromBufferAttribute( attribute, a );
950 _vB.fromBufferAttribute( attribute, b );
951 _vC.fromBufferAttribute( attribute, c );
952
953 const morphInfluences = object.morphTargetInfluences;
954
955 if ( morphAttribute && morphInfluences ) {
956
957 _morphA.set( 0, 0, 0 );
958 _morphB.set( 0, 0, 0 );
959 _morphC.set( 0, 0, 0 );
960
961 for ( let i = 0, il = morphAttribute.length; i < il; i ++ ) {
962
963 const influence = morphInfluences[ i ];
964 const morph = morphAttribute[ i ];
965
966 if ( influence === 0 ) continue;
967
968 _tempA.fromBufferAttribute( morph, a );
969 _tempB.fromBufferAttribute( morph, b );
970 _tempC.fromBufferAttribute( morph, c );
971
972 if ( morphTargetsRelative ) {
973
974 _morphA.addScaledVector( _tempA, influence );
975 _morphB.addScaledVector( _tempB, influence );
976 _morphC.addScaledVector( _tempC, influence );
977
978 } else {
979
980 _morphA.addScaledVector( _tempA.sub( _vA ), influence );
981 _morphB.addScaledVector( _tempB.sub( _vB ), influence );
982 _morphC.addScaledVector( _tempC.sub( _vC ), influence );
983
984 }
985
986 }
987
988 _vA.add( _morphA );
989 _vB.add( _morphB );
990 _vC.add( _morphC );
991
992 }
993
994 if ( object.isSkinnedMesh ) {
995
996 object.applyBoneTransform( a, _vA );
997 object.applyBoneTransform( b, _vB );
998 object.applyBoneTransform( c, _vC );
999
1000 }
1001
1002 modifiedAttributeArray[ a * 3 + 0 ] = _vA.x;
1003 modifiedAttributeArray[ a * 3 + 1 ] = _vA.y;
1004 modifiedAttributeArray[ a * 3 + 2 ] = _vA.z;
1005 modifiedAttributeArray[ b * 3 + 0 ] = _vB.x;
1006 modifiedAttributeArray[ b * 3 + 1 ] = _vB.y;
1007 modifiedAttributeArray[ b * 3 + 2 ] = _vB.z;
1008 modifiedAttributeArray[ c * 3 + 0 ] = _vC.x;
1009 modifiedAttributeArray[ c * 3 + 1 ] = _vC.y;
1010 modifiedAttributeArray[ c * 3 + 2 ] = _vC.z;
1011
1012 }
1013
1014 const geometry = object.geometry;
1015 const material = object.material;
1016
1017 let a, b, c;
1018 const index = geometry.index;
1019 const positionAttribute = geometry.attributes.position;
1020 const morphPosition = geometry.morphAttributes.position;
1021 const morphTargetsRelative = geometry.morphTargetsRelative;
1022 const normalAttribute = geometry.attributes.normal;
1023 const morphNormal = geometry.morphAttributes.normal;
1024
1025 const groups = geometry.groups;
1026 const drawRange = geometry.drawRange;
1027 let i, j, il, jl;
1028 let group;
1029 let start, end;
1030
1031 const modifiedPosition = new Float32Array( positionAttribute.count * positionAttribute.itemSize );
1032 const modifiedNormal = new Float32Array( normalAttribute.count * normalAttribute.itemSize );
1033
1034 if ( index !== null ) {
1035
1036 // indexed buffer geometry
1037
1038 if ( Array.isArray( material ) ) {
1039
1040 for ( i = 0, il = groups.length; i < il; i ++ ) {
1041
1042 group = groups[ i ];
1043
1044 start = Math.max( group.start, drawRange.start );
1045 end = Math.min( ( group.start + group.count ), ( drawRange.start + drawRange.count ) );
1046
1047 for ( j = start, jl = end; j < jl; j += 3 ) {
1048
1049 a = index.getX( j );
1050 b = index.getX( j + 1 );
1051 c = index.getX( j + 2 );
1052
1053 _calculateMorphedAttributeData(
1054 object,
1055 positionAttribute,
1056 morphPosition,
1057 morphTargetsRelative,
1058 a, b, c,
1059 modifiedPosition
1060 );
1061
1062 _calculateMorphedAttributeData(
1063 object,
1064 normalAttribute,
1065 morphNormal,
1066 morphTargetsRelative,
1067 a, b, c,
1068 modifiedNormal
1069 );
1070
1071 }
1072
1073 }
1074
1075 } else {
1076
1077 start = Math.max( 0, drawRange.start );
1078 end = Math.min( index.count, ( drawRange.start + drawRange.count ) );
1079
1080 for ( i = start, il = end; i < il; i += 3 ) {
1081
1082 a = index.getX( i );
1083 b = index.getX( i + 1 );
1084 c = index.getX( i + 2 );
1085
1086 _calculateMorphedAttributeData(
1087 object,
1088 positionAttribute,
1089 morphPosition,
1090 morphTargetsRelative,
1091 a, b, c,
1092 modifiedPosition
1093 );
1094
1095 _calculateMorphedAttributeData(
1096 object,
1097 normalAttribute,
1098 morphNormal,
1099 morphTargetsRelative,
1100 a, b, c,
1101 modifiedNormal
1102 );
1103
1104 }
1105
1106 }
1107
1108 } else {
1109
1110 // non-indexed buffer geometry
1111
1112 if ( Array.isArray( material ) ) {
1113
1114 for ( i = 0, il = groups.length; i < il; i ++ ) {
1115
1116 group = groups[ i ];
1117
1118 start = Math.max( group.start, drawRange.start );
1119 end = Math.min( ( group.start + group.count ), ( drawRange.start + drawRange.count ) );
1120
1121 for ( j = start, jl = end; j < jl; j += 3 ) {
1122
1123 a = j;
1124 b = j + 1;
1125 c = j + 2;
1126
1127 _calculateMorphedAttributeData(
1128 object,
1129 positionAttribute,
1130 morphPosition,
1131 morphTargetsRelative,
1132 a, b, c,
1133 modifiedPosition
1134 );
1135
1136 _calculateMorphedAttributeData(
1137 object,
1138 normalAttribute,
1139 morphNormal,
1140 morphTargetsRelative,
1141 a, b, c,
1142 modifiedNormal
1143 );
1144
1145 }
1146
1147 }
1148
1149 } else {
1150
1151 start = Math.max( 0, drawRange.start );
1152 end = Math.min( positionAttribute.count, ( drawRange.start + drawRange.count ) );
1153
1154 for ( i = start, il = end; i < il; i += 3 ) {
1155
1156 a = i;
1157 b = i + 1;
1158 c = i + 2;
1159
1160 _calculateMorphedAttributeData(
1161 object,
1162 positionAttribute,
1163 morphPosition,
1164 morphTargetsRelative,
1165 a, b, c,
1166 modifiedPosition
1167 );
1168
1169 _calculateMorphedAttributeData(
1170 object,
1171 normalAttribute,
1172 morphNormal,
1173 morphTargetsRelative,
1174 a, b, c,
1175 modifiedNormal
1176 );
1177
1178 }
1179
1180 }
1181
1182 }
1183
1184 const morphedPositionAttribute = new Float32BufferAttribute( modifiedPosition, 3 );
1185 const morphedNormalAttribute = new Float32BufferAttribute( modifiedNormal, 3 );
1186
1187 return {
1188
1189 positionAttribute: positionAttribute,
1190 normalAttribute: normalAttribute,
1191 morphedPositionAttribute: morphedPositionAttribute,
1192 morphedNormalAttribute: morphedNormalAttribute
1193
1194 };
1195
1196 }
1197
1198 /**
1199 * Merges the {@link BufferGeometry#groups} for the given geometry.
1200 *
1201 * @param {BufferGeometry} geometry - The geometry to modify.
1202 * @return {BufferGeometry} - The updated geometry
1203 */
1204 function mergeGroups( geometry ) {
1205
1206 if ( geometry.groups.length === 0 ) {
1207
1208 console.warn( 'THREE.BufferGeometryUtils.mergeGroups(): No groups are defined. Nothing to merge.' );
1209 return geometry;
1210
1211 }
1212
1213 let groups = geometry.groups;
1214
1215 // sort groups by material index
1216
1217 groups = groups.sort( ( a, b ) => {
1218
1219 if ( a.materialIndex !== b.materialIndex ) return a.materialIndex - b.materialIndex;
1220
1221 return a.start - b.start;
1222
1223 } );
1224
1225 // create index for non-indexed geometries
1226
1227 if ( geometry.getIndex() === null ) {
1228
1229 const positionAttribute = geometry.getAttribute( 'position' );
1230 const indices = [];
1231
1232 for ( let i = 0; i < positionAttribute.count; i += 3 ) {
1233
1234 indices.push( i, i + 1, i + 2 );
1235
1236 }
1237
1238 geometry.setIndex( indices );
1239
1240 }
1241
1242 // sort index
1243
1244 const index = geometry.getIndex();
1245
1246 const newIndices = [];
1247
1248 for ( let i = 0; i < groups.length; i ++ ) {
1249
1250 const group = groups[ i ];
1251
1252 const groupStart = group.start;
1253 const groupLength = groupStart + group.count;
1254
1255 for ( let j = groupStart; j < groupLength; j ++ ) {
1256
1257 newIndices.push( index.getX( j ) );
1258
1259 }
1260
1261 }
1262
1263 geometry.dispose(); // Required to force buffer recreation
1264 geometry.setIndex( newIndices );
1265
1266 // update groups indices
1267
1268 let start = 0;
1269
1270 for ( let i = 0; i < groups.length; i ++ ) {
1271
1272 const group = groups[ i ];
1273
1274 group.start = start;
1275 start += group.count;
1276
1277 }
1278
1279 // merge groups
1280
1281 let currentGroup = groups[ 0 ];
1282
1283 geometry.groups = [ currentGroup ];
1284
1285 for ( let i = 1; i < groups.length; i ++ ) {
1286
1287 const group = groups[ i ];
1288
1289 if ( currentGroup.materialIndex === group.materialIndex ) {
1290
1291 currentGroup.count += group.count;
1292
1293 } else {
1294
1295 currentGroup = group;
1296 geometry.groups.push( currentGroup );
1297
1298 }
1299
1300 }
1301
1302 return geometry;
1303
1304 }
1305
1306 /**
1307 * Modifies the supplied geometry if it is non-indexed, otherwise creates a new,
1308 * non-indexed geometry. Returns the geometry with smooth normals everywhere except
1309 * faces that meet at an angle greater than the crease angle.
1310 *
1311 * @param {BufferGeometry} geometry - The geometry to modify.
1312 * @param {number} [creaseAngle=Math.PI/3] - The crease angle in radians.
1313 * @return {BufferGeometry} - The updated geometry
1314 */
1315 function toCreasedNormals( geometry, creaseAngle = Math.PI / 3 /* 60 degrees */ ) {
1316
1317 // BufferGeometry.toNonIndexed() warns if the geometry is non-indexed
1318 // and returns the original geometry
1319 const resultGeometry = geometry.index ? geometry.toNonIndexed() : geometry;
1320 const posAttr = resultGeometry.attributes.position;
1321 const vertexCount = posAttr.count;
1322
1323 let positions;
1324
1325 if ( posAttr.isBufferAttribute === true && posAttr.itemSize === 3 && posAttr.normalized === false ) {
1326
1327 positions = posAttr.array;
1328
1329 } else {
1330
1331 // flatten the position buffer so the math below operates on plain numbers
1332 positions = new Float64Array( vertexCount * 3 );
1333
1334 for ( let i = 0; i < vertexCount; i ++ ) {
1335
1336 positions[ 3 * i + 0 ] = posAttr.getX( i );
1337 positions[ 3 * i + 1 ] = posAttr.getY( i );
1338 positions[ 3 * i + 2 ] = posAttr.getZ( i );
1339
1340 }
1341
1342 }
1343
1344 const creaseDot = Math.cos( creaseAngle );
1345 const hashMultiplier = ( 1 + 1e-10 ) * 1e2;
1346 const faceCount = vertexCount / 3;
1347
1348 // compute the normal of each face
1349 const faceNormals = new Float64Array( faceCount * 3 );
1350 for ( let f = 0; f < faceCount; f ++ ) {
1351
1352 const f9 = 9 * f;
1353 const ax = positions[ f9 + 0 ], ay = positions[ f9 + 1 ], az = positions[ f9 + 2 ];
1354 const bx = positions[ f9 + 3 ], by = positions[ f9 + 4 ], bz = positions[ f9 + 5 ];
1355 const cx = positions[ f9 + 6 ], cy = positions[ f9 + 7 ], cz = positions[ f9 + 8 ];
1356
1357 const v1x = cx - bx, v1y = cy - by, v1z = cz - bz;
1358 const v2x = ax - bx, v2y = ay - by, v2z = az - bz;
1359
1360 const nx = v1y * v2z - v1z * v2y;
1361 const ny = v1z * v2x - v1x * v2z;
1362 const nz = v1x * v2y - v1y * v2x;
1363
1364 const invLength = 1 / ( Math.sqrt( nx * nx + ny * ny + nz * nz ) || 1 );
1365 faceNormals[ 3 * f + 0 ] = nx * invLength;
1366 faceNormals[ 3 * f + 1 ] = ny * invLength;
1367 faceNormals[ 3 * f + 2 ] = nz * invLength;
1368
1369 }
1370
1371 // assign an id to each vertex, sharing the id between vertices with the same
1372 // quantized position via an open-addressed hash table (slots hold id + 1, 0 means empty)
1373 const vertexIds = new Int32Array( vertexCount );
1374 const quantized = new Float64Array( vertexCount * 3 );
1375
1376 let tableSize = 1;
1377 while ( tableSize < vertexCount * 2 ) tableSize <<= 1;
1378 const tableMask = tableSize - 1;
1379 const table = new Int32Array( tableSize );
1380
1381 let uniqueCount = 0;
1382 for ( let i = 0; i < vertexCount; i ++ ) {
1383
1384 const i3 = 3 * i;
1385 const qx = Math.trunc( positions[ i3 + 0 ] * hashMultiplier );
1386 const qy = Math.trunc( positions[ i3 + 1 ] * hashMultiplier );
1387 const qz = Math.trunc( positions[ i3 + 2 ] * hashMultiplier );
1388
1389 let slot = ( Math.imul( qx, 73856093 ) ^ Math.imul( qy, 19349663 ) ^ Math.imul( qz, 83492791 ) ) & tableMask;
1390
1391 while ( true ) {
1392
1393 const id = table[ slot ];
1394
1395 if ( id === 0 ) {
1396
1397 const q3 = 3 * uniqueCount;
1398 quantized[ q3 + 0 ] = qx;
1399 quantized[ q3 + 1 ] = qy;
1400 quantized[ q3 + 2 ] = qz;
1401
1402 table[ slot ] = uniqueCount + 1;
1403 vertexIds[ i ] = uniqueCount ++;
1404 break;
1405
1406 }
1407
1408 const q3 = 3 * ( id - 1 );
1409
1410 if ( quantized[ q3 + 0 ] === qx && quantized[ q3 + 1 ] === qy && quantized[ q3 + 2 ] === qz ) {
1411
1412 vertexIds[ i ] = id - 1;
1413 break;
1414
1415 }
1416
1417 slot = ( slot + 1 ) & tableMask;
1418
1419 }
1420
1421 }
1422
1423 // bucket the faces surrounding each unique vertex position
1424 const bucketOffsets = new Int32Array( uniqueCount + 1 );
1425 for ( let i = 0; i < vertexCount; i ++ ) bucketOffsets[ vertexIds[ i ] + 1 ] ++;
1426 for ( let i = 0; i < uniqueCount; i ++ ) bucketOffsets[ i + 1 ] += bucketOffsets[ i ];
1427
1428 const bucketFaces = new Int32Array( vertexCount );
1429 const bucketCursors = bucketOffsets.slice( 0, uniqueCount );
1430 for ( let f = 0; f < faceCount; f ++ ) {
1431
1432 const f3 = 3 * f;
1433 bucketFaces[ bucketCursors[ vertexIds[ f3 + 0 ] ] ++ ] = f;
1434 bucketFaces[ bucketCursors[ vertexIds[ f3 + 1 ] ] ++ ] = f;
1435 bucketFaces[ bucketCursors[ vertexIds[ f3 + 2 ] ] ++ ] = f;
1436
1437 }
1438
1439 // average the normals of the faces surrounding each vertex if they are within the
1440 // provided crease threshold
1441 const normalArray = new Float32Array( vertexCount * 3 );
1442 for ( let f = 0; f < faceCount; f ++ ) {
1443
1444 const f3 = 3 * f;
1445 const nx = faceNormals[ f3 + 0 ];
1446 const ny = faceNormals[ f3 + 1 ];
1447 const nz = faceNormals[ f3 + 2 ];
1448
1449 for ( let n = 0; n < 3; n ++ ) {
1450
1451 const i = f3 + n;
1452 const id = vertexIds[ i ];
1453
1454 let sumX = 0, sumY = 0, sumZ = 0;
1455
1456 for ( let k = bucketOffsets[ id ], end = bucketOffsets[ id + 1 ]; k < end; k ++ ) {
1457
1458 const o3 = 3 * bucketFaces[ k ];
1459 const ox = faceNormals[ o3 + 0 ];
1460 const oy = faceNormals[ o3 + 1 ];
1461 const oz = faceNormals[ o3 + 2 ];
1462
1463 if ( nx * ox + ny * oy + nz * oz > creaseDot ) {
1464
1465 sumX += ox;
1466 sumY += oy;
1467 sumZ += oz;
1468
1469 }
1470
1471 }
1472
1473 const invLength = 1 / ( Math.sqrt( sumX * sumX + sumY * sumY + sumZ * sumZ ) || 1 );
1474 normalArray[ 3 * i + 0 ] = sumX * invLength;
1475 normalArray[ 3 * i + 1 ] = sumY * invLength;
1476 normalArray[ 3 * i + 2 ] = sumZ * invLength;
1477
1478 }
1479
1480 }
1481
1482 resultGeometry.setAttribute( 'normal', new BufferAttribute( normalArray, 3, false ) );
1483 return resultGeometry;
1484
1485 }
1486
1487 export {
1488 computeMikkTSpaceTangents,
1489 mergeGeometries,
1490 mergeAttributes,
1491 deepCloneAttribute,
1492 deinterleaveAttribute,
1493 deinterleaveGeometry,
1494 interleaveAttributes,
1495 estimateBytesUsed,
1496 mergeVertices,
1497 toTrianglesDrawMode,
1498 computeMorphedAttributes,
1499 mergeGroups,
1500 toCreasedNormals
1501 };
src/web/render/vendor/three/examples/jsm/utils/SkeletonUtils.js added +496
@@ -0,0 +1,496 @@
1 import {
2 AnimationClip,
3 AnimationMixer,
4 Matrix4,
5 Quaternion,
6 QuaternionKeyframeTrack,
7 SkeletonHelper,
8 Vector3,
9 VectorKeyframeTrack
10 } from 'three';
11
12 /**
13 * @module SkeletonUtils
14 * @three_import import * as SkeletonUtils from 'three/addons/utils/SkeletonUtils.js';
15 */
16
17 function getBoneName( bone, options ) {
18
19 if ( options.getBoneName !== undefined ) {
20
21 return options.getBoneName( bone );
22
23 }
24
25 return options.names[ bone.name ];
26
27 }
28
29 /**
30 * Retargets the skeleton from the given source to the target.
31 *
32 * Both `target` and `source` can be a 3D object with a skeleton property (e.g. a skinned mesh)
33 * or a {@link Skeleton} directly.
34 *
35 * @param {Object3D|Skeleton} target - The target object.
36 * @param {Object3D|Skeleton} source - The source object.
37 * @param {module:SkeletonUtils~RetargetOptions} options - The options.
38 */
39 function retarget( target, source, options = {} ) {
40
41 const quat = new Quaternion(),
42 scale = new Vector3(),
43 relativeMatrix = new Matrix4(),
44 globalMatrix = new Matrix4();
45
46 options.preserveBoneMatrix = options.preserveBoneMatrix !== undefined ? options.preserveBoneMatrix : true;
47 options.preserveBonePositions = options.preserveBonePositions !== undefined ? options.preserveBonePositions : true;
48 options.useTargetMatrix = options.useTargetMatrix !== undefined ? options.useTargetMatrix : false;
49 options.hip = options.hip !== undefined ? options.hip : 'hip';
50 options.hipInfluence = options.hipInfluence !== undefined ? options.hipInfluence : new Vector3( 1, 1, 1 );
51 options.scale = options.scale !== undefined ? options.scale : 1;
52 options.names = options.names || {};
53
54 const sourceBones = source.isObject3D ? source.skeleton.bones : getBones( source ),
55 bones = target.isObject3D ? target.skeleton.bones : getBones( target );
56
57 let bone, name, boneTo,
58 bonesPosition;
59
60 // reset bones
61
62 if ( target.isObject3D ) {
63
64 target.skeleton.pose();
65
66 } else {
67
68 options.useTargetMatrix = true;
69 options.preserveBoneMatrix = false;
70
71 }
72
73 if ( options.preserveBonePositions ) {
74
75 bonesPosition = [];
76
77 for ( let i = 0; i < bones.length; i ++ ) {
78
79 bonesPosition.push( bones[ i ].position.clone() );
80
81 }
82
83 }
84
85 if ( options.preserveBoneMatrix ) {
86
87 // reset matrix
88
89 target.updateMatrixWorld();
90
91 target.matrixWorld.identity();
92
93 // reset children matrix
94
95 for ( let i = 0; i < target.children.length; ++ i ) {
96
97 target.children[ i ].updateMatrixWorld( true );
98
99 }
100
101 }
102
103 for ( let i = 0; i < bones.length; ++ i ) {
104
105 bone = bones[ i ];
106 name = getBoneName( bone, options );
107
108 boneTo = getBoneByName( name, sourceBones );
109
110 globalMatrix.copy( bone.matrixWorld );
111
112 if ( boneTo ) {
113
114 boneTo.updateMatrixWorld();
115
116 if ( options.useTargetMatrix ) {
117
118 relativeMatrix.copy( boneTo.matrixWorld );
119
120 } else {
121
122 relativeMatrix.copy( target.matrixWorld ).invert();
123 relativeMatrix.multiply( boneTo.matrixWorld );
124
125 }
126
127 // ignore scale to extract rotation
128
129 scale.setFromMatrixScale( relativeMatrix );
130 relativeMatrix.scale( scale.set( 1 / scale.x, 1 / scale.y, 1 / scale.z ) );
131
132 // apply to global matrix
133
134 globalMatrix.makeRotationFromQuaternion( quat.setFromRotationMatrix( relativeMatrix ) );
135
136 if ( target.isObject3D ) {
137
138 if ( options.localOffsets ) {
139
140 if ( options.localOffsets[ bone.name ] ) {
141
142 globalMatrix.multiply( options.localOffsets[ bone.name ] );
143
144 }
145
146 }
147
148 }
149
150 globalMatrix.copyPosition( relativeMatrix );
151
152 }
153
154 if ( name === options.hip ) {
155
156 globalMatrix.elements[ 12 ] *= options.scale * options.hipInfluence.x;
157 globalMatrix.elements[ 13 ] *= options.scale * options.hipInfluence.y;
158 globalMatrix.elements[ 14 ] *= options.scale * options.hipInfluence.z;
159
160 if ( options.hipPosition !== undefined ) {
161
162 globalMatrix.elements[ 12 ] += options.hipPosition.x * options.scale;
163 globalMatrix.elements[ 13 ] += options.hipPosition.y * options.scale;
164 globalMatrix.elements[ 14 ] += options.hipPosition.z * options.scale;
165
166 }
167
168 }
169
170 if ( bone.parent ) {
171
172 bone.matrix.copy( bone.parent.matrixWorld ).invert();
173 bone.matrix.multiply( globalMatrix );
174
175 } else {
176
177 bone.matrix.copy( globalMatrix );
178
179 }
180
181 bone.matrix.decompose( bone.position, bone.quaternion, bone.scale );
182
183 bone.updateMatrixWorld();
184
185 }
186
187 if ( options.preserveBonePositions ) {
188
189 for ( let i = 0; i < bones.length; ++ i ) {
190
191 bone = bones[ i ];
192 name = getBoneName( bone, options ) || bone.name;
193
194 if ( name !== options.hip ) {
195
196 bone.position.copy( bonesPosition[ i ] );
197
198 }
199
200 }
201
202 }
203
204 if ( options.preserveBoneMatrix ) {
205
206 // restore matrix
207
208 target.updateMatrixWorld( true );
209
210 }
211
212 }
213
214 /**
215 * Retargets the animation clip of the source to the target 3D object.
216 *
217 * The `source` can be a 3D object with a skeleton property (e.g. a skinned mesh)
218 * or a {@link Skeleton} directly.
219 *
220 * @param {Object3D} target - The target 3D object. Must have a `skeleton` property.
221 * @param {Object3D|Skeleton} source - The source object.
222 * @param {AnimationClip} clip - The animation clip.
223 * @param {module:SkeletonUtils~RetargetOptions} options - The options.
224 * @return {AnimationClip} The retargeted animation clip.
225 */
226 function retargetClip( target, source, clip, options = {} ) {
227
228 options.useFirstFramePosition = options.useFirstFramePosition !== undefined ? options.useFirstFramePosition : false;
229
230 // Calculate the fps from the source clip based on the track with the most frames, unless fps is already provided.
231 options.fps = options.fps !== undefined ? options.fps : ( Math.max( ...clip.tracks.map( track => track.times.length ) ) / clip.duration );
232 options.names = options.names || [];
233
234 if ( ! source.isObject3D ) {
235
236 source = getHelperFromSkeleton( source );
237
238 }
239
240 const numFrames = Math.round( clip.duration * ( options.fps / 1000 ) * 1000 ),
241 delta = clip.duration / ( numFrames - 1 ),
242 convertedTracks = [],
243 mixer = new AnimationMixer( source ),
244 bones = getBones( target.skeleton ),
245 boneDatas = [];
246
247 let positionOffset,
248 bone, boneTo, boneData,
249 name;
250
251 mixer.clipAction( clip ).play();
252
253 // trim
254
255 let start = 0, end = numFrames;
256
257 if ( options.trim !== undefined ) {
258
259 start = Math.round( options.trim[ 0 ] * options.fps );
260 end = Math.min( Math.round( options.trim[ 1 ] * options.fps ), numFrames ) - start;
261
262 mixer.update( options.trim[ 0 ] );
263
264 } else {
265
266 mixer.update( 0 );
267
268 }
269
270 source.updateMatrixWorld();
271
272 //
273
274 for ( let frame = 0; frame < end; ++ frame ) {
275
276 const time = frame * delta;
277
278 retarget( target, source, options );
279
280 for ( let j = 0; j < bones.length; ++ j ) {
281
282 bone = bones[ j ];
283 name = getBoneName( bone, options ) || bone.name;
284 boneTo = getBoneByName( name, source.skeleton );
285
286 if ( boneTo ) {
287
288 boneData = boneDatas[ j ] = boneDatas[ j ] || { bone: bone };
289
290 if ( options.hip === name ) {
291
292 if ( ! boneData.pos ) {
293
294 boneData.pos = {
295 times: new Float32Array( end ),
296 values: new Float32Array( end * 3 )
297 };
298
299 }
300
301 if ( options.useFirstFramePosition ) {
302
303 if ( frame === 0 ) {
304
305 positionOffset = bone.position.clone();
306
307 }
308
309 bone.position.sub( positionOffset );
310
311 }
312
313 boneData.pos.times[ frame ] = time;
314
315 bone.position.toArray( boneData.pos.values, frame * 3 );
316
317 }
318
319 if ( ! boneData.quat ) {
320
321 boneData.quat = {
322 times: new Float32Array( end ),
323 values: new Float32Array( end * 4 )
324 };
325
326 }
327
328 boneData.quat.times[ frame ] = time;
329
330 bone.quaternion.toArray( boneData.quat.values, frame * 4 );
331
332 }
333
334 }
335
336 if ( frame === end - 2 ) {
337
338 // last mixer update before final loop iteration
339 // make sure we do not go over or equal to clip duration
340 mixer.update( delta - 0.0000001 );
341
342 } else {
343
344 mixer.update( delta );
345
346 }
347
348 source.updateMatrixWorld();
349
350 }
351
352 for ( let i = 0; i < boneDatas.length; ++ i ) {
353
354 boneData = boneDatas[ i ];
355
356 if ( boneData ) {
357
358 if ( boneData.pos ) {
359
360 convertedTracks.push( new VectorKeyframeTrack(
361 '.bones[' + boneData.bone.name + '].position',
362 boneData.pos.times,
363 boneData.pos.values
364 ) );
365
366 }
367
368 convertedTracks.push( new QuaternionKeyframeTrack(
369 '.bones[' + boneData.bone.name + '].quaternion',
370 boneData.quat.times,
371 boneData.quat.values
372 ) );
373
374 }
375
376 }
377
378 mixer.uncacheAction( clip );
379
380 return new AnimationClip( clip.name, - 1, convertedTracks );
381
382 }
383
384 /**
385 * Clones the given 3D object and its descendants, ensuring that any `SkinnedMesh` instances are
386 * correctly associated with their bones. Bones are also cloned, and must be descendants of the
387 * object passed to this method. Other data, like geometries and materials, are reused by reference.
388 *
389 * @param {Object3D} source - The 3D object to clone.
390 * @return {Object3D} The cloned 3D object.
391 */
392 function clone( source ) {
393
394 const sourceLookup = new Map();
395 const cloneLookup = new Map();
396
397 const clone = source.clone();
398
399 parallelTraverse( source, clone, function ( sourceNode, clonedNode ) {
400
401 sourceLookup.set( clonedNode, sourceNode );
402 cloneLookup.set( sourceNode, clonedNode );
403
404 } );
405
406 clone.traverse( function ( node ) {
407
408 if ( ! node.isSkinnedMesh ) return;
409
410 const clonedMesh = node;
411 const sourceMesh = sourceLookup.get( node );
412 const sourceBones = sourceMesh.skeleton.bones;
413
414 clonedMesh.skeleton = sourceMesh.skeleton.clone();
415 clonedMesh.bindMatrix.copy( sourceMesh.bindMatrix );
416
417 clonedMesh.skeleton.bones = sourceBones.map( function ( bone ) {
418
419 return cloneLookup.get( bone );
420
421 } );
422
423 clonedMesh.bind( clonedMesh.skeleton, clonedMesh.bindMatrix );
424
425 } );
426
427 return clone;
428
429 }
430
431 // internal helper
432
433 function getBoneByName( name, skeleton ) {
434
435 for ( let i = 0, bones = getBones( skeleton ); i < bones.length; i ++ ) {
436
437 if ( name === bones[ i ].name )
438
439 return bones[ i ];
440
441 }
442
443 }
444
445 function getBones( skeleton ) {
446
447 return Array.isArray( skeleton ) ? skeleton : skeleton.bones;
448
449 }
450
451
452 function getHelperFromSkeleton( skeleton ) {
453
454 const source = new SkeletonHelper( skeleton.bones[ 0 ] );
455 source.skeleton = skeleton;
456
457 return source;
458
459 }
460
461 function parallelTraverse( a, b, callback ) {
462
463 callback( a, b );
464
465 for ( let i = 0; i < a.children.length; i ++ ) {
466
467 parallelTraverse( a.children[ i ], b.children[ i ], callback );
468
469 }
470
471 }
472
473 /**
474 * Retarget options of `SkeletonUtils`.
475 *
476 * @typedef {Object} module:SkeletonUtils~RetargetOptions
477 * @property {boolean} [useFirstFramePosition=false] - Whether to use the position of the first frame or not.
478 * @property {number} [fps] - The FPS of the clip.
479 * @property {Object<string,string>} [names] - A dictionary for mapping target to source bone names.
480 * @property {function(string):string} [getBoneName] - A function for mapping bone names. Alternative to `names`.
481 * @property {Array<number>} [trim] - Whether to trim the clip or not. If set the array should hold two values for the start and end.
482 * @property {boolean} [preserveBoneMatrix=true] - Whether to preserve bone matrices or not.
483 * @property {boolean} [preserveBonePositions=true] - Whether to preserve bone positions or not.
484 * @property {boolean} [useTargetMatrix=false] - Whether to use the target matrix or not.
485 * @property {string} [hip='hip'] - The name of the source's hip bone.
486 * @property {Vector3} [hipInfluence=(1,1,1)] - The hip influence.
487 * @property {number} [scale=1] - The scale.
488 * @property {Object<string,Matrix4>} [localOffsets] - Per-bone local offset matrices, keyed by bone name.
489 * @property {Vector3} [hipPosition] - An additional position offset applied to the hip bone.
490 **/
491
492 export {
493 retarget,
494 retargetClip,
495 clone,
496 };
src/web/style.ts +2
@@ -27,6 +27,7 @@ import { boardCss } from "./pages/board_style.ts";
2727 import { base, layers, reset, utilities } from "./style/base.ts";
2828 import { components } from "./style/components.ts";
2929 import { content } from "./style/content.ts";
30 import { model3dCss } from "./render/model3d-css.ts";
3031 import { pickCss } from "./pick.ts";
3132 import { studioCss } from "./studio/style.ts";
3233
@@ -39,6 +40,7 @@ export const css = [
3940 studioCss,
4041 boardCss,
4142 content,
43 model3dCss,
4244 utilities,
4345 swatchCss,
4446 pickCss,
test/blob-media-ceiling.test.ts +1 −1
@@ -54,7 +54,7 @@ describe("the page ceiling, by kind", () => {
5454 const pdf = stub();
5555 const found = rendererFor("doc/big.pdf", capped(blob(PDF)), { pdf: pdf.render });
5656 expect(found).toBeDefined();
57 found!.render({ path: "doc/big.pdf", size: PDF.length, body: found!.body, media: "/m" });
57 found!.render({ path: "doc/big.pdf", size: PDF.length, body: found!.body, media: "/m", download: "/m?download=1" });
5858 expect(pdf.seen).toHaveLength(1);
5959 expect(pdf.seen[0]!.body.length).toBe(0);
6060 expect(pdf.seen[0]!.media).toBe("/m");
test/config-broken-language.test.ts +1 −1
@@ -4,7 +4,7 @@ import { config } from "../src/web/render/config.ts";
44
55 const input = (path: string, text: string) => {
66 const body = new TextEncoder().encode(text);
7 return { path, size: body.length, body, media: `/o/r/media/main/${path}` };
7 return { path, size: body.length, body, media: `/o/r/media/main/${path}`, download: `/o/r/raw/main/${path}` };
88 };
99
1010 test("a broken YAML file is one plain line, then its source with the failing line marked", () => {
test/config-too-many.test.ts +1 −1
@@ -6,7 +6,7 @@ import { MAX_FIELDS } from "../src/web/render/config-form.ts";
66 test("a JSON file over MAX_FIELDS is one plain muted line, not a card", () => {
77 const big = JSON.stringify(Object.fromEntries(Array.from({ length: MAX_FIELDS + 1 }, (_, i) => [`k${i}`, i])));
88 const body = new TextEncoder().encode(big);
9 const out = String(config({ path: "big.json", size: body.length, body, media: "/o/r/media/main/big.json" }).content);
9 const out = String(config({ path: "big.json", size: body.length, body, media: "/o/r/media/main/big.json", download: "/o/r/raw/main/big.json" }).content);
1010 expect(out).toContain("too many for a form. Source shows it whole.");
1111 expect(out).toContain('<p class="muted">');
1212 expect(out).not.toContain("card");
test/config-yaml-toml.test.ts +1 −1
@@ -142,7 +142,7 @@ describe("YAML", () => {
142142 test("the blob page draws the form, and says so for a broken file", () => {
143143 const input = (text: string) => {
144144 const body = new TextEncoder().encode(text);
145 return { path: "a.yml", size: body.length, body, media: "/o/r/media/main/a.yml" };
145 return { path: "a.yml", size: body.length, body, media: "/o/r/media/main/a.yml", download: "/o/r/raw/main/a.yml" };
146146 };
147147 expect(String(config(input(YAML)).content)).toContain("config-form");
148148 expect(String(config(input("a: [1\n")).content)).toContain("Not valid YAML");
test/fixtures/model3d/cube-ascii.stl added +86
@@ -0,0 +1,86 @@
1 solid cube
2 facet normal 0 0 0
3 outer loop
4 vertex 0 0 0
5 vertex 20 20 0
6 vertex 20 0 0
7 endloop
8 endfacet
9 facet normal 0 0 0
10 outer loop
11 vertex 0 0 0
12 vertex 0 20 0
13 vertex 20 20 0
14 endloop
15 endfacet
16 facet normal 0 0 0
17 outer loop
18 vertex 0 0 20
19 vertex 20 0 20
20 vertex 20 20 20
21 endloop
22 endfacet
23 facet normal 0 0 0
24 outer loop
25 vertex 0 0 20
26 vertex 20 20 20
27 vertex 0 20 20
28 endloop
29 endfacet
30 facet normal 0 0 0
31 outer loop
32 vertex 0 0 0
33 vertex 20 0 0
34 vertex 20 0 20
35 endloop
36 endfacet
37 facet normal 0 0 0
38 outer loop
39 vertex 0 0 0
40 vertex 20 0 20
41 vertex 0 0 20
42 endloop
43 endfacet
44 facet normal 0 0 0
45 outer loop
46 vertex 20 0 0
47 vertex 20 20 0
48 vertex 20 20 20
49 endloop
50 endfacet
51 facet normal 0 0 0
52 outer loop
53 vertex 20 0 0
54 vertex 20 20 20
55 vertex 20 0 20
56 endloop
57 endfacet
58 facet normal 0 0 0
59 outer loop
60 vertex 20 20 0
61 vertex 0 20 0
62 vertex 0 20 20
63 endloop
64 endfacet
65 facet normal 0 0 0
66 outer loop
67 vertex 20 20 0
68 vertex 0 20 20
69 vertex 20 20 20
70 endloop
71 endfacet
72 facet normal 0 0 0
73 outer loop
74 vertex 0 20 0
75 vertex 0 0 0
76 vertex 0 0 20
77 endloop
78 endfacet
79 facet normal 0 0 0
80 outer loop
81 vertex 0 20 0
82 vertex 0 0 20
83 vertex 0 20 20
84 endloop
85 endfacet
86 endsolid cube
test/fixtures/model3d/cube-binary.stl added
Binary file not shown.
test/fixtures/model3d/cube.obj added +15
@@ -0,0 +1,15 @@
1 # a 20 mm cube, quads
2 v 0 0 0
3 v 20 0 0
4 v 20 20 0
5 v 0 20 0
6 v 0 0 20
7 v 20 0 20
8 v 20 20 20
9 v 0 20 20
10 f 1//1 2//1 3//1 4//1
11 f 5//1 8//1 7//1 6//1
12 f 1//1 5//1 6//1 2//1
13 f 2//1 6//1 7//1 3//1
14 f 3//1 7//1 8//1 4//1
15 f 5//1 1//1 4//1 8//1
test/model3d.test.ts added +309
@@ -0,0 +1,309 @@
1 /**
2 * The 3D view (#7335): STL in both forms and OBJ parsed to the same cube,
3 * the blob page's frame naming the viewer bundle and the media route, a
4 * file too large to read still drawn because the browser fetches it, and
5 * the server never parsing a model — a broken STL gets the same frame.
6 */
7
8 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
9 import { mkdtemp, rm } from "node:fs/promises";
10 import { tmpdir } from "node:os";
11 import { join } from "node:path";
12 import { createApp, type App } from "../src/app.ts";
13 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
14 import { serve, type Servers } from "../src/http/server.ts";
15 import { bounds, isBinaryStl, MeshError, parseObj, parseStl, sizeText } from "../src/web/render/mesh.ts";
16 import { model3d, model3dAsset, model3dAssetPath } from "../src/web/render/model3d.ts";
17 import { RENDERERS, rendererFor } from "../src/web/render/renderers.ts";
18 import { REFUSED_URI, withinModelDir } from "../src/web/render/gltf-uri.ts";
19 import { modelFacts, type ModelFacts } from "../src/web/render/model-facts.ts";
20 import { moduleLoaderJs, shellJs } from "../src/web/client.ts";
21 import { seedAdmin } from "./helpers/admin.ts";
22 import { quietLogs } from "./helpers/globals.ts";
23
24 const modelLine = (m: ModelFacts): string => `${m.title} · ${m.facts}`;
25
26 quietLogs();
27
28 const FIXTURES = join(import.meta.dir, "fixtures", "model3d");
29 const fixture = async (name: string): Promise<Uint8Array> => Bun.file(join(FIXTURES, name)).bytes();
30
31 describe("the mesh parsers", () => {
32 test("an ASCII and a binary cube are 12 triangles in a 20 × 20 × 20 box", async () => {
33 for (const name of ["cube-ascii.stl", "cube-binary.stl"]) {
34 const mesh = parseStl(await fixture(name));
35 expect([name, mesh.triangles]).toEqual([name, 12]);
36 expect([name, bounds(mesh.positions).size]).toEqual([name, [20, 20, 20]]);
37 }
38 });
39
40 test("a binary STL whose header opens with `solid` is still read as binary", async () => {
41 const bytes = await fixture("cube-binary.stl");
42 expect(new TextDecoder().decode(bytes.subarray(0, 5))).toBe("solid");
43 expect(isBinaryStl(bytes)).toBe(true);
44 expect(isBinaryStl(await fixture("cube-ascii.stl"))).toBe(false);
45 });
46
47 test("an OBJ of six quads is the same cube, fanned into triangles", async () => {
48 const mesh = parseObj(new TextDecoder().decode(await fixture("cube.obj")));
49 expect(mesh.triangles).toBe(12);
50 expect(bounds(mesh.positions)).toEqual({ min: [0, 0, 0], max: [20, 20, 20], size: [20, 20, 20] });
51 });
52
53 test("OBJ counts a negative index back from the last vertex", () => {
54 const mesh = parseObj("v 0 0 0\nv 1 0 0\nv 0 2 0\nf -3 -2 -1\n");
55 expect(mesh.triangles).toBe(1);
56 expect(bounds(mesh.positions).size).toEqual([1, 2, 0]);
57 });
58
59 test("a file that is not the format says so instead of drawing nonsense", () => {
60 expect(() => parseObj("v 0 0 0\nf 1 2 3\n")).toThrow(MeshError);
61 expect(() => parseObj("v 0 zero 0\n")).toThrow(MeshError);
62 expect(() => parseStl(new TextEncoder().encode("hello"))).toThrow(MeshError);
63 expect(() =>
64 parseStl(new TextEncoder().encode("solid x\nfacet\nouter loop\nvertex 0 0 0\nvertex 1 0 0\nendloop\nendfacet\n")),
65 ).toThrow(MeshError);
66 });
67
68 test("the header's box reads in millimetres", () => {
69 expect(sizeText([20, 20.004, 3.5])).toBe("20 × 20 × 3.5 mm");
70 });
71 });
72
73 describe("which files the 3D view draws", () => {
74 const body = new Uint8Array([0x73, 0x6f, 0x6c, 0x69, 0x64]);
75 const here = { media: true };
76 test("STL, OBJ, GLB and glTF go to the 3D view", () => {
77 for (const path of ["a.stl", "b/c.OBJ", "d.gltf"])
78 expect([path, rendererFor(path, { kind: "text", body, size: 5 }, RENDERERS, here)?.render]).toEqual([path, model3d]);
79 const glb = new TextEncoder().encode("glTF\x02\0\0\0");
80 expect(rendererFor("e.glb", { kind: "binary", body: glb, size: 8 }, RENDERERS, here)?.render).toBe(model3d);
81 });
82
83 test("a part too large for the page to read is still drawn: the browser fetches it", () => {
84 expect(rendererFor("tower.stl", { kind: "too_large", size: 30_000_000 }, RENDERERS, here)?.render).toBe(model3d);
85 // A kind that reads its body is not handed one it does not have.
86 expect(rendererFor("big.md", { kind: "too_large", size: 30_000_000 }, RENDERERS, here)?.render).toBeUndefined();
87 });
88
89 test("over the size cap there is no view, and the page offers Download", () => {
90 expect(rendererFor("huge.stl", { kind: "too_large", size: 200_000_000 }, RENDERERS, here)?.render).toBeUndefined();
91 });
92
93 test("a page with no media route (a live browse of another forge) keeps a model's source view", () => {
94 expect(rendererFor("a.stl", { kind: "text", body, size: 5 }, RENDERERS, { media: false })?.render).toBeUndefined();
95 // Audio and video play from the media route too, so they keep theirs.
96 expect(rendererFor("a.webm", { kind: "too_large", size: 9_000_000 }, RENDERERS, { media: false })).toBeUndefined();
97 // Markdown reads its body, so it is drawn there as it always was.
98 expect(rendererFor("a.md", { kind: "text", body, size: 5 }, RENDERERS, { media: false })?.render).toBeDefined();
99 });
100
101 test("the frame names the media route, the viewer bundle and a Download, and carries no script", () => {
102 const media = "/alice/parts/media/main/tower/clip.stl";
103 const download = `${media}?download=1`;
104 const { content, lines } = model3d({ path: "tower/clip.stl", size: 684, body, media, download });
105 expect(lines).toBe(0);
106 expect(content.text).toContain(`data-src="${media}"`);
107 expect(content.text).toContain('data-format="stl"');
108 expect(content.text).toContain(`data-module="${model3dAssetPath()}"`);
109 expect(content.text).toContain(`href="${download}"`);
110 expect(content.text).not.toContain("<script");
111 expect(model3dAssetPath()).toMatch(/^\/assets\/model3d-[0-9a-f]{16}\.js$/);
112 });
113
114 test("the toggles are script-only buttons that say whether they are held, and the note is the empty state", () => {
115 const { content } = model3d({ path: "a.stl", size: 5, body, media: "/m", download: "/m?download=1" });
116 const toggles = content.text.match(/<button[^>]*>/g) ?? [];
117 expect(toggles).toHaveLength(2);
118 for (const b of toggles) {
119 expect(b).toContain('type="button"');
120 expect(b).toMatch(/class="btn js-only"/);
121 expect(b).toContain('aria-pressed="false"');
122 }
123 expect(content.text).toContain('value="wireframe"');
124 expect(content.text).toContain('value="axes"');
125 expect(content.text).toContain('class="empty"');
126 expect(content.text).toContain("#i-file");
127 });
128 });
129
130 describe("what a glTF may make the browser fetch", () => {
131 const page = "https://forge.test/alice/parts/blob/main/tower/arm.gltf";
132 const dir = "/alice/parts/media/main/tower/";
133 test("a file beside the model, or below it, is fetched", () => {
134 expect(withinModelDir("/alice/parts/media/main/tower/arm.bin", dir, page)).toBe(true);
135 expect(withinModelDir("/alice/parts/media/main/tower/tex/skin.png", dir, page)).toBe(true);
136 expect(withinModelDir("https://forge.test/alice/parts/media/main/tower/arm.bin", dir, page)).toBe(true);
137 });
138
139 test("anything that walks out, or leaves the origin, is refused", () => {
140 for (const uri of [
141 "/alice/parts/media/main/tower/../../../../../settings/tokens",
142 "/alice/parts/media/main/tower/%2e%2e/%2e%2e/secret",
143 "/alice/parts/media/main/tower/..%2f..%2fsecret",
144 "/alice/parts/media/main/tower/..%5csecret",
145 "/api/v1/user",
146 "/alice/parts/media/main/towerish/x.bin",
147 "//evil.test/x.bin",
148 "https://evil.test/alice/parts/media/main/tower/x.bin",
149 "https://user:[email protected]/alice/parts/media/main/tower/x.bin",
150 "data:application/octet-stream;base64,AAAA",
151 "blob:https://forge.test/1234",
152 "javascript:alert(1)",
153 dir,
154 ])
155 expect([uri, withinModelDir(uri, dir, page)]).toEqual([uri, false]);
156 expect(REFUSED_URI.startsWith("about:")).toBe(true);
157 });
158 });
159
160 describe("a model in words, for the terminal", () => {
161 test("STL in either form and OBJ: the format, the triangles and the box in mm", async () => {
162 for (const name of ["cube-ascii.stl", "cube-binary.stl"]) {
163 const bytes = await fixture(name);
164 expect(modelLine(modelFacts(name, bytes, bytes.length))).toBe("3D model · STL · 12 triangles · 20 × 20 × 20 mm");
165 }
166 const obj = await fixture("cube.obj");
167 expect(modelLine(modelFacts("cube.obj", obj, obj.length))).toBe("3D model · OBJ · 12 triangles · 20 × 20 × 20 mm");
168 });
169
170 test("a GLB is read from its JSON: vertex counts, and position bounds in metres", () => {
171 const text = JSON.stringify({
172 asset: { version: "2.0" },
173 accessors: [
174 { count: 8, min: [0, 0, 0], max: [0.02, 0.02, 0.02] },
175 { count: 36 },
176 ],
177 meshes: [{ primitives: [{ attributes: { POSITION: 0 }, indices: 1 }] }],
178 });
179 // A GLB chunk is padded with spaces to four bytes.
180 const chunk = new TextEncoder().encode(text.padEnd(Math.ceil(text.length / 4) * 4, " "));
181 const glb = new Uint8Array(20 + chunk.length);
182 const view = new DataView(glb.buffer);
183 view.setUint32(0, 0x46546c67, true);
184 view.setUint32(4, 2, true);
185 view.setUint32(8, glb.length, true);
186 view.setUint32(12, chunk.length, true);
187 view.setUint32(16, 0x4e4f534a, true);
188 glb.set(chunk, 20);
189 expect(modelLine(modelFacts("cube.glb", glb, glb.length))).toBe("3D model · GLB · 12 triangles · 20 × 20 × 20 mm");
190 });
191
192 test("over the cap a model is named and sized, not read; a broken one says so", () => {
193 expect(modelLine(modelFacts("big.stl", null, 30_000_000))).toBe("3D model · STL · 30.0 MB");
194 expect(modelLine(modelFacts("bad.stl", new TextEncoder().encode("nope"), 4))).toBe(
195 "3D model · STL · not a readable STL file",
196 );
197 });
198 });
199
200 describe("the shell's module loader", () => {
201 /** The loader against a stand-in document, as the shell runs it. */
202 function loader(modules: string[]) {
203 const added: { type: string; src: string; nonce: string }[] = [];
204 const doc = {
205 currentScript: { nonce: "n0nce" },
206 head: { appendChild: (s: { type: string; src: string; nonce: string }) => added.push({ ...s }) },
207 createElement: () => ({ type: "", src: "", nonce: "" }),
208 querySelectorAll: () => modules.map((m) => ({ getAttribute: () => m })),
209 };
210 const load = new Function("doc", `${moduleLoaderJs}; return loadModules;`)(doc) as () => void;
211 return { load, added, modules };
212 }
213
214 test("a figure's first-party module is loaded once, with the shell's nonce, even across swaps", () => {
215 const l = loader([model3dAssetPath()]);
216 l.load();
217 l.load(); // the code browser swapped the same kind of file in again
218 expect(l.added).toEqual([{ type: "module", src: model3dAssetPath(), nonce: "n0nce" }]);
219 });
220
221 test("anything but an asset's exact shape is refused", () => {
222 const l = loader([
223 "/assets/../alice/x/raw/main/evil.js",
224 "/assets/model3d-abc.js?x=1",
225 "//evil.test/assets/a.js",
226 "https://evil.test/assets/a.js",
227 "/assets/A.js",
228 "/alice/x/media/main/a.js",
229 ]);
230 l.load();
231 expect(l.added).toEqual([]);
232 });
233
234 test("the shell carries the loader and runs it on a swap", () => {
235 expect(shellJs).toContain(moduleLoaderJs);
236 expect(shellJs).toContain("doc.addEventListener('code:swap', loadModules)");
237 });
238 });
239
240 describe("the 3D view on the forge", () => {
241 let dataDir = "";
242 let app: App;
243 let servers: Servers;
244 let base = "";
245
246 beforeAll(async () => {
247 dataDir = await mkdtemp(join(tmpdir(), "forge-model3d-"));
248 app = createApp({ dataDir, port: 0 });
249 servers = await serve(app);
250 base = servers.url;
251 const a = await seedAdmin(app, { username: "alice", email: "[email protected]", password: "a-long-model3d-passphrase" });
252 if (!a.ok) throw new Error("signup failed");
253 const repo = await createRepo(app, a.value, { name: "tower", visibility: "public" });
254 if (!repo.ok) throw new Error("repo failed");
255 const made = await commitToBranch(app, a.value, repo.value, {
256 message: "parts",
257 files: [
258 { path: "parts/cube.stl", content: await fixture("cube-binary.stl") },
259 { path: "parts/broken.stl", content: "this is not a model\n" },
260 ],
261 });
262 if (!made.ok) throw new Error("commit failed");
263 });
264
265 afterAll(async () => {
266 await servers.stop();
267 app.close();
268 await rm(dataDir, { recursive: true, force: true });
269 });
270
271 test("the blob page carries the viewer's module and the media URL, and Source and Download stay", async () => {
272 const res = await fetch(`${base}/alice/tower/blob/main/parts/cube.stl`);
273 expect(res.status).toBe(200);
274 const page = await res.text();
275 expect(page).toContain(`data-module="${model3dAssetPath()}"`);
276 expect(page).toContain('data-src="/alice/tower/media/main/parts/cube.stl"');
277 expect(page).toContain('href="/alice/tower/blob/main/parts/cube.stl?view=source"');
278 expect(page).toContain('href="/alice/tower/media/main/parts/cube.stl?download=1"');
279 // The module is a first-party script, so the page's own-origin script policy admits it.
280 expect(res.headers.get("content-security-policy")).toContain("script-src 'self'");
281
282 const bytes = await fetch(`${base}/alice/tower/media/main/parts/cube.stl`);
283 expect(bytes.headers.get("content-type")).toBe("model/stl");
284 expect(Buffer.from(await bytes.arrayBuffer()).equals(Buffer.from(await fixture("cube-binary.stl")))).toBe(true);
285 });
286
287 test("the code browser's swapped-in fragment carries the module the shell loads", async () => {
288 const res = await fetch(`${base}/alice/tower/blob/main/parts/cube.stl?fragment=1`);
289 expect(res.status).toBe(200);
290 expect(await res.text()).toContain(`data-module="${model3dAssetPath()}"`);
291 });
292
293 test("the server never parses the model: a broken STL gets the same frame", async () => {
294 const page = await (await fetch(`${base}/alice/tower/blob/main/parts/broken.stl`)).text();
295 expect(page).toContain('data-src="/alice/tower/media/main/parts/broken.stl"');
296 });
297
298 test("the viewer bundle is served immutable, with three.js and the parsers inside", async () => {
299 const res = await fetch(`${base}${model3dAssetPath()}`);
300 expect(res.status).toBe(200);
301 expect(res.headers.get("content-type")).toBe("text/javascript; charset=utf-8");
302 expect(res.headers.get("cache-control")).toBe("public, max-age=31536000, immutable");
303 const code = await res.text();
304 expect(code).toBe((await model3dAsset())!.code);
305 expect(code).toContain("a facet without three vertices");
306 expect(code).toContain("WebGLRenderer");
307 expect((await fetch(`${base}/assets/model3d-0000000000000000.js`)).status).toBe(404);
308 });
309 });
test/render-archive.test.ts +2 −2
@@ -125,13 +125,13 @@ describe("archive limits", () => {
125125 expect(r.entries.length).toBe(ENTRY_CAP);
126126 expect(r.count).toBe(ENTRY_CAP + 25);
127127 expect(r.bytes).toBe(ENTRY_CAP + 25);
128 const page = archive({ path: "x.zip", size: 1, body: zip(files), media: "/m" });
128 const page = archive({ path: "x.zip", size: 1, body: zip(files), media: "/m", download: "/d" });
129129 expect(String(page.content)).toContain("25 more entries are not listed");
130130 });
131131 });
132132
133133 describe("archive page", () => {
134 const draw = (path: string, body: Uint8Array) => archive({ path, size: body.length, body, media: "/m" });
134 const draw = (path: string, body: Uint8Array) => archive({ path, size: body.length, body, media: "/m", download: "/d" });
135135 test("a name that is markup is escaped", () => {
136136 const out = String(draw("x.zip", zip([{ name: "<script>alert(1)</script>", size: 1 }])).content);
137137 expect(out).not.toContain("<script>");
test/render-csv.test.ts +3 −3
@@ -15,7 +15,7 @@ const ok = (s: string) => {
1515 return t;
1616 };
1717 const draw = (text: string, all = false) =>
18 csv({ path: "d.csv", size: text.length, body: enc(text), media: "/m", pathHref: "/o/r/blob/main/d.csv", all });
18 csv({ path: "d.csv", size: text.length, body: enc(text), media: "/m", download: "/d", pathHref: "/o/r/blob/main/d.csv", all });
1919
2020 describe("csv parser", () => {
2121 test("quotes, escaped quotes and newline inside a field", () => {
@@ -45,7 +45,7 @@ describe("csv parser", () => {
4545 describe("csv bounds", () => {
4646 test("one very wide row cannot multiply the page", () => {
4747 const text = "h\n" + "x\n".repeat(1_000) + ",".repeat(100_000) + "\n";
48 const r = csv({ path: "w.csv", size: text.length, body: enc(text), media: "/m", pathHref: "/p", all: false });
48 const r = csv({ path: "w.csv", size: text.length, body: enc(text), media: "/m", download: "/d", pathHref: "/p", all: false });
4949 const h = String(r.content);
5050 expect(h.length).toBeLessThan(2_000_000);
5151 expect(h).toContain(`Showing ${COLUMN_CAP} of 100,001 columns`);
@@ -152,7 +152,7 @@ describe("csv on the blob page", () => {
152152
153153 describe("json lines as a table", () => {
154154 const lines = (text: string) =>
155 csv({ path: "d.jsonl", size: text.length, body: enc(text), media: "/m", pathHref: "/o/r/blob/main/d.jsonl" });
155 csv({ path: "d.jsonl", size: text.length, body: enc(text), media: "/m", download: "/d", pathHref: "/o/r/blob/main/d.jsonl" });
156156 const text =
157157 '{"a":1,"b":"x"}\n{"c":{"k":[1,2]},"a":"<script>alert(1)</script>"}\nnot json\n\n{"b":null}\n';
158158 test("kinds: jsonl and ndjson are csv, and carry the table script", () => {
test/render-ics.test.ts +1 −1
@@ -5,7 +5,7 @@ import { fileKind } from "../src/web/render/kinds.ts";
55 import { RENDERERS } from "../src/web/render/renderers.ts";
66
77 const enc = (s: string): Uint8Array => new TextEncoder().encode(s);
8 const draw = (text: string) => ics({ path: "a/cal.ics", size: text.length, body: enc(text), media: "/m" });
8 const draw = (text: string) => ics({ path: "a/cal.ics", size: text.length, body: enc(text), media: "/m", download: "/d" });
99 const wrap = (...events: string[]) =>
1010 `BEGIN:VCALENDAR\r\nVERSION:2.0\r\n${events.map((e) => `BEGIN:VEVENT\r\n${e}\r\nEND:VEVENT\r\n`).join("")}END:VCALENDAR\r\n`;
1111
test/render-image.test.ts +1 −1
@@ -99,7 +99,7 @@ describe("image blob pages", () => {
9999
100100 test("the image cap decides: over it no <img>, under it one", () => {
101101 const draw = (size: number): string =>
102 renderBlob({ base: "/alice/pics", caps: { write: false }, facts: { name: "pics" } } as unknown as RepoScope, {
102 renderBlob({ base: "/alice/pics", kind: "local", caps: { write: false }, facts: { name: "pics" } } as unknown as RepoScope, {
103103 refs: { branches: ["main"], tags: [] } as RefSet,
104104 ref: "main",
105105 path: "a.png",
test/render-kinds.test.ts +1
@@ -45,6 +45,7 @@ const SAMPLES: Record<string, [Uint8Array, FileKind, string]> = {
4545 jsonl: [enc("{\"a\":1}\n"), "csv", "application/octet-stream"],
4646 ndjson: [enc("{\"a\":1}\n"), "csv", "application/octet-stream"],
4747 gltf: [enc('{"asset":{}}'), "model3d", "application/octet-stream"],
48 obj: [enc("v 0 0 0\n"), "model3d", "application/octet-stream"],
4849 json: [enc("{}"), "config", "application/octet-stream"],
4950 jsonc: [enc("{}"), "config", "application/octet-stream"],
5051 yaml: [enc("a: 1\n"), "config", "application/octet-stream"],
test/render-patch.test.ts +1 −1
@@ -4,7 +4,7 @@ import { patch, VALUE_CAP } from "../src/web/render/patch.ts";
44 import { rendererFor } from "../src/web/render/renderers.ts";
55
66 const enc = (s: string): Uint8Array => new TextEncoder().encode(s);
7 const draw = (text: string) => patch({ path: "x.patch", size: text.length, body: enc(text), media: "/m" });
7 const draw = (text: string) => patch({ path: "x.patch", size: text.length, body: enc(text), media: "/m", download: "/d" });
88
99 const DIFF = `diff --git a/one.txt b/one.txt
1010 index 1111111..2222222 100644
test/render-sqlite.test.ts +1 −1
@@ -22,7 +22,7 @@ const make = (): Uint8Array => {
2222 };
2323 const DB = make();
2424 const draw = (body: Uint8Array, table?: string) =>
25 sqlite({ path: "a.db", size: body.length, body, media: "/m", pathHref: "/o/r/blob/main/a.db", ...(table ? { table } : {}) });
25 sqlite({ path: "a.db", size: body.length, body, media: "/m", download: "/d", pathHref: "/o/r/blob/main/a.db", ...(table ? { table } : {}) });
2626
2727 describe("sqlite kind", () => {
2828 test("the header confirms it; a .db without it is binary", () => {
test/render-subtitles.test.ts +1 −1
@@ -6,7 +6,7 @@ import { RENDERERS } from "../src/web/render/renderers.ts";
66
77 const enc = (s: string): Uint8Array => new TextEncoder().encode(s);
88 const draw = (text: string, path = "a.srt") =>
9 subtitles({ path, size: text.length, body: enc(text), media: "/m" });
9 subtitles({ path, size: text.length, body: enc(text), media: "/m", download: "/d" });
1010 const html = (text: string, path?: string): string => String(draw(text, path).content);
1111
1212 const SRT = "1\r\n00:00:01,500 --> 00:00:03,000\r\nHello <i>there</i>\r\n\r\n2\r\n01:02:03,004 --> 01:02:05,000\r\n{\\an8}Line one\r\nLine two\r\n";
test/tui-model3d.test.ts added +114
@@ -0,0 +1,114 @@
1 /**
2 * The terminal's door to a 3D model (#7335): where the page draws the part,
3 * the file view names it, counts its triangles, measures it in millimetres
4 * and says where to download it, instead of "Binary file".
5 */
6
7 import { afterAll, beforeAll, expect, test } from "bun:test";
8 import { mkdtemp, rm } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
12 import { createApp, type App as Forge } from "../src/app.ts";
13 import { getUserByName } from "../src/core/users.ts";
14 import { createForgeApp } from "../src/tui/forge.ts";
15 import { headlessTerminal } from "../src/tui/term/terminal.ts";
16 import { seedAdmin } from "./helpers/admin.ts";
17 import { quietLogs } from "./helpers/globals.ts";
18 import { settleApp, settleUntil } from "./helpers/tui.ts";
19
20 quietLogs();
21
22 let dataDir = "";
23 let forge: Forge;
24
25 beforeAll(async () => {
26 dataDir = await mkdtemp(join(tmpdir(), "forge-tui-model3d-"));
27 forge = createApp({ dataDir, port: 0, url: "https://forge.test" });
28 const a = await seedAdmin(forge, { username: "alice", email: "[email protected]", password: "a-long-passphrase" });
29 if (!a.ok) throw new Error("signup failed");
30 const made = await createRepo(forge, a.value, { name: "tower", visibility: "public" });
31 if (!made.ok) throw new Error("repo failed");
32 await commitToBranch(forge, a.value, made.value, {
33 message: "parts",
34 files: [
35 { path: "README.md", content: "# tower\n" },
36 { path: "parts/cube.stl", content: await Bun.file(join(import.meta.dir, "fixtures", "model3d", "cube-binary.stl")).bytes() },
37 ],
38 });
39 });
40
41 afterAll(async () => {
42 forge.close();
43 await rm(dataDir, { recursive: true, force: true });
44 });
45
46 test("a binary STL reads as a 3D model with its triangles, its box and where to download it", async () => {
47 const user = getUserByName(forge.db, "alice");
48 if (user === undefined) throw new Error("no alice");
49 const { app } = createForgeApp(
50 forge,
51 { sshUsername: "alice", remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user },
52 { terminal: headlessTerminal(140, 40), exitHooks: false },
53 );
54 app.start();
55 const send = (key: string): void => app.dispatch({ type: "key", key, ctrl: false, alt: false, shift: false });
56 try {
57 await settleApp(app);
58 send("/");
59 for (const ch of "tower") send(ch);
60 send("enter");
61 send("enter");
62 await settleUntil(app, "alice/tower");
63 send("t");
64 await settleApp(app);
65 for (const ch of "cubestl") send(ch);
66 send("enter");
67 await settleUntil(app, "12 triangles");
68 const text = app.screen.toText();
69 expect(text).toContain("3D model · STL");
70 expect(text).toContain("12 triangles · 20 × 20 × 20 mm");
71 expect(text).toContain("Download: https://forge.test/alice/tower/media/main/parts/cube.stl?download=1");
72 expect(text).not.toContain("Binary file");
73 } finally {
74 app.stop();
75 }
76 });
77
78 test("a guest in the terminal gets nothing of a model in a private repo", async () => {
79 const alice = getUserByName(forge.db, "alice");
80 if (alice === undefined) throw new Error("no alice");
81 const made = await createRepo(forge, alice, { name: "secret-part", visibility: "private" });
82 if (!made.ok) throw new Error("repo failed");
83 await commitToBranch(forge, alice, made.value, {
84 message: "part",
85 files: [{ path: "hidden.stl", content: await Bun.file(join(import.meta.dir, "fixtures", "model3d", "cube-binary.stl")).bytes() }],
86 });
87 const { app } = createForgeApp(
88 forge,
89 { sshUsername: "git", remoteAddress: "127.0.0.1", fingerprint: "SHA256:unknown" },
90 { terminal: headlessTerminal(140, 40), exitHooks: false },
91 );
92 app.start();
93 const send = (key: string): void => app.dispatch({ type: "key", key, ctrl: false, alt: false, shift: false });
94 try {
95 await settleApp(app);
96 send("/");
97 for (const ch of "secret-part") send(ch);
98 send("enter");
99 send("enter");
100 await settleApp(app);
101 send("t");
102 await settleApp(app);
103 for (const ch of "hiddenstl") send(ch);
104 send("enter");
105 await settleApp(app);
106 const text = app.screen.toText();
107 expect(text).not.toContain("secret-part");
108 expect(text).not.toContain("12 triangles");
109 expect(text).not.toContain("hidden.stl");
110 expect(text).not.toContain("3D model");
111 } finally {
112 app.stop();
113 }
114 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs