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