All files / web/src/components/create/abacus useAbacusScad.ts

88.85% Statements 287/323
82.5% Branches 33/40
90% Functions 9/10
88.85% Lines 287/323

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 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 3241x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 19x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 20x 20x 20x 20x 20x 20x 20x 20x 1x 1x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 32x 32x 13x 13x 13x 32x 32x 32x 32x 32x 32x 32x 32x 32x 32x 32x 32x 32x 32x 10x 10x 10x 28x 28x 9x 9x 9x 28x 28x 28x 28x 28x 28x 28x 28x 28x 28x 28x 28x 10x 10x 10x 10x 10x 10x 10x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x         3x 3x 3x 10x 8x 8x 8x 8x 7x 7x 7x 7x 8x 8x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 20x 10x 10x 10x 10x 10x 10x             10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 19x 19x 19x 19x 19x 19x 19x 19x 19x 19x 19x 19x 19x 19x 19x 19x 19x 19x 19x 19x 19x 19x 19x 10x 10x 10x 19x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 19x 10x 10x                                                     10x 10x 10x  
'use client'
 
// Abacus Studio — OpenSCAD-WASM render orchestration hook.
//
// Owns the two same-origin ES-module workers (main geometry + inset-text plug
// preview) and their latest-wins pumps, plus the one-time load of the .scad
// source and the two TTFs written into the worker's MEMFS /fonts (no font ships
// with the engine, so text() renders nothing without them). Export one-shots
// (negative ids, promise-based) share the main worker: the whole-abacus render
// plus the `only=` part passes that feed the 3MF's separate filament bodies —
// markers, feet, and one per inset-text color group. Results are handed
// back through imperative callbacks — the transferable STL ArrayBuffers never
// sit in React state, and all three.js mesh work stays in the viewer.
//
// Dedup keys off the DEFINES (not the whole params object): the myabacus color /
// filament knobs are JS-only (not sent to the scad), so a pure color tweak leaves
// the key unchanged and render() no-ops — the viewer recolors the existing mesh
// instead of paying a WASM solve.
//
// The worker + engine + fonts are the bench's verbatim, graduated into
// apps/web/public — "renders in the bench" == "renders here". No server-side
// OpenSCAD (that was the dead feature's fatal flaw); this is client WASM only.
 
import { useEffect, useRef } from 'react'
import {
  anyTokens,
  definesFrom,
  exportDefines,
  type Params,
  previewDedupKey,
  type RenderPass,
} from './abacus-model'
 
const WORKER_URL = '/openscad/scad-worker.js'
const SCAD_URL = '/scad/abacus.scad'
const FONT_URLS = ['/fonts/DejaVuSans-Bold.ttf', '/fonts/NotoEmoji-Regular.ttf'] as const
 
// Keyed on previewDedupKey, not the full define list: part-pass-only defines
// (joint_fit) ride every render but provably cannot change the assembled
// preview, so dragging them must not force a re-solve. The explode view knob
// (Studio "take it apart", never a Param) joins the key only when nonzero so
// the seated case keys exactly as it always did.
const mainKeyOf = (p: Params, explode: number): string =>
  `${previewDedupKey(p)}\u0002${p.fn}${explode > 0 ? `\u0002explode=${explode}` : ''}`
 
export type MainResult = { stl: ArrayBuffer; ms: number; id: number }
/** `busy` marks the in-flight phases so a viewer can show progress without
 *  parsing the dev text; a completed render carries neither `busy` nor `error`. */
export type StatusUpdate = { text: string; error?: boolean; busy?: 'loading' | 'rendering' }
 
export type UseAbacusScadArgs = {
  onMain: (res: MainResult) => void
  onPlug: (stl: ArrayBuffer | null) => void
  onStatus?: (s: StatusUpdate) => void
  /** Fired once the .scad + fonts are in MEMFS and the workers can render —
   *  i.e. the moment {@link UseAbacusScad.exportStl} stops rejecting. Consumers
   *  driven by `render()` don't need it (the pump replays the latest params by
   *  itself once loaded); a consumer that ONLY does export one-shots does, and
   *  without it its only way to discover readiness is to call and fail. */
  onReady?: () => void
}
 
export type UseAbacusScad = {
  /** Request a render of `params`; latest-wins, and a no-op if the scad inputs
   *  are unchanged (color-only edits don't re-render). `explode` is the Studio's
   *  "take it apart" VIEW state (mm of +X per modular seam) — deliberately not a
   *  Param so it can never leak into snapshots, content hashes, or exports. */
  render: (params: Params, explode?: number) => void
  /** Fire a one-shot high-quality ($fn=64 by default) render. `pass` selects a
   *  single `only=` slice instead of the whole abacus: either a 3MF filament
   *  body (marker plugs, feet, one text-inlay color group) or an inspection part
   *  (a lone bead, the channel cavity) — see RenderPass. Rejects when the worker
   *  isn't ready or the scad render fails — callers must surface that, not hang. */
  exportStl: (params: Params, pass?: RenderPass, fn?: number) => Promise<ArrayBuffer>
}
 
