partforge 0.136.1 → 0.137.1

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.
@@ -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
- // three renders into a render target in the LINEAR working colour space: as of r184
29
- // WebGLRenderer only applies `outputColorSpace` on the canvas path (WebGLPrograms
30
- // substitutes workingColorSpace whenever a render target is bound), so readback pixels
31
- // are linear no matter what the target texture's colorSpace says. Writing them straight
32
- // into a JPEG is what made captured views come back muddy and dark compared to the live
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
- export function captureViewsFromScene(viewNames, { renderer, liveCamera, grid, bounds, sceneBounds, hidden = [] }) {
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 must hold. The grid is
97
- // hidden for this whole pass, so the part alone is in the second one.
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 — and before this constant existed it set none at all,
110
- // which meant every thumbnail came back on the renderer's default opaque
111
- // black, in light mode as much as dark. One deliberately theme-INDEPENDENT
112
- // colour is the right answer rather than either THEME entry below: a thumbnail
113
- // is baked at capture time and displayed later under host chrome this renderer
114
- // cannot know (partforge-cloud's card grid draws them on both). Near the
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 ?? 0x15181d, grid: [0x2c333d, 0x222831], line: 0x1c232d },
206
- light: { bg: 0xe9edf2, grid: [0xc4ccd6, 0xd6dce4], line: 0x33414f },
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: 0x9fb4cc,
276
- metalness: 0.25,
277
- roughness: 0.55,
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
- const radius = Math.max(size.x, size.y, size.z) / 2 || 10;
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
- bounds: { center, radius },
2022
- // The ENCLOSING radius, which is a different number from the framing one
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
- // gets THUMBNAIL_BG unless `background` says otherwise (`null` = no background, the
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 box = new THREE.Box3().setFromObject(tmpPivot);
2209
- const center = box.getCenter(new THREE.Vector3()).toArray();
2210
- const radius = box.getSize(new THREE.Vector3()).length() / 2 || 1;
2211
- const pose = cameraPoseForView(angle, { center, radius });
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 at the render resolution (the live one is
2228
- // sized to the on-screen canvas); added after framing so it can't perturb the bbox.
2229
- const lineMat = new LineMaterial({ color: THEME.dark.line, linewidth: 1.0 });
2230
- lineMat.resolution.set(size, size);
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) tmpPivot.add(new LineSegments2(edges, lineMat));
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. cameraPoseForView's distance
2240
- // is tuned to this fov, so a narrower one would crop long, thin parts.
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: camera.fov, quality, sceneBounds: { center, radius } },
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?.();
@@ -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 { bounds } from "../framework/oracle/mesh.js";
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 with a pure-JS software
44
- // rasterizer (orthographic, z-buffered, Lambert-shaded, with depth-tested edge
45
- // overlays). No native module, no browser. Returns the written file paths.
46
- // pngjs is lazy-imported so importing the testing barrel for measure never loads it.
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() output,
49
- // typically): a sub-part at 0 is skipped entirely — faces AND edges — but it
50
- // still counts toward the SCENE BOUNDS, so a part crossing 0 cannot silently
51
- // reframe the still. Framing is a property of the pose, not of what happens to
52
- // be visible: without that, `--at 0,0.5,1` over a fade drew the same base at
53
- // three different scales and the sequence read as a zoom.
54
- // Values in (0,1) fade by PRE-BLENDING that part's shaded base and edge
55
- // colours toward the background. That is a z-buffered approximation: a faded
56
- // part still fully occludes whatever is behind it, because real transparency
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
- const [W, H] = size;
65
- // Sub-part names are kept alongside the meshes: opacity is keyed by name.
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 built = buildView(kernel, part, view, params) // copied out
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
- // …and only the visible ones are rasterized: faces AND edges below iterate this.
89
- const meshes = built.filter(({ name }) => opacityOf(name) > 0);
90
-
91
- const center = [(lo[0] + hi[0]) / 2, (lo[1] + hi[1]) / 2, (lo[2] + hi[2]) / 2];
92
- const radius = Math.max(hi[0] - lo[0], hi[1] - lo[1], hi[2] - lo[2]) / 2 || 5;
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 and view key are sanitized, since this is the one place a part's
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
- const written = [];
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 rather
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, PNG.sync.write(png));
185
- written.push(file);
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
  /**
@@ -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 a pure-JS software rasterizer —
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 }