// partforge/testing — headless helpers for building, measuring and verifying a // part with no browser. // // Most of what this barrel re-exports is NOT test-only: the oracle // (measure/verify/buildView/gaps/BVH/min-wall) also runs inside the browser // geometry worker. Only the two kernel booters and the PNG renderer are // Node-bound. import type { GeometryKernel, Mesh, Point3, Solid } from "./kernel.js"; import type { Derived, FontSource, PartDefinition, ResolvedParams } from "./part.js"; import type { KitOptions } from "./index.js"; export type { Derived, GeometryKernel, Mesh, PartDefinition, ResolvedParams, Solid }; // --- kernels ---------------------------------------------------------------- /** * Wrap an already-instantiated Manifold WASM module as a `GeometryKernel`. * `wasm` is the value of `await Module()` from `manifold-3d`, after `setup()`. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- the opaque manifold-3d WASM module export function createManifoldKernel(wasm: any, opts?: { quality?: "preview" | "print" }): GeometryKernel; /** * Boot the Manifold WASM module in a Node process and return a ready kernel. * OCCT must NOT be booted in the same process — keep OCCT tests in their own * files (vitest isolates per file). */ export function bootManifoldKernel(opts?: { quality?: "preview" | "print"; fonts?: Record; }): Promise; /** Boot OCCT/replicad in a Node process and return a ready kernel. */ export function bootOcctKernel(opts?: { fonts?: Record }): Promise; // --- the job loop ----------------------------------------------------------- /** * A reference shape for the `inspect` job to score the part's silhouettes against. * A `profile` is millimetres and so is compared at absolute scale too; an `image` * is a photo mask with no scale, compared on shape alone. */ export type MatchTarget = | { kind: "profile"; rings: Array> } | { kind: "image"; mask: { data: Uint8Array; width: number; height: number } }; /** A job the worker loop accepts. */ export interface WorkerJob { /** `export-bundle` builds the cut & print kit (a ZIP) from `parts`, as `exportParts({ format: "bundle" })` does. */ type: "generate" | "export-stl" | "export-step" | "export-3mf" | "export-bundle" | "inspect"; view?: string; params?: ResolvedParams; /** `generate`: which sub-parts to build. */ subparts?: string[]; /** Export: an explicit sub-part selection, overriding the view's. */ parts?: string[]; quality?: "preview" | "print"; cache?: boolean; /** Correlation id echoed on every reply (the export controller mints strings). */ jobId?: number | string; /** Single-file export name base. */ name?: string; /** `export-bundle` only: the kit's download options; absent means every default. */ options?: KitOptions; /** * `inspect`: score the part's six canonical silhouettes against these. Absent or * empty leaves `match` off the report entirely. * * A target that cannot be scored — malformed, or with no foreground to score * against — is DROPPED rather than reported as a zero, so `report.match` is not * index-aligned with this list. Attribute a result by its `kind` and by the * relative order of the targets sharing that kind, never by index. */ matchTargets?: MatchTarget[]; /** * `inspect`: opt into change tracking. Opaque — the worker keeps one baseline * per key, per worker lifetime, best-effort (a retired worker, a new key, or a * new `view` simply has no baseline yet). A new key forgets whatever baseline * the previous one held. Only an inspect with no (or empty) `params` is tracked * — a parameterized inspect is a different geometry and never becomes, or is * diffed against, the baseline. When present, `report.changes`/ * `report.changesSkipped` are added; absent, the report is unchanged. */ changesKey?: string; } /** * Run one worker job against `kernel`/`part`, posting replies through `post`. * The same loop the geometry Web Worker runs, exposed so a test can exercise an * export end to end without a Worker. */ export function handle( kernel: GeometryKernel, part: PartDefinition, msg: WorkerJob, post: (message: Record, transfer?: Transferable[]) => void, opts?: { isStale?: () => boolean }, ): Promise; // --- the part model --------------------------------------------------------- /** The sub-parts a view shows, in definition order. */ export function viewSubParts(part: PartDefinition, view: string, params: ResolvedParams): string[]; /** Resolve a part's `derive` into the `d` object builds receive. */ export function resolveDerived(part: Pick, p: ResolvedParams): Derived; /** Returned instead of a key set when the relevance analysis could not run. */ export const RELEVANT_ALL: unique symbol; /** * Which raw parameters affect the parts on screen in `view` — a `Set` of param * keys, or `RELEVANT_ALL` when the build could not be analyzed. */ export function relevantParamKeys( part: PartDefinition, view: string, params: ResolvedParams, ): Set | typeof RELEVANT_ALL; // --- assembly checks -------------------------------------------------------- /** One interpenetrating pair. `location` is the intersection bbox centre. */ export interface Overlap { a: string; b: string; /** Intersection volume in mm³. */ volume: number; location: Point3 | null; } /** One measured pair distance. `distance` 0 = touching or interpenetrating. */ export interface Gap { a: string; b: string; distance: number; /** The midpoint between the pair's closest surface points. */ at: Point3 | null; } /** * Build every sub-part of a view in its assembly pose and return the pairs whose * solid-intersection volume exceeds `tolerance` mm³. Manifold-only (needs * `Solid.intersect`). */ export function assemblyOverlaps( kernel: GeometryKernel, part: PartDefinition, view: string, params?: ResolvedParams, opts?: { tolerance?: number }, ): Overlap[]; /** * The complement of `assemblyOverlaps`: sub-part pairs that ALMOST touch * (0 < distance < `threshold` mm) in the display pose. Pure mesh math, so it * runs on both backends. */ export function assemblyGaps( kernel: GeometryKernel, part: PartDefinition, view: string, params?: ResolvedParams, opts?: { threshold?: number }, ): Gap[]; /** One sub-part built in its display pose, as `buildView` returns it. */ export interface BuiltSubPart { name: string; /** LIVE — valid only until `kernel.cleanup()`. */ solid: Solid; /** JS-owned arrays; survives `cleanup()`. */ mesh: Mesh; } /** * Minimum surface-to-surface distance for every pair of pre-built posed meshes. * Pairs involving an empty mesh are skipped. `bvhCache` is a caller-owned `Map` * so each mesh is indexed once across passes. */ export function meshGaps(built: BuiltSubPart[], opts?: { bvhCache?: Map }): Gap[]; /** * Build every sub-part of a view in its display pose. Keeps solids LIVE (does * NOT call `kernel.cleanup()`) so callers can read exact solid facts first. */ export function buildView( kernel: GeometryKernel, part: PartDefinition, view: string, params?: ResolvedParams, ): BuiltSubPart[]; // --- mesh math -------------------------------------------------------------- /** Signed-tetrahedron volume of a triangle mesh. Omit `indices` for a flat soup. */ export function meshVolume(positions: ArrayLike, indices?: ArrayLike): number; /** `[dx, dy, dz]` extent of a flat position array. */ export function bboxSize(positions: ArrayLike): [number, number, number]; /** World-frame axis-aligned bounds of a flat position array. */ export function bounds(positions: ArrayLike): { min: [number, number, number]; max: [number, number, number] }; /** Total surface area of an indexed (or soup, when `indices` is omitted) triangle mesh, mm². */ export function meshArea(positions: ArrayLike, indices?: ArrayLike): number; /** Triangles as `[v0, v1, v2]` coordinate triples, from an indexed mesh or a soup. */ export function meshTriangles(mesh: Mesh): [number, number, number][][]; /** Parse a binary or ASCII STL into a welded, indexed mesh. */ export function parseStl(bytes: Uint8Array | ArrayBuffer): { positions: Float32Array; indices: Uint32Array }; /** Parse a 3MF archive's first mesh object into a welded, indexed mesh. */ export function parse3MF(bytes: Uint8Array | ArrayBuffer): { positions: Float32Array; indices: Uint32Array }; // --- the BVH ---------------------------------------------------------------- /** A triangle BVH over one mesh — nearest ray hit, nearest point, exact mesh distance. */ export interface BVH { /** Nearest hit along `dir` from `origin`, or `null`. `t` is the distance, `tri` the mesh triangle id. */ raycast( origin: Point3, dir: Point3, opts?: { tMin?: number; tMax?: number; skipTri?: number }, ): { t: number; tri: number } | null; /** Nearest surface point to `p`. */ closestPoint(p: Point3): { point: number[] | null; dist: number; tri: number }; /** Exact minimum surface-to-surface distance to another BVH. */ distanceTo(other: BVH): { distance: number; at: number[] | null; pointA: number[] | null; pointB: number[] | null; }; triangleCount: number; /** Flat, 9 coords per triangle, in mesh order. READ-ONLY — writing invalidates the tree. */ vertices: Float32Array | Float64Array; /** The root AABB, `[minx, miny, minz, maxx, maxy, maxz]` (a copy). */ rootBounds: number[]; /** Diagnostic self-report of the four typed arrays' byte lengths. */ bytesAllocated: number; } /** Index a mesh (Manifold soup or OCCT indexed form) for spatial queries. */ export function buildBVH(mesh: Mesh): BVH; /** * Min wall thickness by inward ray-casting per surface triangle. `null` only for * an EMPTY mesh; a mesh whose rays all miss returns `value: null` WITH the * sampling accounting, so "we looked and found nothing" stays distinguishable * from "nobody looked". A sampled reading is an upper bound on the true minimum. */ export function minWall( mesh: Mesh, opts?: { maxThickness?: number; maxSamples?: number; bvh?: BVH }, ): { value: number | null; location: Point3 | null; sampled: boolean; /** The sample BUDGET — triangles selected, not rays actually cast. */ sampledTriangles: number; totalTriangles: number; } | null; /** * Turn partforge's native core (src/framework/core/ — creasedNormals and the * oracle's BVH + min-wall compiled to WebAssembly) on or off for this realm. On * by default wherever WebAssembly exists. Output is bit-identical either way, so * this is a performance and kill switch, never a behaviour choice. A worker is * its own realm: set it there (or `globalThis.PARTFORGE_CORE = false`, which is * read on every use). */ export function setCoreEnabled(on: boolean): void; /** Why the core stopped answering in this realm — a fixed vocabulary, safe to record. */ export type CoreFallbackReason = "no_webassembly" | "boot_failed" | "out_of_memory" | "trap" | "error"; export const CORE_FALLBACK_REASONS: readonly CoreFallbackReason[]; export interface CoreFallback { state: "unavailable" | "faulted"; reason: CoreFallbackReason; } export interface CoreStatus { /** idle: not needed yet · on · off: switched off · unavailable: could not start · faulted: stopped after a fault */ state: "idle" | "on" | "off" | "unavailable" | "faulted"; reason: CoreFallbackReason | null; /** Meshes sent to the JS pass because the core declined them (malformed MeshGL) — not a fault. */ refusedMeshes: number; } /** Where this realm's native core stands. Output is identical either way; this is for telemetry. */ export function coreStatus(): CoreStatus; /** * Called once when the core becomes unavailable or faults (immediately if it * already has), so a host can count fallbacks. Returns an unsubscribe function. */ export function onCoreFallback(listener: (fallback: CoreFallback) => void): () => void; /** * Unsupported downward-facing surface (oracle/overhang.js): the mm² of faces * steeper than `maxAngle` from vertical, excluding the footprint on the bed * (`bedZ`, else the mesh's lowest Z) and a near-bed band. One pass, no index; * `null` only for an empty mesh. Bridges and bore ceilings count. */ export function overhang( mesh: Mesh, opts?: { maxAngle?: number; bedZ?: number; bedEps?: number; bedBand?: number }, ): { area: number; worstAngle: number | null; at: Point3 | null } | null; // --- measure ---------------------------------------------------------------- /** * A sheet sub-part's 2-D facts (`sheetPart()`, partforge/geometry) — what its * process's checks read. Lengths in mm, areas in mm². `bridge` and `gap` are * bisected to 0.05 mm; `…Capped` says nothing narrower than twice the floor was * found and the value is that ceiling. `at2d` is in the drawing frame; `at` is * the same spot in the assembly at mid-thickness, `null` when the sub-part's * pose could not be traced. `evaluated` false: the 2-D time budget ran out, or * the profile was too complex to start the checks under it, and `bridge`, `gap`, * `marksOutside` and `marksArea` are `null`. */ export interface SheetFacts { process: string; /** `null` only on a sheet naming no registered process whose declaration does not resolve (`evaluated` false). */ material: string | null; /** `null` as for `material`. */ thickness: number | null; /** `"|"`, e.g. `"birch plywood|3.00"`; `null` as for `material`. */ group: string | null; /** The cut layer's bounding-box size, nominal (no kerf). */ flat: [number, number]; area: number; pieces: number; customBuild: boolean; marksArea: number | null; bridge: number | null; bridgeCapped: boolean; gap: number | null; gapCapped: boolean; marksOutside: number | null; /** Custom builds only: `100·|volume − (area·thickness − 0.2·marksArea)| / (area·thickness − 0.2·marksArea)`. */ solidMatchPct: number | null; at2d: { bridge: [number, number] | null; gap: [number, number] | null; marks: [number, number] | null }; at: { bridge: number[] | null; gap: number[] | null; marks: number[] | null }; /** * Why a reading is `null` although `evaluated` is true — the geometry engine refused * the profile, or the reading threw — or `null` for each one that was taken. */ readErrors: { bridge: string | null; gap: string | null; marks: string | null; marksArea: string | null }; evaluated: boolean; } export interface SubPartFacts { name: string; /** Size only — `[dx, dy, dz]`. */ bbox: number[]; /** Where the geometry sits. */ bounds: { min: number[]; max: number[] }; /** Volume-weighted centroid, or `null` for a degenerate/zero-volume sub-part. */ centerOfMass: number[] | null; volume: number; surfaceArea: number; triangleCount: number; /** Manifold-only; `null` on OCCT. */ watertight: boolean | null; /** Through-hole count (genus). Manifold-only; `null` on OCCT. */ holes: number | null; /** `null` exactly when no reading exists. */ minWall: number | null; minWallAt: number[] | null; minWallSampled: boolean; minWallSamples: { sampled: number; total: number } | null; /** * The declared wall band's worst member: the sampled thickness farthest from the * range the part's `verify.expect..wall` declared (or, when every member is * inside it, farthest from its midpoint), where it was read, the band, and how many * rays fell in the membership window `[0.75 × min, 1.5 × max]`. `null` when the * sub-part declares no band or min wall was not measured; `value` null when no ray * read as this wall. */ wall: { value: number | null; location: number[] | null; band: { min: number; max: number }; members: number } | null; /** * Unsupported downward-facing area in mm² (oracle/overhang.js), `null` when the * part is not laid out for a bed (`verify.orientation: "print"` under a * profile with an `overhang` angle). Bridges and bore ceilings count. */ overhangArea: number | null; /** Steepest offending face, degrees from vertical; `null` when none. */ overhangAngle: number | null; /** Centroid of the largest offending face; `null` when none. */ overhangAt: number[] | null; /** A sheet sub-part's 2-D facts, or `null` on every other sub-part. */ sheet: SheetFacts | null; /** * Present only in a view that holds a sheet part, when a process bed will read it * (`measuredPrintBboxes`), on each printed (`exportable !== false`, non-sheet) * sub-part: its size in the print (export) pose, which is what verify fits the * process profile's bed to there. */ printBbox?: number[]; /** Instead of `printBbox` when this sub-part's export pose did not build: why. */ printBboxError?: string; } export interface AggregateFacts { bbox: number[]; bounds: { min: number[]; max: number[] }; centerOfMass: number[] | null; volume: number; surfaceArea: number; triangleCount: number; } /** One arc of a probed `Shape2D` ring: centre and radius from the contour IR. */ export interface ProbeArc { center: [number, number]; r: number; from: [number, number]; to: [number, number]; /** Signed; positive counter-clockwise. */ sweepDeg: number; /** Present when the segment was a cubic and the circle is a fit, not a construction. */ fit?: "cubic"; } export interface ProbeRing { /** 0-based index into the shape's `regions` — which region this ring belongs to. */ region: number; /** Whether this is the region's outer boundary or one of its holes. */ ring: "outer" | "hole"; /** 0-based hole index within its region; present only when `ring === "hole"`. */ hole?: number; /** Total segment count, before any cap. */ segments: number; /** At most 64 across the WHOLE summary (all rings combined), not per ring; * `truncated` on the summary says when the list was cut. */ arcs: ProbeArc[]; /** Straight segments, count only. */ lines: number; corners: Array<{ /** Running index across every ring in the summary's own order (region by region, * outer then holes) — the positional index `fillet({corners: {indices}})` and * `shape.corners()` select on. */ position: number; point: [number, number]; interiorAngleDeg: number; convex: boolean; }>; } /** What a `Shape2D` returned from a probe becomes in the report. */ export interface ShapeProbeFacts { kind: "shape2d"; empty: boolean; area: number; bbox: { min: [number, number]; max: [number, number] } | null; /** A flat list of every ring across every region — region by region, outer then * holes — never nested `{outer, holes}` objects. */ rings: ProbeRing[]; /** At most 64 arcs and 64 corners total, across every ring; true when either * budget was spent and something was cut. */ truncated: boolean; } export interface MeasureReport { /** `part.meta.title`, falling back to the view name. */ part: string; view: string; /** Whether this run cast min-wall rays at all. */ measuredMinWall: boolean; /** The overhang angle every sub-part's `overhangArea` was measured against, `null` when the pass did not run. */ measuredOverhang: number | null; /** * Present only in a view holding a sheet part: whether the printed sub-parts' print * (export) poses were built for `printBbox`. */ measuredPrintBboxes?: boolean; subparts: SubPartFacts[]; aggregate: AggregateFacts; overlaps: Overlap[]; /** Every sub-part pair's minimum surface distance. */ gaps: Gap[]; /** The pairs with an unintended-looking gap under the threshold. */ nearMisses: Gap[]; /** * Declared probes' values, present only when the part declares probes and this * run evaluated them. A Solid in a probe's return value becomes a fact object, a * Shape2D becomes a `ShapeProbeFacts`, plain JSON passes through, a throw is `{error}`. */ probes?: Record; /** Every sub-part watertight and nothing interpenetrating. Near misses never affect it. */ ok: boolean; } /** Headless geometric report for one view of a part. */ export function measure( kernel: GeometryKernel, part: PartDefinition, view?: string, params?: ResolvedParams, opts?: { minWall?: boolean; /** * The overhang angle to measure against (degrees from vertical), or `null` * for "not checked". Omitted, measure derives it from the part's own * `verify` block the way verify does. */ overhang?: number | null; gapThreshold?: number; /** * Milliseconds the 2-D sheet checks may spend across every sheet sub-part in * this call (default 1500), charged for 2-D work alone. Past it the remaining * readings are withheld — `sheet.evaluated` false — never the report. */ sheetBudgetMs?: number; /** The clock that budget runs on, in ms (default `Date.now`) — a test's seam. */ now?: () => number; /** * In a view holding a sheet part, build each printed sub-part in its print (export) * pose for `printBbox`. Default: when the part's own `verify.process` has a bed — * the only check that reads it. */ printBboxes?: boolean; /** * A build of this view the caller already has, measured instead of building a * second time. It is trusted, not checked against `view`/`params` — hand in a * build of the same view you are asking about. */ built?: BuiltSubPart[]; /** * A `ChangeTracker`'s `memo` — reuses a previous round's facts (and sub-part * pair gap distances) for any sub-part whose final hash did not change, * instead of re-deriving them. Opaque; hand in `changeTracker.memo` and * nothing else. */ memo?: unknown; }, ): MeasureReport; // --- verify ----------------------------------------------------------------- export type CheckStatus = "pass" | "fail" | "warn" | "skip"; export interface VerifyCheck { /** `"part"` is the vacuous-verify notice; `"case"` the quick-lap "not measured" marker. */ scope: "view" | "subpart" | "part" | "case"; /** The sub-part name, `"a×b"` for a pair check, or `null` for a scalar view metric. */ subpart: string | null; metric: string; /** `gate` failures set a non-zero exit code; `warn` never blocks. */ kind: "gate" | "warn"; /** The asserted expression, stringified. */ expr: string; actual: unknown; status: CheckStatus; pass: boolean | null; message: string; /** One self-contained corrective sentence (part-authored `hint` wins). */ hint?: string; /** A stable ERROR-PATTERNS.md entry id — or `"sheet-parts"`, the authoring guide's "Sheet parts" section, on a sheet check. */ pattern?: string; /** A measurement caveat or companion reading — `minWall` (sampling) and `overhangArea` (the steepest angle) set one. */ note?: string; /** `[x, y, z]` in mm, for the metrics that have one. */ location?: number[] | null; /** * A check the part never declared, offered by the oracle — a sheet part's * process checks, or the notice standing in for them past the 2-D budget. It * warns when it fails and never counts toward `declared`/`evaluated`, so it * never decides `ok`. */ volunteered?: boolean; /** True when the check could not be evaluated this run (a quick lap, or a declared sheet check past the 2-D budget). */ unevaluated?: boolean; } export interface VerifyCaseResult { /** `"defaults"` or a preset name. */ name: string; params: ResolvedParams; checks: VerifyCheck[]; } export interface VerifyReport { /** * Tri-state: `true` when every declared check passed, `false` on any gate * failure, `null` when no verdict can be given — a quick lap that could not * measure a gate, or a part that declared no expectations at all (`declared` * is 0 and `warnings` carries a `no expectations declared` notice). Never read * `null` as a pass. */ ok: boolean | null; view: string; cases: VerifyCaseResult[]; /** Every failing check, flattened, each tagged with its `case`. */ failures: Array; warnings: Array; /** Checks a quick lap could not evaluate. */ unevaluated: Array; /** Check instances the part or its profile declared, across cases (near-miss notices excluded). */ declared: number; /** Of `declared`, how many were actually answered — a skipped or unevaluated check is not. Zero withholds `ok`. */ evaluated: number; } /** * Enforce a part's `verify` block across the default config plus every preset * (or its declared `cases`). * * `seed` lets a caller that has already measured the part hand the result in so * verify does not recompute it. It is consulted only when the seed was measured * with min wall (or this run needs none) and for the same view. */ export function verify( kernel: GeometryKernel, part: PartDefinition, opts?: { /** Force or override the DFM profile. */ process?: string | Record; view?: string; measureFn?: typeof measure; seed?: { params?: ResolvedParams; result: MeasureReport }; }, ): VerifyReport; // --- change tracking --------------------------------------------------------- /** One root operation behind a sub-part's changed final hash, named by the op graph. */ export interface ChangeOpRef { op: string; label?: string; } /** One connected region of material a mesh boolean diff found added or removed. */ export interface ChangeRegion { change: "added" | "removed"; mm3: number; at: [number, number, number]; size: [number, number, number]; } export interface SubPartChange { name: string; verdict: "new" | "deleted" | "moved" | "added" | "removed" | "reshaped"; /** * `verdict: "moved"` only: the sub-part's new bounding-box centre minus its old * one, in mm. A rotation about that centre reads as ≈[0, 0, 0] — see `rotated`. */ moved?: [number, number, number]; /** `verdict: "moved"` only, present (true) when the placement's orientation changed too. */ rotated?: boolean; /** `verdict: "reshaped" | "added" | "removed"`: new volume minus old, in mm³. */ volumeDeltaMm3?: number; /** `verdict: "added" | "removed" | "reshaped"`, when a mesh boolean diff ran. */ addedMm3?: number; removedMm3?: number; /** Up to 3 largest added/removed regions from the mesh boolean diff, largest first. */ regions?: ChangeRegion[]; /** The named root operations behind the changed hash, when the op graph covers it. */ changedOps?: ChangeOpRef[]; } export interface ChangesReport { /** True only when every sub-part's final hash matched the previous build's. */ unchanged?: true; subparts: SubPartChange[]; unchangedSubparts: number; } /** * Per-worker, best-effort "what changed since the last inspect of this view" * tracker behind `inspect`'s `changesKey`. One tracker holds exactly one * baseline at a time (per `begin`'s `key`); a new key or a new `view` simply has * no baseline yet, which is reported as `{}` from `finish`, not an error. * * Call order for one round: `begin` → `buildView` → `endBuild(built)` → * (optionally `measure` with `{ memo: tracker.memo }`) → `finish`. A failure * before `endBuild`/`finish` calls `abort()` instead, which forgets the round * without touching the stored baseline. */ export interface ChangeTracker { /** Hand to `measure`'s `memo` option to reuse facts for unchanged sub-parts. */ readonly memo: unknown; /** Start a round. `key` identifies the baseline; a different key forgets it. */ begin(key: string, view: string): void; /** Capture the just-built view. Call right after `buildView`, before `kernel.cleanup()`. */ endBuild(built: BuiltSubPart[]): void; /** The round failed before `finish`: discard it without touching the baseline. */ abort(): void; /** * Diff this build against the stored baseline and roll it forward as the next * baseline. Call after `measure()` — `measure` calls `kernel.cleanup()` * internally, which is fine: `finish` reads only hash strings, JS-owned mesh * arrays, and `measured.subparts[].volume`, never a live solid. */ finish( kernel: GeometryKernel, view: string, built: BuiltSubPart[], measured: MeasureReport, ): { changes?: ChangesReport; changesSkipped?: string }; } export function createChangeTracker(opts?: { now?: () => number; /** Milliseconds the mesh boolean diff may spend across one `finish()` call (default 2000). */ budgetMs?: number; /** Sub-parts whose combined old+new triangle count exceeds this skip the mesh diff (default 300000). */ maxTriangles?: number; }): ChangeTracker; // --- silhouette match scoring ----------------------------------------------- /** * A binary silhouette. `data` is 0 or 255, one byte per pixel, row 0 at the TOP. * `minX`/`minY` are the projected-plane coordinates of the image's BOTTOM-LEFT * corner. A mask with no `mmPerPx` carries no scale (a photo), which is what makes * the scale-aware comparison unavailable for it. */ export interface SilhouetteMask { data: Uint8Array; width: number; height: number; mmPerPx?: number; minX?: number; minY?: number; } /** The six canonical orthographic views a part is rasterized into for matching. */ export const MATCH_VIEWS: string[]; /** * Project posed meshes onto one of `MATCH_VIEWS` and scanline-fill the silhouette. * `null` when there is nothing to draw or the projection has zero extent. */ export function rasterizeMeshMask( meshes: Array>, view: string, size?: number, ): SilhouetteMask | null; /** * Fill a set of closed 2-D rings (millimetres) into a mask. All rings share one * even-odd group, so a ring inside another is a hole. `null` when nothing fills. */ export function rasterizeRingsMask( rings: Array>, size?: number, ): SilhouetteMask | null; /** * Per-pixel comparison of the two masks: `0` background, `1` overlap, `2` missing * (reference only), `3` excess (candidate only). */ export interface MatchDelta { width: number; height: number; data: Uint8Array; } /** * How close a candidate silhouette is to a reference one. Shape is compared * pose-normalized, so `iou` and `boundaryIoU` ignore position and size. `iouScale` * appears only for a scale-aware comparison, which also makes `contourDist` a real * millimetre distance instead of a percentage of the reference's bbox diagonal. */ export interface MatchScores { iou: number; boundaryIoU: number; contourDist: number; contourUnit: "mm" | "%bbox-diag"; iouScale?: number; delta: MatchDelta; } /** * Score one candidate mask against a reference. `null` when either has no * foreground at all — unscoreable is not the same as scoring zero. * * `scaleAware` is the caller's promise that both masks are in millimetres; it takes * effect only when both actually carry a finite `mmPerPx`. */ export function matchMasks( candidate: SilhouetteMask | null | undefined, reference: SilhouetteMask | null | undefined, opts?: { scaleAware?: boolean }, ): MatchScores | null; /** * Score every view's mask against one reference and name the best. Unscoreable * views are left out of `views` entirely; `best` is `null` when none scored. */ export function matchViews( viewMasks: Record, reference: SilhouetteMask | null | undefined, opts?: { scaleAware?: boolean }, ): { best: ({ view: string } & MatchScores) | null; views: Record }; // --- rendering -------------------------------------------------------------- /** The canonical angle names `renderViews` accepts. */ export const RENDER_VIEWS: string[]; /** * Render canonical-angle PNGs of one view with the styled pure-JS renderer — * no native module, no browser. Returns the written file paths. */ export function renderViews( kernel: GeometryKernel, part: PartDefinition, view?: string, opts?: { views?: string[]; out?: string; /** [width, height] px. Default: 800×600, or 640 wide at the style's aspect when it has one (thumbnail → 640×480). */ size?: [number, number]; edges?: boolean; params?: ResolvedParams; /** Frame suffix in the written filename (`---.png`). */ tag?: string; /** * Per-sub-part opacity, keyed by sub-part name (an animation `evaluate()` * result). `0` omits the sub-part entirely; `0 < v < 1` fades it toward the * background. Absent keys render solid. */ opacity?: Record; style?: "cad" | "thumbnail"; /** Samples per pixel per axis (default 2). */ supersample?: number; }, ): Promise; /** The built-in capture looks: "cad" (agent renders) and "thumbnail" (product shot). */ export const RENDER_STYLES: Readonly>; /** * Render canonical-angle PNGs of one view in memory with the styled pure-JS * renderer — the same looks as the viewer's offscreen captures. */ export function renderViewImages( kernel: GeometryKernel, part: PartDefinition, view?: string, opts?: { views?: string[]; /** [width, height] px. Default: 800×600, or 640 wide at the style's aspect when it has one (thumbnail → 640×480). */ size?: [number, number]; edges?: boolean; params?: ResolvedParams; opacity?: Record; style?: "cad" | "thumbnail"; /** Samples per pixel per axis (default 2). */ supersample?: number; }, ): Promise<{ angle: string; png: Uint8Array }[]>; // --- sketch-annotation rays -------------------------------------------------- export interface AnnotationRay { origin: [number, number, number]; dir: [number, number, number] } export interface RayPlaneHit { point: [number, number, number]; t: number } export type PlaneSpec = | { point: [number, number, number]; normal: [number, number, number] } | "xy" | "yz" | "zx"; /** Rebuild the pick ray for a screen point of an ANNOTATION_VERSION 3 payload. */ export function annotationRay( payload: { camera: unknown; viewport: { aspect: number } }, screen: [number, number] | { screen: [number, number] }, opts?: { frame?: "parts" | "world" }, ): AnnotationRay; /** Intersect a ray with a plane; null on parallel / behind-origin misses. */ export function rayPlane(ray: AnnotationRay, plane: PlaneSpec): RayPlaneHit | null;