chore(monorepo): import hmwebviewer history at a717900
git-subtree-dir: apps/viewer-3d git-subtree-mainline:5e10bb1be4git-subtree-split:a71790070d
This commit is contained in:
@@ -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.
|
||||
@@ -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); });
|
||||
@@ -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.');
|
||||
}
|
||||
@@ -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); });
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
Reference in New Issue
Block a user