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.
@@ -3009,14 +3009,16 @@ instead of (or in addition to) the built-in `#part` bar:
3009
3009
  `viewName` rendered offscreen (falling back to the resolved default view — see
3010
3010
  `resolveDefaultView` / `default-view.js` — when `viewName` is omitted or names a view the
3011
3011
  part doesn't declare). Never disturbs the active tab, the live camera, or the on-screen
3012
- scene; `opts` forwards to the underlying render (size, quality, angle, background).
3012
+ scene; `opts` forwards to the underlying render (size, quality, angle, background, style).
3013
3013
  Resolves `null` on failure rather than throwing (a build error, a part with no sub-parts
3014
3014
  in that view, a disposed runtime). The render happens in a throwaway scene, so it takes
3015
- no colour from the viewer's light/dark theme: it gets a fixed neutral grey, on the
3016
- reasoning that a thumbnail is captured once and then displayed under host chrome
3017
- partforge cannot see. Pass `background` (any `THREE.Color`-compatible value) to choose
3018
- your own, or `background: null` for no background at all — which clears to opaque black
3019
- unless the embedder has set a clear colour.
3015
+ no colour from the viewer's light/dark theme. The default `style` is `"thumbnail"`, the
3016
+ product shot: a fixed light background, soft lights, lighter edges, a contact shadow and
3017
+ fit framing — a thumbnail is captured once and then displayed under host chrome
3018
+ partforge cannot see. `style: "cad"` gives the agent-render look instead. `style.view` is
3019
+ only a default angle; an explicit `angle` wins. Pass `background` (any
3020
+ `THREE.Color`-compatible value) to override the style's, or `background: null` for no
3021
+ background at all — which clears to opaque black unless the embedder has set a clear colour.
3020
3022
 
3021
3023
  Pass `onViewChange(name)` to `mount()` to be told the active view: it fires once
3022
3024
  synchronously during mount with the initial resolved view (before `runtime.ready` settles),
@@ -3143,7 +3145,8 @@ pane's pixel size:
3143
3145
  or when measurement dimensions are pinned (their labels sit beside the part and
3144
3146
  could otherwise be cut off).
3145
3147
  - `runtime.captureViews(viewNames) → [{ view, dataUrl }]` — the canonical-angle
3146
- counterpart (fixed poses, framed to the visible assembly, 1024², grid hidden). Sized
3148
+ counterpart (fixed view directions, each fitted so the visible assembly's projected
3149
+ geometry fills 90% of the frame and sits centred in it; 1024², grid hidden). Sized
3147
3150
  for feeding a vision model, not for display; use `captureCurrent` for showcase images.
3148
3151
 
3149
3152
  ### `runtime.projection`
@@ -3469,6 +3472,11 @@ the part's first declared view. Treat renders as complementary evidence, not a r
3469
3472
  use several views for complex parts and the interactive viewer's cutaway for hidden
3470
3473
  interfaces, but rely on `measure` / `verify` for dimensions, contact, and clearance.
3471
3474
 
3475
+ Headlessly, `renderViewImages(kernel, part, view, opts)` from `partforge/testing` returns
3476
+ the same stills in memory as `[{ angle, png }]` (PNG buffers; `renderViews` writes them to
3477
+ disk through it). Its `style` option is `"cad"` (default) or `"thumbnail"`; `style.view`
3478
+ is only a default, so explicit `views` always win.
3479
+
3472
3480
  The `measure` function is also exported for vitest (boot a Manifold kernel as in
3473
3481
  "Testing a part", then `measure(kernel, part, "<view>")`):
3474
3482
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.136.1",
3
+ "version": "0.137.0",
4
4
  "description": "Turn a declarative part definition into a parametric-CAD web app (three.js + Manifold/Replicad). Requires a Vite-based consumer.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -106,7 +106,9 @@
106
106
  "lint": "eslint .",
107
107
  "lint:dead": "knip",
108
108
  "check": "node scripts/check-app.mjs",
109
- "offset-rates": "node scripts/offset-rates.mjs"
109
+ "offset-rates": "node scripts/offset-rates.mjs",
110
+ "bench:softrender": "node scripts/bench-softrender.mjs",
111
+ "check:softrender": "node scripts/check-softrender-parity.mjs"
110
112
  },
