chore: remove abandoned 3d-printing feature
Remove dead code from abandoned 3D printing initiative: - Delete jobManager.ts (had unbounded memory growth) - Delete openscad.worker.ts (unused) - Delete 3D model files from public/ - Remove openscad-wasm-prebuilt dependency - Clean up doc references 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
ae1a0a8e2d
commit
f74db216da
|
|
@ -1,335 +0,0 @@
|
||||||
# 3D Printing Docker Setup
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
The 3D printable abacus customization feature is fully containerized with optimized Docker multi-stage builds.
|
|
||||||
|
|
||||||
**Key Technologies:**
|
|
||||||
|
|
||||||
- OpenSCAD 2021.01 (for rendering STL/3MF from .scad files)
|
|
||||||
- BOSL2 v2.0.0 (minimized library, .scad files only)
|
|
||||||
- Typst v0.11.1 (pre-built binary)
|
|
||||||
|
|
||||||
**Image Size:** ~257MB (optimized with multi-stage builds, saved ~38MB)
|
|
||||||
|
|
||||||
**Build Stages:** 7 total (base → builder → deps → typst-builder → bosl2-builder → runner)
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
The 3D printable abacus customization feature requires OpenSCAD and the BOSL2 library to be available in the Docker container.
|
|
||||||
|
|
||||||
## Size Optimization Strategy
|
|
||||||
|
|
||||||
The Dockerfile uses **multi-stage builds** to minimize the final image size:
|
|
||||||
|
|
||||||
1. **typst-builder stage** - Downloads and extracts typst, discards wget/xz-utils
|
|
||||||
2. **bosl2-builder stage** - Clones BOSL2 and removes unnecessary files (tests, docs, examples, images)
|
|
||||||
3. **runner stage** - Only copies final binaries and minimized libraries
|
|
||||||
|
|
||||||
### Size Reductions
|
|
||||||
|
|
||||||
- **Removed from runner**: git, wget, curl, xz-utils (~40MB)
|
|
||||||
- **BOSL2 minimized**: Removed .git, tests, tutorials, examples, images, markdown files (~2-3MB savings)
|
|
||||||
- **Kept only .scad files** in BOSL2 library
|
|
||||||
|
|
||||||
## Dockerfile Changes
|
|
||||||
|
|
||||||
### Build Stages Overview
|
|
||||||
|
|
||||||
The Dockerfile now has **7 stages**:
|
|
||||||
|
|
||||||
1. **base** (Alpine) - Install build tools and dependencies
|
|
||||||
2. **builder** (Alpine) - Build Next.js application
|
|
||||||
3. **deps** (Alpine) - Install production node_modules
|
|
||||||
4. **typst-builder** (Debian) - Download and extract typst binary
|
|
||||||
5. **bosl2-builder** (Debian) - Clone and minimize BOSL2 library
|
|
||||||
6. **runner** (Debian) - Final production image
|
|
||||||
|
|
||||||
### Stage 1-3: Base, Builder, Deps (unchanged)
|
|
||||||
|
|
||||||
Uses Alpine Linux for building the application (smaller and faster builds).
|
|
||||||
|
|
||||||
### Stage 4: Typst Builder (lines 68-87)
|
|
||||||
|
|
||||||
```dockerfile
|
|
||||||
FROM node:18-slim AS typst-builder
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
||||||
wget \
|
|
||||||
xz-utils \
|
|
||||||
ca-certificates \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
RUN ARCH=$(uname -m) && \
|
|
||||||
... download and install typst from GitHub releases
|
|
||||||
```
|
|
||||||
|
|
||||||
**Purpose:** Download typst binary in isolation, then discard build tools (wget, xz-utils).
|
|
||||||
|
|
||||||
**Result:** Only the typst binary is copied to runner stage (line 120).
|
|
||||||
|
|
||||||
### Stage 5: BOSL2 Builder (lines 90-103)
|
|
||||||
|
|
||||||
```dockerfile
|
|
||||||
FROM node:18-slim AS bosl2-builder
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
||||||
git \
|
|
||||||
ca-certificates \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
RUN mkdir -p /bosl2 && \
|
|
||||||
cd /bosl2 && \
|
|
||||||
git clone --depth 1 --branch v2.0.0 https://github.com/BelfrySCAD/BOSL2.git . && \
|
|
||||||
# Remove unnecessary files to minimize size
|
|
||||||
rm -rf .git .github tests tutorials examples images *.md CONTRIBUTING* LICENSE* && \
|
|
||||||
# Keep only .scad files and essential directories
|
|
||||||
find . -type f ! -name "*.scad" -delete && \
|
|
||||||
find . -type d -empty -delete
|
|
||||||
```
|
|
||||||
|
|
||||||
**Purpose:** Clone BOSL2 and aggressively minimize by removing:
|
|
||||||
|
|
||||||
- `.git` directory
|
|
||||||
- Tests, tutorials, examples
|
|
||||||
- Documentation (markdown files)
|
|
||||||
- Images
|
|
||||||
- All non-.scad files
|
|
||||||
|
|
||||||
**Result:** Minimized BOSL2 library (~1-2MB instead of ~5MB) copied to runner (line 124).
|
|
||||||
|
|
||||||
### Stage 6: Runner - Production Image (lines 106-177)
|
|
||||||
|
|
||||||
**Base Image:** `node:18-slim` (Debian) - Required for OpenSCAD availability
|
|
||||||
|
|
||||||
**Runtime Dependencies (lines 111-117):**
|
|
||||||
|
|
||||||
```dockerfile
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
||||||
python3 \
|
|
||||||
python3-pip \
|
|
||||||
qpdf \
|
|
||||||
openscad \
|
|
||||||
ca-certificates \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
```
|
|
||||||
|
|
||||||
**Removed from runner:**
|
|
||||||
|
|
||||||
- ❌ git (only needed in bosl2-builder)
|
|
||||||
- ❌ wget (only needed in typst-builder)
|
|
||||||
- ❌ curl (not needed at runtime)
|
|
||||||
- ❌ xz-utils (only needed in typst-builder)
|
|
||||||
|
|
||||||
**Artifacts Copied from Other Stages:**
|
|
||||||
|
|
||||||
```dockerfile
|
|
||||||
# From typst-builder (line 120)
|
|
||||||
COPY --from=typst-builder /usr/local/bin/typst /usr/local/bin/typst
|
|
||||||
|
|
||||||
# From bosl2-builder (line 124)
|
|
||||||
COPY --from=bosl2-builder /bosl2 /usr/share/openscad/libraries/BOSL2
|
|
||||||
|
|
||||||
# From builder (lines 131-159)
|
|
||||||
# Next.js app, styled-system, server files, etc.
|
|
||||||
|
|
||||||
# From deps (lines 145-146)
|
|
||||||
# Production node_modules only
|
|
||||||
```
|
|
||||||
|
|
||||||
BOSL2 v2.0.0 (minimized) is copied to `/usr/share/openscad/libraries/BOSL2/`, which is OpenSCAD's default library search path. This allows `include <BOSL2/std.scad>` to work in the abacus.scad file.
|
|
||||||
|
|
||||||
### Temp Directory for Job Outputs (line 168)
|
|
||||||
|
|
||||||
```dockerfile
|
|
||||||
RUN mkdir -p tmp/3d-jobs && chown nextjs:nodejs tmp
|
|
||||||
```
|
|
||||||
|
|
||||||
Creates the directory where JobManager stores generated 3D files.
|
|
||||||
|
|
||||||
## Files Included in Docker Image
|
|
||||||
|
|
||||||
The following files are automatically included via the `COPY` command at line 132:
|
|
||||||
|
|
||||||
```
|
|
||||||
apps/web/public/3d-models/
|
|
||||||
├── abacus.scad (parametric OpenSCAD source)
|
|
||||||
└── simplified.abacus.stl (base model, 4.8MB)
|
|
||||||
```
|
|
||||||
|
|
||||||
These files are NOT excluded by `.dockerignore`.
|
|
||||||
|
|
||||||
## Testing the Docker Build
|
|
||||||
|
|
||||||
### Local Testing
|
|
||||||
|
|
||||||
1. **Build the Docker image:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker build -t soroban-abacus-test .
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Run the container:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker run -p 3000:3000 soroban-abacus-test
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Test OpenSCAD inside the container:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker exec -it <container-id> sh
|
|
||||||
openscad --version
|
|
||||||
ls /usr/share/openscad/libraries/BOSL2
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **Test the 3D printing endpoint:**
|
|
||||||
- Visit http://localhost:3000/3d-print
|
|
||||||
- Adjust parameters and generate a file
|
|
||||||
- Monitor job progress
|
|
||||||
- Download the result
|
|
||||||
|
|
||||||
### Verify BOSL2 Installation
|
|
||||||
|
|
||||||
Inside the running container:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Check OpenSCAD version
|
|
||||||
openscad --version
|
|
||||||
|
|
||||||
# Verify BOSL2 library exists
|
|
||||||
ls -la /usr/share/openscad/libraries/BOSL2/
|
|
||||||
|
|
||||||
# Test rendering a simple file
|
|
||||||
cd /app/apps/web/public/3d-models
|
|
||||||
openscad -o /tmp/test.stl abacus.scad
|
|
||||||
```
|
|
||||||
|
|
||||||
## Production Deployment
|
|
||||||
|
|
||||||
### Environment Variables
|
|
||||||
|
|
||||||
No additional environment variables are required for the 3D printing feature.
|
|
||||||
|
|
||||||
### Volume Mounts (Optional)
|
|
||||||
|
|
||||||
For better performance and to avoid rebuilding the image when updating 3D models:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker run -p 3000:3000 \
|
|
||||||
-v $(pwd)/apps/web/public/3d-models:/app/apps/web/public/3d-models:ro \
|
|
||||||
soroban-abacus-test
|
|
||||||
```
|
|
||||||
|
|
||||||
### Disk Space Considerations
|
|
||||||
|
|
||||||
- **BOSL2 library**: ~5MB (cloned during build)
|
|
||||||
- **Base STL file**: 4.8MB (in public/3d-models/)
|
|
||||||
- **Generated files**: Vary by parameters, typically 1-10MB each
|
|
||||||
- **Job cleanup**: Old jobs are automatically cleaned up after 1 hour
|
|
||||||
|
|
||||||
## Image Size
|
|
||||||
|
|
||||||
The final image is Debian-based (required for OpenSCAD), but optimized using multi-stage builds:
|
|
||||||
|
|
||||||
**Before optimization (original Debian approach):**
|
|
||||||
|
|
||||||
- Base runner: ~250MB
|
|
||||||
- With all build tools (git, wget, curl, xz-utils): ~290MB
|
|
||||||
- With BOSL2 (full): ~295MB
|
|
||||||
- **Total: ~295MB**
|
|
||||||
|
|
||||||
**After optimization (current multi-stage approach):**
|
|
||||||
|
|
||||||
- Base runner: ~250MB
|
|
||||||
- Runtime deps only (no build tools): ~250MB
|
|
||||||
- BOSL2 (minimized, .scad only): ~252MB
|
|
||||||
- 3D models (STL): ~257MB
|
|
||||||
- **Total: ~257MB**
|
|
||||||
|
|
||||||
**Savings: ~38MB (~13% reduction)**
|
|
||||||
|
|
||||||
### What Was Removed
|
|
||||||
|
|
||||||
- ❌ git (~15MB)
|
|
||||||
- ❌ wget (~2MB)
|
|
||||||
- ❌ curl (~5MB)
|
|
||||||
- ❌ xz-utils (~1MB)
|
|
||||||
- ❌ BOSL2 .git directory (~1MB)
|
|
||||||
- ❌ BOSL2 tests, examples, tutorials (~10MB)
|
|
||||||
- ❌ BOSL2 images and docs (~4MB)
|
|
||||||
|
|
||||||
**Total removed: ~38MB**
|
|
||||||
|
|
||||||
This trade-off (Debian vs Alpine) is necessary for OpenSCAD availability, but the multi-stage approach minimizes the size impact.
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### OpenSCAD Not Found
|
|
||||||
|
|
||||||
If you see "openscad: command not found" in logs:
|
|
||||||
|
|
||||||
1. Verify OpenSCAD is installed:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker exec -it <container-id> which openscad
|
|
||||||
docker exec -it <container-id> openscad --version
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Check if the Debian package install succeeded:
|
|
||||||
```bash
|
|
||||||
docker exec -it <container-id> dpkg -l | grep openscad
|
|
||||||
```
|
|
||||||
|
|
||||||
### BOSL2 Include Error
|
|
||||||
|
|
||||||
If OpenSCAD reports "Can't open library 'BOSL2/std.scad'":
|
|
||||||
|
|
||||||
1. Check BOSL2 exists:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker exec -it <container-id> ls /usr/share/openscad/libraries/BOSL2/std.scad
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Test include path:
|
|
||||||
```bash
|
|
||||||
docker exec -it <container-id> sh -c "cd /tmp && echo 'include <BOSL2/std.scad>; cube(10);' > test.scad && openscad -o test.stl test.scad"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Job Fails with "Permission Denied"
|
|
||||||
|
|
||||||
Check tmp directory permissions:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker exec -it <container-id> ls -la /app/apps/web/tmp
|
|
||||||
# Should show: drwxr-xr-x ... nextjs nodejs ... 3d-jobs
|
|
||||||
```
|
|
||||||
|
|
||||||
### Large File Generation Timeout
|
|
||||||
|
|
||||||
Jobs timeout after 60 seconds. For complex models, increase the timeout in `jobManager.ts:138`:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
timeout: 120000, // 2 minutes instead of 60 seconds
|
|
||||||
```
|
|
||||||
|
|
||||||
## Performance Notes
|
|
||||||
|
|
||||||
- **Cold start**: First generation takes ~5-10 seconds (OpenSCAD initialization)
|
|
||||||
- **Warm generations**: Subsequent generations take ~3-5 seconds
|
|
||||||
- **STL size**: Typically 5-15MB depending on scale parameters
|
|
||||||
- **3MF size**: Similar to STL (no significant compression)
|
|
||||||
- **SCAD size**: ~1KB (just text parameters)
|
|
||||||
|
|
||||||
## Monitoring
|
|
||||||
|
|
||||||
Job processing is logged to stdout:
|
|
||||||
|
|
||||||
```
|
|
||||||
Executing: openscad -o /app/apps/web/tmp/3d-jobs/abacus-abc123.stl ...
|
|
||||||
Job abc123 completed successfully
|
|
||||||
```
|
|
||||||
|
|
||||||
Check logs with:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker logs <container-id> | grep "Job"
|
|
||||||
```
|
|
||||||
|
|
@ -676,8 +676,6 @@ The practice experience is the actual problem-solving interface where the studen
|
||||||
│ │ ● ● ● ● ○ ○ ○ ○ ○ │ │
|
│ │ ● ● ● ● ○ ○ ○ ○ ○ │ │
|
||||||
│ └───────────────────────┘ │
|
│ └───────────────────────┘ │
|
||||||
│ │
|
│ │
|
||||||
│ 3D Model: public/3d-models/simplified.abacus.stl │
|
|
||||||
│ │
|
|
||||||
└─────────────────────────────────────────────────────────────────┘
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -896,7 +894,6 @@ const constraints = {
|
||||||
| `InputPhase` | `src/arcade-games/memory-quiz/components/InputPhase.tsx` | Custom numeric keypad + device detection |
|
| `InputPhase` | `src/arcade-games/memory-quiz/components/InputPhase.tsx` | Custom numeric keypad + device detection |
|
||||||
| `problemGenerator` | `src/utils/problemGenerator.ts` | Skill-constrained problem generation |
|
| `problemGenerator` | `src/utils/problemGenerator.ts` | Skill-constrained problem generation |
|
||||||
| `AbacusReact` | `@soroban/abacus-react` | On-screen abacus (last resort) |
|
| `AbacusReact` | `@soroban/abacus-react` | On-screen abacus (last resort) |
|
||||||
| 3D Abacus Model | `public/3d-models/simplified.abacus.stl` | Physical abacus recommendation |
|
|
||||||
|
|
||||||
### Data Model Extensions
|
### Data Model Extensions
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -79,7 +79,6 @@
|
||||||
"next": "^14.2.32",
|
"next": "^14.2.32",
|
||||||
"next-auth": "5.0.0-beta.29",
|
"next-auth": "5.0.0-beta.29",
|
||||||
"next-intl": "^4.4.0",
|
"next-intl": "^4.4.0",
|
||||||
"openscad-wasm-prebuilt": "^1.2.0",
|
|
||||||
"python-bridge": "^1.1.0",
|
"python-bridge": "^1.1.0",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"qrcode.react": "^4.2.0",
|
"qrcode.react": "^4.2.0",
|
||||||
|
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
// 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();
|
|
||||||
}
|
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
include <BOSL2/std.scad>; // BOSL2 v2.0 or newer
|
|
||||||
|
|
||||||
// ---- 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 = "./simplified.abacus.stl";
|
|
||||||
|
|
||||||
// Calculate parameters based on column count
|
|
||||||
// The full STL has 13 columns. We want columns/2 per side (mirrored).
|
|
||||||
// The original bounding box intersection: scale([35/186, 1, 1])
|
|
||||||
// 35/186 ≈ 0.188 = ~2.44 columns, so 186 units ≈ 13 columns, ~14.3 units per column
|
|
||||||
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
|
|
||||||
// Original spacing of 69 for ~2.4 columns/side
|
|
||||||
// Calculate proportional spacing based on columns
|
|
||||||
units_per_column = 186 / 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);
|
|
||||||
|
|
||||||
module half_abacus() {
|
|
||||||
intersection() {
|
|
||||||
scale([width_scale, 1, 1]) bounding_box() imported();
|
|
||||||
imported();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
scale([scale_factor, scale_factor, scale_factor]) {
|
|
||||||
translate([column_spacing, 0, 0]) mirror([1,0,0]) half_abacus();
|
|
||||||
half_abacus();
|
|
||||||
}
|
|
||||||
Binary file not shown.
|
|
@ -1,215 +0,0 @@
|
||||||
import { exec } from 'node:child_process'
|
|
||||||
import { randomBytes } from 'node:crypto'
|
|
||||||
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'
|
|
||||||
import { join } from 'node:path'
|
|
||||||
import { promisify } from 'node:util'
|
|
||||||
|
|
||||||
const execAsync = promisify(exec)
|
|
||||||
|
|
||||||
export type JobStatus = 'pending' | 'processing' | 'completed' | 'failed'
|
|
||||||
|
|
||||||
export interface Job {
|
|
||||||
id: string
|
|
||||||
status: JobStatus
|
|
||||||
params: AbacusParams
|
|
||||||
error?: string
|
|
||||||
outputPath?: string
|
|
||||||
createdAt: Date
|
|
||||||
completedAt?: Date
|
|
||||||
progress?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AbacusParams {
|
|
||||||
columns: number // Number of columns (1-13)
|
|
||||||
scaleFactor: number // Overall size multiplier
|
|
||||||
widthMm?: number // Optional: desired width in mm (overrides scaleFactor)
|
|
||||||
format: 'stl' | '3mf' | 'scad'
|
|
||||||
// 3MF color options
|
|
||||||
frameColor?: string
|
|
||||||
heavenBeadColor?: string
|
|
||||||
earthBeadColor?: string
|
|
||||||
decorationColor?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
// In-memory job storage (can be upgraded to Redis later)
|
|
||||||
const jobs = new Map<string, Job>()
|
|
||||||
|
|
||||||
// Temporary directory for generated files
|
|
||||||
const TEMP_DIR = join(process.cwd(), 'tmp', '3d-jobs')
|
|
||||||
|
|
||||||
export class JobManager {
|
|
||||||
static generateJobId(): string {
|
|
||||||
return randomBytes(16).toString('hex')
|
|
||||||
}
|
|
||||||
|
|
||||||
static async createJob(params: AbacusParams): Promise<string> {
|
|
||||||
const jobId = JobManager.generateJobId()
|
|
||||||
const job: Job = {
|
|
||||||
id: jobId,
|
|
||||||
status: 'pending',
|
|
||||||
params,
|
|
||||||
createdAt: new Date(),
|
|
||||||
}
|
|
||||||
|
|
||||||
jobs.set(jobId, job)
|
|
||||||
|
|
||||||
// Start processing in background
|
|
||||||
JobManager.processJob(jobId).catch((error) => {
|
|
||||||
console.error(`Job ${jobId} failed:`, error)
|
|
||||||
const job = jobs.get(jobId)
|
|
||||||
if (job) {
|
|
||||||
job.status = 'failed'
|
|
||||||
job.error = error.message
|
|
||||||
job.completedAt = new Date()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return jobId
|
|
||||||
}
|
|
||||||
|
|
||||||
static getJob(jobId: string): Job | undefined {
|
|
||||||
return jobs.get(jobId)
|
|
||||||
}
|
|
||||||
|
|
||||||
static async processJob(jobId: string): Promise<void> {
|
|
||||||
const job = jobs.get(jobId)
|
|
||||||
if (!job) throw new Error('Job not found')
|
|
||||||
|
|
||||||
job.status = 'processing'
|
|
||||||
job.progress = 'Preparing workspace...'
|
|
||||||
|
|
||||||
// Create temp directory
|
|
||||||
await mkdir(TEMP_DIR, { recursive: true })
|
|
||||||
|
|
||||||
const outputFileName = `abacus-${jobId}.${job.params.format}`
|
|
||||||
const outputPath = join(TEMP_DIR, outputFileName)
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Build OpenSCAD command
|
|
||||||
const scadPath = join(process.cwd(), 'public', '3d-models', 'abacus.scad')
|
|
||||||
const stlPath = join(process.cwd(), 'public', '3d-models', 'simplified.abacus.stl')
|
|
||||||
|
|
||||||
// If format is 'scad', just copy the file with custom parameters
|
|
||||||
if (job.params.format === 'scad') {
|
|
||||||
job.progress = 'Generating OpenSCAD file...'
|
|
||||||
const scadContent = await readFile(scadPath, 'utf-8')
|
|
||||||
const customizedScad = scadContent
|
|
||||||
.replace(/columns = \d+\.?\d*/, `columns = ${job.params.columns}`)
|
|
||||||
.replace(/scale_factor = \d+\.?\d*/, `scale_factor = ${job.params.scaleFactor}`)
|
|
||||||
|
|
||||||
await writeFile(outputPath, customizedScad)
|
|
||||||
job.outputPath = outputPath
|
|
||||||
job.status = 'completed'
|
|
||||||
job.completedAt = new Date()
|
|
||||||
job.progress = 'Complete!'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
job.progress = 'Rendering 3D model...'
|
|
||||||
|
|
||||||
// Build command with parameters
|
|
||||||
const cmd = [
|
|
||||||
'openscad',
|
|
||||||
'-o',
|
|
||||||
outputPath,
|
|
||||||
'-D',
|
|
||||||
`'columns=${job.params.columns}'`,
|
|
||||||
'-D',
|
|
||||||
`'scale_factor=${job.params.scaleFactor}'`,
|
|
||||||
scadPath,
|
|
||||||
].join(' ')
|
|
||||||
|
|
||||||
console.log(`Executing: ${cmd}`)
|
|
||||||
|
|
||||||
// Execute OpenSCAD (with 60s timeout)
|
|
||||||
// Note: OpenSCAD may exit with non-zero status due to CGAL warnings
|
|
||||||
// but still produce valid output. We'll check file existence afterward.
|
|
||||||
try {
|
|
||||||
await execAsync(cmd, {
|
|
||||||
timeout: 60000,
|
|
||||||
cwd: join(process.cwd(), 'public', '3d-models'),
|
|
||||||
})
|
|
||||||
} catch (execError) {
|
|
||||||
// Log the error but don't throw yet - check if output was created
|
|
||||||
console.warn(`OpenSCAD reported errors, but checking if output was created:`, execError)
|
|
||||||
|
|
||||||
// Check if output file exists despite the error
|
|
||||||
try {
|
|
||||||
await readFile(outputPath)
|
|
||||||
console.log(`Output file created despite OpenSCAD warnings - proceeding`)
|
|
||||||
} catch (readError) {
|
|
||||||
// File doesn't exist, this is a real failure
|
|
||||||
console.error(`OpenSCAD execution failed and no output file created:`, execError)
|
|
||||||
if (execError instanceof Error) {
|
|
||||||
throw new Error(`OpenSCAD error: ${execError.message}`)
|
|
||||||
}
|
|
||||||
throw execError
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
job.progress = 'Finalizing...'
|
|
||||||
|
|
||||||
// Verify output exists and check file size
|
|
||||||
const fileBuffer = await readFile(outputPath)
|
|
||||||
const fileSizeMB = fileBuffer.length / (1024 * 1024)
|
|
||||||
|
|
||||||
// Maximum file size: 100MB (to prevent memory issues)
|
|
||||||
const MAX_FILE_SIZE_MB = 100
|
|
||||||
if (fileSizeMB > MAX_FILE_SIZE_MB) {
|
|
||||||
throw new Error(
|
|
||||||
`Generated file is too large (${fileSizeMB.toFixed(1)}MB). Maximum allowed is ${MAX_FILE_SIZE_MB}MB. Try reducing scale parameters.`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Generated STL file size: ${fileSizeMB.toFixed(2)}MB`)
|
|
||||||
|
|
||||||
job.outputPath = outputPath
|
|
||||||
job.status = 'completed'
|
|
||||||
job.completedAt = new Date()
|
|
||||||
job.progress = 'Complete!'
|
|
||||||
|
|
||||||
console.log(`Job ${jobId} completed successfully`)
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Job ${jobId} failed:`, error)
|
|
||||||
job.status = 'failed'
|
|
||||||
job.error = error instanceof Error ? error.message : 'Unknown error occurred'
|
|
||||||
job.completedAt = new Date()
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static async getJobOutput(jobId: string): Promise<Buffer> {
|
|
||||||
const job = jobs.get(jobId)
|
|
||||||
if (!job) throw new Error('Job not found')
|
|
||||||
if (job.status !== 'completed') throw new Error(`Job is ${job.status}, not completed`)
|
|
||||||
if (!job.outputPath) throw new Error('Output path not set')
|
|
||||||
|
|
||||||
return await readFile(job.outputPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
static async cleanupJob(jobId: string): Promise<void> {
|
|
||||||
const job = jobs.get(jobId)
|
|
||||||
if (!job) return
|
|
||||||
|
|
||||||
if (job.outputPath) {
|
|
||||||
try {
|
|
||||||
await rm(job.outputPath)
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Failed to cleanup job ${jobId}:`, error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
jobs.delete(jobId)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cleanup old jobs (should be called periodically)
|
|
||||||
static async cleanupOldJobs(maxAgeMs = 3600000): Promise<void> {
|
|
||||||
const now = Date.now()
|
|
||||||
for (const [jobId, job] of jobs.entries()) {
|
|
||||||
const age = now - job.createdAt.getTime()
|
|
||||||
if (age > maxAgeMs) {
|
|
||||||
await JobManager.cleanupJob(jobId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,209 +0,0 @@
|
||||||
/// <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()
|
|
||||||
Loading…
Reference in New Issue