partforge 0.121.0 → 0.122.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.
Files changed (63) hide show
  1. package/docs/AUTHORING-PARTS.md +164 -3
  2. package/package.json +3 -1
  3. package/src/app-materials.js +23 -0
  4. package/src/framework/app.css +16 -1
  5. package/src/framework/cutaway.js +12 -0
  6. package/src/framework/geometry/arc-math.js +27 -0
  7. package/src/framework/geometry/contour-corners.js +130 -0
  8. package/src/framework/geometry/contour-ops.js +11 -125
  9. package/src/framework/geometry/paper-bridge.js +4 -21
  10. package/src/framework/geometry/threemf.js +32 -3
  11. package/src/framework/jobs.js +2 -1
  12. package/src/framework/lint/index.js +2 -1
  13. package/src/framework/lint/rules-materials.js +56 -0
  14. package/src/framework/materials/assets/SOURCES.md +90 -0
  15. package/src/framework/materials/assets/env-outdoor.jpg +0 -0
  16. package/src/framework/materials/assets/env-print-bed.jpg +0 -0
  17. package/src/framework/materials/assets/env-studio.jpg +0 -0
  18. package/src/framework/materials/assets/env-workshop.jpg +0 -0
  19. package/src/framework/materials/assets/ground-concrete-normal.jpg +0 -0
  20. package/src/framework/materials/assets/ground-concrete-rough.jpg +0 -0
  21. package/src/framework/materials/assets/ground-concrete.jpg +0 -0
  22. package/src/framework/materials/assets/ground-maple-color.jpg +0 -0
  23. package/src/framework/materials/assets/ground-maple-normal.jpg +0 -0
  24. package/src/framework/materials/assets/ground-paper-normal.jpg +0 -0
  25. package/src/framework/materials/assets/ground-paper-rough.jpg +0 -0
  26. package/src/framework/materials/assets/ground-paper.jpg +0 -0
  27. package/src/framework/materials/assets/ground-pei-rough.jpg +0 -0
  28. package/src/framework/materials/assets/ground-pei.jpg +0 -0
  29. package/src/framework/materials/assets/pattern-carbon.jpg +0 -0
  30. package/src/framework/materials/assets/pattern-oak-color.jpg +0 -0
  31. package/src/framework/materials/assets/pattern-oak-normal.jpg +0 -0
  32. package/src/framework/materials/assets/pattern-oak-rough.jpg +0 -0
  33. package/src/framework/materials/assets/pattern-walnut-color.jpg +0 -0
  34. package/src/framework/materials/assets/pattern-walnut-normal.jpg +0 -0
  35. package/src/framework/materials/assets/pattern-walnut-rough.jpg +0 -0
  36. package/src/framework/materials/assets.js +39 -0
  37. package/src/framework/materials/contact-shadow.js +155 -0
  38. package/src/framework/materials/environment.js +157 -0
  39. package/src/framework/materials/environments.js +82 -0
  40. package/src/framework/materials/patterns.js +195 -0
  41. package/src/framework/materials/physical.js +82 -0
  42. package/src/framework/materials/presets.js +89 -0
  43. package/src/framework/materials/print-bed.js +169 -0
  44. package/src/framework/materials/print-frame.js +18 -0
  45. package/src/framework/materials/resolve.js +117 -0
  46. package/src/framework/materials/tonemap-readback.js +36 -0
  47. package/src/framework/materials/uv.js +23 -0
  48. package/src/framework/mount.js +121 -4
  49. package/src/framework/oracle/shape-probe.js +2 -2
  50. package/src/framework/realistic-controls.js +78 -0
  51. package/src/framework/selection/feature-highlight.js +3 -0
  52. package/src/framework/view-state.js +27 -1
  53. package/src/framework/viewer.js +623 -34
  54. package/src/materials-worker.js +3 -0
  55. package/src/parts/bracket.js +2 -1
  56. package/src/parts/hinged-box.js +3 -1
  57. package/src/parts/material-swatches.js +40 -0
  58. package/src/parts/planter.js +2 -1
  59. package/src/parts/propeller.js +2 -1
  60. package/src/parts/scott-label.js +3 -2
  61. package/src/parts/screw.js +2 -1
  62. package/types/index.d.ts +48 -3
  63. package/types/part.d.ts +15 -2