111
113
  "peerDependencies": {
112
114
  "happy-dom": ">=20"
@@ -0,0 +1,30 @@
1
+ // src/framework/contact-shadow-plane.js
2
+ // The viewer's half of the contact shadow (contact-shadow.js holds the mask):
3
+ // a black, unlit, alpha-mapped plane a hair under the floor. MeshBasicMaterial
4
+ // so no light touches it; depthWrite off so the part's bottom face never
5
+ // z-fights it. The CPU renderer samples the same mask per pixel instead.
6
+ import * as THREE from "three";
7
+
8
+ export function buildContactShadowPlane(mask, shadow) {
9
+ const { width: W, height: H, data, rect, y } = mask;
10
+ // alphaMap reads the GREEN channel. PlaneGeometry's v runs along local +y,
11
+ // which rotation.x = -PI/2 turns into world -z: texture row 0 (v = 0) is
12
+ // world z1, so mask row j (world z0 + ...) goes to texture row H-1-j.
13
+ const px = new Uint8Array(W * H * 4);
14
+ for (let j = 0; j < H; j++) for (let i = 0; i < W; i++) {
15
+ const v = Math.round(Math.min(1, Math.max(0, data[j * W + i])) * 255);
16
+ const o = ((H - 1 - j) * W + i) * 4;
17
+ px[o] = px[o + 1] = px[o + 2] = v; px[o + 3] = 255;
18
+ }
19
+ const tex = new THREE.DataTexture(px, W, H, THREE.RGBAFormat);
20
+ tex.magFilter = THREE.LinearFilter; tex.minFilter = THREE.LinearFilter;
21
+ tex.needsUpdate = true;
22
+ const sx = rect.x1 - rect.x0, sz = rect.z1 - rect.z0;
23
+ const mat = new THREE.MeshBasicMaterial({
24
+ color: 0x000000, transparent: true, opacity: shadow.opacity, alphaMap: tex, depthWrite: false,
25
+ });
26
+ const mesh = new THREE.Mesh(new THREE.PlaneGeometry(sx, sz), mat);
27
+ mesh.rotation.x = -Math.PI / 2;
28
+ mesh.position.set((rect.x0 + rect.x1) / 2, y - 1e-3 * Math.max(sx, sz), (rect.z0 + rect.z1) / 2);
29
+ return mesh;
30
+ }
@@ -0,0 +1,83 @@
1
+ // The product shot's soft contact shadow, as a top-down mask over the floor
2
+ // (the part's lowest world Y). Each cell is darkest where geometry sits ON the
3
+ // floor and fades to nothing `falloff·diag` above it, then the mask is blurred.
4
+ // Pure, so the viewer (as an alpha texture on a ground plane) and the CPU
5
+ // renderer (sampled per background pixel) draw the SAME shadow.
6
+ import { triangleCount, triangleOffsets } from "./softrender/triangles.js";
7
+
8
+ export function contactShadowMask(meshes, box, shadow) {
9
+ const size = [0, 1, 2].map((k) => box.max[k] - box.min[k]);
10
+ const diag = Math.hypot(...size);
11
+ if (!(diag > 0)) return null;
12
+ const falloff = shadow.falloff * diag;
13
+ const blurW = shadow.blur * diag;
14
+ const x0 = box.min[0] - 2 * blurW, x1 = box.max[0] + 2 * blurW;
15
+ const z0 = box.min[2] - 2 * blurW, z1 = box.max[2] + 2 * blurW;
16
+ const spanX = Math.max(x1 - x0, 1e-9), spanZ = Math.max(z1 - z0, 1e-9);
17
+ const span = Math.max(spanX, spanZ);
18
+ const W = Math.max(1, Math.round((shadow.resolution * spanX) / span));
19
+ const H = Math.max(1, Math.round((shadow.resolution * spanZ) / span));
20
+ const floor = box.min[1];
21
+ const minH = new Float32Array(W * H).fill(Infinity);
22
+
23
+ const gx = (x) => ((x - x0) / spanX) * W, gz = (z) => ((z - z0) / spanZ) * H;
24
+ for (const m of meshes) {
25
+ const P = m.positions;
26
+ const n = triangleCount(m);
27
+ for (let t = 0; t < n; t++) {
28
+ const o = triangleOffsets(m, t);
29
+ const ax = gx(P[o[0]]), az = gz(P[o[0] + 2]), ah = P[o[0] + 1] - floor;
30
+ const bx = gx(P[o[1]]), bz = gz(P[o[1] + 2]), bh = P[o[1] + 1] - floor;
31
+ const cx = gx(P[o[2]]), cz = gz(P[o[2] + 2]), ch = P[o[2] + 1] - floor;
32
+ const area = (bx - ax) * (cz - az) - (bz - az) * (cx - ax);
33
+ if (Math.abs(area) < 1e-12) continue; // a wall seen from above covers no cells
34
+ const i0 = Math.max(0, Math.floor(Math.min(ax, bx, cx))), i1 = Math.min(W - 1, Math.ceil(Math.max(ax, bx, cx)));
35
+ const j0 = Math.max(0, Math.floor(Math.min(az, bz, cz))), j1 = Math.min(H - 1, Math.ceil(Math.max(az, bz, cz)));
36
+ for (let j = j0; j <= j1; j++) for (let i = i0; i <= i1; i++) {
37
+ const px = i + 0.5, pz = j + 0.5;
38
+ const w0 = ((cx - bx) * (pz - bz) - (cz - bz) * (px - bx)) / area;
39
+ const w1 = ((ax - cx) * (pz - cz) - (az - cz) * (px - cx)) / area;
40
+ const w2 = 1 - w0 - w1;
41
+ if (w0 < 0 || w1 < 0 || w2 < 0) continue;
42
+ const h = w0 * ah + w1 * bh + w2 * ch;
43
+ const k = j * W + i;
44
+ if (h < minH[k]) minH[k] = h;
45
+ }
46
+ }
47
+ }
48
+
49
+ let data = new Float32Array(W * H);
50
+ for (let k = 0; k < data.length; k++) {
51
+ const h = minH[k];
52
+ data[k] = Number.isFinite(h) ? Math.min(1, Math.max(0, 1 - Math.max(0, h) / falloff)) : 0;
53
+ }
54
+ const r = Math.max(1, Math.round((blurW / spanX) * W));
55
+ for (let pass = 0; pass < 3; pass++) data = boxBlur(boxBlur(data, W, H, r, true), W, H, r, false);
56
+ return { width: W, height: H, data, rect: { x0, x1, z0, z1 }, y: floor };
57
+ }
58
+
59
+ // One separable box-blur pass, edges clamped.
60
+ function boxBlur(src, W, H, r, horizontal) {
61
+ const out = new Float32Array(src.length);
62
+ const n = horizontal ? W : H, lines = horizontal ? H : W;
63
+ for (let l = 0; l < lines; l++) {
64
+ const at = (i) => (horizontal ? l * W + i : i * W + l);
65
+ let sum = 0;
66
+ for (let i = -r; i <= r; i++) sum += src[at(Math.min(n - 1, Math.max(0, i)))];
67
+ for (let i = 0; i < n; i++) {
68
+ out[at(i)] = sum / (2 * r + 1);
69
+ sum += src[at(Math.min(n - 1, i + r + 1))] - src[at(Math.max(0, i - r))];
70
+ }
71
+ }
72
+ return out;
73
+ }
74
+
75
+ export function sampleShadowMask(mask, x, z) {
76
+ const { width: W, height: H, data, rect } = mask;
77
+ if (x < rect.x0 || x > rect.x1 || z < rect.z0 || z > rect.z1) return 0;
78
+ const u = ((x - rect.x0) / (rect.x1 - rect.x0)) * W - 0.5;
79
+ const v = ((z - rect.z0) / (rect.z1 - rect.z0)) * H - 0.5;
80
+ const i = Math.floor(u), j = Math.floor(v), fu = u - i, fv = v - j;
81
+ const at = (ii, jj) => data[Math.min(H - 1, Math.max(0, jj)) * W + Math.min(W - 1, Math.max(0, ii))];
82
+ return (at(i, j) * (1 - fu) + at(i + 1, j) * fu) * (1 - fv) + (at(i, j + 1) * (1 - fu) + at(i + 1, j + 1) * fu) * fv;
83
+ }
@@ -4,13 +4,10 @@
4
4
  // builds MeshPhysicalMaterial. Both read resolve.js, so the two modes can never
5
5
  // disagree about which preset a sub-part is.
6
6
  import * as THREE from "three";
7
- import { cadAppearance, resolveMaterial } from "./resolve.js";
7
+ import { cadAppearance, resolveMaterial, hasAppearance } from "./resolve.js";
8
8
  import { applyBrushFrame, applyBurn, applyPattern } from "./patterns.js";
9
9
  import { PATTERN_TEXTURES } from "./assets.js";
10
10
 
11
- const APPEARANCE_KEYS = ["color", "opacity", "material", "roughness", "metalness", "clearcoat", "clearcoatRoughness", "anisotropy", "textureScale"];
12
- const hasAppearance = (display) => !!display && APPEARANCE_KEYS.some((k) => display[k] != null);
13
-
14
11
  export function buildCadMaterial(display, base) {
15
12
  if (!hasAppearance(display)) return base;
16
13
  const a = cadAppearance(display);
@@ -141,3 +141,9 @@ export function resolveEnvironmentId(id) {
141
141
  if (typeof id === "string" && Object.hasOwn(ENVIRONMENTS, id)) return { id, known: true };
142
142
  return { id: DEFAULT_ENVIRONMENT_ID, known: false };
143
143
  }
144
+
145
+ // Whether a sub-part's `display` declares any appearance at all. Without one
146
+ // the CAD view uses the shared default material untouched (physical.js
147
+ // buildCadMaterial; softrender/index.js mirrors that rule).
148
+ const APPEARANCE_KEYS = ["color", "opacity", "material", "roughness", "metalness", "clearcoat", "clearcoatRoughness", "anisotropy", "textureScale"];
149
+ export const hasAppearance = (display) => !!display && APPEARANCE_KEYS.some((k) => display[k] != null);
@@ -1390,7 +1390,8 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
1390
1390
  // touches the active tab, getView(), or the live scene — best-effort: any
1391
1391
  // failure, including a resolved-null from a worker build failure (4A
1392
1392
  // settles rather than throwing), returns null. `opts` is spread last, so
1393
- // renderMeshPayloads' own options (including `background`) pass straight
1393
+ // renderMeshPayloads' own options (including `background` and
1394
+ // `style`: "thumbnail" by default, "cad" for the agent look) pass straight
1394
1395
  // through from the caller.
1395
1396
  const captureView = async (viewName, opts = {}) => {
1396
1397
  try {
@@ -0,0 +1,146 @@
1
+ // src/framework/renderStyles.js
2
+ // The two offscreen capture looks, as plain data: `cad` (the agent's renders)
3
+ // and `thumbnail` (the card/product shot). Read by BOTH the three.js viewer
4
+ // (viewer.js, viewer-lighting.js) and the CPU renderer (softrender/), so a
5
+ // look is written down once and the two renderers cannot drift apart. No
6
+ // three.js here: this module runs in the Vercel Sandbox under plain Node.
7
+ //
8
+ // Colours are sRGB hex, exactly as three.js receives them; consumers convert
9
+ // to linear (srgbHexToLinear) the way three's ColorManagement does.
10
+
11
+ export const CAD_LIGHT_THEME = Object.freeze({ bg: 0xe9edf2, line: 0x33414f });
12
+ export const CAD_DARK_THEME = Object.freeze({ bg: 0x15181d, line: 0x1c232d });
13
+
14
+ const CAD_LIGHTS = {
15
+ hemisphere: { sky: 0xdce9ff, ground: 0x687586, intensity: 1.35 },
16
+ // Offsets are camera-space multiples of the camera-to-target distance:
17
+ // key over the viewer's shoulder, fill opposing it at eye level
18
+ // (style-camera.js captureLightPoses places them).
19
+ key: { color: 0xffffff, intensity: 1.45, offset: { right: 0.45, up: 0.75 } },
20
+ fill: { color: 0xe5efff, intensity: 0.65, offset: { right: -0.7, up: 0.15 } },
21
+ };
22
+ const CAD_MATERIAL = { color: 0x9fb4cc, metalness: 0.25, roughness: 0.55 };
23
+
24
+ // The product shot, tuned 2026-10-02 on demo / hinged-box / propeller captures
25
+ // judged at card size (~300px from the 640px thumbnail): fill 0.88 (room for
26
+ // the shadow and the card's corners), hemisphere 1.65, key
27
+ // 1.25, fill light 1.05 (the shaded side stays off the background), 1.5px edges
28
+ // at 0.7 (visible at card size, lighter than the editor's), and a contact shadow
29
+ // of 0.5 opacity / 0.25 falloff / 0.07 blur (faint, but it reads — most of it
30
+ // sits under the part at iso). Retune with scripts/check-softrender-parity.mjs
31
+ // --out, on captures, never by eye on the hex; both renderers move together.
32
+ // Both looks frame by "fit" (style-camera.js fitPoseToPoints): the canonical
33
+ // direction, at the distance that brings the projected geometry to `fill` of
34
+ // the frame, centred on it. cad's 0.9 leaves a margin so a silhouette never
35
+ // touches the frame edge. "canonical" (cameraPoseForView's fixed distance)
36
+ // stays valid for a caller-supplied style.
37
+ const STYLES = {
38
+ cad: {
39
+ camera: { projection: "perspective", fov: 45, framing: "fit", fill: 0.9 },
40
+ lights: CAD_LIGHTS,
41
+ material: CAD_MATERIAL,
42
+ edges: { color: CAD_LIGHT_THEME.line, widthPx: 1, opacity: 1 },
43
+ background: CAD_LIGHT_THEME.bg,
44
+ shadow: null,
45
+ view: null,
46
+ },
47
+ thumbnail: {
48
+ camera: { projection: "perspective", fov: 45, framing: "fit", fill: 0.88 },
49
+ lights: {
50
+ hemisphere: { ...CAD_LIGHTS.hemisphere, intensity: 1.65 },
51
+ key: { ...CAD_LIGHTS.key, intensity: 1.25 },
52
+ fill: { ...CAD_LIGHTS.fill, intensity: 1.05 },
53
+ },
54
+ material: CAD_MATERIAL,
55
+ edges: { color: CAD_LIGHT_THEME.line, widthPx: 1.5, opacity: 0.7 },
56
+ background: 0xeef1f5,
57
+ // falloff/blur are fractions of the part's bounding-box diagonal;
58
+ // resolution is the mask's long side in cells.
59
+ shadow: { opacity: 0.5, falloff: 0.25, blur: 0.07, resolution: 128 },
60
+ view: "iso",
61
+ },
62
+ };
63
+
64
+ const isHex = (v) => Number.isInteger(v) && v >= 0 && v <= 0xffffff;
65
+ const inRange = (v, lo, hi) => typeof v === "number" && Number.isFinite(v) && v >= lo && v <= hi;
66
+ function check(ok, field) { if (!ok) throw new Error(`render style: ${field} is invalid`); }
67
+
68
+ export function validateRenderStyle(s) {
69
+ check(s && typeof s === "object", "style");
70
+ check(s.camera?.projection === "perspective", "camera.projection");
71
+ check(inRange(s.camera.fov, 1, 120), "camera.fov");
72
+ check(s.camera.framing === "canonical" || s.camera.framing === "fit", "camera.framing");
73
+ if (s.camera.framing === "fit") check(inRange(s.camera.fill, 0.05, 1), "camera.fill");
74
+ const h = s.lights?.hemisphere;
75
+ check(isHex(h?.sky) && isHex(h?.ground) && inRange(h?.intensity, 0, 20), "lights.hemisphere");
76
+ for (const k of ["key", "fill"]) {
77
+ const l = s.lights[k];
78
+ check(isHex(l?.color) && inRange(l?.intensity, 0, 20), `lights.${k}`);
79
+ check(inRange(l.offset?.right, -10, 10) && inRange(l.offset?.up, -10, 10), `lights.${k}.offset`);
80
+ }
81
+ check(isHex(s.material?.color), "material.color");
82
+ check(inRange(s.material.metalness, 0, 1), "material.metalness");
83
+ check(inRange(s.material.roughness, 0, 1), "material.roughness");
84
+ check(isHex(s.edges?.color) && inRange(s.edges?.widthPx, 0.25, 8) && inRange(s.edges?.opacity, 0, 1), "edges");
85
+ check(isHex(s.background), "background");
86
+ if (s.shadow !== null) {
87
+ const sh = s.shadow;
88
+ check(inRange(sh?.opacity, 0, 1) && inRange(sh?.falloff, 0.001, 10) && inRange(sh?.blur, 0, 1)
89
+ && Number.isInteger(sh?.resolution) && sh.resolution >= 8 && sh.resolution <= 1024, "shadow");
90
+ }
91
+ check(s.view === null || typeof s.view === "string", "view");
92
+ return s;
93
+ }
94
+
95
+ function deepFreeze(o) {
96
+ for (const v of Object.values(o)) if (v && typeof v === "object") deepFreeze(v);
97
+ return Object.freeze(o);
98
+ }
99
+
100
+ // Validated once, at load: a typo in the data above fails every test that
101
+ // imports this module rather than producing odd images.
102
+ for (const s of Object.values(STYLES)) validateRenderStyle(s);
103
+ export const RENDER_STYLES = deepFreeze(structuredClone(STYLES));
104
+
105
+ export function getRenderStyle(nameOrStyle) {
106
+ if (typeof nameOrStyle === "string") {
107
+ if (!Object.hasOwn(RENDER_STYLES, nameOrStyle)) throw new Error(`unknown render style "${nameOrStyle}"`);
108
+ return RENDER_STYLES[nameOrStyle];
109
+ }
110
+ return validateRenderStyle(nameOrStyle);
111
+ }
112
+
113
+ // sRGB hex → linear [r, g, b], the same transfer three's ColorManagement
114
+ // applies to a hex colour handed to a material or a light.
115
+ export function srgbHexToLinear(hex) {
116
+ return [(hex >> 16) & 255, (hex >> 8) & 255, hex & 255].map((b) => {
117
+ const c = b / 255;
118
+ return c < 0.04045 ? c * 0.0773993808 : ((c * 0.9478672986) + 0.0521327014) ** 2.4;
119
+ });
120
+ }
121
+
122
+ // three renders into a render target in the LINEAR working colour space: as of r184
123
+ // WebGLRenderer only applies `outputColorSpace` on the canvas path (WebGLPrograms
124
+ // substitutes workingColorSpace whenever a render target is bound), so readback pixels
125
+ // are linear no matter what the target texture's colorSpace says. Writing them straight
126
+ // into a JPEG is what made captured views come back muddy and dark compared to the live
127
+ // canvas. Encode the transfer function ourselves. The 8-bit LUT loses precision only in
128
+ // the deepest shadows, which a quality-0.9 JPEG would not have preserved anyway.
129
+ export const SRGB8 = (() => {
130
+ const table = new Uint8Array(256);
131
+ for (let i = 0; i < 256; i++) {
132
+ const l = i / 255;
133
+ table[i] = Math.round(255 * (l <= 0.0031308 ? 12.92 * l : 1.055 * l ** (1 / 2.4) - 0.055));
134
+ }
135
+ return table;
136
+ })();
137
+
138
+ // Linear RGBA bytes → sRGB, in place. Alpha is a coverage value, not a colour: untouched.
139
+ export function srgbEncodeInPlace(data) {
140
+ for (let i = 0; i < data.length; i += 4) {
141
+ data[i] = SRGB8[data[i]];
142
+ data[i + 1] = SRGB8[data[i + 1]];
143
+ data[i + 2] = SRGB8[data[i + 2]];
144
+ }
145
+ return data;
146
+ }
@@ -0,0 +1,38 @@
1
+ // A perspective camera with three.js's conventions (PerspectiveCamera +
2
+ // lookAt: vertical fov, right = forward × up, screen y down in pixel space),
3
+ // so a pose from style-camera.js lands every vertex where the viewer would.
4
+ const sub = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
5
+ 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]];
6
+ const norm = (v) => { const l = Math.hypot(v[0], v[1], v[2]) || 1; return [v[0] / l, v[1] / l, v[2] / l]; };
7
+
8
+ export function makeCamera(pose, { fov, width, height }) {
9
+ const f = norm(sub(pose.target, pose.position));
10
+ let r = cross(f, pose.up);
11
+ if (Math.hypot(...r) < 1e-9) r = cross(f, [0, 0, 1]);
12
+ r = norm(r);
13
+ const u = cross(r, f);
14
+ const tanH = Math.tan((fov * Math.PI) / 360);
15
+ const aspect = width / height;
16
+ const [px, py, pz] = pose.position;
17
+ return {
18
+ position: pose.position, forward: f, right: r, up: u, tanH, aspect, width, height,
19
+ // Both write into `out` when given: the rasterizer and shading loops call
20
+ // them per vertex / per sample and pass one scratch array, so the hot
21
+ // path allocates nothing.
22
+ project(x, y, z, out = [0, 0, 0]) {
23
+ const dx = x - px, dy = y - py, dz = z - pz;
24
+ const vz = dx * f[0] + dy * f[1] + dz * f[2];
25
+ const vx = dx * r[0] + dy * r[1] + dz * r[2];
26
+ const vy = dx * u[0] + dy * u[1] + dz * u[2];
27
+ out[0] = (vx / (vz * tanH * aspect) * 0.5 + 0.5) * width;
28
+ out[1] = (0.5 - (vy / (vz * tanH)) * 0.5) * height;
29
+ out[2] = vz;
30
+ return out;
31
+ },
32
+ ray(sx, sy, out = [0, 0, 0]) {
33
+ const a = ((sx / width) * 2 - 1) * tanH * aspect, b = (1 - (sy / height) * 2) * tanH;
34
+ for (let i = 0; i < 3; i++) out[i] = f[i] + a * r[i] + b * u[i];
35
+ return out;
36
+ },
37
+ };
38
+ }
@@ -0,0 +1,65 @@
1
+ // three r184's DFG look-up table (renderers/shaders/DFGLUTData.js), copied
2
+ // verbatim: 16x16 texels of (scale, bias) as RG16F half floats, 4096 samples
3
+ // each. Row = dot(N, V), column = roughness, as the shader samples it
4
+ // (texture2D(dfgLUT, vec2(roughness, dotNV))). three feeds it to the standard
5
+ // material's multiscattering terms even with no environment map, so shade.js
6
+ // needs it. Pure data + a bilinear sampler; no three.js import.
7
+ const DATA = new Uint16Array([
8
+ 0x30b5, 0x3ad1, 0x314c, 0x3a4d, 0x33d2, 0x391c, 0x35ef, 0x3828, 0x37f3, 0x36a6, 0x38d1, 0x3539, 0x3979, 0x3410, 0x39f8, 0x3252,
9
+ 0x3a53, 0x30f0, 0x3a94, 0x2fc9, 0x3abf, 0x2e35, 0x3ada, 0x2d05, 0x3ae8, 0x2c1f, 0x3aed, 0x2ae0, 0x3aea, 0x29d1, 0x3ae1, 0x28ff,
10
+ 0x3638, 0x38e4, 0x364a, 0x38ce, 0x3699, 0x385e, 0x374e, 0x372c, 0x3839, 0x35a4, 0x38dc, 0x3462, 0x396e, 0x32c4, 0x39de, 0x3134,
11
+ 0x3a2b, 0x3003, 0x3a59, 0x2e3a, 0x3a6d, 0x2ce1, 0x3a6e, 0x2bba, 0x3a5f, 0x2a33, 0x3a49, 0x290a, 0x3a2d, 0x2826, 0x3a0a, 0x26e8,
12
+ 0x3894, 0x36d7, 0x3897, 0x36c9, 0x38a3, 0x3675, 0x38bc, 0x35ac, 0x38ee, 0x349c, 0x393e, 0x3332, 0x3997, 0x3186, 0x39e2, 0x3038,
13
+ 0x3a13, 0x2e75, 0x3a29, 0x2cf5, 0x3a2d, 0x2bac, 0x3a21, 0x29ff, 0x3a04, 0x28bc, 0x39dc, 0x2790, 0x39ad, 0x261a, 0x3978, 0x24fa,
14
+ 0x39ac, 0x34a8, 0x39ac, 0x34a3, 0x39ae, 0x3480, 0x39ae, 0x3423, 0x39b1, 0x330e, 0x39c2, 0x31a9, 0x39e0, 0x3063, 0x39fc, 0x2eb5,
15
+ 0x3a0c, 0x2d1d, 0x3a14, 0x2bcf, 0x3a07, 0x29ff, 0x39e9, 0x28a3, 0x39be, 0x273c, 0x3989, 0x25b3, 0x394a, 0x2488, 0x3907, 0x2345,
16
+ 0x3a77, 0x3223, 0x3a76, 0x321f, 0x3a73, 0x3204, 0x3a6a, 0x31b3, 0x3a58, 0x3114, 0x3a45, 0x303b, 0x3a34, 0x2eb6, 0x3a26, 0x2d31,
17
+ 0x3a1e, 0x2bef, 0x3a0b, 0x2a0d, 0x39ec, 0x28a1, 0x39c0, 0x271b, 0x3987, 0x2580, 0x3944, 0x2449, 0x38fa, 0x22bd, 0x38ac, 0x2155,
18
+ 0x3b07, 0x2fca, 0x3b06, 0x2fca, 0x3b00, 0x2fb8, 0x3af4, 0x2f7c, 0x3adb, 0x2eea, 0x3ab4, 0x2e00, 0x3a85, 0x2cec, 0x3a5e, 0x2bc5,
19
+ 0x3a36, 0x2a00, 0x3a0d, 0x2899, 0x39dc, 0x2707, 0x39a0, 0x2562, 0x395a, 0x2424, 0x390b, 0x2268, 0x38b7, 0x20fd, 0x385f, 0x1fd1,
20
+ 0x3b69, 0x2cb9, 0x3b68, 0x2cbb, 0x3b62, 0x2cbb, 0x3b56, 0x2cae, 0x3b3b, 0x2c78, 0x3b0d, 0x2c0a, 0x3acf, 0x2ae3, 0x3a92, 0x2998,
21
+ 0x3a54, 0x2867, 0x3a17, 0x26d0, 0x39d3, 0x253c, 0x3989, 0x2402, 0x3935, 0x2226, 0x38dc, 0x20bd, 0x387d, 0x1f54, 0x381d, 0x1db3,
22
+ 0x3ba9, 0x296b, 0x3ba8, 0x296f, 0x3ba3, 0x297b, 0x3b98, 0x2987, 0x3b7f, 0x2976, 0x3b4e, 0x2927, 0x3b0e, 0x2895, 0x3ac2, 0x27b7,
23
+ 0x3a73, 0x263b, 0x3a23, 0x24e7, 0x39d0, 0x239b, 0x3976, 0x21d9, 0x3917, 0x207e, 0x38b2, 0x1ee7, 0x384b, 0x1d53, 0x37c7, 0x1c1e,
24
+ 0x3bd2, 0x25cb, 0x3bd1, 0x25d3, 0x3bcd, 0x25f0, 0x3bc2, 0x261f, 0x3bad, 0x2645, 0x3b7d, 0x262d, 0x3b3e, 0x25c4, 0x3aec, 0x250f,
25
+ 0x3a93, 0x243a, 0x3a32, 0x22ce, 0x39d0, 0x215b, 0x3969, 0x202a, 0x38fe, 0x1e6e, 0x388f, 0x1cf1, 0x381f, 0x1b9b, 0x3762, 0x19dd,
26
+ 0x3be9, 0x21ab, 0x3be9, 0x21b7, 0x3be5, 0x21e5, 0x3bdd, 0x2241, 0x3bc9, 0x22a7, 0x3ba0, 0x22ec, 0x3b62, 0x22cd, 0x3b0f, 0x2247,
27
+ 0x3aae, 0x2175, 0x3a44, 0x2088, 0x39d4, 0x1f49, 0x3960, 0x1dbe, 0x38e9, 0x1c77, 0x3870, 0x1ae8, 0x37f1, 0x1953, 0x3708, 0x181b,
28
+ 0x3bf6, 0x1cea, 0x3bf6, 0x1cfb, 0x3bf3, 0x1d38, 0x3bec, 0x1dbd, 0x3bda, 0x1e7c, 0x3bb7, 0x1f25, 0x3b7d, 0x1f79, 0x3b2c, 0x1f4c,
29
+ 0x3ac6, 0x1ea6, 0x3a55, 0x1dbb, 0x39da, 0x1cbd, 0x395a, 0x1b9d, 0x38d8, 0x1a00, 0x3855, 0x18ac, 0x37ab, 0x173c, 0x36b7, 0x1598,
30
+ 0x3bfc, 0x1736, 0x3bfc, 0x1759, 0x3bf9, 0x17e7, 0x3bf4, 0x1896, 0x3be4, 0x1997, 0x3bc6, 0x1aa8, 0x3b91, 0x1b84, 0x3b43, 0x1bd2,
31
+ 0x3ade, 0x1b8a, 0x3a65, 0x1acd, 0x39e2, 0x19d3, 0x3957, 0x18cd, 0x38ca, 0x17b3, 0x383e, 0x1613, 0x376d, 0x14bf, 0x366f, 0x135e,
32
+ 0x3bff, 0x101b, 0x3bff, 0x1039, 0x3bfc, 0x10c8, 0x3bf9, 0x1226, 0x3bea, 0x1428, 0x3bcf, 0x1584, 0x3b9f, 0x16c5, 0x3b54, 0x179a,
33
+ 0x3af0, 0x17ce, 0x3a76, 0x1771, 0x39ea, 0x16a4, 0x3956, 0x15a7, 0x38bf, 0x14a7, 0x3829, 0x1379, 0x3735, 0x11ea, 0x362d, 0x10a1,
34
+ 0x3c00, 0x061b, 0x3c00, 0x066a, 0x3bfe, 0x081c, 0x3bfa, 0x0a4c, 0x3bed, 0x0d16, 0x3bd5, 0x0fb3, 0x3ba9, 0x114d, 0x3b63, 0x127c,
35
+ 0x3b01, 0x132f, 0x3a85, 0x1344, 0x39f4, 0x12d2, 0x3957, 0x120d, 0x38b5, 0x1122, 0x3817, 0x103c, 0x3703, 0x0ed3, 0x35f0, 0x0d6d,
36
+ 0x3c00, 0x007a, 0x3c00, 0x0089, 0x3bfe, 0x011d, 0x3bfb, 0x027c, 0x3bf0, 0x04fa, 0x3bda, 0x0881, 0x3bb1, 0x0acd, 0x3b6f, 0x0c97,
37
+ 0x3b10, 0x0d7b, 0x3a93, 0x0df1, 0x39fe, 0x0def, 0x3959, 0x0d8a, 0x38af, 0x0ce9, 0x3808, 0x0c31, 0x36d5, 0x0af0, 0x35b9, 0x09a3,
38
+ 0x3c00, 0x0000, 0x3c00, 0x0001, 0x3bff, 0x0015, 0x3bfb, 0x0059, 0x3bf2, 0x00fd, 0x3bdd, 0x01df, 0x3bb7, 0x031c, 0x3b79, 0x047c,
39
+ 0x3b1d, 0x05d4, 0x3aa0, 0x06d5, 0x3a08, 0x075a, 0x395d, 0x075e, 0x38aa, 0x06f7, 0x37f4, 0x0648, 0x36ac, 0x0576, 0x3586, 0x049f,
40
+ ]);
41
+
42
+ const SIZE = 16;
43
+
44
+ function half(h) {
45
+ const e = (h >> 10) & 0x1f, f = h & 0x3ff, sign = h & 0x8000 ? -1 : 1;
46
+ if (e === 0) return sign * 2 ** -14 * (f / 1024);
47
+ return sign * 2 ** (e - 15) * (1 + f / 1024);
48
+ }
49
+ const LUT = Float32Array.from(DATA, half);
50
+
51
+ // LinearFilter + ClampToEdge sample at (u, v) = (roughness, dotNV) -> [scale, bias].
52
+ export function dfgLut(roughness, dotNV) {
53
+ const x = Math.min(Math.max(roughness * SIZE - 0.5, 0), SIZE - 1);
54
+ const y = Math.min(Math.max(dotNV * SIZE - 0.5, 0), SIZE - 1);
55
+ const x0 = Math.floor(x), y0 = Math.floor(y);
56
+ const x1 = Math.min(x0 + 1, SIZE - 1), y1 = Math.min(y0 + 1, SIZE - 1);
57
+ const fx = x - x0, fy = y - y0;
58
+ const at = (xi, yi, c) => LUT[(yi * SIZE + xi) * 2 + c];
59
+ const mix = (c) => {
60
+ const a = at(x0, y0, c) * (1 - fx) + at(x1, y0, c) * fx;
61
+ const b = at(x0, y1, c) * (1 - fx) + at(x1, y1, c) * fx;
62
+ return a * (1 - fy) + b * fy;
63
+ };
64
+ return [mix(0), mix(1)];
65
+ }
@@ -0,0 +1,39 @@
1
+ // src/framework/softrender/edges.js
2
+ // Feature edges as lines of the style's width, stamped at supersample
3
+ // resolution (so they come out antialiased after the downsample) and depth-
4
+ // tested against the G-buffer with a scene-relative bias. Coverage is marked
5
+ // first and blended ONCE per sample: overlapping stamps of a translucent line
6
+ // must not darken where they overlap.
7
+ import { NEAR } from "./raster.js";
8
+
9
+ export function drawEdges(color, gb, cam, segments, rgb, opacity, widthPx, ss, bias) {
10
+ if (!segments?.length || opacity <= 0) return;
11
+ const { width: W, height: H, depth } = gb;
12
+ const mark = new Uint8Array(W * H);
13
+ const half = Math.max(0.5, (widthPx * ss) / 2);
14
+ const p0 = [0, 0, 0], p1 = [0, 0, 0];
15
+ for (let i = 0; i + 5 < segments.length; i += 6) {
16
+ cam.project(segments[i], segments[i + 1], segments[i + 2], p0);
17
+ cam.project(segments[i + 3], segments[i + 4], segments[i + 5], p1);
18
+ if (p0[2] < NEAR || p1[2] < NEAR) continue;
19
+ const steps = Math.max(1, Math.ceil(Math.hypot(p1[0] - p0[0], p1[1] - p0[1]) * 2));
20
+ for (let s = 0; s <= steps; s++) {
21
+ const t = s / steps;
22
+ const x = p0[0] + (p1[0] - p0[0]) * t, y = p0[1] + (p1[1] - p0[1]) * t;
23
+ const z = 1 / ((1 - t) / p0[2] + t / p1[2]); // depth is linear in 1/z on screen
24
+ const xmin = Math.max(0, Math.floor(x - half + 0.5)), xmax = Math.min(W - 1, Math.max(xmin, Math.floor(x + half - 0.5)));
25
+ const ymin = Math.max(0, Math.floor(y - half + 0.5)), ymax = Math.min(H - 1, Math.max(ymin, Math.floor(y + half - 0.5)));
26
+ for (let yy = ymin; yy <= ymax; yy++) for (let xx = xmin; xx <= xmax; xx++) {
27
+ const k = yy * W + xx;
28
+ if (z - bias <= depth[k]) mark[k] = 1;
29
+ }
30
+ }
31
+ }
32
+ for (let k = 0; k < mark.length; k++) {
33
+ if (!mark[k]) continue;
34
+ const o = k * 3;
35
+ color[o] += (rgb[0] - color[o]) * opacity;
36
+ color[o + 1] += (rgb[1] - color[o + 1]) * opacity;
37
+ color[o + 2] += (rgb[2] - color[o + 2]) * opacity;
38
+ }
39
+ }
@@ -0,0 +1,136 @@
1
+ // src/framework/softrender/index.js
2
+ // renderStyled: one view of a set of sub-part meshes in a named capture style
3
+ // (renderStyles.js), as raw sRGB RGBA. The CPU twin of the viewer's offscreen
4
+ // captures: same framing (style-camera.js), same lights and material model
5
+ // (shade.js), same contact shadow (contact-shadow.js). Encoding is the
6
+ // caller's. Opacity keeps the old contract: pre-blended toward the
7
+ // background, still occluding — real transparency is out of scope.
8
+ import { getRenderStyle, srgbHexToLinear, SRGB8 } from "../renderStyles.js";
9
+ import { stylePose, modelToWorld, boundsOf } from "../style-camera.js";
10
+ import { contactShadowMask, sampleShadowMask } from "../contact-shadow.js";
11
+ import { cadAppearance, hasAppearance } from "../materials/resolve.js";
12
+ import { makeCamera } from "./camera.js";
13
+ import { createGBuffer, rasterizeMesh } from "./raster.js";
14
+ import { prepareMaterial, prepareLights, shade } from "./shade.js";
15
+ import { drawEdges } from "./edges.js";
16
+
17
+ const NORMAL_SAME = 0.9995; // samples this aligned share one shading evaluation
18
+ const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v);
19
+
20
+ export function renderStyled(subparts, { view, style = "cad", size = [1024, 1024], supersample = 2, edges = true } = {}) {
21
+ const st = getRenderStyle(style);
22
+ const angle = view ?? st.view ?? "iso";
23
+ const [W, H] = size;
24
+ const ss = Math.round(supersample);
25
+ if (!Number.isFinite(ss) || ss < 1) throw new Error("renderStyled: supersample must be a positive integer");
26
+ const WS = W * ss, HS = H * ss;
27
+
28
+ const parts = subparts.map((sp) => {
29
+ const opacity = Number.isFinite(sp.opacity) ? clamp01(sp.opacity) : 1;
30
+ const look = hasAppearance(sp.display) ? cadAppearance(sp.display) : { ...st.material, opacity: 1 };
31
+ return {
32
+ positions: modelToWorld(sp.mesh.positions ?? []),
33
+ normals: sp.mesh.normals?.length ? modelToWorld(sp.mesh.normals) : null,
34
+ indices: sp.mesh.indices,
35
+ edges: sp.mesh.edges?.length ? modelToWorld(sp.mesh.edges) : null,
36
+ opacity: opacity * (Number.isFinite(look.opacity) ? look.opacity : 1),
37
+ material: prepareMaterial(look),
38
+ };
39
+ });
40
+ // Framing counts EVERY sub-part, visible or not: a fade must not reframe.
41
+ const box = boundsOf(parts.map((p) => p.positions));
42
+ const points = new Float32Array(parts.reduce((n, p) => n + p.positions.length, 0));
43
+ parts.reduce((o, p) => { points.set(p.positions, o); return o + p.positions.length; }, 0);
44
+ const { pose, fov, sceneBounds } = stylePose(st, angle, box, { aspect: W / H, points });
45
+ const cam = makeCamera(pose, { fov, width: WS, height: HS });
46
+ const visible = parts.map((p, i) => ({ ...p, owner: i })).filter((p) => p.opacity > 0);
47
+
48
+ const gb = createGBuffer(WS, HS);
49
+ for (const p of visible) rasterizeMesh(gb, cam, { positions: p.positions, normals: p.normals, indices: p.indices }, p.owner);
50
+
51
+ const bg = srgbHexToLinear(st.background);
52
+ const lights = prepareLights(st.lights, pose);
53
+ const color = new Float32Array(WS * HS * 3);
54
+
55
+ // Shadow: only from visible geometry, only seen from above the floor.
56
+ const mask = st.shadow && box && pose.position[1] > box.min[1]
57
+ ? contactShadowMask(visible.map((p) => ({ positions: p.positions, indices: p.indices })), box, st.shadow)
58
+ : null;
59
+
60
+ const N = [0, 0, 0], V = [0, 0, 0], R = [0, 0, 0], tmp = new Float32Array(3);
61
+ const background = (k, sx, sy) => {
62
+ let a = 0;
63
+ if (mask) {
64
+ const r = cam.ray(sx, sy, R);
65
+ if (r[1] < 0) {
66
+ const t = (mask.y - cam.position[1]) / r[1];
67
+ a = sampleShadowMask(mask, cam.position[0] + r[0] * t, cam.position[2] + r[2] * t) * st.shadow.opacity;
68
+ }
69
+ }
70
+ color[k * 3] = bg[0] * (1 - a); color[k * 3 + 1] = bg[1] * (1 - a); color[k * 3 + 2] = bg[2] * (1 - a);
71
+ };
72
+ const surface = (k, owner, sx, sy, out, o) => {
73
+ N[0] = gb.normal[k * 3]; N[1] = gb.normal[k * 3 + 1]; N[2] = gb.normal[k * 3 + 2];
74
+ const r = cam.ray(sx, sy, R);
75
+ const l = Math.hypot(r[0], r[1], r[2]);
76
+ V[0] = -r[0] / l; V[1] = -r[1] / l; V[2] = -r[2] / l;
77
+ const p = parts[owner];
78
+ shade(N, V, p.material, lights, out, o);
79
+ if (p.opacity < 1) for (let c = 0; c < 3; c++) out[o + c] = bg[c] + (out[o + c] - bg[c]) * p.opacity;
80
+ };
81
+
82
+ // Shade per output pixel; a pixel whose samples all belong to one sub-part
83
+ // with near-identical normals is shaded once (most of the image), others
84
+ // per sample (silhouettes, creases). Same picture, far fewer evaluations.
85
+ for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) {
86
+ const k0 = (y * ss) * WS + x * ss;
87
+ const owner0 = gb.owner[k0];
88
+ let uniform = owner0 >= 0;
89
+ for (let j = 0; j < ss && uniform; j++) for (let i = 0; i < ss && uniform; i++) {
90
+ const k = (y * ss + j) * WS + x * ss + i;
91
+ if (gb.owner[k] !== owner0) uniform = false;
92
+ else {
93
+ const d = gb.normal[k * 3] * gb.normal[k0 * 3] + gb.normal[k * 3 + 1] * gb.normal[k0 * 3 + 1] + gb.normal[k * 3 + 2] * gb.normal[k0 * 3 + 2];
94
+ if (d < NORMAL_SAME) uniform = false;
95
+ }
96
+ }
97
+ if (uniform) {
98
+ surface(k0, owner0, (x + 0.5) * ss, (y + 0.5) * ss, tmp, 0);
99
+ for (let j = 0; j < ss; j++) for (let i = 0; i < ss; i++) color.set(tmp, ((y * ss + j) * WS + x * ss + i) * 3);
100
+ } else {
101
+ for (let j = 0; j < ss; j++) for (let i = 0; i < ss; i++) {
102
+ const k = (y * ss + j) * WS + x * ss + i;
103
+ const sx = x * ss + i + 0.5, sy = y * ss + j + 0.5;
104
+ if (gb.owner[k] < 0) background(k, sx, sy);
105
+ else surface(k, gb.owner[k], sx, sy, color, k * 3);
106
+ }
107
+ }
108
+ }
109
+
110
+ if (edges) {
111
+ const edgeRgb = srgbHexToLinear(st.edges.color);
112
+ const bias = sceneBounds.radius * 0.01;
113
+ for (const p of visible) {
114
+ const rgb = p.opacity < 1 ? edgeRgb.map((c, i) => bg[i] + (c - bg[i]) * p.opacity) : edgeRgb;
115
+ drawEdges(color, gb, cam, p.edges, rgb, st.edges.opacity, st.edges.widthPx, ss, bias);
116
+ }
117
+ }
118
+
119
+ // Resolve: average linear samples (as an MSAA resolve does), quantize to a
120
+ // linear byte, then the same sRGB LUT the viewer's readback uses.
121
+ const rgba = new Uint8ClampedArray(W * H * 4);
122
+ const inv = 1 / (ss * ss);
123
+ for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) {
124
+ let r = 0, g = 0, b = 0;
125
+ for (let j = 0; j < ss; j++) for (let i = 0; i < ss; i++) {
126
+ const o = ((y * ss + j) * WS + x * ss + i) * 3;
127
+ r += color[o]; g += color[o + 1]; b += color[o + 2];
128
+ }
129
+ const q = (y * W + x) * 4;
130
+ rgba[q] = SRGB8[Math.round(clamp01(r * inv) * 255)];
131
+ rgba[q + 1] = SRGB8[Math.round(clamp01(g * inv) * 255)];
132
+ rgba[q + 2] = SRGB8[Math.round(clamp01(b * inv) * 255)];
133
+ rgba[q + 3] = 255;
134
+ }
135
+ return { width: W, height: H, rgba, view: angle };
136
+ }