partforge 0.122.0 → 0.124.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.
@@ -86,6 +86,21 @@ const rotVec = (p, k, th) => { // Rodrigues rotation about unit axis k
86
86
  return add(add(scl(p, c), scl(cross(k, p), s)), scl(k, dot(k, p) * (1 - c)));
87
87
  };
88
88
 
89
+ // Analytic shading. Each fillet tool's blend wall is a canal surface about a known
90
+ // spine (blend-surfaces.js); tell the kernel, which registers it against the tool's
91
+ // surface id so toMesh can emit the exact rolling-ball normals instead of facet
92
+ // averages. `surf` is in the tool's CURRENT (posed, world) frame. A kernel without
93
+ // the hook (or a chamfer, whose flat/conical walls facet-shade exactly already) just
94
+ // gets the tool back.
95
+ const markBlend = (k, tool, surf) => (k._markBlendSurface ? k._markBlendSurface(tool, surf) : tool);
96
+ // The ball centre's offset from the edge point, given the two unit flank normals —
97
+ // profile2D's C, in any dimension (2-D profile coordinates or 3-D world vectors).
98
+ const ballOffset = (n1, n2, r, convex) => {
99
+ const c = clamp1(n1.reduce((acc, x, i) => acc + x * n2[i], 0));
100
+ const f = (convex ? 1 : -1) * (-r / (1 + c));
101
+ return n1.map((x, i) => f * (x + n2[i]));
102
+ };
103
+
89
104
  // ---------------------------------------------------------------------------
90
105
  // Sharp-edge extraction: weld vertices, keep edges whose two incident triangles
91
106
  // meet at a dihedral sharper than sharpDeg, tag convexity and flank normals.
@@ -663,7 +678,8 @@ function prismTool(k, chain, magnitude, mode, segs, pSegs = segs) {
663
678
  { shading: "smooth" },
664
679
  );
665
680
  if (axis) tool = tool.rotateAbout({ axis, deg: (theta * 180) / Math.PI });
666
- return tool.translate(a);
681
+ tool = tool.translate(a);
682
+ return mode === "fillet" ? markBlend(k, tool, { kind: "line", p: add(a, ballOffset(n1, n2, magnitude, convex)), d: e }) : tool;
667
683
  }
668
684
 
669
685
  // `segs` is the KERNEL quality — it sizes the flank-facet guards (sag/ext) and the
@@ -756,7 +772,11 @@ function revolveTool(k, chain, magnitude, mode, segs, pSegs = segs, flankAt = ()
756
772
  const dephase = closed ? Math.PI / flankSegs : 0;
757
773
  const twist = Math.atan2(dot(w, cross(xImage, startDir)), dot(xImage, startDir)) + dephase;
758
774
  if (Math.abs(twist) > 1e-9) tool = tool.rotateAbout({ axis: w, deg: (twist * 180) / Math.PI });
759
- return tool.translate(O);
775
+ tool = tool.translate(O);
776
+ if (mode !== "fillet") return tool;
777
+ // spine: the ball centre's circle — profile C = [R, 0] + offset in (ρ, w)
778
+ const C = ballOffset(n1, n2, magnitude, convex);
779
+ return markBlend(k, tool, { kind: "circle", c: add(O, scl(w, C[1])), a: w, R: R + C[0] });
760
780
  }
761
781
 
762
782
  // ---------------------------------------------------------------------------
@@ -895,6 +915,32 @@ function weldChainPoints(pts, wallNs, closed) {
895
915
  return { pts: outP, wallNs: outW };
896
916
  }
897
917
 
