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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/7335-files-render-in-the-browser-stl-and-obj → main 24 commits fork 4 threads · 4 unresolved
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. | ||
| 71 | 71 | `kinds.ts` is plain data with no web dependency: the editor and the |
| 72 | 72 | terminal ask it whether a path is Markdown, as the blob page does. |
| 73 | 73 | |
| 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 | |
| 77 | 81 | file's header has the **Raw** and **Download** buttons. |
| 78 | 82 | |
| 79 | 83 | ## Audio and video (#7333) |
| @@ -127,6 +131,55 @@ name it once in `RENDERERS` (`render/renderers.ts`); `rendererFor` picks it. Poi | ||
| 127 | 131 | that needs the bytes at `input.media`, never at an inline copy, so |
| 128 | 132 | repository content only ever runs under the media route's sandbox. `render/markdown.ts` is the example. |
| 129 | 133 | |
| 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 | ||
| 130 | 183 | ## Images and SVG (#7331) |
| 131 | 184 | |
| 132 | 185 | `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 { | ||
| 192 | 192 | treePage, |
| 193 | 193 | } from "../web/pages/repo.ts"; |
| 194 | 194 | import { mediaBlob } from "../web/pages/media.ts"; |
| 195 | import { model3dAsset } from "../web/render/model3d.ts"; | |
| 195 | 196 | import { |
| 196 | 197 | branchCreate, |
| 197 | 198 | branchDelete, |
| @@ -770,6 +771,11 @@ export function buildRouter(): Router<Handler> { | ||
| 770 | 771 | const term = await machineTermAsset(); |
| 771 | 772 | return term !== null && ctx.params.name === term.name ? assetResponse(ctx.request, term) : notFound(); |
| 772 | 773 | } |
| 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 | } | |
| 773 | 779 | const asset = await highlightAsset(); |
| 774 | 780 | if (asset === null || ctx.params.name !== asset.name) return notFound(); |
| 775 | 781 | return assetResponse(ctx.request, asset); |
src/tui/screens/workspace.ts +35 −4
| @@ -50,6 +50,8 @@ import type { TreeEntry } from "../../git/read.ts"; | ||
| 50 | 50 | import { parseUnifiedDiff, type Diff } from "../../git/diff.ts"; |
| 51 | 51 | import { renderDiff as renderPatch } from "../ui/diff.ts"; |
| 52 | 52 | 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"; | |
| 53 | 55 | import type { RepoFacts, RepoSource } from "../../view/source.ts"; |
| 54 | 56 | import { detectLanguage, highlightLines } from "../../web/highlight.ts"; |
| 55 | 57 | import { MAX_COLUMNS, parseCsv, type Table } from "../../text/csv.ts"; |
| @@ -349,6 +351,8 @@ class Tab { | ||
| 349 | 351 | loading = true; |
| 350 | 352 | /** Non-empty when there is a reason the bytes are not on screen. */ |
| 351 | 353 | note = ""; |
| 354 | noteTitle = "Not shown"; | |
| 355 | noteDetail = ""; | |
| 352 | 356 | raw: Uint8Array | null = null; |
| 353 | 357 | plain: string[] = []; |
| 354 | 358 | lines: Span[][] = []; |
| @@ -528,6 +532,10 @@ class Tab { | ||
| 528 | 532 | /** One blob, as the workspace keeps it: text, a picture, or a reason. */ |
| 529 | 533 | interface CacheEntry { |
| 530 | 534 | 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; | |
| 531 | 539 | readonly raw: Uint8Array | null; |
| 532 | 540 | readonly picture: Uint8Array | null; |
| 533 | 541 | } |
| @@ -606,6 +614,11 @@ export function workspaceScreen( | ||
| 606 | 614 | // arriving from a search hit means "show me *that*", not "show me where I |
| 607 | 615 | // was". Everything else is restored. |
| 608 | 616 | 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 | }; | |
| 609 | 622 | let facts: RepoFacts | undefined; |
| 610 | 623 | let roots: Node[] = memory?.roots ?? []; |
| 611 | 624 | let loadedTree = roots.length > 0; |
| @@ -800,6 +813,15 @@ export function workspaceScreen( | ||
| 800 | 813 | const read = await src.blob(ref, t.path); |
| 801 | 814 | if (stale(mine)) return; |
| 802 | 815 | 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 | }; | |
| 803 | 825 | let raw: Uint8Array | null = null; |
| 804 | 826 | let picture: Uint8Array | null = null; |
| 805 | 827 | if (!read.ok) |
| @@ -808,12 +830,15 @@ export function workspaceScreen( | ||
| 808 | 830 | ? "No such file at this revision." |
| 809 | 831 | : errorText(read.error); |
| 810 | 832 | 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. | |
| 812 | 835 | const media = |
| 813 | 836 | read.value.kind === "text" |
| 814 | 837 | ? undefined |
| 815 | 838 | : 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; | |
| 817 | 842 | else if (read.value.kind === "too_large") |
| 818 | 843 | note = `${formatSize(read.value.size)} — too large to display.`; |
| 819 | 844 | else if (read.value.kind === "binary") { |
| @@ -827,12 +852,14 @@ export function workspaceScreen( | ||
| 827 | 852 | `Binary file, ${formatSize(read.value.size)}.`; |
| 828 | 853 | } else raw = read.value.body; |
| 829 | 854 | } |
| 830 | entry = { note, raw, picture }; | |
| 855 | entry = { note, noteTitle, noteDetail, raw, picture }; | |
| 831 | 856 | remember(key, entry); |
| 832 | 857 | } |
| 833 | 858 | if (stale(mine)) return; |
| 834 | 859 | t.loading = false; |
| 835 | 860 | t.note = entry.note; |
| 861 | t.noteTitle = entry.noteTitle; | |
| 862 | t.noteDetail = entry.noteDetail; | |
| 836 | 863 | t.raw = entry.raw; |
| 837 | 864 | t.image = |
| 838 | 865 | entry.picture === null |
| @@ -2345,7 +2372,11 @@ export function workspaceScreen( | ||
| 2345 | 2372 | return; |
| 2346 | 2373 | } |
| 2347 | 2374 | 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 | }); | |
| 2349 | 2380 | return; |
| 2350 | 2381 | } |
| 2351 | 2382 | if (t.mode === "edit") { |
src/tui/ui/widgets.ts +4 −1
| @@ -915,7 +915,7 @@ export function emptyState( | ||
| 915 | 915 | s: Screen, |
| 916 | 916 | r: Rect, |
| 917 | 917 | c: Tokens, |
| 918 | o: { title: string; body?: string; action?: string }, | |
| 918 | o: { title: string; body?: string; detail?: string; action?: string }, | |
| 919 | 919 | ): void { |
| 920 | 920 | if (r.w <= 0 || r.h < 3) return; |
| 921 | 921 | const w = Math.min(r.w, 44); |
| @@ -924,6 +924,9 @@ export function emptyState( | ||
| 924 | 924 | s.line(inner, 2, truncate(o.title, w), style(c.fg, c.bg, BOLD), "center"); |
| 925 | 925 | if (o.body !== undefined && inner.h > 3) |
| 926 | 926 | 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"); | |
| 927 | 930 | if (o.action !== undefined && inner.h > 5) |
| 928 | 931 | button( |
| 929 | 932 | s, |
src/web/client.ts +31
| @@ -1274,6 +1274,32 @@ export const editorJs = /* js */ ` | ||
| 1274 | 1274 | })(); |
| 1275 | 1275 | `; |
| 1276 | 1276 | |
| 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 | ||
| 1277 | 1303 | const shellCore = /* js */ ` |
| 1278 | 1304 | (function () { |
| 1279 | 1305 | var doc = document; |
| @@ -1418,6 +1444,11 @@ const shellCore = /* js */ ` | ||
| 1418 | 1444 | paint(); |
| 1419 | 1445 | } |
| 1420 | 1446 | |
| 1447 | // ---- a heavy viewer, loaded only by the page that shows one ---- | |
| 1448 | ${moduleLoaderJs} | |
| 1449 | doc.addEventListener('code:swap', loadModules); | |
| 1450 | loadModules(); | |
| 1451 | ||
| 1421 | 1452 | // ---- settings: the section list narrows as you type ---- |
| 1422 | 1453 | doc.addEventListener('input', function (e) { |
| 1423 | 1454 | 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})$/; | ||
| 50 | 50 | export const mediaHref = (base: string, ref: string, path: string): string => |
| 51 | 51 | `${base}/media/${encodeURIComponent(ref)}/${path}`; |
| 52 | 52 | |
| 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 | ||
| 53 | 57 | export async function mediaBlob(ctx: Ctx): Promise<Response> { |
| 54 | 58 | // Server-Timing: `session` as the router measured it, `git` the object |
| 55 | 59 | // 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"; | ||
| 90 | 90 | import { hasTable } from "../render/sqlite.ts"; |
| 91 | 91 | import { fileKind, HEAD_BYTES, RENDER_CAP } from "../render/kinds.ts"; |
| 92 | 92 | import { RENDERERS, rendererFor } from "../render/renderers.ts"; |
| 93 | import { mediaHref } from "./media.ts"; | |
| 93 | import { downloadHref, mediaHref } from "./media.ts"; | |
| 94 | 94 | import { browseBase, localScope, type RepoScope } from "../scope.ts"; |
| 95 | 95 | import { repoForkOf } from "../../core/origins.ts"; |
| 96 | 96 | import { recentMove } from "../../core/transfer.ts"; |
| @@ -685,10 +685,6 @@ export async function readmeFor( | ||
| 685 | 685 | */ |
| 686 | 686 | export type BlobView = "source" | "rendered"; |
| 687 | 687 | |
| 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 | ||
| 692 | 688 | export function blobView(value: string | null): BlobView { |
| 693 | 689 | return value === "source" ? "source" : "rendered"; |
| 694 | 690 | } |
| @@ -1095,7 +1091,7 @@ export function editAction(scope: RepoScope, ref: string, path: string): Html { | ||
| 1095 | 1091 | * a repository on this forge has. |
| 1096 | 1092 | */ |
| 1097 | 1093 | 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" }); | |
| 1099 | 1095 | return found?.render === RENDERERS.pdf && scope.kind !== "local" ? undefined : found; |
| 1100 | 1096 | } |
| 1101 | 1097 | |
| @@ -1158,6 +1154,7 @@ export function renderBlob( | ||
| 1158 | 1154 | size: blob.size, |
| 1159 | 1155 | body: renderer.body, |
| 1160 | 1156 | media: mediaHref(base, o.ref, o.path), |
| 1157 | download: downloadHref(base, o.ref, o.path), | |
| 1161 | 1158 | pathHref: `${base}/blob/${encoded}/${o.path}`, |
| 1162 | 1159 | all: o.all === true, |
| 1163 | 1160 | ...(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>> = { | ||
| 146 | 146 | jsonl: "csv", |
| 147 | 147 | ndjson: "csv", |
| 148 | 148 | gltf: "model3d", |
| 149 | obj: "model3d", | |
| 149 | 150 | json: "config", |
| 150 | 151 | jsonc: "config", |
| 151 | 152 | yaml: "config", |
| @@ -219,6 +220,16 @@ export function fileKind(path: string, head: Uint8Array): FileKind { | ||
| 219 | 220 | return TEXTUAL[extension(path)] ?? "text"; |
| 220 | 221 | } |
| 221 | 222 | |
| 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 | ||
| 222 | 233 | /** |
| 223 | 234 | * What a file's name alone claims to be, unconfirmed: for a page that never |
| 224 | 235 | * 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 | ||
|
||
| 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: |
||
| 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"; | ||
| 14 | 14 | import { font } from "./font.ts"; |
| 15 | 15 | import { ics } from "./ics.ts"; |
| 16 | 16 | 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"; | |
| 18 | 18 | import { log, hasEscape } from "./log.ts"; |
| 19 | 19 | import { markdown } from "./markdown.ts"; |
| 20 | import { model3d } from "./model3d.ts"; | |
| 20 | 21 | import { patch } from "./patch.ts"; |
| 21 | 22 | import { pdf } from "./pdf.ts"; |
| 22 | 23 | import { sqlite } from "./sqlite.ts"; |
| @@ -34,6 +35,8 @@ export interface RenderInput { | ||
| 34 | 35 | readonly body: Uint8Array; |
| 35 | 36 | /** `/:owner/:name/media/:ref/*path`, for `<img>`, `<video>` and `fetch()`. */ |
| 36 | 37 | readonly media: string; |
| 38 | /** The same, saved under the file's own name (`downloadHref`). */ | |
| 39 | readonly download: string; | |
| 37 | 40 | /** The file page's own address, for a link back to it (`?all=1`). */ |
| 38 | 41 | readonly pathHref?: string; |
| 39 | 42 | /** `?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>>> = { | ||
| 63 | 66 | sqlite, |
| 64 | 67 | audio, |
| 65 | 68 | video, |
| 69 | model3d, | |
| 66 | 70 | config, |
| 67 | 71 | patch, |
| 68 | 72 | log, |
| @@ -88,19 +92,28 @@ const MEDIA_KINDS: ReadonlySet<FileKind> = new Set<FileKind>([ | ||
| 88 | 92 | "model3d", |
| 89 | 93 | ]); |
| 90 | 94 | |
| 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 | */ | |
| 92 | 101 | export function rendererFor( |
| 93 | 102 | path: string, |
| 94 | 103 | blob: Blob, |
| 95 | 104 | renderers: Partial<Readonly<Record<FileKind, Renderer>>> = RENDERERS, |
| 105 | where: { readonly media: boolean } = { media: true }, | |
| 96 | 106 | ): { 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. | |
| 97 | 109 | const kind = |
| 98 | 110 | blob.kind !== "too_large" |
| 99 | 111 | ? fileKind(path, blob.body.subarray(0, HEAD_BYTES)) |
| 100 | 112 | : blob.head !== undefined |
| 101 | 113 | ? 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; | |
| 104 | 117 | if (blob.kind === "too_large" && !MEDIA_KINDS.has(kind)) return undefined; |
| 105 | 118 | // A kind may draw only some of its files: `config` draws JSON, YAML and |
| 106 | 119 | // 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 | [](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 filesrc/web/render/vendor/three/build/three.module.js added +19719
Diff not shown — 19,719 changed lines (limit 5,000). +19719
View filesrc/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 filesrc/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"; | ||
| 27 | 27 | import { base, layers, reset, utilities } from "./style/base.ts"; |
| 28 | 28 | import { components } from "./style/components.ts"; |
| 29 | 29 | import { content } from "./style/content.ts"; |
| 30 | import { model3dCss } from "./render/model3d-css.ts"; | |
| 30 | 31 | import { pickCss } from "./pick.ts"; |
| 31 | 32 | import { studioCss } from "./studio/style.ts"; |
| 32 | 33 | |
| @@ -39,6 +40,7 @@ export const css = [ | ||
| 39 | 40 | studioCss, |
| 40 | 41 | boardCss, |
| 41 | 42 | content, |
| 43 | model3dCss, | |
| 42 | 44 | utilities, |
| 43 | 45 | swatchCss, |
| 44 | 46 | pickCss, |
test/blob-media-ceiling.test.ts +1 −1
| @@ -54,7 +54,7 @@ describe("the page ceiling, by kind", () => { | ||
| 54 | 54 | const pdf = stub(); |
| 55 | 55 | const found = rendererFor("doc/big.pdf", capped(blob(PDF)), { pdf: pdf.render }); |
| 56 | 56 | 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" }); | |
| 58 | 58 | expect(pdf.seen).toHaveLength(1); |
| 59 | 59 | expect(pdf.seen[0]!.body.length).toBe(0); |
| 60 | 60 | 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"; | ||
| 4 | 4 | |
| 5 | 5 | const input = (path: string, text: string) => { |
| 6 | 6 | 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}` }; | |
| 8 | 8 | }; |
| 9 | 9 | |
| 10 | 10 | 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"; | ||
| 6 | 6 | test("a JSON file over MAX_FIELDS is one plain muted line, not a card", () => { |
| 7 | 7 | const big = JSON.stringify(Object.fromEntries(Array.from({ length: MAX_FIELDS + 1 }, (_, i) => [`k${i}`, i]))); |
| 8 | 8 | 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); | |
| 10 | 10 | expect(out).toContain("too many for a form. Source shows it whole."); |
| 11 | 11 | expect(out).toContain('<p class="muted">'); |
| 12 | 12 | expect(out).not.toContain("card"); |
test/config-yaml-toml.test.ts +1 −1
| @@ -142,7 +142,7 @@ describe("YAML", () => { | ||
| 142 | 142 | test("the blob page draws the form, and says so for a broken file", () => { |
| 143 | 143 | const input = (text: string) => { |
| 144 | 144 | 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" }; | |
| 146 | 146 | }; |
| 147 | 147 | expect(String(config(input(YAML)).content)).toContain("config-form"); |
| 148 | 148 | 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
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", () => { | ||
| 125 | 125 | expect(r.entries.length).toBe(ENTRY_CAP); |
| 126 | 126 | expect(r.count).toBe(ENTRY_CAP + 25); |
| 127 | 127 | 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" }); | |
| 129 | 129 | expect(String(page.content)).toContain("25 more entries are not listed"); |
| 130 | 130 | }); |
| 131 | 131 | }); |
| 132 | 132 | |
| 133 | 133 | 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" }); | |
| 135 | 135 | test("a name that is markup is escaped", () => { |
| 136 | 136 | const out = String(draw("x.zip", zip([{ name: "<script>alert(1)</script>", size: 1 }])).content); |
| 137 | 137 | expect(out).not.toContain("<script>"); |
test/render-csv.test.ts +3 −3
| @@ -15,7 +15,7 @@ const ok = (s: string) => { | ||
| 15 | 15 | return t; |
| 16 | 16 | }; |
| 17 | 17 | 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 }); | |
| 19 | 19 | |
| 20 | 20 | describe("csv parser", () => { |
| 21 | 21 | test("quotes, escaped quotes and newline inside a field", () => { |
| @@ -45,7 +45,7 @@ describe("csv parser", () => { | ||
| 45 | 45 | describe("csv bounds", () => { |
| 46 | 46 | test("one very wide row cannot multiply the page", () => { |
| 47 | 47 | 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 }); | |
| 49 | 49 | const h = String(r.content); |
| 50 | 50 | expect(h.length).toBeLessThan(2_000_000); |
| 51 | 51 | expect(h).toContain(`Showing ${COLUMN_CAP} of 100,001 columns`); |
| @@ -152,7 +152,7 @@ describe("csv on the blob page", () => { | ||
| 152 | 152 | |
| 153 | 153 | describe("json lines as a table", () => { |
| 154 | 154 | 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" }); | |
| 156 | 156 | const text = |
| 157 | 157 | '{"a":1,"b":"x"}\n{"c":{"k":[1,2]},"a":"<script>alert(1)</script>"}\nnot json\n\n{"b":null}\n'; |
| 158 | 158 | 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"; | ||
| 5 | 5 | import { RENDERERS } from "../src/web/render/renderers.ts"; |
| 6 | 6 | |
| 7 | 7 | 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" }); | |
| 9 | 9 | const wrap = (...events: string[]) => |
| 10 | 10 | `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`; |
| 11 | 11 |
test/render-image.test.ts +1 −1
| @@ -99,7 +99,7 @@ describe("image blob pages", () => { | ||
| 99 | 99 | |
| 100 | 100 | test("the image cap decides: over it no <img>, under it one", () => { |
| 101 | 101 | 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, { | |
| 103 | 103 | refs: { branches: ["main"], tags: [] } as RefSet, |
| 104 | 104 | ref: "main", |
| 105 | 105 | path: "a.png", |
test/render-kinds.test.ts +1
| @@ -45,6 +45,7 @@ const SAMPLES: Record<string, [Uint8Array, FileKind, string]> = { | ||
| 45 | 45 | jsonl: [enc("{\"a\":1}\n"), "csv", "application/octet-stream"], |
| 46 | 46 | ndjson: [enc("{\"a\":1}\n"), "csv", "application/octet-stream"], |
| 47 | 47 | gltf: [enc('{"asset":{}}'), "model3d", "application/octet-stream"], |
| 48 | obj: [enc("v 0 0 0\n"), "model3d", "application/octet-stream"], | |
| 48 | 49 | json: [enc("{}"), "config", "application/octet-stream"], |
| 49 | 50 | jsonc: [enc("{}"), "config", "application/octet-stream"], |
| 50 | 51 | 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"; | ||
| 4 | 4 | import { rendererFor } from "../src/web/render/renderers.ts"; |
| 5 | 5 | |
| 6 | 6 | 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" }); | |
| 8 | 8 | |
| 9 | 9 | const DIFF = `diff --git a/one.txt b/one.txt |
| 10 | 10 | index 1111111..2222222 100644 |
test/render-sqlite.test.ts +1 −1
| @@ -22,7 +22,7 @@ const make = (): Uint8Array => { | ||
| 22 | 22 | }; |
| 23 | 23 | const DB = make(); |
| 24 | 24 | 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 } : {}) }); | |
| 26 | 26 | |
| 27 | 27 | describe("sqlite kind", () => { |
| 28 | 28 | 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"; | ||
| 6 | 6 | |
| 7 | 7 | const enc = (s: string): Uint8Array => new TextEncoder().encode(s); |
| 8 | 8 | 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" }); | |
| 10 | 10 | const html = (text: string, path?: string): string => String(draw(text, path).content); |
| 11 | 11 | |
| 12 | 12 | 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 | }); |
Stray double blank line left behind by the removed
downloadHref.