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
+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);
});