partforge 0.1.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/README.md +3 -1
  2. package/bin/cli.js +73 -0
  3. package/docs/AUTHORING-PARTS.md +200 -3
  4. package/package.json +15 -1
  5. package/src/app-filleted-box.js +7 -0
  6. package/src/filleted-box-worker.js +3 -0
  7. package/src/framework/app.css +26 -1
  8. package/src/framework/controls.js +153 -36
  9. package/src/framework/debug-overlay.js +40 -0
  10. package/src/framework/geometry/edge-selector.js +17 -0
  11. package/src/framework/geometry/errors.js +10 -0
  12. package/src/framework/geometry/face-selector.js +19 -0
  13. package/src/framework/geometry/kernel.js +9 -1
  14. package/src/framework/geometry/manifold-backend.js +76 -18
  15. package/src/framework/geometry/occt-backend.js +118 -4
  16. package/src/framework/geometry/polygon.js +117 -0
  17. package/src/framework/geometry/probe.js +52 -0
  18. package/src/framework/geometry/solid-cache.js +39 -0
  19. package/src/framework/geometry/solid-hash.js +21 -0
  20. package/src/framework/geometry-service.js +8 -10
  21. package/src/framework/jobs.js +24 -8
  22. package/src/framework/markdown.js +41 -0
  23. package/src/framework/mount.js +144 -18
  24. package/src/framework/param-deps.js +81 -0
  25. package/src/framework/selection/format.js +31 -0
  26. package/src/framework/selection/index.js +5 -0
  27. package/src/framework/selection/pick.js +48 -0
  28. package/src/framework/selection/resolve.js +54 -0
  29. package/src/framework/view-state.js +55 -0
  30. package/src/framework/viewer.js +42 -3
  31. package/src/parts/demo.js +29 -11
  32. package/src/parts/filleted-box.js +46 -0
  33. package/src/testing/build.js +17 -0
  34. package/src/testing/measure.js +53 -0
  35. package/src/testing/mesh.js +27 -0
  36. package/src/testing/render.js +159 -0
  37. package/src/testing.js +3 -0
package/src/parts/demo.js CHANGED
@@ -1,38 +1,56 @@
1
- // Example PartDefinition — a parametric spacer. Proof that a new part is just a new
2
- // script: the framework (viewer, controls, workers, STL/STEP export) is reused
3
- // unchanged. Mount it with its own app/worker entry exactly as the drum does.
1
+ // Example PartDefinition — a parametric spacer. Doubles as the worked example for
2
+ // docs/AUTHORING-PARTS.md "Designing the control panel": a description on every
3
+ // control, a hidden internal constant, and a derive() that turns raw inputs into the
4
+ // dependent dimensions the build consumes. The framework (viewer, controls, workers,
5
+ // STL/STEP export) is reused unchanged.
4
6
  export default {
5
7
  meta: { title: "Spacer", units: "mm", background: 0x15181d },
6
8
  parameters: [
7
9
  {
8
10
  id: "body",
9
11
  title: "Body",
12
+ description: "The spacer barrel and its through-bore. Pick a preset for a common screw size, or open **Advanced** to set exact dimensions.",
10
13
  presets: { M3: { od: 8, bore: 3.4, h: 10 }, M5: { od: 12, bore: 5.4, h: 16 } },
11
14
  advanced: [
12
- { key: "od", label: "Outer diameter", unit: "mm", min: 4, max: 40, step: 0.5 },
13
- { key: "bore", label: "Bore", unit: "mm", min: 1, max: 30, step: 0.1, control: "number" },
14
- { key: "h", label: "Height", unit: "mm", min: 2, max: 60, step: 1 },
15
+ { key: "od", label: "Outer diameter", unit: "mm", min: 4, max: 40, step: 0.5,
16
+ description: "Barrel outer diameter. Keep it comfortably larger than the bore so a wall remains. See the [authoring guide](https://github.com/scottsykora/partforge/blob/main/docs/AUTHORING-PARTS.md)." },
17
+ { key: "bore", label: "Bore", unit: "mm", min: 1, max: 30, step: 0.1, control: "number",
18
+ description: "Nominal screw clearance hole. A fixed print clearance is added automatically (see `derive`), so enter the *nominal* size." },
19
+ { key: "h", label: "Height", unit: "mm", min: 2, max: 60, step: 1,
20
+ description: "Spacer length along the axis." },
21
+ { key: "flange_h", label: "Flange thickness", unit: "mm", min: 1, max: 5, step: 0.5, hidden: true,
22
+ description: "Internal: flange plate thickness, fixed by the design. Hidden from the end user, but still drives the geometry." },
15
23
  ],
16
24
  },
17
25
  {
18
26
  id: "flange",
19
27
  title: "Flange",
28
+ description: "Optional base flange — a wider seating plate at one end.",
20
29
  features: [
21
30
  { label: "Base flange", key: "flange_d", on: 16,
22
- sliders: [{ key: "flange_d", label: "Flange diameter", unit: "mm", min: 8, max: 50, step: 1 }] },
31
+ description: "Adds a `flange_h`-thick plate of this diameter at the base.",
32
+ sliders: [{ key: "flange_d", label: "Flange diameter", unit: "mm", min: 8, max: 50, step: 1,
33
+ description: "Outer diameter of the base flange." }] },
23
34
  ],
24
35
  },
25
36
  ],
