partforge 0.24.0 → 0.25.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/package.json
CHANGED
|
@@ -1,13 +1,66 @@
|
|
|
1
1
|
import * as THREE from "three";
|
|
2
2
|
|
|
3
|
+
const KEY_COLOR = 0xffffff, KEY_INTENSITY = 1.45;
|
|
4
|
+
const FILL_COLOR = 0xe5efff, FILL_INTENSITY = 0.65;
|
|
5
|
+
|
|
3
6
|
export function addViewerLights(scene) {
|
|
4
7
|
const hemisphere = new THREE.HemisphereLight(0xdce9ff, 0x687586, 1.35);
|
|
5
|
-
const key = new THREE.DirectionalLight(
|
|
8
|
+
const key = new THREE.DirectionalLight(KEY_COLOR, KEY_INTENSITY);
|
|
6
9
|
key.position.set(8, 14, 10);
|
|
7
|
-
const fill = new THREE.DirectionalLight(
|
|
10
|
+
const fill = new THREE.DirectionalLight(FILL_COLOR, FILL_INTENSITY);
|
|
8
11
|
fill.position.set(-10, 6, -8);
|
|
9
12
|
|
|
10
13
|
scene.add(hemisphere, key, fill);
|
|
11
14
|
|
|
12
15
|
return { hemisphere, key, fill };
|
|
13
16
|
}
|
|
17
|
+
|
|
18
|
+
// The key/fill above are fixed in WORLD space, which is right for a user who orbits
|
|
19
|
+
// into the lit hemisphere and wrong for an offscreen canonical-view capture: `bottom`,
|
|
20
|
+
// `back`, and `left` stare at faces the key at (8,14,10) never reaches, so they come
|
|
21
|
+
// back flat — hemisphere ambient only, with no shading gradient to reveal a chamfer or
|
|
22
|
+
// a 1 mm snap barb. These two lights are the capture-time stand-ins, swapped in for the
|
|
23
|
+
// duration of one offscreen render (see viewer.js renderOffscreen) and posed relative to
|
|
24
|
+
// the view axis, so every canonical view is exposed and shaded the same way.
|
|
25
|
+
export function createCaptureLights() {
|
|
26
|
+
const key = new THREE.DirectionalLight(KEY_COLOR, KEY_INTENSITY);
|
|
27
|
+
const fill = new THREE.DirectionalLight(FILL_COLOR, FILL_INTENSITY);
|
|
28
|
+
return { key, fill };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Offsets from the camera, in camera-space multiples of the camera-to-target distance:
|
|
32
|
+
// the key sits over the viewer's shoulder (up and to the right, ~41° off the view axis),
|
|
33
|
+
// the fill opposes it from the left at eye level. Both still shine mostly ALONG the view
|
|
34
|
+
// direction, so whatever the camera can see is lit.
|
|
35
|
+
const KEY_OFFSET = { right: 0.45, up: 0.75 };
|
|
36
|
+
const FILL_OFFSET = { right: -0.7, up: 0.15 };
|
|
37
|
+
|
|
38
|
+
const sub = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
|
|
39
|
+
const cross = (a, b) => [
|
|
40
|
+
a[1] * b[2] - a[2] * b[1],
|
|
41
|
+
a[2] * b[0] - a[0] * b[2],
|
|
42
|
+
a[0] * b[1] - a[1] * b[0],
|
|
43
|
+
];
|
|
44
|
+
const length = (v) => Math.hypot(v[0], v[1], v[2]);
|
|
45
|
+
const norm = (v) => {
|
|
46
|
+
const l = length(v) || 1;
|
|
47
|
+
return [v[0] / l, v[1] / l, v[2] / l];
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// World-space positions for the capture key/fill, given a camera pose (the same
|
|
51
|
+
// `{position, up, target}` shape cameraPoseForView returns). Pure — no THREE, no scene.
|
|
52
|
+
export function captureLightPoses({ position, up, target }) {
|
|
53
|
+
const forward = norm(sub(target, position));
|
|
54
|
+
// Camera basis. A canonical view never passes an `up` parallel to its own view axis,
|
|
55
|
+
// but a caller could, and a degenerate basis would put NaN into the light positions.
|
|
56
|
+
let right = cross(forward, up);
|
|
57
|
+
if (length(right) < 1e-6) right = cross(forward, [0, 0, 1]);
|
|
58
|
+
if (length(right) < 1e-6) right = cross(forward, [0, 1, 0]);
|
|
59
|
+
right = norm(right);
|
|
60
|
+
const trueUp = norm(cross(right, forward));
|
|
61
|
+
const dist = length(sub(target, position)) || 1;
|
|
62
|
+
const place = (offset) => [0, 1, 2].map(
|
|
63
|
+
(i) => position[i] + (right[i] * offset.right + trueUp[i] * offset.up) * dist,
|
|
64
|
+
);
|
|
65
|
+
return { key: place(KEY_OFFSET), fill: place(FILL_OFFSET) };
|
|
66
|
+
}
|
package/src/framework/viewer.js
CHANGED
|
@@ -5,9 +5,35 @@ import { LineSegments2 } from "three/addons/lines/LineSegments2.js";
|
|
|
5
5
|
import { LineSegmentsGeometry } from "three/addons/lines/LineSegmentsGeometry.js";
|
|
6
6
|
import { LineMaterial } from "three/addons/lines/LineMaterial.js";
|
|
7
7
|
import { createCutaway } from "./cutaway.js";
|
|
8
|
-
import { addViewerLights } from "./viewer-lighting.js";
|
|
8
|
+
import { addViewerLights, captureLightPoses, createCaptureLights } from "./viewer-lighting.js";
|
|
9
9
|
import { CANONICAL_VIEWS, cameraPoseForView } from "./view-angles.js";
|
|
10
10
|
|
|
11
|
+
// three renders into a render target in the LINEAR working colour space: as of r184
|
|
12
|
+
// WebGLRenderer only applies `outputColorSpace` on the canvas path (WebGLPrograms
|
|
13
|
+
// substitutes workingColorSpace whenever a render target is bound), so readback pixels
|
|
14
|
+
// are linear no matter what the target texture's colorSpace says. Writing them straight
|
|
15
|
+
// into a JPEG is what made captured views come back muddy and dark compared to the live
|
|
16
|
+
// canvas. Encode the transfer function ourselves. The 8-bit LUT loses precision only in
|
|
17
|
+
// the deepest shadows, which a quality-0.9 JPEG would not have preserved anyway.
|
|
18
|
+
const SRGB8 = (() => {
|
|
19
|
+
const table = new Uint8Array(256);
|
|
20
|
+
for (let i = 0; i < 256; i++) {
|
|
21
|
+
const l = i / 255;
|
|
22
|
+
table[i] = Math.round(255 * (l <= 0.0031308 ? 12.92 * l : 1.055 * l ** (1 / 2.4) - 0.055));
|
|
23
|
+
}
|
|
24
|
+
return table;
|
|
25
|
+
})();
|
|
26
|
+
|
|
27
|
+
// Linear RGBA bytes → sRGB, in place. Alpha is a coverage value, not a colour: untouched.
|
|
28
|
+
export function srgbEncodeInPlace(data) {
|
|
29
|
+
for (let i = 0; i < data.length; i += 4) {
|
|
30
|
+
data[i] = SRGB8[data[i]];
|
|
31
|
+
data[i + 1] = SRGB8[data[i + 1]];
|
|
32
|
+
data[i + 2] = SRGB8[data[i + 2]];
|
|
33
|
+
}
|
|
34
|
+
return data;
|
|
35
|
+
}
|
|
36
|
+
|
|
11
37
|
// Render a set of canonical views without disturbing the live camera/canvas.
|
|
12
38
|
// `renderer.renderOffscreen(pose)` does the GL work (temp camera → offscreen
|
|
13
39
|
// target → readback → JPEG data URL); injected so this is unit-testable without
|
|
@@ -57,7 +83,7 @@ export function createViewer(container, part) {
|
|
|
57
83
|
controls.autoRotateSpeed = 1.6;
|
|
58
84
|
|
|
59
85
|
// --- lights + grid --------------------------------------------------------
|
|
60
|
-
addViewerLights(scene);
|
|
86
|
+
const liveLights = addViewerLights(scene);
|
|
61
87
|
// 1 cm grid (mm units): 300 mm wide, 30 divisions -> 10 mm (1 cm) squares.
|
|
62
88
|
const GRID_SIZE = 300, GRID_DIVS = 30;
|
|
63
89
|
let floorY = 0; // world Y of the grid plane; set to the part's bbox bottom in frameTo
|
|
@@ -288,19 +314,47 @@ export function createViewer(container, part) {
|
|
|
288
314
|
// Offscreen render of the shared scene from an arbitrary pose → JPEG data URL.
|
|
289
315
|
// A separate WebGLRenderTarget + temp camera means the visible canvas and the
|
|
290
316
|
// live `camera` are never touched. WebGL pixels are bottom-up, so flip on encode.
|
|
291
|
-
|
|
317
|
+
//
|
|
318
|
+
// These captures are read by a model, not shown as a thumbnail (partforge-cloud's
|
|
319
|
+
// render_part_views tool feeds them straight to the agent), so they are sized and lit
|
|
320
|
+
// for reading small features: 1024² is the largest square that fits Anthropic's
|
|
321
|
+
// ~1.15 MP no-downscale budget, 4× MSAA keeps a thin wall from aliasing into noise,
|
|
322
|
+
// and the light rig follows the camera so no view is a flat silhouette.
|
|
323
|
+
const _rtSize = 1024;
|
|
292
324
|
let _rt = null;
|
|
325
|
+
let _capLights = null;
|
|
293
326
|
function renderOffscreen({ position, up, target }) {
|
|
294
|
-
_rt = _rt ?? new THREE.WebGLRenderTarget(_rtSize, _rtSize);
|
|
327
|
+
_rt = _rt ?? new THREE.WebGLRenderTarget(_rtSize, _rtSize, { samples: 4 });
|
|
328
|
+
_capLights = _capLights ?? createCaptureLights();
|
|
295
329
|
const cam = new THREE.PerspectiveCamera(45, 1, 0.1, 1000);
|
|
296
330
|
cam.position.set(position[0], position[1], position[2]);
|
|
297
331
|
cam.up.set(up[0], up[1], up[2]);
|
|
298
332
|
cam.lookAt(target[0], target[1], target[2]);
|
|
299
|
-
renderer.setRenderTarget(_rt);
|
|
300
|
-
renderer.render(scene, cam);
|
|
301
333
|
const buf = new Uint8Array(_rtSize * _rtSize * 4);
|
|
302
|
-
|
|
303
|
-
|
|
334
|
+
// Swap the world-fixed key/fill for the camera-relative pair, for this one render
|
|
335
|
+
// only. A DirectionalLight aims at its `target`, whose matrixWorld only updates
|
|
336
|
+
// while it is in the scene graph, so both go in and both come back out.
|
|
337
|
+
const poses = captureLightPoses({ position, up, target });
|
|
338
|
+
const { key: capKey, fill: capFill } = _capLights;
|
|
339
|
+
capKey.position.set(poses.key[0], poses.key[1], poses.key[2]);
|
|
340
|
+
capFill.position.set(poses.fill[0], poses.fill[1], poses.fill[2]);
|
|
341
|
+
for (const light of [capKey, capFill]) light.target.position.set(target[0], target[1], target[2]);
|
|
342
|
+
liveLights.key.visible = false;
|
|
343
|
+
liveLights.fill.visible = false;
|
|
344
|
+
scene.add(capKey, capKey.target, capFill, capFill.target);
|
|
345
|
+
try {
|
|
346
|
+
renderer.setRenderTarget(_rt);
|
|
347
|
+
renderer.render(scene, cam);
|
|
348
|
+
// render() resolves the multisample renderbuffer into the target texture, so this
|
|
349
|
+
// reads antialiased pixels.
|
|
350
|
+
renderer.readRenderTargetPixels(_rt, 0, 0, _rtSize, _rtSize, buf);
|
|
351
|
+
} finally {
|
|
352
|
+
// Never leave the user's own view unlit or pointed at the offscreen target.
|
|
353
|
+
renderer.setRenderTarget(null);
|
|
354
|
+
scene.remove(capKey, capKey.target, capFill, capFill.target);
|
|
355
|
+
liveLights.key.visible = true;
|
|
356
|
+
liveLights.fill.visible = true;
|
|
357
|
+
}
|
|
304
358
|
const canvas = document.createElement("canvas");
|
|
305
359
|
canvas.width = _rtSize; canvas.height = _rtSize;
|
|
306
360
|
const ctx = canvas.getContext("2d");
|
|
@@ -310,8 +364,9 @@ export function createViewer(container, part) {
|
|
|
310
364
|
const src = (_rtSize - 1 - y) * _rtSize * 4;
|
|
311
365
|
img.data.set(buf.subarray(src, src + _rtSize * 4), y * _rtSize * 4);
|
|
312
366
|
}
|
|
367
|
+
srgbEncodeInPlace(img.data);
|
|
313
368
|
ctx.putImageData(img, 0, 0);
|
|
314
|
-
return canvas.toDataURL("image/jpeg", 0.
|
|
369
|
+
return canvas.toDataURL("image/jpeg", 0.9);
|
|
315
370
|
}
|
|
316
371
|
|
|
317
372
|
// Render the canonical camera angles offscreen, framed to whatever is visible,
|