partforge 0.136.1 → 0.137.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.
- package/docs/AUTHORING-PARTS.md +15 -7
- package/package.json +4 -2
- package/src/framework/contact-shadow-plane.js +30 -0
- package/src/framework/contact-shadow.js +83 -0
- package/src/framework/materials/physical.js +1 -4
- package/src/framework/materials/resolve.js +6 -0
- package/src/framework/mount.js +2 -1
- package/src/framework/renderStyles.js +146 -0
- package/src/framework/softrender/camera.js +38 -0
- package/src/framework/softrender/dfgLut.js +65 -0
- package/src/framework/softrender/edges.js +39 -0
- package/src/framework/softrender/index.js +136 -0
- package/src/framework/softrender/raster.js +78 -0
- package/src/framework/softrender/shade.js +96 -0
- package/src/framework/softrender/triangles.js +9 -0
- package/src/framework/style-camera.js +174 -0
- package/src/framework/viewer-lighting.js +11 -45
- package/src/framework/viewer.js +107 -76
- package/src/testing/render.js +40 -176
- package/src/testing.js +2 -1
- package/types/index.d.ts +2 -0
- package/types/testing.d.ts +27 -1
package/src/framework/viewer.js
CHANGED
|
@@ -25,31 +25,11 @@ import { addViewerLights, captureLightPoses, createCaptureLights, createHemisphe
|
|
|
25
25
|
import { makeCaptureCamera, recenteredView, captureDepthRange } from "./capture-frame.js";
|
|
26
26
|
import { CANONICAL_VIEWS, cameraPoseForView } from "./view-angles.js";
|
|
27
27
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
// canvas. Encode the transfer function ourselves. The 8-bit LUT loses precision only in
|
|
34
|
-
// the deepest shadows, which a quality-0.9 JPEG would not have preserved anyway.
|
|
35
|
-
const SRGB8 = (() => {
|
|
36
|
-
const table = new Uint8Array(256);
|
|
37
|
-
for (let i = 0; i < 256; i++) {
|
|
38
|
-
const l = i / 255;
|
|
39
|
-
table[i] = Math.round(255 * (l <= 0.0031308 ? 12.92 * l : 1.055 * l ** (1 / 2.4) - 0.055));
|
|
40
|
-
}
|
|
41
|
-
return table;
|
|
42
|
-
})();
|
|
43
|
-
|
|
44
|
-
// Linear RGBA bytes → sRGB, in place. Alpha is a coverage value, not a colour: untouched.
|
|
45
|
-
export function srgbEncodeInPlace(data) {
|
|
46
|
-
for (let i = 0; i < data.length; i += 4) {
|
|
47
|
-
data[i] = SRGB8[data[i]];
|
|
48
|
-
data[i + 1] = SRGB8[data[i + 1]];
|
|
49
|
-
data[i + 2] = SRGB8[data[i + 2]];
|
|
50
|
-
}
|
|
51
|
-
return data;
|
|
52
|
-
}
|
|
28
|
+
import { srgbEncodeInPlace, RENDER_STYLES, getRenderStyle, CAD_LIGHT_THEME, CAD_DARK_THEME } from "./renderStyles.js";
|
|
29
|
+
import { stylePose, modelToWorld } from "./style-camera.js";
|
|
30
|
+
import { contactShadowMask } from "./contact-shadow.js";
|
|
31
|
+
import { buildContactShadowPlane } from "./contact-shadow-plane.js";
|
|
32
|
+
export { srgbEncodeInPlace }; // was exported from here; keep the name importable
|
|
53
33
|
|
|
54
34
|
// Readback (a realistic capture's target) → linear floats: half-float bits,
|
|
55
35
|
// or linear bytes from the 8-bit fallback target.
|
|
@@ -80,7 +60,9 @@ export function halfFloatCaptureSupported({ readable, readType = null, halfFloat
|
|
|
80
60
|
// `renderer.renderOffscreen(pose)` does the GL work (temp camera → offscreen
|
|
81
61
|
// target → readback → JPEG data URL); injected so this is unit-testable without
|
|
82
62
|
// a GL context. The grid is hidden for the whole synchronous pass and restored.
|
|
83
|
-
|
|
63
|
+
// `poseFor(view)`, when given, places each view's camera in place of
|
|
64
|
+
// cameraPoseForView(view, bounds) — the viewer passes the cad style's fit.
|
|
65
|
+
export function captureViewsFromScene(viewNames, { renderer, liveCamera, grid, bounds, sceneBounds, hidden = [], poseFor }) {
|
|
84
66
|
const views = (viewNames?.length ? viewNames : ["iso", "front", "top"])
|
|
85
67
|
.filter((v) => CANONICAL_VIEWS.includes(v))
|
|
86
68
|
.slice(0, CANONICAL_VIEWS.length);
|
|
@@ -91,10 +73,11 @@ export function captureViewsFromScene(viewNames, { renderer, liveCamera, grid, b
|
|
|
91
73
|
for (const o of hidden) o.visible = false;
|
|
92
74
|
try {
|
|
93
75
|
return views.map((view) => {
|
|
94
|
-
const pose = cameraPoseForView(view, bounds);
|
|
95
|
-
// `bounds` frames (half the max extent, by cameraPoseForView's contract)
|
|
96
|
-
// `sceneBounds` is the sphere the depth planes
|
|
97
|
-
// hidden for this whole pass, so the part alone is
|
|
76
|
+
const pose = poseFor ? poseFor(view) : cameraPoseForView(view, bounds);
|
|
77
|
+
// `bounds` frames (half the max extent, by cameraPoseForView's contract)
|
|
78
|
+
// when there is no `poseFor`; `sceneBounds` is the sphere the depth planes
|
|
79
|
+
// must hold. The grid is hidden for this whole pass, so the part alone is
|
|
80
|
+
// in the second one.
|
|
98
81
|
return { view, dataUrl: renderer.renderOffscreen(pose, { sceneBounds }) };
|
|
99
82
|
});
|
|
100
83
|
} finally {
|
|
@@ -106,23 +89,12 @@ export function captureViewsFromScene(viewNames, { renderer, liveCamera, grid, b
|
|
|
106
89
|
|
|
107
90
|
// The off-loop thumbnail capture (renderMeshPayloads, behind the handle's
|
|
108
91
|
// captureView) renders a THROWAWAY scene, so it gets no background from the
|
|
109
|
-
// live scene's theme
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
|
|
115
|
-
// perceptual midpoint of THEME.light.bg / THEME.dark.bg, so it commits to
|
|
116
|
-
// neither, and clear of both the part material (0x9fb4cc, lighter) and the
|
|
117
|
-
// feature-edge lines (0x1c232d, much darker).
|
|
118
|
-
//
|
|
119
|
-
// The near-ZERO chroma is the part that looks arbitrary and isn't: the default
|
|
120
|
-
// part material is blue-grey, so a blue-grey background of the same value
|
|
121
|
-
// (0x6b7280 was the first try) competes with it and the shaded side of a part
|
|
122
|
-
// half-disappears into the plate. A neutral grey separates by hue as well as
|
|
123
|
-
// value. Judged on real captures of demo.js and hinged-box.js — if this is
|
|
124
|
-
// ever retuned, retune it the same way and not by eye on the hex.
|
|
125
|
-
export const THUMBNAIL_BG = 0x6e6e73;
|
|
92
|
+
// live scene's theme. Its background is the product-shot style's light
|
|
93
|
+
// background (renderStyles.js): fixed, and never following the viewer theme,
|
|
94
|
+
// because a thumbnail is baked at capture time and displayed later under host
|
|
95
|
+
// chrome this renderer cannot know. Retune it in renderStyles.js, on real
|
|
96
|
+
// captures against the CPU renderer's parity check, not by eye on the hex.
|
|
97
|
+
export const THUMBNAIL_BG = RENDER_STYLES.thumbnail.background;
|
|
126
98
|
|
|
127
99
|
// Resolve renderMeshPayloads' `background` option to what Scene.background
|
|
128
100
|
// wants. Exported for its own sake: renderMeshPayloads needs a GL context and
|
|
@@ -202,8 +174,8 @@ export function createViewer(container, part) {
|
|
|
202
174
|
// Light/dark scene palettes (the page chrome is themed separately, via CSS on the
|
|
203
175
|
// host page). A part can override the dark background through meta.background.
|
|
204
176
|
const THEME = {
|
|
205
|
-
dark: { bg: part.meta?.background ??
|
|
206
|
-
light: { bg:
|
|
177
|
+
dark: { bg: part.meta?.background ?? CAD_DARK_THEME.bg, grid: [0x2c333d, 0x222831], line: CAD_DARK_THEME.line },
|
|
178
|
+
light: { bg: CAD_LIGHT_THEME.bg, grid: [0xc4ccd6, 0xd6dce4], line: CAD_LIGHT_THEME.line },
|
|
207
179
|
};
|
|
208
180
|
scene.background = new THREE.Color(THEME.dark.bg);
|
|
209
181
|
|
|
@@ -272,9 +244,9 @@ export function createViewer(container, part) {
|
|
|
272
244
|
|
|
273
245
|
// --- material + part groups -----------------------------------------------
|
|
274
246
|
const material = new THREE.MeshStandardMaterial({
|
|
275
|
-
color:
|
|
276
|
-
metalness:
|
|
277
|
-
roughness:
|
|
247
|
+
color: RENDER_STYLES.cad.material.color,
|
|
248
|
+
metalness: RENDER_STYLES.cad.material.metalness,
|
|
249
|
+
roughness: RENDER_STYLES.cad.material.roughness,
|
|
278
250
|
flatShading: false,
|
|
279
251
|
polygonOffset: true, // push the surface back so edge lines sit cleanly on top
|
|
280
252
|
polygonOffsetFactor: 1,
|
|
@@ -490,6 +462,24 @@ export function createViewer(container, part) {
|
|
|
490
462
|
return _worldBounds;
|
|
491
463
|
}
|
|
492
464
|
|
|
465
|
+
// Every vertex of the visible sub-part meshes in WORLD space, flat xyz — what
|
|
466
|
+
// the canonical captures fit their framing to (getVisibleWorldBounds' meshes).
|
|
467
|
+
const _worldVertex = new THREE.Vector3();
|
|
468
|
+
function visibleWorldPositions() {
|
|
469
|
+
const meshes = Object.values(subMesh).filter((m) => m.visible && m.geometry?.attributes?.position);
|
|
470
|
+
const out = new Float32Array(meshes.reduce((n, m) => n + m.geometry.attributes.position.count * 3, 0));
|
|
471
|
+
let o = 0;
|
|
472
|
+
for (const mesh of meshes) {
|
|
473
|
+
mesh.updateWorldMatrix(true, false);
|
|
474
|
+
const pos = mesh.geometry.attributes.position;
|
|
475
|
+
for (let i = 0; i < pos.count; i++, o += 3) {
|
|
476
|
+
_worldVertex.fromBufferAttribute(pos, i).applyMatrix4(mesh.matrixWorld);
|
|
477
|
+
out[o] = _worldVertex.x; out[o + 1] = _worldVertex.y; out[o + 2] = _worldVertex.z;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
return out;
|
|
481
|
+
}
|
|
482
|
+
|
|
493
483
|
// --- depth range ------------------------------------------------------------
|
|
494
484
|
// The sphere the near/far planes are sized against: everything a render will
|
|
495
485
|
// actually draw, as `{ center, radius }`. `withGrid` is a parameter rather
|
|
@@ -1929,6 +1919,25 @@ export function createViewer(container, part) {
|
|
|
1929
1919
|
// blue wash over it (an orange PLA sample came out pink).
|
|
1930
1920
|
const reshowFlashDots = [];
|
|
1931
1921
|
for (const dot of [...flashDots, ...captureHidden]) if (dot.visible) { dot.visible = false; reshowFlashDots.push(dot); }
|
|
1922
|
+
// Fat lines (LineSegments2: part edges, their fade clones, the cutaway
|
|
1923
|
+
// outline) take their pixel width from material.resolution, and three's
|
|
1924
|
+
// LineSegments2.onBeforeRender resets that to the CANVAS viewport on every
|
|
1925
|
+
// draw — so a 1px edge in a 1024² capture came out 1024/canvas px wide, under
|
|
1926
|
+
// half a pixel on a high-DPI screen. For this render each visible line's hook
|
|
1927
|
+
// is a no-op and each material's resolution is the target's width × height.
|
|
1928
|
+
// With a viewOffset that is still width × height, not the full frame: the
|
|
1929
|
+
// offset projection maps just the sub-window onto clip space, and the line
|
|
1930
|
+
// shader converts its pixel width through clip space, so the pixels it
|
|
1931
|
+
// counts are the target's. Put back exactly in the finally below.
|
|
1932
|
+
const lineHooks = [];
|
|
1933
|
+
const lineRes = new Map();
|
|
1934
|
+
renderScene.traverseVisible((obj) => {
|
|
1935
|
+
if (!obj.isLineSegments2) return;
|
|
1936
|
+
lineHooks.push({ obj, own: Object.hasOwn(obj, "onBeforeRender"), hook: obj.onBeforeRender });
|
|
1937
|
+
obj.onBeforeRender = () => {};
|
|
1938
|
+
const m = obj.material;
|
|
1939
|
+
if (m?.isLineMaterial && !lineRes.has(m)) { lineRes.set(m, m.resolution.clone()); m.resolution.set(width, height); }
|
|
1940
|
+
});
|
|
1932
1941
|
try {
|
|
1933
1942
|
renderer.setRenderTarget(rt);
|
|
1934
1943
|
renderWithBackdrop(renderScene, cam);
|
|
@@ -1945,6 +1954,8 @@ export function createViewer(container, part) {
|
|
|
1945
1954
|
liveLights.fill.visible = fillWas;
|
|
1946
1955
|
}
|
|
1947
1956
|
for (const dot of reshowFlashDots) dot.visible = true;
|
|
1957
|
+
for (const { obj, own, hook } of lineHooks) { if (own) obj.onBeforeRender = hook; else delete obj.onBeforeRender; }
|
|
1958
|
+
for (const [m, res] of lineRes) m.resolution.copy(res);
|
|
1948
1959
|
if (!cachedSize) rt.dispose();
|
|
1949
1960
|
}
|
|
1950
1961
|
const canvas = document.createElement("canvas");
|
|
@@ -2010,18 +2021,21 @@ export function createViewer(container, part) {
|
|
|
2010
2021
|
if (!box || box.isEmpty()) return [];
|
|
2011
2022
|
const center = box.getCenter(new THREE.Vector3()).toArray();
|
|
2012
2023
|
const size = box.getSize(new THREE.Vector3());
|
|
2013
|
-
|
|
2024
|
+
// Framed like the CPU renderer frames the same views: the cad style's fit to
|
|
2025
|
+
// the visible geometry's WORLD positions (stylePose), so a part fills the
|
|
2026
|
+
// frame by its own silhouette rather than a fixed distance from its box.
|
|
2027
|
+
const styleBox = { min: box.min.toArray(), max: box.max.toArray() };
|
|
2028
|
+
const points = visibleWorldPositions();
|
|
2014
2029
|
return captureViewsFromScene(viewNames, {
|
|
2030
|
+
poseFor: (view) => stylePose(RENDER_STYLES.cad, view, styleBox, { aspect: 1, points }).pose,
|
|
2015
2031
|
renderer: { renderOffscreen },
|
|
2016
2032
|
// The live camera only to save/restore its position around the pass; the
|
|
2017
2033
|
// renders themselves pass no `projection`, so they stay perspective.
|
|
2018
2034
|
liveCamera: activeCamera,
|
|
2019
2035
|
grid,
|
|
2020
2036
|
hidden: [...canonicalCaptureHidden],
|
|
2021
|
-
|
|
2022
|
-
//
|
|
2023
|
-
// above: a box's corners reach √3 further than half its max extent, and
|
|
2024
|
-
// the depth planes have to clear the corners. A realistic capture keeps
|
|
2037
|
+
// The ENCLOSING radius the depth planes must clear (half the diagonal: the
|
|
2038
|
+
// box's corners, not just its faces). A realistic capture keeps
|
|
2025
2039
|
// its ground disc, which is several part radii across, so it counts too.
|
|
2026
2040
|
sceneBounds: (realisticRig && sceneDepthBounds()) || { center, radius: size.length() / 2 || 10 },
|
|
2027
2041
|
});
|
|
@@ -2176,19 +2190,21 @@ export function createViewer(container, part) {
|
|
|
2176
2190
|
|
|
2177
2191
|
// Offscreen render of an arbitrary mesh set (a non-active view), for thumbnails.
|
|
2178
2192
|
// Assembles a THROWAWAY scene mirroring the live pivot convention, frames it from a
|
|
2179
|
-
// canonical angle, renders through the parameterized renderOffscreen, and disposes
|
|
2193
|
+
// canonical angle (fitted to the payloads' geometry), renders through the parameterized renderOffscreen, and disposes
|
|
2180
2194
|
// everything. Never touches the live scene, camera, subMesh, or subCache. The scene
|
|
2181
|
-
//
|
|
2195
|
+
// renders the `thumbnail` style unless `style` says otherwise (the style's
|
|
2196
|
+
// background unless `background` says otherwise; `null` = no background, the
|
|
2182
2197
|
// renderer's clear colour). `payloads` is the worker's [{name, positions, normals,
|
|
2183
2198
|
// indices, …}] array — placement is already baked into shared-frame coords, so
|
|
2184
2199
|
// meshes are NOT recentred.
|
|
2185
|
-
function renderMeshPayloads(payloads, { angle = "iso", size = 640, quality = 0.8, background } = {}) {
|
|
2200
|
+
function renderMeshPayloads(payloads, { angle = "iso", size = 640, quality = 0.8, background, style = "thumbnail" } = {}) {
|
|
2186
2201
|
if (disposed) return null; // same guard as captureCurrent/captureCanonicalViews — never touch a torn-down renderer
|
|
2202
|
+
const st = getRenderStyle(style);
|
|
2187
2203
|
const tmpScene = new THREE.Scene();
|
|
2188
2204
|
// Deliberately the throwaway scene's own background, never the live one's:
|
|
2189
2205
|
// this must not follow the viewer theme (see THUMBNAIL_BG) and must not
|
|
2190
2206
|
// reach the live-scene captures, which correctly do follow it.
|
|
2191
|
-
tmpScene.background = thumbnailBackground(background);
|
|
2207
|
+
tmpScene.background = thumbnailBackground(background === undefined ? st.background : background);
|
|
2192
2208
|
const tmpPivot = new THREE.Group();
|
|
2193
2209
|
tmpPivot.rotation.x = -Math.PI / 2; // model Z (CAD up) -> vertical, same as live pivot
|
|
2194
2210
|
tmpScene.add(tmpPivot);
|
|
@@ -2205,17 +2221,21 @@ export function createViewer(container, part) {
|
|
|
2205
2221
|
// coords but rendered rotated by tmpPivot, so a model-space bbox centre would aim
|
|
2206
2222
|
// the camera at the wrong point — an off-origin part would render off-centre or blank.
|
|
2207
2223
|
tmpPivot.updateMatrixWorld(true);
|
|
2208
|
-
const
|
|
2209
|
-
const
|
|
2210
|
-
|
|
2211
|
-
|
|
2224
|
+
const b3 = new THREE.Box3().setFromObject(tmpPivot);
|
|
2225
|
+
const box = b3.isEmpty() ? null : { min: b3.min.toArray(), max: b3.max.toArray() };
|
|
2226
|
+
// The same world positions frame the view (fit to the geometry, not its box)
|
|
2227
|
+
// and cast the contact shadow below.
|
|
2228
|
+
const worldPositions = payloads.map((p) => modelToWorld(p.positions));
|
|
2229
|
+
const points = new Float32Array(worldPositions.reduce((n, a) => n + a.length, 0));
|
|
2230
|
+
worldPositions.reduce((o, a) => { points.set(a, o); return o + a.length; }, 0);
|
|
2231
|
+
const { pose, fov, sceneBounds } = stylePose(st, angle, box, { aspect: 1, points });
|
|
2212
2232
|
|
|
2213
2233
|
// Light the throwaway scene ourselves: renderOffscreen's own key/fill (and the
|
|
2214
2234
|
// persistent hemisphere) live in the LIVE scene, which is never rendered here — so
|
|
2215
2235
|
// without our own ambient + camera-relative key/fill it comes back near-black.
|
|
2216
|
-
const hemi = createHemisphereLight();
|
|
2217
|
-
const capLights = createCaptureLights();
|
|
2218
|
-
const poses = captureLightPoses(pose);
|
|
2236
|
+
const hemi = createHemisphereLight(st.lights);
|
|
2237
|
+
const capLights = createCaptureLights(st.lights);
|
|
2238
|
+
const poses = captureLightPoses(pose, st.lights);
|
|
2219
2239
|
capLights.key.position.set(poses.key[0], poses.key[1], poses.key[2]);
|
|
2220
2240
|
capLights.fill.position.set(poses.fill[0], poses.fill[1], poses.fill[2]);
|
|
2221
2241
|
for (const light of [capLights.key, capLights.fill]) {
|
|
@@ -2224,25 +2244,35 @@ export function createViewer(container, part) {
|
|
|
2224
2244
|
tmpScene.add(hemi, capLights.key, capLights.key.target, capLights.fill, capLights.fill.target);
|
|
2225
2245
|
|
|
2226
2246
|
// Feature-edge lines, so the thumbnail carries the same hole/seam/chamfer outlines the
|
|
2227
|
-
// live viewer shows. A dedicated LineMaterial
|
|
2228
|
-
//
|
|
2229
|
-
|
|
2230
|
-
lineMat.
|
|
2247
|
+
// live viewer shows. A dedicated LineMaterial in the style's colour and width
|
|
2248
|
+
// (renderOffscreen sizes its resolution to the target, as for every capture); added
|
|
2249
|
+
// after framing so it can't perturb the bbox.
|
|
2250
|
+
const lineMat = new LineMaterial({ color: st.edges.color, linewidth: st.edges.widthPx, transparent: st.edges.opacity < 1, opacity: st.edges.opacity });
|
|
2231
2251
|
for (const mesh of built) {
|
|
2232
2252
|
const edges = mesh.geometry.userData.edges;
|
|
2233
|
-
if (edges)
|
|
2253
|
+
if (!edges) continue;
|
|
2254
|
+
tmpPivot.add(new LineSegments2(edges, lineMat));
|
|
2255
|
+
}
|
|
2256
|
+
|
|
2257
|
+
// The product shot's contact shadow, the same mask the CPU renderer samples.
|
|
2258
|
+
// Only from above: a camera under the floor would see the shadow's underside.
|
|
2259
|
+
let shadowPlane = null;
|
|
2260
|
+
if (st.shadow && box && pose.position[1] > box.min[1]) {
|
|
2261
|
+
const worldMeshes = payloads.map((p, i) => ({ positions: worldPositions[i], indices: p.indices }));
|
|
2262
|
+
const mask = contactShadowMask(worldMeshes, box, st.shadow);
|
|
2263
|
+
if (mask) { shadowPlane = buildContactShadowPlane(mask, st.shadow); tmpScene.add(shadowPlane); }
|
|
2234
2264
|
}
|
|
2235
2265
|
|
|
2236
2266
|
try {
|
|
2237
2267
|
// fov comes from the PERSPECTIVE camera, deliberately, not from whichever
|
|
2238
2268
|
// camera is live: thumbnails are canonical captures and stay perspective
|
|
2239
|
-
// whichever projection is live.
|
|
2240
|
-
//
|
|
2269
|
+
// whichever projection is live. The fit (stylePose) solves its distance
|
|
2270
|
+
// for the style's own fov, and the render must use that same fov.
|
|
2241
2271
|
return renderOffscreen(
|
|
2242
2272
|
pose,
|
|
2243
2273
|
// The throwaway scene holds these meshes and nothing else — no grid, no
|
|
2244
2274
|
// gizmo — so its own bounds are the whole of what the planes must hold.
|
|
2245
|
-
{ width: size, height: size, fov
|
|
2275
|
+
{ width: size, height: size, fov, quality, sceneBounds },
|
|
2246
2276
|
tmpScene,
|
|
2247
2277
|
);
|
|
2248
2278
|
} finally {
|
|
@@ -2252,6 +2282,7 @@ export function createViewer(container, part) {
|
|
|
2252
2282
|
if (mesh.material !== material) mesh.material.dispose(); // clone only — never the shared singleton
|
|
2253
2283
|
}
|
|
2254
2284
|
lineMat.dispose();
|
|
2285
|
+
if (shadowPlane) { shadowPlane.geometry.dispose(); shadowPlane.material.alphaMap.dispose(); shadowPlane.material.dispose(); }
|
|
2255
2286
|
hemi.dispose?.();
|
|
2256
2287
|
capLights.key.dispose?.();
|
|
2257
2288
|
capLights.fill.dispose?.();
|
package/src/testing/render.js
CHANGED
|
@@ -3,7 +3,8 @@ import { join, resolve, sep } from "node:path";
|
|
|
3
3
|
import { safeName } from "../framework/safe-name.js";
|
|
4
4
|
import { ORIENTATIONS } from "../framework/view-angles.js";
|
|
5
5
|
import { buildView } from "../framework/oracle/build.js";
|
|
6
|
-
import {
|
|
6
|
+
import { renderStyled } from "../framework/softrender/index.js";
|
|
7
|
+
import { getRenderStyle } from "../framework/renderStyles.js";
|
|
7
8
|
|
|
8
9
|
// Canonical view directions in MODEL space (Z-up). `dir` is the direction from
|
|
9
10
|
// the part centre toward the camera; `up` is the camera up vector.
|
|
@@ -35,34 +36,28 @@ export function renderAngle(name) {
|
|
|
35
36
|
return { dir: toModel(o.dir), up: toModel(o.up) };
|
|
36
37
|
}
|
|
37
38
|
|
|
38
|
-
const sub = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
|
|
39
|
-
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]];
|
|
40
|
-
const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
|
|
41
|
-
const norm = (a) => { const l = Math.hypot(a[0], a[1], a[2]) || 1; return [a[0] / l, a[1] / l, a[2] / l]; };
|
|
42
39
|
|
|
43
|
-
// Render canonical-angle PNGs of one view of a part
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
40
|
+
// Render canonical-angle PNGs of one view of a part, in memory, through the
|
|
41
|
+
// styled CPU renderer (framework/softrender) — the same look the viewer's
|
|
42
|
+
// offscreen captures have (`style`: "cad" for the agent's renders, "thumbnail"
|
|
43
|
+
// for the product shot). No native module, no browser. pngjs is lazy-imported
|
|
44
|
+
// so importing the testing barrel for measure never loads it.
|
|
47
45
|
//
|
|
48
|
-
// `opacity` is a Record<subPartName, number> (an animation's evaluate()
|
|
49
|
-
// typically): a sub-part at 0
|
|
50
|
-
// still counts toward the
|
|
51
|
-
// reframe the still.
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
// needs back-to-front sorting this rasterizer does not do. Stills only need to
|
|
58
|
-
// read as faded, so the approximation is the contract, not a stopgap.
|
|
59
|
-
export async function renderViews(kernel, part, view = Object.keys(part.views)[0], {
|
|
60
|
-
views = ["iso", "front", "top"], out = "render", size = [800, 600], edges = true, params = {}, tag = "",
|
|
61
|
-
opacity = {},
|
|
46
|
+
// `opacity` is a Record<subPartName, number> (an animation's evaluate()
|
|
47
|
+
// output, typically): a sub-part at 0 draws nothing — faces, edges, shadow —
|
|
48
|
+
// but still counts toward the FRAMING, so a part crossing 0 cannot silently
|
|
49
|
+
// reframe the still. Values in (0,1) PRE-BLEND toward the background: a faded
|
|
50
|
+
// part still occludes what is behind it (real transparency needs a depth sort
|
|
51
|
+
// this renderer does not do; stills only need to read as faded).
|
|
52
|
+
export async function renderViewImages(kernel, part, view = Object.keys(part.views)[0], {
|
|
53
|
+
views = ["iso", "front", "top"], size = [800, 600], edges = true, params = {}, opacity = {},
|
|
54
|
+
style = "cad", supersample = 2,
|
|
62
55
|
} = {}) {
|
|
63
56
|
const { PNG } = await import("pngjs");
|
|
64
|
-
|
|
65
|
-
|
|
57
|
+
getRenderStyle(style); // a bad style fails here, before the kernel build
|
|
58
|
+
for (const angle of views) {
|
|
59
|
+
if (!renderAngle(angle)) throw new Error(`unknown angle "${angle}" (use: ${RENDER_VIEWS.join(", ")}, or a view-cube orientation such as top-front-left)`);
|
|
60
|
+
}
|
|
66
61
|
// Own-key lookups only — a part named "constructor" must not inherit a value
|
|
67
62
|
// off Object.prototype and vanish from the render.
|
|
68
63
|
const opacityOf = (name) => {
|
|
@@ -70,165 +65,34 @@ export async function renderViews(kernel, part, view = Object.keys(part.views)[0
|
|
|
70
65
|
const v = Number(opacity[name]);
|
|
71
66
|
return Number.isFinite(v) ? Math.min(1, Math.max(0, v)) : 1; // a junk value renders solid
|
|
72
67
|
};
|
|
73
|
-
const
|
|
74
|
-
.map((b) => ({ name: b.name, mesh: b.mesh }));
|
|
75
|
-
|
|
76
|
-
// Scene bounds over EVERY built sub-part, visible or not (positions are
|
|
77
|
-
// JS-owned; safe after cleanup). Opacity is deliberately NOT consulted here —
|
|
78
|
-
// see the note above on why a fade must not move the camera.
|
|
79
|
-
const lo = [Infinity, Infinity, Infinity], hi = [-Infinity, -Infinity, -Infinity];
|
|
80
|
-
for (const { mesh: m } of built) {
|
|
81
|
-
const b = bounds(m.positions);
|
|
82
|
-
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]); }
|
|
83
|
-
}
|
|
84
|
-
// kernel.cleanup() walks the backend's own tracked list, not this array, so
|
|
85
|
-
// dropping the hidden sub-parts afterwards frees nothing and skips nothing.
|
|
68
|
+
const subparts = buildView(kernel, part, view, params) // meshes are JS-owned: safe after cleanup
|
|
69
|
+
.map((b) => ({ name: b.name, mesh: b.mesh, display: part.parts?.[b.name]?.display, opacity: opacityOf(b.name) }));
|
|
86
70
|
kernel.cleanup?.();
|
|
71
|
+
return views.map((angle) => {
|
|
72
|
+
const img = renderStyled(subparts, { view: angle, style, size, supersample, edges });
|
|
73
|
+
const png = new PNG({ width: img.width, height: img.height });
|
|
74
|
+
png.data.set(img.rgba);
|
|
75
|
+
return { angle, png: PNG.sync.write(png) };
|
|
76
|
+
});
|
|
77
|
+
}
|
|
87
78
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
const
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
const bg = [0x15, 0x18, 0x1d], base = [0x9f, 0xb4, 0xcc], edgeColor = [0x1c, 0x23, 0x2d];
|
|
95
|
-
const ambient = 0.35, diffuse = 0.75;
|
|
96
|
-
const bias = radius * 0.02; // edge depth bias so visible edges win ties
|
|
97
|
-
|
|
79
|
+
// renderViewImages, written to disk as `<title>-<view>-<angle>[-<tag>].png`
|
|
80
|
+
// under `out`. Returns the written paths (relative to `out`, as the CLI echoes them).
|
|
81
|
+
export async function renderViews(kernel, part, view = Object.keys(part.views)[0], opts = {}) {
|
|
82
|
+
const { out = "render", tag = "", ...rest } = opts;
|
|
83
|
+
const images = await renderViewImages(kernel, part, view, rest);
|
|
98
84
|
// `out` is operator-supplied (a CLI flag) and stays verbatim; the part-derived
|
|
99
|
-
// title
|
|
85
|
+
// title, view key and frame tag are sanitized — the one place a part's
|
|
100
86
|
// strings reach the filesystem.
|
|
101
87
|
const outDir = resolve(out);
|
|
102
88
|
mkdirSync(outDir, { recursive: true });
|
|
103
89
|
const name = safeName(part.meta?.title ?? view);
|
|
104
90
|
const viewName = safeName(view);
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
for (const angle of views) {
|
|
108
|
-
const a = renderAngle(angle);
|
|
109
|
-
if (!a) throw new Error(`unknown angle "${angle}" (use: ${RENDER_VIEWS.join(", ")}, or a view-cube orientation such as top-front-left)`);
|
|
110
|
-
// orthographic camera basis: zc toward camera, xc right, yc up
|
|
111
|
-
const zc = norm(a.dir), xc = norm(cross(a.up, zc)), yc = cross(zc, xc);
|
|
112
|
-
// Key direction (toward the light), placed over the viewer's shoulder — up and to
|
|
113
|
-
// the right of the view axis — so every angle is lit and shaded. A world-fixed key
|
|
114
|
-
// would leave whichever face the camera happens to be looking at in flat ambient:
|
|
115
|
-
// that is what made `bottom` a featureless disc, and the browser viewer's offscreen
|
|
116
|
-
// captures had the same bug (framework/viewer-lighting.js captureLightPoses).
|
|
117
|
-
const light = norm([0, 1, 2].map((i) => zc[i] + 0.45 * xc[i] + 0.75 * yc[i]));
|
|
118
|
-
const ppu = Math.min(W, H) / (2 * radius * 1.25); // pixels per mm (uniform; margin)
|
|
119
|
-
const project = (p) => {
|
|
120
|
-
const r = sub(p, center);
|
|
121
|
-
return [W / 2 + dot(r, xc) * ppu, H / 2 - dot(r, yc) * ppu, dot(r, zc)]; // [sx, sy, depth]
|
|
122
|
-
};
|
|
123
|
-
|
|
124
|
-
const color = new Uint8Array(W * H * 3);
|
|
125
|
-
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]; }
|
|
126
|
-
const zbuf = new Float32Array(W * H).fill(-Infinity); // larger depth = nearer camera
|
|
127
|
-
|
|
128
|
-
for (const { name, mesh: m } of meshes) {
|
|
129
|
-
// Pre-blend toward the background: the fade is baked into the material
|
|
130
|
-
// colour before shading, so no per-pixel compositing (and no depth sort)
|
|
131
|
-
// is needed. See the note on renderViews for why that is enough here.
|
|
132
|
-
const v = opacityOf(name);
|
|
133
|
-
const faded = v < 1 ? base.map((c, i) => Math.round(c * v + bg[i] * (1 - v))) : base;
|
|
134
|
-
const P = m.positions, N = m.normals, ind = m.indices;
|
|
135
|
-
// Manifold meshes are a non-indexed soup (3 consecutive verts/triangle);
|
|
136
|
-
// OCCT meshes are indexed. Both carry per-vertex normals.
|
|
137
|
-
const triCount = ind?.length ? ind.length / 3 : P.length / 9;
|
|
138
|
-
for (let t = 0; t < triCount; t++) {
|
|
139
|
-
const ai = ind?.length ? ind[t * 3] * 3 : t * 9;
|
|
140
|
-
const bi = ind?.length ? ind[t * 3 + 1] * 3 : t * 9 + 3;
|
|
141
|
-
const ci = ind?.length ? ind[t * 3 + 2] * 3 : t * 9 + 6;
|
|
142
|
-
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]];
|
|
143
|
-
const sp = [va, vb, vc].map(project);
|
|
144
|
-
let inten;
|
|
145
|
-
if (N?.length) {
|
|
146
|
-
// per-vertex normals (same layout/offset as positions)
|
|
147
|
-
inten = [ai, bi, ci].map((o) =>
|
|
148
|
-
Math.min(1, ambient + diffuse * Math.max(0, N[o] * light[0] + N[o + 1] * light[1] + N[o + 2] * light[2])));
|
|
149
|
-
} else {
|
|
150
|
-
// no normals → flat face normal, two-sided so it lights regardless of winding
|
|
151
|
-
const ux = vb[0] - va[0], uy = vb[1] - va[1], uz = vb[2] - va[2];
|
|
152
|
-
const wx = vc[0] - va[0], wy = vc[1] - va[1], wz = vc[2] - va[2];
|
|
153
|
-
const nx = uy * wz - uz * wy, ny = uz * wx - ux * wz, nz = ux * wy - uy * wx;
|
|
154
|
-
const L = Math.hypot(nx, ny, nz) || 1;
|
|
155
|
-
const I0 = Math.min(1, ambient + diffuse * Math.abs((nx * light[0] + ny * light[1] + nz * light[2]) / L));
|
|
156
|
-
inten = [I0, I0, I0];
|
|
157
|
-
}
|
|
158
|
-
rasterTri(sp, inten, faded, color, zbuf, W, H);
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
if (edges) {
|
|
163
|
-
for (const { name, mesh: m } of meshes) {
|
|
164
|
-
const E = m.edges;
|
|
165
|
-
if (!E?.length) continue;
|
|
166
|
-
const v = opacityOf(name);
|
|
167
|
-
const fadedEdge = v < 1 ? edgeColor.map((c, i) => Math.round(c * v + bg[i] * (1 - v))) : edgeColor;
|
|
168
|
-
for (let i = 0; i < E.length; i += 6)
|
|
169
|
-
drawLine(project([E[i], E[i + 1], E[i + 2]]), project([E[i + 3], E[i + 4], E[i + 5]]), fadedEdge, color, zbuf, W, H, bias);
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
const png = new PNG({ width: W, height: H });
|
|
174
|
-
for (let i = 0; i < W * H; i++) {
|
|
175
|
-
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;
|
|
176
|
-
}
|
|
177
|
-
// The animation frame tag goes through safeName() as well — one rule for
|
|
178
|
-
// every string that reaches a filename here.
|
|
91
|
+
return images.map(({ angle, png }) => {
|
|
179
92
|
const file = join(out, `${name}-${viewName}-${angle}${tag ? `-${safeName(tag)}` : ""}.png`);
|
|
180
|
-
// Belt and braces over safeName(): assert the escape never happened
|
|
181
|
-
// than trusting the slug, because a miss here writes bytes to disk. (The
|
|
182
|
-
// returned paths stay relative to `out` — the CLI echoes them.)
|
|
93
|
+
// Belt and braces over safeName(): assert the escape never happened.
|
|
183
94
|
if (!resolve(file).startsWith(outDir + sep)) throw new Error(`renderViews: refusing to write outside ${out}`);
|
|
184
|
-
writeFileSync(file,
|
|
185
|
-
|
|
186
|
-
}
|
|
187
|
-
return written;
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
// signed area of the 2-D edge from a to b evaluated at p (for barycentric coords)
|
|
191
|
-
const edgeFn = (a, b, p) => (b[0] - a[0]) * (p[1] - a[1]) - (b[1] - a[1]) * (p[0] - a[0]);
|
|
192
|
-
|
|
193
|
-
// Fill one projected triangle, z-buffered, with Gouraud-interpolated Lambert shading.
|
|
194
|
-
function rasterTri(sp, inten, base, color, zbuf, W, H) {
|
|
195
|
-
const [a, b, c] = sp;
|
|
196
|
-
const area = edgeFn(a, b, c);
|
|
197
|
-
if (Math.abs(area) < 1e-9) return;
|
|
198
|
-
const minX = Math.max(0, Math.floor(Math.min(a[0], b[0], c[0])));
|
|
199
|
-
const maxX = Math.min(W - 1, Math.ceil(Math.max(a[0], b[0], c[0])));
|
|
200
|
-
const minY = Math.max(0, Math.floor(Math.min(a[1], b[1], c[1])));
|
|
201
|
-
const maxY = Math.min(H - 1, Math.ceil(Math.max(a[1], b[1], c[1])));
|
|
202
|
-
for (let y = minY; y <= maxY; y++) {
|
|
203
|
-
for (let x = minX; x <= maxX; x++) {
|
|
204
|
-
const p = [x + 0.5, y + 0.5];
|
|
205
|
-
const w0 = edgeFn(b, c, p), w1 = edgeFn(c, a, p), w2 = edgeFn(a, b, p);
|
|
206
|
-
// inside if all the same sign (handle either winding from the projection)
|
|
207
|
-
if (!((w0 >= 0 && w1 >= 0 && w2 >= 0) || (w0 <= 0 && w1 <= 0 && w2 <= 0))) continue;
|
|
208
|
-
const l0 = w0 / area, l1 = w1 / area, l2 = w2 / area;
|
|
209
|
-
const depth = l0 * a[2] + l1 * b[2] + l2 * c[2];
|
|
210
|
-
const idx = y * W + x;
|
|
211
|
-
if (depth <= zbuf[idx]) continue;
|
|
212
|
-
zbuf[idx] = depth;
|
|
213
|
-
const I = l0 * inten[0] + l1 * inten[1] + l2 * inten[2];
|
|
214
|
-
color[idx * 3] = Math.min(255, base[0] * I);
|
|
215
|
-
color[idx * 3 + 1] = Math.min(255, base[1] * I);
|
|
216
|
-
color[idx * 3 + 2] = Math.min(255, base[2] * I);
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
// Depth-tested line for edge overlays: drawn only where it isn't behind the
|
|
222
|
-
// surface (within `bias`), so the silhouette and feature edges read as crisp lines.
|
|
223
|
-
function drawLine(p0, p1, col, color, zbuf, W, H, bias) {
|
|
224
|
-
const steps = Math.max(1, Math.ceil(Math.max(Math.abs(p1[0] - p0[0]), Math.abs(p1[1] - p0[1]))));
|
|
225
|
-
for (let s = 0; s <= steps; s++) {
|
|
226
|
-
const t = s / steps;
|
|
227
|
-
const x = Math.round(p0[0] + (p1[0] - p0[0]) * t), y = Math.round(p0[1] + (p1[1] - p0[1]) * t);
|
|
228
|
-
if (x < 0 || x >= W || y < 0 || y >= H) continue;
|
|
229
|
-
const depth = p0[2] + (p1[2] - p0[2]) * t;
|
|
230
|
-
const idx = y * W + x;
|
|
231
|
-
if (depth + bias < zbuf[idx]) continue; // occluded
|
|
232
|
-
color[idx * 3] = col[0]; color[idx * 3 + 1] = col[1]; color[idx * 3 + 2] = col[2];
|
|
233
|
-
}
|
|
95
|
+
writeFileSync(file, png);
|
|
96
|
+
return file;
|
|
97
|
+
});
|
|
234
98
|
}
|
package/src/testing.js
CHANGED
|
@@ -15,7 +15,8 @@ export { resolveDerived } from "./framework/derive.js";
|
|
|
15
15
|
export { relevantParamKeys, RELEVANT_ALL } from "./framework/param-deps.js";
|
|
16
16
|
export { assemblyOverlaps } from "./framework/assembly.js";
|
|
17
17
|
export { bootOcctKernel } from "./testing/occt.js";
|
|
18
|
-
export { renderViews, RENDER_VIEWS } from "./testing/render.js";
|
|
18
|
+
export { renderViews, renderViewImages, RENDER_VIEWS } from "./testing/render.js";
|
|
19
|
+
export { RENDER_STYLES } from "./framework/renderStyles.js";
|
|
19
20
|
// The whole oracle surface (measure/verify/buildView, gaps/BVH/min-wall, silhouette
|
|
20
21
|
// match scoring, and the semantic mesh oracle) comes through partforge/oracle — one
|
|
21
22
|
// list of names, two doors. See src/oracle.js for what each group is.
|
package/types/index.d.ts
CHANGED
|
@@ -426,6 +426,8 @@ export interface CaptureViewOptions {
|
|
|
426
426
|
quality?: number;
|
|
427
427
|
/** Canonical angle to render from. Default `"iso"`. */
|
|
428
428
|
angle?: CanonicalView | string;
|
|
429
|
+
/** Capture style. `"thumbnail"` (the default) is the light product shot with a contact shadow; `"cad"` is the agent-facing CAD look. */
|
|
430
|
+
style?: "cad" | "thumbnail";
|
|
429
431
|
}
|
|
430
432
|
|
|
431
433
|
/**
|
package/types/testing.d.ts
CHANGED
|
@@ -668,7 +668,7 @@ export function matchViews(
|
|
|
668
668
|
export const RENDER_VIEWS: string[];
|
|
669
669
|
|
|
670
670
|
/**
|
|
671
|
-
* Render canonical-angle PNGs of one view with
|
|
671
|
+
* Render canonical-angle PNGs of one view with the styled pure-JS renderer —
|
|
672
672
|
* no native module, no browser. Returns the written file paths.
|
|
673
673
|
*/
|
|
674
674
|
export function renderViews(
|
|
@@ -689,9 +689,35 @@ export function renderViews(
|
|
|
689
689
|
* background. Absent keys render solid.
|
|
690
690
|
*/
|
|
691
691
|
opacity?: Record<string, number>;
|
|
692
|
+
style?: "cad" | "thumbnail";
|
|
693
|
+
/** Samples per pixel per axis (default 2). */
|
|
694
|
+
supersample?: number;
|
|
692
695
|
},
|
|
693
696
|
): Promise<string[]>;
|
|
694
697
|
|
|
698
|
+
/** The built-in capture looks: "cad" (agent renders) and "thumbnail" (product shot). */
|
|
699
|
+
export const RENDER_STYLES: Readonly<Record<"cad" | "thumbnail", unknown>>;
|
|
700
|
+
|
|
701
|
+
/**
|
|
702
|
+
* Render canonical-angle PNGs of one view in memory with the styled pure-JS
|
|
703
|
+
* renderer — the same looks as the viewer's offscreen captures.
|
|
704
|
+
*/
|
|
705
|
+
export function renderViewImages(
|
|
706
|
+
kernel: GeometryKernel,
|
|
707
|
+
part: PartDefinition,
|
|
708
|
+
view?: string,
|
|
709
|
+
opts?: {
|
|
710
|
+
views?: string[];
|
|
711
|
+
size?: [number, number];
|
|
712
|
+
edges?: boolean;
|
|
713
|
+
params?: ResolvedParams;
|
|
714
|
+
opacity?: Record<string, number>;
|
|
715
|
+
style?: "cad" | "thumbnail";
|
|
716
|
+
/** Samples per pixel per axis (default 2). */
|
|
717
|
+
supersample?: number;
|
|
718
|
+
},
|
|
719
|
+
): Promise<{ angle: string; png: Uint8Array }[]>;
|
|
720
|
+
|
|
695
721
|
// --- sketch-annotation rays --------------------------------------------------
|
|
696
722
|
export interface AnnotationRay { origin: [number, number, number]; dir: [number, number, number] }
|
|
697
723
|
export interface RayPlaneHit { point: [number, number, number]; t: number }
|