@@ -0,0 +1,169 @@
1
+ // src/framework/materials/print-bed.js
2
+ // The print-bed environment's ground: not a disc fading into the backdrop but
3
+ // a real build plate — a rounded, square PEI sheet of a standard size, cut out
4
+ // of nothing, with white markings printed on it: a millimetre ruler along the
5
+ // left and back edges, the partforge wordmark in the front-left corner, the
6
+ // plate's own size in the front-right, and a lift tab with diagonal sides off
7
+ // the middle of the front edge. Like the disc, it comes to the
8
+ // part (centred under its footprint) and never moves it.
9
+ import * as THREE from "three";
10
+
11
+ // Common square bed sizes, smallest first; a footprint past the last rounds up
12
+ // to the next 50 mm.
13
+ export const BED_SIZES_MM = [180, 220, 256, 350];
14
+ const MARGIN_MM = 16; // clear plate kept around the footprint when picking a size
15
+ const THICKNESS_MM = 2.5;
16
+ const CORNER_MM = 6;
17
+ // The lift tab on the front edge: wide where it meets the plate, narrower at
18
+ // its tip, so its two sides run diagonally.
19
+ const TAB_BASE_MM = 132;
20
+ const TAB_TIP_MM = 96;
21
+ const TAB_DEPTH_MM = 9;
22
+
23
+ export function bedSizeFor(footprintMm) {
24
+ const need = footprintMm + MARGIN_MM * 2;
25
+ return BED_SIZES_MM.find((s) => s >= need) ?? Math.ceil(need / 50) * 50;
26
+ }
27
+
28
+ function plateGeometry(size) {
29
+ const h = size / 2, r = CORNER_MM;
30
+ const shape = new THREE.Shape();
31
+ // Shape y = -h is the FRONT edge (it lands at +Z once laid flat), and the
32
+ // lift tab sticks out of its middle: a trapezoid with diagonal sides.
33
+ const tb = TAB_BASE_MM / 2, tt = TAB_TIP_MM / 2, td = TAB_DEPTH_MM;
34
+ shape.moveTo(-h + r, -h);
35
+ shape.lineTo(-tb, -h); shape.lineTo(-tt, -h - td); shape.lineTo(tt, -h - td); shape.lineTo(tb, -h);
36
+ shape.lineTo(h - r, -h); shape.quadraticCurveTo(h, -h, h, -h + r);
37
+ shape.lineTo(h, h - r); shape.quadraticCurveTo(h, h, h - r, h);
38
+ shape.lineTo(-h + r, h); shape.quadraticCurveTo(-h, h, -h, h - r);
39
+ shape.lineTo(-h, -h + r); shape.quadraticCurveTo(-h, -h, -h + r, -h);
40
+ // Extruded along +Z then laid flat: the top cap ends up at y = 0, the plate
41
+ // hangs below it, and the caps' UVs are shape coordinates — millimetres.
42
+ const g = new THREE.ExtrudeGeometry(shape, { depth: THICKNESS_MM, bevelEnabled: false, curveSegments: 6 });
43
+ g.rotateX(-Math.PI / 2);
44
+ g.translate(0, -THICKNESS_MM, 0);
45
+ return g;
46
+ }
47
+
48
+ function defaultCanvas(w, h) {
49
+ if (typeof OffscreenCanvas !== "undefined") return new OffscreenCanvas(w, h);
50
+ if (typeof document !== "undefined") return Object.assign(document.createElement("canvas"), { width: w, height: h });
51
+ return null;
52
+ }
53
+
54
+ // Draws the markings for a plate `size` mm square onto a transparent canvas
55
+ // (top of the canvas = back of the bed). Returns null where there is no 2D
56
+ // canvas (tests, a worker): the plate is then just unmarked PEI.
57
+ export function drawBedMarkings(size, createCanvas = defaultCanvas) {
58
+ const px = 2048;
59
+ const canvas = createCanvas(px, px);
60
+ const ctx = canvas?.getContext?.("2d");
61
+ if (!ctx) return null;
62
+ const k = px / size; // pixels per millimetre
63
+ const ink = "rgba(255,255,255,0.88)";
64
+ const mono = (mm, weight = 600) => `${weight} ${Math.round(mm * k)}px "Geist Mono", ui-monospace, SFMono-Regular, Menlo, monospace`;
65
+ ctx.clearRect(0, 0, px, px);
66
+ ctx.strokeStyle = ink;
67
+ ctx.fillStyle = ink;
68
+ ctx.lineCap = "round";
69
+
70
+ // Printable-area outline, inset from the edge.
71
+ const inset = 5;
72
+ ctx.lineWidth = 0.35 * k;
73
+ ctx.beginPath();
74
+ ctx.roundRect(inset * k, inset * k, (size - inset * 2) * k, (size - inset * 2) * k, (CORNER_MM - 2) * k);
75
+ ctx.stroke();
76
+
77
+ // Ruler: a tick every 10 mm, a longer numbered one every 50 mm, along the
78
+ // back edge (left to right) and the left edge (front to back), measured
79
+ // from the front-left corner the way a slicer's bed is.
80
+ const ruler = inset + 1;
81
+ ctx.font = mono(3.2, 500);
82
+ for (let mm = 10; mm < size - inset; mm += 10) {
83
+ const major = mm % 50 === 0;
84
+ const len = (major ? 4 : 2) * k;
85
+ ctx.lineWidth = (major ? 0.45 : 0.3) * k;
86
+ ctx.beginPath(); // back edge: x = mm
87
+ ctx.moveTo(mm * k, ruler * k); ctx.lineTo(mm * k, ruler * k + len);
88
+ ctx.stroke();
89
+ ctx.beginPath(); // left edge: z = mm from the front
90
+ ctx.moveTo(ruler * k, (size - mm) * k); ctx.lineTo(ruler * k + len, (size - mm) * k);
91
+ ctx.stroke();
92
+ if (!major) continue;
93
+ ctx.textAlign = "center"; ctx.textBaseline = "top";
94
+ ctx.fillText(String(mm), mm * k, ruler * k + len + 1 * k);
95
+ ctx.textAlign = "left"; ctx.textBaseline = "middle";
96
+ ctx.fillText(String(mm), ruler * k + len + 1 * k, (size - mm) * k);
97
+ }
98
+
99
+ // A small centre cross.
100
+ ctx.lineWidth = 0.35 * k;
101
+ const c = (size / 2) * k, arm = 3 * k;
102
+ ctx.beginPath();
103
+ ctx.moveTo(c - arm, c); ctx.lineTo(c + arm, c);
104
+ ctx.moveTo(c, c - arm); ctx.lineTo(c, c + arm);
105
+ ctx.stroke();
106
+
107
+ // Front strip: the wordmark in the left corner, the plate's size in the
108
+ // right, leaving the middle clear for the lift tab.
109
+ const front = (size - inset - 7) * k;
110
+ ctx.textBaseline = "middle";
111
+ ctx.font = mono(Math.min(8, size * 0.03));
112
+ ctx.textAlign = "left";
113
+ ctx.fillText("partforge", (inset + 5) * k, front);
114
+ ctx.textAlign = "right";
115
+ ctx.font = mono(Math.min(5, size * 0.02), 500);
116
+ ctx.fillText(`${size} × ${size} mm`, (size - inset - 5) * k, front);
117
+ return canvas;
118
+ }
119
+
120
+ // `pei` is the plate's surface material (the PEI colour + roughness maps with
121
+ // UVs in millimetres, repeat 1/tileMm); the rest is built and owned here.
122
+ export function createPrintBed({ pei, tileMm, createCanvas }) {
123
+ const group = new THREE.Group();
124
+ const edge = new THREE.MeshStandardMaterial({ color: 0x2a2b2d, metalness: 0.6, roughness: 0.45 });
125
+ const plate = new THREE.Mesh(new THREE.BufferGeometry(), [pei, edge]);
126
+ const inkMat = new THREE.MeshStandardMaterial({
127
+ transparent: true, roughness: 0.6, metalness: 0,
128
+ polygonOffset: true, polygonOffsetFactor: -1, polygonOffsetUnits: -1,
129
+ });
130
+ const ink = new THREE.Mesh(new THREE.PlaneGeometry(1, 1).rotateX(-Math.PI / 2), inkMat);
131
+ ink.visible = false;
132
+ group.add(plate, ink);
133
+ group.renderOrder = -1;
134
+ let size = 0;
135
+ let inkTex = null;
136
+
137
+ function setSize(next) {
138
+ if (next === size) return;
139
+ size = next;
140
+ plate.geometry.dispose();
141
+ plate.geometry = plateGeometry(size);
142
+ for (const t of [pei.map, pei.roughnessMap]) t?.repeat.set(1 / tileMm, 1 / tileMm);
143
+ inkTex?.dispose();
144
+ inkTex = null;
145
+ const canvas = drawBedMarkings(size, createCanvas);
146
+ if (canvas) {
147
+ inkTex = new THREE.CanvasTexture(canvas);
148
+ inkTex.colorSpace = THREE.SRGBColorSpace;
149
+ inkTex.anisotropy = 8;
150
+ }
151
+ inkMat.map = inkTex;
152
+ inkMat.needsUpdate = true;
153
+ ink.visible = !!inkTex;
154
+ ink.scale.set(size, 1, size);
155
+ }
156
+
157
+ return {
158
+ object: group,
159
+ get sizeMm() { return size; },
160
+ place({ y, centerX = 0, centerZ = 0, footprintMm }) {
161
+ setSize(bedSizeFor(footprintMm));
162
+ group.position.set(centerX, y - 0.01, centerZ);
163
+ },
164
+ dispose() {
165
+ plate.geometry.dispose(); ink.geometry.dispose();
166
+ edge.dispose(); inkMat.dispose(); inkTex?.dispose();
167
+ },
168
+ };
169
+ }
@@ -0,0 +1,18 @@
1
+ // The frame 3D-print layer lines are drawn in. Delivered display meshes carry
2
+ // the DISPLAY pose (place(..., {purpose:"display"})); the part is printed in the
3
+ // EXPORT pose. partforge already requires the two to differ by a rigid motion
4
+ // (lint place-not-rigid), and the geometry-free pose probe reports each as a
5
+ // list of rigid steps — so the display→export map is E · D⁻¹, with no geometry.
6
+ // Three-free and DOM-free: the pose probe and pose.js are pure.
7
+ import { probeSubPartPose } from "../pose-probe-core.js";
8
+ import { poseDelta } from "../geometry/pose.js";
9
+
10
+ const IDENTITY = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
11
+
12
+ export function printFrameMatrix(subPart, { view, p, d }) {
13
+ if (!subPart?.place) return [...IDENTITY];
14
+ const disp = probeSubPartPose(subPart, { view, purpose: "display", p, d });
15
+ const exp = probeSubPartPose(subPart, { view, purpose: "export", p, d });
16
+ if (!disp.trusted || !exp.trusted || disp.baseHash !== exp.baseHash) return [...IDENTITY];
17
+ return poseDelta(exp.pose, disp.pose);
18
+ }
@@ -0,0 +1,117 @@
1
+ // src/framework/materials/resolve.js
2
+ // Pure resolution of a sub-part's `display` block against the library. Never
3
+ // throws and never rejects a value: an unknown preset falls back to the default,
4
+ // an out-of-range override clamps, an unknown key is ignored — and every one of
5
+ // those is returned as an `issue` so lint can warn about it. Appearance must
6
+ // never be able to fail a build.
7
+ import { PRESETS, DEFAULT_PRESET_ID } from "./presets.js";
8
+ import { ENVIRONMENTS, DEFAULT_ENVIRONMENT_ID } from "./environments.js";
9
+
10
+ export const OVERRIDE_RANGES = {
11
+ roughness: [0, 1],
12
+ metalness: [0, 1],
13
+ clearcoat: [0, 1],
14
+ clearcoatRoughness: [0, 1],
15
+ anisotropy: [0, 1],
16
+ textureScale: [0.01, 1000],
17
+ };
18
+
19
+ export const DISPLAY_KEYS = ["color", "opacity", "material", ...Object.keys(OVERRIDE_RANGES)];
20
+
21
+ const isColor = (v) => Number.isInteger(v) && v >= 0 && v <= 0xffffff;
22
+
23
+ export function resolveMaterial(display) {
24
+ const d = display && typeof display === "object" ? display : {};
25
+ const issues = [];
26
+ let preset = PRESETS[DEFAULT_PRESET_ID];
27
+ if (d.material != null) {
28
+ if (typeof d.material === "string" && Object.hasOwn(PRESETS, d.material)) preset = PRESETS[d.material];
29
+ else issues.push({ kind: "unknown-material", key: "material", value: d.material });
30
+ }
31
+ const params = {
32
+ color: preset.color,
33
+ metalness: preset.metalness,
34
+ roughness: preset.roughness,
35
+ clearcoat: preset.clearcoat ?? 0,
36
+ clearcoatRoughness: preset.clearcoatRoughness ?? 0,
37
+ anisotropy: preset.anisotropy ?? 0,
38
+ transmission: preset.transmission ?? 0,
39
+ thickness: preset.thickness ?? 0,
40
+ ior: preset.ior ?? 1.5,
41
+ pattern: preset.pattern ?? null,
42
+ textureScale: preset.textureScale ?? 1,
43
+ envIntensity: preset.envIntensity ?? 1,
44
+ specularIntensity: preset.specularIntensity ?? 1,
45
+ textures: preset.textures ?? null,
46
+ opacity: 1,
47
+ };
48
+ if (isColor(d.color)) params.color = d.color;
49
+ if (typeof d.opacity === "number" && Number.isFinite(d.opacity)) params.opacity = Math.min(1, Math.max(0, d.opacity));
50
+ for (const [key, [lo, hi]] of Object.entries(OVERRIDE_RANGES)) {
51
+ if (!(key in d)) continue;
52
+ const v = d[key];
53
+ if (typeof v !== "number" || !Number.isFinite(v)) {
54
+ issues.push({ kind: "material-override-clamped", key, value: v, clampedTo: params[key] });
55
+ continue;
56
+ }
57
+ const c = Math.min(hi, Math.max(lo, v));
58
+ if (c !== v) issues.push({ kind: "material-override-clamped", key, value: v, clampedTo: c });
59
+ params[key] = c;
60
+ }
61
+ for (const key of Object.keys(d)) {
62
+ if (!DISPLAY_KEYS.includes(key)) issues.push({ kind: "material-key-unknown", key, value: d[key] });
63
+ }
64
+ // `tinted`: the author named a colour. A textured preset's colour map carries
65
+ // its own colour, so only an explicit tint multiplies it (physical.js).
66
+ return { preset, params, issues, tinted: isColor(d.color) };
67
+ }
68
+
69
+ // CAD-view appearance: the resolved colour on the CAD MeshStandardMaterial,
70
+ // flattened so it reads without an environment map (a metalness-1 material with
71
+ // no environment renders near-black), and lifted to a minimum luminance so a
72
+ // black-rubber part doesn't hide its geometry in the agent's screenshots.
73
+ const MIN_CAD_LUMA = 60; // 0..255, Rec. 709
74
+ export function cadAppearance(display) {
75
+ const d = display && typeof display === "object" ? display : {};
76
+ const { params } = resolveMaterial(d);
77
+ if (d.material == null) {
78
+ // Byte-for-byte today's behaviour for a part without `material`.
79
+ return { color: params.color, metalness: 0.25, roughness: 0.55, opacity: params.opacity };
80
+ }
81
+ return {
82
+ color: liftLuma(params.color, MIN_CAD_LUMA),
83
+ metalness: Math.min(params.metalness, 0.5),
84
+ roughness: Math.max(params.roughness, 0.3),
85
+ opacity: params.opacity,
86
+ };
87
+ }
88
+
89
+ function liftLuma(color, min) {
90
+ let r = (color >> 16) & 255, g = (color >> 8) & 255, b = color & 255;
91
+ const luma = 0.2126 * r + 0.7152 * g + 0.0722 * b;
92
+ if (luma >= min) return color;
93
+ // Mix toward mid-grey just far enough to reach `min`, keeping hue.
94
+ const t = (min - luma) / (128 - luma || 1);
95
+ r = Math.round(r + (128 - r) * t); g = Math.round(g + (128 - g) * t); b = Math.round(b + (128 - b) * t);
96
+ return (r << 16) | (g << 8) | b;
97
+ }
98
+
99
+ // The colour a 3MF object carries: the explicit tint, else the preset's own
100
+ // colour. Null when the sub-part says nothing about appearance, which is what
101
+ // keeps an appearance-free export byte-for-byte unchanged.
102
+ export function printColor(display) {
103
+ if (!display || typeof display !== "object") return null;
104
+ if (isColor(display.color)) return display.color;
105
+ if (display.material == null) return null;
106
+ return resolveMaterial(display).params.color;
107
+ }
108
+
109
+ export function declaresMaterials(part) {
110
+ return Object.values(part?.parts ?? {}).some((sp) => sp?.display?.material != null);
111
+ }
112
+
113
+ export function resolveEnvironmentId(id) {
114
+ if (id == null) return { id: DEFAULT_ENVIRONMENT_ID, known: true };
115
+ if (typeof id === "string" && Object.hasOwn(ENVIRONMENTS, id)) return { id, known: true };
116
+ return { id: DEFAULT_ENVIRONMENT_ID, known: false };
117
+ }
@@ -0,0 +1,36 @@
1
+ // three applies tone mapping and output colour space ONLY on the canvas path
2
+ // (r184: a bound render target gets NoToneMapping and linear output). Realistic
3
+ // captures therefore render HDR into a half-float target and finish here, in JS:
4
+ // exposure → Khronos PBR Neutral (the same curve THREE.NeutralToneMapping uses on
5
+ // the live canvas) → sRGB. Pure; no three import.
6
+ const START = 0.8 - 0.04, DESAT = 0.15;
7
+
8
+ function neutral(r, g, b) {
9
+ const x = Math.min(r, g, b);
10
+ const offset = x < 0.08 ? x - 6.25 * x * x : 0.04;
11
+ r -= offset; g -= offset; b -= offset;
12
+ const peak = Math.max(r, g, b);
13
+ if (peak < START) return [r, g, b];
14
+ const d = 1 - START;
15
+ const newPeak = 1 - (d * d) / (peak + d - START);
16
+ const s = newPeak / peak;
17
+ r *= s; g *= s; b *= s;
18
+ const t = 1 - 1 / (DESAT * (peak - newPeak) + 1);
19
+ return [r + (newPeak - r) * t, g + (newPeak - g) * t, b + (newPeak - b) * t];
20
+ }
21
+
22
+ const srgb = (l) => {
23
+ const c = Math.min(1, Math.max(0, l));
24
+ return Math.round(255 * (c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055));
25
+ };
26
+
27
+ // Linear RGBA floats → sRGB RGBA bytes. Alpha is forced opaque: a realistic
28
+ // capture always has the environment backdrop behind it.
29
+ export function neutralToneMapToSrgb8(src, exposure = 1) {
30
+ const out = new Uint8ClampedArray(src.length);
31
+ for (let i = 0; i < src.length; i += 4) {
32
+ const [r, g, b] = neutral(src[i] * exposure, src[i + 1] * exposure, src[i + 2] * exposure);
33
+ out[i] = srgb(r); out[i + 1] = srgb(g); out[i + 2] = srgb(b); out[i + 3] = 255;
34
+ }
35
+ return out;
36
+ }
@@ -0,0 +1,23 @@
1
+ // Box-projected UVs, added only for realistic mode. three's anisotropy (brushed
2
+ // metal) builds its tangent frame from UV derivatives, and CAD meshes carry no
3
+ // UVs. Projection is in OBJECT space along each vertex normal's dominant axis, so
4
+ // it rides with the sub-part and never swims. Seams on curved surfaces are
5
+ // acceptable: nothing samples a texture through these UVs — triplanar patterns
6
+ // (patterns.js) ignore them.
7
+ import * as THREE from "three";
8
+
9
+ export function ensureBoxUVs(geometry, scaleMm = 20) {
10
+ if (geometry.userData.boxUVs && geometry.attributes.uv) return geometry;
11
+ const pos = geometry.attributes.position, nrm = geometry.attributes.normal;
12
+ const uv = new Float32Array(pos.count * 2);
13
+ for (let i = 0; i < pos.count; i++) {
14
+ const x = pos.getX(i), y = pos.getY(i), z = pos.getZ(i);
15
+ const ax = Math.abs(nrm ? nrm.getX(i) : 0), ay = Math.abs(nrm ? nrm.getY(i) : 0), az = Math.abs(nrm ? nrm.getZ(i) : 1);
16
+ let u, v;
17
+ if (az >= ax && az >= ay) { u = x; v = y; } else if (ay >= ax) { u = x; v = z; } else { u = y; v = z; }
18
+ uv[i * 2] = u / scaleMm; uv[i * 2 + 1] = v / scaleMm;
19
+ }
20
+ geometry.setAttribute("uv", new THREE.BufferAttribute(uv, 2));
21
+ geometry.userData.boxUVs = true;
22
+ return geometry;
23
+ }
@@ -7,12 +7,16 @@ import { attachRail } from "./rail.js";
7
7
  import { declaredSourceLookup } from "./panel/declared-source.js";