26
- defaults: { od: 8, bore: 3.4, h: 10, flange_d: 0 },
37
+ defaults: { od: 8, bore: 3.4, h: 10, flange_d: 0, flange_h: 2 },
38
+ // derive(): turn raw inputs into the dependent dimensions the build needs, so one
39
+ // input drives the geometry consistently — here the bore gains a fixed print
40
+ // clearance and the cut tool is sized to pierce the whole part.
41
+ derive: (p) => ({
42
+ boreR: (p.bore + 0.2) / 2, // nominal bore + 0.2 mm print clearance, as a radius
43
+ cutH: p.h + 4, // through-cut tool, taller than the part
44
+ }),
27
45
  parts: {
28
46
  spacer: {
29
47
  label: "Spacer",
30
48
  views: ["spacer"],
31
49
  export: { name: "spacer" },
32
- build: (k, p) => {
50
+ build: (k, p, d) => {
33
51
  let s = k.cylinder(p.od / 2, p.od / 2, p.h);
34
- if (p.flange_d > 0) s = k.union([s, k.cylinder(p.flange_d / 2, p.flange_d / 2, 2)]);
35
- return s.cut(k.cylinder(p.bore / 2, p.bore / 2, p.h + 4).translate([0, 0, -2]));
52
+ if (p.flange_d > 0) s = k.union([s, k.cylinder(p.flange_d / 2, p.flange_d / 2, p.flange_h)]);
53
+ return s.cut(k.cylinder(d.boreR, d.boreR, d.cutH).translate([0, 0, -2]));
36
54
  },
37
55
  },
38
56
  },
@@ -0,0 +1,46 @@
1
+ // Example part exercising native CAD ops — it auto-routes to the OCCT backend
2
+ // because it uses fillet (and chamfer when enabled). Vertical edges are rounded
3
+ // and an optional bore drilled; the base chamfer is off by default (turn it up to
4
+ // try chamfering, kept off in defaults so the gating build uses only the fillet).
5
+ export default {
6
+ meta: { title: "Filleted Box", units: "mm", background: 0x15181d },
7
+ parameters: [
8
+ {
9
+ id: "box", title: "Box",
10
+ advanced: [
11
+ { key: "w", label: "Width", unit: "mm", min: 10, max: 80, step: 1 },
12
+ { key: "d", label: "Depth", unit: "mm", min: 10, max: 80, step: 1 },
13
+ { key: "h", label: "Height", unit: "mm", min: 5, max: 40, step: 1 },
14
+ { key: "fillet", label: "Edge fillet", unit: "mm", min: 0, max: 10, step: 0.5 },
15
+ { key: "top", label: "Top fillet", unit: "mm", min: 0, max: 5, step: 0.5 },
16
+ { key: "chamfer", label: "Base chamfer", unit: "mm", min: 0, max: 10, step: 0.5 },
17
+ { key: "bore", label: "Bore", unit: "mm", min: 0, max: 24, step: 0.5 },
18
+ ],
19
+ },
20
+ ],
21
+ defaults: { w: 40, d: 30, h: 16, fillet: 3, top: 2, chamfer: 0, bore: 8 },
22
+ parts: {
23
+ body: {
24
+ label: "Body",
25
+ views: ["box"],
26
+ build: (k, p) => {
27
+ let s = k.box([0, 0, 0], [p.w, p.d, p.h]);
28
+ const half = Math.min(p.w, p.d) / 2;
29
+ // Round the vertical edges, then the top rim — each clamped to the box so a
30
+ // radius can't exceed the available material.
31
+ const vFillet = Math.min(p.fillet, half - 0.5, p.h - 0.5);
32
+ if (vFillet > 0) s = s.fillet(vFillet, { dir: "Z" }); // 4 vertical edges
33
+ const topFillet = Math.min(p.top, half - vFillet - 0.5, p.h / 2 - 0.5);
34
+ if (topFillet > 0) s = s.fillet(topFillet, { inPlane: "XY", at: p.h }); // top rim — curves all the way around
35
+ // Base chamfer AFTER the fillets, so it cuts a clean curve across the rounded
36
+ // corners. No manual limit needed: the backend auto-clamps a chamfer to half
37
+ // the shortest edge it touches (here the fillets' bottom arcs), so it stops at
38
+ // its valid maximum instead of mangling the bottom face.
39
+ if (p.chamfer > 0) s = s.chamfer(p.chamfer, { inPlane: "XY", at: 0 }); // base edges
40
+ if (p.bore > 0) s = s.cut(k.cylinder(p.bore / 2, p.bore / 2, p.h + 2).translate([p.w / 2, p.d / 2, -1]));
41
+ return s;
42
+ },
43
+ },
44
+ },
45
+ views: { box: { label: "Box" } },
46
+ };
@@ -0,0 +1,17 @@
1
+ import { viewSubParts } from "../framework/jobs.js";
2
+
3
+ // Build every sub-part of a view in its display (assembly) pose with the given
4
+ // Manifold kernel, returning live solids + copied-out meshes. Mirrors the
5
+ // `generate` path in jobs.js, but keeps solids LIVE (does NOT call
6
+ // kernel.cleanup()) so callers can read exact solid facts (volume/genus/empty)
7
+ // before they free the kernel. Meshes are JS-owned arrays and survive cleanup.
8
+ export function buildView(kernel, part, view, params = {}) {
9
+ const p = { ...part.defaults, ...params };
10
+ const d = part.derive ? part.derive(p) : {};
11
+ return viewSubParts(part, view, p).map((name) => {
12
+ const sp = part.parts[name];
13
+ let solid = sp.build(kernel, p, d);
14
+ if (sp.place) solid = sp.place(solid, { view, purpose: "display", p, d });
15
+ return { name, solid, mesh: solid.toMesh() };
16
+ });
17
+ }
@@ -0,0 +1,53 @@
1
+ import { buildView } from "./build.js";
2
+ import { assemblyOverlaps } from "../framework/assembly.js";
3
+ import { bounds, meshArea } from "./mesh.js";
4
+
5
+ const size = ({ min, max }) => [max[0] - min[0], max[1] - min[1], max[2] - min[2]];
6
+ const unionBounds = (list) => list.reduce(
7
+ (acc, b) => ({ min: acc.min.map((v, i) => Math.min(v, b.min[i])), max: acc.max.map((v, i) => Math.max(v, b.max[i])) }),
8
+ { min: [Infinity, Infinity, Infinity], max: [-Infinity, -Infinity, -Infinity] },
9
+ );
10
+
11
+ // Headless geometric report for one view of a part (Manifold-only). Reads exact
12
+ // solid facts (volume/genus/emptiness) and mesh facts (bbox/area/triangles), plus
13
+ // the assembly overlap check. All solid facts are read BEFORE assemblyOverlaps,
14
+ // which frees the shared kernel's objects at its end.
15
+ // → { part, view, subparts[], aggregate, overlaps[], ok }
16
+ export function measure(kernel, part, view = Object.keys(part.views)[0], params = {}) {
17
+ const built = buildView(kernel, part, view, params);
18
+ const subBounds = [];
19
+ const subparts = built.map(({ name, solid, mesh }) => {
20
+ const b = bounds(mesh.positions);
21
+ subBounds.push(b);
22
+ return {
23
+ name,
24
+ bbox: size(b),
25
+ volume: solid.volume(),
26
+ surfaceArea: meshArea(mesh.positions, mesh.indices),
27
+ triangleCount: mesh.triangles,
28
+ watertight: typeof solid.isEmpty === "function" ? !solid.isEmpty() : null,
29
+ holes: typeof solid.genus === "function" ? solid.genus() : null,
30
+ };
31
+ });
32
+
33
+ // Rebuilds with the same kernel and cleans up at its end — every solid fact
34
+ // above is already read, so this is safe.
35
+ const canIntersect = built.length > 0 && typeof built[0].solid.intersect === "function";
36
+ const overlaps = canIntersect ? assemblyOverlaps(kernel, part, view, params) : [];
37
+ kernel.cleanup?.();
38
+
39
+ const aggregate = {
40
+ bbox: subparts.length ? size(unionBounds(subBounds)) : [0, 0, 0],
41
+ volume: subparts.reduce((a, s) => a + s.volume, 0),
42
+ surfaceArea: subparts.reduce((a, s) => a + s.surfaceArea, 0),
43
+ triangleCount: subparts.reduce((a, s) => a + s.triangleCount, 0),
44
+ };
45
+ return {
46
+ part: part.meta?.title ?? view,
47
+ view,
48
+ subparts,
49
+ aggregate,
50
+ overlaps,
51
+ ok: subparts.every((s) => s.watertight !== false) && overlaps.length === 0,
52
+ };
53
+ }
@@ -19,3 +19,30 @@ export function bboxSize(positions) {
19
19
  }
20
20
  return [hi[0] - lo[0], hi[1] - lo[1], hi[2] - lo[2]];
21
21
  }
