partforge 0.121.1 → 0.122.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/AUTHORING-PARTS.md +164 -3
- package/package.json +3 -1
- package/src/app-materials.js +23 -0
- package/src/framework/app.css +16 -1
- package/src/framework/cutaway.js +12 -0
- package/src/framework/geometry/threemf.js +32 -3
- package/src/framework/jobs.js +2 -1
- package/src/framework/lint/index.js +2 -1
- package/src/framework/lint/rules-materials.js +56 -0
- package/src/framework/materials/assets/SOURCES.md +90 -0
- package/src/framework/materials/assets/env-outdoor.jpg +0 -0
- package/src/framework/materials/assets/env-print-bed.jpg +0 -0
- package/src/framework/materials/assets/env-studio.jpg +0 -0
- package/src/framework/materials/assets/env-workshop.jpg +0 -0
- package/src/framework/materials/assets/ground-concrete-normal.jpg +0 -0
- package/src/framework/materials/assets/ground-concrete-rough.jpg +0 -0
- package/src/framework/materials/assets/ground-concrete.jpg +0 -0
- package/src/framework/materials/assets/ground-maple-color.jpg +0 -0
- package/src/framework/materials/assets/ground-maple-normal.jpg +0 -0
- package/src/framework/materials/assets/ground-paper-normal.jpg +0 -0
- package/src/framework/materials/assets/ground-paper-rough.jpg +0 -0
- package/src/framework/materials/assets/ground-paper.jpg +0 -0
- package/src/framework/materials/assets/ground-pei-rough.jpg +0 -0
- package/src/framework/materials/assets/ground-pei.jpg +0 -0
- package/src/framework/materials/assets/pattern-carbon.jpg +0 -0
- package/src/framework/materials/assets/pattern-oak-color.jpg +0 -0
- package/src/framework/materials/assets/pattern-oak-normal.jpg +0 -0
- package/src/framework/materials/assets/pattern-oak-rough.jpg +0 -0
- package/src/framework/materials/assets/pattern-walnut-color.jpg +0 -0
- package/src/framework/materials/assets/pattern-walnut-normal.jpg +0 -0
- package/src/framework/materials/assets/pattern-walnut-rough.jpg +0 -0
- package/src/framework/materials/assets.js +39 -0
- package/src/framework/materials/contact-shadow.js +155 -0
- package/src/framework/materials/environment.js +157 -0
- package/src/framework/materials/environments.js +82 -0
- package/src/framework/materials/patterns.js +195 -0
- package/src/framework/materials/physical.js +82 -0
- package/src/framework/materials/presets.js +89 -0
- package/src/framework/materials/print-bed.js +169 -0
- package/src/framework/materials/print-frame.js +18 -0
- package/src/framework/materials/resolve.js +117 -0
- package/src/framework/materials/tonemap-readback.js +36 -0
- package/src/framework/materials/uv.js +23 -0
- package/src/framework/mount.js +121 -4
- package/src/framework/realistic-controls.js +78 -0
- package/src/framework/selection/feature-highlight.js +3 -0
- package/src/framework/view-state.js +27 -1
- package/src/framework/viewer.js +623 -34
- package/src/materials-worker.js +3 -0
- package/src/parts/bracket.js +2 -1
- package/src/parts/hinged-box.js +3 -1
- package/src/parts/material-swatches.js +40 -0
- package/src/parts/planter.js +2 -1
- package/src/parts/propeller.js +2 -1
- package/src/parts/scott-label.js +3 -2
- package/src/parts/screw.js +2 -1
- package/types/index.d.ts +48 -3
- package/types/part.d.ts +15 -2
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// src/framework/materials/assets.js
|
|
2
|
+
// The ONLY module that turns an asset file name into a URL. `new URL("./assets/x",
|
|
3
|
+
// import.meta.url)` with a LITERAL path is what makes a consuming Vite build emit
|
|
4
|
+
// the file and rewrite the URL — a computed path is invisible to the bundler and
|
|
5
|
+
// ships a 404 (the partforge/geometry incident class). So every file is listed
|
|
6
|
+
// literally, once, here.
|
|
7
|
+
const URLS = {
|
|
8
|
+
"env-studio.jpg": new URL("./assets/env-studio.jpg", import.meta.url).href,
|
|
9
|
+
"env-workshop.jpg": new URL("./assets/env-workshop.jpg", import.meta.url).href,
|
|
10
|
+
"env-print-bed.jpg": new URL("./assets/env-print-bed.jpg", import.meta.url).href,
|
|
11
|
+
"env-outdoor.jpg": new URL("./assets/env-outdoor.jpg", import.meta.url).href,
|
|
12
|
+
"ground-paper.jpg": new URL("./assets/ground-paper.jpg", import.meta.url).href,
|
|
13
|
+
"ground-paper-normal.jpg": new URL("./assets/ground-paper-normal.jpg", import.meta.url).href,
|
|
14
|
+
"ground-paper-rough.jpg": new URL("./assets/ground-paper-rough.jpg", import.meta.url).href,
|
|
15
|
+
"ground-maple-color.jpg": new URL("./assets/ground-maple-color.jpg", import.meta.url).href,
|
|
16
|
+
"ground-maple-normal.jpg": new URL("./assets/ground-maple-normal.jpg", import.meta.url).href,
|
|
17
|
+
"ground-pei.jpg": new URL("./assets/ground-pei.jpg", import.meta.url).href,
|
|
18
|
+
"ground-pei-rough.jpg": new URL("./assets/ground-pei-rough.jpg", import.meta.url).href,
|
|
19
|
+
"ground-concrete.jpg": new URL("./assets/ground-concrete.jpg", import.meta.url).href,
|
|
20
|
+
"ground-concrete-rough.jpg": new URL("./assets/ground-concrete-rough.jpg", import.meta.url).href,
|
|
21
|
+
"ground-concrete-normal.jpg": new URL("./assets/ground-concrete-normal.jpg", import.meta.url).href,
|
|
22
|
+
"pattern-oak-color.jpg": new URL("./assets/pattern-oak-color.jpg", import.meta.url).href,
|
|
23
|
+
"pattern-oak-normal.jpg": new URL("./assets/pattern-oak-normal.jpg", import.meta.url).href,
|
|
24
|
+
"pattern-oak-rough.jpg": new URL("./assets/pattern-oak-rough.jpg", import.meta.url).href,
|
|
25
|
+
"pattern-walnut-color.jpg": new URL("./assets/pattern-walnut-color.jpg", import.meta.url).href,
|
|
26
|
+
"pattern-walnut-normal.jpg": new URL("./assets/pattern-walnut-normal.jpg", import.meta.url).href,
|
|
27
|
+
"pattern-walnut-rough.jpg": new URL("./assets/pattern-walnut-rough.jpg", import.meta.url).href,
|
|
28
|
+
"pattern-carbon.jpg": new URL("./assets/pattern-carbon.jpg", import.meta.url).href,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// Single-mask patterns. Wood is a full PBR set named per preset (presets.js
|
|
32
|
+
// `textures`), so it has no entry here.
|
|
33
|
+
export const PATTERN_TEXTURES = { carbon: "pattern-carbon.jpg", concrete: "ground-concrete.jpg" };
|
|
34
|
+
|
|
35
|
+
export function assetUrl(name) {
|
|
36
|
+
const url = URLS[name];
|
|
37
|
+
if (!url) throw new Error(`partforge: unknown material asset "${name}"`);
|
|
38
|
+
return url;
|
|
39
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// src/framework/materials/contact-shadow.js
|
|
2
|
+
// A contact shadow for realistic mode (after three's webgl_shadow_contact
|
|
3
|
+
// example): render the casters' depth from BELOW into a target, blur it, and
|
|
4
|
+
// lay it on the ground. Rendered on demand — on geometry change, and at low
|
|
5
|
+
// resolution while an animation moves a sub-part — never every frame.
|
|
6
|
+
//
|
|
7
|
+
// Two layers, like a real shadow: a TIGHT one (high resolution, a millimetre
|
|
8
|
+
// or so of blur, a steep height falloff) that follows the part's own outline
|
|
9
|
+
// where it meets the ground, over a faint SOFT one (low resolution, a blur
|
|
10
|
+
// that scales with the part) that stands in for the ambient darkening around
|
|
11
|
+
// it. The tight layer alone reads as a cut-out; the soft one alone as a blob.
|
|
12
|
+
import * as THREE from "three";
|
|
13
|
+
import { HorizontalBlurShader } from "three/addons/shaders/HorizontalBlurShader.js";
|
|
14
|
+
import { VerticalBlurShader } from "three/addons/shaders/VerticalBlurShader.js";
|
|
15
|
+
|
|
16
|
+
const LAYERS = [
|
|
17
|
+
// blurMm: a fixed reach in millimetres; blurFrac: a reach as a fraction of
|
|
18
|
+
// the shadow plane (so it grows with the part). falloff: the power the
|
|
19
|
+
// height fade is raised to — higher is denser at contact, thinner above.
|
|
20
|
+
{ name: "soft", resolution: 256, darkness: 0.22, blurFrac: 0.035, falloff: 1.5 },
|
|
21
|
+
{ name: "tight", resolution: 1024, darkness: 0.42, blurMm: 1.2, falloff: 2.5 },
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
function depthMaterialFor(darkness, falloff) {
|
|
25
|
+
const m = new THREE.MeshDepthMaterial();
|
|
26
|
+
m.onBeforeCompile = (shader) => {
|
|
27
|
+
shader.fragmentShader = shader.fragmentShader.replace(
|
|
28
|
+
"gl_FragColor = vec4( vec3( 1.0 - fragCoordZ ), opacity );",
|
|
29
|
+
`gl_FragColor = vec4( vec3( 0.0 ), pow( 1.0 - fragCoordZ, ${falloff.toFixed(2)} ) * ${darkness.toFixed(3)} );`,
|
|
30
|
+
);
|
|
31
|
+
};
|
|
32
|
+
m.customProgramCacheKey = () => `pf-contact-${darkness}-${falloff}`;
|
|
33
|
+
m.depthTest = false;
|
|
34
|
+
m.depthWrite = false;
|
|
35
|
+
return m;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function createContactShadow({ renderer, sizeMm = 400 }) {
|
|
39
|
+
const group = new THREE.Group();
|
|
40
|
+
const planeGeo = new THREE.PlaneGeometry(1, 1).rotateX(Math.PI / 2);
|
|
41
|
+
|
|
42
|
+
const layers = LAYERS.map((spec, i) => {
|
|
43
|
+
const rt = new THREE.WebGLRenderTarget(spec.resolution, spec.resolution);
|
|
44
|
+
rt.texture.generateMipmaps = false;
|
|
45
|
+
const rtBlur = new THREE.WebGLRenderTarget(spec.resolution, spec.resolution);
|
|
46
|
+
rtBlur.texture.generateMipmaps = false;
|
|
47
|
+
// polygonOffset: the ground sits a hair below this plane and writes
|
|
48
|
+
// depth; at a normal viewing distance that hair is inside the depth
|
|
49
|
+
// buffer's precision, so without the offset the shadow z-fights it.
|
|
50
|
+
const plane = new THREE.Mesh(planeGeo, new THREE.MeshBasicMaterial({
|
|
51
|
+
map: rt.texture, opacity: 1, transparent: true, depthWrite: false,
|
|
52
|
+
polygonOffset: true, polygonOffsetFactor: -1, polygonOffsetUnits: -4 - i,
|
|
53
|
+
}));
|
|
54
|
+
plane.renderOrder = 1 + i; // tight over soft
|
|
55
|
+
group.add(plane);
|
|
56
|
+
return { spec, rt, rtBlur, plane, depthMaterial: depthMaterialFor(spec.darkness, spec.falloff) };
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const blurPlane = new THREE.Mesh(planeGeo);
|
|
60
|
+
blurPlane.visible = false;
|
|
61
|
+
group.add(blurPlane);
|
|
62
|
+
|
|
63
|
+
const cam = new THREE.OrthographicCamera(-0.5, 0.5, 0.5, -0.5, 0, 1);
|
|
64
|
+
cam.rotation.x = Math.PI / 2;
|
|
65
|
+
group.add(cam);
|
|
66
|
+
|
|
67
|
+
const hBlur = new THREE.ShaderMaterial(HorizontalBlurShader); hBlur.depthTest = false;
|
|
68
|
+
const vBlur = new THREE.ShaderMaterial(VerticalBlurShader); vBlur.depthTest = false;
|
|
69
|
+
|
|
70
|
+
// The meshes are scaled; the camera is SIZED. three leaves scale out of a
|
|
71
|
+
// camera's view matrix, so a camera sized by a scaled parent would see a
|
|
72
|
+
// 1 mm square (it did: the shadow was empty in every environment).
|
|
73
|
+
let planeSize = sizeMm;
|
|
74
|
+
function setSize(size, height = size) {
|
|
75
|
+
planeSize = size;
|
|
76
|
+
for (const l of layers) l.plane.scale.set(size, -1, size); // y = -1: rendered from below
|
|
77
|
+
blurPlane.scale.set(size, 1, size);
|
|
78
|
+
cam.left = -size / 2; cam.right = size / 2;
|
|
79
|
+
cam.top = size / 2; cam.bottom = -size / 2;
|
|
80
|
+
cam.far = height;
|
|
81
|
+
cam.updateProjectionMatrix();
|
|
82
|
+
}
|
|
83
|
+
setSize(sizeMm);
|
|
84
|
+
|
|
85
|
+
// The blur plane sits in the shadow group, i.e. in the user's scene, so it
|
|
86
|
+
// is hidden again even if a blur render throws. three's blur shaders reach
|
|
87
|
+
// four taps either side, `h` apart in UV: a reach of `mm` millimetres on a
|
|
88
|
+
// plane `planeSize` across is h = mm / planeSize / 4.
|
|
89
|
+
function blurPass(layer, mm) {
|
|
90
|
+
const step = mm / planeSize / 4;
|
|
91
|
+
blurPlane.visible = true;
|
|
92
|
+
try {
|
|
93
|
+
blurPlane.material = hBlur; hBlur.uniforms.tDiffuse.value = layer.rt.texture; hBlur.uniforms.h.value = step;
|
|
94
|
+
renderer.setRenderTarget(layer.rtBlur); renderer.render(blurPlane, cam);
|
|
95
|
+
blurPlane.material = vBlur; vBlur.uniforms.tDiffuse.value = layer.rtBlur.texture; vBlur.uniforms.v.value = step;
|
|
96
|
+
renderer.setRenderTarget(layer.rt); renderer.render(blurPlane, cam);
|
|
97
|
+
} finally {
|
|
98
|
+
blurPlane.visible = false;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// `casters` are the visible sub-part meshes; everything else in `scene` is
|
|
103
|
+
// hidden for the depth pass so only the part casts. Every mutation here —
|
|
104
|
+
// to the scene and to the renderer's own state — is captured up front and
|
|
105
|
+
// restored in `finally`, so a throw mid-render (context loss, a shader
|
|
106
|
+
// compile failure) never leaves the user's scene with hidden objects, the
|
|
107
|
+
// depth override material pinned, no background, or the wrong render
|
|
108
|
+
// target bound.
|
|
109
|
+
//
|
|
110
|
+
// While a part moves (lowRes) only the soft layer is redrawn and the tight
|
|
111
|
+
// one is hidden: an outline left where the part WAS would be wrong, and the
|
|
112
|
+
// settle render that follows brings it back.
|
|
113
|
+
function render(scene, casters, { lowRes = false } = {}) {
|
|
114
|
+
const bg = scene.background;
|
|
115
|
+
const overrideBefore = scene.overrideMaterial;
|
|
116
|
+
const clear = renderer.getClearAlpha();
|
|
117
|
+
const prevRT = renderer.getRenderTarget();
|
|
118
|
+
const hidden = [];
|
|
119
|
+
scene.traverseVisible((o) => { if (o.isMesh || o.isLine || o.isLineSegments || o.isSprite) if (!casters.includes(o)) { o.visible = false; hidden.push(o); } });
|
|
120
|
+
for (const l of layers) l.plane.visible = false;
|
|
121
|
+
try {
|
|
122
|
+
scene.background = null;
|
|
123
|
+
renderer.setClearAlpha(0);
|
|
124
|
+
for (const l of layers) {
|
|
125
|
+
if (lowRes && l.spec.name === "tight") continue;
|
|
126
|
+
scene.overrideMaterial = l.depthMaterial;
|
|
127
|
+
renderer.setRenderTarget(l.rt);
|
|
128
|
+
renderer.render(scene, cam);
|
|
129
|
+
scene.overrideMaterial = overrideBefore;
|
|
130
|
+
const reach = l.spec.blurMm ?? l.spec.blurFrac * planeSize;
|
|
131
|
+
blurPass(l, reach);
|
|
132
|
+
if (!lowRes) blurPass(l, reach * 0.5);
|
|
133
|
+
}
|
|
134
|
+
} finally {
|
|
135
|
+
scene.overrideMaterial = overrideBefore;
|
|
136
|
+
renderer.setRenderTarget(prevRT);
|
|
137
|
+
renderer.setClearAlpha(clear);
|
|
138
|
+
for (const o of hidden) o.visible = true;
|
|
139
|
+
// After the restore above: the planes are in the scene too, so they were
|
|
140
|
+
// among `hidden`, and restoring them first would undo this.
|
|
141
|
+
for (const l of layers) l.plane.visible = !(lowRes && l.spec.name === "tight");
|
|
142
|
+
scene.background = bg;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
group,
|
|
148
|
+
render,
|
|
149
|
+
setSize,
|
|
150
|
+
dispose() {
|
|
151
|
+
for (const l of layers) { l.rt.dispose(); l.rtBlur.dispose(); l.plane.material.dispose(); l.depthMaterial.dispose(); }
|
|
152
|
+
planeGeo.dispose(); hBlur.dispose(); vBlur.dispose();
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// src/framework/materials/environment.js
|
|
2
|
+
// One environment "rig": PMREM-filtered lighting from a baked UltraHDR JPEG, a
|
|
3
|
+
// backdrop, a textured ground disc that fades into the backdrop at its rim, and a
|
|
4
|
+
// contact shadow. Loaded lazily the first time an environment is shown; the
|
|
5
|
+
// loaders are injected so this is testable without GL or network.
|
|
6
|
+
import * as THREE from "three";
|
|
7
|
+
import { ENVIRONMENTS } from "./environments.js";
|
|
8
|
+
import { resolveEnvironmentId } from "./resolve.js";
|
|
9
|
+
import { assetUrl } from "./assets.js";
|
|
10
|
+
import { createContactShadow } from "./contact-shadow.js";
|
|
11
|
+
import { createPrintBed } from "./print-bed.js";
|
|
12
|
+
|
|
13
|
+
const GROUND_RADIUS_FACTOR = 4; // ground disc radius, in part radii
|
|
14
|
+
|
|
15
|
+
// A ground normal map is OpenGL-format (+Y up, Poly Haven's "nor_gl"), which is
|
|
16
|
+
// what three's tangent-space normal maps expect: a positive normalScale.
|
|
17
|
+
const GROUND_NORMAL_SCALE = 1.5;
|
|
18
|
+
|
|
19
|
+
// ground.detail: {scale, strength} re-samples the colour map `scale` times
|
|
20
|
+
// finer and multiplies it in at `strength`, so a ground seen up close under a
|
|
21
|
+
// small part keeps crisp grain after the base map's texels have gone soft.
|
|
22
|
+
// `mean` is the map's average linear luminance, so the detail layer darkens
|
|
23
|
+
// and lightens about 1 rather than shifting the ground's overall tone.
|
|
24
|
+
function injectDetail(shader, { scale, strength, mean }) {
|
|
25
|
+
shader.fragmentShader = shader.fragmentShader.replace(
|
|
26
|
+
"#include <map_fragment>",
|
|
27
|
+
`#include <map_fragment>
|
|
28
|
+
#ifdef USE_MAP
|
|
29
|
+
{
|
|
30
|
+
float pfDl = dot(texture2D(map, vMapUv * ${scale.toFixed(3)}).rgb, vec3(0.3333));
|
|
31
|
+
diffuseColor.rgb *= mix(1.0, pfDl / ${mean.toFixed(3)}, ${strength.toFixed(3)});
|
|
32
|
+
}
|
|
33
|
+
#endif`,
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function groundMaterial(tex, rough, normal, { tint, roughness = 1, normalScale = GROUND_NORMAL_SCALE, bed = false, detail }) {
|
|
38
|
+
const m = new THREE.MeshStandardMaterial({
|
|
39
|
+
color: tint, map: tex, roughnessMap: rough ?? null, roughness, metalness: 0, transparent: !bed,
|
|
40
|
+
...(normal ? { normalMap: normal, normalScale: new THREE.Vector2(normalScale, normalScale) } : {}),
|
|
41
|
+
});
|
|
42
|
+
if (bed) {
|
|
43
|
+
// a build plate has a hard edge: no rim fade
|
|
44
|
+
if (detail) {
|
|
45
|
+
m.onBeforeCompile = (shader) => injectDetail(shader, detail);
|
|
46
|
+
m.customProgramCacheKey = () => "pf-ground-bed-detail";
|
|
47
|
+
}
|
|
48
|
+
return m;
|
|
49
|
+
}
|
|
50
|
+
// Radial fade to transparent at the rim so the disc melts into the backdrop.
|
|
51
|
+
m.onBeforeCompile = (shader) => {
|
|
52
|
+
if (detail) injectDetail(shader, detail);
|
|
53
|
+
shader.vertexShader = shader.vertexShader
|
|
54
|
+
.replace("#include <common>", "#include <common>\nvarying vec2 vPfDisc;")
|
|
55
|
+
.replace("#include <begin_vertex>", "#include <begin_vertex>\nvPfDisc = position.xz;");
|
|
56
|
+
shader.fragmentShader = shader.fragmentShader
|
|
57
|
+
.replace("#include <common>", "#include <common>\nvarying vec2 vPfDisc;")
|
|
58
|
+
.replace("#include <dithering_fragment>", "#include <dithering_fragment>\ngl_FragColor.a *= 1.0 - smoothstep(0.35, 0.5, length(vPfDisc));");
|
|
59
|
+
};
|
|
60
|
+
m.customProgramCacheKey = () => (detail ? "pf-ground-detail" : "pf-ground");
|
|
61
|
+
return m;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function loadEnvironmentRig(renderer, requestedId, { loadHdr, loadTexture, pmrem, createCanvas }) {
|
|
65
|
+
const { id } = resolveEnvironmentId(requestedId);
|
|
66
|
+
const env = ENVIRONMENTS[id];
|
|
67
|
+
const hdr = await loadHdr(assetUrl(env.hdr));
|
|
68
|
+
hdr.mapping = THREE.EquirectangularReflectionMapping;
|
|
69
|
+
let envMap;
|
|
70
|
+
try { envMap = pmrem.fromEquirectangular(hdr).texture; } catch (e) { hdr.dispose(); throw e; }
|
|
71
|
+
|
|
72
|
+
// Every environment draws its own photo, blurred, as the backdrop — so the
|
|
73
|
+
// equirect stays alive for the rig's life (freed in dispose()).
|
|
74
|
+
const background = hdr;
|
|
75
|
+
const backgroundBlurriness = env.blurriness ?? 0.6;
|
|
76
|
+
const backgroundIntensity = env.backgroundIntensity ?? 1;
|
|
77
|
+
|
|
78
|
+
const tex = loadTexture(env.ground.texture);
|
|
79
|
+
tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
|
|
80
|
+
tex.colorSpace = THREE.SRGBColorSpace;
|
|
81
|
+
// Roughness and normal maps are data, not colour: never sRGB-decoded.
|
|
82
|
+
const dataMap = (file) => {
|
|
83
|
+
if (!file) return null;
|
|
84
|
+
const t = loadTexture(file);
|
|
85
|
+
t.wrapS = t.wrapT = THREE.RepeatWrapping;
|
|
86
|
+
t.colorSpace = THREE.NoColorSpace;
|
|
87
|
+
return t;
|
|
88
|
+
};
|
|
89
|
+
const rough = dataMap(env.ground.roughnessTexture);
|
|
90
|
+
const normal = dataMap(env.ground.normalTexture);
|
|
91
|
+
const shadow = createContactShadow({ renderer, sizeMm: env.ground.sizeMm });
|
|
92
|
+
|
|
93
|
+
// env.ground.bed: a standard-size build plate instead of the fading disc.
|
|
94
|
+
if (env.ground.bed) {
|
|
95
|
+
const material = groundMaterial(tex, rough, normal, { ...env.ground, bed: true });
|
|
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.
|
|
99
|
+
let keyLight = null;
|
|
100
|
+
if (env.keyLight) {
|
|
101
|
+
keyLight = new THREE.DirectionalLight(0xffffff, env.keyLight.intensity);
|
|
102
|
+
keyLight.position.set(...env.keyLight.direction);
|
|
103
|
+
bed.object.add(keyLight, keyLight.target);
|
|
104
|
+
}
|
|
105
|
+
return {
|
|
106
|
+
id, exposure: env.exposure, envMap, background, backgroundBlurriness, backgroundIntensity,
|
|
107
|
+
rotationY: ((env.rotationDeg ?? 0) * Math.PI) / 180,
|
|
108
|
+
ground: bed.object,
|
|
109
|
+
shadow,
|
|
110
|
+
setGround({ y, centerX = 0, centerZ = 0, radius, footprintMm = radius }) {
|
|
111
|
+
bed.place({ y, centerX, centerZ, footprintMm });
|
|
112
|
+
shadow.group.position.set(centerX, y, centerZ);
|
|
113
|
+
// The shadow plane never hangs past the plate's edge into thin air.
|
|
114
|
+
const s = Math.min(Math.max(radius * 3, 20), bed.sizeMm);
|
|
115
|
+
shadow.setSize(s, Math.max(radius * 4, 20));
|
|
116
|
+
},
|
|
117
|
+
dispose() {
|
|
118
|
+
envMap.dispose(); hdr.dispose(); tex.dispose(); rough?.dispose(); normal?.dispose();
|
|
119
|
+
material.dispose(); bed.dispose(); shadow.dispose(); keyLight?.dispose();
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const discGeo = new THREE.CircleGeometry(0.5, 96).rotateX(-Math.PI / 2);
|
|
125
|
+
const ground = new THREE.Mesh(discGeo, groundMaterial(tex, rough, normal, env.ground));
|
|
126
|
+
ground.renderOrder = -1;
|
|
127
|
+
|
|
128
|
+
function setGround({ y, centerX = 0, centerZ = 0, radius }) {
|
|
129
|
+
const r = Math.max(radius * GROUND_RADIUS_FACTOR, env.ground.sizeMm / 2);
|
|
130
|
+
ground.position.set(centerX, y - 0.01, centerZ);
|
|
131
|
+
ground.scale.setScalar(r * 2);
|
|
132
|
+
// Keep the texture at real millimetres however large the disc is.
|
|
133
|
+
const repeat = (r * 2) / (env.ground.tileMm ?? env.ground.sizeMm);
|
|
134
|
+
tex.repeat.set(repeat, repeat);
|
|
135
|
+
if (rough) rough.repeat.set(repeat, repeat);
|
|
136
|
+
if (normal) normal.repeat.set(repeat, repeat);
|
|
137
|
+
shadow.group.position.set(centerX, y, centerZ);
|
|
138
|
+
shadow.setSize(Math.max(radius * 3, 20), Math.max(radius * 4, 20));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
id,
|
|
143
|
+
exposure: env.exposure,
|
|
144
|
+
envMap,
|
|
145
|
+
background,
|
|
146
|
+
backgroundBlurriness,
|
|
147
|
+
backgroundIntensity,
|
|
148
|
+
rotationY: ((env.rotationDeg ?? 0) * Math.PI) / 180,
|
|
149
|
+
ground,
|
|
150
|
+
shadow,
|
|
151
|
+
setGround,
|
|
152
|
+
dispose() {
|
|
153
|
+
envMap.dispose(); hdr.dispose(); tex.dispose(); rough?.dispose(); normal?.dispose();
|
|
154
|
+
discGeo.dispose(); ground.material.dispose(); shadow.dispose();
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// src/framework/materials/environments.js
|
|
2
|
+
// Environment records — plain data like presets.js. `hdr` and the ground textures
|
|
3
|
+
// are FILE NAMES under ./assets/, resolved to URLs by assets.js (which is the only
|
|
4
|
+
// module that may use `new URL(..., import.meta.url)`, so this one stays importable
|
|
5
|
+
// by lint and the worker).
|
|
6
|
+
//
|
|
7
|
+
// ground.sizeMm is the smallest the ground disc ever gets (and the shadow's
|
|
8
|
+
// starting size); ground.tileMm, when set, is how many millimetres one repeat of
|
|
9
|
+
// the ground texture covers — defaulting to sizeMm. rotationDeg, when set, turns
|
|
10
|
+
// the environment (lighting and backdrop together) about the vertical axis.
|
|
11
|
+
// ground.roughnessTexture and ground.normalTexture are optional data maps
|
|
12
|
+
// (normal maps OpenGL-format, +Y up) tiled the same way as the colour map;
|
|
13
|
+
// ground.roughness (default 1) scales the roughness map. backgroundIntensity
|
|
14
|
+
// (default 1) dims the drawn backdrop without dimming the light on the part.
|
|
15
|
+
// ground.detail ({scale, strength, mean}) blends the colour map back in at a
|
|
16
|
+
// finer scale so the ground keeps grain up close (environment.js).
|
|
17
|
+
|
|
18
|
+
export const DEFAULT_ENVIRONMENT_ID = "studio";
|
|
19
|
+
|
|
20
|
+
export const ENVIRONMENTS = {
|
|
21
|
+
studio: {
|
|
22
|
+
id: "studio", label: "Studio", exposure: 0.8,
|
|
23
|
+
hdr: "env-studio.jpg",
|
|
24
|
+
// Paper 001's fibre relief (normal + roughness) does the work: its colour
|
|
25
|
+
// map is nearly flat, so without them the sweep read as soft blotches up
|
|
26
|
+
// close. The detail layer keeps the grain crisp under a small part.
|
|
27
|
+
ground: {
|
|
28
|
+
texture: "ground-paper.jpg", normalTexture: "ground-paper-normal.jpg", roughnessTexture: "ground-paper-rough.jpg",
|
|
29
|
+
normalScale: 0.6, sizeMm: 400, tileMm: 200, tint: 0xf2f2f2,
|
|
30
|
+
detail: { scale: 5.17, strength: 0.5, mean: 0.9 },
|
|
31
|
+
},
|
|
32
|
+
// Turns the studio photo so its two octagonal softboxes glow behind the part
|
|
33
|
+
// from the viewer's default camera, the classic product-shot backdrop.
|
|
34
|
+
rotationDeg: 300,
|
|
35
|
+
blurriness: 0.3,
|
|
36
|
+
},
|
|
37
|
+
workshop: {
|
|
38
|
+
id: "workshop", label: "Workshop", exposure: 0.88,
|
|
39
|
+
hdr: "env-workshop.jpg",
|
|
40
|
+
// Unfinished maple: Poly Haven "Oak Veneer 01" lightened and desaturated
|
|
41
|
+
// at bake time, with its normal map at a gentle strength and NO roughness
|
|
42
|
+
// map — a flat, fully matte roughness, so the floor never shows a glossy
|
|
43
|
+
// spot. Tiled at 600 mm (finer grain than the 1.83 m scan, as maple is).
|
|
44
|
+
ground: {
|
|
45
|
+
texture: "ground-maple-color.jpg", normalTexture: "ground-maple-normal.jpg",
|
|
46
|
+
sizeMm: 600, tileMm: 600, roughness: 0.92, normalScale: 0.8, tint: 0xffffff,
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
"print-bed": {
|
|
50
|
+
id: "print-bed", label: "Print bed", exposure: 0.42,
|
|
51
|
+
// The studio photo re-baked with --contrast: a darker, harsher room, like
|
|
52
|
+
// the inside of a printer's enclosure.
|
|
53
|
+
hdr: "env-print-bed.jpg",
|
|
54
|
+
blurriness: 0.4,
|
|
55
|
+
backgroundIntensity: 0.6, // the enclosure walls sit darker than the light on the part
|
|
56
|
+
// A hard overhead light from above and a little in front, like the LED bar
|
|
57
|
+
// in a printer's enclosure (direction is toward the light, from the bed).
|
|
58
|
+
keyLight: { intensity: 4.5, direction: [0.4, 1, 0.6] },
|
|
59
|
+
// bed: a cut-out, standard-size build plate (print-bed.js) with printed
|
|
60
|
+
// markings, instead of a disc fading into the backdrop. tileMm is one PEI
|
|
61
|
+
// texture repeat.
|
|
62
|
+
ground: {
|
|
63
|
+
texture: "ground-pei.jpg", roughnessTexture: "ground-pei-rough.jpg", sizeMm: 256, tileMm: 128,
|
|
64
|
+
tint: 0xffffff, bed: true,
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
outdoor: {
|
|
68
|
+
id: "outdoor", label: "Outdoor", exposure: 0.72,
|
|
69
|
+
hdr: "env-outdoor.jpg",
|
|
70
|
+
// Tinted to a warm mid-grey: the overcast sky lights an untinted concrete
|
|
71
|
+
// map near-white and blue. Exposed-aggregate concrete with its own normal
|
|
72
|
+
// map, tiled at 400 mm so the grain stays crisp (about 0.4 mm a texel)
|
|
73
|
+
// under a small part seen up close.
|
|
74
|
+
ground: {
|
|
75
|
+
texture: "ground-concrete.jpg", roughnessTexture: "ground-concrete-rough.jpg",
|
|
76
|
+
normalTexture: "ground-concrete-normal.jpg", normalScale: 0.7,
|
|
77
|
+
sizeMm: 800, tileMm: 400, tint: 0x9e9282,
|
|
78
|
+
// Micro-grain for close-ups: the same map ~7x finer, blended lightly.
|
|
79
|
+
detail: { scale: 7.13, strength: 0.245, mean: 0.5 },
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
};
|
|
@@ -0,0 +1,195 @@
|
|
|
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
|
+
}
|