type Pump = {
  latest: Params | null
  latestExplode: number // main pump only; the plug pass always renders SEATED
  latestKey: string
  drawnKey: string
  rendering: boolean
  reqId: number // positive, pump-owned; export one-shots use negative ids
  pending: { id: number; key: string } | null
}
const newPump = (): Pump => ({
  latest: null,
  latestExplode: 0,
  latestKey: '',
  drawnKey: '',
  rendering: false,
  reqId: 0,
  pending: null,
})
 
export function useAbacusScad(args: UseAbacusScadArgs): UseAbacusScad {
  // keep the callbacks current without re-creating the workers
  const cbRef = useRef(args)
  cbRef.current = args
 
  const stateRef = useRef<{
    scad: string
    fonts: Record<string, Uint8Array>
    loaded: boolean
    worker: Worker | null
    plugWorker: Worker | null
    main: Pump
    plug: Pump
    nextExportId: number
    pumpMain?: () => void
    pumpPlug?: () => void
  }>({
    scad: '',
    fonts: {},
    loaded: false,
    worker: null,
    plugWorker: null,
    main: newPump(),
    plug: newPump(),
    // export one-shots count DOWN so they can never collide with the pumps'
    // positive reqIds — nor with each other when several fire in the same ms
    // (the old `-performance.now()` scheme could).
    nextExportId: -1,
  })
 
  useEffect(() => {
    const st = stateRef.current
    let disposed = false
    const renderFiles = () => ({ '/abacus.scad': st.scad, ...st.fonts })
 
    // ---- main geometry pump --------------------------------------------------
    function pumpMain() {
      const m = st.main
      if (m.rendering || !st.loaded || !m.latest || m.latestKey === m.drawnKey) return
      m.rendering = true
      const id = ++m.reqId
      m.pending = { id, key: m.latestKey }
      cbRef.current.onStatus?.({ text: `rendering #${id}…`, busy: 'rendering' })
      st.worker?.postMessage({
        id,
        entry: '/abacus.scad',
        files: renderFiles(),
        // -Dexplode only when nonzero: the scad default is 0, and the zero case
        // must post byte-identical defines to what it always posted.
        defines: [
          ...definesFrom(m.latest),
          ...(m.latestExplode > 0 ? [`-Dexplode=${m.latestExplode}`] : []),
        ],
        fn: m.latest.fn,
      })
    }
 
    // ---- inset text-plug pump (second worker; never blocks the main pump) ----
    function pumpPlug() {
      const pl = st.plug
      if (pl.rendering || !st.loaded || !pl.latest || pl.latestKey === pl.drawnKey) return
      pl.rendering = true
      const id = ++pl.reqId
      pl.pending = { id, key: pl.latestKey }
      st.plugWorker?.postMessage({
        id,
        entry: '/abacus.scad',
        files: renderFiles(),
        // No -Dplug_group here on purpose: the preview wants ONE mesh carrying
        // every token (it tints per-triangle in JS), which is exactly the scad's
        // plug_group = −1 default. Only the export splits the soup per color
        // group — see exportDefines.
        defines: [...definesFrom(pl.latest), '-Donly="text_plugs"'],
        fn: pl.latest.fn,
      })
    }
 
    const worker = new Worker(WORKER_URL, { type: 'module' })
    const plugWorker = new Worker(WORKER_URL, { type: 'module' })
    st.worker = worker
    st.plugWorker = plugWorker
 
    worker.onmessage = (e: MessageEvent) => {
      const { id, ok, stl, ms, error } = e.data
      // export one-shots (negative ids) are handled entirely by their own
      // once-listeners in exportStl — touching the pump state here would
      // falsely clear `rendering` mid-flight and re-post redundant renders.
      if (typeof id === 'number' && id < 0) return
      const m = st.main
      m.rendering = false
      if (m.pending && id === m.pending.id) {
        if (ok) {
          cbRef.current.onMain({ stl: stl as ArrayBuffer, ms, id })
          m.drawnKey = m.pending.key
          cbRef.current.onStatus?.({ text: `#${id}  ${ms}ms` })
        } else {
          cbRef.current.onStatus?.({ text: `#${id} FAILED\n${error}`, error: true })
          // count failures as drawn so a scad assert doesn't re-post forever
          m.drawnKey = m.pending.key
        }
      }
      pumpMain()
    }
    plugWorker.onmessage = (e: MessageEvent) => {
      const { id, ok, stl } = e.data
      const pl = st.plug
      pl.rendering = false
      if (pl.pending && id === pl.pending.id) {
        if (ok) cbRef.current.onPlug(stl as ArrayBuffer)
        // count failures as drawn too — the main pump already surfaces the error
        pl.drawnKey = pl.pending.key
      }
      pumpPlug()
    }
 
    // ---- one-time load of the scad source + fonts ----------------------------
    ;(async () => {
      cbRef.current.onStatus?.({ text: 'loading abacus.scad + fonts…', busy: 'loading' })
      try {
        const [scad, ...fontBufs] = await Promise.all([
          fetch(SCAD_URL).then((r) => r.text()),
          ...FONT_URLS.map((u) => fetch(u).then((r) => r.arrayBuffer())),
        ])
        if (disposed) return
        st.scad = scad
        // the public URLs (/fonts/…) double as the absolute MEMFS paths
        FONT_URLS.forEach((u, i) => {
          st.fonts[u] = new Uint8Array(fontBufs[i])
        })
        st.loaded = true
        cbRef.current.onReady?.()
        pumpMain()
        pumpPlug()
      } catch (err) {
        if (!disposed)
          cbRef.current.onStatus?.({
            text: `failed to load harness assets: ${String((err as Error)?.message ?? err)}`,
            error: true,
          })
      }
    })()
 
    st.pumpMain = pumpMain
    st.pumpPlug = pumpPlug
 
    return () => {
      disposed = true
      worker.terminate()
      plugWorker.terminate()
      st.worker = null
      st.plugWorker = null
      st.pumpMain = undefined
      st.pumpPlug = undefined
    }
    // workers are created once for the component's lifetime (deps intentionally
    // empty; biome's useExhaustiveDependencies is off for this repo)
  }, [])
 
  const render = (params: Params, explode = 0): void => {
    const st = stateRef.current
    const key = mainKeyOf(params, explode)
    st.main.latest = params
    st.main.latestExplode = explode
    st.main.latestKey = key
    st.pumpMain?.()
    // The plug pass follows anyTokens, which is modular-aware: on snap-together
    // columns only the side rails/end walls survive textSlots, and the scad's
    // text_plugs dispatch renders exactly those, at their ASSEMBLED positions —
    // matching the modular-aware tokenCenters the tint reads, so the overlay
    // cannot drift the way the old mono-positioned pass did. A modular design
    // whose words all sit on crossing slots skips the pass entirely, and the
    // else branch must clear + orphan, or a render still in flight repaints the
    // gated-out overlay after the flip (the CP8-aftercare race).
    //
    // Explode does NOT gate this pass (Gitea #44). text_plugs still renders at
    // SEATED assembled positions — it always did — but the viewer no longer
    // draws the overlay as one slab: it splits the plug soup by module and
    // rides each piece in that module's group, so the seated coordinates land
    // where the module actually is at any pose. A modular design is now ALWAYS
    // rendered exploded, so gating on explode here would mean no inset-text
    // preview on a modular design at all.
    if (params.text_mode === 'inset' && anyTokens(params)) {
      st.plug.latest = params
      st.plug.latestKey = key
      st.pumpPlug?.()
    } else {
      st.plug.latest = null
      st.plug.latestKey = ''
      st.plug.drawnKey = ''
      // Orphan any in-flight plug render too: onmessage delivers whatever
      // matches `pending`, so without this a mono plug pass completing AFTER
      // the modular flip repaints the stale mono-positioned overlay right over
      // the clear — the drifting frame text the first manual pass caught.
      st.plug.pending = null
      cbRef.current.onPlug(null)
    }
  }
 
  const exportStl = (params: Params, pass?: RenderPass, fn = 64): Promise<ArrayBuffer> => {
    const st = stateRef.current
    if (!st.worker || !st.loaded) {
      return Promise.reject(new Error('3D exporter not ready — the render engine is still loading'))
    }
    const worker = st.worker
    const id = st.nextExportId--
    return new Promise<ArrayBuffer>((resolve, reject) => {
      const onceHandler = (e: MessageEvent) => {
        if (e.data.id !== id) return
        worker.removeEventListener('message', onceHandler)
        if (e.data.ok) resolve(e.data.stl as ArrayBuffer)
        else
          reject(new Error(`export render failed: ${String(e.data.error ?? 'unknown scad error')}`))
      }
      worker.addEventListener('message', onceHandler)
      // NOTE: if the worker is terminated mid-render (viewer unmount) the promise
      // never settles — callers that outlive the viewer must race a timeout.
      worker.postMessage({
        id,
        entry: '/abacus.scad',
        files: { '/abacus.scad': st.scad, ...st.fonts },
        defines: exportDefines(params, pass),
        fn,
      })
    })
  }
 
  return { render, exportStl }
}