partforge 0.121.1 → 0.123.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/docs/KERNEL-CONTRACT.md +10 -0
  3. package/package.json +3 -1
  4. package/src/app-materials.js +23 -0
  5. package/src/framework/app.css +16 -1
  6. package/src/framework/cutaway.js +12 -0
  7. package/src/framework/geometry/blend-surfaces.js +256 -0
  8. package/src/framework/geometry/creased-normals.js +121 -7
  9. package/src/framework/geometry/manifold-backend.js +56 -15
  10. package/src/framework/geometry/mesh-fillet.js +55 -5
  11. package/src/framework/geometry/threemf.js +32 -3
  12. package/src/framework/jobs.js +2 -1
  13. package/src/framework/lint/index.js +2 -1
  14. package/src/framework/lint/rules-materials.js +56 -0
  15. package/src/framework/materials/assets/SOURCES.md +90 -0
  16. package/src/framework/materials/assets/env-outdoor.jpg +0 -0
  17. package/src/framework/materials/assets/env-print-bed.jpg +0 -0
  18. package/src/framework/materials/assets/env-studio.jpg +0 -0
  19. package/src/framework/materials/assets/env-workshop.jpg +0 -0
  20. package/src/framework/materials/assets/ground-concrete-normal.jpg +0 -0
  21. package/src/framework/materials/assets/ground-concrete-rough.jpg +0 -0
  22. package/src/framework/materials/assets/ground-concrete.jpg +0 -0
  23. package/src/framework/materials/assets/ground-maple-color.jpg +0 -0
  24. package/src/framework/materials/assets/ground-maple-normal.jpg +0 -0
  25. package/src/framework/materials/assets/ground-paper-normal.jpg +0 -0
  26. package/src/framework/materials/assets/ground-paper-rough.jpg +0 -0
  27. package/src/framework/materials/assets/ground-paper.jpg +0 -0
  28. package/src/framework/materials/assets/ground-pei-rough.jpg +0 -0
  29. package/src/framework/materials/assets/ground-pei.jpg +0 -0
  30. package/src/framework/materials/assets/pattern-carbon.jpg +0 -0
  31. package/src/framework/materials/assets/pattern-oak-color.jpg +0 -0
  32. package/src/framework/materials/assets/pattern-oak-normal.jpg +0 -0
  33. package/src/framework/materials/assets/pattern-oak-rough.jpg +0 -0
  34. package/src/framework/materials/assets/pattern-walnut-color.jpg +0 -0
  35. package/src/framework/materials/assets/pattern-walnut-normal.jpg +0 -0
  36. package/src/framework/materials/assets/pattern-walnut-rough.jpg +0 -0
  37. package/src/framework/materials/assets.js +39 -0
  38. package/src/framework/materials/contact-shadow.js +155 -0
  39. package/src/framework/materials/environment.js +157 -0
  40. package/src/framework/materials/environments.js +82 -0
  41. package/src/framework/materials/patterns.js +243 -0
  42. package/src/framework/materials/physical.js +88 -0
  43. package/src/framework/materials/presets.js +89 -0
  44. package/src/framework/materials/print-bed.js +169 -0
  45. package/src/framework/materials/print-frame.js +18 -0
  46. package/src/framework/materials/resolve.js +117 -0
  47. package/src/framework/materials/tonemap-readback.js +36 -0
  48. package/src/framework/materials/uv.js +42 -0
  49. package/src/framework/mount.js +121 -4
  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,243 @@
