feat: add client-side OpenSCAD WASM support for 3D preview

Migrate 3D abacus preview generation from server-side OpenSCAD to client-side
WASM execution using web workers.

Changes:
- Add openscad-wasm-prebuilt dependency for browser-based rendering
- Refactor STLPreview to use web worker for non-blocking WASM execution
- Create openscad.worker.ts for isolated 3D model generation
- Add abacus-inline.scad template for parametric abacus generation
- Improve preview debouncing (500ms) and error handling

Benefits:
- No server-side OpenSCAD dependency required
- Faster preview updates (no network roundtrip)
- Better UX with non-blocking worker execution
- Consistent rendering across environments

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock
2025-11-05 08:42:30 -06:00
parent 32f51ae739
commit eaaf17cd4c
4 changed files with 317 additions and 45 deletions

View File

@@ -71,6 +71,7 @@
"next": "^14.2.32",
"next-auth": "5.0.0-beta.29",
"next-intl": "^4.4.0",
"openscad-wasm-prebuilt": "^1.2.0",
"python-bridge": "^1.1.0",
"qrcode.react": "^4.2.0",
"react": "^18.2.0",

View File

@@ -0,0 +1,47 @@
// Inline version of abacus.scad that doesn't require BOSL2
// This version uses a hardcoded bounding box size instead of the bounding_box() function
// ---- USER CUSTOMIZABLE PARAMETERS ----
// These can be overridden via command line: -D 'columns=7' etc.
columns = 13; // Total number of columns (1-13, mirrored book design)
scale_factor = 1.5; // Overall size scale (preserves aspect ratio)
// -----------------------------------------
stl_path = "/3d-models/simplified.abacus.stl";
// Known bounding box dimensions of the simplified.abacus.stl file
// These were measured from the original file
bbox_size = [186, 60, 120]; // [width, depth, height] in STL units
// Calculate parameters based on column count
// The full STL has 13 columns. We want columns/2 per side (mirrored).
total_columns_in_stl = 13;
columns_per_side = columns / 2;
width_scale = columns_per_side / total_columns_in_stl;
// Column spacing: distance between mirrored halves
units_per_column = bbox_size[0] / total_columns_in_stl; // ~14.3 units per column
column_spacing = columns_per_side * units_per_column;
// --- actual model ---
module imported() {
import(stl_path, convexity = 10);
}
// Create a bounding box manually instead of using BOSL2's bounding_box()
module bounding_box_manual() {
translate([-bbox_size[0]/2, -bbox_size[1]/2, -bbox_size[2]/2])
cube(bbox_size);
}
module half_abacus() {
intersection() {
scale([width_scale, 1, 1]) bounding_box_manual();
imported();
}
}
scale([scale_factor, scale_factor, scale_factor]) {
translate([column_spacing, 0, 0]) mirror([1,0,0]) half_abacus();
half_abacus();
}

View File

