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.
- package/docs/AUTHORING-PARTS.md +16 -8
- package/package.json +4 -2
- package/src/framework/contact-shadow-plane.js +30 -0
- package/src/framework/contact-shadow.js +83 -0
- package/src/framework/materials/physical.js +1 -4
- package/src/framework/materials/resolve.js +6 -0
- package/src/framework/mount.js +2 -1
- package/src/framework/renderStyles.js +146 -0
- package/src/framework/softrender/camera.js +38 -0
- package/src/framework/softrender/dfgLut.js +65 -0
- package/src/framework/softrender/edges.js +39 -0
- package/src/framework/softrender/index.js +136 -0
- package/src/framework/softrender/raster.js +78 -0
- package/src/framework/softrender/shade.js +96 -0
- package/src/framework/softrender/triangles.js +9 -0
- package/src/framework/style-camera.js +174 -0
- package/src/framework/viewer-lighting.js +11 -45
- package/src/framework/viewer.js +107 -76
- package/src/testing/render.js +40 -176
- package/src/testing.js +2 -1
- package/types/index.d.ts +2 -0
- package/types/testing.d.ts +27 -1
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Triangle rasterizer into a G-buffer: nearest view depth, perspective-correct
|
|
2
|
+
// interpolated normal, and which sub-part owns each sample. No culling — the
|
|
3
|
+
// z-buffer resolves closed meshes, and a no-normals mesh gets a two-sided face
|
|
4
|
+
// normal turned toward the camera. A triangle crossing the near plane is
|
|
5
|
+
// skipped: the framing keeps the camera well outside the part, so only
|
|
6
|
+
// degenerate input can produce one, and clipping it buys nothing here.
|
|
7
|
+
import { triangleCount } from "./triangles.js";
|
|
8
|
+
|
|
9
|
+
export const NEAR = 1e-3;
|
|
10
|
+
|
|
11
|
+
export function createGBuffer(width, height) {
|
|
12
|
+
const n = width * height;
|
|
13
|
+
return { width, height, depth: new Float32Array(n).fill(Infinity), normal: new Float32Array(n * 3), owner: new Int16Array(n).fill(-1) };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const edge = (ax, ay, bx, by, px, py) => (bx - ax) * (py - ay) - (by - ay) * (px - ax);
|
|
17
|
+
|
|
18
|
+
export function rasterizeMesh(gb, cam, mesh, owner) {
|
|
19
|
+
const P = mesh.positions, N = mesh.normals?.length ? mesh.normals : null;
|
|
20
|
+
const { width: W, height: H, depth, normal, owner: own } = gb;
|
|
21
|
+
const n = triangleCount(mesh);
|
|
22
|
+
const I = mesh.indices?.length ? mesh.indices : null;
|
|
23
|
+
const a = [0, 0, 0], b = [0, 0, 0], c = [0, 0, 0]; // scratch: no per-vertex allocation
|
|
24
|
+
for (let t = 0; t < n; t++) {
|
|
25
|
+
// triangleOffsets, inlined: its returned array was a per-triangle allocation
|
|
26
|
+
const ai = I ? I[t * 3] * 3 : t * 9, bi = I ? I[t * 3 + 1] * 3 : t * 9 + 3, ci = I ? I[t * 3 + 2] * 3 : t * 9 + 6;
|
|
27
|
+
cam.project(P[ai], P[ai + 1], P[ai + 2], a);
|
|
28
|
+
cam.project(P[bi], P[bi + 1], P[bi + 2], b);
|
|
29
|
+
cam.project(P[ci], P[ci + 1], P[ci + 2], c);
|
|
30
|
+
if (a[2] < NEAR || b[2] < NEAR || c[2] < NEAR) continue;
|
|
31
|
+
const area = edge(a[0], a[1], b[0], b[1], c[0], c[1]);
|
|
32
|
+
if (Math.abs(area) < 1e-12) continue;
|
|
33
|
+
const invArea = 1 / area;
|
|
34
|
+
|
|
35
|
+
let fx = 0, fy = 0, fz = 0;
|
|
36
|
+
if (!N) {
|
|
37
|
+
const ux = P[bi] - P[ai], uy = P[bi + 1] - P[ai + 1], uz = P[bi + 2] - P[ai + 2];
|
|
38
|
+
const vx = P[ci] - P[ai], vy = P[ci + 1] - P[ai + 1], vz = P[ci + 2] - P[ai + 2];
|
|
39
|
+
fx = uy * vz - uz * vy; fy = uz * vx - ux * vz; fz = ux * vy - uy * vx;
|
|
40
|
+
const l = Math.hypot(fx, fy, fz) || 1; fx /= l; fy /= l; fz /= l;
|
|
41
|
+
// turn toward the camera: compare with the direction to the camera
|
|
42
|
+
const tx = cam.position[0] - P[ai], ty = cam.position[1] - P[ai + 1], tz = cam.position[2] - P[ai + 2];
|
|
43
|
+
if (fx * tx + fy * ty + fz * tz < 0) { fx = -fx; fy = -fy; fz = -fz; }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const wa = 1 / a[2], wb = 1 / b[2], wc = 1 / c[2];
|
|
47
|
+
const x0 = Math.max(0, Math.floor(Math.min(a[0], b[0], c[0]))), x1 = Math.min(W - 1, Math.ceil(Math.max(a[0], b[0], c[0])));
|
|
48
|
+
const y0 = Math.max(0, Math.floor(Math.min(a[1], b[1], c[1]))), y1 = Math.min(H - 1, Math.ceil(Math.max(a[1], b[1], c[1])));
|
|
49
|
+
// Barycentrics are affine in screen space: step them per pixel instead of
|
|
50
|
+
// re-evaluating both edge functions (the dominant cost on dense meshes).
|
|
51
|
+
const d0x = -(c[1] - b[1]) * invArea, d1x = -(a[1] - c[1]) * invArea;
|
|
52
|
+
for (let y = y0; y <= y1; y++) {
|
|
53
|
+
const py = y + 0.5, px0 = x0 + 0.5;
|
|
54
|
+
// dividing by the signed area makes "inside" non-negative for either winding
|
|
55
|
+
let l0 = edge(b[0], b[1], c[0], c[1], px0, py) * invArea;
|
|
56
|
+
let l1 = edge(c[0], c[1], a[0], a[1], px0, py) * invArea;
|
|
57
|
+
for (let x = x0; x <= x1; x++, l0 += d0x, l1 += d1x) {
|
|
58
|
+
const l2 = 1 - l0 - l1;
|
|
59
|
+
if (l0 < 0 || l1 < 0 || l2 < 0) continue;
|
|
60
|
+
const inv = l0 * wa + l1 * wb + l2 * wc;
|
|
61
|
+
const z = 1 / inv;
|
|
62
|
+
const k = y * W + x;
|
|
63
|
+
if (z >= depth[k]) continue;
|
|
64
|
+
depth[k] = z; own[k] = owner;
|
|
65
|
+
if (N) {
|
|
66
|
+
const p0 = (l0 * wa) / inv, p1 = (l1 * wb) / inv, p2 = (l2 * wc) / inv;
|
|
67
|
+
let nx = p0 * N[ai] + p1 * N[bi] + p2 * N[ci];
|
|
68
|
+
let ny = p0 * N[ai + 1] + p1 * N[bi + 1] + p2 * N[ci + 1];
|
|
69
|
+
let nz = p0 * N[ai + 2] + p1 * N[bi + 2] + p2 * N[ci + 2];
|
|
70
|
+
const l = Math.sqrt(nx * nx + ny * ny + nz * nz) || 1;
|
|
71
|
+
normal[k * 3] = nx / l; normal[k * 3 + 1] = ny / l; normal[k * 3 + 2] = nz / l;
|
|
72
|
+
} else {
|
|
73
|
+
normal[k * 3] = fx; normal[k * 3 + 1] = fy; normal[k * 3 + 2] = fz;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// Per-sample shading = three.js MeshStandardMaterial under a HemisphereLight
|
|
2
|
+
// and two DirectionalLights, no environment map, no tone mapping (the CAD and
|
|
3
|
+
// thumbnail captures render LDR, linear). Ported from three r184's shader
|
|
4
|
+
// chunks (common, lights_physical_pars_fragment, lights_pars_begin,
|
|
5
|
+
// lights_physical_fragment); if three changes them, re-port — the parity
|
|
6
|
+
// script is what notices.
|
|
7
|
+
//
|
|
8
|
+
// r184 differs from the classic Lambert + GGX picture even with no env map:
|
|
9
|
+
// direct specular carries a multiscattering term that reads the DFG LUT.
|
|
10
|
+
// Not ported: `geometryRoughness` (a screen-space derivative of the geometric
|
|
11
|
+
// normal, nonzero only across creases / smooth-normal gradients).
|
|
12
|
+
import { srgbHexToLinear } from "../renderStyles.js";
|
|
13
|
+
import { captureLightPoses } from "../style-camera.js";
|
|
14
|
+
import { dfgLut } from "./dfgLut.js";
|
|
15
|
+
|
|
16
|
+
const RECIPROCAL_PI = 1 / Math.PI;
|
|
17
|
+
const EPSILON = 1e-6;
|
|
18
|
+
const FAVG_K = 0.047619; // 1/21
|
|
19
|
+
const sat = (x) => (x < 0 ? 0 : x > 1 ? 1 : x);
|
|
20
|
+
|
|
21
|
+
export function prepareMaterial({ color, metalness, roughness }) {
|
|
22
|
+
const base = srgbHexToLinear(color);
|
|
23
|
+
return {
|
|
24
|
+
diffuse: base.map((c) => c * (1 - metalness)), // diffuseContribution
|
|
25
|
+
f0: base.map((c) => 0.04 * (1 - metalness) + c * metalness), // specularColorBlended
|
|
26
|
+
roughness: Math.min(Math.max(roughness, 0.0525), 1),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function prepareLights(lights, pose) {
|
|
31
|
+
const poses = captureLightPoses(pose, lights);
|
|
32
|
+
const lin = (hex, k) => srgbHexToLinear(hex).map((c) => c * k);
|
|
33
|
+
const dirTo = (p) => {
|
|
34
|
+
const d = [p[0] - pose.target[0], p[1] - pose.target[1], p[2] - pose.target[2]];
|
|
35
|
+
const l = Math.hypot(...d) || 1;
|
|
36
|
+
return d.map((c) => c / l);
|
|
37
|
+
};
|
|
38
|
+
return {
|
|
39
|
+
sky: lin(lights.hemisphere.sky, lights.hemisphere.intensity),
|
|
40
|
+
ground: lin(lights.hemisphere.ground, lights.hemisphere.intensity),
|
|
41
|
+
up: [0, 1, 0],
|
|
42
|
+
directional: [
|
|
43
|
+
{ dir: dirTo(poses.key), color: lin(lights.key.color, lights.key.intensity) },
|
|
44
|
+
{ dir: dirTo(poses.fill), color: lin(lights.fill.color, lights.fill.intensity) },
|
|
45
|
+
],
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function shade(N, V, mat, lights, out, o) {
|
|
50
|
+
const { diffuse, f0, roughness } = mat;
|
|
51
|
+
const dotNV = sat(N[0] * V[0] + N[1] * V[1] + N[2] * V[2]);
|
|
52
|
+
const dfgV = dfgLut(roughness, dotNV);
|
|
53
|
+
const EssV = dfgV[0] + dfgV[1];
|
|
54
|
+
const EmsV = 1 - EssV;
|
|
55
|
+
const col = [0, 0, 0];
|
|
56
|
+
|
|
57
|
+
// Indirect: hemisphere irradiance only reaches RE_IndirectDiffuse. three
|
|
58
|
+
// passes RE_IndirectSpecular the env-map irradiance/radiance, both 0 here,
|
|
59
|
+
// so it contributes nothing without an environment map.
|
|
60
|
+
const w = 0.5 * (N[0] * lights.up[0] + N[1] * lights.up[1] + N[2] * lights.up[2]) + 0.5;
|
|
61
|
+
for (let i = 0; i < 3; i++) {
|
|
62
|
+
const irr = lights.ground[i] + (lights.sky[i] - lights.ground[i]) * w;
|
|
63
|
+
col[i] = irr * diffuse[i] * RECIPROCAL_PI;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const alpha = roughness * roughness, a2 = alpha * alpha;
|
|
67
|
+
for (const { dir: L, color } of lights.directional) {
|
|
68
|
+
const dotNL = sat(N[0] * L[0] + N[1] * L[1] + N[2] * L[2]);
|
|
69
|
+
if (dotNL <= 0) continue;
|
|
70
|
+
let hx = L[0] + V[0], hy = L[1] + V[1], hz = L[2] + V[2];
|
|
71
|
+
const hl = Math.hypot(hx, hy, hz) || 1; hx /= hl; hy /= hl; hz /= hl;
|
|
72
|
+
const dotNH = sat(N[0] * hx + N[1] * hy + N[2] * hz);
|
|
73
|
+
const dotVH = sat(V[0] * hx + V[1] * hy + V[2] * hz);
|
|
74
|
+
// F_Schlick, three's exp2 form, f90 = 1
|
|
75
|
+
const fresnel = 2 ** ((-5.55473 * dotVH - 6.98316) * dotVH);
|
|
76
|
+
// V_GGX_SmithCorrelated
|
|
77
|
+
const gv = dotNL * Math.sqrt(a2 + (1 - a2) * dotNV * dotNV);
|
|
78
|
+
const gl = dotNV * Math.sqrt(a2 + (1 - a2) * dotNL * dotNL);
|
|
79
|
+
const vis = 0.5 / Math.max(gv + gl, EPSILON);
|
|
80
|
+
// D_GGX
|
|
81
|
+
const denom = dotNH * dotNH * (a2 - 1) + 1;
|
|
82
|
+
const D = (RECIPROCAL_PI * a2) / (denom * denom);
|
|
83
|
+
const sv = vis * D;
|
|
84
|
+
// BRDF_GGX_Multiscatter's compensation (Turquin)
|
|
85
|
+
const dfgL = dfgLut(roughness, dotNL);
|
|
86
|
+
const EmsL = 1 - (dfgL[0] + dfgL[1]);
|
|
87
|
+
for (let i = 0; i < 3; i++) {
|
|
88
|
+
const single = (f0[i] * (1 - fresnel) + fresnel) * sv;
|
|
89
|
+
const favg = f0[i] + (1 - f0[i]) * FAVG_K;
|
|
90
|
+
const fssV = f0[i] * dfgV[0] + dfgV[1], fssL = f0[i] * dfgL[0] + dfgL[1];
|
|
91
|
+
const multi = ((fssV * fssL * favg) / (1 - EmsV * EmsL * favg + EPSILON)) * EmsV * EmsL;
|
|
92
|
+
col[i] += dotNL * color[i] * (diffuse[i] * RECIPROCAL_PI + single + multi);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
out[o] = col[0]; out[o + 1] = col[1]; out[o + 2] = col[2];
|
|
96
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Manifold meshes are a non-indexed soup (3 consecutive vertices per
|
|
2
|
+
// triangle); OCCT meshes are indexed. Offsets returned are into the flat
|
|
3
|
+
// positions/normals arrays (vertex index × 3).
|
|
4
|
+
export const triangleCount = (m) => (m.indices?.length ? m.indices.length / 3 : (m.positions?.length ?? 0) / 9) | 0;
|
|
5
|
+
|
|
6
|
+
export function triangleOffsets(m, t) {
|
|
7
|
+
if (m.indices?.length) return [m.indices[t * 3] * 3, m.indices[t * 3 + 1] * 3, m.indices[t * 3 + 2] * 3];
|
|
8
|
+
return [t * 9, t * 9 + 3, t * 9 + 6];
|
|
9
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// Where a styled capture puts its camera, for BOTH renderers: the viewer's
|
|
2
|
+
// captures (its throwaway thumbnail scene and its live-scene agent renders)
|
|
3
|
+
// and the CPU renderer call this one function, so they frame every view
|
|
4
|
+
// identically. World space is the viewer's Y-up frame
|
|
5
|
+
// (the pivot's rotation.x = -PI/2 maps model (x, y, z) -> world (x, z, -y)).
|
|
6
|
+
// Pure — no three.js.
|
|
7
|
+
import { cameraPoseForView } from "./view-angles.js";
|
|
8
|
+
import { RENDER_STYLES } from "./renderStyles.js";
|
|
9
|
+
|
|
10
|
+
const DEFAULT_RADIUS = 10; // the viewer's own `|| 10` fallback for an empty box
|
|
11
|
+
const RECENTER_PASSES = 3; // fitPoseToPoints' recentre/re-solve rounds (see there)
|
|
12
|
+
|
|
13
|
+
export function modelToWorld(arr) {
|
|
14
|
+
const out = new Float32Array(arr.length);
|
|
15
|
+
for (let i = 0; i + 2 < arr.length; i += 3) {
|
|
16
|
+
out[i] = arr[i]; out[i + 1] = arr[i + 2]; out[i + 2] = -arr[i + 1];
|
|
17
|
+
}
|
|
18
|
+
return out;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function boundsOf(arrays) {
|
|
22
|
+
const min = [Infinity, Infinity, Infinity], max = [-Infinity, -Infinity, -Infinity];
|
|
23
|
+
let any = false;
|
|
24
|
+
for (const a of arrays) {
|
|
25
|
+
for (let i = 0; i + 2 < (a?.length ?? 0); i += 3) {
|
|
26
|
+
any = true;
|
|
27
|
+
for (let k = 0; k < 3; k++) { if (a[i + k] < min[k]) min[k] = a[i + k]; if (a[i + k] > max[k]) max[k] = a[i + k]; }
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return any ? { min, max } : null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const sub = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
|
|
34
|
+
const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
|
|
35
|
+
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]];
|
|
36
|
+
const norm = (v) => { const l = Math.hypot(v[0], v[1], v[2]) || 1; return [v[0] / l, v[1] / l, v[2] / l]; };
|
|
37
|
+
|
|
38
|
+
// The camera basis a fit works in: d (target -> camera), right and trueUp,
|
|
39
|
+
// from the canonical view's direction and up vector.
|
|
40
|
+
function fitBasis(view, center) {
|
|
41
|
+
const base = cameraPoseForView(view, { center, radius: 1 });
|
|
42
|
+
const d = norm(sub(base.position, center)); // centre -> camera
|
|
43
|
+
const f = [-d[0], -d[1], -d[2]];
|
|
44
|
+
let right = cross(f, base.up);
|
|
45
|
+
if (Math.hypot(...right) < 1e-9) right = cross(f, [0, 0, 1]);
|
|
46
|
+
right = norm(right);
|
|
47
|
+
return { d, right, up: cross(right, f), baseUp: base.up };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Fit a view to the geometry itself rather than to its bounding box: a box's
|
|
51
|
+
// corners stand well outside a long, thin or L-shaped part, which then sits
|
|
52
|
+
// small and off-centre. `points` are flat WORLD (Y-up) xyz triples; null when
|
|
53
|
+
// there is nothing to frame (no points, or one point repeated).
|
|
54
|
+
//
|
|
55
|
+
// The view keeps its canonical direction and up. For a point at offset r from
|
|
56
|
+
// the target, at camera distance D along d, it is inside `fill` of the frame
|
|
57
|
+
// when
|
|
58
|
+
// |r·right| <= (D - r·d)·tanH·aspect·fill and |r·up| <= (D - r·d)·tanH·fill
|
|
59
|
+
// so the nearest such D is the max over points of the two rearranged bounds.
|
|
60
|
+
// Pass 1 aims at the bbox centre. The projected extent is then generally
|
|
61
|
+
// lopsided (perspective, and a shape that is not symmetric about its box
|
|
62
|
+
// centre), so the target slides sideways by the projected NDC bbox centre —
|
|
63
|
+
// converted back to world units at distance D — and D is re-solved around the
|
|
64
|
+
// shifted target. One recentre is not quite enough for a part with real depth
|
|
65
|
+
// along the view (a 60 mm bar seen at iso was still ~0.04 NDC off after it,
|
|
66
|
+
// because the re-solved D changes the perspective the recentre measured), so
|
|
67
|
+
// the recentre/re-solve runs RECENTER_PASSES times: ~0.002 on that bar. The
|
|
68
|
+
// last step is always a re-solve, so every point stays inside `fill` whatever
|
|
69
|
+
// the recentring achieved. (Looking straight down a long bar puts its near end
|
|
70
|
+
// almost at the eye, where no amount of sliding centres it; it stays in frame.)
|
|
71
|
+
export function fitPoseToPoints(view, points, { fov, aspect = 1, fill }) {
|
|
72
|
+
const box = boundsOf([points]);
|
|
73
|
+
if (!box || box.min.every((v, i) => v === box.max[i])) return null;
|
|
74
|
+
const center = [0, 1, 2].map((i) => (box.min[i] + box.max[i]) / 2);
|
|
75
|
+
const { d, right, up, baseUp } = fitBasis(view, center);
|
|
76
|
+
const tanH = Math.tan((fov * Math.PI) / 360);
|
|
77
|
+
const t = tanH * fill;
|
|
78
|
+
const n = points.length - (points.length % 3);
|
|
79
|
+
|
|
80
|
+
// Per point: [depth, x, y] = r·d, r·right, r·up for r = p − target. Scalar
|
|
81
|
+
// and allocation-free — this runs over every vertex of the part, per view.
|
|
82
|
+
const project = (target, fn) => {
|
|
83
|
+
for (let i = 0; i < n; i += 3) {
|
|
84
|
+
const rx = points[i] - target[0], ry = points[i + 1] - target[1], rz = points[i + 2] - target[2];
|
|
85
|
+
fn(rx * d[0] + ry * d[1] + rz * d[2], rx * right[0] + ry * right[1] + rz * right[2], rx * up[0] + ry * up[1] + rz * up[2]);
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
const distance = (target) => {
|
|
89
|
+
let D = 0;
|
|
90
|
+
project(target, (depth, x, y) => {
|
|
91
|
+
D = Math.max(D, depth + Math.abs(x) / (t * aspect), depth + Math.abs(y) / t);
|
|
92
|
+
});
|
|
93
|
+
return D;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
let target = center;
|
|
97
|
+
let D = distance(target);
|
|
98
|
+
for (let pass = 0; pass < RECENTER_PASSES; pass++) {
|
|
99
|
+
// Recentre: the NDC bbox of every point seen from target + d·D. A point at
|
|
100
|
+
// the eye plane (only a line along the view axis puts one there) has no
|
|
101
|
+
// projection and is left out rather than poisoning the bbox with Infinity.
|
|
102
|
+
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
|
|
103
|
+
project(target, (depth, x, y) => {
|
|
104
|
+
const z = D - depth;
|
|
105
|
+
if (!(z > 1e-12)) return;
|
|
106
|
+
const nx = x / (z * tanH * aspect), ny = y / (z * tanH);
|
|
107
|
+
if (nx < minX) minX = nx; if (nx > maxX) maxX = nx;
|
|
108
|
+
if (ny < minY) minY = ny; if (ny > maxY) maxY = ny;
|
|
109
|
+
});
|
|
110
|
+
if (!(minX <= maxX)) break;
|
|
111
|
+
const sx = ((minX + maxX) / 2) * D * tanH * aspect, sy = ((minY + maxY) / 2) * D * tanH;
|
|
112
|
+
target = [0, 1, 2].map((i) => target[i] + right[i] * sx + up[i] * sy);
|
|
113
|
+
D = distance(target);
|
|
114
|
+
}
|
|
115
|
+
return { position: [0, 1, 2].map((i) => target[i] + d[i] * D), up: baseUp, target };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// `points` (flat world xyz, optional) lets a "fit" style frame the geometry
|
|
119
|
+
// itself (fitPoseToPoints); without them it fits the box's eight corners.
|
|
120
|
+
export function stylePose(style, view, box, { aspect = 1, points } = {}) {
|
|
121
|
+
const fov = style.camera.fov;
|
|
122
|
+
const min = box?.min ?? [0, 0, 0], max = box?.max ?? [0, 0, 0];
|
|
123
|
+
const center = [(min[0] + max[0]) / 2, (min[1] + max[1]) / 2, (min[2] + max[2]) / 2];
|
|
124
|
+
const size = sub(max, min);
|
|
125
|
+
const diag = Math.hypot(size[0], size[1], size[2]);
|
|
126
|
+
// Enclosing radius for the depth planes; a shadow plane reaches 2·blur·diag
|
|
127
|
+
// past the footprint (contact-shadow.js pads by that much).
|
|
128
|
+
const pad = style.shadow ? 2 * style.shadow.blur * diag : 0;
|
|
129
|
+
const sceneBounds = { center, radius: (diag / 2 || DEFAULT_RADIUS) + pad };
|
|
130
|
+
|
|
131
|
+
if (style.camera.framing === "canonical" || diag === 0) {
|
|
132
|
+
const radius = Math.max(size[0], size[1], size[2]) / 2 || DEFAULT_RADIUS;
|
|
133
|
+
return { pose: cameraPoseForView(view, { center, radius }), fov, sceneBounds };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const fitted = points?.length >= 3 && fitPoseToPoints(view, points, { fov, aspect, fill: style.camera.fill });
|
|
137
|
+
if (fitted) return { pose: fitted, fov, sceneBounds };
|
|
138
|
+
|
|
139
|
+
// Fallback fit, with no points to hand: the same bound over the box's eight
|
|
140
|
+
// corners, aimed at the box centre (see fitPoseToPoints for the inequality).
|
|
141
|
+
const { d, right, up, baseUp } = fitBasis(view, center);
|
|
142
|
+
const t = Math.tan((fov * Math.PI) / 360) * style.camera.fill;
|
|
143
|
+
let D = 0;
|
|
144
|
+
for (const x of [min[0], max[0]]) for (const y of [min[1], max[1]]) for (const z of [min[2], max[2]]) {
|
|
145
|
+
const r = sub([x, y, z], center);
|
|
146
|
+
const depth = dot(r, d);
|
|
147
|
+
D = Math.max(D, depth + Math.abs(dot(r, right)) / (t * aspect), depth + Math.abs(dot(r, up)) / t);
|
|
148
|
+
}
|
|
149
|
+
const position = [center[0] + d[0] * D, center[1] + d[1] * D, center[2] + d[2] * D];
|
|
150
|
+
return { pose: { position, up: baseUp, target: center }, fov, sceneBounds };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// The offsets (renderStyles.js) are camera-space multiples of the camera-to-target
|
|
154
|
+
// distance: the key sits over the viewer's shoulder (up and to the right, ~41° off the
|
|
155
|
+
// view axis), the fill opposes it from the left at eye level. Both still shine mostly
|
|
156
|
+
// ALONG the view direction, so whatever the camera can see is lit.
|
|
157
|
+
|
|
158
|
+
// World-space positions for the capture key/fill, given a camera pose (the same
|
|
159
|
+
// `{position, up, target}` shape cameraPoseForView returns). Pure — no THREE, no scene.
|
|
160
|
+
export function captureLightPoses({ position, up, target }, lights = RENDER_STYLES.cad.lights) {
|
|
161
|
+
const forward = norm(sub(target, position));
|
|
162
|
+
// Camera basis. A canonical view never passes an `up` parallel to its own view axis,
|
|
163
|
+
// but a caller could, and a degenerate basis would put NaN into the light positions.
|
|
164
|
+
let right = cross(forward, up);
|
|
165
|
+
if (Math.hypot(...right) < 1e-6) right = cross(forward, [0, 0, 1]);
|
|
166
|
+
if (Math.hypot(...right) < 1e-6) right = cross(forward, [0, 1, 0]);
|
|
167
|
+
right = norm(right);
|
|
168
|
+
const trueUp = norm(cross(right, forward));
|
|
169
|
+
const dist = Math.hypot(...sub(target, position)) || 1;
|
|
170
|
+
const place = (offset) => [0, 1, 2].map(
|
|
171
|
+
(i) => position[i] + (right[i] * offset.right + trueUp[i] * offset.up) * dist,
|
|
172
|
+
);
|
|
173
|
+
return { key: place(lights.key.offset), fill: place(lights.fill.offset) };
|
|
174
|
+
}
|
|
@@ -1,20 +1,21 @@
|
|
|
1
1
|
import * as THREE from "three";
|
|
2
|
+
import { RENDER_STYLES } from "./renderStyles.js";
|
|
2
3
|
|
|
3
|
-
const
|
|
4
|
-
const FILL_COLOR = 0xe5efff, FILL_INTENSITY = 0.65;
|
|
4
|
+
const CAD = RENDER_STYLES.cad.lights;
|
|
5
5
|
|
|
6
6
|
// The persistent sky/ground ambient. Shared with the offscreen thumbnail path
|
|
7
7
|
// (viewer.js renderMeshPayloads), which builds its own throwaway scene and so needs
|
|
8
8
|
// the same hemisphere fill to avoid rendering faces outside the key/fill cones near-black.
|
|
9
|
-
export function createHemisphereLight() {
|
|
10
|
-
|
|
9
|
+
export function createHemisphereLight(lights = CAD) {
|
|
10
|
+
const h = lights.hemisphere;
|
|
11
|
+
return new THREE.HemisphereLight(h.sky, h.ground, h.intensity);
|
|
11
12
|
}
|
|
12
13
|
|
|
13
14
|
export function addViewerLights(scene) {
|
|
14
15
|
const hemisphere = createHemisphereLight();
|
|
15
|
-
const key = new THREE.DirectionalLight(
|
|
16
|
+
const key = new THREE.DirectionalLight(CAD.key.color, CAD.key.intensity);
|
|
16
17
|
key.position.set(8, 14, 10);
|
|
17
|
-
const fill = new THREE.DirectionalLight(
|
|
18
|
+
const fill = new THREE.DirectionalLight(CAD.fill.color, CAD.fill.intensity);
|
|
18
19
|
fill.position.set(-10, 6, -8);
|
|
19
20
|
|
|
20
21
|
scene.add(hemisphere, key, fill);
|
|
@@ -29,45 +30,10 @@ export function addViewerLights(scene) {
|
|
|
29
30
|
// a 1 mm snap barb. These two lights are the capture-time stand-ins, swapped in for the
|
|
30
31
|
// duration of one offscreen render (see viewer.js renderOffscreen) and posed relative to
|
|
31
32
|
// the view axis, so every canonical view is exposed and shaded the same way.
|
|
32
|
-
export function createCaptureLights() {
|
|
33
|
-
const key = new THREE.DirectionalLight(
|
|
34
|
-
const fill = new THREE.DirectionalLight(
|
|
33
|
+
export function createCaptureLights(lights = CAD) {
|
|
34
|
+
const key = new THREE.DirectionalLight(lights.key.color, lights.key.intensity);
|
|
35
|
+
const fill = new THREE.DirectionalLight(lights.fill.color, lights.fill.intensity);
|
|
35
36
|
return { key, fill };
|
|
36
37
|
}
|
|
37
38
|
|
|
38
|
-
|
|
39
|
-
// the key sits over the viewer's shoulder (up and to the right, ~41° off the view axis),
|
|
40
|
-
// the fill opposes it from the left at eye level. Both still shine mostly ALONG the view
|
|
41
|
-
// direction, so whatever the camera can see is lit.
|
|
42
|
-
const KEY_OFFSET = { right: 0.45, up: 0.75 };
|
|
43
|
-
const FILL_OFFSET = { right: -0.7, up: 0.15 };
|
|
44
|
-
|
|
45
|
-
const sub = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
|
|
46
|
-
const cross = (a, b) => [
|
|
47
|
-
a[1] * b[2] - a[2] * b[1],
|
|
48
|
-
a[2] * b[0] - a[0] * b[2],
|
|
49
|
-
a[0] * b[1] - a[1] * b[0],
|
|
50
|
-
];
|
|
51
|
-
const length = (v) => Math.hypot(v[0], v[1], v[2]);
|
|
52
|
-
const norm = (v) => {
|
|
53
|
-
const l = length(v) || 1;
|
|
54
|
-
return [v[0] / l, v[1] / l, v[2] / l];
|
|
55
|
-
};
|
|
56
|
-
|
|
57
|
-
// World-space positions for the capture key/fill, given a camera pose (the same
|
|
58
|
-
// `{position, up, target}` shape cameraPoseForView returns). Pure — no THREE, no scene.
|
|
59
|
-
export function captureLightPoses({ position, up, target }) {
|
|
60
|
-
const forward = norm(sub(target, position));
|
|
61
|
-
// Camera basis. A canonical view never passes an `up` parallel to its own view axis,
|
|
62
|
-
// but a caller could, and a degenerate basis would put NaN into the light positions.
|
|
63
|
-
let right = cross(forward, up);
|
|
64
|
-
if (length(right) < 1e-6) right = cross(forward, [0, 0, 1]);
|
|
65
|
-
if (length(right) < 1e-6) right = cross(forward, [0, 1, 0]);
|
|
66
|
-
right = norm(right);
|
|
67
|
-
const trueUp = norm(cross(right, forward));
|
|
68
|
-
const dist = length(sub(target, position)) || 1;
|
|
69
|
-
const place = (offset) => [0, 1, 2].map(
|
|
70
|
-
(i) => position[i] + (right[i] * offset.right + trueUp[i] * offset.up) * dist,
|
|
71
|
-
);
|
|
72
|
-
return { key: place(KEY_OFFSET), fill: place(FILL_OFFSET) };
|
|
73
|
-
}
|
|
39
|
+
export { captureLightPoses } from "./style-camera.js";
|