partforge 0.121.1 → 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 (58) 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/threemf.js +32 -3
  7. package/src/framework/jobs.js +2 -1
  8. package/src/framework/lint/index.js +2 -1
  9. package/src/framework/lint/rules-materials.js +56 -0
  10. package/src/framework/materials/assets/SOURCES.md +90 -0
  11. package/src/framework/materials/assets/env-outdoor.jpg +0 -0
  12. package/src/framework/materials/assets/env-print-bed.jpg +0 -0
  13. package/src/framework/materials/assets/env-studio.jpg +0 -0
  14. package/src/framework/materials/assets/env-workshop.jpg +0 -0
  15. package/src/framework/materials/assets/ground-concrete-normal.jpg +0 -0
  16. package/src/framework/materials/assets/ground-concrete-rough.jpg +0 -0
  17. package/src/framework/materials/assets/ground-concrete.jpg +0 -0
  18. package/src/framework/materials/assets/ground-maple-color.jpg +0 -0
  19. package/src/framework/materials/assets/ground-maple-normal.jpg +0 -0
  20. package/src/framework/materials/assets/ground-paper-normal.jpg +0 -0
  21. package/src/framework/materials/assets/ground-paper-rough.jpg +0 -0
  22. package/src/framework/materials/assets/ground-paper.jpg +0 -0
  23. package/src/framework/materials/assets/ground-pei-rough.jpg +0 -0
  24. package/src/framework/materials/assets/ground-pei.jpg +0 -0
  25. package/src/framework/materials/assets/pattern-carbon.jpg +0 -0
  26. package/src/framework/materials/assets/pattern-oak-color.jpg +0 -0
  27. package/src/framework/materials/assets/pattern-oak-normal.jpg +0 -0
  28. package/src/framework/materials/assets/pattern-oak-rough.jpg +0 -0
  29. package/src/framework/materials/assets/pattern-walnut-color.jpg +0 -0
  30. package/src/framework/materials/assets/pattern-walnut-normal.jpg +0 -0
  31. package/src/framework/materials/assets/pattern-walnut-rough.jpg +0 -0
  32. package/src/framework/materials/assets.js +39 -0
  33. package/src/framework/materials/contact-shadow.js +155 -0
  34. package/src/framework/materials/environment.js +157 -0
  35. package/src/framework/materials/environments.js +82 -0
  36. package/src/framework/materials/patterns.js +195 -0
  37. package/src/framework/materials/physical.js +82 -0
  38. package/src/framework/materials/presets.js +89 -0
  39. package/src/framework/materials/print-bed.js +169 -0
  40. package/src/framework/materials/print-frame.js +18 -0
  41. package/src/framework/materials/resolve.js +117 -0
  42. package/src/framework/materials/tonemap-readback.js +36 -0
  43. package/src/framework/materials/uv.js +23 -0
  44. package/src/framework/mount.js +121 -4
  45. package/src/framework/realistic-controls.js +78 -0
  46. package/src/framework/selection/feature-highlight.js +3 -0
  47. package/src/framework/view-state.js +27 -1
  48. package/src/framework/viewer.js +623 -34
  49. package/src/materials-worker.js +3 -0
  50. package/src/parts/bracket.js +2 -1
  51. package/src/parts/hinged-box.js +3 -1
  52. package/src/parts/material-swatches.js +40 -0
  53. package/src/parts/planter.js +2 -1
  54. package/src/parts/propeller.js +2 -1
  55. package/src/parts/scott-label.js +3 -2
  56. package/src/parts/screw.js +2 -1
  57. package/types/index.d.ts +48 -3
  58. package/types/part.d.ts +15 -2