22
+
23
+ // Axis-aligned bounds of a flat position array (x,y,z per vertex).
24
+ export function bounds(positions) {
25
+ const min = [Infinity, Infinity, Infinity], max = [-Infinity, -Infinity, -Infinity];
26
+ for (let i = 0; i < positions.length; i += 3) for (let a = 0; a < 3; a++) {
27
+ const v = positions[i + a];
28
+ if (v < min[a]) min[a] = v;
29
+ if (v > max[a]) max[a] = v;
30
+ }
31
+ return { min, max };
32
+ }
33
+
34
+ // Surface area (mm²) of a triangle mesh. `indices` is optional: when omitted the
35
+ // positions are a non-indexed soup (3 consecutive verts per triangle, Manifold);
36
+ // when given, positions is a vertex array indexed by triangle (OCCT/replicad).
37
+ export function meshArea(positions, indices) {
38
+ let area = 0;
39
+ const n = indices ? indices.length : positions.length / 3;
40
+ for (let i = 0; i < n; i += 3) {
41
+ const a = (indices ? indices[i] : i) * 3, b = (indices ? indices[i + 1] : i + 1) * 3, c = (indices ? indices[i + 2] : i + 2) * 3;
42
+ const ux = positions[b] - positions[a], uy = positions[b + 1] - positions[a + 1], uz = positions[b + 2] - positions[a + 2];
43
+ const vx = positions[c] - positions[a], vy = positions[c + 1] - positions[a + 1], vz = positions[c + 2] - positions[a + 2];
44
+ const nx = uy * vz - uz * vy, ny = uz * vx - ux * vz, nz = ux * vy - uy * vx;
45
+ area += Math.hypot(nx, ny, nz) / 2;
46
+ }
47
+ return area;
48
+ }
@@ -0,0 +1,159 @@
1
+ import { writeFileSync, mkdirSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { buildView } from "./build.js";
4
+ import { bounds } from "./mesh.js";
5
+
6
+ // Canonical view directions in MODEL space (Z-up). `dir` is the direction from
7
+ // the part centre toward the camera; `up` is the camera up vector.
8
+ const ANGLES = {
9
+ iso: { dir: [1, 1, 1], up: [0, 0, 1] },
10
+ front: { dir: [0, -1, 0], up: [0, 0, 1] },
11
+ top: { dir: [0, 0, 1], up: [0, 1, 0] },
12
+ };
13
+
14
+ const slug = (s) => String(s).toLowerCase().replace(/\s+/g, "-");
15
+ const sub = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
16
+ const cross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
17
+ const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
18
+ const norm = (a) => { const l = Math.hypot(a[0], a[1], a[2]) || 1; return [a[0] / l, a[1] / l, a[2] / l]; };
19
+
20
+ // Render canonical-angle PNGs of one view of a part with a pure-JS software
21
+ // rasterizer (orthographic, z-buffered, Lambert-shaded, with depth-tested edge
22
+ // overlays). No native module, no browser. Returns the written file paths.
23
+ // pngjs is lazy-imported so importing the testing barrel for measure never loads it.
24
+ export async function renderViews(kernel, part, view = Object.keys(part.views)[0], {
25
+ views = ["iso", "front", "top"], out = "render", size = [800, 600], edges = true, params = {},
26
+ } = {}) {
27
+ const { PNG } = await import("pngjs");
28
+ const [W, H] = size;
29
+ const meshes = buildView(kernel, part, view, params).map((b) => b.mesh); // copied out
30
+
31
+ // scene bounds over all sub-parts (positions are JS-owned; safe after cleanup)
32
+ const lo = [Infinity, Infinity, Infinity], hi = [-Infinity, -Infinity, -Infinity];
33
+ for (const m of meshes) {
34
+ const b = bounds(m.positions);
35
+ for (let i = 0; i < 3; i++) { lo[i] = Math.min(lo[i], b.min[i]); hi[i] = Math.max(hi[i], b.max[i]); }
36
+ }
37
+ kernel.cleanup?.();
38
+
39
+ const center = [(lo[0] + hi[0]) / 2, (lo[1] + hi[1]) / 2, (lo[2] + hi[2]) / 2];
40
+ const radius = Math.max(hi[0] - lo[0], hi[1] - lo[1], hi[2] - lo[2]) / 2 || 5;
41
+
42
+ const bg = [0x15, 0x18, 0x1d], base = [0x9f, 0xb4, 0xcc], edgeColor = [0x1c, 0x23, 0x2d];
43
+ const light = norm([0.4, 0.5, 0.8]); // world-space key direction (toward the light)
44
+ const ambient = 0.35, diffuse = 0.75;
45
+ const bias = radius * 0.02; // edge depth bias so visible edges win ties
46
+
47
+ mkdirSync(out, { recursive: true });
48
+ const name = slug(part.meta?.title ?? view);
49
+ const written = [];
50
+
51
+ for (const angle of views) {
52
+ const a = ANGLES[angle];
53
+ if (!a) throw new Error(`unknown angle "${angle}" (use: ${Object.keys(ANGLES).join(", ")})`);
54
+ // orthographic camera basis: zc toward camera, xc right, yc up
55
+ const zc = norm(a.dir), xc = norm(cross(a.up, zc)), yc = cross(zc, xc);
56
+ const ppu = Math.min(W, H) / (2 * radius * 1.25); // pixels per mm (uniform; margin)
57
+ const project = (p) => {
58
+ const r = sub(p, center);
59
+ return [W / 2 + dot(r, xc) * ppu, H / 2 - dot(r, yc) * ppu, dot(r, zc)]; // [sx, sy, depth]
60
+ };
61
+
62
+ const color = new Uint8Array(W * H * 3);
63
+ for (let i = 0; i < W * H; i++) { color[i * 3] = bg[0]; color[i * 3 + 1] = bg[1]; color[i * 3 + 2] = bg[2]; }
64
+ const zbuf = new Float32Array(W * H).fill(-Infinity); // larger depth = nearer camera
65
+
66
+ for (const m of meshes) {
67
+ const P = m.positions, N = m.normals, ind = m.indices;
68
+ // Manifold meshes are a non-indexed soup (3 consecutive verts/triangle) with
69
+ // per-vertex normals; OCCT meshes are indexed and carry no normals.
70
+ const triCount = ind?.length ? ind.length / 3 : P.length / 9;
71
+ for (let t = 0; t < triCount; t++) {
72
+ const ai = ind?.length ? ind[t * 3] * 3 : t * 9;
73
+ const bi = ind?.length ? ind[t * 3 + 1] * 3 : t * 9 + 3;
74
+ const ci = ind?.length ? ind[t * 3 + 2] * 3 : t * 9 + 6;
75
+ const va = [P[ai], P[ai + 1], P[ai + 2]], vb = [P[bi], P[bi + 1], P[bi + 2]], vc = [P[ci], P[ci + 1], P[ci + 2]];
76
+ const sp = [va, vb, vc].map(project);
77
+ let inten;
78
+ if (N?.length) {
79
+ // per-vertex normals (same layout/offset as positions)
80
+ inten = [ai, bi, ci].map((o) =>
81
+ Math.min(1, ambient + diffuse * Math.max(0, N[o] * light[0] + N[o + 1] * light[1] + N[o + 2] * light[2])));
82
+ } else {
83
+ // no normals → flat face normal, two-sided so it lights regardless of winding
84
+ const ux = vb[0] - va[0], uy = vb[1] - va[1], uz = vb[2] - va[2];
85
+ const wx = vc[0] - va[0], wy = vc[1] - va[1], wz = vc[2] - va[2];
86
+ const nx = uy * wz - uz * wy, ny = uz * wx - ux * wz, nz = ux * wy - uy * wx;
87
+ const L = Math.hypot(nx, ny, nz) || 1;
88
+ const I0 = Math.min(1, ambient + diffuse * Math.abs((nx * light[0] + ny * light[1] + nz * light[2]) / L));
89
+ inten = [I0, I0, I0];
90
+ }
91
+ rasterTri(sp, inten, base, color, zbuf, W, H);
92
+ }
93
+ }
94
+
95
+ if (edges) {
96
+ for (const m of meshes) {
97
+ const E = m.edges;
98
+ if (!E?.length) continue;
99
+ for (let i = 0; i < E.length; i += 6)
100
+ drawLine(project([E[i], E[i + 1], E[i + 2]]), project([E[i + 3], E[i + 4], E[i + 5]]), edgeColor, color, zbuf, W, H, bias);
101
+ }
102
+ }
103
+
104
+ const png = new PNG({ width: W, height: H });
105
+ for (let i = 0; i < W * H; i++) {
106
+ png.data[i * 4] = color[i * 3]; png.data[i * 4 + 1] = color[i * 3 + 1]; png.data[i * 4 + 2] = color[i * 3 + 2]; png.data[i * 4 + 3] = 255;
107
+ }
108
+ const file = join(out, `${name}-${view}-${angle}.png`);
109
+ writeFileSync(file, PNG.sync.write(png));
110
+ written.push(file);
111
+ }
112
+ return written;
113
+ }
114
+
115
+ // signed area of the 2-D edge from a to b evaluated at p (for barycentric coords)
116
+ const edgeFn = (a, b, p) => (b[0] - a[0]) * (p[1] - a[1]) - (b[1] - a[1]) * (p[0] - a[0]);
117
+
118
+ // Fill one projected triangle, z-buffered, with Gouraud-interpolated Lambert shading.
119
+ function rasterTri(sp, inten, base, color, zbuf, W, H) {
120
+ const [a, b, c] = sp;
121
+ const area = edgeFn(a, b, c);
122
+ if (Math.abs(area) < 1e-9) return;
123
+ const minX = Math.max(0, Math.floor(Math.min(a[0], b[0], c[0])));
124
+ const maxX = Math.min(W - 1, Math.ceil(Math.max(a[0], b[0], c[0])));
125
+ const minY = Math.max(0, Math.floor(Math.min(a[1], b[1], c[1])));
126
+ const maxY = Math.min(H - 1, Math.ceil(Math.max(a[1], b[1], c[1])));
127
+ for (let y = minY; y <= maxY; y++) {
128
+ for (let x = minX; x <= maxX; x++) {
129
+ const p = [x + 0.5, y + 0.5];
130
+ const w0 = edgeFn(b, c, p), w1 = edgeFn(c, a, p), w2 = edgeFn(a, b, p);
131
+ // inside if all the same sign (handle either winding from the projection)
132
+ if (!((w0 >= 0 && w1 >= 0 && w2 >= 0) || (w0 <= 0 && w1 <= 0 && w2 <= 0))) continue;
133
+ const l0 = w0 / area, l1 = w1 / area, l2 = w2 / area;
134
+ const depth = l0 * a[2] + l1 * b[2] + l2 * c[2];
135
+ const idx = y * W + x;
136
+ if (depth <= zbuf[idx]) continue;
137
+ zbuf[idx] = depth;
138
+ const I = l0 * inten[0] + l1 * inten[1] + l2 * inten[2];
139
+ color[idx * 3] = Math.min(255, base[0] * I);
140
+ color[idx * 3 + 1] = Math.min(255, base[1] * I);
141
+ color[idx * 3 + 2] = Math.min(255, base[2] * I);
142
+ }
143
+ }
144
+ }
145
+
146
+ // Depth-tested line for edge overlays: drawn only where it isn't behind the
147
+ // surface (within `bias`), so the silhouette and feature edges read as crisp lines.
148
+ function drawLine(p0, p1, col, color, zbuf, W, H, bias) {
149
+ const steps = Math.max(1, Math.ceil(Math.max(Math.abs(p1[0] - p0[0]), Math.abs(p1[1] - p0[1]))));
150
+ for (let s = 0; s <= steps; s++) {
151
+ const t = s / steps;
152
+ const x = Math.round(p0[0] + (p1[0] - p0[0]) * t), y = Math.round(p0[1] + (p1[1] - p0[1]) * t);
153
+ if (x < 0 || x >= W || y < 0 || y >= H) continue;
154
+ const depth = p0[2] + (p1[2] - p0[2]) * t;
155
+ const idx = y * W + x;
156
+ if (depth + bias < zbuf[idx]) continue; // occluded
157
+ color[idx * 3] = col[0]; color[idx * 3 + 1] = col[1]; color[idx * 3 + 2] = col[2];
158
+ }
159
+ }
package/src/testing.js CHANGED
@@ -6,3 +6,6 @@ export { handle, viewSubParts } from "./framework/jobs.js";
6
6
  export { assemblyOverlaps } from "./framework/assembly.js";
7
7
  export { bootOcctKernel } from "./testing/occt.js";
8
8
  export { meshVolume, bboxSize } from "./testing/mesh.js";
9
+ export { buildView } from "./testing/build.js";
10
+ export { measure } from "./testing/measure.js";
11
+ export { renderViews } from "./testing/render.js";