Overview · Clients · CAD Viewer

CAD Viewer

Fresh · 0d

Three.js renderer with edge overlay (shaded + lines, like SW). Drag to rotate, scroll to zoom, right-drag to pan. Click any field below to edit — title, specs, notes, status all save locally.

Click a body to select.
Section axis: Position:
No selection. Click a body in the canvas.
Loading…

Loading…

Status
Notes

Roadmap v0.5 → v0.7

`; const w = window.open('', '_blank', 'width=900,height=1100'); if (!w) { alert('Popup blocked.'); return; } w.document.open(); w.document.write(html); w.document.close(); } // ============================================================ // Export BOM — bill of materials → printable HTML page // ============================================================ function exportBOM() { const partId = currentId; const part = PARTS[partId]; if (!part) return; // Resolve which BOM items to list for this view. let bomKeys = ASSEMBLY_BOM[partId]; if (!bomKeys) { // Single-part view of a PRT or motor → just that one row. bomKeys = (BOM_DATA[partId]) ? [partId] : []; } if (bomKeys.length === 0) { alert(`No BOM data for ${partId}. Add an entry to BOM_DATA in cad-viewer.html or pick assembly_v2 / comp_drum.`); return; } const today = new Date().toISOString().slice(0, 10); // Build rows + running totals. let totalMass = 0; let totalCost = 0; let costMissingCount = 0; const rows = bomKeys.map(key => { const b = BOM_DATA[key]; const partTitle = b.title || (PARTS[key]?.title) || key; const qty = b.qty_per_drum || b.qty_per_feeder || b.qty || 1; const massEach = b.mass_kg_each; const massTotal = massEach != null ? massEach * qty : null; if (massTotal != null) totalMass += massTotal; const costEach = b.cost_usd_each; const costTotal = costEach != null ? costEach * qty : null; if (costTotal != null) totalCost += costTotal; else costMissingCount++; const fmt = (v, suffix = '') => v == null ? '—' : `${v.toLocaleString('en-US', { maximumFractionDigits: 1 })}${suffix}`; const fmtUSD = (v) => v == null ? 'TBD' : `$${v.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; const quoteRef = b.quote ? `
${b.quote}` : ''; const leadStr = b.lead_weeks != null ? `${b.lead_weeks} wk` : 'TBD'; return ` ${key} ${partTitle} ${b.material || '—'} ${qty} ${fmt(massEach, ' kg')} ${fmt(massTotal, ' kg')} ${b.vendor || 'TBD'}${quoteRef} ${leadStr} ${fmtUSD(costEach)} ${fmtUSD(costTotal)} ${b.notes || ''} `; }).join(''); const massCell = `${totalMass.toLocaleString('en-US', { maximumFractionDigits: 1 })} kg`; const costNote = costMissingCount > 0 ? ` · ${costMissingCount} TBD line${costMissingCount === 1 ? '' : 's'} not included` : ''; const costCell = `$${totalCost.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}${costNote}`; const html = ` BOM — ${part.title}

${part.title}

Bill of materials — for fab handoff (Annex B). Mass figures from each part's cadquery script. Quote references point to PDFs in RUBISCO2/supplier_quotes/.

Part: ${partId} Items: ${bomKeys.length} Total mass (per unit): ${massCell} Total cost (per unit): ${costCell} Generated: ${today}
${rows}
Part # Description Material / spec Qty Mass (each) Mass (total) Vendor Lead USD (each) USD (total) Notes
TOTAL (this assembly): ${massCell} ${costNote ? 'partial' : ''} ${costCell}
`; const w = window.open('', '_blank', 'width=1280,height=900'); if (!w) { alert('Popup blocked — allow popups for this site to use Export BOM.'); return; } w.document.open(); w.document.write(html); w.document.close(); } async function exportDrawing() { if (!currentObject) return; const part = PARTS[currentId]; if (!part) return; // Bbox of the live scene (assembly or single part — whichever is loaded). const box = new THREE.Box3().setFromObject(currentObject); const size = box.getSize(new THREE.Vector3()); const center = box.getCenter(new THREE.Vector3()); const maxDim = Math.max(size.x, size.y, size.z); // Save current renderer state so we can restore after offscreen renders. const originalSize = { w: renderer.domElement.width, h: renderer.domElement.height }; const originalEnv = scene.environment; const originalBg = scene.background; const tcVisible = transformControls.visible; transformControls.visible = false; // Force the drawing renders to a clean white blueprint look — even if user // is in photoreal mode. Drawings should always read as black-on-white. scene.background = new THREE.Color(0xffffff); scene.environment = null; for (const m of meshes) { const blue = m.userData.blueMat; if (blue) { m.material = blue; // clear any selection emissive for the snapshot blue.emissive.setHex(0x000000); blue.emissiveIntensity = 0; } } // Use a temporary OrthographicCamera at fixed snapshot size. const SNAP = 1100; renderer.setSize(SNAP, SNAP, false); const ortho = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, maxDim * 20); const dist = maxDim * 3; // World-frame viewing axes. dir = camera position relative to center. // up = world up for that view. (w, h) = visible footprint in world. // wLabel/hLabel = which world axis the screen-horizontal/vertical maps to. const angles = [ { name: 'Front (looking +Z)', dir: [0, 0, 1], up: [0, 1, 0], w: size.x, h: size.y, wLabel: 'X', hLabel: 'Y', dim: true, section: false }, { name: 'Top (looking down)', dir: [0, 1, 0], up: [0, 0, -1], w: size.x, h: size.z, wLabel: 'X', hLabel: 'Z', dim: true, section: false }, { name: 'Right (looking -X)', dir: [-1, 0, 0], up: [0, 1, 0], w: size.z, h: size.y, wLabel: 'Z', hLabel: 'Y', dim: true, section: false }, { name: 'Section (Y=0 cut, top half hidden)', dir: [0, 0, 1], up: [0, 1, 0], w: size.x, h: size.y, wLabel: '', hLabel: '', dim: false, section: true }, { name: 'Iso (1:1:1)', dir: [1, 1, 1], up: [0, 1, 0], w: maxDim, h: maxDim, wLabel: '', hLabel: '', dim: false, section: false } ]; const captures = []; for (const a of angles) { const half = Math.max(a.w, a.h) * 0.6; // 20% margin ortho.left = -half; ortho.right = half; ortho.top = half; ortho.bottom = -half; ortho.near = 0.1; ortho.far = dist * 5; ortho.updateProjectionMatrix(); const dirVec = new THREE.Vector3(a.dir[0], a.dir[1], a.dir[2]).normalize(); ortho.position.copy(center.clone().add(dirVec.multiplyScalar(dist))); ortho.up.set(a.up[0], a.up[1], a.up[2]); ortho.lookAt(center); // Hide pinned-dim 3D markers during the snapshot (we draw them as 2D // overlay later — having both makes it noisy). pinnedGroup.visible = false; measureGroup.visible = false; // Section view: install a temporary clipping plane at Y=0 so the drum's // top half is hidden, exposing rings, stringers, lifters. Restored after. let prevClip = null; if (a.section) { prevClip = meshes.map(m => ({ blue: m.userData.blueMat.clippingPlanes, photo: m.userData.photoMat.clippingPlanes, edge: m.userData.edgeMat.clippingPlanes })); const plane = new THREE.Plane(new THREE.Vector3(0, -1, 0), 0); for (const m of meshes) { m.userData.blueMat.clippingPlanes = [plane]; m.userData.photoMat.clippingPlanes = [plane]; m.userData.edgeMat.clippingPlanes = [plane]; } } renderer.render(scene, ortho); if (a.section && prevClip) { meshes.forEach((m, i) => { m.userData.blueMat.clippingPlanes = prevClip[i].blue; m.userData.photoMat.clippingPlanes = prevClip[i].photo; m.userData.edgeMat.clippingPlanes = prevClip[i].edge; }); } pinnedGroup.visible = true; measureGroup.visible = true; const rawDataUrl = renderer.domElement.toDataURL('image/png'); let finalDataUrl = rawDataUrl; if (a.dim) { finalDataUrl = await annotateBboxDims(finalDataUrl, SNAP, a, half); } // Pinned dims layer (always — even on the iso view) const pinnedDims = loadPinnedDimsForCurrent(); if (pinnedDims.length) { finalDataUrl = await annotatePinnedDims(finalDataUrl, SNAP, ortho, pinnedDims, a.dim); } captures.push({ name: a.name, dataURL: finalDataUrl }); } // Restore everything transformControls.visible = tcVisible; scene.background = originalBg; scene.environment = originalEnv; applyView(currentView); // re-applies blueMat vs photoMat per current toggle renderer.setSize(originalSize.w, originalSize.h, false); resize(); // canvas-wrap aware resize openDrawingWindow(captures, part); } // Phase 6: bbox dimensions overlaid on each ortho view. // Loads the rendered PNG, composites it onto a 2D canvas with margins, // computes screen-space bbox positions from the ortho camera params, // and draws extension lines + dim lines + arrows + value text. async function annotateBboxDims(pngDataUrl, renderSize, viewSpec, halfWorld) { const img = new Image(); img.src = pngDataUrl; await img.decode(); // Margins around the render to leave room for dim leaders + text. const ML = 70, MR = 110, MT = 50, MB = 90; const W = renderSize + ML + MR; const H = renderSize + MT + MB; const c = document.createElement('canvas'); c.width = W; c.height = H; const ctx = c.getContext('2d'); ctx.fillStyle = '#fff'; ctx.fillRect(0, 0, W, H); // Faint border around the drawing region ctx.strokeStyle = '#ddd'; ctx.lineWidth = 1; ctx.strokeRect(ML, MT, renderSize, renderSize); ctx.drawImage(img, ML, MT); // World-to-pixel mapping: ortho spans [-halfWorld, +halfWorld] both axes, // mapped to [0, renderSize] pixels. const px_per_mm = renderSize / (halfWorld * 2); // Centerline of geometry sits at the render center. const cx = ML + renderSize / 2; const cy = MT + renderSize / 2; const halfW_screen = (viewSpec.w / 2) * px_per_mm; const halfH_screen = (viewSpec.h / 2) * px_per_mm; const x0 = cx - halfW_screen, x1 = cx + halfW_screen; const y0 = cy - halfH_screen, y1 = cy + halfH_screen; // Horizontal width dim — below the geometry drawDimH(ctx, x0, x1, MT + renderSize + 38, `${viewSpec.w.toFixed(1)} mm` + (viewSpec.wLabel ? ` (${viewSpec.wLabel})` : ''), y1); // Vertical height dim — right of the geometry drawDimV(ctx, ML + renderSize + 50, y0, y1, `${viewSpec.h.toFixed(1)} mm` + (viewSpec.hLabel ? ` (${viewSpec.hLabel})` : ''), x1); return c.toDataURL('image/png'); } function drawArrow(ctx, x, y, dx, dy) { // Filled triangular arrowhead; (dx, dy) is the unit vector from arrow tip // pointing INTO the dim line (i.e. away from the line endpoint, toward the // opposite endpoint). Length 9, half-width 3.5. const len = 9; const halfW = 3.5; // Perpendicular (rotated 90°) const px = -dy, py = dx; ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x + dx * len + px * halfW, y + dy * len + py * halfW); ctx.lineTo(x + dx * len - px * halfW, y + dy * len - py * halfW); ctx.closePath(); ctx.fill(); } function drawDimH(ctx, x0, x1, dimY, text, geomBottomY) { ctx.strokeStyle = '#1a1a1a'; ctx.fillStyle = '#1a1a1a'; ctx.lineWidth = 1.1; // Extension lines — vertical, from geometry bottom edge to slightly past dim line ctx.beginPath(); ctx.moveTo(x0, geomBottomY + 4); ctx.lineTo(x0, dimY + 6); ctx.moveTo(x1, geomBottomY + 4); ctx.lineTo(x1, dimY + 6); ctx.stroke(); // Dim line — horizontal between extension lines ctx.beginPath(); ctx.moveTo(x0, dimY); ctx.lineTo(x1, dimY); ctx.stroke(); // Arrows pointing into the dim line from each side drawArrow(ctx, x0, dimY, 1, 0); drawArrow(ctx, x1, dimY, -1, 0); // Value text centered above dim line ctx.font = 'bold 16px ui-sans-serif, system-ui, sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'bottom'; // White stroke behind text for legibility against geometry ctx.lineWidth = 4; ctx.strokeStyle = '#fff'; ctx.strokeText(text, (x0 + x1) / 2, dimY - 5); ctx.fillText(text, (x0 + x1) / 2, dimY - 5); } function drawDimV(ctx, dimX, y0, y1, text, geomRightX) { ctx.strokeStyle = '#1a1a1a'; ctx.fillStyle = '#1a1a1a'; ctx.lineWidth = 1.1; // Extension lines — horizontal, from geometry right edge past dim line ctx.beginPath(); ctx.moveTo(geomRightX + 4, y0); ctx.lineTo(dimX + 6, y0); ctx.moveTo(geomRightX + 4, y1); ctx.lineTo(dimX + 6, y1); ctx.stroke(); // Dim line — vertical ctx.beginPath(); ctx.moveTo(dimX, y0); ctx.lineTo(dimX, y1); ctx.stroke(); // Arrows drawArrow(ctx, dimX, y0, 0, 1); drawArrow(ctx, dimX, y1, 0, -1); // Text — rotated 90° on the dim line ctx.save(); ctx.translate(dimX + 14, (y0 + y1) / 2); ctx.rotate(-Math.PI / 2); ctx.font = 'bold 16px ui-sans-serif, system-ui, sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.lineWidth = 4; ctx.strokeStyle = '#fff'; ctx.strokeText(text, 0, 0); ctx.fillText(text, 0, 0); ctx.restore(); } function loadPinnedDimsForCurrent() { // Single-part: just that part's dims. Assembly: dims from the assembly id // itself plus dims from each sub-part (so a dim pinned in single-part view // also shows up in assembly view). if (!currentId) return []; const part = PARTS[currentId]; const result = [...loadPinnedDims(currentId)]; if (part && part.kind === 'assembly' && part.children) { for (const ch of part.children) result.push(...loadPinnedDims(ch.partId)); } return result; } // Projects each pinned dim's two world points into the ortho view, draws a // red dim line + endpoint dots + 3D-distance label. Skips dims whose screen // length is < 6 px (perpendicular to view = degenerate). async function annotatePinnedDims(pngDataUrl, renderSize, ortho, dims, hasBboxMargins) { const img = new Image(); img.src = pngDataUrl; await img.decode(); const ML = hasBboxMargins ? 70 : 0; const MT = hasBboxMargins ? 50 : 0; const c = document.createElement('canvas'); c.width = img.width; c.height = img.height; const ctx = c.getContext('2d'); ctx.drawImage(img, 0, 0); for (const d of dims) { const aProj = new THREE.Vector3(d.p1[0], d.p1[1], d.p1[2]).project(ortho); const bProj = new THREE.Vector3(d.p2[0], d.p2[1], d.p2[2]).project(ortho); const ax = ML + (aProj.x + 1) / 2 * renderSize; const ay = MT + (1 - aProj.y) / 2 * renderSize; const bx = ML + (bProj.x + 1) / 2 * renderSize; const by = MT + (1 - bProj.y) / 2 * renderSize; const screenLen = Math.hypot(bx - ax, by - ay); if (screenLen < 6) continue; // perpendicular to view → skip const p1 = new THREE.Vector3(d.p1[0], d.p1[1], d.p1[2]); const p2 = new THREE.Vector3(d.p2[0], d.p2[1], d.p2[2]); const worldDist = p1.distanceTo(p2); ctx.strokeStyle = '#c0392b'; ctx.fillStyle = '#c0392b'; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.moveTo(ax, ay); ctx.lineTo(bx, by); ctx.stroke(); ctx.beginPath(); ctx.arc(ax, ay, 3.5, 0, Math.PI * 2); ctx.fill(); ctx.beginPath(); ctx.arc(bx, by, 3.5, 0, Math.PI * 2); ctx.fill(); // Label centered on the dim line, offset perpendicular. const mx = (ax + bx) / 2, my = (ay + by) / 2; ctx.font = 'bold 14px ui-sans-serif, system-ui, sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.lineWidth = 4; ctx.strokeStyle = '#fff'; ctx.strokeText(`${worldDist.toFixed(1)} mm`, mx, my - 12); ctx.fillStyle = '#c0392b'; ctx.fillText(`${worldDist.toFixed(1)} mm`, mx, my - 12); } return c.toDataURL('image/png'); } function openDrawingWindow(captures, part) { const partId = currentId; const manifest = regenManifest && regenManifest[partId]; const paramRows = manifest?.parameters?.map(p => { const input = paramsList.querySelector(`input[data-name="${p.name}"]`); const value = input ? input.value : p.default; const def = input ? input.dataset.default : p.default; const dirty = String(value) !== String(def) ? ' style="background:#fff8d8"' : ''; return `${p.label || p.name}${p.name}${value}${p.unit || ''}`; }).join('') || 'No editable parameters defined for this part.'; const box = new THREE.Box3().setFromObject(currentObject); const size = box.getSize(new THREE.Vector3()); const bboxStr = `${size.x.toFixed(1)} × ${size.y.toFixed(1)} × ${size.z.toFixed(1)} mm`; const title = part.title || partId; const subtitle = part.subtitle || ''; const today = new Date().toISOString().slice(0, 10); const sourceScript = manifest?.script || '—'; // Assembly note: if this is an assembly, list its children. const childList = part.kind === 'assembly' && part.children ? `Children${part.children.map(c => c.partId).join(', ')}` : ''; const html = ` Drawing — ${title}

${title}

${subtitle}

${captures.map(c => `
${c.name}
${c.name}
`).join('')}
${partId}
Title
${title}
Source
${sourceScript}
Bbox
${bboxStr}
Generated
${today}
By
cad-viewer Phase 5
${paramRows}${childList}
ParameterConstantValueUnit
Highlighted rows = parameter has been edited from default. Drawing is informational; commit values via "Save to source" before fab handoff. Bible-frozen tolerances + materials are documented in the part's specs file.
`; const w = window.open('', '_blank', 'width=1280,height=900'); if (!w) { alert('Popup blocked — allow popups for this site to use Export drawing.'); return; } w.document.open(); w.document.write(html); w.document.close(); } // (activate() picks up renderParameters via the inline call we added inside it) // Boot activate('comp_drum');