All files / web/src/components/create/abacus abacus-3mf.ts

100% Statements 102/102
84.21% Branches 16/19
100% Functions 1/1
100% Lines 102/102

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 1031x 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 1x 1x 1x 1x 1x 5x 5x 5x 5x 5x 5x 5x 5x 5x 1x 1x 4x 4x 4x 4x 4x 4x 5x 16x 16x 16x 4x 4x 4x 5x 10x 10x 10x 10x 5x 16x 16x 16x 16x 16x 4x 4x 4x 5x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 4x 4x 4x  
/**
 * Multi-material 3MF assembly for the abacus export (Phase 2b, Gitea #9).
 *
 * The scad worker renders ONE binary STL of the whole abacus (frame + free
 * beads) with no color information. This module splits that triangle soup by
 * filament slot — `analyzeShells` union-finds the shells, `shellSlotIndex`
 * maps each shell to the slot its role rides (the same mapping the viewer's
 * recolor pass uses) — and hands the per-slot bodies to frames-engine's
 * `meshesToThreeMf`, which keeps them CO-REGISTERED (one shared translate,
 * never per-body re-origin) so the beads stay threaded on their rods.
 *
 * Marker pockets and inset text ride the frame body in this pass: with the
 * scad's default `inlay_plugs=false` the plug solids aren't in the export STL
 * at all (a flush plug would weld into the frame shell). Separate marker/text
 * bodies via the scad's `only=` selectors are a follow-up render pass, not a
 * split of this one.
 */
import { type ColorBody, meshesToThreeMf } from '@eink/frames-engine/print-bundle'
import { parseStl, writeBinaryStl } from '@eink/frames-engine/stl'
import { analyzeShells, type FilamentMap, type Params, shellSlotIndex } from './abacus-model'
 
/** Per-body summary of what went into the 3MF — feeds the print panel + tests. */
export interface SpoolBodySummary {
  /** Filament slot index (0-based, into `FilamentMap.slots`). */
  slot: number
  label: string
  colorHex: string
  triangleCount: number
}
 
export interface AbacusThreeMf {
  /** The finished multi-material `.3mf` (zip bytes). */
  bytes: Uint8Array
  /** One entry per emitted body, ascending slot order. Slots with no geometry are absent. */
  bodies: SpoolBodySummary[]
}
 
/**
 * Split the export STL by filament slot and build the multi-material 3MF.
 *
 * @param stl        The one-shot export render (binary STL, whole abacus).
 * @param params     The scad params the STL was rendered from — shell
 *                   classification reads the same layout constants.
 * @param filamentMap The role→slot mapping the plan materialized.
 * @param slotLabels Optional human names per slot (e.g. spool names from the
 *                   AMS roster); defaults to `Filament N`.
 */
export function buildAbacusThreeMf(args: {
  stl: ArrayBuffer
  params: Params
  filamentMap: FilamentMap
  slotLabels?: readonly string[]
}): AbacusThreeMf {
  const { stl, params, filamentMap, slotLabels } = args
 
  const mesh = parseStl(stl)
  if (mesh.triangleCount === 0) {
    throw new Error('export STL has no triangles — nothing to print')
  }
 
  const { triShell, shellInfo } = analyzeShells(mesh.positions, params)
  const slotOfShell = shellInfo.map((info) => shellSlotIndex(info, params, filamentMap))
 
  // Count triangles per slot, then bucket the position soup (9 floats/tri).
  const triCount = new Map<number, number>()
  for (let t = 0; t < mesh.triangleCount; t++) {
    const slot = slotOfShell[triShell[t]]
    triCount.set(slot, (triCount.get(slot) ?? 0) + 1)
  }
 
  const slots = [...triCount.keys()].sort((a, b) => a - b)
  const buckets = new Map<number, { positions: Float32Array; fill: number }>()
  for (const slot of slots) {
    const count = triCount.get(slot)
    if (count === undefined) continue
    buckets.set(slot, { positions: new Float32Array(count * 9), fill: 0 })
  }
  for (let t = 0; t < mesh.triangleCount; t++) {
    const bucket = buckets.get(slotOfShell[triShell[t]])
    if (!bucket) continue
    bucket.positions.set(mesh.positions.subarray(t * 9, t * 9 + 9), bucket.fill)
    bucket.fill += 9
  }
 
  const bodies: SpoolBodySummary[] = []
  const colorBodies: ColorBody[] = []
  for (const slot of slots) {
    const bucket = buckets.get(slot)
    const count = triCount.get(slot)
    if (!bucket || count === undefined) continue
    const label = slotLabels?.[slot] ?? `Filament ${slot + 1}`
    const colorHex = filamentMap.slots[slot]
    const stlBytes = writeBinaryStl(bucket.positions)
    // ColorBody wants a plain ArrayBuffer; copy into an exact-size one.
    const stlBuffer = new ArrayBuffer(stlBytes.byteLength)
    new Uint8Array(stlBuffer).set(stlBytes)
    bodies.push({ slot, label, colorHex, triangleCount: count })
    colorBodies.push({ label, stl: stlBuffer, colorHex })
  }
 
  return { bytes: meshesToThreeMf(colorBodies), bodies }
}