8
8
  import { attachMobileTabs } from "./mobile-tabs.js";
9
9
  import { createTooltipPresenter, attachButtonTooltips } from "./tooltip.js";
10
- import { loadCamera, loadProjection, saveProjection } from "./view-state.js";
10
+ import { loadCamera, loadProjection, saveProjection, loadRenderMode, loadEnvironment } from "./view-state.js";
11
+ import { attachRealisticControls } from "./realistic-controls.js";
12
+ import { ENVIRONMENTS } from "./materials/environments.js";
13
+ import { declaresMaterials, resolveMaterial } from "./materials/resolve.js";
14
+ import { printFrameMatrix } from "./materials/print-frame.js";
11
15
  import { buildControls } from "./controls.js";
12
16
  import { relevantParamKeys } from "./param-deps.js";
13
17
  import { createMeshCache } from "./mesh-cache.js";
14
18
  import { createGeometryService } from "./geometry-service.js";
15
- import { viewSubParts } from "./part-model.js";
19
+ import { viewSubParts, resolveParams } from "./part-model.js";
16
20
  import { resolveDerived } from "./derive.js";
17
21
  import { createBackendPolicy } from "./backend-select.js";
18
22
  import { createDebugOverlay } from "./debug-overlay.js";
@@ -63,7 +67,7 @@ const IMPORT_MESH_BROKEN_MESSAGE = "STEP import tessellation failed to satisfy t
63
67
  // carries the worker's own error text. See the correlated "error" case below.
