partforge 0.24.0 → 0.26.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,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.24.0",
3
+ "version": "0.26.0",
4
4
  "description": "Turn a declarative part definition into a parametric-CAD web app (three.js + Manifold/Replicad). Requires a Vite-based consumer.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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(0xffffff, 1.45);
8
+ const key = new THREE.DirectionalLight(KEY_COLOR, KEY_INTENSITY);
6
9
  key.position.set(8, 14, 10);
7
- const fill = new THREE.DirectionalLight(0xe5efff, 0.65);
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
+ }
@@ -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
- const _rtSize = 512;
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
- renderer.readRenderTargetPixels(_rt, 0, 0, _rtSize, _rtSize, buf);
303
- renderer.setRenderTarget(null);
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.8);
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,
@@ -5,11 +5,22 @@ import { bounds } from "./mesh.js";
5
5
 
6
6
  // Canonical view directions in MODEL space (Z-up). `dir` is the direction from
7
7
  // the part centre toward the camera; `up` is the camera up vector.
8
- const ANGLES = {
9
- iso: { dir: [1, 1, 1], up: [0, 0, 1] },
10
- front: { dir: [0, -1, 0], up: [0, 0, 1] },
11
- top: { dir: [0, 0, 1], up: [0, 1, 0] },
8
+ //
9
+ // The same seven cameras as the viewer's framework/view-angles.js, which states
10
+ // them in the viewer's Y-up WORLD space (the pivot rotates the Z-up model into
11
+ // it). The two tables are related by model (x, y, z) → world (x, z, -y), and
12
+ // test/render-angles.test.js holds them to that — an agent asking for `left`
13
+ // through the CLI and through the browser must be shown the same face.
14
+ export const RENDER_ANGLES = {
15
+ iso: { dir: [1, -1, 1], up: [0, 0, 1] },
16
+ front: { dir: [0, -1, 0], up: [0, 0, 1] },
17
+ back: { dir: [0, 1, 0], up: [0, 0, 1] },
18
+ top: { dir: [0, 0, 1], up: [0, 1, 0] },
19
+ bottom: { dir: [0, 0, -1], up: [0, -1, 0] },
20
+ left: { dir: [-1, 0, 0], up: [0, 0, 1] },
21
+ right: { dir: [1, 0, 0], up: [0, 0, 1] },
12
22
  };
23
+ export const RENDER_VIEWS = Object.keys(RENDER_ANGLES);
13
24
 
14
25
  const slug = (s) => String(s).toLowerCase().replace(/\s+/g, "-");
15
26
  const sub = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
@@ -40,7 +51,6 @@ export async function renderViews(kernel, part, view = Object.keys(part.views)[0
40
51
  const radius = Math.max(hi[0] - lo[0], hi[1] - lo[1], hi[2] - lo[2]) / 2 || 5;
41
52
 
42
53
  const bg = [0x15, 0x18, 0x1d], base = [0x9f, 0xb4, 0xcc], edgeColor = [0x1c, 0x23, 0x2d];
43
- const light = norm([0.4, 0.5, 0.8]); // world-space key direction (toward the light)
44
54
  const ambient = 0.35, diffuse = 0.75;
45
55
  const bias = radius * 0.02; // edge depth bias so visible edges win ties
46
56
 
@@ -49,10 +59,16 @@ export async function renderViews(kernel, part, view = Object.keys(part.views)[0
49
59
  const written = [];
50
60
 
51
61
  for (const angle of views) {
52
- const a = ANGLES[angle];
53
- if (!a) throw new Error(`unknown angle "${angle}" (use: ${Object.keys(ANGLES).join(", ")})`);
62
+ const a = RENDER_ANGLES[angle];
63
+ if (!a) throw new Error(`unknown angle "${angle}" (use: ${RENDER_VIEWS.join(", ")})`);
54
64
  // orthographic camera basis: zc toward camera, xc right, yc up
55
65
  const zc = norm(a.dir), xc = norm(cross(a.up, zc)), yc = cross(zc, xc);
66
+ // Key direction (toward the light), placed over the viewer's shoulder — up and to
67
+ // the right of the view axis — so every angle is lit and shaded. A world-fixed key
68
+ // would leave whichever face the camera happens to be looking at in flat ambient:
69
+ // that is what made `bottom` a featureless disc, and the browser viewer's offscreen
70
+ // captures had the same bug (framework/viewer-lighting.js captureLightPoses).
71
+ const light = norm([0, 1, 2].map((i) => zc[i] + 0.45 * xc[i] + 0.75 * yc[i]));
56
72
  const ppu = Math.min(W, H) / (2 * radius * 1.25); // pixels per mm (uniform; margin)
57
73
  const project = (p) => {
58
74
  const r = sub(p, center);
package/src/testing.js CHANGED
@@ -12,7 +12,7 @@ export { bootOcctKernel } from "./testing/occt.js";
12
12
  export { meshVolume, bboxSize } from "./testing/mesh.js";
13
13
  export { buildView } from "./testing/build.js";
14
14
  export { measure } from "./testing/measure.js";
15
- export { renderViews } from "./testing/render.js";
15
+ export { renderViews, RENDER_VIEWS } from "./testing/render.js";
16
16
  export { verify } from "./testing/verify.js";
17
17
  export { buildBVH } from "./testing/bvh.js";
18
18
  export { minWall } from "./testing/min-wall.js";