partforge 0.126.0 → 0.127.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.126.0",
3
+ "version": "0.127.0",
4
4
  "description": "Turn a declarative part definition into a parametric-CAD web app (three.js + Manifold/Replicad). Requires a Vite-based consumer.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -85,23 +85,79 @@ vec3 pfTriplanarNormal(vec3 p, vec3 n, float scale) {
85
85
  }
86
86
  `;
87
87
 
88
+ // Layer lines are 0.2 mm, so at an ordinary viewing distance a layer spans a
89
+ // few pixels or less, and point-sampling the bead profile per pixel aliased
90
+ // into moiré (worst in the specular, through the bead normals). So one layer's
91
+ // profile is baked into a small repeating texture sampled by layer height, and
92
+ // the GPU's mipmaps band-limit it. Channels: R = groove shade, G = bead slope
93
+ // (encoded slope/6 + 0.5), B = slope^2 / 9. A mip level averages G and B
94
+ // separately, so B - G^2 is the slope VARIANCE inside the pixel — the ridges it
95
+ // can no longer show — which widens the specular lobe instead (Toksvig / LEAN
96
+ // mapping), so a far wall keeps its satin sheen rather than turning glossy.
97
+ export const LAYER_PROFILE_WIDTH = 256;
98
+ // Gradient scale on the profile lookup (+0.58 mip). A box-filtered mip chain at
99
+ // 1x still carries the full fundamental at 2 px per layer (its 2-texel level),
100
+ // right at Nyquist, which banded across a whole-part view; 1.5 clears that and
101
+ // keeps the lines crisp close up (2 visibly softened them).
102
+ const LAYER_BLUR = "1.5";
103
+
104
+ // One layer's bead, t running -1 (a seam) through 0 (the crest) to 1 (the next
105
+ // seam): the groove shade darkens the 35% of each half-bead nearest a seam, and
106
+ // the slope is a round bead's (t / sqrt(1 - t^2)), clamped to ±3 where it goes
107
+ // vertical. (The procedural version darkened the CREST instead — its ridge was
108
+ // |t|, not 1 - |t| — half a layer off the valleys its own normals drew.)
109
+ export function layerProfileData(width = LAYER_PROFILE_WIDTH) {
110
+ const data = new Uint8Array(width * 4);
111
+ for (let i = 0; i < width; i++) {
112
+ const t = ((i + 0.5) / width) * 2 - 1;
113
+ const g = Math.min(1, (1 - Math.abs(t)) / 0.35);
114
+ const groove = g * g * (3 - 2 * g);
115
+ const slope = Math.min(3, Math.max(-3, t / Math.sqrt(Math.max(1 - t * t, 0.04))));
116
+ data[i * 4] = Math.round(groove * 255);
117
+ data[i * 4 + 1] = Math.round((slope / 6 + 0.5) * 255);
118
+ data[i * 4 + 2] = Math.round((slope * slope / 9) * 255);
119
+ data[i * 4 + 3] = 255;
120
+ }
121
+ return data;
122
+ }
123
+
124
+ // One texture shared by every layer-lines material, built on first use.
125
+ let layerProfileTex = null;
126
+ function layerProfileTexture() {
127
+ if (layerProfileTex) return layerProfileTex;
128
+ const t = new THREE.DataTexture(layerProfileData(), LAYER_PROFILE_WIDTH, 1, THREE.RGBAFormat, THREE.UnsignedByteType);
129
+ t.wrapS = THREE.RepeatWrapping;
130
+ t.wrapT = THREE.ClampToEdgeWrapping;
131
+ t.magFilter = THREE.LinearFilter;
132
+ t.minFilter = THREE.LinearMipmapLinearFilter;
133
+ t.generateMipmaps = true;
134
+ t.colorSpace = THREE.NoColorSpace;
135
+ t.needsUpdate = true;
136
+ return (layerProfileTex = t);
137
+ }
138
+
139
+ const FRAG_DECL_LAYERS = "uniform sampler2D pfLayerMap;\n";
140
+
88
141
  // Per-kind fragment code, injected after <roughnessmap_fragment> so it can
89
142
  // modulate both diffuseColor (already written by <map_fragment>) and roughnessFactor.
90
143
  const FRAG_BODY = {
144
+ // Declared at main() scope, not in a block: FRAG_NORMAL reads the same
145
+ // profile sample (pfLayer) rather than looking it up again.
91
146
  "layer-lines": `