918
+ // The spine of a planar sweep tool, as a "path" blend surface (blend-surfaces.js).
919
+ // The sweep transports the profile about the face normal, so the wall keeps its
920
+ // face-normal component and its in-plane part turns with the path; the seed segment
921
+ // (the one sweepSeedFrame is built ⟂ to: the first, or the closing one of a loop)
922
+ // fixes which side that is. Vertex directions bisect their two segments' sides.
923
+ function planarSpine(path, closed, faceN, wallN, magnitude, convex) {
924
+ const n = path.length, nSeg = closed ? n : n - 1;
925
+ const tan = (i) => norm(sub(path[(i + 1) % n], path[i]));
926
+ const tSeed = tan(closed ? n - 1 : 0);
927
+ const wallP = norm(sub(wallN, scl(tSeed, dot(wallN, tSeed))));
928
+ const cf = dot(wallP, faceN);
929
+ const wIn = sub(wallP, scl(faceN, cf));
930
+ const side = dot(wIn, cross(faceN, tSeed)) >= 0 ? 1 : -1;
931
+ const segSide = [];
932
+ for (let i = 0; i < nSeg; i++) segSide.push(scl(norm(cross(faceN, tan(i))), side));
933
+ const dirs = path.map((_, i) => {
934
+ const a = closed ? segSide[(i - 1 + nSeg) % nSeg] : segSide[Math.max(0, i - 1)];
935
+ const b = closed ? segSide[i % nSeg] : segSide[Math.min(nSeg - 1, i)];
936
+ const m = norm(add(a, b));
937
+ return len(m) > 0 ? m : a;
938
+ });
939
+ // ballOffset(faceN, wall) = K·(faceN + wall) with wall = cf·faceN + |wIn|·dir
940
+ const K = (convex ? 1 : -1) * (-magnitude / (1 + clamp1(cf)));
941
+ return { kind: "path", pts: path, closed, dirs, f: faceN, kf: K * (1 + cf), kd: K * len(wIn) };
942
+ }
943
+
898
944
  function planarTool(k, chain, magnitude, mode, segs, pSegs = segs, endTins = null, flankAt = () => segs) {
899
945
  const { points, closed, convex, faceN } = chain;
900
946
  let { wallNs } = chain;
@@ -1036,7 +1082,8 @@ function planarTool(k, chain, magnitude, mode, segs, pSegs = segs, endTins = nul
1036
1082
  return [q[0] / l, q[1] / l];
1037
1083
  };
1038
1084
  const poly = profile2D({ P: [0, 0], n1: p2(faceN), n2: p2(wallN), magnitude, mode, convex, segs: pSegs });
1039
- return k.sweep(poly, path3D, { closed: isClosed });
1085
+ const tool = k.sweep(poly, path3D, { closed: isClosed });
1086
+ return mode === "fillet" ? markBlend(k, tool, planarSpine(path3D, isClosed, faceN, wallN, magnitude, convex)) : tool;
1040
1087
  };
1041
1088
 
1042
1089
  // Sweep one open stretch; when the sweep refuses a VERTEX fold the pre-split
@@ -1333,7 +1380,9 @@ function reflexPivotTool(k, { vertex, f, u0, span }, magnitude, mode, segs, pSeg
1333
1380
  const xImage = axis ? rotVec([1, 0, 0], axis, theta) : [1, 0, 0];
1334
1381
  const twist = Math.atan2(dot(f, cross(xImage, startDir)), dot(xImage, startDir));
1335
1382
  if (Math.abs(twist) > 1e-9) tool = tool.rotateAbout({ axis: f, deg: (twist * 180) / Math.PI });
1336
- return tool.translate(vertex);
1383
+ tool = tool.translate(vertex);
1384
+ // spine: the ball centre swings on a circle of radius r, r below the face
1385
+ return mode === "fillet" ? markBlend(k, tool, { kind: "circle", c: sub(vertex, scl(f, r)), a: f, R: r }) : tool;
1337
1386
  }
1338
1387
 
1339
1388
  function chainEndInfo(ch, end) {
@@ -1546,7 +1595,8 @@ function cornerPatches(k, selected, r, segs, flankAt = () => segs) {
1546
1595
  const twist = Math.atan2(dot(e1, cross(xImage, e2)), dot(xImage, e2));
1547
1596
  if (Math.abs(twist) > 1e-9) block = block.rotateAbout({ axis: e1, deg: (twist * 180) / Math.PI });
1548
1597
  block = block.translate(V);
1549
- patches.push(block.cut(k.sphere({ r }).at(add(C, scl(inward, bury)))));
1598
+ const centre = add(C, scl(inward, bury));
1599
+ patches.push(block.cut(markBlend(k, k.sphere({ r }).at(centre), { kind: "point", c: centre })));
1550
1600
  }
1551
1601
  return patches;
1552
1602
  }
@@ -68,7 +68,7 @@ the sliced primary and gain-map buffers now start with SOI (`FF D8`) and end wit
68
68
  | `pattern-walnut-rough.jpg` | Poly Haven "Dark Wood" (roughness; API key `Rough`) | https://polyhaven.com/a/dark_wood — file https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/dark_wood/dark_wood_rough_1k.jpg | 2026-09-24 | 1K JPG, grayscale data -> `node scripts/bake-environments.mjs --texture dark_wood_rough_1k.jpg pattern-walnut-rough.jpg --gray` (source is neutral grey; channel means unchanged). |
69
69
  | `ground-maple-color.jpg` | Poly Haven "Oak Veneer 01" (colour; API key `Diffuse`) | https://polyhaven.com/a/oak_veneer_01 — file https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/oak_veneer_01/oak_veneer_01_diff_1k.jpg | 2026-09-24 | Workshop floor, "unfinished maple": `node scripts/bake-environments.mjs --texture oak_veneer_01_diff_1k.jpg ground-maple-color.jpg --modulate 1.5,0.7 --quality 85` (lightened 1.5x, saturation 0.7). |
