Skip to content

Commit 6674935

Browse files
AndrewAltimitAI Agent BotclaudeAI Review Agent
authored
feat(site): interactive 3D blueprint viewer for hardware diagrams (#102)
* feat(site): interactive 3D blueprint viewer for hardware diagrams Data-driven Three.js blueprint viewer at site/blueprint.html with ?diagram= URL param. Two diagrams: psp-relay (H-bridge actuator wiring) and psp-usb-adapter (passive USB-C bridge with upright PSP model). Journal entries 05 and 08 link to interactive versions from their static diagram images. Includes headless screenshot test script. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor(site): modular blueprint viewer with shared engine and components Split 880-line monolith into 4 focused files: - blueprint.html (73 lines) — thin HTML shell, loads scripts - blueprint-engine.js (410 lines) — reusable engine: orbit controls, explode slider, hover tooltips, tabs, render loop, shared PSP body component (buildPSP), and drawing helpers - blueprints/psp-relay.js (154 lines) — relay diagram data + build - blueprints/psp-usb-adapter.js (154 lines) — adapter diagram, uses shared buildPSP() component Adding a new diagram is now: create js/blueprints/foo.js, call BlueprintEngine.register('foo', {...}), add a <script> tag. Also fixes Three.js group.add().position.set() bug where add() returns the parent group, not the child — was silently shifting the PSP group. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refine(site): blueprint engine helpers, data-driven tabs, lifecycle cleanup 1. Add geometry helpers: plane(), ring(), circle(), sphere(), v() — diagram authors no longer need raw Three.js for common shapes 2. Make tab system data-driven: tabStates now declares {explode: N} per tab instead of hardcoding 'assembled'/'exploded' IDs in engine 3. Validate diagram.build() return with defaults + try/catch error display 4. Scene lifecycle cleanup(): removes old canvas, resize listener, tabs on re-init — prevents accumulation if init() called twice 5. DRY: v() helper in engine, diagram files delegate to h.v() 6. Document PSP unit system (1 unit = 10mm) and spec source 7. Document dims group skip in explode controller Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(site): replace procedural PSP with GLB model wireframe Load a real PSP-3001 3D model (converted from FBX to GLB) and render as wireframe + dark fill in the blueprint viewer. The model has proper curved grips, button indentations, screen bezel depth, speaker grilles, and UMD slot detail — far more accurate than the procedural wireBox approximation which is kept as fallback. Pipeline: FBX → puppeteer + Three.js FBXLoader → merged GLB (2.7MB geometry only, no textures). Rotation calibrated via interactive test grid (PI/2 - 0.75 compensates for FBX export tilt). Model height measured from bbox to position adapter correctly on top edge. Includes GLTFLoader.js (r128), FBX→GLB conversion scripts, and Git LFS tracking for .glb files. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * polish(site): 3D text labels, accurate wiring, hardware-matched spacing - Replace all billboard sprite labels with fixed 3D world-space text (tag() now uses PlaneGeometry with canvas-measured text sizing) - Fix relay H-bridge wiring to match real hardware: 12V+ to NC1/NO2, GND to NO1/NC2, COM1/COM2 to actuator (from reference photos) - Center Micro-USB port on relay PCB (was in corner) - Match PSP top-edge connector spacing to real hardware photos: one 5V power pad per side, screw holes at outer edges - Fix HUD layout spacing (back-link, title, tabs, dims no longer overlap) - Default to fully compact view (explode slider starts at 0) - Add model comparison and rotation test scripts for iterating on 3D model orientation Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address AI review feedback (iteration 1) Automated fix by Claude in response to Gemini/Codex review. Iteration: 1/5 Co-Authored-By: AI Review Agent <noreply@anthropic.com> * fix: address AI review feedback (iteration 2) Automated fix by Claude in response to Gemini/Codex review. Iteration: 2/5 Co-Authored-By: AI Review Agent <noreply@anthropic.com> * fix: cancel requestAnimationFrame loop on cleanup to prevent memory leak Store the rAF ID and cancel it in cleanup() so re-initializing the engine doesn't leave orphaned render loops running with references to old WebGL contexts, scenes, and renderers. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address AI review feedback (iteration 3) Automated fix by Claude in response to Gemini/Codex review. Iteration: 3/5 Co-Authored-By: AI Review Agent <noreply@anthropic.com> --------- Co-authored-by: AI Agent Bot <ai-agent@localhost> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: AI Review Agent <ai-review-agent@localhost>
1 parent 8ec4131 commit 6674935

16 files changed

+5402
-5
lines changed

.gitattributes

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,4 @@
99
*.json text eol=lf
1010
*.yaml text eol=lf
1111
*.yml text eol=lf
12+
*.glb filter=lfs diff=lfs merge=lfs -text

scripts/blueprint-screenshot.js

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
#!/usr/bin/env node
2+
// Takes screenshots of blueprint.html from multiple camera angles.
3+
// Usage: node scripts/blueprint-screenshot.js [diagram]
4+
// Starts a local HTTP server, launches headless Chromium, captures screenshots.
5+
6+
const http = require('http');
7+
const fs = require('fs');
8+
const path = require('path');
9+
const puppeteer = require('puppeteer');
10+
11+
const SITE_DIR = path.join(__dirname, '..', 'site');
12+
const OUT_DIR = path.join(__dirname, '..', 'screenshots', 'blueprint');
13+
const DIAGRAM = process.argv[2] || 'psp-usb-adapter';
14+
const WIDTH = 1366;
15+
const HEIGHT = 768;
16+
17+
// Camera presets: [phi, theta, r] — spherical coords
18+
const VIEWS = [
19+
{ name: 'front', phi: Math.PI/2, theta: 0, r: 30, desc: 'straight-on front' },
20+
{ name: 'hero', phi: Math.PI/2.8, theta: Math.PI/8, r: 30, desc: 'hero angle (default)' },
21+
{ name: 'top-down', phi: 0.3, theta: 0, r: 35, desc: 'near top-down' },
22+
{ name: 'side-left', phi: Math.PI/2.5, theta: Math.PI/2, r: 28, desc: 'left side' },
23+
{ name: 'close-top', phi: Math.PI/3, theta: Math.PI/6, r: 18, desc: 'close-up on top edge' },
24+
{ name: 'exploded', phi: Math.PI/3, theta: Math.PI/5, r: 40, desc: 'exploded view' },
25+
];
26+
27+
// Simple static file server
28+
function startServer() {
29+
return new Promise((resolve) => {
30+
const MIME = {
31+
'.html': 'text/html', '.css': 'text/css', '.js': 'application/javascript',
32+
'.png': 'image/png', '.jpg': 'image/jpeg', '.svg': 'image/svg+xml'
33+
};
34+
const server = http.createServer((req, res) => {
35+
const urlPath = req.url.split('?')[0];
36+
let filePath = path.join(SITE_DIR, urlPath === '/' ? 'index.html' : urlPath);
37+
const ext = path.extname(filePath);
38+
fs.readFile(filePath, (err, data) => {
39+
if (err) { res.writeHead(404); res.end('Not found'); return; }
40+
res.writeHead(200, { 'Content-Type': MIME[ext] || 'application/octet-stream' });
41+
res.end(data);
42+
});
43+
});
44+
server.listen(0, '127.0.0.1', () => {
45+
const port = server.address().port;
46+
resolve({ server, port });
47+
});
48+
});
49+
}
50+
51+
async function main() {
52+
fs.mkdirSync(OUT_DIR, { recursive: true });
53+
54+
const { server, port } = await startServer();
55+
console.log(`Server on http://127.0.0.1:${port}`);
56+
57+
const browser = await puppeteer.launch({
58+
headless: 'new',
59+
executablePath: '/snap/bin/chromium',
60+
args: ['--no-sandbox', '--disable-setuid-sandbox',
61+
'--enable-webgl', '--use-gl=angle', '--use-angle=swiftshader',
62+
'--enable-unsafe-swiftshader',
63+
`--window-size=${WIDTH},${HEIGHT}`],
64+
});
65+
66+
const page = await browser.newPage();
67+
await page.setViewport({ width: WIDTH, height: HEIGHT });
68+
69+
const url = `http://127.0.0.1:${port}/blueprint.html?diagram=${DIAGRAM}`;
70+
console.log(`Loading ${url}`);
71+
await page.goto(url, { waitUntil: 'load', timeout: 15000 });
72+
// Wait for Three.js to render
73+
await new Promise(r => setTimeout(r, 2000));
74+
75+
for (const view of VIEWS) {
76+
console.log(` Capturing: ${view.name} (${view.desc})`);
77+
78+
// Set camera via orbit controls and optionally explode slider
79+
await page.evaluate((v) => {
80+
if (window.orbit) {
81+
window.orbit.phi = v.phi;
82+
window.orbit.theta = v.theta;
83+
window.orbit.r = v.r;
84+
window.camUpdate();
85+
}
86+
}, { phi: view.phi, theta: view.theta, r: view.r });
87+
88+
// For exploded view, push the slider
89+
if (view.name === 'exploded') {
90+
await page.evaluate(() => {
91+
const slider = document.getElementById('explode');
92+
if (slider) {
93+
slider.value = 80;
94+
slider.dispatchEvent(new Event('input'));
95+
}
96+
});
97+
} else {
98+
await page.evaluate(() => {
99+
const slider = document.getElementById('explode');
100+
if (slider) {
101+
slider.value = 20;
102+
slider.dispatchEvent(new Event('input'));
103+
}
104+
});
105+
}
106+
107+
await new Promise(r => setTimeout(r, 500)); // let render settle
108+
const filename = path.join(OUT_DIR, `${DIAGRAM}-${view.name}.png`);
109+
await page.screenshot({ path: filename });
110+
console.log(` -> ${filename}`);
111+
}
112+
113+
await browser.close();
114+
server.close();
115+
console.log(`\nDone! ${VIEWS.length} screenshots in ${OUT_DIR}`);
116+
}
117+
118+
main().catch(e => { console.error(e); process.exit(1); });

scripts/fbx-to-glb.html

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<head><title>FBX to GLB Converter</title></head>
4+
<body>
5+
<script src="../site/js/three.min.js"></script>
6+
<script src="../site/js/fflate.js"></script>
7+
<script src="../site/js/NURBSUtils.js"></script>
8+
<script src="../site/js/NURBSCurve.js"></script>
9+
<script src="../site/js/FBXLoader.js"></script>
10+
<script>
11+
// Load FBX, extract geometry stats, and export wireframe data
12+
window.convertFBX = function(arrayBuffer) {
13+
var loader = new THREE.FBXLoader();
14+
var scene = loader.parse(arrayBuffer);
15+
16+
var stats = { meshes: [], totalVertices: 0, totalFaces: 0 };
17+
var allPositions = [];
18+
var allIndices = [];
19+
var vertexOffset = 0;
20+
21+
scene.traverse(function(child) {
22+
if (child.isMesh && child.geometry && child.name !== 'ground') {
23+
var geo = child.geometry;
24+
var pos = geo.attributes.position;
25+
var idx = geo.index;
26+
27+
// Apply world transform
28+
child.updateWorldMatrix(true, false);
29+
geo.applyMatrix4(child.matrixWorld);
30+
31+
var vCount = pos.count;
32+
var fCount = idx ? idx.count / 3 : vCount / 3;
33+
34+
stats.meshes.push({
35+
name: child.name,
36+
vertices: vCount,
37+
faces: fCount
38+
});
39+
stats.totalVertices += vCount;
40+
stats.totalFaces += fCount;
41+
42+
// Collect positions
43+
for (var i = 0; i < pos.count; i++) {
44+
allPositions.push(pos.getX(i), pos.getY(i), pos.getZ(i));
45+
}
46+
47+
// Collect indices (offset by vertex count of previous meshes)
48+
if (idx) {
49+
for (var i = 0; i < idx.count; i++) {
50+
allIndices.push(idx.getX(i) + vertexOffset);
51+
}
52+
} else {
53+
for (var i = 0; i < vCount; i++) {
54+
allIndices.push(i + vertexOffset);
55+
}
56+
}
57+
vertexOffset += vCount;
58+
}
59+
});
60+
61+
stats.positionsLength = allPositions.length;
62+
stats.indicesLength = allIndices.length;
63+
64+
// Compute bounding box
65+
var minX=Infinity, minY=Infinity, minZ=Infinity;
66+
var maxX=-Infinity, maxY=-Infinity, maxZ=-Infinity;
67+
for (var i = 0; i < allPositions.length; i += 3) {
68+
var x = allPositions[i], y = allPositions[i+1], z = allPositions[i+2];
69+
if (x < minX) minX = x; if (x > maxX) maxX = x;
70+
if (y < minY) minY = y; if (y > maxY) maxY = y;
71+
if (z < minZ) minZ = z; if (z > maxZ) maxZ = z;
72+
}
73+
stats.boundingBox = {
74+
min: [minX, minY, minZ],
75+
max: [maxX, maxY, maxZ],
76+
size: [maxX-minX, maxY-minY, maxZ-minZ]
77+
};
78+
79+
// Export as merged geometry JSON (positions + indices)
80+
window._exportData = {
81+
positions: new Float32Array(allPositions),
82+
indices: new Uint32Array(allIndices),
83+
stats: stats
84+
};
85+
86+
return stats;
87+
};
88+
</script>
89+
</body>
90+
</html>

scripts/fbx-to-glb.js

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
#!/usr/bin/env node
2+
// Converts FBX to a lightweight GLB using puppeteer + Three.js FBXLoader
3+
// Usage: node scripts/fbx-to-glb.js <input.fbx> <output.glb>
4+
5+
const fs = require('fs');
6+
const path = require('path');
7+
const http = require('http');
8+
const puppeteer = require('puppeteer');
9+
10+
const INPUT = process.argv[2];
11+
if (!INPUT) {
12+
console.error('Usage: node scripts/fbx-to-glb.js <input.fbx> [output.glb]');
13+
process.exit(1);
14+
}
15+
const OUTPUT = process.argv[3] || path.join(__dirname, '..', 'site', 'models', 'psp.glb');
16+
17+
const PROJ_ROOT = path.join(__dirname, '..');
18+
19+
async function main() {
20+
// Serve project root so HTML can load scripts from site/js/
21+
const server = http.createServer((req, res) => {
22+
const urlPath = req.url.split('?')[0];
23+
const filePath = path.join(PROJ_ROOT, urlPath === '/' ? 'index.html' : urlPath);
24+
fs.readFile(filePath, (err, data) => {
25+
if (err) { res.writeHead(404); res.end('Not found'); return; }
26+
res.writeHead(200); res.end(data);
27+
});
28+
});
29+
30+
await new Promise(r => server.listen(0, '127.0.0.1', r));
31+
const port = server.address().port;
32+
33+
const browser = await puppeteer.launch({
34+
headless: 'new',
35+
executablePath: '/snap/bin/chromium',
36+
args: ['--no-sandbox', '--disable-setuid-sandbox', '--enable-webgl',
37+
'--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader']
38+
});
39+
40+
const page = await browser.newPage();
41+
page.on('console', m => console.log('PAGE:', m.text()));
42+
page.on('pageerror', e => console.log('ERR:', e.message));
43+
44+
await page.goto(`http://127.0.0.1:${port}/scripts/fbx-to-glb.html`, {
45+
waitUntil: 'networkidle0', timeout: 15000
46+
});
47+
48+
// Read FBX file and pass to browser
49+
const fbxData = fs.readFileSync(INPUT);
50+
console.log(`Loading FBX: ${INPUT} (${(fbxData.length/1024).toFixed(0)} KB)`);
51+
52+
// Transfer buffer to page and convert
53+
const stats = await page.evaluate(async (fbxBase64) => {
54+
const binary = atob(fbxBase64);
55+
const bytes = new Uint8Array(binary.length);
56+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
57+
return window.convertFBX(bytes.buffer);
58+
}, fbxData.toString('base64'));
59+
60+
console.log('Mesh stats:', JSON.stringify(stats, null, 2));
61+
62+
// Extract positions and indices arrays
63+
const exportData = await page.evaluate(() => {
64+
const d = window._exportData;
65+
return {
66+
positions: Array.from(d.positions),
67+
indices: Array.from(d.indices)
68+
};
69+
});
70+
71+
console.log(`Positions: ${exportData.positions.length/3} vertices`);
72+
console.log(`Indices: ${exportData.indices.length/3} triangles`);
73+
74+
// Build a minimal GLB (glTF binary)
75+
const glb = buildGLB(
76+
new Float32Array(exportData.positions),
77+
new Uint32Array(exportData.indices),
78+
stats.boundingBox
79+
);
80+
81+
fs.mkdirSync(path.dirname(OUTPUT), { recursive: true });
82+
fs.writeFileSync(OUTPUT, glb);
83+
console.log(`\nWrote: ${OUTPUT} (${(glb.length/1024).toFixed(0)} KB)`);
84+
85+
await browser.close();
86+
server.close();
87+
}
88+
89+
// Build minimal GLB with just positions + indices
90+
function buildGLB(positions, indices, bbox) {
91+
const posBytes = Buffer.from(positions.buffer);
92+
const idxBytes = Buffer.from(indices.buffer);
93+
94+
// Pad to 4-byte alignment
95+
const posPad = (4 - posBytes.length % 4) % 4;
96+
const idxPad = (4 - idxBytes.length % 4) % 4;
97+
98+
const binLength = posBytes.length + posPad + idxBytes.length + idxPad;
99+
100+
const gltf = {
101+
asset: { version: '2.0', generator: 'oasis-blueprint-converter' },
102+
scene: 0,
103+
scenes: [{ nodes: [0] }],
104+
nodes: [{ mesh: 0, name: 'PSP-3001' }],
105+
meshes: [{
106+
primitives: [{
107+
attributes: { POSITION: 0 },
108+
indices: 1,
109+
mode: 4 // TRIANGLES
110+
}]
111+
}],
112+
accessors: [
113+
{
114+
bufferView: 0,
115+
componentType: 5126, // FLOAT
116+
count: positions.length / 3,
117+
type: 'VEC3',
118+
min: bbox.min,
119+
max: bbox.max
120+
},
121+
{
122+
bufferView: 1,
123+
componentType: 5125, // UNSIGNED_INT
124+
count: indices.length,
125+
type: 'SCALAR'
126+
}
127+
],
128+
bufferViews: [
129+
{
130+
buffer: 0,
131+
byteOffset: 0,
132+
byteLength: posBytes.length,
133+
target: 34962 // ARRAY_BUFFER
134+
},
135+
{
136+
buffer: 0,
137+
byteOffset: posBytes.length + posPad,
138+
byteLength: idxBytes.length,
139+
target: 34963 // ELEMENT_ARRAY_BUFFER
140+
}
141+
],
142+
buffers: [{ byteLength: binLength }]
143+
};
144+
145+
const jsonStr = JSON.stringify(gltf);
146+
const jsonPad = (4 - jsonStr.length % 4) % 4;
147+
const jsonBuf = Buffer.from(jsonStr + ' '.repeat(jsonPad));
148+
const binBuf = Buffer.concat([posBytes, Buffer.alloc(posPad), idxBytes, Buffer.alloc(idxPad)]);
149+
150+
// GLB header: magic(4) + version(4) + length(4) = 12 bytes
151+
// JSON chunk: length(4) + type(4) + data
152+
// BIN chunk: length(4) + type(4) + data
153+
const totalLength = 12 + 8 + jsonBuf.length + 8 + binBuf.length;
154+
155+
const out = Buffer.alloc(totalLength);
156+
let off = 0;
157+
158+
// Header
159+
out.writeUInt32LE(0x46546C67, off); off += 4; // glTF magic
160+
out.writeUInt32LE(2, off); off += 4; // version
161+
out.writeUInt32LE(totalLength, off); off += 4;
162+
163+
// JSON chunk
164+
out.writeUInt32LE(jsonBuf.length, off); off += 4;
165+
out.writeUInt32LE(0x4E4F534A, off); off += 4; // JSON type
166+
jsonBuf.copy(out, off); off += jsonBuf.length;
167+
168+
// BIN chunk
169+
out.writeUInt32LE(binBuf.length, off); off += 4;
170+
out.writeUInt32LE(0x004E4942, off); off += 4; // BIN type
171+
binBuf.copy(out, off);
172+
173+
return out;
174+
}
175+
176+
main().catch(e => { console.error(e); process.exit(1); });

0 commit comments

Comments
 (0)