147
+ float pfH = (pfPrintFrame * vec4(vPfObjPos, 1.0)).z / pfPatternScale; // in layers
148
+ // fract() keeps the texel coordinate small (hundreds of layers x 256 texels
149
+ // loses sub-texel precision in the sampler); the explicit gradients are the
150
+ // unwrapped height's, so the wrap at each seam doesn't drop to the 1x1 mip.
151
+ vec4 pfLayer = textureGrad(pfLayerMap, vec2(fract(pfH), 0.5),
152
+ vec2(dFdx(pfH) * ${LAYER_BLUR}, 0.0), vec2(dFdy(pfH) * ${LAYER_BLUR}, 0.0));
92
153
  {
93
- float h = (pfPrintFrame * vec4(vPfObjPos, 1.0)).z / pfPatternScale;
94
- float ridge = abs(fract(h) - 0.5) * 2.0; // 0 at a layer seam, 1 mid-layer
95
- float groove = smoothstep(0.0, 0.35, ridge);
96
- // Anti-alias: fwidth(h) is layers per pixel. Where a layer spans fewer
97
- // than ~6 px the band fades to its mean over a period (0.825: the groove
98
- // ramps over 35% of it, the rest is flat), and past ~2.5 px it is gone —
99
- // point-sampling finer layers than that is what aliased into moiré.
100
- groove = mix(0.825, groove, 1.0 - smoothstep(0.16, 0.4, fwidth(h)));
154
+ float groove = pfLayer.r;
101
155
  diffuseColor.rgb *= mix(0.9, 1.0, groove); // the normal map (FRAG_NORMAL) carries most of the relief
102
156
  roughnessFactor = clamp(roughnessFactor + (1.0 - groove) * 0.18, 0.0, 1.0);
103
157
  // Filament mottling: a fine grain (~0.17 mm) over slow blotches (~2 mm),
104
158
  // fixed to the part, so a print doesn't read as flat injection-moulded plastic.
159
+ // Hash noise, not a noise volume: a mipmapped 64^3 texture measured ~10%
160
+ // slower per frame on an M1 Max (a filtered 3D fetch reads 16 texels).
105
161
  float mottle = pfNoise(vPfObjPos * 6.0) * 0.6 + pfNoise(vPfObjPos * 0.51) * 0.4;
106
162
  diffuseColor.rgb *= mix(0.893, 1.047, mottle);
107
163
  roughnessFactor = clamp(roughnessFactor + (mottle - 0.5) * 0.084, 0.0, 1.0);
@@ -149,15 +205,14 @@ const FRAG_BODY = {
149
205
  // layer is a round bead, so across one layer the surface normal swings from
150
206
  // facing down (bottom of the bead) through straight out to facing up, along
151
207
  // the print direction projected onto the surface. Faces parallel to the layers
152
- // (tops and bottoms) get no ridges, as on a real print. Same fwidth fade as the
153
- // colour bands, so far-away layers don't alias.
208
+ // (tops and bottoms) get no ridges, as on a real print. The slope comes from the
209
+ // same mip-filtered profile sample as the colour bands, and the slope variance
210
+ // the filter averaged away goes into roughness: alpha^2 += var(tilt).
154
211
  const FRAG_NORMAL = {
155
212
  "layer-lines": `
156
213
  {
157
- float h = (pfPrintFrame * vec4(vPfObjPos, 1.0)).z / pfPatternScale;
158
- float aa = 1.0 - smoothstep(0.16, 0.4, fwidth(h));
159
- float t = fract(h) * 2.0 - 1.0; // -1 at a seam, 0 bead crest, 1 next seam
160
- float slope = clamp(t / sqrt(max(1.0 - t * t, 0.04)), -3.0, 3.0);
214
+ float slope = pfLayer.g * 6.0 - 3.0;
215
+ float slopeVar = max(pfLayer.b * 9.0 - slope * slope, 0.0);
161
216
  vec3 up = normalize(vPfPrintUpView);
162
217
  // NOT normalised: its length is how steeply the surface cuts across the
163
218
  // layers (1 on a vertical wall, 0 on a face parallel to them), so ridges fade
@@ -165,7 +220,10 @@ const FRAG_NORMAL = {
165
220
  // full-strength tilt in a direction set by rounding noise, flipping sign
166
221
  // wherever the face sat on a layer boundary — a visible patch.
167
222
  vec3 along = up - normal * dot(normal, up);
168
- normal = normalize(normal + along * slope * 0.35 * aa);
223
+ normal = normalize(normal + along * slope * 0.35);
224
+ // tilt = slope * 0.35 * |along|, so var(tilt) = slopeVar * 0.35^2 * |along|^2
225
+ float a = roughnessFactor * roughnessFactor;
226
+ roughnessFactor = min(sqrt(sqrt(a * a + slopeVar * 0.1225 * dot(along, along))), 1.0);
169
227
  }`,
170
228
  // Wood replaces the view-space normal with its triplanar normal map, built in
171
229
  // object space and carried to view space through the normal matrix. A
@@ -216,7 +274,11 @@ export function setGrainAxis(material, axis) {
216
274
  export function applyPattern(material, { kind, scale = 1, printFrame, texture, normalMap, roughnessMap, roughnessMean = 0.5, normalScale = 1, grain = "u" } = {}) {
217
275
  if (!kind || !FRAG_BODY[kind]) return material;
218
276
  const wood = kind === "wood";
277
+ const layers = kind === "layer-lines";
219
278
  const uniforms = {
279
+ ...(layers ? {
280
+ pfLayerMap: { value: layerProfileTexture() },
281
+ } : {}),
220
282
  pfPatternScale: { value: scale },
221
283
  pfPrintFrame: { value: new THREE.Matrix4().fromArray(printFrame ?? new THREE.Matrix4().toArray()) },
222
284
  pfPatternMap: { value: texture ?? null },
@@ -236,7 +298,7 @@ export function applyPattern(material, { kind, scale = 1, printFrame, texture, n
236
298
  .replace("#include <common>", `#include <common>\n${VERT_DECL}${wood ? VERT_DECL_NM : ""}`)
237
299
  .replace("#include <begin_vertex>", `#include <begin_vertex>\n${VERT_BODY}${wood ? VERT_BODY_NM : ""}`);
238
300
  shader.fragmentShader = shader.fragmentShader
239
- .replace("#include <common>", `#include <common>\n${FRAG_DECL}${wood ? FRAG_DECL_WOOD : ""}`)
301
+ .replace("#include <common>", `#include <common>\n${FRAG_DECL}${wood ? FRAG_DECL_WOOD : ""}${layers ? FRAG_DECL_LAYERS : ""}`)
240
302
  .replace("#include <roughnessmap_fragment>", `#include <roughnessmap_fragment>\n${FRAG_BODY[kind]}`)
241
303
  .replace("#include <normal_fragment_maps>", `#include <normal_fragment_maps>\n${FRAG_NORMAL[kind] ?? ""}`);
242
304
  };