@@ -0,0 +1,82 @@
1
+ // src/framework/materials/physical.js
2
+ // three.js materials from resolved library data. The CAD half keeps the viewer's
3
+ // MeshStandardMaterial and only swaps its numbers; the realistic half (Task 8)
4
+ // builds MeshPhysicalMaterial. Both read resolve.js, so the two modes can never
5
+ // disagree about which preset a sub-part is.
6
+ import * as THREE from "three";
7
+ import { cadAppearance, resolveMaterial } from "./resolve.js";
8
+ import { applyPattern } from "./patterns.js";
9
+ import { PATTERN_TEXTURES } from "./assets.js";
10
+
11
+ const APPEARANCE_KEYS = ["color", "opacity", "material", "roughness", "metalness", "clearcoat", "clearcoatRoughness", "anisotropy", "textureScale"];
12
+ const hasAppearance = (display) => !!display && APPEARANCE_KEYS.some((k) => display[k] != null);
13
+
14
+ export function buildCadMaterial(display, base) {
15
+ if (!hasAppearance(display)) return base;
16
+ const a = cadAppearance(display);
17
+ const m = base.clone();
18
+ m.color = new THREE.Color(a.color);
19
+ m.metalness = a.metalness;
20
+ m.roughness = a.roughness;
21
+ if (a.opacity < 1) { m.transparent = true; m.opacity = a.opacity; m.depthWrite = false; }
22
+ return m;
23
+ }
24
+
25
+ // Realistic-mode material. `loadTexture(fileName)` is injected (the viewer owns
26
+ // a caching TextureLoader), so this stays unit-testable without a network.
27
+ export function buildPhysicalMaterial(display, { printFrame, loadTexture } = {}) {
28
+ const { params, tinted } = resolveMaterial(display);
29
+ const set = params.textures;
30
+ // A textured preset's colour map carries its own colour; the preset `color` is
31
+ // only its average (for CAD and 3MF). So the material starts white and an
32
+ // explicit `color` tints the map.
33
+ const base = set?.color && !tinted ? 0xffffff : params.color;
34
+ const m = new THREE.MeshPhysicalMaterial({
35
+ color: new THREE.Color(base),
36
+ metalness: params.metalness,
37
+ roughness: params.roughness,
38
+ clearcoat: params.clearcoat,
39
+ clearcoatRoughness: params.clearcoatRoughness,
40
+ transmission: params.transmission,
41
+ thickness: params.thickness,
42
+ ior: params.ior,
43
+ anisotropy: params.anisotropy,
44
+ envMapIntensity: params.envIntensity,
45
+ specularIntensity: params.specularIntensity,
46
+ });
47
+ if (params.opacity < 1) { m.transparent = true; m.opacity = params.opacity; m.depthWrite = false; }
48
+ m.userData.pfAnisotropic = params.anisotropy > 0;
49
+ if (!loadTexture) {
50
+ applyPattern(m, { kind: params.pattern, scale: params.textureScale, printFrame });
51
+ return m;
52
+ }
53
+ if (set) {
54
+ // A full PBR set: colour decoded as sRGB, normal and roughness are data.
55
+ const load = (file, colorSpace) => {
56
+ if (!file) return undefined;
57
+ const t = loadTexture(file);
58
+ t.wrapS = t.wrapT = THREE.RepeatWrapping;
59
+ t.colorSpace = colorSpace;
60
+ return t;
61
+ };
62
+ applyPattern(m, {
63
+ kind: params.pattern, scale: params.textureScale, printFrame,
64
+ texture: load(set.color, THREE.SRGBColorSpace),
65
+ normalMap: load(set.normal, THREE.NoColorSpace),
66
+ roughnessMap: load(set.roughness, THREE.NoColorSpace),
67
+ roughnessMean: set.roughnessMean,
68
+ normalScale: set.normalScale,
69
+ });
70
+ return m;
71
+ }
72
+ const textureFile = PATTERN_TEXTURES[params.pattern];
73
+ const texture = textureFile ? loadTexture(textureFile) : undefined;
74
+ // The carbon texture is a luminance mask, read raw; decoding it as sRGB
75
+ // crushed its contrast to nothing. Concrete is a colour map.
76
+ if (texture) {
77
+ texture.wrapS = texture.wrapT = THREE.RepeatWrapping;
78
+ texture.colorSpace = params.pattern === "concrete" ? THREE.SRGBColorSpace : THREE.NoColorSpace;
79
+ }
80
+ applyPattern(m, { kind: params.pattern, scale: params.textureScale, printFrame, texture });
81
+ return m;
82
+ }
@@ -0,0 +1,89 @@
1
+ // src/framework/materials/presets.js
2
+ // The material library: plain data, deliberately three-free and DOM-free so lint,
3
+ // the worker's 3MF writer and the docs parity test can all read it (see
4
+ // test/lint-purity.test.js and test/worker-layering.test.js). The viewer turns a
5
+ // record into a three.js material in physical.js; nothing here knows about three.
6
+ //
7
+ // Metal base colours are measured reflectance (F0) from physicallybased.info,
8
+ // converted to sRGB. Adding a preset is one record here plus a look at the
9
+ // contact sheet (materials.html) in every environment.
10
+
11
+ export const DEFAULT_PRESET_ID = "default";
12
+
13
+ const P = (id, label, category, use, fields) => ({ id, label, category, use, tintable: false, ...fields });
14
+
15
+ export const PRESETS = {
16
+ // The viewer's look before this library existed. Not documented as a choice:
17
+ // it is what a sub-part with no `material` renders as.
18
+ default: P("default", "Default", "default", "The viewer's neutral blue-grey, used when a sub-part names no material.",
19
+ { color: 0x9fb4cc, metalness: 0.25, roughness: 0.55 }),
20
+
21
+ "machined-aluminum": P("machined-aluminum", "Machined aluminium", "aluminium", "Bare CNC-milled aluminium, fine tool marks, satin sheen.",
22
+ { color: 0xf5f6f6, metalness: 1, roughness: 0.32 }),
23
+ "brushed-aluminum": P("brushed-aluminum", "Brushed aluminium", "aluminium", "Directionally brushed aluminium panels and enclosures.",
24
+ { color: 0xf5f6f6, metalness: 1, roughness: 0.38, anisotropy: 0.7 }),
25
+ "anodized-aluminum": P("anodized-aluminum", "Anodized aluminium", "aluminium", "Dyed anodized aluminium; tint with `color` (e.g. red, black, blue).",
26
+ { color: 0xb9bcc0, metalness: 1, roughness: 0.3, clearcoat: 0.4, clearcoatRoughness: 0.25, tintable: true }),
27
+ "bead-blasted-aluminum": P("bead-blasted-aluminum", "Bead-blasted aluminium", "aluminium", "Matte, even-textured aluminium (laptop-shell finish).",
28
+ { color: 0xe8e9ea, metalness: 1, roughness: 0.62 }),
29
+
30
+ "brushed-stainless": P("brushed-stainless", "Brushed stainless steel", "steel", "Brushed 304 stainless: kitchen, marine and fastener hardware.",
31
+ { color: 0xc4c5c6, metalness: 1, roughness: 0.34, anisotropy: 0.6 }),
32
+ "polished-chrome": P("polished-chrome", "Polished chrome", "steel", "Mirror chrome plating; shows the environment strongly.",
33
+ { color: 0xcacbcc, metalness: 1, roughness: 0.05 }),
34
+ "black-oxide-steel": P("black-oxide-steel", "Black-oxide steel", "steel", "Blackened steel tooling and fasteners with a slight oily sheen.",
35
+ { color: 0x2c2d30, metalness: 1, roughness: 0.45 }),
36
+ "cast-iron": P("cast-iron", "Cast iron", "steel", "Raw sand-cast iron: dark, rough and matte.",
37
+ { color: 0x6d6b69, metalness: 1, roughness: 0.78 }),
38
+ titanium: P("titanium", "Titanium", "steel", "Bare titanium: slightly warm grey, satin.",
39
+ { color: 0xc1bab1, metalness: 1, roughness: 0.35 }),
40
+
41
+ brass: P("brass", "Brass", "warm-metal", "Yellow brass fittings and decorative hardware.",
42
+ { color: 0xf8dc82, metalness: 1, roughness: 0.28 }),
43
+ copper: P("copper", "Copper", "warm-metal", "Bare copper: busbars, heat sinks, decorative parts.",
44
+ { color: 0xfad0c0, metalness: 1, roughness: 0.3 }),
45
+ bronze: P("bronze", "Bronze", "warm-metal", "Cast bronze bushings and sculpture.",
46
+ { color: 0xd9a86c, metalness: 1, roughness: 0.42 }),
47
+
48
+ "powder-coat": P("powder-coat", "Powder coat", "coating", "Durable textured paint over metal; tint with `color`.",
49
+ { color: 0x303236, metalness: 0, roughness: 0.55, clearcoat: 0.15, clearcoatRoughness: 0.5, tintable: true }),
50
+ "painted-metal": P("painted-metal", "Painted metal", "coating", "Glossy enamel or automotive-style paint; tint with `color`.",
51
+ { color: 0xb3261e, metalness: 0, roughness: 0.4, clearcoat: 1, clearcoatRoughness: 0.08, tintable: true }),
52
+
53
+ "pla-print": P("pla-print", "PLA print", "print", "FDM-printed PLA with visible layer lines; tint with `color`.",
54
+ { color: 0xe8e4da, metalness: 0, roughness: 0.55, envIntensity: 0.6, specularIntensity: 0.5, pattern: "layer-lines", textureScale: 0.2, tintable: true }),
55
+ "petg-print": P("petg-print", "PETG print", "print", "FDM-printed PETG: glossier than PLA, visible layers; tint with `color`.",
56
+ { color: 0x2d6cdf, metalness: 0, roughness: 0.32, envIntensity: 0.7, specularIntensity: 0.7, pattern: "layer-lines", textureScale: 0.2, tintable: true }),
57
+ "resin-print": P("resin-print", "Resin print", "print", "SLA/MSLA resin print: smooth, faintly waxy; tint with `color`.",
58
+ { color: 0x8a8f96, metalness: 0, roughness: 0.35, clearcoat: 0.2, clearcoatRoughness: 0.3, tintable: true }),
59
+ "nylon-sls": P("nylon-sls", "Nylon SLS", "print", "Powder-bed nylon: matte, grainy, usually white or dyed black.",
60
+ { color: 0xf0eee8, metalness: 0, roughness: 0.85, pattern: "sls-grain", textureScale: 0.15, tintable: true }),
61
+
62
+ "abs-plastic": P("abs-plastic", "ABS plastic", "plastic", "Injection-moulded ABS housings and knobs; tint with `color`.",
63
+ { color: 0x1c1d20, metalness: 0, roughness: 0.42, tintable: true }),
64
+ "clear-acrylic": P("clear-acrylic", "Clear acrylic", "plastic", "Transparent PMMA/polycarbonate windows and covers; tint for coloured acrylic.",
65
+ { color: 0xffffff, metalness: 0, roughness: 0.04, transmission: 1, thickness: 3, ior: 1.49, tintable: true }),
66
+ rubber: P("rubber", "Rubber", "plastic", "Matte elastomer: gaskets, feet, grips; tint with `color`.",
67
+ { color: 0x1a1a1a, metalness: 0, roughness: 0.9, tintable: true }),
68
+
69
+ // Wood is a full PBR texture set (colour, normal, roughness — Poly Haven scans,
70
+ // assets/SOURCES.md), projected triplanar in object space (patterns.js).
71
+ // textureScale is one tile in mm. It is NOT the scans' stated size (1.83 m oak,
72
+ // 2 m walnut): a 1K map spread that wide is ~2 mm a texel, so a hand-sized part
73
+ // showed blurred blotches and no grain. 250/400 mm keep the grain crisp at part
74
+ // scale and still read as plausible figure (checked in the contact sheet). `color` is the
75
+ // texture's average colour: what the CAD view and a 3MF export show. In realistic
76
+ // mode the texture carries the colour itself; an explicit `color` tints it.
77
+ // `roughnessMean` is the roughness map's mean, so the preset `roughness` (or an
78
+ // override) sets the AVERAGE roughness and the map only varies it. `normalScale`
79
+ // strengthens the normal map: these scans' tangent normals barely leave +Z (a
80
+ // ~1% tilt), which reads as flat under an environment map.
81
+ oak: P("oak", "Oak", "natural", "Light oak with open grain.",
82
+ { color: 0xa17e57, metalness: 0, roughness: 0.65, pattern: "wood", textureScale: 250,
83
+ textures: { color: "pattern-oak-color.jpg", normal: "pattern-oak-normal.jpg", roughness: "pattern-oak-rough.jpg", roughnessMean: 0.53, normalScale: 2.5 } }),
84
+ walnut: P("walnut", "Walnut", "natural", "Dark oiled walnut.",
85
+ { color: 0x5d2a18, metalness: 0, roughness: 0.5, clearcoat: 0.3, clearcoatRoughness: 0.4, pattern: "wood", textureScale: 400,
86
+ textures: { color: "pattern-walnut-color.jpg", normal: "pattern-walnut-normal.jpg", roughness: "pattern-walnut-rough.jpg", roughnessMean: 0.628, normalScale: 3 } }),
87
+ "carbon-fiber": P("carbon-fiber", "Carbon fibre", "natural", "2x2 twill carbon fibre under clear coat.",
88
+ { color: 0x1b1c1e, metalness: 0.2, roughness: 0.35, clearcoat: 1, clearcoatRoughness: 0.05, pattern: "carbon", textureScale: 48 }),
89
+ };
@@ -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
+ }