partforge 0.144.0 → 0.144.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.144.0",
3
+ "version": "0.144.1",
4
4
  "description": "Turn a declarative part definition into a parametric-CAD web app (three.js + Manifold/Replicad). Requires a Vite-based consumer.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -22,6 +22,12 @@
22
22
  // { kind: "spine", pts, closed, r }
23
23
  // general chain (mesh-fillet generalTool): the
24
24
  // ball-centre polyline itself; r is the radius
25
+ // { kind: "planes", planes: [{ n, d }], tol }
26
+ // roundAll's flat faces (mesh-roundall.js
27
+ // flatFacePlanes): a point within tol of one plane
28
+ // (n·x = d) takes its normal; anywhere else — the
29
+ // rounded band off its seams — there is none, and
30
+ // creased-normals keeps the facet average
25
31
  //
26
32
  // A path's polyline usually STANDS FOR a smooth curve (a circle's rim, an outline's
27
33
  // rounded corner): its per-segment cylinders would shade faceted around the curve,
@@ -105,10 +111,32 @@ export function mapSurface(desc, M) {
105
111
  M[0] * (M[4] * M[8] - M[5] * M[7]) - M[3] * (M[1] * M[8] - M[2] * M[7]) + M[6] * (M[1] * M[5] - M[2] * M[4])));
106
112
  return { kind: "spine", closed: desc.closed, pts: desc.pts.map((p) => applyPt(M, p)), r: desc.r * s };
107
113
  }
114
+ case "planes": {
115
+ const s = Math.cbrt(Math.abs(
116
+ M[0] * (M[4] * M[8] - M[5] * M[7]) - M[3] * (M[1] * M[8] - M[2] * M[7]) + M[6] * (M[1] * M[5] - M[2] * M[4])));
117
+ return { kind: "planes", tol: desc.tol * s, planes: desc.planes.map(({ n, d }) => {
118
+ const n2 = norm(applyDir(M, n)) ?? n;
119
+ const p = applyPt(M, [n[0] * d, n[1] * d, n[2] * d]);
120
+ return { n: n2, d: n2[0] * p[0] + n2[1] * p[1] + n2[2] * p[2] };
121
+ }) };
122
+ }
108
123
  default: throw new Error(`blend surface: unknown kind ${desc.kind}`);
109
124
  }
110
125
  }
111
126
 
127
+ // The plane x lies on, or null — off every plane, or on two at once (where two
128
+ // flat faces would meet, which a rounded result never shows, so no answer is right).
129
+ function planeNormal(desc, x) {
130
+ let hit = null;
131
+ for (const pl of desc.planes) {
132
+ const n = pl.n;
133
+ if (Math.abs(n[0] * x[0] + n[1] * x[1] + n[2] * x[2] - pl.d) > desc.tol) continue;
134
+ if (hit) return null;
135
+ hit = n;
136
+ }
137
+ return hit;
138
+ }
139
+
112
140
  // Nearest spine point to x, in the descriptor's own frame.
