partforge 0.121.1 → 0.123.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/docs/KERNEL-CONTRACT.md +10 -0
- 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/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/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 +243 -0
- package/src/framework/materials/physical.js +88 -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 +42 -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
|
@@ -8,12 +8,30 @@
|
|
|
8
8
|
// near-tangent), and a surface's own sharp edges stay crisp too. Each original
|
|
9
9
|
// surface may carry a shading policy (shading-policy.js); surfaces without one
|
|
10
10
|
// use SMOOTH, which reproduces the pre-policy behavior exactly.
|
|
11
|
+
//
|
|
12
|
+
// Analytic blend normals: a surface id with a registered blend-surface descriptor
|
|
13
|
+
// (`surfaces`, blend-surfaces.js — mesh fillet bands) shades with the EXACT normal of
|
|
14
|
+
// its rolling-ball surface instead of a facet average. Wherever such a surface takes
|
|
15
|
+
// part in a vertex's smooth group, the group's normal is the average of the analytic
|
|
16
|
+
// normals alone: at a band's tangent boundary that is the neighbouring face's own
|
|
17
|
+
// normal (the surfaces meet with zero angle), so the face side of the seam, the band
|
|
18
|
+
// side, and any band vertex riding a face edge as a T-junction all agree.
|
|
11
19
|
import { SMOOTH, COPLANAR_ANGLE, MIN_EDGE, MIN_FACE, cosDeg } from "./shading-policy.js";
|
|
20
|
+
import { affineAt, IDENTITY, runEvaluator } from "./blend-surfaces.js";
|
|
12
21
|
|
|
13
22
|
const COPLANAR_COS = cosDeg(COPLANAR_ANGLE);
|
|
14
23
|
const MIN_EDGE2 = MIN_EDGE * MIN_EDGE;
|
|
24
|
+
// A descriptor's normal is trusted on a triangle only while it stays within this of
|
|
25
|
+
// the facet: a blend facet spans at most 30° of arc (blendSegs' floor of 12), so its
|
|
26
|
+
// normal is ≤15° from any of its vertices' — anything further is a tool face that is
|
|
27
|
+
// NOT the blend wall (a filler's flush end cap, a cutter's closing wall).
|
|
28
|
+
const ANALYTIC_COS = cosDeg(25);
|
|
29
|
+
// Shading-adjacency weld radius (mm): far below anything visible, above the boolean's
|
|
30
|
+
// nanometre seam splits (see the weld below).
|
|
31
|
+
const SHADE_WELD = 5e-5;
|
|
32
|
+
const SHADE_CELL = 1e-3; // its hash-grid cell
|
|
15
33
|
|
|
16
|
-
export function creasedNormals(g, { policies = null, featureLabels = null } = {}) {
|
|
34
|
+
export function creasedNormals(g, { policies = null, featureLabels = null, surfaces = null } = {}) {
|
|
17
35
|
const np = g.numProp, vp = g.vertProperties, tris = g.triVerts;
|
|
18
36
|
const nTri = (tris.length / 3) | 0, nVert = (vp.length / np) | 0;
|
|
19
37
|
|
|
@@ -38,6 +56,43 @@ export function creasedNormals(g, { policies = null, featureLabels = null } = {}
|
|
|
38
56
|
for (let r = 0; r < roid.length; r++)
|
|
39
57
|
for (let t = ri[r] / 3; t < ri[r + 1] / 3; t++) triOID[t] = roid[r];
|
|
40
58
|
|
|
59
|
+
// per-run analytic evaluators (only runs whose surface id has a descriptor), and
|
|
60
|
+
// each triangle's run — evaluated lazily, memoized per (run, vertex)
|
|
61
|
+
let triRun = null, runEval = null;
|
|
62
|
+
if (surfaces?.size) {
|
|
63
|
+
const rt = g.runTransform;
|
|
64
|
+
runEval = new Array(roid.length).fill(null);
|
|
65
|
+
let any = false;
|
|
66
|
+
for (let r = 0; r < roid.length; r++) {
|
|
67
|
+
const desc = surfaces.get(roid[r]);
|
|
68
|
+
if (!desc) continue;
|
|
69
|
+
runEval[r] = runEvaluator(desc, rt && rt.length >= (r + 1) * 12 ? affineAt(rt, r) : IDENTITY);
|
|
70
|
+
any ||= !!runEval[r];
|
|
71
|
+
}
|
|
72
|
+
if (any) {
|
|
73
|
+
triRun = new Uint32Array(nTri);
|
|
74
|
+
for (let r = 0; r < roid.length; r++)
|
|
75
|
+
for (let t = ri[r] / 3; t < ri[r + 1] / 3; t++) triRun[t] = r;
|
|
76
|
+
} else runEval = null;
|
|
77
|
+
}
|
|
78
|
+
const analyticMemo = new Map();
|
|
79
|
+
// analytic normal of triangle t at vertex v, oriented with the facet — or null
|
|
80
|
+
const analyticAt = (t, v) => {
|
|
81
|
+
const r = triRun[t], ev = runEval[r];
|
|
82
|
+
if (!ev) return null;
|
|
83
|
+
const key = r * nVert + v;
|
|
84
|
+
let n = analyticMemo.get(key);
|
|
85
|
+
if (n === undefined) {
|
|
86
|
+
const o = v * np;
|
|
87
|
+
n = ev([vp[o], vp[o + 1], vp[o + 2]]);
|
|
88
|
+
analyticMemo.set(key, n);
|
|
89
|
+
}
|
|
90
|
+
if (!n) return null;
|
|
91
|
+
const d = n[0] * fn[t * 3] + n[1] * fn[t * 3 + 1] + n[2] * fn[t * 3 + 2];
|
|
92
|
+
if (Math.abs(d) < ANALYTIC_COS) return null;
|
|
93
|
+
return d < 0 ? [-n[0], -n[1], -n[2]] : n;
|
|
94
|
+
};
|
|
95
|
+
|
|
41
96
|
// per-triangle face normals, plus each triangle's minimum height (2·area /
|
|
42
97
|
// longest edge) — the "thinness" the feature-edge pass gates on below
|
|
43
98
|
const fn = new Float32Array(nTri * 3);
|
|
@@ -69,14 +124,40 @@ export function creasedNormals(g, { policies = null, featureLabels = null } = {}
|
|
|
69
124
|
// pass below deliberately keeps `remap` (Manifold's own topology): welding
|
|
70
125
|
// its edge keys would make pairing at collapsed seams order-dependent and
|
|
71
126
|
// could pair a boundary ring's edges away.
|
|
127
|
+
//
|
|
128
|
+
// The join is within SHADE_WELD, not float-exact: a seam where a fillet band meets
|
|
129
|
+
// a curved flank comes out of the boolean as two or three vertex columns a few
|
|
130
|
+
// NANOmetres apart (measured 0.5-4 nm on a cylinder rim fillet), so an exact key
|
|
131
|
+
// left the flank's rim vertices shading on their own facet while the band beside
|
|
132
|
+
// them shaded smooth — a ~1.5° lighting step along every rim, which mirror-like
|
|
133
|
+
// materials show as a seam.
|
|
72
134
|
const weld = Uint32Array.from(remap);
|
|
73
135
|
{
|
|
74
|
-
|
|
136
|
+
// cells SHADE_CELL wide (≫ SHADE_WELD), so a vertex probes a neighbor cell only
|
|
137
|
+
// on the axes where it sits within SHADE_WELD of the cell wall — one lookup for
|
|
138
|
+
// almost every vertex. Numeric hashed keys; a collision only adds candidates,
|
|
139
|
+
// and every candidate is distance-checked.
|
|
140
|
+
const inv = 1 / SHADE_CELL, edge = SHADE_WELD / SHADE_CELL, tol2 = SHADE_WELD * SHADE_WELD;
|
|
141
|
+
const cells = new Map();
|
|
142
|
+
const hash = (x, y, z) => (Math.imul(x, 73856093) ^ Math.imul(y, 19349663) ^ Math.imul(z, 83492791)) | 0;
|
|
143
|
+
const offs = (f) => (f < edge ? [0, -1] : f > 1 - edge ? [0, 1] : [0]);
|
|
75
144
|
for (let i = 0; i < nVert; i++) {
|
|
76
|
-
const o = i * np;
|
|
77
|
-
const
|
|
78
|
-
const
|
|
79
|
-
|
|
145
|
+
const o = i * np, x = vp[o], y = vp[o + 1], z = vp[o + 2];
|
|
146
|
+
const fx = x * inv, fy = y * inv, fz = z * inv;
|
|
147
|
+
const cx = Math.floor(fx), cy = Math.floor(fy), cz = Math.floor(fz);
|
|
148
|
+
let hit = -1;
|
|
149
|
+
search: for (const dx of offs(fx - cx)) for (const dy of offs(fy - cy)) for (const dz of offs(fz - cz)) {
|
|
150
|
+
const reps = cells.get(hash(cx + dx, cy + dy, cz + dz));
|
|
151
|
+
if (!reps) continue;
|
|
152
|
+
for (const j of reps) {
|
|
153
|
+
const q = j * np;
|
|
154
|
+
if ((vp[q] - x) ** 2 + (vp[q + 1] - y) ** 2 + (vp[q + 2] - z) ** 2 <= tol2) { hit = j; break search; }
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (hit >= 0) { weld[i] = weld[hit]; continue; }
|
|
158
|
+
const key = hash(cx, cy, cz);
|
|
159
|
+
const reps = cells.get(key);
|
|
160
|
+
if (reps) reps.push(i); else cells.set(key, [i]);
|
|
80
161
|
}
|
|
81
162
|
}
|
|
82
163
|
|
|
@@ -89,14 +170,42 @@ export function creasedNormals(g, { policies = null, featureLabels = null } = {}
|
|
|
89
170
|
if (arr) arr.push(t); else incident.set(cv, [t]);
|
|
90
171
|
}
|
|
91
172
|
|
|
173
|
+
// Micro-ledges. Where two independently tessellated blend surfaces meet — a
|
|
174
|
+
// corner sphere octant against its edge cylinders, buried a hair on purpose (see
|
|
175
|
+
// mesh-fillet's cornerPatches) — the boolean leaves a step a few µm tall whose
|
|
176
|
+
// triangles face 90° off the band. Sub-visible as geometry, but shaded with their
|
|
177
|
+
// own facet they draw a bright/dark hairline around every fillet corner on a
|
|
178
|
+
// mirror material. A triangle thinner than MIN_FACE cannot show a crease of its
|
|
179
|
+
// own (the line pass drops it for the same reason), so one that touches a blend
|
|
180
|
+
// takes the band's analytic normal at each corner, crease and surface filters
|
|
181
|
+
// notwithstanding.
|
|
182
|
+
const ledgeNormal = (v) => {
|
|
183
|
+
let ax = 0, ay = 0, az = 0, any = false;
|
|
184
|
+
for (const t2 of incident.get(weld[v])) {
|
|
185
|
+
if (thin[t2] < MIN_FACE) continue;
|
|
186
|
+
const an = analyticAt(t2, weld[v]);
|
|
187
|
+
if (an) { ax += an[0]; ay += an[1]; az += an[2]; any = true; }
|
|
188
|
+
}
|
|
189
|
+
const L = Math.hypot(ax, ay, az);
|
|
190
|
+
return any && L > 1e-9 ? [ax / L, ay / L, az / L] : null;
|
|
191
|
+
};
|
|
192
|
+
|
|
92
193
|
const positions = new Float32Array(nTri * 9);
|
|
93
194
|
const normals = new Float32Array(nTri * 9);
|
|
94
195
|
for (let t = 0; t < nTri; t++) {
|
|
95
196
|
const fx = fn[t * 3], fy = fn[t * 3 + 1], fz = fn[t * 3 + 2], oid = triOID[t];
|
|
96
197
|
const sharpCos = cosFor(oid); // per-surface crease threshold
|
|
198
|
+
const ledge = runEval && thin[t] < MIN_FACE;
|
|
97
199
|
for (let k = 0; k < 3; k++) {
|
|
98
200
|
const v = tris[t * 3 + k];
|
|
99
|
-
|
|
201
|
+
const ln = ledge ? ledgeNormal(v) : null;
|
|
202
|
+
if (ln) {
|
|
203
|
+
const o = (t * 3 + k) * 3, vv = v * np;
|
|
204
|
+
positions[o] = vp[vv]; positions[o + 1] = vp[vv + 1]; positions[o + 2] = vp[vv + 2];
|
|
205
|
+
normals[o] = ln[0]; normals[o + 1] = ln[1]; normals[o + 2] = ln[2];
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
let nx = 0, ny = 0, nz = 0, ax = 0, ay = 0, az = 0, analytic = false;
|
|
100
209
|
for (const t2 of incident.get(weld[v])) {
|
|
101
210
|
// different cut surface → hard, EXCEPT when a blend surface (boundaryLines)
|
|
102
211
|
// is involved on either side. Blend↔blend: one band is many tool surfaces
|
|
@@ -112,7 +221,12 @@ export function creasedNormals(g, { policies = null, featureLabels = null } = {}
|
|
|
112
221
|
!(polFor(triOID[t2]).boundaryLines || polFor(oid).boundaryLines)) continue;
|
|
113
222
|
if (fn[t2 * 3] * fx + fn[t2 * 3 + 1] * fy + fn[t2 * 3 + 2] * fz < sharpCos) continue; // sharp same-surface edge → hard
|
|
114
223
|
nx += fn[t2 * 3]; ny += fn[t2 * 3 + 1]; nz += fn[t2 * 3 + 2];
|
|
224
|
+
if (runEval) {
|
|
225
|
+
const an = analyticAt(t2, weld[v]);
|
|
226
|
+
if (an) { ax += an[0]; ay += an[1]; az += an[2]; analytic = true; }
|
|
227
|
+
}
|
|
115
228
|
}
|
|
229
|
+
if (analytic && Math.hypot(ax, ay, az) > 1e-9) { nx = ax; ny = ay; nz = az; }
|
|
116
230
|
const L = Math.hypot(nx, ny, nz) || 1;
|
|
117
231
|
const o = (t * 3 + k) * 3, vv = v * np;
|
|
118
232
|
positions[o] = vp[vv]; positions[o + 1] = vp[vv + 1]; positions[o + 2] = vp[vv + 2];
|
|
@@ -18,6 +18,7 @@ import { meshToStl } from "./mesh-stl.js";
|
|
|
18
18
|
import { creasedNormals } from "./creased-normals.js";
|
|
19
19
|
import { loftShadingPolicy, SMOOTH, BLEND } from "./shading-policy.js";
|
|
20
20
|
import { meshFillet, meshChamfer, UnsupportedEdgeError } from "./mesh-fillet.js";
|
|
21
|
+
import { affineAt, invertAffine, mapSurface } from "./blend-surfaces.js";
|
|
21
22
|
import { meshRoundAll, prismSection, roundAllSegs } from "./mesh-roundall.js";
|
|
22
23
|
import { SEGS, circleSegs, doubleCurvatureSegs } from "./circle-segs.js";
|
|
23
24
|
import { checkBooleanResult } from "./boolean-gate.js";
|
|
@@ -137,6 +138,11 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
|
|
|
137
138
|
};
|
|
138
139
|
const featureLabels = new Map(); // originalID -> label string (grows per label(); tiny)
|
|
139
140
|
const oidPolicies = new Map(); // originalID -> shading policy (grows per faceted/hinted loft; tiny)
|
|
141
|
+
// originalID -> analytic blend-surface descriptor in that original's own frame
|
|
142
|
+
// (blend-surfaces.js; registered by mesh-fillet's tools through _markBlendSurface).
|
|
143
|
+
// Keyed on geometry, not on use: a cached tool reused elsewhere IS that surface, so
|
|
144
|
+
// the entry is true wherever the id shows up. Grows per fillet tool, like oidPolicies.
|
|
145
|
+
const blendSurfaces = new Map();
|
|
140
146
|
// name -> { m, digest, hash } | { error, digest } — imported geometry the framework
|
|
141
147
|
// registers pre-build (ensureImports, Task 8). Kernel-lifetime, NOT tracked/T()'d:
|
|
142
148
|
// these masters must survive cleanup() and be read again on every subsequent build.
|
|
@@ -216,7 +222,7 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
|
|
|
216
222
|
// transient mesh handle.
|
|
217
223
|
function meshOut(m, asStl) {
|
|
218
224
|
const g = m.getMesh();
|
|
219
|
-
const r = asStl ? stlFromMesh(g) : creasedNormals(g, { policies: oidPolicies, featureLabels });
|
|
225
|
+
const r = asStl ? stlFromMesh(g) : creasedNormals(g, { policies: oidPolicies, featureLabels, surfaces: blendSurfaces });
|
|
220
226
|
g.delete?.();
|
|
221
227
|
return r;
|
|
222
228
|
}
|
|
@@ -282,6 +288,23 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
|
|
|
282
288
|
// mesh-fillet-perf.test.js. Shading is unchanged (BLEND shades like SMOOTH), and
|
|
283
289
|
// label() on a blend result already handles a mixed-original mesh (the majority
|
|
284
290
|
// vote below).
|
|
291
|
+
// Fresh ids for a blend-aware re-stamp (label(), the roundAll decouple): ONE base id
|
|
292
|
+
// for every non-blend run, and one fresh id PER blend surface — each keeps its BLEND
|
|
293
|
+
// policy and its analytic descriptor (runTransform rides through the MeshGL
|
|
294
|
+
// round-trip, so the descriptor's original frame stays valid). Folding the band
|
|
295
|
+
// into a single blend id, as this used to, lost every descriptor with it: a labeled
|
|
296
|
+
// fillet shaded with facet normals again.
|
|
297
|
+
const reserveBlendRestamp = (oids) => {
|
|
298
|
+
const blends = [...oids].filter((oid) => !!oidPolicies.get(oid)?.boundaryLines);
|
|
299
|
+
const baseId = Manifold.reserveIDs(1 + blends.length);
|
|
300
|
+
const blendIdFor = new Map(blends.map((oid, i) => [oid, baseId + 1 + i]));
|
|
301
|
+
for (const [oid, id2] of blendIdFor) {
|
|
302
|
+
oidPolicies.set(id2, BLEND);
|
|
303
|
+
const surf = blendSurfaces.get(oid);
|
|
304
|
+
if (surf) blendSurfaces.set(id2, surf);
|
|
305
|
+
}
|
|
306
|
+
return { baseId, blendIdFor, blendIds: [...blendIdFor.values()] };
|
|
307
|
+
};
|
|
285
308
|
const runOids = (mm) => { const g = mm.getMesh(); const s = new Set(g.runOriginalID); g.delete?.(); return s; };
|
|
286
309
|
const meshCadOp = (op, baseM, run) => {
|
|
287
310
|
try {
|
|
@@ -373,16 +396,16 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
|
|
|
373
396
|
// folded blend and base into one fresh surface, erasing the distinction
|
|
374
397
|
// the band-boundary lines need — a roundAll'd prism rendered with no
|
|
375
398
|
// feature lines at all, unlike the identical geometry from fillet().
|
|
376
|
-
// Re-stamp
|
|
377
|
-
// the extruded section has no policy of its own)
|
|
378
|
-
//
|
|
399
|
+
// Re-stamp onto fresh reserved ids instead — one base id (unregistered →
|
|
400
|
+
// SMOOTH, the extruded section has no policy of its own) and one per blend
|
|
401
|
+
// surface (reserveBlendRestamp) — so the result draws exactly the lines, and
|
|
402
|
+
// shades with exactly the normals, the fillet did.
|
|
379
403
|
const g0 = filleted._m.getMesh();
|
|
380
404
|
try {
|
|
381
405
|
const isBlend = (oid) => !!oidPolicies.get(oid)?.boundaryLines;
|
|
382
406
|
if (![...new Set(g0.runOriginalID)].some(isBlend)) return T(filleted._m.asOriginal());
|
|
383
|
-
const baseId
|
|
384
|
-
g0.runOriginalID = Uint32Array.from(g0.runOriginalID, (o) => (isBlend(o) ?
|
|
385
|
-
oidPolicies.set(blendId, BLEND);
|
|
407
|
+
const { baseId, blendIdFor } = reserveBlendRestamp(new Set(g0.runOriginalID));
|
|
408
|
+
g0.runOriginalID = Uint32Array.from(g0.runOriginalID, (o) => (isBlend(o) ? blendIdFor.get(o) : baseId));
|
|
386
409
|
// The constructor re-welds, and that can pinch a latent sliver off a
|
|
387
410
|
// component as fresh femto-debris (the same rebirth dropDebris's own
|
|
388
411
|
// compose() loop guards against) — sweep the reconstruction too.
|
|
@@ -507,8 +530,10 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
|
|
|
507
530
|
// Blend-aware re-stamp. If this mesh carries blend surfaces (the boundaryLines
|
|
508
531
|
// policy), one asOriginal() would fold band and base into a single surface and
|
|
509
532
|
// erase the band-boundary overlay on exactly the solids parts label — every
|
|
510
|
-
// real part labels its top-level solids. Re-stamp
|
|
511
|
-
// base and
|
|
533
|
+
// real part labels its top-level solids. Re-stamp onto fresh reserved ids
|
|
534
|
+
// instead, one base id and one per blend surface (reserveBlendRestamp, which
|
|
535
|
+
// also carries each band's analytic normals): the label covers all of them
|
|
536
|
+
// (same string → one feature entry), the
|
|
512
537
|
// distinction survives labeling and every later boolean, and reserved ids are
|
|
513
538
|
// fresh so cached-solid reuse under another label cannot collide — the same
|
|
514
539
|
// guarantee asOriginal() gives the plain path below.
|
|
@@ -544,7 +569,7 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
|
|
|
544
569
|
} };
|
|
545
570
|
}
|
|
546
571
|
if ([...oids0].some(isBlend)) {
|
|
547
|
-
const baseId
|
|
572
|
+
const { baseId, blendIdFor, blendIds } = reserveBlendRestamp(oids0);
|
|
548
573
|
// base-group policy: the same triangle-weighted majority vote as the plain
|
|
549
574
|
// path, but over the NON-blend runs only — blend runs would elect BLEND for
|
|
550
575
|
// the base group and flag BOTH sides of every boundary seam, which is
|
|
@@ -561,16 +586,15 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
|
|
|
561
586
|
const better = weight > bestWeight || (weight === bestWeight && !pol.sameSurfaceLines && basePol?.sameSurfaceLines);
|
|
562
587
|
if (better) { bestWeight = weight; basePol = pol; }
|
|
563
588
|
}
|
|
564
|
-
g0.runOriginalID = Uint32Array.from(roid, (o2) => (isBlend(o2) ?
|
|
589
|
+
g0.runOriginalID = Uint32Array.from(roid, (o2) => (isBlend(o2) ? blendIdFor.get(o2) : baseId));
|
|
565
590
|
const o = T(new Manifold(g0));
|
|
566
591
|
g0.delete?.();
|
|
567
592
|
featureLabels.set(baseId, name);
|
|
568
|
-
featureLabels.set(
|
|
569
|
-
oidPolicies.set(blendId, BLEND);
|
|
593
|
+
for (const id2 of blendIds) featureLabels.set(id2, name);
|
|
570
594
|
if (basePol !== undefined) oidPolicies.set(baseId, basePol);
|
|
571
595
|
return { value: wrap(o, lh), pin: o, dispose: () => {
|
|
572
|
-
featureLabels.delete(baseId);
|
|
573
|
-
|
|
596
|
+
featureLabels.delete(baseId); oidPolicies.delete(baseId);
|
|
597
|
+
for (const id2 of blendIds) { featureLabels.delete(id2); oidPolicies.delete(id2); blendSurfaces.delete(id2); }
|
|
574
598
|
o.delete?.();
|
|
575
599
|
} };
|
|
576
600
|
}
|
|
@@ -839,6 +863,23 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
|
|
|
839
863
|
// is total; errors are lazy"). Re-registering the same name+digest is a no-op
|
|
840
864
|
// EXCEPT an error entry is always upgradable (the post-crossover retry depends
|
|
841
865
|
// on this — see `_importDigest`).
|
|
866
|
+
// Side-channel for mesh-fillet (underscore = off-contract): `surf` describes the
|
|
867
|
+
// tool's blend wall in its current frame. Stored per surface id in the id's
|
|
868
|
+
// ORIGINAL frame (inverse runTransform), so any later pose or boolean — which
|
|
869
|
+
// carry runTransform along — still evaluates it exactly.
|
|
870
|
+
_markBlendSurface: (tool, surf) => {
|
|
871
|
+
const g = tool._m.getMesh();
|
|
872
|
+
try {
|
|
873
|
+
const roid = g.runOriginalID, rt = g.runTransform;
|
|
874
|
+
for (let r = 0; r < roid.length; r++) {
|
|
875
|
+
const inv = invertAffine(affineAt(rt, r));
|
|
876
|
+
if (inv) blendSurfaces.set(roid[r], mapSurface(surf, inv));
|
|
877
|
+
}
|
|
878
|
+
} finally {
|
|
879
|
+
g.delete?.();
|
|
880
|
+
}
|
|
881
|
+
return tool;
|
|
882
|
+
},
|
|
842
883
|
_registerImport: ({ name, digest, positions, indices, error }) => {
|
|
843
884
|
const prev = imports.get(name);
|
|
844
885
|
if (!prev?.error && prev?.digest === digest) return; // error entries are always upgradable
|
|
@@ -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
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Minimal 3MF writer. 3MF is an OPC package (a zip) holding an XML model; it's a
|
|
2
2
|
// mesh format like STL but supports units and multiple named objects in one file,
|
|
3
3
|
// so a multi-part view exports as a single .3mf. Built from indexed meshes.
|
|
4
|
+
// Each object may carry an optional colour (0xRRGGBB).
|
|
4
5
|
import { zipSync, strToU8 } from "fflate";
|
|
5
6
|
|
|
6
7
|
const CONTENT_TYPES =
|
|
@@ -67,16 +68,44 @@ function weld(positions, indices) {
|
|
|
67
68
|
return { verts, tris };
|
|
68
69
|
}
|
|
69
70
|
|
|
70
|
-
//
|
|
71
|
-
//
|
|
71
|
+
// Default for an uncoloured object in an export where some other object IS
|
|
72
|
+
// coloured: the viewer's own default look (presets.js "default"). Written as a
|
|
73
|
+
// literal so this module keeps its fflate-only import closure.
|
|
74
|
+
const DEFAULT_DISPLAY_COLOR = 0x9fb4cc;
|
|
75
|
+
// Base-material group id: outside the object id range (objects are 1..n).
|
|
76
|
+
const BASE_MATERIALS_ID = 1000;
|
|
77
|
+
const hexColor = (c) => `#${c.toString(16).padStart(6, "0").toUpperCase()}FF`;
|
|
78
|
+
|
|
79
|
+
// parts: [{ name, positions, indices, color? }] → ArrayBuffer of the .3mf zip.
|
|
80
|
+
// `color` (0xRRGGBB) is optional per part. When NO part carries one, the model is
|
|
81
|
+
// byte-for-byte what it was before colour support — no <basematerials>, no pid.
|
|
82
|
+
// When any does, every object points (pid/pindex) into one core-spec
|
|
83
|
+
// <basematerials> group, one <base> per distinct colour, so a slicer opens the
|
|
84
|
+
// file pre-split into coloured objects.
|
|
72
85
|
export function meshTo3MF(parts) {
|
|
86
|
+
const colored = parts.some((p) => p.color != null);
|
|
87
|
+
const palette = [];
|
|
88
|
+
const pindexOf = (c) => {
|
|
89
|
+
const color = c ?? DEFAULT_DISPLAY_COLOR;
|
|
90
|
+
let i = palette.indexOf(color);
|
|
91
|
+
if (i < 0) { i = palette.length; palette.push(color); }
|
|
92
|
+
return i;
|
|
93
|
+
};
|
|
94
|
+
const pindex = colored ? parts.map((p) => pindexOf(p.color)) : [];
|
|
95
|
+
|
|
73
96
|
const out = [
|
|
74
97
|
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
75
98
|
'<model unit="millimeter" xml:lang="en-US" xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02">',
|
|
76
99
|
"<resources>",
|
|
77
100
|
];
|
|
101
|
+
if (colored) {
|
|
102
|
+
out.push(`<basematerials id="${BASE_MATERIALS_ID}">`);
|
|
103
|
+
palette.forEach((c, i) => out.push(`<base name="color-${i + 1}" displaycolor="${hexColor(c)}"/>`));
|
|
104
|
+
out.push("</basematerials>");
|
|
105
|
+
}
|
|
78
106
|
parts.forEach((p, i) => {
|
|
79
|
-
|
|
107
|
+
const mat = colored ? ` pid="${BASE_MATERIALS_ID}" pindex="${pindex[i]}"` : "";
|
|
108
|
+
out.push(`<object id="${i + 1}" type="model" name="${xmlEsc(p.name)}"${mat}><mesh><vertices>`);
|
|
80
109
|
const { verts: v, tris: t } = weld(p.positions, p.indices);
|
|
81
110
|
for (let k = 0; k < v.length; k += 3) out.push(`<vertex x="${v[k]}" y="${v[k + 1]}" z="${v[k + 2]}"/>`);
|
|
82
111
|
out.push("</vertices><triangles>");
|
package/src/framework/jobs.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// the oracle and the collision check can share it without importing this async,
|
|
4
4
|
// kernel-bound module back.
|
|
5
5
|
import { meshTo3MF } from "./geometry/threemf.js";
|
|
6
|
+
import { printColor } from "./materials/resolve.js";
|
|
6
7
|
import { exportablePartNames } from "./export-select.js";
|
|
7
8
|
import { fontControlAllows, fontSourceAllowed, isNoFontSource } from "./font-source.js";
|
|
8
9
|
import { fontsFor, resolveFonts } from "./fonts.js";
|
|
@@ -449,7 +450,7 @@ export async function handle(kernel, part, msg, post, opts = {}) {
|
|
|
449
450
|
const meshes = names.map((name) => {
|
|
450
451
|
onProgress(`building ${label(name)}`);
|
|
451
452
|
const { positions, indices } = posed(name, "export", onProgress).toIndexedMesh({ quality: msg.quality ?? "print" });
|
|
452
|
-
return { name: exportName(name), positions, indices };
|
|
453
|
+
return { name: exportName(name), positions, indices, color: printColor(part.parts[name]?.display) };
|
|
453
454
|
});
|
|
454
455
|
onProgress("writing 3MF file");
|
|
455
456
|
const data = meshTo3MF(meshes);
|
|
@@ -20,8 +20,9 @@ import { FONT_RULES } from "./rules-fonts.js";
|
|
|
20
20
|
import { IMAGE_RULES } from "./rules-images.js";
|
|
21
21
|
import { SOURCE_RULES } from "./rules-source.js";
|
|
22
22
|
import { VECTOR_RULES } from "./rules-vector.js";
|
|
23
|
+
import { MATERIAL_RULES } from "./rules-materials.js";
|
|
23
24
|
|
|
24
|
-
export const RULES = [...SHAPE_RULES, ...SCHEMA_RULES, ...BUILD_RULES, ...VERIFY_RULES, ...ANIMATION_RULES, ...PLACE_RULES, ...IMPORT_RULES, ...FONT_RULES, ...IMAGE_RULES, ...SOURCE_RULES, ...VECTOR_RULES];
|
|
25
|
+
export const RULES = [...SHAPE_RULES, ...SCHEMA_RULES, ...BUILD_RULES, ...VERIFY_RULES, ...ANIMATION_RULES, ...PLACE_RULES, ...IMPORT_RULES, ...FONT_RULES, ...IMAGE_RULES, ...SOURCE_RULES, ...VECTOR_RULES, ...MATERIAL_RULES];
|
|
25
26
|
|
|
26
27
|
// A usable sources input, or null. Deliberately forgiving: lintPart's callers
|
|
27
28
|
// include hosted paths handing over user/LLM-authored trees, so a malformed
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// src/framework/lint/rules-materials.js
|
|
2
|
+
// Appearance findings. All WARNINGS by design: a misspelled material must never
|
|
3
|
+
// block geometry, and the viewer already falls back (resolve.js). Pure — imports
|
|
4
|
+
// only the three-free materials data (test/lint-purity.test.js).
|
|
5
|
+
import { warn } from "./finding.js";
|
|
6
|
+
import { PRESETS, DEFAULT_PRESET_ID } from "../materials/presets.js";
|
|
7
|
+
import { ENVIRONMENTS } from "../materials/environments.js";
|
|
8
|
+
import { resolveMaterial, resolveEnvironmentId, OVERRIDE_RANGES } from "../materials/resolve.js";
|
|
9
|
+
import { suggest } from "../geometry/op-options.js";
|
|
10
|
+
|
|
11
|
+
const listedPresets = () => Object.keys(PRESETS).filter((id) => id !== DEFAULT_PRESET_ID);
|
|
12
|
+
const subParts = (part) => Object.entries(part?.parts ?? {}).filter(([, sp]) => sp && typeof sp === "object");
|
|
13
|
+
const issuesOf = (part, kind) => subParts(part).flatMap(([name, sp]) =>
|
|
14
|
+
resolveMaterial(sp.display).issues.filter((i) => i.kind === kind).map((i) => ({ name, ...i })));
|
|
15
|
+
|
|
16
|
+
export const MATERIAL_RULES = [
|
|
17
|
+
{
|
|
18
|
+
id: "unknown-material",
|
|
19
|
+
run: ({ part }) => issuesOf(part, "unknown-material").map(({ name, value }) => {
|
|
20
|
+
const near = typeof value === "string" ? suggest(value, listedPresets()) : null;
|
|
21
|
+
return warn("unknown-material",
|
|
22
|
+
`sub-part "${name}" names material ${JSON.stringify(value)}, which is not in the library`,
|
|
23
|
+
`${near ? `Did you mean "${near}"? ` : ""}The viewer shows the default look instead. Known materials: ${listedPresets().join(", ")}.`,
|
|
24
|
+
`parts.${name}.display.material`);
|
|
25
|
+
}),
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
id: "unknown-environment",
|
|
29
|
+
run: ({ part }) => {
|
|
30
|
+
const env = part?.meta?.environment;
|
|
31
|
+
if (resolveEnvironmentId(env).known) return [];
|
|
32
|
+
return [warn("unknown-environment",
|
|
33
|
+
`meta.environment ${JSON.stringify(env)} is not a known environment`,
|
|
34
|
+
`Realistic mode uses "studio" instead. Known environments: ${Object.keys(ENVIRONMENTS).join(", ")}.`,
|
|
35
|
+
"meta.environment")];
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
id: "material-key-unknown",
|
|
40
|
+
run: ({ part }) => issuesOf(part, "material-key-unknown").map(({ name, key }) =>
|
|
41
|
+
warn("material-key-unknown",
|
|
42
|
+
`sub-part "${name}" display has unknown key "${key}"`,
|
|
43
|
+
`It is ignored. display accepts color, opacity, material and the overrides ${Object.keys(OVERRIDE_RANGES).join(", ")}; transmission, IOR, sheen and iridescence come only from a preset.`,
|
|
44
|
+
`parts.${name}.display.${key}`)),
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
id: "material-override-clamped",
|
|
48
|
+
run: ({ part }) => issuesOf(part, "material-override-clamped").map(({ name, key, value, clampedTo }) => {
|
|
49
|
+
const [lo, hi] = OVERRIDE_RANGES[key];
|
|
50
|
+
return warn("material-override-clamped",
|
|
51
|
+
`sub-part "${name}" display.${key} = ${JSON.stringify(value)} is outside ${lo}..${hi}; using ${clampedTo}`,
|
|
52
|
+
`Give ${key} a number from ${lo} to ${hi}.`,
|
|
53
|
+
`parts.${name}.display.${key}`);
|
|
54
|
+
}),
|
|
55
|
+
},
|
|
56
|
+
];
|