partforge 0.136.0 → 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.
@@ -2396,7 +2396,7 @@ The framework resolves these — fetch/read bytes, detect the format (filename e
2396
2396
  - **STL/3MF on Manifold** — native: parsed, repaired (vertex merge + winding/orientation fix), and handed to `Manifold.ofMesh`. A mesh still non-manifold after repair throws loudly with the open-edge count — see [ERROR-PATTERNS.md#import-mesh-not-solid](ERROR-PATTERNS.md#import-mesh-not-solid).
2397
2397
  - **STL/3MF on OCCT** — never attempted: mesh-to-B-rep conversion isn't in scope for v1. Declaring a mesh import on a part (or sub-part, under per-sub-part routing) that routes to OCCT is an error — see [ERROR-PATTERNS.md#import-mesh-on-occt](ERROR-PATTERNS.md#import-mesh-on-occt).
2398
2398
 
2399
- `import` is not in `OCCT_ONLY_OPS` — a STEP import does not by itself force OCCT routing (the crossover exists precisely so it doesn't have to); backend selection is still driven by `fillet`/`chamfer`/`shell` on a `Solid`, or `meta.backend`.
2399
+ `import` is not in `OCCT_ONLY_OPS` — a STEP import does not by itself force OCCT routing (the crossover exists precisely so it doesn't have to); backend selection is still driven by `shell` on a `Solid` or `meta.backend` (`fillet`/`chamfer` run on Manifold and send a sub-part to OCCT only when an edge they select is one the mesh fillet cannot blend).
2400
2400
 
2401
2401
  **Units:** everything normalizes to millimetres at parse time — STEP units are honored by the OCCT importer, a 3MF file's `unit` attribute is converted, and **STL is assumed to already be in millimetres** (the format carries no unit metadata).
2402
2402
 
@@ -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.0",
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
+ }