64
68
  const importTessellateFailedMessage = (workerMessage) => `STEP import tessellation failed — ${workerMessage}`;
65
69
 
66
- export function makeHandle({ ready, dispose, viewer, setParams, listExportableParts, exportParts, warmExportKernel, setHostPane, setRailLayout, animation, getView, setView, captureView, attachTooltips, measure, annotate, projection, pickMarker, getPanelState, getPanelErrors }) {
70
+ export function makeHandle({ ready, dispose, viewer, setParams, listExportableParts, exportParts, warmExportKernel, setHostPane, setRailLayout, animation, getView, setView, captureView, attachTooltips, measure, annotate, projection, pickMarker, getPanelState, getPanelErrors, renderMode, environment, declaresMaterials, renderViews }) {
67
71
  return {
68
72
  ready, dispose, setParams,
69
73
  // Part-declared animation playback (spec 2026-08-02): animations are
@@ -77,11 +81,19 @@ export function makeHandle({ ready, dispose, viewer, setParams, listExportablePa
77
81
  setView,
78
82
  // Offscreen render of a named view (default when omitted, or on an unknown name).
79
83
  captureView,
84
+ // Always CAD, whatever the live render mode: these are the agent's renders.
80
85
  captureViews: (viewNames) => viewer.captureCanonicalViews(viewNames),
86
+ // captureViews in a chosen appearance: renderMode "cad" (the default) is
87
+ // exactly captureViews() in either live mode; "realistic" borrows the realistic look for the
88
+ // capture (waiting on the environment's assets) without switching the live
89
+ // view. Rejects if realistic's assets fail to load.
90
+ renderViews: renderViews ?? (async (viewNames) => viewer.captureCanonicalViews(viewNames)),
81
91
  // `recenter` reads the sub-part geometry only, so with measurement pins on
82
92
  // screen the dimension labels — which sit beside the part, not on it —
83
93
  // could land outside the centred window. A dimensioned capture therefore
84
94
  // keeps the user's exact framing; a host wanting both re-frames first.
95
+ // Follows the live render mode unless opts.renderMode pins one ("realistic"
96
+ // only once the environment has loaded — see viewer.captureCurrent).
85
97
  captureCurrent: (opts) => viewer.captureCurrent(
86
98
  opts?.recenter && (measure ?? NOOP_MEASURE).isEnabled() && (measure ?? NOOP_MEASURE).pinCount() > 0
87
99
  ? { ...opts, recenter: false }
@@ -100,6 +112,15 @@ export function makeHandle({ ready, dispose, viewer, setParams, listExportablePa
100
112
  camera: viewer.getCameraState(),
101
113
  projection: viewer.getProjection?.() ?? "perspective",
102
114
  cutaway: viewer.getCutawayState?.() ?? null,
115
+ // The mode the user is headed for, not only the one on screen: a
116
+ // realistic restore or switch still loading reads "realistic", so a
117
+ // remount mid-load (the cloud remounts on every edit) doesn't drop it.
118
+ // A failed load settles back to "cad".
119
+ renderMode: viewer.isRealisticPending?.() ? "realistic" : viewer.getRenderMode?.() ?? "cad",
120
+ // Only an environment someone CHOSE: carrying the one merely seeded
121
+ // from meta.environment (or the default) would outrank the part's own
122
+ // meta.environment on the next mount, masking an edit to it.
123
+ ...(viewer.isEnvironmentChosen?.() ? { environment: viewer.getEnvironment() } : {}),
103
124
  }),
104
125
  // Every custom control's transient state (selection, a scroll position),
105
126
  // keyed by param, as plain JSON — the panel's twin of getViewerState. Hand
@@ -170,6 +191,18 @@ export function makeHandle({ ready, dispose, viewer, setParams, listExportablePa
170
191
  release: () => {},
171
192
  onAnchorChange: () => () => {},
172
193
  },
194
+ // Realistic render mode, a viewer-wide display mode like `projection` and
195
+ // in the same shape. set() resolves to the mode actually in effect — "cad"
196
+ // if the realistic assets failed to load; onChange receives the viewer's
197
+ // {mode, busy, error} event (busy while assets load).
198
+ renderMode: renderMode ?? { get: () => "cad", set: async () => "cad", onChange: () => () => {} },
199
+ // The realistic environment. set() resolves to the id in effect (a failed
200
+ // switch while realistic reverts to the one still shown, and onChange hears
201
+ // both); list() is every environment as {id, label}, for a host picker.
202
+ environment: environment ?? { get: () => "studio", set: async (id) => id, onChange: () => () => {}, list: () => [] },
203
+ // True when any sub-part names a display.material — a host can offer the
204
+ // realistic toggle only where it shows something a CAD view does not.
205
+ declaresMaterials: declaresMaterials ?? false,
173
206
  };
174
207
  }
175
208
 
@@ -285,6 +318,24 @@ function createCleanupStack() {
285
318
  // // "perspective" | "orthographic". Drives the LIVE view
286
319
  // // and captureCurrent only — captureCanonicalViews,
287
320
  // // renderMeshPayloads and the CLI stay perspective.
321
+ // runtime.renderMode: { get, set, onChange }
322
+ // // "cad" | "realistic". set() resolves to the mode in
323
+ // // effect ("cad" if the realistic assets failed);
324
+ // // onChange gets {mode, busy, error}. Same shape as
325
+ // // projection. Drives the live view; captureCurrent
326
+ // // follows it unless given { renderMode }. captureViews
327
+ // // is always CAD — the live mode never changes what
328
+ // // the agent sees.
329
+ // runtime.environment: { get, set, onChange, list }
330
+ // // the realistic environment ("studio" | "workshop" |
331
+ // // "print-bed" | "outdoor"); list() → [{id, label}].
332
+ // // A failed switch while realistic reverts, and onChange
333
+ // // hears the revert.
334
+ // runtime.declaresMaterials // true when any sub-part names a display.material
335
+ // await runtime.renderViews(["front"], { renderMode: "realistic" });
336
+ // // captureViews in a chosen appearance, without
337
+ // // switching the live view; "cad" is exactly captureViews(),
338
+ // // CAD in either live mode
288
339
  // runtime.dispose(); // full teardown
289
340
  // onBuild fires per completed build, so it does NOT fire for a pose-only edit —
290
341
  // those are repaired in the viewer and produce no build at all.
@@ -307,7 +358,8 @@ function createCleanupStack() {
307
358
  // // KB of base64 apiece, so a host should not assume this
308
359
  // // payload is small, only that it is bounded.
309
360
  // viewerState: ViewerState // a previous mount's runtime.getViewerState(), handed back to
310
- // // resume the camera, projection and cutaway where that mount
361
+ // // resume the camera, projection, cutaway, render mode and
362
+ // // environment where that mount
311
363
  // // left them. For a host that applies edits by REMOUNTING: the
312
364
  // // part changed, the user's view of it should not. Omit on a
313
365
  // // first mount — the viewer then restores its own persisted
@@ -338,6 +390,13 @@ function createCleanupStack() {
338
390
  // // not a degraded one). A rejection is reported through the
339
391
  // // control's own onError; the widget keeps the converted blob
340
392
  // // so a retry costs a network call, not a reconvert.
393
+ // elements.chrome.realistic / .environment // optional realistic-mode viewbar controls — a
394
+ // // <button> toggle and an (empty) <select> the mount
395
+ // // fills with the environments; default ids #realistic
396
+ // // and #environment. Without them the host drives
397
+ // // runtime.renderMode / runtime.environment itself.
398
+ // // Both preferences persist like the theme (and carry
399
+ // // in viewerState, which outranks what is stored).
341
400
  // Every `elements` entry defaults to the legacy global-ID lookup (below), resolved
342
401
  // exactly once here — submodules take element refs and never query the document.
343
402
  // `container`/`controls` remain as deprecated aliases for elements.viewer/.controls.
@@ -380,6 +439,8 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
380
439
  measure: elements.chrome?.measure ?? byId("measure"),
381
440
  annotate: elements.chrome?.annotate ?? byId("annotate"),
382
441
  railToggle: elements.chrome?.railToggle ?? byId("rail-toggle"),
442
+ realistic: elements.chrome?.realistic ?? byId("realistic"),
443
+ environment: elements.chrome?.environment ?? byId("environment"),
383
444
  },
384
445
  };
385
446
 
@@ -592,6 +653,24 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
592
653
  // A carried state outranks the persisted preference: it is this session's
593
654
  // live answer, where the stored one is the last page-reload's.
594
655
  viewer.setProjection(viewerState?.projection ?? loadProjection());
656
+ // The environment by the same precedence (carried, stored, then the part's
657
+ // own meta.environment — which the viewer already starts on, unchosen). A
658
+ // carried or stored one was chosen, so it stays chosen and carries on. In
659
+ // CAD this only records the id, so the realistic restore below loads the
660
+ // right rig first time rather than the default and then this one.
661
+ const initialEnv = viewerState?.environment ?? loadEnvironment();
662
+ if (initialEnv) viewer.setEnvironment(initialEnv);
663
+ // Realistic mode by the same precedence, requested HERE rather than on the
664
+ // first accepted build: every mount has a fresh renderer, so the
665
+ // environment loads again, and starting now runs that alongside the build
666
+ // instead of showing CAD until the build lands and only then waiting on
667
+ // the assets (a CAD flash on every edit for a host that remounts per
668
+ // edit). Nothing needs a part yet — unbuilt sub-parts compile when first
669
+ // shown, and showAssembly brings the ground to the part when it arrives.
670
+ // While it loads the viewer reports it pending, which is what
671
+ // getViewerState carries; an explicit mode change supersedes it, and a
672
+ // failed load settles to CAD.
673
+ if ((viewerState?.renderMode ?? loadRenderMode() ?? "cad") === "realistic") viewer.setRenderMode("realistic");
595
674
  const viewcube = attachViewcubeControls(viewer, { stage: els.viewer }, { tooltip });
596
675
  cleanup.defer(() => viewcube.detach());
597
676
  cleanup.defer(viewer.onProjectionChange((mode) => saveProjection(mode)));
@@ -810,6 +889,25 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
810
889
  }
811
890
  }
812
891
 
892
+ // Print frames for the layer-line pattern: the display → export map of the
893
+ // geometry just delivered, so layers run the way the part is printed rather
894
+ // than the way it is displayed. Computed at delivery (the frame describes
895
+ // the delivered mesh, which a later pose-only repair only moves) and only
896
+ // for sub-parts whose material draws layer lines — each one costs two
897
+ // geometry-free probe builds.
898
+ const layerLined = new Set(Object.keys(part.parts).filter((n) => {
899
+ try { return resolveMaterial(part.parts[n].display).params.pattern === "layer-lines"; } catch { return false; }
900
+ }));
901
+ const printFrames = {};
902
+ function recordPrintFrames(names) {
903
+ const wanted = names.filter((n) => layerLined.has(n));
904
+ if (!wanted.length) return;
905
+ let resolved;
906
+ try { resolved = resolveParams(part, params); } catch { return; } // diagnosed by the build
907
+ for (const n of wanted) printFrames[n] = printFrameMatrix(part.parts[n], { view: view(), ...resolved });
908
+ viewer.setPrintFrames({ ...printFrames });
909
+ }
910
+
813
911
  // Sub-parts whose latest fresh delivery had zero triangles (see the `meshes` case).
814
912
  const emptySubParts = new Set();
815
913
 
@@ -869,6 +967,7 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
869
967
  // delivered, which buildDone() true guarantees is at the live params.
870
968
  fastPath.recordDelivered(m.name);
871
969
  }
970
+ recordPrintFrames(data.meshes.map((m) => m.name));
872
971
  // A split dispatch answers in two meshes replies; the busy spinner
873
972
  // stays up until the view has everything (the other worker's job may
874
973
  // still be running — often OCCT, the slow one).
@@ -1164,6 +1263,11 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
1164
1263
  // Optional host-page viewer chrome (reframe / theme) + camera persistence.
1165
1264
  const chrome = attachViewerControls(viewer, els.chrome, { tooltip });
1166
1265
  cleanup.defer(() => chrome.detach());
1266
+ // Optional realistic-mode chrome: the toggle and the environment picker.
1267
+ const realisticChrome = attachRealisticControls(viewer, {
1268
+ toggle: els.chrome.realistic, envMenu: els.chrome.environment,
1269
+ }, { tooltip });
1270
+ cleanup.defer(() => realisticChrome.detach());
1167
1271
 
1168
1272
  // Full teardown of everything this mount created. Idempotent. A disposed runtime
1169
1273
  // can never surface a late build result (workers are terminated, the loop is
@@ -1244,6 +1348,19 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
1244
1348
  release: () => viewer.releaseFlashPoints(),
1245
1349
  onAnchorChange: (cb) => viewer.onFlashAnchorChange(cb),
1246
1350
  },
1351
+ renderMode: {
1352
+ get: () => viewer.getRenderMode(),
1353
+ set: (mode) => viewer.setRenderMode(mode),
1354
+ onChange: (cb) => viewer.onRenderModeChange(cb),
1355
+ },
1356
+ environment: {
1357
+ get: () => viewer.getEnvironment(),
1358
+ set: (id) => viewer.setEnvironment(id),
1359
+ onChange: (cb) => viewer.onEnvironmentChange(cb),
1360
+ list: () => Object.values(ENVIRONMENTS).map(({ id, label }) => ({ id, label })),
1361
+ },
1362
+ declaresMaterials: declaresMaterials(part),
1363
+ renderViews: (viewNames, opts) => viewer.renderViews(viewNames, opts),
1247
1364
  });
1248
1365
  } catch (error) {
1249
1366
  try {
@@ -40,8 +40,8 @@
40
40
  // out at rings[] → ring → arcs[] → arc → center[] — 4 containers below the summary
41
41
  // object itself (5 including it), against the cloud's `DESCRIBE_MAX_DEPTH = 8`, with
42
42
  // headroom even wrapped in the paired-probe `{pair: {mine, ref}}` form.
43
- import { arcCenterAndSweep } from "../geometry/paper-bridge.js";
44
- import { cubicAt, profileCorners } from "../geometry/contour-ops.js";
43
+ import { arcCenterAndSweep } from "../geometry/arc-math.js";
44
+ import { cubicAt, profileCorners } from "../geometry/contour-corners.js";
45
45
 
46
46
  export const MAX_ARCS = 64;
47
47
  export const MAX_CORNERS = 64;