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:
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user