70
70
  | `ground-maple-normal.jpg` | Poly Haven "Oak Veneer 01" (OpenGL normal map; API key `nor_gl`) | https://polyhaven.com/a/oak_veneer_01 — file https://dl.polyhaven.org/file/ph-assets/Textures/jpg/1k/oak_veneer_01/oak_veneer_01_nor_gl_1k.jpg | 2026-09-24 | Workshop floor relief: `node scripts/bake-environments.mjs --texture oak_veneer_01_nor_gl_1k.jpg ground-maple-normal.jpg --data --quality 92`. A separate file from `pattern-oak-normal.jpg` so the floor and the oak material never share (and dispose) one texture. |
71
- | `ground-pei.jpg` | None — procedural | n/a | n/a | Gold-brown fine-grain speckle, no external source. First attempt tinted ambientCG "Plastic 010"'s colour map (near-uniform, stdev < 1/channel — visibly flat, no grain, when actually viewed) gold-brown; that colour map carries no usable grain at all (its roughness map does, which is why `ground-pei-rough.jpg` below is still Plastic010's roughness). Checked ambientCG alternatives (Metal049A — also flat; Granite006A — real speckle but reads as rock, not powder-coat) and found nothing closer. Generated in-baker instead, same procedural precedent as `pattern-carbon.jpg`: per-pixel triangular-distributed noise around a `196,154,82` gold-brown base (asymmetric per channel so flecks warm rather than grey), then a 0.5px blur to turn single-pixel noise into soft grains -> `node scripts/bake-environments.mjs --speckle ground-pei.jpg --tint 196,154,82`. |
71
+ | `ground-pei.jpg` | None — procedural | n/a | n/a | Gold-brown fine-grain speckle, no external source. First attempt tinted ambientCG "Plastic 010"'s colour map (near-uniform, stdev < 1/channel — visibly flat, no grain, when actually viewed) gold-brown; that colour map carries no usable grain at all (its roughness map does, which is why `ground-pei-rough.jpg` below is still Plastic010's roughness). Checked ambientCG alternatives (Metal049A — also flat; Granite006A — real speckle but reads as rock, not powder-coat) and found nothing closer. Generated in-baker instead, same procedural precedent as `pattern-carbon.jpg`: per-pixel triangular-distributed noise around a `168,134,82` bronze-gold base (asymmetric per channel so flecks warm rather than grey), then a 0.5px blur to turn single-pixel noise into soft grains -> `node scripts/bake-environments.mjs --speckle ground-pei.jpg --tint 168,134,82`. (Re-baked 2026-09-24 from `196,154,82`: darker and less saturated, closer to a Bambu textured-PEI plate's muted bronze-gold.) |
72
72
  | `ground-pei-rough.jpg` | ambientCG "Plastic 010" (Roughness) | https://ambientcg.com/a/Plastic010 | 2026-09-24 | 1K JPG, grayscale -> `node scripts/bake-environments.mjs --texture Plastic010_1K-JPG_Roughness.jpg ground-pei-rough.jpg --gray` |
73
73
  | `ground-concrete.jpg` | ambientCG "Concrete 047 A" (Color) | https://ambientcg.com/a/Concrete047A | 2026-09-24 | 2K JPG -> `node scripts/bake-environments.mjs --texture Concrete047A_2K-JPG_Color.jpg ground-concrete.jpg --size 1024 --modulate 1.35,0 --flatten 0.7 --quality 82` (grey, brightened to the old map's mean so the concrete material preset keeps its tone; `--flatten 0.7` is 30% less grain contrast). Replaced ambientCG "Concrete 034", whose colour map was nearly flat (stdev ~5/255) and read as blur up close; 047 A's exposed aggregate keeps fine grain. |
74
74
  | `ground-concrete-rough.jpg` | ambientCG "Concrete 047 A" (Roughness) | https://ambientcg.com/a/Concrete047A | 2026-09-24 | 2K JPG, grayscale -> `node scripts/bake-environments.mjs --texture Concrete047A_2K-JPG_Roughness.jpg ground-concrete-rough.jpg --gray --size 1024` |
@@ -94,21 +94,32 @@ export async function loadEnvironmentRig(renderer, requestedId, { loadHdr, loadT
94
94
  if (env.ground.bed) {
95
95
  const material = groundMaterial(tex, rough, normal, { ...env.ground, bed: true });
96
96
  const bed = createPrintBed({ pei: material, tileMm: env.ground.tileMm ?? env.ground.sizeMm, createCanvas });
97
- // An enclosure's LED bar: one hard overhead light, riding with the bed so
98
- // it always falls on the part from the same place.
97
+ // An enclosure's LED bar: one hard overhead light, aimed at the bed's own
98
+ // origin. The LIGHT itself is deliberately not a child of bed.object:
99
+ // updateForCamera hides that whole group from below, and a hidden parent
100
+ // stops three from rendering its children too — the light would go dark
101
+ // exactly when the part needs it lit from underneath. It rides in
102
+ // `lights` instead and is repositioned by hand wherever the bed moves.
103
+ // The target CAN stay a child (matrixWorld updates regardless of
104
+ // visibility), which is what lets it keep tracking the bed for free.
99
105
  let keyLight = null;
100
106
  if (env.keyLight) {
101
107
  keyLight = new THREE.DirectionalLight(0xffffff, env.keyLight.intensity);
102
- keyLight.position.set(...env.keyLight.direction);
103
- bed.object.add(keyLight, keyLight.target);
108
+ bed.object.add(keyLight.target);
104
109
  }
105
110
  return {
106
111
  id, exposure: env.exposure, envMap, background, backgroundBlurriness, backgroundIntensity,
107
112
  rotationY: ((env.rotationDeg ?? 0) * Math.PI) / 180,
108
113
  ground: bed.object,
114
+ lights: keyLight ? [keyLight] : [],
109
115
  shadow,
116
+ updateForCamera(camera) { bed.updateForCamera(camera); },
110
117
  setGround({ y, centerX = 0, centerZ = 0, radius, footprintMm = radius }) {
111
118
  bed.place({ y, centerX, centerZ, footprintMm });
119
+ if (keyLight) {
120
+ const p = bed.object.position; // the fixed offset off the bed's origin
121
+ keyLight.position.set(p.x + env.keyLight.direction[0], p.y + env.keyLight.direction[1], p.z + env.keyLight.direction[2]);
122
+ }
112
123
  shadow.group.position.set(centerX, y, centerZ);
113
124
  // The shadow plane never hangs past the plate's edge into thin air.
114
125
  const s = Math.min(Math.max(radius * 3, 20), bed.sizeMm);
@@ -147,6 +158,7 @@ export async function loadEnvironmentRig(renderer, requestedId, { loadHdr, loadT
147
158
  backgroundIntensity,
148
159
  rotationY: ((env.rotationDeg ?? 0) * Math.PI) / 180,
149
160
  ground,
161
+ lights: [],
150
162
  shadow,
151
163
  setGround,
152
164
  dispose() {
@@ -45,10 +45,22 @@ uniform sampler2D pfNormalMap;
45
45
  uniform sampler2D pfRoughMap;
46
46
  uniform float pfRoughMean;
47
47
  uniform float pfNormalStrength;
48
+ uniform vec3 pfGrainSwap;
48
49
  varying vec3 vPfNmX;
49
50
  varying vec3 vPfNmY;
50
51
  varying vec3 vPfNmZ;
51
52
  vec3 pfTriplanarWeights(vec3 n) { vec3 w = pow(abs(n), vec3(4.0)); return w / (w.x + w.y + w.z); }
53
+ // One grain direction on every face: each projection's UV pair is transposed
54
+ // where pfGrainSwap says so (grainSwaps below), so the texture's grain lands on
55
+ // the sub-part's grain axis. A transpose is a mirror, not a rotation, which is
56
+ // what keeps the normal map exact: its slopes just swap axes (t.yx), no signs.
57
+ vec2 pfGrainUv(vec2 ab, float s) { return mix(ab, ab.yx, s); }
58
+ vec3 pfTriplanarWood(sampler2D map, vec3 p, vec3 n) {
59
+ vec3 w = pfTriplanarWeights(n);
60
+ return texture2D(map, pfGrainUv(p.yz, pfGrainSwap.x) / pfPatternScale).rgb * w.x
61
+ + texture2D(map, pfGrainUv(p.xz, pfGrainSwap.y) / pfPatternScale).rgb * w.y
62
+ + texture2D(map, pfGrainUv(p.xy, pfGrainSwap.z) / pfPatternScale).rgb * w.z;
63
+ }
52
64
  // Triplanar tangent-space normal map, whiteout-blended (Golus, "Normal Mapping
53
65
  // for a Triplanar Shader"). Each projection's map is read as a height field over
54
66
  // the two object axes its UVs run along, so its tangent normal (x, y) is a slope
@@ -59,10 +71,13 @@ vec3 pfTriplanarWeights(vec3 n) { vec3 w = pow(abs(n), vec3(4.0)); return w / (w
59
71
  // Poly Haven "nor_gl" convention. Returns an OBJECT-space unit normal.
60
72
  vec3 pfTriplanarNormal(vec3 p, vec3 n, float scale) {
61
73
  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;
74
+ vec3 tx = texture2D(pfNormalMap, pfGrainUv(p.yz, pfGrainSwap.x) / scale).xyz * 2.0 - 1.0;
75
+ vec3 ty = texture2D(pfNormalMap, pfGrainUv(p.xz, pfGrainSwap.y) / scale).xyz * 2.0 - 1.0;
76
+ vec3 tz = texture2D(pfNormalMap, pfGrainUv(p.xy, pfGrainSwap.z) / scale).xyz * 2.0 - 1.0;
77
+ // a transposed sample's slopes run along the swapped axes
78
+ tx.xy = pfGrainUv(tx.xy, pfGrainSwap.x) * pfNormalStrength;
79
+ ty.xy = pfGrainUv(ty.xy, pfGrainSwap.y) * pfNormalStrength;
80
+ tz.xy = pfGrainUv(tz.xy, pfGrainSwap.z) * pfNormalStrength;
66
81
  tx = vec3(tx.xy + n.yz, abs(tx.z) * n.x); // uv = (y, z), face axis x
67
82
  ty = vec3(ty.xy + n.xz, abs(ty.z) * n.y); // uv = (x, z), face axis y
68
83
  tz = vec3(tz.xy + n.xy, abs(tz.z) * n.z); // uv = (x, y), face axis z
@@ -102,11 +117,12 @@ const FRAG_BODY = {
102
117
  // author tinted it, physical.js — and the roughness map varies roughnessFactor
103
118
  // around the preset's value (divided by the map's own mean, so the preset sets
104
119
  // the average). Mipmaps (and RepeatWrapping) do the far-distance filtering.
120
+ // All three maps follow the grain axis (pfTriplanarWood / setGrainAxis).
105
121
  wood: `
106
122
  {
107
123
  vec3 pfN = normalize(vPfObjNormal);
108
- diffuseColor.rgb *= pfTriplanar(pfPatternMap, vPfObjPos, pfN, pfPatternScale);
109
- float r = pfTriplanar(pfRoughMap, vPfObjPos, pfN, pfPatternScale).g;
124
+ diffuseColor.rgb *= pfTriplanarWood(pfPatternMap, vPfObjPos, pfN);
125
+ float r = pfTriplanarWood(pfRoughMap, vPfObjPos, pfN).g;
110
126
  roughnessFactor = clamp(roughnessFactor * r / pfRoughMean, 0.02, 1.0);
111
127
  }`,
112
128
  // Carbon samples a luminance MASK (raw, not sRGB-decoded: physical.js) and
@@ -164,7 +180,40 @@ const FRAG_NORMAL = {
164
180
  }`,
165
181
  };
166
182
 
167
- export function applyPattern(material, { kind, scale = 1, printFrame, texture, normalMap, roughnessMap, roughnessMean = 0.5, normalScale = 1 } = {}) {
183
+ // Wood grain: real wood has ONE grain direction, the board's long axis, but a
184
+ // triplanar projection with fixed UVs (X-faces (y, z), Y-faces (x, z), Z-faces
185
+ // (x, y)) lands the texture's grain on a different object axis per face — on a
186
+ // block, adjacent faces showed grain at right angles. So each sub-part gets a
187
+ // grain axis (its longest object-space bounding-box axis, X on a tie) and every
188
+ // projection whose face CONTAINS that axis is transposed where needed so the
189
+ // texture's grain runs along it. Faces across the axis are end grain; there is
190
+ // no end-grain texture, so they keep the default sample.
191
+ //
192
+ // `grain` is which texture axis a scan's figure runs along ("u" horizontal, "v"
193
+ // vertical in the image), declared per texture set in presets.js.
194
+ const PLANE_AXES = [[1, 2], [0, 2], [0, 1]]; // each projection's (u, v) object axes
195
+ export function grainAxisFor(box) {
196
+ if (!box || box.isEmpty?.()) return 0;
197
+ const s = [box.max.x - box.min.x, box.max.y - box.min.y, box.max.z - box.min.z];
198
+ const eps = Math.max(...s) * 1e-6;
199
+ let axis = 0;
200
+ for (let i = 1; i < 3; i++) if (s[i] > s[axis] + eps) axis = i;
201
+ return axis;
202
+ }
203
+ export function grainSwaps(axis, grain = "u") {
204
+ const want = grain === "v" ? 1 : 0;
205
+ return PLANE_AXES.map(([u], face) => (face === axis ? 0 : (axis === u ? 0 : 1) !== want ? 1 : 0));
206
+ }
207
+ // Point a wood material (and every clone sharing its uniforms) at a grain axis.
208
+ // A no-op on any other material.
209
+ export function setGrainAxis(material, axis) {
210
+ const u = material?.userData?.patternUniforms?.pfGrainSwap;
211
+ if (!u) return;
212
+ material.userData.pfGrainAxis = axis;
213
+ u.value.fromArray(grainSwaps(axis, material.userData.pfGrain));
214
+ }
215
+
216
+ export function applyPattern(material, { kind, scale = 1, printFrame, texture, normalMap, roughnessMap, roughnessMean = 0.5, normalScale = 1, grain = "u" } = {}) {
168
217
  if (!kind || !FRAG_BODY[kind]) return material;
169
218
  const wood = kind === "wood";
170
219
  const uniforms = {
@@ -176,9 +225,11 @@ export function applyPattern(material, { kind, scale = 1, printFrame, texture, n
176
225
  pfRoughMap: { value: roughnessMap ?? null },
177
226
  pfRoughMean: { value: roughnessMean },
178
227
  pfNormalStrength: { value: normalScale },
228
+ pfGrainSwap: { value: new THREE.Vector3() },
179
229
  } : {}),
180
230
  };
181
231
  material.userData.patternUniforms = uniforms;
232
+ if (wood) { material.userData.pfGrain = grain; setGrainAxis(material, 0); }
182
233
  material.onBeforeCompile = (shader) => {
183
234
  Object.assign(shader.uniforms, uniforms);
184
235
  shader.vertexShader = shader.vertexShader
@@ -193,3 +244,51 @@ export function applyPattern(material, { kind, scale = 1, printFrame, texture, n
193
244
  material.needsUpdate = true;
194
245
  return material;
195
246
  }
247
+
248
+ // Brush frame for anisotropic (brushed) metal, chosen PER PIXEL. three follows the
249
+ // tangent frame `tbn`; built from per-vertex data it interpolates across each
250
+ // triangle, so wherever the brush direction has to turn (it must, somewhere, on
251
+ // any closed part) the turn followed the triangulation — a jagged staircase across
252
+ // a fillet corner's small facets. Here the box-projection rule (brush along object
253
+ // X, or along Y where X dominates the normal) runs on the interpolated object
254
+ // normal in the fragment, so the turn lands on the smooth |nx| = max(|ny|,|nz|)
255
+ // contour instead. The frame is rebuilt from the final shading `normal`, just
256
+ // before <lights_physical_fragment> reads it. Composes with a pattern's own
257
+ // injection (applied first) rather than replacing it.
258
+ const BRUSH_VERT_DECL = "varying vec3 vPfBrushN;\nvarying vec3 vPfBrushNmX;\nvarying vec3 vPfBrushNmY;\nvarying vec3 vPfBrushNmZ;\n";
259
+ const BRUSH_VERT_BODY = "vPfBrushN = normal;\nvPfBrushNmX = normalMatrix[0];\nvPfBrushNmY = normalMatrix[1];\nvPfBrushNmZ = normalMatrix[2];\n";
260
+ const BRUSH_FRAG_DECL = "varying vec3 vPfBrushN;\nvarying vec3 vPfBrushNmX;\nvarying vec3 vPfBrushNmY;\nvarying vec3 vPfBrushNmZ;\n";
261
+ const BRUSH_FRAG_BODY = `
262
+ #ifdef USE_ANISOTROPY
263
+ {
264
+ vec3 pfN = normalize(vPfBrushN);
265
+ vec3 pfA = abs(pfN);
266
+ vec3 pfAxis = (pfA.x > pfA.y && pfA.x > pfA.z) ? vec3(0.0, 1.0, 0.0) : vec3(1.0, 0.0, 0.0);
267
+ vec3 pfT = mat3(vPfBrushNmX, vPfBrushNmY, vPfBrushNmZ) * (pfAxis - pfN * dot(pfAxis, pfN));
268
+ pfT = pfT - normal * dot(normal, pfT);
269
+ if (dot(pfT, pfT) > 1e-12) {
270
+ pfT = normalize(pfT);
271
+ tbn[0] = pfT;
272
+ tbn[1] = cross(normal, pfT);
273
+ }
274
+ }
275
+ #endif
276
+ `;
277
+
278
+ export function applyBrushFrame(material) {
279
+ const prev = material.onBeforeCompile;
280
+ const prevKey = material.customProgramCacheKey;
281
+ material.onBeforeCompile = (shader, renderer) => {
282
+ prev?.call(material, shader, renderer);
283
+ shader.vertexShader = shader.vertexShader
284
+ .replace("#include <common>", `#include <common>\n${BRUSH_VERT_DECL}`)
285
+ .replace("#include <begin_vertex>", `#include <begin_vertex>\n${BRUSH_VERT_BODY}`);
286
+ shader.fragmentShader = shader.fragmentShader
287
+ .replace("#include <common>", `#include <common>\n${BRUSH_FRAG_DECL}`)
288
+ .replace("#include <lights_physical_fragment>", `${BRUSH_FRAG_BODY}\n#include <lights_physical_fragment>`);
289
+ };
290
+ const base = prevKey && prevKey !== THREE.Material.prototype.customProgramCacheKey ? prevKey.call(material) : "";
291
+ material.customProgramCacheKey = () => `${base}|pf-brush`;
292
+ material.needsUpdate = true;
293
+ return material;
294
+ }
@@ -5,7 +5,7 @@
5
5
  // disagree about which preset a sub-part is.
6
6
  import * as THREE from "three";
7
7
  import { cadAppearance, resolveMaterial } from "./resolve.js";
8
- import { applyPattern } from "./patterns.js";
8
+ import { applyBrushFrame, applyPattern } from "./patterns.js";
9
9
  import { PATTERN_TEXTURES } from "./assets.js";
10
10
 
11
11
  const APPEARANCE_KEYS = ["color", "opacity", "material", "roughness", "metalness", "clearcoat", "clearcoatRoughness", "anisotropy", "textureScale"];
@@ -24,7 +24,13 @@ export function buildCadMaterial(display, base) {
24
24
 
25
25
  // Realistic-mode material. `loadTexture(fileName)` is injected (the viewer owns
26
26
  // a caching TextureLoader), so this stays unit-testable without a network.
27
- export function buildPhysicalMaterial(display, { printFrame, loadTexture } = {}) {
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 } = {}) {
28
34
  const { params, tinted } = resolveMaterial(display);
29
35
  const set = params.textures;
30
36
  // A textured preset's colour map carries its own colour; the preset `color` is
@@ -66,6 +72,7 @@ export function buildPhysicalMaterial(display, { printFrame, loadTexture } = {})
66
72
  roughnessMap: load(set.roughness, THREE.NoColorSpace),
67
73
  roughnessMean: set.roughnessMean,
68
74
  normalScale: set.normalScale,
75
+ grain: set.grain,
69
76
  });
70
77
  return m;
71
78
  }
@@ -77,13 +77,16 @@ export const PRESETS = {
77
77
  // `roughnessMean` is the roughness map's mean, so the preset `roughness` (or an
78
78
  // override) sets the AVERAGE roughness and the map only varies it. `normalScale`
79
79
  // strengthens the normal map: these scans' tangent normals barely leave +Z (a
80
- // ~1% tilt), which reads as flat under an environment map.
80
+ // ~1% tilt), which reads as flat under an environment map. `grain` is the
81
+ // texture axis the scan's figure runs along — oak's runs up the image ("v"),
82
+ // walnut's across it ("u") — so patterns.js can lay it along each sub-part's
83
+ // long axis.
81
84
  oak: P("oak", "Oak", "natural", "Light oak with open grain.",
82
85
  { 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 } }),
86
+ textures: { color: "pattern-oak-color.jpg", normal: "pattern-oak-normal.jpg", roughness: "pattern-oak-rough.jpg", roughnessMean: 0.53, normalScale: 2.5, grain: "v" } }),
84
87
  walnut: P("walnut", "Walnut", "natural", "Dark oiled walnut.",
85
88
  { 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 } }),
89
+ textures: { color: "pattern-walnut-color.jpg", normal: "pattern-walnut-normal.jpg", roughness: "pattern-walnut-rough.jpg", roughnessMean: 0.628, normalScale: 3, grain: "u" } }),
87
90
  "carbon-fiber": P("carbon-fiber", "Carbon fibre", "natural", "2x2 twill carbon fibre under clear coat.",
88
91
  { color: 0x1b1c1e, metalness: 0.2, roughness: 0.35, clearcoat: 1, clearcoatRoughness: 0.05, pattern: "carbon", textureScale: 48 }),
89
92
  };
@@ -154,6 +154,14 @@ export function createPrintBed({ pei, tileMm, createCanvas }) {
154
154
  ink.scale.set(size, 1, size);
155
155
  }
156
156
 
157
+ // Below this, the whole plate hides — like the other environments' single-
158
+ // sided ground discs, which cull away once the camera passes their back
159
+ // face. The epsilon keeps a camera sitting exactly at plate height from
160
+ // flickering the bed in and out as float error nudges it either side.
161
+ const HIDE_EPSILON_MM = 0.05;
162
+ const _camWorld = new THREE.Vector3();
163
+ const _topWorld = new THREE.Vector3();
164
+
157
165
  return {
158
166
  object: group,
159
167
  get sizeMm() { return size; },
@@ -161,6 +169,15 @@ export function createPrintBed({ pei, tileMm, createCanvas }) {
161
169
  setSize(bedSizeFor(footprintMm));
162
170
  group.position.set(centerX, y - 0.01, centerZ);
163
171
  },
172
+ // Called once per render, with the camera actually doing the rendering
173
+ // (the live camera, or an offscreen capture's) — never a cached "current
174
+ // view" — so a canonical "from below" render also sees the part rather
175
+ // than the plate's underside.
176
+ updateForCamera(camera) {
177
+ camera.getWorldPosition(_camWorld);
178
+ group.getWorldPosition(_topWorld); // local y = 0 is the plate's top surface
179
+ group.visible = _camWorld.y >= _topWorld.y - HIDE_EPSILON_MM;
180
+ },
164
181
  dispose() {
165
182
  plate.geometry.dispose(); ink.geometry.dispose();
166
183
  edge.dispose(); inkMat.dispose(); inkTex?.dispose();
@@ -1,23 +1,42 @@
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.
1
+ // Box-projected UVs and brush tangents, added only for realistic mode. three's
2
+ // anisotropy (brushed metal) needs a tangent frame, and CAD meshes carry neither
3
+ // UVs nor tangents. Projection is in OBJECT space along each vertex normal's
4
+ // dominant axis, so it rides with the sub-part and never swims. Nothing samples a
5
+ // texture through these UVs — triplanar patterns (patterns.js) ignore them.
6
+ //
7
+ // The TANGENT attribute is what the brush actually follows. Without one, three
8
+ // derives the frame per pixel from screen-space UV derivatives, which is constant
9
+ // across each triangle: on a fillet or a corner every facet read as its own flat
10
+ // brushed patch, and a triangle whose corners picked different projection axes
11
+ // got a garbage frame outright. The per-vertex tangent is the projection's u-axis
12
+ // (world X, or Y where X dominates the normal) flattened into the tangent plane, so
13
+ // it interpolates smoothly with the (analytic) vertex normals. It turns 90° only
14
+ // across the one facet row where the dominant axis changes, and never through
15
+ // zero — the two candidate axes are orthogonal.
7
16
  import * as THREE from "three";
8
17
 
9
18
  export function ensureBoxUVs(geometry, scaleMm = 20) {
10
- if (geometry.userData.boxUVs && geometry.attributes.uv) return geometry;
19
+ if (geometry.userData.boxUVs && geometry.attributes.uv && geometry.attributes.tangent) return geometry;
11
20
  const pos = geometry.attributes.position, nrm = geometry.attributes.normal;
12
21
  const uv = new Float32Array(pos.count * 2);
22
+ const tangent = new Float32Array(pos.count * 4);
13
23
  for (let i = 0; i < pos.count; i++) {
14
24
  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);
25
+ const nx = nrm ? nrm.getX(i) : 0, ny = nrm ? nrm.getY(i) : 0, nz = nrm ? nrm.getZ(i) : 1;
26
+ const ax = Math.abs(nx), ay = Math.abs(ny), az = Math.abs(nz);
16
27
  let u, v;
17
28
  if (az >= ax && az >= ay) { u = x; v = y; } else if (ay >= ax) { u = x; v = z; } else { u = y; v = z; }
18
29
  uv[i * 2] = u / scaleMm; uv[i * 2 + 1] = v / scaleMm;
30
+ // u-axis minus its normal component; |that| ≥ √½ because the axis is never the
31
+ // dominant one, so the normalize is always well-conditioned
32
+ const uxAxis = !(ax > ay && ax > az);
33
+ const d = uxAxis ? nx : ny;
34
+ let tx = (uxAxis ? 1 : 0) - d * nx, ty = (uxAxis ? 0 : 1) - d * ny, tz = -d * nz;
35
+ const L = Math.hypot(tx, ty, tz) || 1;
36
+ tangent[i * 4] = tx / L; tangent[i * 4 + 1] = ty / L; tangent[i * 4 + 2] = tz / L; tangent[i * 4 + 3] = 1;
19
37
  }
20
38
  geometry.setAttribute("uv", new THREE.BufferAttribute(uv, 2));
39
+ geometry.setAttribute("tangent", new THREE.BufferAttribute(tangent, 4));
21
40
  geometry.userData.boxUVs = true;
22
41
  return geometry;
23
42
  }