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.
- package/docs/AUTHORING-PARTS.md +15 -7
- package/package.json +4 -2
- package/src/framework/contact-shadow-plane.js +30 -0
- package/src/framework/contact-shadow.js +83 -0
- package/src/framework/geometry/occt-backend.js +42 -9
- package/src/framework/geometry/region-interaction.js +98 -0
- package/src/framework/materials/physical.js +1 -4
- package/src/framework/materials/resolve.js +6 -0
- package/src/framework/mount.js +2 -1
- package/src/framework/renderStyles.js +146 -0
- package/src/framework/softrender/camera.js +38 -0
- package/src/framework/softrender/dfgLut.js +65 -0
- package/src/framework/softrender/edges.js +39 -0
- package/src/framework/softrender/index.js +136 -0
- package/src/framework/softrender/raster.js +78 -0
- package/src/framework/softrender/shade.js +96 -0
- package/src/framework/softrender/triangles.js +9 -0
- package/src/framework/style-camera.js +174 -0
- package/src/framework/viewer-lighting.js +11 -45
- package/src/framework/viewer.js +107 -76
- package/src/testing/render.js +40 -176
- package/src/testing.js +2 -1
- package/types/index.d.ts +2 -0
- package/types/testing.d.ts +27 -1
package/docs/AUTHORING-PARTS.md
CHANGED
|
@@ -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
|
|
3016
|
-
|
|
3017
|
-
|
|
3018
|
-
|
|
3019
|
-
|
|
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
|
|
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.
|
|
3
|
+
"version": "0.137.1",
|
|
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
|
+
}
|
|
@@ -21,6 +21,7 @@ import { toEdgeFinder } from "./edge-selector.js";
|
|
|
21
21
|
import { toFaceFinder } from "./face-selector.js";
|
|
22
22
|
import { addSugar } from "./solid-sugar.js";
|
|
23
23
|
import { makeShape2dFactory } from "./shape2d.js";
|
|
24
|
+
import { groupInteractingRegions } from "./region-interaction.js";
|
|
24
25
|
import { finishKernel } from "./kernel-front.js";
|
|
25
26
|
import { makeProfileWarner } from "./profile-warnings.js";
|
|
26
27
|
import { createOcctRepair } from "./occt-repair.js";
|
|
@@ -519,15 +520,47 @@ export function createOcctKernel(replicad) {
|
|
|
519
520
|
for (const hole of holes) region = region.cut(contourDrawing(hole));
|
|
520
521
|
return region;
|
|
521
522
|
};
|
|
522
|
-
// Region list ->
|
|
523
|
-
//
|
|
524
|
-
//
|
|
525
|
-
//
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
523
|
+
// Region list -> Drawing, for extrude/revolve (via drawingFor below).
|
|
524
|
+
//
|
|
525
|
+
// A Shape2D's regions are NOT guaranteed disjoint: booleans/offset emit disjoint
|
|
526
|
+
// regions (an island inside a hole is its own top-level region), but k.shape2d([...])
|
|
527
|
+
// lifts a hand-authored region array verbatim, so two of them may overlap. The old
|
|
528
|
+
// left-fold `acc.fuse(region)` unioned them correctly but re-organised every
|
|
529
|
+
// accumulated blueprint on each fuse (replicad organiseBlueprints → containment
|
|
530
|
+
// search), so N regions cost O(N²) — a ~1,500-triangle lattice took ~45 s and
|
|
531
|
+
// gigabytes of embind wrappers. Now regions are grouped by whether they actually
|
|
532
|
+
// INTERACT (see regionsInteract); a region that touches no other is turned straight
|
|
533
|
+
// into Blueprints with no boolean at all, and only genuinely interacting clusters
|
|
534
|
+
// are fused, as a balanced tree.
|
|
535
|
+
//
|
|
536
|
+
// A region becomes an outer Blueprint plus its hole Blueprints in one CompoundBlueprint
|
|
537
|
+
// (outer first — what organiseBlueprints would produce); holes are never cut one at a
|
|
538
|
+
// time, since each cut2D re-organises too. Hole winding is irrelevant: replicad's
|
|
539
|
+
// CompoundSketch.face() runs ShapeFix_Face.FixOrientation.
|
|
540
|
+
const { Drawing, Blueprints, CompoundBlueprint } = replicad;
|
|
541
|
+
const bpOf = (contour) => contourDrawing(contour).innerShape;
|
|
542
|
+
const regionBlueprint = (rg) => (rg.holes.length
|
|
543
|
+
? new CompoundBlueprint([bpOf(rg.outer), ...rg.holes.map(bpOf)])
|
|
544
|
+
: bpOf(rg.outer));
|
|
545
|
+
const fuseBalanced = (drawings) => {
|
|
546
|
+
while (drawings.length > 1) {
|
|
547
|
+
const next = [];
|
|
548
|
+
for (let i = 0; i < drawings.length; i += 2)
|
|
549
|
+
next.push(i + 1 < drawings.length ? drawings[i].fuse(drawings[i + 1]) : drawings[i]);
|
|
550
|
+
drawings = next;
|
|
551
|
+
}
|
|
552
|
+
return drawings[0];
|
|
553
|
+
};
|
|
554
|
+
const drawingFromRegions = (regions) => {
|
|
555
|
+
if (regions.length === 0) return null;
|
|
556
|
+
const parts = [];
|
|
557
|
+
for (const group of groupInteractingRegions(regions, SHAPE2D_SEGS)) {
|
|
558
|
+
if (group.length === 1) { parts.push(regionBlueprint(group[0])); continue; }
|
|
559
|
+
const fused = fuseBalanced(group.map((rg) => new Drawing(regionBlueprint(rg)))).innerShape;
|
|
560
|
+
parts.push(...(fused instanceof Blueprints ? fused.blueprints : [fused]));
|
|
561
|
+
}
|
|
562
|
+
return new Drawing(parts.length === 1 ? parts[0] : new Blueprints(parts));
|
|
563
|
+
};
|
|
531
564
|
|
|
532
565
|
// 2-D boolean value: the SHARED Shape2D (shape2d.js), identical to the Manifold
|
|
533
566
|
// backend's. Storage is the curve-native contour IR and every op — including
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// Pure (WASM-free) grouping of Shape2D regions into clusters that must be unioned
|
|
2
|
+
// together before a kernel op, versus regions that provably touch nothing else and can
|
|
3
|
+
// be handed to the kernel as-is. Used by the OCCT backend's drawingFromRegions: a
|
|
4
|
+
// lattice of N disjoint regions must not pay N-1 boolean fuses (each one re-runs
|
|
5
|
+
// replicad's containment search over everything accumulated: O(N²)).
|
|
6
|
+
//
|
|
7
|
+
// Two regions INTERACT when their boundaries touch or cross, or one lies inside the
|
|
8
|
+
// other's material (inside its outer and not inside one of its holes — so an island
|
|
9
|
+
// sitting in another region's hole is DISJOINT, which is exactly how booleans emit it).
|
|
10
|
+
// The test runs on the contours tessellated at the shape's LOD; a pair separated by less
|
|
11
|
+
// than the chord error of a curve is a degenerate near-touch the kernel's own tolerance
|
|
12
|
+
// can't resolve either.
|
|
13
|
+
import { tessellateContour } from "./profile.js";
|
|
14
|
+
import { pointInRing } from "./shape2d-regions.js";
|
|
15
|
+
|
|
16
|
+
const EPS = 1e-7;
|
|
17
|
+
|
|
18
|
+
const orient = (a, b, c) => (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
|
|
19
|
+
const onSeg = (a, b, p) =>
|
|
20
|
+
Math.min(a[0], b[0]) - EPS <= p[0] && p[0] <= Math.max(a[0], b[0]) + EPS &&
|
|
21
|
+
Math.min(a[1], b[1]) - EPS <= p[1] && p[1] <= Math.max(a[1], b[1]) + EPS;
|
|
22
|
+
// Segments ab and cd cross or touch (collinear overlap and shared endpoints count).
|
|
23
|
+
const segsMeet = (a, b, c, d) => {
|
|
24
|
+
const o1 = orient(a, b, c), o2 = orient(a, b, d), o3 = orient(c, d, a), o4 = orient(c, d, b);
|
|
25
|
+
if (((o1 > 0 && o2 < 0) || (o1 < 0 && o2 > 0)) && ((o3 > 0 && o4 < 0) || (o3 < 0 && o4 > 0))) return true;
|
|
26
|
+
return (o1 === 0 && onSeg(a, b, c)) || (o2 === 0 && onSeg(a, b, d)) ||
|
|
27
|
+
(o3 === 0 && onSeg(c, d, a)) || (o4 === 0 && onSeg(c, d, b));
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const ringBox = (ring) => {
|
|
31
|
+
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
|
|
32
|
+
for (const [x, y] of ring) { if (x < x0) x0 = x; if (x > x1) x1 = x; if (y < y0) y0 = y; if (y > y1) y1 = y; }
|
|
33
|
+
return [x0, y0, x1, y1];
|
|
34
|
+
};
|
|
35
|
+
const boxesMeet = (a, b) => a[0] <= b[2] + EPS && b[0] <= a[2] + EPS && a[1] <= b[3] + EPS && b[1] <= a[3] + EPS;
|
|
36
|
+
|
|
37
|
+
const ringsMeet = (ra, rb) => {
|
|
38
|
+
if (!boxesMeet(ra.box, rb.box)) return false;
|
|
39
|
+
const A = ra.pts, B = rb.pts;
|
|
40
|
+
for (let i = 0; i < A.length; i++) {
|
|
41
|
+
const a = A[i], b = A[(i + 1) % A.length];
|
|
42
|
+
const ex0 = Math.min(a[0], b[0]) - EPS, ex1 = Math.max(a[0], b[0]) + EPS;
|
|
43
|
+
const ey0 = Math.min(a[1], b[1]) - EPS, ey1 = Math.max(a[1], b[1]) + EPS;
|
|
44
|
+
if (ex1 < rb.box[0] || ex0 > rb.box[2] || ey1 < rb.box[1] || ey0 > rb.box[3]) continue;
|
|
45
|
+
for (let j = 0; j < B.length; j++) {
|
|
46
|
+
const c = B[j], d = B[(j + 1) % B.length];
|
|
47
|
+
if (Math.max(c[0], d[0]) < ex0 || Math.min(c[0], d[0]) > ex1 ||
|
|
48
|
+
Math.max(c[1], d[1]) < ey0 || Math.min(c[1], d[1]) > ey1) continue;
|
|
49
|
+
if (segsMeet(a, b, c, d)) return true;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return false;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// p is in the region's MATERIAL: inside the outer ring, outside every hole.
|
|
56
|
+
const inMaterial = (p, r) =>
|
|
57
|
+
boxesMeet(r.outer.box, [p[0], p[1], p[0], p[1]]) && pointInRing(p, r.outer.pts) &&
|
|
58
|
+
!r.holes.some((h) => pointInRing(p, h.pts));
|
|
59
|
+
|
|
60
|
+
const regionsInteract = (a, b) => {
|
|
61
|
+
if (!boxesMeet(a.outer.box, b.outer.box)) return false;
|
|
62
|
+
for (const ra of [a.outer, ...a.holes])
|
|
63
|
+
for (const rb of [b.outer, ...b.holes])
|
|
64
|
+
if (ringsMeet(ra, rb)) return true;
|
|
65
|
+
// No boundary contact: each ring of one is wholly inside or outside the other, so one
|
|
66
|
+
// vertex of each outer decides.
|
|
67
|
+
return inMaterial(b.outer.pts[0], a) || inMaterial(a.outer.pts[0], b);
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
// regions: [{outer, holes}] (curve-native contours). Returns arrays of the ORIGINAL
|
|
71
|
+
// region objects: singletons are independent of everything else; larger groups
|
|
72
|
+
// interact transitively and need a union. Groups come out in first-member order.
|
|
73
|
+
export function groupInteractingRegions(regions, segs) {
|
|
74
|
+
const ring = (c) => { const pts = tessellateContour(c, segs); return { pts, box: ringBox(pts) }; };
|
|
75
|
+
const info = regions.map((rg) => ({ outer: ring(rg.outer), holes: rg.holes.map(ring) }));
|
|
76
|
+
const parent = regions.map((_, i) => i);
|
|
77
|
+
const find = (i) => { while (parent[i] !== i) { parent[i] = parent[parent[i]]; i = parent[i]; } return i; };
|
|
78
|
+
|
|
79
|
+
// Sweep along x: only regions whose x-extents overlap can interact.
|
|
80
|
+
const order = regions.map((_, i) => i).sort((i, j) => info[i].outer.box[0] - info[j].outer.box[0]);
|
|
81
|
+
for (let oi = 0; oi < order.length; oi++) {
|
|
82
|
+
const i = order[oi];
|
|
83
|
+
for (let oj = oi + 1; oj < order.length; oj++) {
|
|
84
|
+
const j = order[oj];
|
|
85
|
+
if (info[j].outer.box[0] > info[i].outer.box[2] + EPS) break;
|
|
86
|
+
if (find(i) === find(j)) continue;
|
|
87
|
+
if (regionsInteract(info[i], info[j])) parent[find(j)] = find(i);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const groups = new Map();
|
|
91
|
+
regions.forEach((rg, i) => {
|
|
92
|
+
const root = find(i);
|
|
93
|
+
if (!groups.has(root)) groups.set(root, []);
|
|
94
|
+
groups.get(root).push(rg);
|
|
95
|
+
});
|
|
96
|
+
// Map preserves insertion order: first member by index → stable output.
|
|
97
|
+
return [...groups.values()];
|
|
98
|
+
}
|
|
@@ -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);
|
package/src/framework/mount.js
CHANGED
|
@@ -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`
|
|
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
|
+
}
|