1
+ // The ONE shader-injection module (onBeforeCompile). Everything a future TSL /
2
+ // WebGPU port would have to rewrite lives here and nowhere else.
3
+ //
4
+ // All patterns work in OBJECT space (the sub-part's delivered mesh frame), so
5
+ // they move with the sub-part under animation and never slide when the camera
6
+ // moves. Layer lines additionally map object space through `pfPrintFrame`
7
+ // (print-frame.js: display→export), so they run the way the part will print.
8
+ import * as THREE from "three";
9
+
10
+ const VERT_DECL = "varying vec3 vPfObjPos;\nvarying vec3 vPfObjNormal;\nvarying vec3 vPfPrintUpView;\nuniform mat4 pfPrintFrame;\n";
11
+ // Wood's triplanar normal map builds its normal in OBJECT space; the fragment
12
+ // stage has no normalMatrix, so its three columns ride along as varyings.
13
+ const VERT_DECL_NM = "varying vec3 vPfNmX;\nvarying vec3 vPfNmY;\nvarying vec3 vPfNmZ;\n";
14
+ const VERT_BODY_NM = "vPfNmX = normalMatrix[0];\nvPfNmY = normalMatrix[1];\nvPfNmZ = normalMatrix[2];\n";
15
+ // vPfPrintUpView: the print direction (export +Z) in VIEW space — the gradient
16
+ // of the layer height, carried by normalMatrix like any other normal — so the
17
+ // layer-line normal map can tilt normals along it (identity print frame for the
18
+ // other patterns, which never read it).
19
+ const VERT_BODY = "vPfObjPos = position;\nvPfObjNormal = normal;\nvPfPrintUpView = normalMatrix * vec3(pfPrintFrame[0].z, pfPrintFrame[1].z, pfPrintFrame[2].z);\n";
20
+
21
+ const FRAG_DECL = `
22
+ varying vec3 vPfObjPos;
23
+ varying vec3 vPfObjNormal;
24
+ varying vec3 vPfPrintUpView;
25
+ uniform float pfPatternScale;
26
+ uniform mat4 pfPrintFrame;
27
+ uniform sampler2D pfPatternMap;
28
+ float pfHash(vec3 p) { p = fract(p * 0.3183099 + 0.1); p *= 17.0; return fract(p.x * p.y * p.z * (p.x + p.y + p.z)); }
29
+ float pfNoise(vec3 x) {
30
+ vec3 i = floor(x); vec3 f = fract(x); f = f * f * (3.0 - 2.0 * f);
31
+ return mix(mix(mix(pfHash(i), pfHash(i + vec3(1,0,0)), f.x), mix(pfHash(i + vec3(0,1,0)), pfHash(i + vec3(1,1,0)), f.x), f.y),
32
+ mix(mix(pfHash(i + vec3(0,0,1)), pfHash(i + vec3(1,0,1)), f.x), mix(pfHash(i + vec3(0,1,1)), pfHash(i + vec3(1,1,1)), f.x), f.y), f.z);
33
+ }
34
+ vec3 pfTriplanar(sampler2D map, vec3 p, vec3 n, float scale) {
35
+ vec3 w = pow(abs(normalize(n)), vec3(4.0)); w /= (w.x + w.y + w.z);
36
+ return texture2D(map, p.yz / scale).rgb * w.x + texture2D(map, p.xz / scale).rgb * w.y + texture2D(map, p.xy / scale).rgb * w.z;
37
+ }
38
+ `;
39
+
40
+ // Extra declarations only the wood kind needs: its normal and roughness maps and
41
+ // the normal-matrix varyings. Kept out of FRAG_DECL so the other kinds don't bind
42
+ // two empty samplers.
43
+ const FRAG_DECL_WOOD = `
44
+ uniform sampler2D pfNormalMap;
45
+ uniform sampler2D pfRoughMap;
46
+ uniform float pfRoughMean;
47
+ uniform float pfNormalStrength;
48
+ varying vec3 vPfNmX;
49
+ varying vec3 vPfNmY;
50
+ varying vec3 vPfNmZ;
51
+ vec3 pfTriplanarWeights(vec3 n) { vec3 w = pow(abs(n), vec3(4.0)); return w / (w.x + w.y + w.z); }
52
+ // Triplanar tangent-space normal map, whiteout-blended (Golus, "Normal Mapping
53
+ // for a Triplanar Shader"). Each projection's map is read as a height field over
54
+ // the two object axes its UVs run along, so its tangent normal (x, y) is a slope
55
+ // along those axes and z points along the face's own axis — the whiteout form
56
+ // (t.xy + n.<uv axes>, |t.z| * n.<face axis>) needs no per-side sign flips: on
57
+ // the back face n.<face axis> is negative and the swizzle follows the same height
58
+ // field, so light still catches the same grain. OpenGL-format maps (+Y up), the
59
+ // Poly Haven "nor_gl" convention. Returns an OBJECT-space unit normal.
60
+ vec3 pfTriplanarNormal(vec3 p, vec3 n, float scale) {
61
+ vec3 w = pfTriplanarWeights(n);
62
+ vec3 tx = texture2D(pfNormalMap, p.yz / scale).xyz * 2.0 - 1.0;
63
+ vec3 ty = texture2D(pfNormalMap, p.xz / scale).xyz * 2.0 - 1.0;
64
+ vec3 tz = texture2D(pfNormalMap, p.xy / scale).xyz * 2.0 - 1.0;
65
+ tx.xy *= pfNormalStrength; ty.xy *= pfNormalStrength; tz.xy *= pfNormalStrength;
66
+ tx = vec3(tx.xy + n.yz, abs(tx.z) * n.x); // uv = (y, z), face axis x
67
+ ty = vec3(ty.xy + n.xz, abs(ty.z) * n.y); // uv = (x, z), face axis y
68
+ tz = vec3(tz.xy + n.xy, abs(tz.z) * n.z); // uv = (x, y), face axis z
69
+ return normalize(tx.zxy * w.x + ty.xzy * w.y + tz.xyz * w.z);
70
+ }
71
+ `;
72
+
73
+ // Per-kind fragment code, injected after <roughnessmap_fragment> so it can
74
+ // modulate both diffuseColor (already written by <map_fragment>) and roughnessFactor.
75
+ const FRAG_BODY = {
76
+ "layer-lines": `
77
+ {
78
+ float h = (pfPrintFrame * vec4(vPfObjPos, 1.0)).z / pfPatternScale;
79
+ float ridge = abs(fract(h) - 0.5) * 2.0; // 0 at a layer seam, 1 mid-layer
80
+ float groove = smoothstep(0.0, 0.35, ridge);
81
+ // Anti-alias: fwidth(h) is layers per pixel. Where a layer spans fewer
82
+ // than ~6 px the band fades to its mean over a period (0.825: the groove
83
+ // ramps over 35% of it, the rest is flat), and past ~2.5 px it is gone —
84
+ // point-sampling finer layers than that is what aliased into moiré.
85
+ groove = mix(0.825, groove, 1.0 - smoothstep(0.16, 0.4, fwidth(h)));
86
+ diffuseColor.rgb *= mix(0.9, 1.0, groove); // the normal map (FRAG_NORMAL) carries most of the relief
87
+ roughnessFactor = clamp(roughnessFactor + (1.0 - groove) * 0.18, 0.0, 1.0);
88
+ // Filament mottling: a fine grain (~0.17 mm) over slow blotches (~2 mm),
89
+ // fixed to the part, so a print doesn't read as flat injection-moulded plastic.
90
+ float mottle = pfNoise(vPfObjPos * 6.0) * 0.6 + pfNoise(vPfObjPos * 0.51) * 0.4;
91
+ diffuseColor.rgb *= mix(0.893, 1.047, mottle);
92
+ roughnessFactor = clamp(roughnessFactor + (mottle - 0.5) * 0.084, 0.0, 1.0);
93
+ }`,
94
+ "sls-grain": `
95
+ {
96
+ float g = pfNoise(vPfObjPos / pfPatternScale);
97
+ diffuseColor.rgb *= mix(0.92, 1.04, g);
98
+ roughnessFactor = clamp(roughnessFactor + (g - 0.5) * 0.1, 0.0, 1.0);
99
+ }`,
100
+ // Wood is a full PBR set sampled triplanar in object space: the colour map
101
+ // (sRGB, so already linear here) multiplies diffuseColor — white unless the
102
+ // author tinted it, physical.js — and the roughness map varies roughnessFactor
103
+ // around the preset's value (divided by the map's own mean, so the preset sets
104
+ // the average). Mipmaps (and RepeatWrapping) do the far-distance filtering.
105
+ wood: `
106
+ {
107
+ vec3 pfN = normalize(vPfObjNormal);
108
+ diffuseColor.rgb *= pfTriplanar(pfPatternMap, vPfObjPos, pfN, pfPatternScale);
109
+ float r = pfTriplanar(pfRoughMap, vPfObjPos, pfN, pfPatternScale).g;
110
+ roughnessFactor = clamp(roughnessFactor * r / pfRoughMean, 0.02, 1.0);
111
+ }`,
112
+ // Carbon samples a luminance MASK (raw, not sRGB-decoded: physical.js) and
113
+ // modulates around the texture's own mean (0.171; its spread is ~0.06), so a
114
+ // mipmapped far swatch keeps its preset colour and the weave reads at full
115
+ // contrast up close.
116
+ carbon: `
117
+ {
118
+ vec3 t = pfTriplanar(pfPatternMap, vPfObjPos, vPfObjNormal, pfPatternScale);
119
+ float l = dot(t, vec3(0.2126, 0.7152, 0.0722));
120
+ float n = clamp((l - 0.171) / 0.06, -1.0, 1.0);
121
+ diffuseColor.rgb *= 1.0 + n * 0.75;
122
+ roughnessFactor = clamp(roughnessFactor - n * 0.2, 0.0, 1.0);
123
+ }`,
124
+ concrete: `
125
+ {
126
+ vec3 t = pfTriplanar(pfPatternMap, vPfObjPos, vPfObjNormal, pfPatternScale);
127
+ diffuseColor.rgb *= t * 1.4;
128
+ }`,
129
+ };
130
+
131
+ // Per-kind NORMAL perturbation, injected after <normal_fragment_maps> (where the
132
+ // view-space \`normal\` is final). Layer lines are a procedural normal map: each
133
+ // layer is a round bead, so across one layer the surface normal swings from
134
+ // facing down (bottom of the bead) through straight out to facing up, along
135
+ // the print direction projected onto the surface. Faces parallel to the layers
136
+ // (tops and bottoms) get no ridges, as on a real print. Same fwidth fade as the
137
+ // colour bands, so far-away layers don't alias.
138
+ const FRAG_NORMAL = {
139
+ "layer-lines": `
140
+ {
141
+ float h = (pfPrintFrame * vec4(vPfObjPos, 1.0)).z / pfPatternScale;
142
+ float aa = 1.0 - smoothstep(0.16, 0.4, fwidth(h));
143
+ float t = fract(h) * 2.0 - 1.0; // -1 at a seam, 0 bead crest, 1 next seam
144
+ float slope = clamp(t / sqrt(max(1.0 - t * t, 0.04)), -3.0, 3.0);
145
+ vec3 up = normalize(vPfPrintUpView);
146
+ // NOT normalised: its length is how steeply the surface cuts across the
147
+ // layers (1 on a vertical wall, 0 on a face parallel to them), so ridges fade
148
+ // out on tops and bottoms. Normalising it once made a flat top pick up a
149
+ // full-strength tilt in a direction set by rounding noise, flipping sign
150
+ // wherever the face sat on a layer boundary — a visible patch.
151
+ vec3 along = up - normal * dot(normal, up);
152
+ normal = normalize(normal + along * slope * 0.35 * aa);
153
+ }`,
154
+ // Wood replaces the view-space normal with its triplanar normal map, built in
155
+ // object space and carried to view space through the normal matrix. A
156
+ // double-sided back face is flipped the way <normal_fragment_begin> flips it.
157
+ wood: `
158
+ {
159
+ vec3 pfObjN = pfTriplanarNormal(vPfObjPos, normalize(vPfObjNormal), pfPatternScale);
160
+ normal = normalize(mat3(vPfNmX, vPfNmY, vPfNmZ) * pfObjN);
161
+ #ifdef DOUBLE_SIDED
162
+ normal *= faceDirection;
163
+ #endif
164
+ }`,
165
+ };
166
+
167
+ export function applyPattern(material, { kind, scale = 1, printFrame, texture, normalMap, roughnessMap, roughnessMean = 0.5, normalScale = 1 } = {}) {
168
+ if (!kind || !FRAG_BODY[kind]) return material;
169
+ const wood = kind === "wood";
170
+ const uniforms = {
171
+ pfPatternScale: { value: scale },
172
+ pfPrintFrame: { value: new THREE.Matrix4().fromArray(printFrame ?? new THREE.Matrix4().toArray()) },
173
+ pfPatternMap: { value: texture ?? null },
174
+ ...(wood ? {
175
+ pfNormalMap: { value: normalMap ?? null },
176
+ pfRoughMap: { value: roughnessMap ?? null },
177
+ pfRoughMean: { value: roughnessMean },
178
+ pfNormalStrength: { value: normalScale },
179
+ } : {}),
180
+ };
181
+ material.userData.patternUniforms = uniforms;
182
+ material.onBeforeCompile = (shader) => {
183
+ Object.assign(shader.uniforms, uniforms);
184
+ shader.vertexShader = shader.vertexShader
185
+ .replace("#include <common>", `#include <common>\n${VERT_DECL}${wood ? VERT_DECL_NM : ""}`)
186
+ .replace("#include <begin_vertex>", `#include <begin_vertex>\n${VERT_BODY}${wood ? VERT_BODY_NM : ""}`);
187
+ shader.fragmentShader = shader.fragmentShader
188
+ .replace("#include <common>", `#include <common>\n${FRAG_DECL}${wood ? FRAG_DECL_WOOD : ""}`)
189
+ .replace("#include <roughnessmap_fragment>", `#include <roughnessmap_fragment>\n${FRAG_BODY[kind]}`)
190
+ .replace("#include <normal_fragment_maps>", `#include <normal_fragment_maps>\n${FRAG_NORMAL[kind] ?? ""}`);
191
+ };
192
+ material.customProgramCacheKey = () => `pf-pattern:${kind}`;
193
+ material.needsUpdate = true;
194
+ return material;
195
+ }
196
+
197
+ // Brush frame for anisotropic (brushed) metal, chosen PER PIXEL. three follows the
198
+ // tangent frame `tbn`; built from per-vertex data it interpolates across each
199
+ // triangle, so wherever the brush direction has to turn (it must, somewhere, on
200
+ // any closed part) the turn followed the triangulation — a jagged staircase across
201
+ // a fillet corner's small facets. Here the box-projection rule (brush along object
202
+ // X, or along Y where X dominates the normal) runs on the interpolated object
203
+ // normal in the fragment, so the turn lands on the smooth |nx| = max(|ny|,|nz|)
204
+ // contour instead. The frame is rebuilt from the final shading `normal`, just
205
+ // before <lights_physical_fragment> reads it. Composes with a pattern's own
206
+ // injection (applied first) rather than replacing it.
207
+ const BRUSH_VERT_DECL = "varying vec3 vPfBrushN;\nvarying vec3 vPfBrushNmX;\nvarying vec3 vPfBrushNmY;\nvarying vec3 vPfBrushNmZ;\n";
208
+ const BRUSH_VERT_BODY = "vPfBrushN = normal;\nvPfBrushNmX = normalMatrix[0];\nvPfBrushNmY = normalMatrix[1];\nvPfBrushNmZ = normalMatrix[2];\n";
209
+ const BRUSH_FRAG_DECL = "varying vec3 vPfBrushN;\nvarying vec3 vPfBrushNmX;\nvarying vec3 vPfBrushNmY;\nvarying vec3 vPfBrushNmZ;\n";
210
+ const BRUSH_FRAG_BODY = `
211
+ #ifdef USE_ANISOTROPY
212
+ {
213
+ vec3 pfN = normalize(vPfBrushN);
214
+ vec3 pfA = abs(pfN);
215
+ vec3 pfAxis = (pfA.x > pfA.y && pfA.x > pfA.z) ? vec3(0.0, 1.0, 0.0) : vec3(1.0, 0.0, 0.0);
216
+ vec3 pfT = mat3(vPfBrushNmX, vPfBrushNmY, vPfBrushNmZ) * (pfAxis - pfN * dot(pfAxis, pfN));
217
+ pfT = pfT - normal * dot(normal, pfT);
218
+ if (dot(pfT, pfT) > 1e-12) {
219
+ pfT = normalize(pfT);
220
+ tbn[0] = pfT;
221
+ tbn[1] = cross(normal, pfT);
222
+ }
223
+ }
224
+ #endif
225
+ `;
226
+
227
+ export function applyBrushFrame(material) {
228
+ const prev = material.onBeforeCompile;
229
+ const prevKey = material.customProgramCacheKey;
230
+ material.onBeforeCompile = (shader, renderer) => {
231
+ prev?.call(material, shader, renderer);
232
+ shader.vertexShader = shader.vertexShader
233
+ .replace("#include <common>", `#include <common>\n${BRUSH_VERT_DECL}`)
234
+ .replace("#include <begin_vertex>", `#include <begin_vertex>\n${BRUSH_VERT_BODY}`);
235
+ shader.fragmentShader = shader.fragmentShader
236
+ .replace("#include <common>", `#include <common>\n${BRUSH_FRAG_DECL}`)
237
+ .replace("#include <lights_physical_fragment>", `${BRUSH_FRAG_BODY}\n#include <lights_physical_fragment>`);
238
+ };
239
+ const base = prevKey && prevKey !== THREE.Material.prototype.customProgramCacheKey ? prevKey.call(material) : "";
240
+ material.customProgramCacheKey = () => `${base}|pf-brush`;
241
+ material.needsUpdate = true;
242
+ return material;
243
+ }
@@ -0,0 +1,88 @@
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 { applyBrushFrame, 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, opts = {}) {
28
+ const m = buildPhysical(display, opts);
29
+ // brushed metal picks its brush direction per pixel (patterns.js applyBrushFrame)
30
+ return m.userData.pfAnisotropic ? applyBrushFrame(m) : m;
31
+ }
32
+
33
+ function buildPhysical(display, { printFrame, loadTexture } = {}) {
34
+ const { params, tinted } = resolveMaterial(display);
35
+ const set = params.textures;
36
+ // A textured preset's colour map carries its own colour; the preset `color` is
37
+ // only its average (for CAD and 3MF). So the material starts white and an
38
+ // explicit `color` tints the map.
39
+ const base = set?.color && !tinted ? 0xffffff : params.color;
40
+ const m = new THREE.MeshPhysicalMaterial({
41
+ color: new THREE.Color(base),
42
+ metalness: params.metalness,
43
+ roughness: params.roughness,
44
+ clearcoat: params.clearcoat,
45
+ clearcoatRoughness: params.clearcoatRoughness,
46
+ transmission: params.transmission,
47
+ thickness: params.thickness,
48
+ ior: params.ior,
49
+ anisotropy: params.anisotropy,
50
+ envMapIntensity: params.envIntensity,
51
+ specularIntensity: params.specularIntensity,
52
+ });
53
+ if (params.opacity < 1) { m.transparent = true; m.opacity = params.opacity; m.depthWrite = false; }
54
+ m.userData.pfAnisotropic = params.anisotropy > 0;
55
+ if (!loadTexture) {
56
+ applyPattern(m, { kind: params.pattern, scale: params.textureScale, printFrame });
57
+ return m;
58
+ }
59
+ if (set) {
60
+ // A full PBR set: colour decoded as sRGB, normal and roughness are data.
61
+ const load = (file, colorSpace) => {
62
+ if (!file) return undefined;
63
+ const t = loadTexture(file);
64
+ t.wrapS = t.wrapT = THREE.RepeatWrapping;
65
+ t.colorSpace = colorSpace;
66
+ return t;
67
+ };
68
+ applyPattern(m, {
69
+ kind: params.pattern, scale: params.textureScale, printFrame,
70
+ texture: load(set.color, THREE.SRGBColorSpace),
71
+ normalMap: load(set.normal, THREE.NoColorSpace),
72
+ roughnessMap: load(set.roughness, THREE.NoColorSpace),
73
+ roughnessMean: set.roughnessMean,
74
+ normalScale: set.normalScale,
75
+ });
76
+ return m;
77
+ }
78
+ const textureFile = PATTERN_TEXTURES[params.pattern];
79
+ const texture = textureFile ? loadTexture(textureFile) : undefined;
80
+ // The carbon texture is a luminance mask, read raw; decoding it as sRGB
81
+ // crushed its contrast to nothing. Concrete is a colour map.
82
+ if (texture) {
83
+ texture.wrapS = texture.wrapT = THREE.RepeatWrapping;
84
+ texture.colorSpace = params.pattern === "concrete" ? THREE.SRGBColorSpace : THREE.NoColorSpace;
85
+ }
86
+ applyPattern(m, { kind: params.pattern, scale: params.textureScale, printFrame, texture });
87
+ return m;
88
+ }
@@ -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
+ }