AI Prompt: Forma3D.Connect — Server-Side OpenSCAD Preview Pipeline (Option B)¶
Purpose: Replace the JSCAD geometry pipeline with server-side OpenSCAD CLI execution in Docker, using the original
gridflock.scadsource for pixel-perfect 3D preview rendering
Estimated Effort: 20–30 hours
Prerequisites: Current JSCAD-based preview pipeline working (POST /api/v1/gridflock/preview), Docker Compose staging deployment operational
Output: Preview endpoint generating mathematically identical STLs to the GridFlock web editor, with aggressive caching, feature flag rollout, and zero client-side changes
Status: 🚧 TODO
Research:docs/03-architecture/research/openscad-wasm-preview-research.md— Option B (Server-Side OpenSCAD CLI)
🎯 Mission¶
Replace the custom JSCAD geometry layer (libs/gridflock-core/src/lib/geometry/) with server-side OpenSCAD CLI execution inside a Docker container. Instead of reimplementing GridFlock geometry in JavaScript, execute the original gridflock.scad file directly using the OpenSCAD binary, producing pixel-perfect STL output identical to the GridFlock web editor.
Why this matters:
- Geometry fidelity: The current JSCAD pipeline produces approximations with internal faces and CSG artifacts. OpenSCAD uses the same kernel as GridFlock, producing exact output.
- Feature coverage: JSCAD reimplements ~40% of GridFlock features. OpenSCAD supports 100% — click latch, ClickGroove, vertical screws, thumb screws, filler algorithms — all for free.
- Maintenance burden: Eliminates ~1,500 lines of custom geometry code in
libs/gridflock-core/src/lib/geometry/. Upstream GridFlock updates require only pulling new.scadfiles. - Licensing safety: OpenSCAD (GPL-2.0) runs as a server-side tool. Output STL files are not GPL-encumbered. No client-side GPL distribution.
What changes:
- A new Docker sidecar container runs OpenSCAD CLI
- A new
OpenScadGeneratorServiceinapps/gridflock-servicereplaces JSCAD calls - Parameter mapping translates our API DTO to OpenSCAD
-Dflags - Redis-backed STL caching eliminates redundant renders
- Feature flag allows gradual rollout and instant rollback
What stays the same:
- Client-side code (
configurator-3d.js,configurator.liquid) — zero changes - API contract (
POST /api/v1/gridflock/previewwithPreviewGridDto) — same endpoint, same binary STL response - Three.js rendering — unchanged
- BullMQ-based async generation pipeline — unchanged (enhanced later)
📌 Context (Current State)¶
Current Preview Flow¶
Client: POST /api/v1/gridflock/preview { widthMm, heightMm }
│
▼
PreviewController (preview.controller.ts)
│ Rate limit: 10 req/min, Cache-Control: 5 min
│
▼
GridflockPreviewService.generatePreview()
├── calculatePlateSet(widthMm, heightMm) → PlateSpec[]
├── For each plate:
│ └── generateGridFlockPlate(spec)
│ ├── JSCAD: base-plate.ts (rounded rectangle)
│ ├── JSCAD: grid-cells.ts (cell cutters)
│ ├── JSCAD: magnet-holes.ts (cavities)
│ ├── JSCAD: intersection-puzzle.ts (connectors)
│ ├── JSCAD: edge-puzzle.ts (edge connectors)
│ ├── JSCAD: numbering.ts (plate numbers)
│ └── serializeToStl() → Buffer
└── combineStlBuffers(buffers, offsets) → Combined STL
│
▼
Binary STL → StreamableFile response
Current Geometry Issues¶
| Issue | Impact | Root Cause |
|---|---|---|
| Internal faces / CSG artifacts | Visual glitches in Three.js | JSCAD CSG creates overlapping internal geometry |
| Connector fidelity | Shape mismatch vs real GridFlock | intersection-puzzle.ts approximates SVG-derived paths |
| Missing features | 60% of GridFlock features unavailable | Manual reimplementation hasn't kept up |
| Rendering quality | Doesn't match slicer preview quality | Simplified geometry (e.g., no lip detail in base-plate.ts) |
Key Files¶
| Layer | File | Role |
|---|---|---|
| Library | libs/gridflock-core/src/lib/generator.ts |
Orchestrates JSCAD geometry |
| Library | libs/gridflock-core/src/lib/serializer.ts |
JSCAD → binary STL |
| Library | libs/gridflock-core/src/lib/geometry/*.ts |
6 geometry modules (~1,500 lines total) |
| Library | libs/gridflock-core/src/lib/stl-combiner.ts |
Merges plate STLs with offsets |
| Library | libs/gridflock-core/src/lib/plate-set-calculator.ts |
Calculates multi-plate layouts |
| Service | apps/gridflock-service/src/gridflock/preview.controller.ts |
Public preview endpoint |
| Service | apps/gridflock-service/src/gridflock/gridflock-preview.service.ts |
Preview generation orchestration |
| Service | apps/gridflock-service/src/gridflock/dto/preview-grid.dto.ts |
Request DTO (widthMm, heightMm) |
| Docker | apps/gridflock-service/Dockerfile |
Multi-stage Node 20 Alpine build |
| Compose | deployment/staging/docker-compose.yml |
Gridflock service config (port 3004) |
| Client | deployment/shopify-theme/assets/configurator-3d.js |
Three.js STL renderer |
| Client | deployment/shopify-theme/sections/configurator.liquid |
Shopify theme integration |
Infrastructure Context¶
| Component | Details |
|---|---|
| Redis | redis:7-alpine, max 256MB, noeviction policy, used for BullMQ queues + event bus |
| Docker volumes | gridflock-data:/data/gridflock for persistent STL storage |
| Health check | wget http://localhost:3004/health |
| Rate limiting | 10 requests/minute per IP on preview endpoint |
| HTTP caching | Cache-Control: public, max-age=300 on preview responses |
🛠️ Tech Stack Reference¶
- OpenSCAD: CLI binary in Docker container (official
openscad/openscadimage or custom Alpine build) - GridFlock source:
gridflock.scad(MIT license) +gridfinity-rebuilt-openscadsubmodule (MIT) +puzzle.svg - Server runtime: NestJS on Node 20 Alpine
- Subprocess execution: Node.js
child_process.execFilewith timeout - Caching: Redis 7 (existing) for STL binary caching
- Container orchestration: Docker Compose (existing staging setup)
- Client rendering: Three.js (unchanged)
- Feature flags: Environment variable toggle (
OPENSCAD_ENABLED=true/false)
📐 Architecture¶
Target Preview Flow¶
Client: POST /api/v1/gridflock/preview { widthMm, heightMm }
│
▼
PreviewController (unchanged)
│
▼
GridflockPreviewService.generatePreview()
│
├── [Feature flag: OPENSCAD_ENABLED]
│ │
│ ├── true → OpenScadGeneratorService
│ │ ├── mapParamsToScadFlags(dto) → string[]
│ │ ├── computeCacheKey(params) → SHA256 hash
│ │ ├── Redis cache lookup
│ │ │ ├── HIT → return cached STL (<50ms)
│ │ │ └── MISS ↓
│ │ ├── Write .scad params to temp dir
│ │ ├── Execute: openscad gridflock.scad -o output.stl -D "..."
│ │ │ (subprocess with timeout, resource limits)
│ │ ├── Read output.stl → Buffer
│ │ ├── Cache in Redis (TTL 7 days)
│ │ └── Return STL Buffer
│ │
│ └── false → Existing JSCAD pipeline (unchanged)
│
▼
Binary STL → StreamableFile response (unchanged)
Container Architecture¶
Docker Compose Network
│
├── gridflock-service (Node 20 Alpine, port 3004)
│ ├── PreviewController
│ ├── OpenScadGeneratorService
│ │ └── Calls openscad-sidecar via HTTP or subprocess
│ └── JSCAD pipeline (fallback)
│
├── openscad-sidecar (new container)
│ ├── OpenSCAD CLI binary
│ ├── /scad/gridflock.scad
│ ├── /scad/gridfinity-rebuilt-openscad/
│ ├── /scad/puzzle.svg
│ └── /scad/gridflock_paths.scad (pre-extracted polygon data)
│
└── redis (existing, port 6379)
└── STL cache (key: scad:stl:<sha256>, value: binary STL, TTL: 7d)
Sub-option Choice: Sidecar vs Embedded¶
Two approaches for running OpenSCAD from the NestJS service:
Approach 1: Sidecar container (recommended)
- Separate Docker container with OpenSCAD binary + .scad files
- NestJS service calls it via HTTP API (thin wrapper) or shared volume + subprocess
- Pros: Clean separation, independent scaling, independent image updates
- Cons: Network hop or volume coordination
Approach 2: Embedded in gridflock-service image
- Install OpenSCAD binary directly into the gridflock-service Dockerfile
- Call via child_process.execFile
- Pros: Simpler deployment, no network hop
- Cons: Larger image, tighter coupling, OpenSCAD updates require service rebuild
Recommendation: Start with Approach 2 (Embedded) for simplicity during PoC and initial integration. Extract to a sidecar in Phase 3 if the image size or scaling requirements warrant it. The OpenScadGeneratorService interface should abstract the execution method so switching is seamless.
📁 Files to Create/Modify¶
New Files¶
libs/gridflock-core/src/lib/openscad/
├── openscad-executor.ts # Subprocess execution wrapper
├── openscad-param-mapper.ts # DTO → OpenSCAD -D flag mapping
├── openscad-cache.ts # Redis-backed STL cache
├── openscad-types.ts # OpenSCAD-specific types/interfaces
└── __tests__/
├── openscad-executor.spec.ts
├── openscad-param-mapper.spec.ts
└── openscad-cache.spec.ts
apps/gridflock-service/src/gridflock/
├── openscad-generator.service.ts # NestJS service wrapping OpenSCAD execution
└── __tests__/
└── openscad-generator.service.spec.ts
docker/openscad/
├── Dockerfile # OpenSCAD container with .scad files
├── scad/ # GridFlock source files (vendored)
│ ├── gridflock.scad
│ ├── puzzle.svg
│ ├── gridflock_paths.scad # Pre-extracted polygon data
│ └── gridfinity-rebuilt-openscad/ # MIT-licensed submodule
└── scripts/
├── extract-paths.sh # Runs extract_paths.py during build
└── healthcheck.sh # Container health check
Modified Files¶
apps/gridflock-service/src/gridflock/gridflock-preview.service.ts # Feature flag routing
apps/gridflock-service/src/gridflock/gridflock.module.ts # Register new service
apps/gridflock-service/src/gridflock/dto/preview-grid.dto.ts # Extended params (Phase 4)
apps/gridflock-service/Dockerfile # Install OpenSCAD binary
deployment/staging/docker-compose.yml # Add openscad-sidecar (Phase 3)
deployment/staging/.env.example # Add OPENSCAD_ENABLED flag
🔧 Implementation Phases¶
Phase 1: OpenSCAD Docker Setup & Validation (4–6 hours)¶
Priority: Critical | Impact: High | Dependencies: None
Validate that gridflock.scad runs correctly in a Docker container and produces correct STL output.
1. Vendor GridFlock source files¶
Clone the GridFlock repository and extract the required files into the project:
git clone https://github.com/yawkat/GridFlock.git /tmp/gridflock
mkdir -p docker/openscad/scad
cp /tmp/gridflock/gridflock.scad docker/openscad/scad/
cp /tmp/gridflock/puzzle.svg docker/openscad/scad/
cp -r /tmp/gridflock/gridfinity-rebuilt-openscad docker/openscad/scad/
cp /tmp/gridflock/extract_paths.py docker/openscad/scad/
Pin to a specific GridFlock commit for reproducibility. Document the commit hash in a docker/openscad/scad/VERSION file:
# GridFlock source version
GRIDFLOCK_COMMIT=<pin-to-specific-commit>
GRIDFLOCK_REPO=https://github.com/yawkat/GridFlock
2. Run the path extraction step¶
GridFlock's extract_paths.py converts puzzle.svg into OpenSCAD polygon data. This must run during the Docker build:
cd docker/openscad/scad
python3 extract_paths.py
# Produces gridflock_paths.scad (or equivalent output file)
Verify the output file exists and contains polygon definitions. This file must be present in the OpenSCAD working directory at render time.
3. Create the OpenSCAD Dockerfile¶
Create docker/openscad/Dockerfile:
FROM alpine:3.19
RUN apk add --no-cache \
openscad \
python3 \
py3-pip \
&& pip3 install --break-system-packages lxml
WORKDIR /scad
COPY scad/ /scad/
RUN python3 extract_paths.py
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD openscad --version || exit 1
ENTRYPOINT ["openscad"]
If Alpine's OpenSCAD package does not include the Manifold backend (important for performance), consider using:
- The official openscad/openscad Docker image (if available)
- Building from the openscad-wasm AppImage
- Installing from OpenSCAD's nightly PPA on an Ubuntu base image
The Manifold backend provides 10-100x speedup for CSG union operations. Verify the backend with:
openscad --info 2>&1 | grep -i manifold
4. Build and test the container¶
docker build -t forma3d-openscad docker/openscad/
# Test with a simple render
docker run --rm -v $(pwd)/tmp:/output forma3d-openscad \
/scad/gridflock.scad \
-o /output/test.stl \
-D 'plate_size=[200,200]' \
-D 'bed_size=[256,256]'
5. Validate output correctness¶
- Verify the generated STL is valid binary STL (check header + triangle count)
- Open the STL in a 3D viewer and visually compare to the GridFlock web editor output for the same parameters
- Compare file sizes — they should be in the same ballpark
6. Measure rendering times¶
Benchmark rendering times for various configurations:
| Configuration | Parameters | Expected Time |
|---|---|---|
| Simple 2×2 plate | plate_size=[84,84] |
2-8s |
| Medium 4×4 plate | plate_size=[168,168] |
5-20s |
| Complex 6×6 with magnets | plate_size=[252,252], magnets=true |
15-50s |
| Full bed plate | plate_size=[256,256] |
10-30s |
Record these baselines for comparison after optimization.
7. Document OpenSCAD parameter interface¶
Map between our concepts and OpenSCAD's -D flag names by examining gridflock.scad source. Expected parameters include:
| Our Parameter | OpenSCAD Variable | Type | Example |
|---|---|---|---|
| Plate width (mm) | plate_size[0] |
number | 200 |
| Plate height (mm) | plate_size[1] |
number | 200 |
| Bed size (mm) | bed_size |
vector | [256,256] |
| Magnets enabled | magnets |
boolean | true |
| Magnet style | magnet_style |
string/enum | "press_fit" |
| Connector type | connector_intersection_puzzle |
boolean | true |
| Edge puzzle | connector_edge_puzzle |
boolean | false |
| Click latch | connector_click_latch |
boolean | false |
| Numbering | numbering |
boolean | true |
| Resolution | $fn |
number | 32 (preview) / 64 (production) |
Read the full variable list from gridflock.scad's editor.toml and the .scad file header.
Deliverable: Working Docker container that generates verified GridFlock STLs from parameters, with documented parameter mapping and performance baselines.
Phase 2: OpenSCAD Executor & Param Mapper (4–6 hours)¶
Priority: Critical | Impact: High | Dependencies: Phase 1
Create the TypeScript abstraction layer that executes OpenSCAD as a subprocess and maps our DTO to OpenSCAD parameters.
1. Create OpenSCAD types¶
Create libs/gridflock-core/src/lib/openscad/openscad-types.ts:
export interface OpenScadRenderParams {
plateSizeMm: [number, number];
bedSizeMm: [number, number];
magnets: boolean;
magnetStyle?: 'press_fit' | 'glued';
connectorIntersectionPuzzle: boolean;
connectorEdgePuzzle: boolean;
connectorClickLatch: boolean;
numbering: boolean;
resolution: number;
}
export interface OpenScadRenderResult {
stlBuffer: Buffer;
renderTimeMs: number;
cacheHit: boolean;
triangleCount: number;
}
export interface OpenScadExecutorOptions {
binaryPath: string;
scadFilePath: string;
workingDirectory: string;
timeoutMs: number;
maxMemoryMb: number;
}
2. Create the parameter mapper¶
Create libs/gridflock-core/src/lib/openscad/openscad-param-mapper.ts:
The mapper converts OpenScadRenderParams to an array of -D flag strings for the OpenSCAD CLI. It must:
- Convert TypeScript booleans to OpenSCAD booleans (
true/false) - Convert arrays to OpenSCAD vectors (
[200,200]) - Handle string enums correctly
- Produce a deterministic, canonical representation for cache key computation
- Validate parameter ranges
export function mapParamsToScadFlags(params: OpenScadRenderParams): string[] {
const flags: string[] = [];
flags.push('-D', `plate_size=[${params.plateSizeMm[0]},${params.plateSizeMm[1]}]`);
flags.push('-D', `bed_size=[${params.bedSizeMm[0]},${params.bedSizeMm[1]}]`);
flags.push('-D', `magnets=${params.magnets}`);
// ... map all parameters
return flags;
}
export function computeCacheKey(params: OpenScadRenderParams): string {
const canonical = JSON.stringify(params, Object.keys(params).sort());
return createHash('sha256').update(canonical).digest('hex');
}
Write tests for every parameter mapping, especially edge cases (zero values, max values, special characters in string params).
3. Create the OpenSCAD executor¶
Create libs/gridflock-core/src/lib/openscad/openscad-executor.ts:
The executor wraps child_process.execFile to run the OpenSCAD CLI with:
- Configurable timeout (default: 120 seconds)
- Stdout/stderr capture for error reporting
- Temp directory management (create before render, clean up after)
- Exit code validation (0 = success, non-zero = error)
- Structured error types (timeout, out-of-memory, invalid params, etc.)
export async function executeOpenScad(
params: OpenScadRenderParams,
options: OpenScadExecutorOptions,
): Promise<Buffer> {
const tmpDir = await mkdtemp(join(tmpdir(), 'openscad-'));
try {
const outputPath = join(tmpDir, 'output.stl');
const args = [
options.scadFilePath,
'-o', outputPath,
...mapParamsToScadFlags(params),
];
await execFileAsync(options.binaryPath, args, {
cwd: options.workingDirectory,
timeout: options.timeoutMs,
maxBuffer: options.maxMemoryMb * 1024 * 1024,
});
return await readFile(outputPath);
} finally {
await rm(tmpDir, { recursive: true, force: true });
}
}
Handle error cases:
- Timeout: Throw OpenScadTimeoutError with render params for debugging
- Non-zero exit: Parse stderr for OpenSCAD error messages, throw OpenScadRenderError
- Missing output file: Throw OpenScadOutputError (render succeeded but produced no output)
- Invalid STL: Validate output with isValidBinaryStl() from existing serializer
4. Export from gridflock-core¶
Update libs/gridflock-core/src/index.ts to export the new modules.
5. Write comprehensive tests¶
openscad-param-mapper.spec.ts: Test every parameter mapping, cache key determinism, parameter validationopenscad-executor.spec.ts: Test with mockedexecFile— success, timeout, error exit codes, missing output, temp directory cleanup
Deliverable: Tested TypeScript modules for OpenSCAD parameter mapping and subprocess execution.
Phase 3: NestJS Service Integration (4–6 hours)¶
Priority: Critical | Impact: Very High | Dependencies: Phase 2
Create the NestJS service that wires OpenSCAD execution into the existing preview flow with feature flag routing and Redis caching.
1. Create OpenScadGeneratorService¶
Create apps/gridflock-service/src/gridflock/openscad-generator.service.ts:
@Injectable()
export class OpenScadGeneratorService {
constructor(
@Inject('REDIS_CLIENT') private readonly redis: Redis,
private readonly configService: ConfigService,
) {}
async generatePlateStl(params: OpenScadRenderParams): Promise<OpenScadRenderResult> {
const cacheKey = `scad:stl:${computeCacheKey(params)}`;
const startTime = Date.now();
const cached = await this.redis.getBuffer(cacheKey);
if (cached) {
return {
stlBuffer: cached,
renderTimeMs: Date.now() - startTime,
cacheHit: true,
triangleCount: getStlTriangleCount(cached),
};
}
const stlBuffer = await executeOpenScad(params, this.getExecutorOptions());
await this.redis.setex(cacheKey, 7 * 24 * 60 * 60, stlBuffer);
return {
stlBuffer,
renderTimeMs: Date.now() - startTime,
cacheHit: false,
triangleCount: getStlTriangleCount(stlBuffer),
};
}
private getExecutorOptions(): OpenScadExecutorOptions {
return {
binaryPath: this.configService.get('OPENSCAD_BINARY_PATH', 'openscad'),
scadFilePath: this.configService.get('OPENSCAD_SCAD_PATH', '/scad/gridflock.scad'),
workingDirectory: this.configService.get('OPENSCAD_WORKING_DIR', '/scad'),
timeoutMs: this.configService.getOrThrow<number>('OPENSCAD_TIMEOUT_MS'),
maxMemoryMb: this.configService.get('OPENSCAD_MAX_MEMORY_MB', 512),
};
}
}
2. Add feature flag routing to GridflockPreviewService¶
Modify apps/gridflock-service/src/gridflock/gridflock-preview.service.ts:
- Inject
OpenScadGeneratorService - Read
OPENSCAD_ENABLEDfromConfigService - Route to OpenSCAD or JSCAD based on the flag
- Map
PreviewGridDto(widthMm, heightMm) toOpenScadRenderParams
The mapping from PreviewGridDto to OpenScadRenderParams must account for:
- Converting widthMm/heightMm to plate_size (may involve calculatePlateSet logic)
- Setting sensible defaults for new parameters (magnets, connectors, etc.)
- Using a lower $fn (resolution) for preview vs production
For preview, consider rendering a single plate segment rather than the full plate set to reduce render time. The existing combineStlBuffers logic can still stitch multiple segments.
3. Register in GridflockModule¶
Update apps/gridflock-service/src/gridflock/gridflock.module.ts:
- Add OpenScadGeneratorService to providers
- Add Redis client provider (or reuse existing BullMQ Redis connection)
4. Add environment variables¶
Add to deployment/staging/.env.example:
# OpenSCAD Configuration
OPENSCAD_ENABLED=false
OPENSCAD_BINARY_PATH=/usr/bin/openscad
OPENSCAD_SCAD_PATH=/scad/gridflock.scad
OPENSCAD_WORKING_DIR=/scad
OPENSCAD_TIMEOUT_MS=120000
OPENSCAD_MAX_MEMORY_MB=512
OPENSCAD_CACHE_TTL_SECONDS=604800
5. Install OpenSCAD in the gridflock-service Dockerfile¶
Modify apps/gridflock-service/Dockerfile to install OpenSCAD and bundle the .scad files:
# Add to the production stage
RUN apk add --no-cache openscad python3
COPY docker/openscad/scad/ /scad/
RUN cd /scad && python3 extract_paths.py
If Alpine's OpenSCAD package is too old or lacks Manifold, use a multi-stage build to install from a different source.
6. Write integration tests¶
Create apps/gridflock-service/src/gridflock/__tests__/openscad-generator.service.spec.ts:
- Test cache hit path (mock Redis to return a buffer)
- Test cache miss path (mock
executeOpenScad, verify Redis write) - Test feature flag routing in
GridflockPreviewService - Test error handling (timeout, render failure)
Deliverable: Feature-flagged preview endpoint that can generate STLs via OpenSCAD or JSCAD, with Redis caching.
Phase 4: Production Hardening (4–6 hours)¶
Priority: High | Impact: High | Dependencies: Phase 3
Make the OpenSCAD pipeline production-ready with resource management, monitoring, and resilience.
1. Add subprocess resource limits¶
Limit CPU and memory for OpenSCAD subprocesses:
const child = execFile(binaryPath, args, {
timeout: timeoutMs,
maxBuffer: maxMemoryMb * 1024 * 1024,
env: {
...process.env,
OPENSCAD_HARDWARNINGS: '1',
},
});
On Linux, consider using nice or cpulimit to prevent OpenSCAD from starving the NestJS event loop:
const args = ['nice', '-n', '10', 'openscad', ...scadArgs];
2. Add request queuing / concurrency limiting¶
Prevent multiple concurrent OpenSCAD renders from overloading the server. Use a semaphore or BullMQ queue:
private readonly renderSemaphore = new Semaphore(
parseInt(process.env['OPENSCAD_MAX_CONCURRENT'] ?? '2', 10)
);
async generatePlateStl(params: OpenScadRenderParams): Promise<OpenScadRenderResult> {
return this.renderSemaphore.acquire(async () => {
// ... render logic
});
}
Environment variable: OPENSCAD_MAX_CONCURRENT=2 (tune based on server CPU cores).
3. Add structured logging and metrics¶
Log every render with structured metadata:
this.logger.log('OpenSCAD render completed', {
cacheHit: result.cacheHit,
renderTimeMs: result.renderTimeMs,
triangleCount: result.triangleCount,
plateSizeMm: params.plateSizeMm,
cacheKey: computeCacheKey(params),
});
Track metrics for monitoring/alerting:
- openscad.render.duration_ms (histogram)
- openscad.render.cache_hit_rate (counter)
- openscad.render.error_count (counter by error type)
- openscad.render.queue_depth (gauge)
4. Add cache pre-warming¶
Create a startup hook or CLI command that pre-generates STLs for common configurations:
@Injectable()
export class OpenScadCacheWarmer implements OnApplicationBootstrap {
async onApplicationBootstrap(): Promise<void> {
if (!this.configService.get('OPENSCAD_PREWARM_CACHE', false)) return;
const commonConfigs = this.getCommonConfigurations();
for (const config of commonConfigs) {
const cacheKey = computeCacheKey(config);
const exists = await this.redis.exists(`scad:stl:${cacheKey}`);
if (!exists) {
await this.openScadGenerator.generatePlateStl(config);
}
}
}
}
Common configurations to pre-warm based on printer presets from editor.toml:
- Bambu Lab X1/P1 (256×256 bed)
- Prusa MK4 (250×220 bed)
- Ender 3 (235×235 bed)
- Default params (no magnets, intersection puzzle connectors)
5. Add graceful degradation¶
If OpenSCAD rendering fails (timeout, crash, out-of-memory), fall back to the JSCAD pipeline automatically:
try {
return await this.openScadGenerator.generatePlateStl(params);
} catch (error) {
this.logger.error('OpenSCAD render failed, falling back to JSCAD', {
error: error.message,
params,
});
return this.jscadFallback(params);
}
This ensures the preview endpoint never returns 500 errors due to OpenSCAD issues.
6. Update Docker Compose for resource constraints¶
Add resource limits for the gridflock-service container in deployment/staging/docker-compose.yml:
gridflock-service:
deploy:
resources:
limits:
cpus: '1.00' # Increased to accommodate OpenSCAD subprocess
memory: 1024M # Increased for OpenSCAD memory usage
reservations:
cpus: '0.50'
memory: 512M
7. Add health check for OpenSCAD availability¶
Extend the service health check to verify OpenSCAD is functional:
@HealthCheck()
async checkOpenScad() {
try {
await execFileAsync('openscad', ['--version'], { timeout: 5000 });
return { status: 'up' };
} catch {
return { status: 'down', message: 'OpenSCAD binary not available' };
}
}
Deliverable: Production-grade OpenSCAD pipeline with concurrency control, monitoring, cache warming, and graceful degradation.
Phase 5: Async Generation Pipeline Integration (2–4 hours)¶
Priority: Medium | Impact: Medium | Dependencies: Phase 3
Update the BullMQ-based async generation pipeline (gridflock-pipeline.service.ts, gridflock.processor.ts) to also use OpenSCAD for full plate set generation (not just preview).
1. Update GridflockPipelineService¶
Add OpenSCAD execution path for full plate set generation:
- Use the same feature flag (
OPENSCAD_ENABLED) - For production generation, use higher
$fnresolution - Generate individual plate STLs and save to
/data/gridflock
2. Extend PreviewGridDto (optional — for Phase 4 features)¶
If exposing additional GridFlock parameters in the preview API, extend the DTO:
export class PreviewGridDto {
widthMm!: number;
heightMm!: number;
// Optional — new OpenSCAD-enabled parameters
magnets?: boolean;
connectorType?: 'intersection_puzzle' | 'edge_puzzle' | 'click_latch' | 'none';
magnetStyle?: 'press_fit' | 'glued';
}
Maintain backward compatibility — new parameters must be optional with sensible defaults matching current behavior.
3. Write integration tests¶
Test the full pipeline with OpenSCAD: - Job creation → processing → STL output - Verify output STL is saved to the correct location - Verify job status updates correctly
Deliverable: Full generation pipeline supporting OpenSCAD with higher quality output.
Phase 6: JSCAD Deprecation & Cleanup (2–4 hours)¶
Priority: Low | Impact: Medium | Dependencies: Phase 4 validated in staging
After the OpenSCAD pipeline is validated in staging and the feature flag has been enabled for a sufficient period:
1. Remove the feature flag¶
Set OPENSCAD_ENABLED=true permanently and remove the flag check.
2. Deprecate JSCAD geometry modules¶
Mark the following as deprecated (do not delete yet — keep for emergency rollback):
libs/gridflock-core/src/lib/geometry/base-plate.ts
libs/gridflock-core/src/lib/geometry/grid-cells.ts
libs/gridflock-core/src/lib/geometry/magnet-holes.ts
libs/gridflock-core/src/lib/geometry/intersection-puzzle.ts
libs/gridflock-core/src/lib/geometry/edge-puzzle.ts
libs/gridflock-core/src/lib/geometry/numbering.ts
libs/gridflock-core/src/lib/serializer.ts (JSCAD serializer)
3. Remove JSCAD dependencies¶
After a full release cycle with no rollbacks, remove:
- @jscad/modeling from package.json
- @jscad/stl-serializer from package.json
- The deprecated geometry modules
- Associated tests
4. Update documentation¶
Update docs/03-architecture/ to reflect the new OpenSCAD pipeline architecture.
Deliverable: Clean codebase with JSCAD geometry removed and OpenSCAD as the sole generator.
📊 Expected Performance Profile¶
Render Times (Server-Side OpenSCAD vs Current JSCAD)¶
| Configuration | Current (JSCAD) | OpenSCAD (cold) | OpenSCAD (cached) |
|---|---|---|---|
| Simple 2×2 plate | 1-3s | 3-8s | <50ms |
| Medium 4×4 plate | 2-5s | 8-20s | <50ms |
| Complex 6×6 with magnets | 3-8s | 20-50s | <50ms |
| Full bed plate | 2-5s | 10-30s | <50ms |
Key insight: OpenSCAD cold renders are slower, but caching makes repeated requests near-instant. Since plate configurations are deterministic (same params = same STL), cache hit rates should be very high in production.
Cache Effectiveness Estimates¶
| Metric | Estimate |
|---|---|
| Unique configurations per day | ~50-200 |
| Repeat requests per configuration | ~5-20 |
| Expected cache hit rate | 80-95% |
| Average response time (with caching) | <100ms |
| Cache storage per STL | 200KB-2MB |
| Total cache size (7-day TTL) | 50-500MB |
✅ Validation Checklist¶
Phase 1: Docker Setup¶
- GridFlock source files vendored with pinned commit hash
-
extract_paths.pyruns successfully in Docker build - OpenSCAD container builds without errors
- STL output generated for sample parameters
- Output visually matches GridFlock web editor (side-by-side comparison)
- Render times benchmarked for 3+ configurations
- Parameter mapping documented (our API → OpenSCAD
-Dflags) - Manifold backend availability checked (
openscad --info)
Phase 2: Executor & Mapper¶
-
openscad-param-mapper.tsmaps all parameters correctly -
computeCacheKey()is deterministic (same params → same key) -
openscad-executor.tshandles timeout gracefully -
openscad-executor.tscleans up temp directories on success and failure - Error types cover timeout, render failure, missing output, invalid STL
- All new modules exported from
libs/gridflock-core/src/index.ts - Unit tests pass for param mapper and executor
Phase 3: Service Integration¶
-
OpenScadGeneratorServiceregistered inGridflockModule - Feature flag (
OPENSCAD_ENABLED) routes correctly to OpenSCAD or JSCAD - Redis cache hit returns stored STL buffer
- Redis cache miss triggers OpenSCAD render and stores result
- Cache TTL set to 7 days
-
PreviewGridDto→OpenScadRenderParamsmapping correct - Preview endpoint returns valid binary STL via OpenSCAD
- Client-side Three.js renders the OpenSCAD-generated STL correctly
- Environment variables added to
.env.example - Integration tests pass
Phase 4: Production Hardening¶
- Concurrency limiter prevents server overload
- Structured logging captures render metrics
- Cache pre-warming generates STLs for common configurations
- Graceful degradation falls back to JSCAD on OpenSCAD failure
- Health check verifies OpenSCAD binary availability
- Docker Compose resource limits updated
- No regression in existing preview functionality when
OPENSCAD_ENABLED=false
Verification Commands¶
# OpenSCAD binary available in container
docker exec forma3d-gridflock-service openscad --version
# Manual STL generation test
docker exec forma3d-gridflock-service openscad \
/scad/gridflock.scad -o /tmp/test.stl \
-D 'plate_size=[200,200]' -D 'bed_size=[256,256]'
# Preview endpoint test (with OpenSCAD enabled)
curl -X POST http://localhost:3004/api/v1/gridflock/preview \
-H 'Content-Type: application/json' \
-d '{"widthMm": 200, "heightMm": 200}' \
-o preview.stl
file preview.stl # Should show "data" (binary STL)
# Cache key verification (Redis)
docker exec forma3d-redis redis-cli KEYS 'scad:stl:*' | head -5
# Build passes
pnpm nx build gridflock-service
pnpm nx build gridflock-core
# Tests pass
pnpm nx test gridflock-service
pnpm nx test gridflock-core
# Lint passes
pnpm nx lint gridflock-service
pnpm nx lint gridflock-core
🚫 Constraints and Rules¶
MUST DO¶
- Pin GridFlock source to a specific commit hash (reproducible builds)
- Run
extract_paths.pyduring Docker build, not at runtime - Use
child_process.execFile(notexec) to avoid shell injection - Clean up temp directories after every render (success or failure)
- Validate STL output with
isValidBinaryStl()before caching - Implement the feature flag so OpenSCAD can be disabled without redeployment
- Maintain backward compatibility of the
POST /api/v1/gridflock/previewAPI contract - Keep JSCAD pipeline intact as fallback until OpenSCAD is validated in production
- Use deterministic cache keys (sorted, canonical JSON serialization)
- Set concurrency limits on OpenSCAD subprocess execution
- Add structured logging for every render (cache hit/miss, duration, params)
MUST NOT¶
- Distribute the OpenSCAD binary or WASM to the client (GPL-2.0 licensing)
- Break the existing preview endpoint behavior when
OPENSCAD_ENABLED=false - Remove JSCAD geometry modules before OpenSCAD is validated in staging
- Use
child_process.execwith string interpolation (shell injection risk) - Store unbounded data in Redis without TTL (memory exhaustion)
- Allow OpenSCAD subprocess to run without a timeout
- Hardcode
.scadfile paths or OpenSCAD binary location - Commit
.scadfiles without documenting their upstream source and version - Skip the
extract_paths.pystep (OpenSCAD will fail without polygon data) - Use
any,ts-ignore,eslint-disable, orconsole.login production code
SHOULD DO (Nice to Have)¶
- Extract OpenSCAD into a sidecar container in Phase 4 if image size is a concern
- Add a
/api/v1/gridflock/preview/statusendpoint that reports OpenSCAD availability and cache stats - Pre-generate STLs for all printer presets from
editor.tomlon deploy - Add Server-Sent Events (SSE) for render progress feedback on long renders
- Explore rendering with reduced
$fnfor faster preview, full$fnfor production downloads - Consider adding a
/api/v1/gridflock/preview/compareendpoint during rollout that returns both JSCAD and OpenSCAD STLs for visual comparison
🔄 Rollback Plan¶
This implementation is designed for safe, incremental rollout:
- Feature flag off: Set
OPENSCAD_ENABLED=false→ entire OpenSCAD path is bypassed, existing JSCAD pipeline handles all requests. Zero impact, instant rollback. - Graceful degradation: Even with
OPENSCAD_ENABLED=true, if OpenSCAD fails (crash, timeout, missing binary), the service automatically falls back to JSCAD. - No API changes: The preview endpoint contract is identical — same URL, same request body, same binary STL response. Clients are unaware of the backend change.
- Cache isolation: OpenSCAD cache keys are prefixed with
scad:stl:and don't conflict with any existing Redis keys. - Phased JSCAD removal: Geometry modules are deprecated but not deleted until the OpenSCAD pipeline has been stable in production for a full release cycle.
To roll back at any phase:
- Phase 1-2: Delete docker/openscad/ and new libs/gridflock-core/src/lib/openscad/ files. No production impact.
- Phase 3: Set OPENSCAD_ENABLED=false in .env. Redeploy. Instant rollback with zero downtime.
- Phase 4: Same as Phase 3 — feature flag disables everything.
- Phase 6: If JSCAD was already removed, restore from Git history.
📚 Key References¶
OpenSCAD¶
- OpenSCAD CLI reference — Command-line usage
- OpenSCAD Docker images — Official container images
- Manifold CSG backend — 10-100x faster CSG kernel
- OpenSCAD WASM NPM — Server-side WASM option (backup plan)
GridFlock¶
- yawkat/GridFlock — Source repository (MIT license)
- GridFlock editor — Live web editor for visual comparison
- editor.toml — Parameter definitions and presets
- gridflock.scad — Main OpenSCAD source
Research¶
- OpenSCAD WASM Preview Research — Full analysis of Options A, B, C
Existing Codebase¶
- Geometry library:
libs/gridflock-core/src/lib/geometry/— JSCAD modules to be replaced - Generator:
libs/gridflock-core/src/lib/generator.ts— Current orchestration - Preview service:
apps/gridflock-service/src/gridflock/gridflock-preview.service.ts - Preview controller:
apps/gridflock-service/src/gridflock/preview.controller.ts - Docker Compose:
deployment/staging/docker-compose.yml - Service Dockerfile:
apps/gridflock-service/Dockerfile
END OF PROMPT
This prompt implements Option B (Server-Side OpenSCAD CLI) from the OpenSCAD WASM research document. The AI should set up an OpenSCAD Docker environment with vendored GridFlock source files, create a TypeScript executor and parameter mapper in libs/gridflock-core, build an OpenScadGeneratorService in the gridflock-service with Redis-backed STL caching, and wire it into the existing preview endpoint behind a feature flag. The JSCAD geometry pipeline remains as a fallback until OpenSCAD is validated in production. Zero client-side changes are required — the API contract and Three.js rendering remain identical.