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.
Files changed (63) hide show
  1. package/docs/AUTHORING-PARTS.md +164 -3
  2. package/docs/KERNEL-CONTRACT.md +10 -0
  3. package/package.json +3 -1
  4. package/src/app-materials.js +23 -0
  5. package/src/framework/app.css +16 -1
  6. package/src/framework/cutaway.js +12 -0
  7. package/src/framework/geometry/blend-surfaces.js +256 -0
  8. package/src/framework/geometry/creased-normals.js +121 -7
  9. package/src/framework/geometry/manifold-backend.js +56 -15
  10. package/src/framework/geometry/mesh-fillet.js +55 -5
  11. package/src/framework/geometry/threemf.js +32 -3
  12. package/src/framework/jobs.js +2 -1
  13. package/src/framework/lint/index.js +2 -1
  14. package/src/framework/lint/rules-materials.js +56 -0
  15. package/src/framework/materials/assets/SOURCES.md +90 -0
  16. package/src/framework/materials/assets/env-outdoor.jpg +0 -0
  17. package/src/framework/materials/assets/env-print-bed.jpg +0 -0
  18. package/src/framework/materials/assets/env-studio.jpg +0 -0
  19. package/src/framework/materials/assets/env-workshop.jpg +0 -0
  20. package/src/framework/materials/assets/ground-concrete-normal.jpg +0 -0
  21. package/src/framework/materials/assets/ground-concrete-rough.jpg +0 -0
  22. package/src/framework/materials/assets/ground-concrete.jpg +0 -0
  23. package/src/framework/materials/assets/ground-maple-color.jpg +0 -0
  24. package/src/framework/materials/assets/ground-maple-normal.jpg +0 -0
  25. package/src/framework/materials/assets/ground-paper-normal.jpg +0 -0
  26. package/src/framework/materials/assets/ground-paper-rough.jpg +0 -0
  27. package/src/framework/materials/assets/ground-paper.jpg +0 -0
  28. package/src/framework/materials/assets/ground-pei-rough.jpg +0 -0
  29. package/src/framework/materials/assets/ground-pei.jpg +0 -0
  30. package/src/framework/materials/assets/pattern-carbon.jpg +0 -0
  31. package/src/framework/materials/assets/pattern-oak-color.jpg +0 -0
  32. package/src/framework/materials/assets/pattern-oak-normal.jpg +0 -0
  33. package/src/framework/materials/assets/pattern-oak-rough.jpg +0 -0
  34. package/src/framework/materials/assets/pattern-walnut-color.jpg +0 -0
  35. package/src/framework/materials/assets/pattern-walnut-normal.jpg +0 -0
  36. package/src/framework/materials/assets/pattern-walnut-rough.jpg +0 -0
  37. package/src/framework/materials/assets.js +39 -0
  38. package/src/framework/materials/contact-shadow.js +155 -0
  39. package/src/framework/materials/environment.js +157 -0
  40. package/src/framework/materials/environments.js +82 -0
  41. package/src/framework/materials/patterns.js +243 -0
  42. package/src/framework/materials/physical.js +88 -0
  43. package/src/framework/materials/presets.js +89 -0
  44. package/src/framework/materials/print-bed.js +169 -0
  45. package/src/framework/materials/print-frame.js +18 -0
  46. package/src/framework/materials/resolve.js +117 -0
  47. package/src/framework/materials/tonemap-readback.js +36 -0
  48. package/src/framework/materials/uv.js +42 -0
  49. package/src/framework/mount.js +121 -4
  50. package/src/framework/realistic-controls.js +78 -0
  51. package/src/framework/selection/feature-highlight.js +3 -0
  52. package/src/framework/view-state.js +27 -1
  53. package/src/framework/viewer.js +623 -34
  54. package/src/materials-worker.js +3 -0
  55. package/src/parts/bracket.js +2 -1
  56. package/src/parts/hinged-box.js +3 -1
  57. package/src/parts/material-swatches.js +40 -0
  58. package/src/parts/planter.js +2 -1
  59. package/src/parts/propeller.js +2 -1
  60. package/src/parts/scott-label.js +3 -2
  61. package/src/parts/screw.js +2 -1
  62. package/types/index.d.ts +48 -3
  63. 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
- const byPos = new Map();
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 key = `${vp[o]}|${vp[o + 1]}|${vp[o + 2]}`;
78
- const first = byPos.get(key);
79
- if (first === undefined) byPos.set(key, weld[i]); else weld[i] = first;
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
- let nx = 0, ny = 0, nz = 0;
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 as two reserved ids instead (base stays unregistered → SMOOTH,
377
- // the extruded section has no policy of its own) so the result draws
378
- // exactly the lines the fillet drew.
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 = Manifold.reserveIDs(2), blendId = baseId + 1;
384
- g0.runOriginalID = Uint32Array.from(g0.runOriginalID, (o) => (isBlend(o) ? blendId : baseId));
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 as TWO reserved ids instead,
511
- // base and blend: the label covers both (same string → one feature entry), the
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 = Manifold.reserveIDs(2), blendId = baseId + 1;
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) ? blendId : baseId));
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(blendId, name);
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); featureLabels.delete(blendId);
573
- oidPolicies.delete(baseId); oidPolicies.delete(blendId);
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
- return tool.translate(a);
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
- return tool.translate(O);
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
- return k.sweep(poly, path3D, { closed: isClosed });
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
- return tool.translate(vertex);
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
- patches.push(block.cut(k.sphere({ r }).at(add(C, scl(inward, bury)))));
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
- // parts: [{ name, positions: Float32Array (x,y,z per vertex), indices: Uint32Array (3 per triangle) }]
71
- // → ArrayBuffer of the .3mf zip (millimetre units; one <object> + <build> item per part).
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
- out.push(`<object id="${i + 1}" type="model" name="${xmlEsc(p.name)}"><mesh><vertices>`);
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>");
@@ -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
+ ];