113
141
  function spinePoint(desc, x) {
114
142
  switch (desc.kind) {
@@ -240,6 +268,7 @@ function nearestSegment(desc, x) {
240
268
  // Unit spine→point direction at x in the descriptor's frame, or null where undefined.
241
269
  export function surfaceNormal(desc, x) {
242
270
  if (desc.kind === "path") return pathNormal(desc, x);
271
+ if (desc.kind === "planes") return planeNormal(desc, x);
243
272
  if (desc.kind === "spine") {
244
273
  const { bi, bt } = nearestSegment(desc, x);
245
274
  if (bi < 0) return null;
@@ -18,8 +18,8 @@ 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";
22
- import { meshRoundAll, prismSection, roundAllSegs } from "./mesh-roundall.js";
21
+ import { affineAt, IDENTITY, invertAffine, mapSurface } from "./blend-surfaces.js";
22
+ import { flatFacePlanes, meshRoundAll, prismSection, roundAllSegs } from "./mesh-roundall.js";
23
23
  import { SEGS, circleSegs, doubleCurvatureSegs } from "./circle-segs.js";
24
24
  import { checkBooleanResult } from "./boolean-gate.js";
25
25
  import { KernelCapabilityError } from "./errors.js";
@@ -294,12 +294,20 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
294
294
  // round-trip, so the descriptor's original frame stays valid). Folding the band
295
295
  // into a single blend id, as this used to, lost every descriptor with it: a labeled
296
296
  // fillet shaded with facet normals again.
297
+ //
298
+ // "Blend" here is any surface that must keep an id of its own: a fillet band (the
299
+ // boundaryLines policy) or a surface carrying an analytic descriptor at all —
300
+ // roundAll's flat-face planes ride on a SMOOTH surface, and folding that into the
301
+ // base id would drop them and bring the flat-face shading gradient back on every
302
+ // labeled roundAll. Each keeps the policy it had (none, for the roundAll surface).
303
+ const keepsOwnId = (oid) => !!oidPolicies.get(oid)?.boundaryLines || blendSurfaces.has(oid);
297
304
  const reserveBlendRestamp = (oids) => {
298
- const blends = [...oids].filter((oid) => !!oidPolicies.get(oid)?.boundaryLines);
305
+ const blends = [...oids].filter(keepsOwnId);
299
306
  const baseId = Manifold.reserveIDs(1 + blends.length);
300
307
  const blendIdFor = new Map(blends.map((oid, i) => [oid, baseId + 1 + i]));
301
308
  for (const [oid, id2] of blendIdFor) {
302
- oidPolicies.set(id2, BLEND);
309
+ const pol = oidPolicies.get(oid);
310
+ if (pol !== undefined) oidPolicies.set(id2, pol);
303
311
  const surf = blendSurfaces.get(oid);
304
312
  if (surf) blendSurfaces.set(id2, surf);
305
313
  }
@@ -405,7 +413,7 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
405
413
  const isBlend = (oid) => !!oidPolicies.get(oid)?.boundaryLines;
406
414
  if (![...new Set(g0.runOriginalID)].some(isBlend)) return T(filleted._m.asOriginal());
407
415
  const { baseId, blendIdFor } = reserveBlendRestamp(new Set(g0.runOriginalID));
408
- g0.runOriginalID = Uint32Array.from(g0.runOriginalID, (o) => (isBlend(o) ? blendIdFor.get(o) : baseId));
416
+ g0.runOriginalID = Uint32Array.from(g0.runOriginalID, (o) => blendIdFor.get(o) ?? baseId);
409
417
  // The constructor re-welds, and that can pinch a latent sliver off a
410
418
  // component as fresh femto-debris (the same rebirth dropDebris's own
411
419
  // compose() loop guards against) — sweep the reconstruction too.
@@ -423,6 +431,39 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
423
431
  }
424
432
  };
425
433
 
434
+ // The reference roundAll's flat faces (mesh-roundall.js flatFacePlanes): the
435
+ // input's flat faces, which the chain returns exactly to their planes, become a
436
+ // "planes" descriptor on the result's surface so creased-normals shades them —
437
+ // and the band's seams beside them — with the plane's own normal. The tolerance
438
+ // sits well under the first ring of band vertices off each face (the same ring
439
+ // meshRoundAll's simplify tolerance protects), so only the face and its seam
440
+ // vertices are on a plane. Keyed on geometry like every descriptor (see
441
+ // blendSurfaces above), so it is never removed with the cache entry.
442
+ const markFlatFaces = (input, out, r) => {
443
+ const gi = input.getMesh();
444
+ let planes;
445
+ try {
446
+ planes = flatFacePlanes(gi, { policyFor: (oid) => oidPolicies.get(oid) });
447
+ } finally {
448
+ gi.delete?.();
449
+ }
450
+ if (!planes.length) return out;
451
+ const segs = roundAllSegs(2 * r, quality);
452
+ const tol = Math.min(1e-3, 0.25 * r * (1 - Math.cos((2 * Math.PI) / segs)));
453
+ const go = out.getMesh();
454
+ try {
455
+ const roid = go.runOriginalID, rt = go.runTransform;
456
+ for (let i = 0; i < roid.length; i++) {
457
+ // a fresh original carries no runTransform at all — identity, as creased-normals reads it
458
+ const inv = rt && rt.length >= (i + 1) * 12 ? invertAffine(affineAt(rt, i)) : IDENTITY;
459
+ if (inv) blendSurfaces.set(roid[i], mapSurface({ kind: "planes", planes, tol }, inv));
460
+ }
461
+ } finally {
462
+ go.delete?.();
463
+ }
464
+ return out;
465
+ };
466
+
426
467
  // Replay a recorded transform chain onto a solid. Each record maps back to the op
427
468
  // that produced it, so the replayed solid rebuilds the same chain on its own canon.
428
469
  const replay = (solid, chain) => chain.reduce(
@@ -503,7 +544,7 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
503
544
  // key omits it for the same reason); it just spells out that the ball
504
545
  // tessellation, and so the result, is tier-dependent.
505
546
  return cached(h("roundAll", hash, r, quality), () =>
506
- prismRoundAllFast(m, hash, r) ?? T(meshRoundAll(wasm, m, r, quality)));
547
+ prismRoundAllFast(m, hash, r) ?? markFlatFaces(m, T(meshRoundAll(wasm, m, r, quality)), r));
507
548
  },
508
549
  // batch difference: first minus the union of the rest, evaluated as one boolean
509
550
  // tree — no materialized intermediate union (the unionRaw memory note applies)
@@ -538,7 +579,6 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
538
579
  // fresh so cached-solid reuse under another label cannot collide — the same
539
580
  // guarantee asOriginal() gives the plain path below.
540
581
  const g0 = m.getMesh();
541
- const isBlend = (oid) => !!oidPolicies.get(oid)?.boundaryLines;
542
582
  const oids0 = new Set(g0.runOriginalID);
543
583
  // Sector-aware re-stamp: a provenance-sectored loft (the `sector` policy
544
584
  // marker) must keep every run's identity — folding them (either the plain
@@ -561,14 +601,16 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
561
601
  featureLabels.set(newId, name);
562
602
  const pol = oidPolicies.get(oldId);
563
603
  if (pol !== undefined) oidPolicies.set(newId, pol);
604
+ const surf = blendSurfaces.get(oldId); // analytic normals ride the 1:1 stamp too
605
+ if (surf) blendSurfaces.set(newId, surf);
564
606
  setIds.push(newId);
565
607
  }
566
608
  return { value: wrap(o, lh), pin: o, dispose: () => {
567
- for (const id2 of setIds) { featureLabels.delete(id2); oidPolicies.delete(id2); }
609
+ for (const id2 of setIds) { featureLabels.delete(id2); oidPolicies.delete(id2); blendSurfaces.delete(id2); }
568
610
  o.delete?.();
569
611
  } };
570
612
  }
571
- if ([...oids0].some(isBlend)) {
613
+ if ([...oids0].some(keepsOwnId)) {
572
614
  const { baseId, blendIdFor, blendIds } = reserveBlendRestamp(oids0);
573
615
  // base-group policy: the same triangle-weighted majority vote as the plain
574
616
  // path, but over the NON-blend runs only — blend runs would elect BLEND for
@@ -578,7 +620,7 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
578
620
  const weightByKey = new Map();
579
621
  let bestWeight = -1, basePol;
580
622
  for (let r = 0; r < roid.length; r++) {
581
- if (isBlend(roid[r])) continue;
623
+ if (blendIdFor.has(roid[r])) continue;
582
624
  const pol = oidPolicies.get(roid[r]) ?? SMOOTH;
583
625
  const key = `${pol.creaseAngle}/${pol.sameSurfaceLines}/${!!pol.boundaryLines}`;
584
626
  const weight = (weightByKey.get(key) || 0) + (ri[r + 1] / 3 - ri[r] / 3);
@@ -586,7 +628,7 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
586
628
  const better = weight > bestWeight || (weight === bestWeight && !pol.sameSurfaceLines && basePol?.sameSurfaceLines);
587
629
  if (better) { bestWeight = weight; basePol = pol; }
588
630
  }
589
- g0.runOriginalID = Uint32Array.from(roid, (o2) => (isBlend(o2) ? blendIdFor.get(o2) : baseId));
631
+ g0.runOriginalID = Uint32Array.from(roid, (o2) => blendIdFor.get(o2) ?? baseId);
590
632
  const o = T(new Manifold(g0));
591
633
  g0.delete?.();
592
634
  featureLabels.set(baseId, name);
@@ -29,6 +29,7 @@
29
29
  // the two facetings still meets the tier's sagitta tolerance.
30
30
 
31
31
  import { segsForSagitta } from "./circle-segs.js";
32
+ import { SMOOTH, cosDeg } from "./shading-policy.js";
32
33
 
33
34
  // Sphere tessellation from the facet sagitta r·(1 − cos(π/segs)): pick the
34
35
  // fewest segments that keep it under the quality tier's tolerance — its own
@@ -83,6 +84,121 @@ export function meshRoundAll(wasm, m, r, quality) {
83
84
  }
84
85
  }
85
86
 
87
+ // ---------------------------------------------------------------------------
88
+ // Flat-face shading. The reference path's result is one fresh surface, shaded
89
+ // SMOOTH (35° crease), and the rounding ball's facets bend far less than that, so
90
+ // a flat face used to share its corner normals with the band facets around it.
91
+ // The vertex sum is unweighted, so a large face split into two triangles — a
92
+ // sloped palm plate's top — took a several-degree tilt at each corner and showed
93
+ // it as a shading gradient across the whole face (cloud feedback #167).
94
+ //
95
+ // The cure rests on the invariant above: the chain returns every planar face of
96
+ // the input to its own plane. So the input's flat faces, found here, say exactly
97
+ // where the result is flat; the backend registers them as an analytic "planes"
98
+ // descriptor (blend-surfaces.js), and creased-normals then shades every vertex on
99
+ // one of those planes — the band's seam vertices too, since the band is tangent
100
+ // there — with the plane's normal, the same way a fillet band's seam agrees with
101
+ // its face.
102
+ //
103
+ // "Flat" means flat the way the INPUT shades it: a coplanar group counts only if
104
+ // every edge bounding it is one creased-normals would shade hard (a bend past the
105
+ // surface's crease angle, or a cut seam). A rod's wall facets, or a slot's sides
106
+ // running tangentially into its round ends, meet their neighbours softly — the
107
+ // input already shades them as part of a curved surface, and pinning them to
108
+ // their own planes would facet the rounded result.
109
+ //
110
+ // `g` is a MeshGL (or the same fields on plain arrays); `policyFor(oid)` returns
111
+ // that surface's shading policy. Returns [{ n, d }] with n·x = d on the plane.
112
+ const PLANE_COS = cosDeg(0.05); // coplanar neighbours: a boolean split, not a bend
113
+ export function flatFacePlanes(g, { policyFor = () => SMOOTH } = {}) {
114
+ const np = g.numProp, vp = g.vertProperties, tris = g.triVerts;
115
+ const nTri = (tris.length / 3) | 0, nVert = (vp.length / np) | 0;
116
+ const remap = new Uint32Array(nVert);
117
+ for (let i = 0; i < nVert; i++) remap[i] = i;
118
+ const mf = g.mergeFromVert, mt = g.mergeToVert;
119
+ if (mf && mt) for (let i = 0; i < mf.length; i++) remap[mf[i]] = mt[i];
120
+ const triOID = new Uint32Array(nTri);
121
+ const ri = g.runIndex, roid = g.runOriginalID;
122
+ if (ri && roid) for (let r = 0; r < roid.length; r++)
123
+ for (let t = ri[r] / 3; t < ri[r + 1] / 3; t++) triOID[t] = roid[r];
124
+
125
+ // facet normals, areas and centroids; a degenerate triangle has no normal and
126
+ // takes no part — it neither joins a face nor disqualifies one
127
+ const fn = new Float64Array(nTri * 3), area = new Float64Array(nTri), cen = new Float64Array(nTri * 3);
128
+ let span = 0;
129
+ for (let t = 0; t < nTri; t++) {
130
+ const a = tris[t * 3] * np, b = tris[t * 3 + 1] * np, c = tris[t * 3 + 2] * np;
131
+ const ux = vp[b] - vp[a], uy = vp[b + 1] - vp[a + 1], uz = vp[b + 2] - vp[a + 2];
132
+ const vx = vp[c] - vp[a], vy = vp[c + 1] - vp[a + 1], vz = vp[c + 2] - vp[a + 2];
133
+ const nx = uy * vz - uz * vy, ny = uz * vx - ux * vz, nz = ux * vy - uy * vx;
134
+ const L = Math.hypot(nx, ny, nz);
135
+ area[t] = L / 2;
136
+ if (L > 0) { fn[t * 3] = nx / L; fn[t * 3 + 1] = ny / L; fn[t * 3 + 2] = nz / L; }
137
+ for (let k = 0; k < 3; k++) cen[t * 3 + k] = (vp[a + k] + vp[b + k] + vp[c + k]) / 3;
138
+ span = Math.max(span, Math.abs(vp[a]), Math.abs(vp[a + 1]), Math.abs(vp[a + 2]));
139
+ }
140
+ const live = (t) => area[t] > 1e-9;
141
+ const dot = (t, u) => fn[t * 3] * fn[u * 3] + fn[t * 3 + 1] * fn[u * 3 + 1] + fn[t * 3 + 2] * fn[u * 3 + 2];
142
+
143
+ // edge → incident triangles, on welded vertex ids
144
+ const edges = new Map();
145
+ for (let t = 0; t < nTri; t++) for (let k = 0; k < 3; k++) {
146
+ const p = remap[tris[t * 3 + k]], q = remap[tris[t * 3 + ((k + 1) % 3)]];
147
+ const key = p < q ? p * nVert + q : q * nVert + p;
148
+ const list = edges.get(key);
149
+ if (list) list.push(t); else edges.set(key, [t]);
150
+ }
151
+
152
+ // coplanar groups (union-find over shared edges)
153
+ const parent = new Int32Array(nTri);
154
+ for (let t = 0; t < nTri; t++) parent[t] = t;
155
+ const find = (t) => { while (parent[t] !== t) { parent[t] = parent[parent[t]]; t = parent[t]; } return t; };
156
+ for (const list of edges.values()) {
157
+ if (list.length !== 2) continue;
158
+ const [t, u] = list;
159
+ if (live(t) && live(u) && dot(t, u) > PLANE_COS) parent[find(t)] = find(u);
160
+ }
161
+
162
+ // a group is a flat face only if every edge leaving it would shade hard
163
+ const soft = new Uint8Array(nTri); // indexed by group root
164
+ const hard = (t, u) => {
165
+ const pt = policyFor(triOID[t]) ?? SMOOTH, pu = policyFor(triOID[u]) ?? SMOOTH;
166
+ if (triOID[t] !== triOID[u] && !(pt.boundaryLines || pu.boundaryLines)) return true;
167
+ return dot(t, u) < cosDeg(pt.creaseAngle);
168
+ };
169
+ for (const list of edges.values()) {
170
+ if (list.length !== 2) continue; // non-manifold: no smooth neighbour to share with
171
+ const [t, u] = list;
172
+ if (!live(t) || !live(u) || find(t) === find(u)) continue;
173
+ if (!hard(t, u)) soft[find(t)] = 1;
174
+ if (!hard(u, t)) soft[find(u)] = 1;
175
+ }
176
+
177
+ // one area-weighted plane per surviving group, merged where two groups share a
178
+ // plane (a degenerate triangle can split one face into two)
179
+ const acc = new Map();
180
+ for (let t = 0; t < nTri; t++) {
181
+ if (!live(t)) continue;
182
+ const root = find(t);
183
+ if (soft[root]) continue;
184
+ let e = acc.get(root);
185
+ if (!e) acc.set(root, (e = { n: [0, 0, 0], c: [0, 0, 0], a: 0 }));
186
+ for (let k = 0; k < 3; k++) { e.n[k] += fn[t * 3 + k] * area[t]; e.c[k] += cen[t * 3 + k] * area[t]; }
187
+ e.a += area[t];
188
+ }
189
+ const dTol = Math.max(1e-6, span * 1e-7);
190
+ const planes = [];
191
+ for (const e of acc.values()) {
192
+ const L = Math.hypot(...e.n);
193
+ if (!(L > 0)) continue;
194
+ const n = e.n.map((x) => x / L);
195
+ const d = (n[0] * e.c[0] + n[1] * e.c[1] + n[2] * e.c[2]) / e.a;
196
+ const dup = planes.find((p) => p.n[0] * n[0] + p.n[1] * n[1] + p.n[2] * n[2] > PLANE_COS && Math.abs(p.d - d) < dTol);
197
+ if (!dup) planes.push({ n, d });
198
+ }
199
+ return planes;
200
+ }
201
+
86
202
  // ---------------------------------------------------------------------------
87
203
  // Prism detection for the fast path (manifold-backend.js). The Minkowski chain
88
204
  // above is seconds-per-thousand-triangles, but roundAll's expensive real-world