Skip to content

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.scad source 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:

  1. Geometry fidelity: The current JSCAD pipeline produces approximations with internal faces and CSG artifacts. OpenSCAD uses the same kernel as GridFlock, producing exact output.
  2. Feature coverage: JSCAD reimplements ~40% of GridFlock features. OpenSCAD supports 100% — click latch, ClickGroove, vertical screws, thumb screws, filler algorithms — all for free.
  3. Maintenance burden: Eliminates ~1,500 lines of custom geometry code in libs/gridflock-core/src/lib/geometry/. Upstream GridFlock updates require only pulling new .scad files.
  4. 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 OpenScadGeneratorService in apps/gridflock-service replaces JSCAD calls
  • Parameter mapping translates our API DTO to OpenSCAD -D flags
  • 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/preview with PreviewGridDto) — 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/openscad image or custom Alpine build)
  • GridFlock source: gridflock.scad (MIT license) + gridfinity-rebuilt-openscad submodule (MIT) + puzzle.svg
  • Server runtime: NestJS on Node 20 Alpine
  • Subprocess execution: Node.js child_process.execFile with 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 validation
  • openscad-executor.spec.ts: Test with mocked execFile — 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_ENABLED from ConfigService
  • Route to OpenSCAD or JSCAD based on the flag
  • Map PreviewGridDto (widthMm, heightMm) to OpenScadRenderParams

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 $fn resolution
  • 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.py runs 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 -D flags)
  • Manifold backend availability checked (openscad --info)

Phase 2: Executor & Mapper

  • openscad-param-mapper.ts maps all parameters correctly
  • computeCacheKey() is deterministic (same params → same key)
  • openscad-executor.ts handles timeout gracefully
  • openscad-executor.ts cleans 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

  • OpenScadGeneratorService registered in GridflockModule
  • 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
  • PreviewGridDtoOpenScadRenderParams mapping 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.py during Docker build, not at runtime
  • Use child_process.execFile (not exec) 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/preview API 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.exec with string interpolation (shell injection risk)
  • Store unbounded data in Redis without TTL (memory exhaustion)
  • Allow OpenSCAD subprocess to run without a timeout
  • Hardcode .scad file paths or OpenSCAD binary location
  • Commit .scad files without documenting their upstream source and version
  • Skip the extract_paths.py step (OpenSCAD will fail without polygon data)
  • Use any, ts-ignore, eslint-disable, or console.log in 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/status endpoint that reports OpenSCAD availability and cache stats
  • Pre-generate STLs for all printer presets from editor.toml on deploy
  • Add Server-Sent Events (SSE) for render progress feedback on long renders
  • Explore rendering with reduced $fn for faster preview, full $fn for production downloads
  • Consider adding a /api/v1/gridflock/preview/compare endpoint during rollout that returns both JSCAD and OpenSCAD STLs for visual comparison

🔄 Rollback Plan

This implementation is designed for safe, incremental rollout:

  1. Feature flag off: Set OPENSCAD_ENABLED=false → entire OpenSCAD path is bypassed, existing JSCAD pipeline handles all requests. Zero impact, instant rollback.
  2. Graceful degradation: Even with OPENSCAD_ENABLED=true, if OpenSCAD fails (crash, timeout, missing binary), the service automatically falls back to JSCAD.
  3. 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.
  4. Cache isolation: OpenSCAD cache keys are prefixed with scad:stl: and don't conflict with any existing Redis keys.
  5. 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

GridFlock

Research

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.