feat: multi-format 3D viewer with large-file OBJ streaming, Z-up, projection/outline UI

Three.js viewer supporting glb/gltf/obj/fbx/dae/ifc/ply via both server (SSR)
and drag&drop (CSR) paths.

- Streaming OBJ parser (src/viewer/objStream.ts) for files past the V8 max
  string length (>~1GB text) that OBJLoader can't handle; indexed geometry,
  per-vertex color from MTL Kd, float64 recenter baked in.
- In-viewer float64 recenter (objRecenter.ts) for huge CAD/survey coordinates
  (~1e8) so float32 vertex buffers keep precision (no cracked faces).
- Z-up right-handed world; Y-up formats rotated on load.
- OBJ+MTL+texture drag&drop (LoadingManager URL-modifier maps dropped images).
- OrbitControls ground-plane panning (road/rail alignment workflow).
- UI: Zoom Fit, perspective/orthographic toggle, feature-edge outline.
- DoubleSide for CAD OBJ; PLY mesh + point-cloud support.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-19 13:54:46 +09:00
co-authored by Claude Opus 4.8
commit a71790070d
78 changed files with 15921 additions and 0 deletions
+76
View File
@@ -0,0 +1,76 @@
# tools/ — Offline asset pipeline
Offline tooling for preparing 3D assets served by the hmwebviewer. Nothing here
ships to the browser at runtime; these produce the optimized assets and
pre-rendered placeholders that the viewer consumes.
## preprocess.mjs — Draco + KTX2 compression
Compresses a `.glb`/`.gltf` into a single self-contained `.optimized.glb` using
[gltf-transform](https://gltf-transform.dev/): Draco for geometry, KTX2/Basis
Universal for textures, in one pass. Output loads directly in the viewer's
`GLTFLoader` (with `DRACOLoader` + `KTX2Loader` wired on the shared loader).
### Install (once)
These dev dependencies are **not** in `package.json` yet. Run before first use:
```bash
npm install -D @gltf-transform/core @gltf-transform/functions \
@gltf-transform/extensions @gltf-transform/cli
```
The CLI package provides the KTX2 encoder wiring. gltf-transform fetches the
platform basis encoder automatically on first KTX2 run (network needed once).
### Usage
```bash
node tools/preprocess.mjs <input.glb> [output.glb] [--draco-bits N] [--ktx2|--no-ktx2]
```
- Default output: `<input>.optimized.glb` (e.g. `model.glb``model.optimized.glb`).
- `--draco-bits N` — position quantization bits, 8..16 (default **14**). Lower =
smaller file, lossier geometry. Tune per asset.
- `--ktx2` / `--no-ktx2` — texture encoding toggle (default **on**).
Prints before/after byte sizes and reduction %.
### Outputs
- Compressed GLBs land in [`samples/`](../samples/) (`samples/*.optimized.glb`).
- Pre-rendered WebP placeholders land in [`public/previews/`](../public/previews/)
(`*.webp`) — see the pre-render section below.
If a required package is missing at runtime, the script prints the install
command above and exits non-zero (no silent skip).
## Sample assets
Place source `.glb` files under `samples/` (suggested split: small / medium /
large). Compress with `preprocess.mjs` and commit the `.optimized.glb` outputs.
Samples are not committed yet — to be added when a sample asset is available.
## Pre-render pipeline (360° animated WebP placeholder)
For Path A (server asset) the server ships a pre-rendered 360° turntable so the
page shows motion instantly while the real model decodes in the background.
Output: `public/previews/<asset>.webp` (animated, with alpha).
**Status: to be implemented when a sample asset is available.** Two options:
1. **Blender headless CLI (preferred).** Camera parented to an empty at the
model origin, Z axis 0→360° keyframed over N frames. Render frames:
```bash
blender -b scene.blend -o //frame_### -f 1..N -F PNG
```
Assemble to animated WebP (alpha) or WebM VP9 via ffmpeg:
```bash
ffmpeg -framerate 24 -i frame_%03d.png -loop 0 -plays 0 out.webp
```
2. **Puppeteer + headless three.js (fallback).** Spin a minimal three.js page,
rotate the model, and call `page.screenshot({ type: 'webp' })` per rotation
step. Stitch frames into an animated WebP.
Both options need a committed sample asset first; blocked on P4-2.
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env node
/**
* tools/adaptive-smoke.mjs — empirically trigger the adaptive-quality downstep.
*
* Loads a heavy sample in headless Chrome (software GL = slow) + applies a CDP
* CPU throttle so the REAL measured fps sustains < 60. Captures the genuine
* `[hmwebviewer] adaptive quality -> tier N (pr=..)` console logs emitted by
* src/viewer/adaptiveQuality.ts, plus the live #.fps overlay text. Proves the
* <60fps → optimize path fires on real frame measurement (no logic patched).
*
* USAGE: node tools/adaptive-smoke.mjs [--url URL] [--asset PATH] [--throttle N] [--seconds S]
*/
import puppeteer from 'puppeteer-core';
import { existsSync } from 'node:fs';
const CHROME_CANDIDATES = [
'C:/Program Files/Google/Chrome/Application/chrome.exe',
'C:/Program Files (x86)/Google/Chrome/Application/chrome.exe',
'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe',
];
function findBrowser() {
if (process.env.PUPPETEER_EXECUTABLE_PATH && existsSync(process.env.PUPPETEER_EXECUTABLE_PATH)) return process.env.PUPPETEER_EXECUTABLE_PATH;
for (const p of CHROME_CANDIDATES) if (existsSync(p)) return p;
return null;
}
function parseArgs(argv) {
const a = argv.slice(2);
let url = 'http://127.0.0.1:4173', asset = '/samples/ABeautifulGame.ktx2.glb', throttle = 6, seconds = 25;
for (let i = 0; i < a.length; i++) {
if (a[i] === '--url') url = a[++i];
else if (a[i] === '--asset') asset = a[++i];
else if (a[i] === '--throttle') throttle = Number(a[++i]);
else if (a[i] === '--seconds') seconds = Number(a[++i]);
}
return { url, asset, throttle, seconds };
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function main() {
const o = parseArgs(process.argv);
const exe = findBrowser();
if (!exe) { console.error('[adaptive-smoke] No Chrome/Edge found.'); process.exit(1); }
const browser = await puppeteer.launch({
executablePath: exe,
headless: 'new',
args: ['--no-sandbox', '--use-gl=swiftshader', '--enable-webgl', '--ignore-gpu-blocklist'],
});
const page = await browser.newPage();
const tierLogs = [];
page.on('console', (m) => {
const t = m.text();
if (t.includes('adaptive quality')) { tierLogs.push(t); console.log(' LOG ' + t); }
});
await page.evaluateOnNewDocument(() => {
window.__hmwReady = null;
window.addEventListener('hmw:ready', (e) => { window.__hmwReady = e.detail; });
});
const target = o.url + '?model=' + o.asset;
console.log('[adaptive-smoke] ' + target + ' throttle=' + o.throttle + 'x watch=' + o.seconds + 's browser=' + exe);
await page.goto(target, { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForFunction('window.__hmwReady != null', { timeout: 60000 });
console.log('[adaptive-smoke] model loaded; applying CPU throttle + watching fps...');
const client = await page.target().createCDPSession();
await client.send('Emulation.setCPUThrottlingRate', { rate: o.throttle });
const start = Date.now();
while (Date.now() - start < o.seconds * 1000) {
await sleep(2000);
const state = await page.evaluate(() => {
const el = document.querySelector('.fps');
return { fps: el ? el.textContent : '(no overlay)', pr: window.__viewer ? undefined : undefined };
});
console.log(' t+' + Math.round((Date.now() - start) / 1000) + 's overlay=' + state.fps + ' tierSteps=' + tierLogs.length);
if (tierLogs.length >= 3) break; // enough proof
}
await browser.close();
console.log('\n[adaptive-smoke] tier-change log lines captured: ' + tierLogs.length);
tierLogs.forEach((l) => console.log(' ' + l));
if (tierLogs.length === 0) {
console.error('[adaptive-smoke] NO downstep observed — fps stayed >=60 (raise --throttle or use a heavier asset).');
process.exit(2);
}
console.log('[adaptive-smoke] PASS — adaptive quality degraded on sustained <60fps.');
}
main().catch((e) => { console.error('[adaptive-smoke] Fatal: ' + (e && e.stack ? e.stack : e)); process.exit(1); });
+46
View File
@@ -0,0 +1,46 @@
// Copies Draco + KTX2/Basis decoder WASM from three.js into public/ so the
// runtime paths /draco/ and /basis/ resolve (version-matched to installed three).
// Also copies the web-ifc single-thread WASM to public/web-ifc/ for the IFC path.
// Runs automatically via `npm install` (postinstall). Safe to re-run.
import { cpSync, copyFileSync, existsSync, mkdirSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const root = resolve(__dirname, '..');
const libs = resolve(root, 'node_modules/three/examples/jsm/libs');
const targets = [
[resolve(libs, 'draco/gltf'), resolve(root, 'public/draco')],
[resolve(libs, 'basis'), resolve(root, 'public/basis')],
];
if (!existsSync(libs)) {
console.warn('[copy-decoders] three not installed yet — skipping (will run on next install).');
process.exit(0);
}
for (const [src, dest] of targets) {
if (!existsSync(src)) {
console.warn(`[copy-decoders] source missing: ${src}`);
continue;
}
mkdirSync(dest, { recursive: true });
cpSync(src, dest, { recursive: true });
console.log(`[copy-decoders] ${src} -> ${dest}`);
}
// web-ifc single-thread WASM (+ MT build if present) for the IFC load path.
const ifcSrcDir = resolve(root, 'node_modules/web-ifc');
const ifcDest = resolve(root, 'public/web-ifc');
if (existsSync(ifcSrcDir)) {
mkdirSync(ifcDest, { recursive: true });
for (const wasm of ['web-ifc.wasm', 'web-ifc-mt.wasm']) {
const src = resolve(ifcSrcDir, wasm);
if (!existsSync(src)) continue;
copyFileSync(src, resolve(ifcDest, wasm));
console.log(`[copy-decoders] ${src} -> ${resolve(ifcDest, wasm)}`);
}
} else {
console.warn('[copy-decoders] web-ifc not installed yet — skipping IFC wasm.');
}
+84
View File
@@ -0,0 +1,84 @@
#!/usr/bin/env node
/**
* tools/dnd-smoke.mjs — CSR (Path B / drag&drop) load smoke.
*
* The per-format perf-smoke only exercised Path A (?model=). This drives the
* REAL local-file path: fetch each sample into a File, hand it to
* window.__viewer.loadLocalFile(file) (exactly what the dropzone does), and wait
* for hmw:ready. Catches the blob:-URL-has-no-extension class of bug.
*
* USAGE: node tools/dnd-smoke.mjs [--url URL] [--asset /samples/x ...]
* (run `npm run build && npm run preview` first)
*/
import puppeteer from 'puppeteer-core';
import { existsSync } from 'node:fs';
const CHROME_CANDIDATES = [
'C:/Program Files/Google/Chrome/Application/chrome.exe',
'C:/Program Files (x86)/Google/Chrome/Application/chrome.exe',
'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe',
];
function findBrowser() {
if (process.env.PUPPETEER_EXECUTABLE_PATH && existsSync(process.env.PUPPETEER_EXECUTABLE_PATH)) return process.env.PUPPETEER_EXECUTABLE_PATH;
for (const p of CHROME_CANDIDATES) if (existsSync(p)) return p;
return null;
}
function parseArgs(argv) {
const a = argv.slice(2); let url = 'http://127.0.0.1:4173'; const assets = [];
for (let i = 0; i < a.length; i++) {
if (a[i] === '--url') url = a[++i]; else if (a[i] === '--asset') assets.push(a[++i]);
}
if (!assets.length) assets.push('/samples/Box.glb', '/samples/Duck.glb', '/samples/Avocado.glb', '/samples/Cube.obj', '/samples/Cube.fbx', '/samples/Cube.dae', '/samples/Cube.ifc');
return { url, assets };
}
async function dropOne(page, base, asset) {
await page.goto(base, { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForFunction('window.__viewer != null', { timeout: 15000 });
try {
const res = await page.evaluate(async (assetPath) => {
const name = assetPath.split('/').pop();
const resp = await fetch(assetPath);
if (!resp.ok) return { ok: false, err: 'fetch ' + resp.status };
const blob = await resp.blob();
const file = new File([blob], name, { type: blob.type || 'application/octet-stream' });
const ready = new Promise((resolve) => {
const onErr = (e) => resolve({ ok: false, err: 'viewer error: ' + (e.detail || 'unknown') });
window.addEventListener('hmw:ready', () => resolve({ ok: true }), { once: true });
// surface our own onError via console; also time out below
window.__dndTimeout = setTimeout(() => resolve({ ok: false, err: 'timeout (no hmw:ready)' }), 20000);
});
window.__viewer.loadLocalFile(file);
return ready;
}, asset);
return { asset, ...res };
} catch (e) {
return { asset, ok: false, err: (e && e.message) || String(e) };
}
}
async function main() {
const o = parseArgs(process.argv);
const exe = findBrowser();
if (!exe) { console.error('[dnd-smoke] No Chrome/Edge found.'); process.exit(1); }
const browser = await puppeteer.launch({ executablePath: exe, headless: 'new', args: ['--no-sandbox', '--use-gl=swiftshader', '--enable-webgl', '--ignore-gpu-blocklist'] });
const page = await browser.newPage();
const errs = [];
page.on('console', (m) => { const t = m.text(); if (t.includes('load failed') || t.includes('Failed to load')) errs.push(t); });
console.log('[dnd-smoke] CSR drag&drop path, base=' + o.url);
const results = [];
for (const asset of o.assets) {
process.stdout.write(' drop ' + asset + ' ... ');
const r = await dropOne(page, o.url, asset);
results.push(r);
console.log(r.ok ? 'OK' : 'FAIL (' + r.err + ')');
}
await browser.close();
const failed = results.filter((r) => !r.ok);
console.log('\n[dnd-smoke] ' + (results.length - failed.length) + '/' + results.length + ' passed');
if (failed.length) { console.error('[dnd-smoke] FAILURES: ' + failed.map((f) => f.asset).join(', ')); process.exit(1); }
console.log('[dnd-smoke] all local drops load.');
}
main().catch((e) => { console.error('[dnd-smoke] Fatal: ' + (e && e.stack ? e.stack : e)); process.exit(1); });
+78
View File
@@ -0,0 +1,78 @@
#!/usr/bin/env node
/**
* Diagnostic: load a huge OBJ (+mtl) through the REAL drag&drop path in headless
* Chrome, capture console ([recenter]/[hmw] bbox) and a screenshot. Confirms
* whether the in-viewer recenter ran and whether faces crack.
*
* USAGE: node tools/girder-smoke.mjs [--url http://localhost:3333] [obj] [mtl]
*/
import puppeteer from 'puppeteer-core';
import { existsSync } from 'node:fs';
import { resolve } from 'node:path';
const CHROME = [
'C:/Program Files/Google/Chrome/Application/chrome.exe',
'C:/Program Files (x86)/Google/Chrome/Application/chrome.exe',
'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe',
].find((p) => existsSync(p));
const args = process.argv.slice(2);
let url = 'http://localhost:3333';
const files = [];
for (let i = 0; i < args.length; i++) {
if (args[i] === '--url') url = args[++i];
else files.push(args[i]);
}
if (files.length === 0) {
files.push('samples/GirderObjs/part01.obj', 'samples/GirderObjs/part01.mtl');
}
const paths = files.map((f) => resolve(f));
for (const p of paths) if (!existsSync(p)) { console.error('missing', p); process.exit(1); }
const browser = await puppeteer.launch({
executablePath: CHROME,
headless: 'new',
args: ['--no-sandbox', '--use-gl=swiftshader', '--enable-webgl', '--ignore-gpu-blocklist',
'--disable-dev-shm-usage', '--window-size=1400,900'],
});
const page = await browser.newPage();
await page.setViewport({ width: 1400, height: 900 });
page.on('console', (m) => console.log(' [browser]', m.text()));
page.on('pageerror', (e) => console.log(' [pageerror]', e.message));
await page.evaluateOnNewDocument(() => {
window.__hmwReady = null;
window.addEventListener('hmw:ready', (e) => { window.__hmwReady = e.detail; });
});
console.log('goto', url);
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForSelector('#file-input', { timeout: 10000 });
const input = await page.$('#file-input');
console.log('uploading', paths.join(', '));
await input.uploadFile(...paths);
await page.evaluate(() => document.getElementById('file-input')
.dispatchEvent(new Event('change', { bubbles: true })));
console.log('waiting for hmw:ready (up to 240s)...');
const t0 = Date.now();
try {
await page.waitForFunction('window.__hmwReady != null', { timeout: 240000, polling: 1000 });
console.log('READY in', ((Date.now() - t0) / 1000).toFixed(1) + 's');
} catch {
console.log('TIMEOUT after', ((Date.now() - t0) / 1000).toFixed(1) + 's — capturing anyway');
}
await new Promise((r) => setTimeout(r, 1500));
await page.screenshot({ path: resolve('girder-smoke.png') });
console.log('screenshot -> girder-smoke.png (full)');
// Medium zoom (reproduce user's close inspection) via OrbitControls wheel.
await page.mouse.move(700, 450);
for (let i = 0; i < 14; i++) { await page.mouse.wheel({ deltaY: -200 }); await new Promise((r) => setTimeout(r, 20)); }
await new Promise((r) => setTimeout(r, 1000));
await page.screenshot({ path: resolve('girder-zoom.png') });
console.log('screenshot -> girder-zoom.png (zoomed in)');
await browser.close();
+117
View File
@@ -0,0 +1,117 @@
#!/usr/bin/env node
/**
* tools/perf-smoke.mjs — Perceived-load smoke test for hmwebviewer (PLAN P5-3).
*
* Launches headless Chrome via puppeteer-core (uses the system Chrome; no
* Chromium download). For each sample asset it loads `?model=<asset>` and
* measures wall time from navigation-start to the viewer's `hmw:ready` event
* (dispatched in ThreeDViewer.onLoaded). Asserts < 3000ms perceived.
*
* USAGE
* node tools/perf-smoke.mjs [--url http://127.0.0.1:4173] [--asset /samples/Box.glb ...]
*
* Defaults: url = http://127.0.0.1:4173 (vite preview); assets = the sample set.
* Run `npm run build && npm run preview` first (or `npm run dev` on its port).
*
* Browser auto-detected: Chrome, then Edge, then PUPPETEER_EXECUTABLE_PATH.
*/
import puppeteer from 'puppeteer-core';
import { existsSync } from 'node:fs';
const CHROME_CANDIDATES = [
'C:/Program Files/Google/Chrome/Application/chrome.exe',
'C:/Program Files (x86)/Google/Chrome/Application/chrome.exe',
'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe',
];
function findBrowser() {
if (process.env.PUPPETEER_EXECUTABLE_PATH && existsSync(process.env.PUPPETEER_EXECUTABLE_PATH)) {
return process.env.PUPPETEER_EXECUTABLE_PATH;
}
for (const p of CHROME_CANDIDATES) if (existsSync(p)) return p;
return null;
}
function parseArgs(argv) {
const args = argv.slice(2);
let url = 'http://127.0.0.1:4173';
const assets = [];
for (let i = 0; i < args.length; i++) {
if (args[i] === '--url') url = args[++i];
else if (args[i] === '--asset') assets.push(args[++i]);
else if (args[i] === '-h' || args[i] === '--help') return { help: true };
}
if (assets.length === 0) {
assets.push('/samples/Box.glb', '/samples/Duck.glb', '/samples/Duck.optimized.glb', '/samples/Avocado.glb');
}
return { url, assets };
}
const THRESHOLD_MS = 3000;
async function measureAsset(browser, baseUrl, asset) {
const page = await browser.newPage();
await page.evaluateOnNewDocument(() => {
window.__hmwReady = null;
window.addEventListener('hmw:ready', (e) => { window.__hmwReady = e.detail; });
});
const target = baseUrl + '?model=' + asset;
const t0 = Date.now();
try {
await page.goto(target, { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForFunction('window.__hmwReady != null', { timeout: 30000 });
const data = await page.evaluate(() => ({ readyPerf: window.__hmwReady, origin: performance.timeOrigin }));
const ms = (data.origin + data.readyPerf) - t0;
return { asset, ms: Math.round(ms), ok: ms < THRESHOLD_MS };
} catch (e) {
return { asset, ms: null, ok: false, error: (e && e.message) ? e.message : String(e) };
} finally {
await page.close();
}
}
async function main() {
const opts = parseArgs(process.argv);
if (opts.help) {
console.error('Usage: node tools/perf-smoke.mjs [--url URL] [--asset PATH ...]');
process.exit(0);
}
const exe = findBrowser();
if (!exe) {
console.error('[perf-smoke] No Chrome/Edge found. Set PUPPETEER_EXECUTABLE_PATH.');
process.exit(1);
}
const browser = await puppeteer.launch({
executablePath: exe,
headless: 'new',
args: ['--no-sandbox', '--use-gl=swiftshader', '--enable-webgl', '--ignore-gpu-blocklist'],
});
console.log('[perf-smoke] base=' + opts.url + ' browser=' + exe);
console.log('[perf-smoke] threshold=' + THRESHOLD_MS + 'ms perceived');
const results = [];
for (const asset of opts.assets) {
process.stdout.write(' ' + asset + ' ... ');
const r = await measureAsset(browser, opts.url, asset);
results.push(r);
console.log(r.ms === null ? 'FAIL (' + (r.error || 'timeout') + ')' : (r.ms + 'ms ' + (r.ok ? 'OK' : 'OVER')));
}
await browser.close();
console.log('\n[perf-smoke] summary');
for (const r of results) {
console.log(' ' + r.asset.padEnd(34) + (r.ms === null ? 'FAIL' : (r.ms + 'ms').padEnd(8)) + (r.ok ? 'PASS' : 'FAIL'));
}
const failed = results.filter((r) => !r.ok);
if (failed.length) {
console.error('[perf-smoke] ' + failed.length + ' asset(s) over threshold/failed.');
process.exit(1);
}
console.log('[perf-smoke] all within ' + THRESHOLD_MS + 'ms.');
}
main().catch((e) => {
console.error('[perf-smoke] Fatal: ' + (e && e.stack ? e.stack : e));
process.exit(1);
});
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env node
/** Generate an ASCII PLY cube (vertex colors) and load it via the drag&drop path
* in headless Chrome; screenshot to verify PLY support. */
import puppeteer from 'puppeteer-core';
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
const PLY = `ply
format ascii 1.0
element vertex 8
property float x
property float y
property float z
property uchar red
property uchar green
property uchar blue
element face 12
property list uchar int vertex_indices
end_header
-1 -1 -1 255 0 0
1 -1 -1 0 255 0
1 1 -1 0 0 255
-1 1 -1 255 255 0
-1 -1 1 255 0 255
1 -1 1 0 255 255
1 1 1 255 255 255
-1 1 1 90 90 90
3 0 1 2
3 0 2 3
3 4 5 6
3 4 6 7
3 0 4 7
3 0 7 3
3 1 5 6
3 1 6 2
3 3 2 6
3 3 6 7
3 0 1 5
3 0 5 4
`;
const dir = resolve('samples/plytest');
mkdirSync(dir, { recursive: true });
writeFileSync(resolve(dir, 'cube.ply'), PLY);
console.log('asset ->', resolve(dir, 'cube.ply'));
const url = process.argv.includes('--url') ? process.argv[process.argv.indexOf('--url') + 1] : 'http://localhost:3333';
const CHROME = ['C:/Program Files/Google/Chrome/Application/chrome.exe',
'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe'].find((p) => existsSync(p));
const browser = await puppeteer.launch({ executablePath: CHROME, headless: 'new',
args: ['--no-sandbox', '--use-gl=swiftshader', '--enable-webgl', '--ignore-gpu-blocklist', '--window-size=900,700'] });
const page = await browser.newPage();
await page.setViewport({ width: 900, height: 700 });
page.on('console', (m) => console.log(' [browser]', m.text()));
page.on('pageerror', (e) => console.log(' [pageerror]', e.message));
await page.evaluateOnNewDocument(() => { window.__r = null; addEventListener('hmw:ready', (e) => { window.__r = e.detail; }); });
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForSelector('#file-input');
await (await page.$('#file-input')).uploadFile(resolve(dir, 'cube.ply'));
await page.evaluate(() => document.getElementById('file-input').dispatchEvent(new Event('change', { bubbles: true })));
try { await page.waitForFunction('window.__r != null', { timeout: 30000, polling: 500 }); console.log('READY'); }
catch { console.log('TIMEOUT'); }
await new Promise((r) => setTimeout(r, 1200));
await page.screenshot({ path: resolve('ply-test.png') });
console.log('screenshot -> ply-test.png');
await browser.close();
+192
View File
@@ -0,0 +1,192 @@
#!/usr/bin/env node
/**
* tools/preprocess.mjs — Offline GLB optimization for hmwebviewer.
*
* SCOPE: GLB/glTF ONLY (gltf-transform pipeline). Other formats shipped as
* samples (OBJ/DAE/FBX/IFC) are NOT processed here — they are consumed raw by
* their respective three.js loaders; no offline Draco/KTX2 step applies.
*
* Compresses a glTF/GLB with Draco (geometry) + WebP texture compression in one
* pass using gltf-transform. Output is a single self-contained .optimized.glb
* ready for the viewer's GLTFLoader (+ DRACOLoader).
*
* KTX2/Basis texture encoding is NOT done here — it needs the `toktx` platform
* encoder (KTX-Software 4.3+). gltf-transform exposes it via the CLI `etc1s`
* (lossy, smaller) or `uastc` (higher quality) commands, e.g.:
* gltf-transform etc1s <out.optimized.glb> <out.ktx2.glb>
* Install KTX-Software from https://github.com/KhronosGroup/KTX-Software first.
* Runtime: the viewer's KTX2Loader decodes these at load (verified with the
* Khronos ABeautifulGame KTX2+Draco sample — 626ms perceived load).
*
* USAGE
* node tools/preprocess.mjs <input.glb> [output.glb] [--draco-bits N] [--texture FMT]
*
* --draco-bits N Position quantization bits (default 14). 8..16; lower=smaller/lossier.
* --texture FMT Texture re-encode: webp (default) | jpeg | png | none.
*
* OUTPUT
* Default output = input with `.optimized.glb` suffix. Prints before/after
* byte sizes + reduction %.
*
* INSTALL (these are devDependencies now; if absent the script prints the
* install command and exits non-zero — no silent skip):
* npm install -D @gltf-transform/core @gltf-transform/functions \
* @gltf-transform/extensions @gltf-transform/cli
*/
async function loadDeps() {
let core, fns, ext;
try {
core = await import('@gltf-transform/core');
fns = await import('@gltf-transform/functions');
ext = await import('@gltf-transform/extensions');
} catch (e) {
const msg = (e && e.message) ? e.message : String(e);
console.error('[preprocess] Missing required package: ' + msg);
console.error('[preprocess] Install the toolchain with:');
console.error(
' npm install -D @gltf-transform/core @gltf-transform/functions ' +
'@gltf-transform/extensions @gltf-transform/cli'
);
process.exit(1);
}
return { core, fns, ext };
}
const TEXTURE_FORMATS = ['webp', 'jpeg', 'png', 'none'];
function parseArgs(argv) {
const args = argv.slice(2);
if (args.length === 0 || args[0] === '-h' || args[0] === '--help') {
return { help: true };
}
const positional = [];
let dracoBits = 14;
let texture = 'webp';
for (let i = 0; i < args.length; i++) {
const a = args[i];
if (a === '--draco-bits') {
const v = Number(args[++i]);
if (!Number.isInteger(v) || v < 8 || v > 16) {
throw new Error('--draco-bits must be an integer 8..16');
}
dracoBits = v;
} else if (a === '--texture') {
const v = String(args[++i]).toLowerCase();
if (!TEXTURE_FORMATS.includes(v)) {
throw new Error('--texture must be one of: ' + TEXTURE_FORMATS.join(', '));
}
texture = v;
} else if (a.startsWith('--')) {
throw new Error('Unknown option: ' + a);
} else {
positional.push(a);
}
}
if (positional.length === 0) {
throw new Error('Missing <input.glb>');
}
const input = positional[0];
let output = positional[1];
if (!output) {
output = input.replace(/\.glb$/i, '') + '.optimized.glb';
}
return { input, output, dracoBits, texture };
}
function usage() {
console.error(
'Usage: node tools/preprocess.mjs <input.glb> [output.glb] [--draco-bits N] [--texture webp|jpeg|png|none]'
);
}
async function main() {
const opts = parseArgs(process.argv);
if (opts.help) {
usage();
return;
}
const { core, fns, ext } = await loadDeps();
const { WebIO } = core;
const { quantize, dedup, weld, draco, textureCompress, prune } = fns;
const { KHRDracoMeshCompression, EXTTextureWebP } = ext;
const { promises: fs } = await import('node:fs');
const path = await import('node:path');
const inputPath = path.resolve(opts.input);
const outputPath = path.resolve(opts.output);
let inputBytes;
try {
inputBytes = await fs.readFile(inputPath);
} catch (e) {
console.error('[preprocess] Cannot read input "' + inputPath + '": ' + e.message);
process.exit(1);
}
const beforeSize = inputBytes.byteLength;
// Draco encoder must be handed to the extension as a registered dependency.
const draco3d = await import('draco3d');
const dracoEncoder = await draco3d.createEncoderModule();
const io = new WebIO()
.registerExtensions([KHRDracoMeshCompression, EXTTextureWebP])
.registerDependencies({ 'draco3d.encoder': dracoEncoder });
let doc;
try {
doc = await io.readBinary(inputBytes);
} catch (e) {
console.error('[preprocess] Failed to parse glTF: ' + e.message);
process.exit(1);
}
const transforms = [];
transforms.push(dedup());
transforms.push(weld({ tolerance: 1e-4 }));
transforms.push(
quantize({
quantizePosition: opts.dracoBits,
quantizeNormal: 10,
quantizeTexcoord: 12,
quantizeColor: 8,
})
);
transforms.push(prune());
transforms.push(
draco({
encodeSpeed: 5,
decodeSpeed: 5,
quantizePosition: opts.dracoBits,
quantizeNormal: 10,
quantizeTexcoord: 12,
quantizeColor: 8,
})
);
if (opts.texture !== 'none') {
transforms.push(textureCompress({ targetFormat: opts.texture, quality: 8 }));
}
await doc.transform(...transforms);
const outGLB = await io.writeBinary(doc);
await fs.mkdir(path.dirname(outputPath), { recursive: true });
await fs.writeFile(outputPath, outGLB);
const afterSize = outGLB.byteLength;
const reduction = beforeSize > 0 ? ((1 - afterSize / beforeSize) * 100) : 0;
const fmt = (n) => (n / 1024).toFixed(1) + ' KiB';
console.log('[preprocess] ' + path.basename(inputPath) + ' -> ' + path.basename(outputPath));
console.log('[preprocess] before: ' + beforeSize + ' bytes (' + fmt(beforeSize) + ')');
console.log('[preprocess] after: ' + afterSize + ' bytes (' + fmt(afterSize) + ')');
console.log(
'[preprocess] reduction: ' + reduction.toFixed(1) + '% ' +
'(draco-bits=' + opts.dracoBits + ', texture=' + opts.texture + ')'
);
}
main().catch((e) => {
console.error('[preprocess] Fatal: ' + (e && e.stack ? e.stack : e));
process.exit(1);
});
+138
View File
@@ -0,0 +1,138 @@
#!/usr/bin/env node
/**
* tools/prerender.mjs — 360° turntable pre-render → animated WebP placeholder (PLAN P4-3).
*
* Loads the running viewer (?model=<asset>) in headless Chrome, orbits the
* camera one full turn in N steps, screenshots each frame, then assembles the
* PNG sequence into an animated WebP via ffmpeg. The result is an SSR
* placeholder image that hydrates into the live canvas on load.
*
* USAGE
* node tools/prerender.mjs --asset /samples/Duck.glb [--frames 36] [--size 512] \
* [--framerate 20] [--out public/previews/Duck.webp] \
* [--url http://127.0.0.1:4173]
*
* Requires: puppeteer-core (installed) + ffmpeg on PATH + the viewer built & served
* (`npm run build && npm run preview`, or `npm run dev`).
*/
import puppeteer from 'puppeteer-core';
import { existsSync, mkdirSync, rmSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const root = resolve(__dirname, '..');
const CHROME_CANDIDATES = [
'C:/Program Files/Google/Chrome/Application/chrome.exe',
'C:/Program Files (x86)/Google/Chrome/Application/chrome.exe',
'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe',
];
function findBrowser() {
if (process.env.PUPPETEER_EXECUTABLE_PATH && existsSync(process.env.PUPPETEER_EXECUTABLE_PATH)) {
return process.env.PUPPETEER_EXECUTABLE_PATH;
}
for (const p of CHROME_CANDIDATES) if (existsSync(p)) return p;
return null;
}
function parseArgs(argv) {
const a = argv.slice(2);
const o = { asset: null, frames: 36, size: 512, framerate: 20, out: null, url: 'http://127.0.0.1:4173' };
for (let i = 0; i < a.length; i++) {
const k = a[i], v = a[i + 1];
if (k === '--asset') { o.asset = v; i++; }
else if (k === '--frames') { o.frames = parseInt(v, 10); i++; }
else if (k === '--size') { o.size = parseInt(v, 10); i++; }
else if (k === '--framerate') { o.framerate = parseInt(v, 10); i++; }
else if (k === '--out') { o.out = v; i++; }
else if (k === '--url') { o.url = v; i++; }
else if (k === '-h' || k === '--help') return { help: true };
}
if (!o.asset) return { help: true };
if (!o.out) {
const base = o.asset.split('/').pop().replace(/\.(glb|gltf|obj|fbx|dae|ifc)$/i, '');
o.out = 'public/previews/' + base + '.webp';
}
// Undo MSYS/Git-Bash mangling of leading-slash args (/samples/x.glb -> C:/.../Git/samples/x.glb)
o.asset = o.asset.replace(/^.*\/Program Files\/Git\//i, '/');
if (!o.asset.startsWith('/')) o.asset = '/' + o.asset;
return o;
}
function ffmpegAvailable() {
try { execFileSync('ffmpeg', ['-version'], { stdio: 'ignore' }); return true; }
catch { return false; }
}
async function main() {
const opts = parseArgs(process.argv);
if (opts.help) {
console.error('Usage: node tools/prerender.mjs --asset /samples/Duck.glb [--frames 36] [--size 512] [--framerate 20] [--out public/previews/Duck.webp] [--url URL]');
process.exit(opts.asset ? 0 : 1);
}
const exe = findBrowser();
if (!exe) { console.error('[prerender] No Chrome/Edge found.'); process.exit(1); }
const haveFfmpeg = ffmpegAvailable();
const baseName = opts.asset.split('/').pop().replace(/\.(glb|gltf|obj|fbx|dae|ifc)$/i, '');
const frameDir = resolve(root, '.prerender-frames', baseName);
rmSync(frameDir, { recursive: true, force: true });
mkdirSync(frameDir, { recursive: true });
const browser = await puppeteer.launch({
executablePath: exe,
headless: 'new',
args: ['--no-sandbox', '--use-gl=swiftshader', '--enable-webgl', '--ignore-gpu-blocklist'],
defaultViewport: { width: opts.size, height: opts.size },
});
const page = await browser.newPage();
page.on('console', (m) => { if (m.type() === 'error') console.error('[page-console]', m.text()); });
page.on('pageerror', (e) => console.error('[page-error]', e.message));
page.on('requestfailed', (r) => console.error('[req-failed]', r.url(), r.failure()?.errorText));
await page.evaluateOnNewDocument(() => {
window.__hmwReady = null;
window.addEventListener('hmw:ready', (e) => { window.__hmwReady = e.detail; });
});
await page.goto(opts.url + '?model=' + opts.asset, { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForFunction('window.__hmwReady != null', { timeout: 30000 });
for (let i = 0; i < opts.frames; i++) {
const azimuth = (i / opts.frames) * Math.PI * 2;
await page.evaluate((az) => window.__viewer && window.__viewer.rotateTo(az), azimuth);
const file = resolve(frameDir, 'frame_' + String(i).padStart(3, '0') + '.png');
await page.screenshot({ path: file, omitBackground: false });
}
await browser.close();
const { readdirSync } = await import('node:fs');
const frameCount = readdirSync(frameDir).filter((f) => f.endsWith('.png')).length;
console.log('[prerender] captured ' + frameCount + '/' + opts.frames + ' frames -> ' + frameDir);
const outAbs = resolve(root, opts.out);
mkdirSync(dirname(outAbs), { recursive: true });
if (!haveFfmpeg) {
console.warn('[prerender] ffmpeg not found. Frames left in ' + frameDir);
console.warn('[prerender] assemble manually:');
console.warn(' ffmpeg -framerate ' + opts.framerate + ' -i ' + frameDir + '/frame_%03d.png -loop 0 ' + outAbs);
return;
}
// Assemble animated WebP. NOTE: libwebp_anim is broken in this ffmpeg build
// (outputs 1 frame); -c:v libwebp + -vsync vfr produces correct multi-frame WebP.
execFileSync('ffmpeg', [
'-y', '-framerate', String(opts.framerate), '-vsync', 'vfr',
'-i', resolve(frameDir, 'frame_%03d.png'),
'-vf', 'scale=' + opts.size + ':' + opts.size + ':flags=lanczos',
'-c:v', 'libwebp', '-loop', '0', '-lossless', '0', '-q:v', '70',
outAbs,
], { stdio: 'inherit' });
console.log('[prerender] animated WebP -> ' + opts.out);
rmSync(frameDir, { recursive: true, force: true });
}
main().catch((e) => {
console.error('[prerender] Fatal: ' + (e && e.stack ? e.stack : e));
process.exit(1);
});
+106
View File
@@ -0,0 +1,106 @@
#!/usr/bin/env node
/**
* Generate a textured cube (cube.obj + cube.mtl + checker.png), then load it
* through the real drag&drop path (uploadFile of all 3) in headless Chrome and
* screenshot — verifies OBJ texture support (map_Kd → dropped image blob URL).
*
* USAGE: node tools/tex-test.mjs [--url http://localhost:3333]
*/
import puppeteer from 'puppeteer-core';
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
import { deflateSync } from 'node:zlib';
import { resolve } from 'node:path';
// ---- minimal PNG encoder (truecolor RGB, filter 0) ----
const CRC = (() => {
const t = new Uint32Array(256);
for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; t[n] = c >>> 0; }
return (buf) => { let c = 0xffffffff; for (let i = 0; i < buf.length; i++) c = t[(c ^ buf[i]) & 0xff] ^ (c >>> 8); return (c ^ 0xffffffff) >>> 0; };
})();
function chunk(type, data) {
const len = Buffer.alloc(4); len.writeUInt32BE(data.length, 0);
const td = Buffer.concat([Buffer.from(type, 'ascii'), data]);
const crc = Buffer.alloc(4); crc.writeUInt32BE(CRC(td), 0);
return Buffer.concat([len, td, crc]);
}
function checkerPng(size = 64, cell = 8) {
const raw = Buffer.alloc((size * 3 + 1) * size);
for (let y = 0; y < size; y++) {
let o = y * (size * 3 + 1); raw[o++] = 0; // filter byte
for (let x = 0; x < size; x++) {
const on = ((x / cell | 0) + (y / cell | 0)) & 1;
raw[o++] = on ? 255 : 0; raw[o++] = on ? 0 : 255; raw[o++] = 255; // magenta / cyan
}
}
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(size, 0); ihdr.writeUInt32BE(size, 4);
ihdr[8] = 8; ihdr[9] = 2; // bitdepth 8, colortype 2 (RGB)
const sig = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
return Buffer.concat([sig, chunk('IHDR', ihdr), chunk('IDAT', deflateSync(raw)), chunk('IEND', Buffer.alloc(0))]);
}
const OBJ = `mtllib cube.mtl
v -1 -1 -1
v 1 -1 -1
v 1 1 -1
v -1 1 -1
v -1 -1 1
v 1 -1 1
v 1 1 1
v -1 1 1
vt 0 0
vt 1 0
vt 1 1
vt 0 1
usemtl checker
f 1/1 2/2 3/3
f 1/1 3/3 4/4
f 5/1 6/2 7/3
f 5/1 7/3 8/4
f 1/1 5/2 8/3
f 1/1 8/3 4/4
f 2/1 6/2 7/3
f 2/1 7/3 3/4
f 4/1 3/2 7/3
f 4/1 7/3 8/4
f 1/1 2/2 6/3
f 1/1 6/3 5/4
`;
const MTL = `newmtl checker
Kd 1 1 1
map_Kd checker.png
`;
const dir = resolve('samples/textest');
mkdirSync(dir, { recursive: true });
writeFileSync(resolve(dir, 'cube.obj'), OBJ);
writeFileSync(resolve(dir, 'cube.mtl'), MTL);
writeFileSync(resolve(dir, 'checker.png'), checkerPng());
console.log('assets ->', dir);
const url = process.argv.includes('--url') ? process.argv[process.argv.indexOf('--url') + 1] : 'http://localhost:3333';
const CHROME = [
'C:/Program Files/Google/Chrome/Application/chrome.exe',
'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe',
].find((p) => existsSync(p));
const browser = await puppeteer.launch({
executablePath: CHROME, headless: 'new',
args: ['--no-sandbox', '--use-gl=swiftshader', '--enable-webgl', '--ignore-gpu-blocklist', '--window-size=900,700'],
});
const page = await browser.newPage();
await page.setViewport({ width: 900, height: 700 });
page.on('console', (m) => console.log(' [browser]', m.text()));
page.on('pageerror', (e) => console.log(' [pageerror]', e.message));
await page.evaluateOnNewDocument(() => { window.__r = null; addEventListener('hmw:ready', (e) => { window.__r = e.detail; }); });
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForSelector('#file-input');
const input = await page.$('#file-input');
await input.uploadFile(resolve(dir, 'cube.obj'), resolve(dir, 'cube.mtl'), resolve(dir, 'checker.png'));
await page.evaluate(() => document.getElementById('file-input').dispatchEvent(new Event('change', { bubbles: true })));
try { await page.waitForFunction('window.__r != null', { timeout: 30000, polling: 500 }); console.log('READY'); }
catch { console.log('TIMEOUT'); }
await new Promise((r) => setTimeout(r, 1500));
await page.screenshot({ path: resolve('tex-test.png') });
console.log('screenshot -> tex-test.png');
await browser.close();