partforge 0.136.1 → 0.137.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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 KEY_COLOR = 0xffffff, KEY_INTENSITY = 1.45;
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
- return new THREE.HemisphereLight(0xdce9ff, 0x687586, 1.35);
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(KEY_COLOR, KEY_INTENSITY);
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(FILL_COLOR, FILL_INTENSITY);
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(KEY_COLOR, KEY_INTENSITY);
34
- const fill = new THREE.DirectionalLight(FILL_COLOR, FILL_INTENSITY);
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
- // Offsets from the camera, in camera-space multiples of the camera-to-target distance:
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";