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:
@@ -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",
|
||||
|
||||
47
apps/web/public/3d-models/abacus-inline.scad
Normal file
47
apps/web/public/3d-models/abacus-inline.scad
Normal 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();
|
||||
}
|
||||
@@ -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])
|
||||
|
||||
206
apps/web/src/workers/openscad.worker.ts
Normal file
206
apps/web/src/workers/openscad.worker.ts
Normal 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()
|
||||
Reference in New Issue
Block a user