partforge 0.1.0 → 0.3.3
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.
- package/README.md +3 -1
- package/bin/cli.js +73 -0
- package/docs/AUTHORING-PARTS.md +182 -2
- package/package.json +12 -1
- package/src/app-filleted-box.js +7 -0
- package/src/filleted-box-worker.js +3 -0
- package/src/framework/app.css +26 -1
- package/src/framework/controls.js +153 -36
- package/src/framework/geometry/edge-selector.js +17 -0
- package/src/framework/geometry/errors.js +10 -0
- package/src/framework/geometry/face-selector.js +19 -0
- package/src/framework/geometry/kernel.js +5 -0
- package/src/framework/geometry/manifold-backend.js +21 -0
- package/src/framework/geometry/occt-backend.js +103 -2
- package/src/framework/geometry/polygon.js +103 -0
- package/src/framework/geometry/probe.js +50 -0
- package/src/framework/geometry-service.js +8 -10
- package/src/framework/jobs.js +13 -4
- package/src/framework/markdown.js +41 -0
- package/src/framework/mount.js +57 -13
- package/src/framework/param-deps.js +50 -0
- package/src/framework/view-state.js +55 -0
- package/src/framework/viewer.js +28 -2
- package/src/parts/demo.js +29 -11
- package/src/parts/filleted-box.js +46 -0
- package/src/testing/build.js +17 -0
- package/src/testing/measure.js +53 -0
- package/src/testing/mesh.js +27 -0
- package/src/testing/render.js +159 -0
- package/src/testing.js +3 -0
|
@@ -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";
|