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.
- package/README.md +3 -2
- package/docs/AUTHORING-PARTS.md +57 -31
- package/docs/KERNEL-CONTRACT.md +10 -0
- package/package.json +1 -1
- package/src/framework/annotate/annotate-mode.js +6 -1
- package/src/framework/app.css +69 -57
- package/src/framework/chrome.css +38 -25
- package/src/framework/cutaway-gizmo.js +2 -1
- package/src/framework/geometry/blend-surfaces.js +256 -0
- package/src/framework/geometry/creased-normals.js +121 -7
- package/src/framework/geometry/manifold-backend.js +56 -15
- package/src/framework/geometry/mesh-fillet.js +55 -5
- package/src/framework/materials/assets/SOURCES.md +1 -1
- package/src/framework/materials/assets/ground-pei.jpg +0 -0
- package/src/framework/materials/environment.js +16 -4
- package/src/framework/materials/patterns.js +106 -7
- package/src/framework/materials/physical.js +9 -2
- package/src/framework/materials/presets.js +6 -3
- package/src/framework/materials/print-bed.js +17 -0
- package/src/framework/materials/uv.js +27 -8
- package/src/framework/mount.js +83 -36
- package/src/framework/projection.js +48 -1
- package/src/framework/view-state.js +4 -10
- package/src/framework/view-style-controls.js +287 -0
- package/src/framework/view-style-state.js +27 -0
- package/src/framework/viewcube/viewcube-controls.js +23 -64
- package/src/framework/viewcube/viewcube-mode.js +5 -3
- package/src/framework/viewer.js +526 -47
- package/types/index.d.ts +14 -8
- package/src/framework/realistic-controls.js +0 -78
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 `
|
|
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` |
|
|
Binary file
|
|
@@ -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,
|
|
98
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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 *=
|
|
109
|
-
float r =
|
|
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
|
-
|
|
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,
|
|
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
|
|
2
|
-
// metal)
|
|
3
|
-
// UVs. Projection is in OBJECT space along each vertex normal's
|
|
4
|
-
// it rides with the sub-part and never swims.
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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
|
|
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
|
}
|