@@ -2,7 +2,7 @@
import { OrbitControls, Stage } from '@react-three/drei'
import { Canvas, useLoader } from '@react-three/fiber'
import { Suspense, useEffect, useState } from 'react'
import { Suspense, useEffect, useRef, useState } from 'react'
// @ts-expect-error - STLLoader doesn't have TypeScript declarations
import { STLLoader } from 'three/examples/jsm/loaders/STLLoader.js'
import { css } from '../../../styled-system/css'
@@ -30,68 +30,86 @@ export function STLPreview({ columns, scaleFactor }: STLPreviewProps) {
const [previewUrl, setPreviewUrl] = useState<string>('/3d-models/simplified.abacus.stl')
const [isGenerating, setIsGenerating] = useState(false)
const [error, setError] = useState<string | null>(null)
const workerRef = useRef<Worker | null>(null)
// Initialize worker
useEffect(() => {
let mounted = true
const worker = new Worker(new URL('../../workers/openscad.worker.ts', import.meta.url), {
type: 'module',
})
const generatePreview = async () => {
setIsGenerating(true)
setError(null)
worker.onmessage = (event: MessageEvent) => {
const { data } = event
try {
const response = await fetch('/api/abacus/preview', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ columns, scaleFactor }),
})
switch (data.type) {
case 'ready':
console.log('[STLPreview] Worker ready')
break
if (!response.ok) {
throw new Error('Failed to generate preview')
}
case 'result': {
// Create blob from STL data
const blob = new Blob([data.stl], { type: 'application/octet-stream' })
const objectUrl = URL.createObjectURL(blob)
// Convert response to blob and create object URL
const blob = await response.blob()
const objectUrl = URL.createObjectURL(blob)
if (mounted) {
// Revoke old URL if it exists
if (previewUrl && previewUrl.startsWith('blob:')) {
URL.revokeObjectURL(previewUrl)
}
setPreviewUrl(objectUrl)
} else {
// Component unmounted, clean up the URL
URL.revokeObjectURL(objectUrl)
}
} catch (err) {
if (mounted) {
const errorMessage = err instanceof Error ? err.message : 'Failed to generate preview'
// Check if this is an OpenSCAD not found error
if (
errorMessage.includes('openscad: command not found') ||
errorMessage.includes('Command failed: openscad')
) {
setError('OpenSCAD not installed (preview only available in production/Docker)')
// Fallback to showing the base STL
setPreviewUrl('/3d-models/simplified.abacus.stl')
} else {
setError(errorMessage)
}
console.error('Preview generation error:', err)
}
} finally {
if (mounted) {
setPreviewUrl(objectUrl)
setIsGenerating(false)
setError(null)
break
}
case 'error':
console.error('[STLPreview] Worker error:', data.error)
setError(data.error)
setIsGenerating(false)
// Fallback to showing the base STL
setPreviewUrl('/3d-models/simplified.abacus.stl')
break
default:
console.warn('[STLPreview] Unknown message type:', data)
}
}
// Debounce: Wait 1 second after parameters change before regenerating
const timeoutId = setTimeout(generatePreview, 1000)
worker.onerror = (error) => {
console.error('[STLPreview] Worker error:', error)
setError('Worker failed to load')
setIsGenerating(false)
}
workerRef.current = worker
return () => {
worker.terminate()
workerRef.current = null
// Clean up any blob URLs
if (previewUrl && previewUrl.startsWith('blob:')) {
URL.revokeObjectURL(previewUrl)
}
}
}, [])
// Trigger rendering when parameters change
useEffect(() => {
if (!workerRef.current) return
setIsGenerating(true)
setError(null)
// Debounce: Wait 500ms after parameters change before regenerating
const timeoutId = setTimeout(() => {
workerRef.current?.postMessage({
type: 'render',
columns,
scaleFactor,
})
}, 500)
return () => {
mounted = false
clearTimeout(timeoutId)
}
}, [columns, scaleFactor])

View File

@@ -0,0 +1,206 @@
/// <reference lib="webworker" />
import { createOpenSCAD } from 'openscad-wasm-prebuilt'
declare const self: DedicatedWorkerGlobalScope
let openscad: Awaited<ReturnType<typeof createOpenSCAD>> | null = null
let simplifiedStlData: ArrayBuffer | null = null
let isInitializing = false
let initPromise: Promise<void> | null = null
// Message types
interface RenderRequest {
type: 'render'
columns: number
scaleFactor: number
}
interface InitRequest {
type: 'init'
}
type WorkerRequest = RenderRequest | InitRequest
// Initialize OpenSCAD instance and load base STL file
async function initialize() {
if (openscad) return // Already initialized
if (isInitializing) return initPromise // Already initializing, return existing promise
isInitializing = true
initPromise = (async () => {
try {
console.log('[OpenSCAD Worker] Initializing...')
// Create OpenSCAD instance
openscad = await createOpenSCAD()
console.log('[OpenSCAD Worker] OpenSCAD WASM loaded')
// Fetch the simplified STL file once
const stlResponse = await fetch('/3d-models/simplified.abacus.stl')
if (!stlResponse.ok) {
throw new Error(`Failed to fetch STL: ${stlResponse.statusText}`)
}
simplifiedStlData = await stlResponse.arrayBuffer()
console.log('[OpenSCAD Worker] Simplified STL loaded', simplifiedStlData.byteLength, 'bytes')
self.postMessage({ type: 'ready' })
} catch (error) {
console.error('[OpenSCAD Worker] Initialization failed:', error)
self.postMessage({
type: 'error',
error: error instanceof Error ? error.message : 'Initialization failed',
})
throw error
} finally {
isInitializing = false
}
})()
return initPromise
}
async function render(columns: number, scaleFactor: number) {
// Wait for initialization if not ready
if (!openscad || !simplifiedStlData) {
await initialize()
}
if (!openscad || !simplifiedStlData) {
throw new Error('Worker not initialized')
}
try {
console.log(`[OpenSCAD Worker] Rendering with columns=${columns}, scaleFactor=${scaleFactor}`)
// Get low-level instance for filesystem access
const instance = openscad.getInstance()
// Create directory if it doesn't exist
try {
instance.FS.mkdir('/3d-models')
console.log('[OpenSCAD Worker] Created /3d-models directory')
} catch (e: any) {
// Check if it's EEXIST (directory already exists) - errno 20
if (e.errno === 20) {
console.log('[OpenSCAD Worker] /3d-models directory already exists')
} else {
console.error('[OpenSCAD Worker] Failed to create directory:', e)
throw new Error(`Failed to create /3d-models directory: ${e.message || e}`)
}
}
// Write STL file
instance.FS.writeFile('/3d-models/simplified.abacus.stl', new Uint8Array(simplifiedStlData))
console.log('[OpenSCAD Worker] Wrote simplified STL to filesystem')
// Generate the SCAD code with parameters
const scadCode = `
// Inline version of abacus.scad that doesn't require BOSL2
columns = ${columns};
scale_factor = ${scaleFactor};
stl_path = "/3d-models/simplified.abacus.stl";
// Known bounding box dimensions
bbox_size = [186, 60, 120];
// Calculate parameters
total_columns_in_stl = 13;
columns_per_side = columns / 2;
width_scale = columns_per_side / total_columns_in_stl;
units_per_column = bbox_size[0] / total_columns_in_stl;
column_spacing = columns_per_side * units_per_column;
// Model modules
module imported() {
import(stl_path, convexity = 10);
}
module bounding_box_manual() {
translate([-bbox_size[0]/2, -bbox_size[1]/2, -bbox_size[2]/2])
cube(bbox_size);
}
module half_abacus() {
intersection() {
scale([width_scale, 1, 1]) bounding_box_manual();
imported();
}
}
scale([scale_factor, scale_factor, scale_factor]) {
translate([column_spacing, 0, 0]) mirror([1,0,0]) half_abacus();
half_abacus();
}
`
// Use high-level renderToStl API
console.log('[OpenSCAD Worker] Calling renderToStl...')
const stlBuffer = await openscad.renderToStl(scadCode)
console.log('[OpenSCAD Worker] Rendering complete:', stlBuffer.byteLength, 'bytes')
// Send the result back
self.postMessage({
type: 'result',
stl: stlBuffer,
}, [stlBuffer]) // Transfer ownership of the buffer
// Clean up STL file
try {
instance.FS.unlink('/3d-models/simplified.abacus.stl')
} catch (e) {
// Ignore cleanup errors
}
} catch (error) {
console.error('[OpenSCAD Worker] Rendering failed:', error)
// Try to get more error details
let errorMessage = 'Rendering failed'
if (error instanceof Error) {
errorMessage = error.message
console.error('[OpenSCAD Worker] Error stack:', error.stack)
}
// Check if it's an Emscripten FS error
if (error && typeof error === 'object' && 'errno' in error) {
console.error('[OpenSCAD Worker] FS errno:', (error as any).errno)
console.error('[OpenSCAD Worker] FS error details:', error)
}
self.postMessage({
type: 'error',
error: errorMessage,
})
}
}
// Message handler
self.onmessage = async (event: MessageEvent<WorkerRequest>) => {
const { data } = event
try {
switch (data.type) {
case 'init':
await initialize()
break
case 'render':
await render(data.columns, data.scaleFactor)
break
default:
console.error('[OpenSCAD Worker] Unknown message type:', data)
}
} catch (error) {
console.error('[OpenSCAD Worker] Message handler error:', error)
self.postMessage({
type: 'error',
error: error instanceof Error ? error.message : 'Unknown error',
})
}
}
// Auto-initialize on worker start
initialize()