All files / web/public/openscad scad-worker.js

0% Statements 0/133
0% Branches 0/1
0% Functions 0/1
0% Lines 0/133

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134                                                                                                                                                                                                                                                                           
// Frame enclosure generator — OpenSCAD render worker (Gitea #285, PH2 #288).
//
// A real same-origin ES-module worker. (A blob-URL module worker cannot import
// the absolute engine module in Chrome — it fails opaquely; a hosted file
// works.) The engine is loaded as a runtime static asset, never bundled.
//
// Protocol: the main thread posts { id, files, defines, fn, entry } where
// `files` maps an absolute wasm-FS path → file content (the committed `.scad`
// library as strings; the diff path also injects binary STLs as Uint8Array) and
// `defines` is the `-D name=value` list from web/src/lib/frames/geometry.ts.
// `entry` is the FS path to render (default `/frame.scad`; the option-card ghost
// preview passes `/diff.scad` to subtract two STLs). The worker renders a binary
// STL and posts back its ArrayBuffer (transferred).
//
// A fresh OpenSCAD instance is needed per render: callMain() exits the
// emscripten runtime, so instances are not reusable. With the Manifold backend
// the solve is ~13 ms, so instantiation (~28 ms) is now the dominant cost — we
// keep the *next* instance warming during idle and hand it over the instant a
// render arrives, so the request itself pays only the solve (warm-instance
// pool, Gitea #285 perf pass). The browser caches the compiled module, so this
// is re-instantiation, never re-download.

import OpenSCAD from '/openscad/openscad.js';

/** Ensure every parent directory of an absolute FS path exists. */
function mkdirp(FS, filePath) {
  const parts = filePath.split('/').filter(Boolean);
  parts.pop(); // drop the filename
  let cur = '';
  for (const part of parts) {
    cur += '/' + part;
    try {
      FS.mkdir(cur);
    } catch {
      // already exists
    }
  }
}

// Engine stdout/stderr is captured into the active render's log rather than
// sprayed to console.warn — kept quiet on success, surfaced on failure. The
// print handlers are bound once at instantiation, so they push to a mutable
// sink that each render swaps to its own array.
let logSink = [];
function createInstance() {
  return OpenSCAD({
    noInitialRun: true,
    print: (line) => logSink.push(line),
    printErr: (line) => logSink.push(line),
  });
}

// Start warming the first instance immediately, before any render request.
let warm = createInstance();

// Renders run strictly one at a time. `render()` below claims the warm instance
// with an `await`, and only replaces `warm` once that await resolves — so two
// handlers running concurrently would both park on the *same* pending instance
// and both get it. The second one's callMain() then throws the engine's raw
// "program has already aborted!" (callMain exits the runtime, which sets
// emscripten's ABORT, and every wasm export is abort-wrapped). That is exactly
// what a fan-out of postMessage()s in one tick does — e.g. the 3MF export's
// frame + marker_black + marker_white passes, or a pump render overlapping an
// export one-shot. Chaining on a queue keeps the claim exclusive without
// instantiating N engines at once.
let queue = Promise.resolve();
self.onmessage = (e) => {
  queue = queue.then(() => render(e.data)).catch((err) => {
    // render() reports its own render failures, so reaching here means the
    // failure path itself broke (e.g. postMessage on an already-detached
    // buffer). Still answer the request: a caller whose promise never settles
    // hangs the export forever, which is worse than a surfaced error. The catch
    // also keeps the chain alive — a rejected `queue` would silently skip every
    // later render for the life of the worker.
    try {
      self.postMessage({
        id: e.data && e.data.id,
        ok: false,
        error: `worker render crashed: ${String((err && err.message) || err)}`,
      });
    } catch (postErr) {
      // the channel itself is gone; console is the only sink left
      console.error('scad-worker: cannot report render failure', err, postErr);
    }
  });
};

async function render(msg) {
  const { id, files, defines, fn, entry } = msg;
  const t0 = performance.now();
  const log = [];
  logSink = log;
  try {
    const inst = await warm;
    // Begin warming the next instance now; on a single thread its async
    // instantiation runs only once this handler yields (after the solve +
    // postMessage below), filling otherwise-idle time so the *next* render is
    // already warm.
    warm = createInstance();

    for (const [path, content] of Object.entries(files)) {
      mkdirp(inst.FS, path);
      inst.FS.writeFile(path, content);
    }
    const args = [
      entry || '/frame.scad',
      '-o',
      '/out.stl',
      '--export-format=binstl',
      // Manifold CSG backend (OpenSCAD 2025.03.25 build): ~20–38× faster than
      // the old CGAL default on this boolean-heavy solid (~270 ms → ~13 ms at
      // $fn=24), byte-identical geometry. The engine is single-threaded, so no
      // SharedArrayBuffer / COOP-COEP headers are required.
      '--backend=Manifold',
      // textmetrics()/fontmetrics() (experimental builtins) power the scad's
      // overflow-proof auto-fit text sizing — real glyph ink boxes, not guesses.
      '--enable=textmetrics',
      ...(defines || []),
    ];
    if (typeof fn === 'number') args.push(`-Dfn=${Math.max(8, Math.round(fn))}`);
    const rc = inst.callMain(args);
    if (rc !== 0) {
      throw new Error(`OpenSCAD exited with code ${rc}${log.length ? `: ${log.join(' ').slice(-200)}` : ''}`);
    }
    const out = inst.FS.readFile('/out.stl');
    self.postMessage(
      { id, ok: true, stl: out.buffer, ms: Math.round(performance.now() - t0) },
      [out.buffer],
    );
  } catch (err) {
    self.postMessage({ id, ok: false, error: String((err && err.message) || err) });
  }
}