partforge 0.123.0 → 0.125.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.
@@ -110,7 +110,12 @@ export function createContactShadow({ renderer, sizeMm = 400 }) {
110
110
  // While a part moves (lowRes) only the soft layer is redrawn and the tight
111
111
  // one is hidden: an outline left where the part WAS would be wrong, and the
112
112
  // settle render that follows brings it back.
113
- function render(scene, casters, { lowRes = false } = {}) {
113
+ //
114
+ // `clippingPlanes` (world space) clip the casters the way the cutaway clips
115
+ // what is on screen. The depth pass draws every caster with the one override
116
+ // material, which carries none of the casters' own clipping planes, so
117
+ // without this the half the cutaway removed still cast its shadow.
118
+ function render(scene, casters, { lowRes = false, clippingPlanes = null } = {}) {
114
119
  const bg = scene.background;
115
120
  const overrideBefore = scene.overrideMaterial;
116
121
  const clear = renderer.getClearAlpha();
@@ -123,6 +128,7 @@ export function createContactShadow({ renderer, sizeMm = 400 }) {
123
128
  renderer.setClearAlpha(0);
124
129
  for (const l of layers) {
125
130
  if (lowRes && l.spec.name === "tight") continue;
131
+ setClipping(l.depthMaterial, clippingPlanes);
126
132
  scene.overrideMaterial = l.depthMaterial;
127
133
  renderer.setRenderTarget(l.rt);
128
134
  renderer.render(scene, cam);
@@ -143,6 +149,27 @@ export function createContactShadow({ renderer, sizeMm = 400 }) {
143
149
  }
144
150
  }
145
151
 
152
+ // A new plane count needs a new program; the same planes moved do not (three
153
+ // reads their values every frame).
154
+ //
155
+ // A clipped pass also draws BOTH sides, depth-tested. The pass looks UP at
156
+ // the casters, and a cut that removes a part's underside (a kept side facing
157
+ // up, e.g. a top-view cut after Flip) leaves no downward-facing faces at the
158
+ // cut — from below only back faces remain, which FrontSide culls, and the
159
+ // shadow shrinks to a faint ring of side walls. The interior back faces fill
160
+ // the silhouette instead, and the depth test keeps the LOWEST surface under
161
+ // each texel (the one the ground's darkness is measured from) rather than
162
+ // whichever face drew last. Unclipped, the pass is exactly as before:
163
+ // front faces, no depth test.
164
+ function setClipping(material, planes) {
165
+ const next = planes?.length ? planes : null;
166
+ const side = next ? THREE.DoubleSide : THREE.FrontSide;
167
+ if ((material.clippingPlanes?.length ?? 0) !== (next?.length ?? 0) || material.side !== side) material.needsUpdate = true;
168
+ material.clippingPlanes = next;
169
+ material.side = side;
170
+ material.depthTest = material.depthWrite = !!next;
171
+ }
172
+
146
173
  return {
147
174
  group,
148
175
  render,
@@ -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
@@ -72,6 +72,7 @@ function buildPhysical(display, { printFrame, loadTexture } = {}) {
72
72
  roughnessMap: load(set.roughness, THREE.NoColorSpace),
73
73
  roughnessMean: set.roughnessMean,
74
74
  normalScale: set.normalScale,
75
+ grain: set.grain,
75
76
  });
76
77
  return m;
77
78
  }
@@ -8,15 +8,12 @@
8
8
  // converted to sRGB. Adding a preset is one record here plus a look at the
9
9
  // contact sheet (materials.html) in every environment.
10
10
 
11
- export const DEFAULT_PRESET_ID = "default";
12
-
13
11
  const P = (id, label, category, use, fields) => ({ id, label, category, use, tintable: false, ...fields });
14
12
 
15
13
  export const PRESETS = {
16
- // The viewer's look before this library existed. Not documented as a choice:
17
- // it is what a sub-part with no `material` renders as.
18
- default: P("default", "Default", "default", "The viewer's neutral blue-grey, used when a sub-part names no material.",
19
- { color: 0x9fb4cc, metalness: 0.25, roughness: 0.55 }),
14
+ // Every record here is a documented, nameable material. A sub-part that names
15
+ // none is drawn by resolve.js (the blue-grey CAD look; a PLA print in that
16
+ // colour in realistic mode) — there is no hidden "default" record any more.
20
17
 
21
18
  "machined-aluminum": P("machined-aluminum", "Machined aluminium", "aluminium", "Bare CNC-milled aluminium, fine tool marks, satin sheen.",
22
19
  { color: 0xf5f6f6, metalness: 1, roughness: 0.32 }),
@@ -77,13 +74,16 @@ export const PRESETS = {
77
74
  // `roughnessMean` is the roughness map's mean, so the preset `roughness` (or an
78
75
  // override) sets the AVERAGE roughness and the map only varies it. `normalScale`
79
76
  // strengthens the normal map: these scans' tangent normals barely leave +Z (a
80
- // ~1% tilt), which reads as flat under an environment map.
77
+ // ~1% tilt), which reads as flat under an environment map. `grain` is the
78
+ // texture axis the scan's figure runs along — oak's runs up the image ("v"),
79
+ // walnut's across it ("u") — so patterns.js can lay it along each sub-part's
80
+ // long axis.
81
81
  oak: P("oak", "Oak", "natural", "Light oak with open grain.",
82
82
  { color: 0xa17e57, metalness: 0, roughness: 0.65, pattern: "wood", textureScale: 250,
83
- textures: { color: "pattern-oak-color.jpg", normal: "pattern-oak-normal.jpg", roughness: "pattern-oak-rough.jpg", roughnessMean: 0.53, normalScale: 2.5 } }),
83
+ 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
84
  walnut: P("walnut", "Walnut", "natural", "Dark oiled walnut.",
85
85
  { color: 0x5d2a18, metalness: 0, roughness: 0.5, clearcoat: 0.3, clearcoatRoughness: 0.4, pattern: "wood", textureScale: 400,
86
- textures: { color: "pattern-walnut-color.jpg", normal: "pattern-walnut-normal.jpg", roughness: "pattern-walnut-rough.jpg", roughnessMean: 0.628, normalScale: 3 } }),
86
+ textures: { color: "pattern-walnut-color.jpg", normal: "pattern-walnut-normal.jpg", roughness: "pattern-walnut-rough.jpg", roughnessMean: 0.628, normalScale: 3, grain: "u" } }),
87
87
  "carbon-fiber": P("carbon-fiber", "Carbon fibre", "natural", "2x2 twill carbon fibre under clear coat.",
88
88
  { color: 0x1b1c1e, metalness: 0.2, roughness: 0.35, clearcoat: 1, clearcoatRoughness: 0.05, pattern: "carbon", textureScale: 48 }),
89
89
  };
@@ -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,10 +1,11 @@
1
1
  // src/framework/materials/resolve.js
2
2
  // Pure resolution of a sub-part's `display` block against the library. Never
3
- // throws and never rejects a value: an unknown preset falls back to the default,
3
+ // throws and never rejects a value: an unknown preset falls back to the
4
+ // no-material look (below),
4
5
  // an out-of-range override clamps, an unknown key is ignored — and every one of
5
6
  // those is returned as an `issue` so lint can warn about it. Appearance must
6
7
  // never be able to fail a build.
7
- import { PRESETS, DEFAULT_PRESET_ID } from "./presets.js";
8
+ import { PRESETS } from "./presets.js";
8
9
  import { ENVIRONMENTS, DEFAULT_ENVIRONMENT_ID } from "./environments.js";
9
10
 
10
11
  export const OVERRIDE_RANGES = {
@@ -20,16 +21,34 @@ export const DISPLAY_KEYS = ["color", "opacity", "material", ...Object.keys(OVER
20
21
 
21
22
  const isColor = (v) => Number.isInteger(v) && v >= 0 && v <= 0xffffff;
22
23
 
24
+ // A sub-part with no usable material — none named, or one the library does not
25
+ // know. The CAD view draws it in the viewer's neutral blue-grey drafting look
26
+ // (the look every part had before the library existed; viewer.js's base
27
+ // material and threemf.js's uncoloured-object colour are the same literal).
28
+ // Realistic mode draws it as a PLA print — the pla-print finish, layer lines
29
+ // and all — in that same colour, or the part's own `color`, so a part is the
30
+ // same colour in both views, just printed. `declaresMaterials` still counts
31
+ // only a NAMED material: this default never makes a part "declare" one.
32
+ export const NO_MATERIAL_COLOR = 0x9fb4cc;
33
+ export const NO_MATERIAL_PRESET_ID = "pla-print";
34
+ const NO_MATERIAL_CAD = { color: NO_MATERIAL_COLOR, metalness: 0.25, roughness: 0.55 };
35
+
36
+ const knownMaterial = (m) => typeof m === "string" && Object.hasOwn(PRESETS, m);
37
+
23
38
  export function resolveMaterial(display) {
24
39
  const d = display && typeof display === "object" ? display : {};
25
40
  const issues = [];
26
- let preset = PRESETS[DEFAULT_PRESET_ID];
27
- if (d.material != null) {
28
- if (typeof d.material === "string" && Object.hasOwn(PRESETS, d.material)) preset = PRESETS[d.material];
29
- else issues.push({ kind: "unknown-material", key: "material", value: d.material });
30
- }
41
+ if (d.material != null && !knownMaterial(d.material)) issues.push({ kind: "unknown-material", key: "material", value: d.material });
42
+ const preset = knownMaterial(d.material) ? PRESETS[d.material] : PRESETS[NO_MATERIAL_PRESET_ID];
43
+ const baseColor = knownMaterial(d.material) ? preset.color : NO_MATERIAL_COLOR;
44
+ return { preset, ...applyDisplay(d, preset, baseColor, issues) };
45
+ }
46
+
47
+ // A preset record's numbers with the display block's colour, opacity and clamped
48
+ // overrides applied. Pushes onto `issues`; returns them with the params.
49
+ function applyDisplay(d, preset, baseColor, issues) {
31
50
  const params = {
32
- color: preset.color,
51
+ color: baseColor,
33
52
  metalness: preset.metalness,
34
53
  roughness: preset.roughness,
35
54
  clearcoat: preset.clearcoat ?? 0,
@@ -63,7 +82,7 @@ export function resolveMaterial(display) {
63
82
  }
64
83
  // `tinted`: the author named a colour. A textured preset's colour map carries
65
84
  // its own colour, so only an explicit tint multiplies it (physical.js).
66
- return { preset, params, issues, tinted: isColor(d.color) };
85
+ return { params, issues, tinted: isColor(d.color) };
67
86
  }
68
87
 
69
88
  // CAD-view appearance: the resolved colour on the CAD MeshStandardMaterial,
@@ -73,11 +92,15 @@ export function resolveMaterial(display) {
73
92
  const MIN_CAD_LUMA = 60; // 0..255, Rec. 709
74
93
  export function cadAppearance(display) {
75
94
  const d = display && typeof display === "object" ? display : {};
76
- const { params } = resolveMaterial(d);
77
95
  if (d.material == null) {
78
- // Byte-for-byte today's behaviour for a part without `material`.
79
- return { color: params.color, metalness: 0.25, roughness: 0.55, opacity: params.opacity };
96
+ // The drafting look, untouched by overrides — as it always was.
97
+ const { params } = resolveMaterial(d);
98
+ return { color: params.color, metalness: NO_MATERIAL_CAD.metalness, roughness: NO_MATERIAL_CAD.roughness, opacity: params.opacity };
80
99
  }
100
+ // An unknown material flattens the drafting look rather than the realistic
101
+ // PLA default, exactly as it did when it fell back to the retired "default"
102
+ // preset.
103
+ const { params } = knownMaterial(d.material) ? resolveMaterial(d) : applyDisplay(d, NO_MATERIAL_CAD, NO_MATERIAL_COLOR, []);
81
104
  return {
82
105
  color: liftLuma(params.color, MIN_CAD_LUMA),
83
106
  metalness: Math.min(params.metalness, 0.5),
@@ -97,8 +120,9 @@ function liftLuma(color, min) {
97
120
  }
98
121
 
99
122
  // The colour a 3MF object carries: the explicit tint, else the preset's own
100
- // colour. Null when the sub-part says nothing about appearance, which is what
101
- // keeps an appearance-free export byte-for-byte unchanged.
123
+ // colour (the blue-grey for an unknown material, never the realistic PLA
124
+ // default's). Null when the sub-part says nothing about appearance, which is
125
+ // what keeps an appearance-free export byte-for-byte unchanged.
102
126
  export function printColor(display) {
103
127
  if (!display || typeof display !== "object") return null;
104
128
  if (isColor(display.color)) return display.color;