partforge 0.45.0 → 0.46.2

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.
@@ -234,7 +234,7 @@ future contract v2 — but are not shown here; see `docs/KERNEL-CONTRACT.md`
234
234
  | `k.box({ size, center? })` · `k.box({ min, max })` | `{size:[x,y,z]}` = centered X/Y, base at z=0 (`center:true` also centers Z); `{min,max}` = explicit `[x,y,z]` corners |
235
235
  | `k.prism({ points, h, twist?, scaleTop? })` | extrude a 2-D polygon (or an **arc profile** from `roundedProfile`) from z=0; optional `twist` (degrees over the height) and `scaleTop` (uniform top taper: 1 straight, <1 taper in, 0 → point/cone) |
236
236
  | `k.extrude({ profile, h, twist?, scaleTop? })` | extrude a **polygon-with-holes** region from z=0 in one op — `profile` is `{ outer, holes? }` where each contour is a points array **or an arc profile** (`roundedProfile`, for true STEP fillets), or a bare points array / arc profile for outer-only; same `twist`/`scaleTop` as `prism` (both backends) |
237
- | `k.loft({ rings, ruled?, closed? })` | stack polygon cross-sections into a solid — ruled walls between consecutive rings, capped ends (both backends; `closed:true` capless loops are Manifold-only). `ruled:false` (smooth C2 blend) is honoured only by OCCT/STEP export; the Manifold preview always shows faceted straight walls |
237
+ | `k.loft({ rings, ruled?, closed?, shading? })` | stack polygon cross-sections into a solid — ruled walls between consecutive rings, capped ends (both backends; `closed:true` capless loops are Manifold-only). `ruled:false` (smooth C2 blend) is honoured only by OCCT/STEP export; the Manifold preview always shows faceted straight walls. `shading?: "smooth" \| "faceted"` overrides facet/smooth shading inference (default: <32-side rings shade as flat facets, drawing no same-surface lines at all — not even their own cap rims — though cut seams against other solids still draw; ≥32 sides shade smooth) |
238
238
  | `k.sweep({ profile, path, cornerRadius?, closed?, ruled?, smooth? })` | sweep a fixed 2-D profile along a 3-D polyline path — sharp mitered corners (or `cornerRadius` fillets), capped ends (both backends). `closed:true` capless loops and `smooth:true` (OCCT-native swept B-rep, STEP-exact / preview-faceted) are backend-specific, like loft's `closed`/`ruled:false`. `closed:true` loops must be **planar** — RMF frame-transport holonomy can seam-twist a non-planar closed loop where the last station rejoins the first, so only planar closed loops are supported/tested |
239
239
  | `k.sphere({ r\|d })` | sphere centred at the origin; bare `k.sphere(r)` also stays valid |
240
240
  | `k.roundedBox({ size, center?, round })` | box with rounded edges — `round` = number (all edges) or `{ side?, top?, bottom? }` (vertical edges / rims); stays on Manifold (no OCCT routing, unlike `fillet`); `side` must be 0 or ≥ the rim radii (between clamps with a warning); with `side > 0`, `top + bottom` must be strictly `< h` |
@@ -398,6 +398,10 @@ if (p.drain > 0) s = s.cut(k.cylinder({ r: d.drainR, h: p.floor + 4 }).at([0, 0,
398
398
  a cutting tool's label lands on the faces it leaves behind (the hole's wall).
399
399
  - Label **after** shaping compound tools (e.g. after an `intersect` clip) and
400
400
  either before or after transforms — labels ride through `at`/`rotate`/etc.
401
+ Labeling a compound collapses it to ONE shading surface — the majority
402
+ policy of its registered surfaces (by triangle count) applies to the whole
403
+ solid, so a faceted policy also suppresses line-drawing on the compound's
404
+ internal seams.
401
405
  - **Same label merges; distinct siblings need distinct names.** The same label on
402
406
  several solids merges into one feature — label a ring of four bolt holes
403
407
  `"Mounting holes"` and they hover/highlight as one. Conversely, when two similar
@@ -1411,6 +1415,13 @@ everything else (so sweep-heavy parts, e.g. helical grooves, stay fast). Force i
1411
1415
  `meta.backend: "occt" | "manifold"` if you ever need to. Because an OCCT part is built
1412
1416
  entirely on OCCT, its fillets are exact in the STEP **and** present in the printed STL.
1413
1417
 
1418
+ **Shading intent.** The kernel decides what shades smooth and where edge lines
1419
+ draw — spheres, cylinders and fillets are smooth by construction; boolean cut
1420
+ seams always shade hard and draw a line; a loft's facets shade flat when its
1421
+ rings have fewer than 32 sides (`shading: "smooth"|"faceted"` on `k.loft`
1422
+ overrides the inference either way). If your part previews smooth but would
1423
+ print faceted — or the reverse — set the hint rather than changing facet counts.
1424
+
1414
1425
  > Trade-off: OCCT is much slower on heavy swept geometry (helical grooves), so don't reach for
1415
1426
  > `fillet`/`chamfer` on a sweep-heavy part — design those edges in, or keep the part on Manifold.
1416
1427
 
@@ -318,6 +318,25 @@ Variant literals under this entry: `offsetPolygon: delta must be a finite number
318
318
  - **Cause:** A track drives a param that feeds real geometry (or a build the pose probe can't trust — a query op or function selector), so every frame is a worker rebuild instead of a pose repair.
319
319
  - **Fix:** Run `npx partforge lint <part>` — the `animation-track-rebuilds` note names the track. Restructure so the param only feeds rigid placement (`place()` or a trailing translate/rotate in `build`), or accept best-effort playback if geometry morphing is the intent. See [AUTHORING-PARTS.md](AUTHORING-PARTS.md) § "Animations".
320
320
 
321
+ ## phantom-edges-on-curved-surface
322
+
323
+ - **Symptom:** edge lines or hard-shaded patches appear scattered on a smooth
324
+ curved surface (a sphere, fillet, or blend) in the viewer or in `render` PNGs.
325
+ - **Cause:** the mesh reached the viewer without kernel `normals`/`edges`, so a
326
+ consumer fell back to dihedral-angle guessing on coarse preview tessellation.
327
+ - **Fix:** the backend's `toMesh` must return analytic normals and filtered
328
+ feature edges ([KERNEL-CONTRACT.md](KERNEL-CONTRACT.md) "Shading intent") —
329
+ fix the backend or payload plumbing; do not tune viewer angle thresholds.
330
+
331
+ ## faceted-loft-previews-smooth
332
+
333
+ - **Symptom:** an intentionally faceted loft (low-side-count rings) previews
334
+ smooth-shaded, but exports/prints show flat facets.
335
+ - **Cause:** the loft's shading policy resolved to smooth — a `shading:
336
+ "smooth"` hint, `ruled: false`, or rings with 32+ sides.
337
+ - **Fix:** pass `shading: "faceted"` to `k.loft` (or drop the smooth-implying
338
+ option) per [AUTHORING-PARTS.md](AUTHORING-PARTS.md) shading-intent note.
339
+
321
340
  # Hardware library
322
341
 
323
342
  Reserved for `hardware-*` patterns (issue #30). No entries yet.
@@ -258,7 +258,7 @@ Normative signatures: `kernel.js`'s `@typedef Solid`.
258
258
  | `boundingBox()` | `{min, max, center, size}`; `center`/`size` are derived by `addSugar` from the backend's `{min, max}`. |
259
259
  | `volume()` | Solid volume in mm³. |
260
260
  | `genus()` / `isEmpty()` | Optional (`SOLID_OPTIONAL_OPS`): mesh-topology queries — through-hole count / no-geometry test. The mesh backend provides them; OCCT has no cheap equivalent. |
261
- | `toMesh({quality?})` | Render mesh: `{positions, normals, indices?, triangles, edges?, featureIds?, features?}`. `indices` optional (a backend may emit soup or indexed); `normals` may be empty (`length 0`) to delegate creasing to the viewer; `edges` (feature-line segments) and the feature fields are optional metadata. |
261
+ | `toMesh({quality?})` | Render mesh: `{positions, normals, indices?, triangles, edges?, featureIds?, features?}`. `indices` optional (a backend may emit soup or indexed); `normals` and `edges` are authoritative shading intent from both backends see [Shading intent](#shading-intent-tomesh-normals-and-edges) below; `featureIds`/`features` are optional metadata. |
262
262
  | `toSTL({quality?})` | `Promise<ArrayBuffer>`, binary STL, outward CCW winding. Stored facet normals may be zero — slicers recompute them (the mesh backend happens to write them). |
263
263
  | `toIndexedMesh({quality?})` | `{positions, indices}` indexed mesh (3MF path); defaults to `"print"` like `toSTL`. Coincident vertices need NOT be welded — the 3MF writer welds, because that format reads topology from the indices rather than re-stitching soup by position the way an STL consumer does. |
264
264
  | `fillet(r)` · `fillet({r, edges?})` / `chamfer(d)` · `chamfer({d, edges?})` / `shell({t, open})` | B-rep class (core throws `KernelCapabilityError`). Scalar `fillet(3)`/`chamfer(1)` acts on all edges; the options form adds an `edges` selector. `shell` hollows inward, keeping outer dimensions; `open` (face selector) is required. |
@@ -267,6 +267,39 @@ Normative signatures: `kernel.js`'s `@typedef Solid`.
267
267
  speed and a backend may bake it at kernel creation (Manifold does). A part must never
268
268
  depend on triangle counts, segment counts, or normals being present.
269
269
 
270
+ ### Shading intent (toMesh normals and edges)
271
+
272
+ `toMesh` output is the authoritative statement of how a solid SHADES and which
273
+ edges are FEATURE edges — consumers (viewer, CLI renderer) must draw what they
274
+ are given and must not re-derive either from dihedral angles when the fields
275
+ are present:
276
+
277
+ - `normals` — per-vertex shading normals. Smooth within one surface, hard
278
+ across boolean-cut seams. OCCT ships analytic B-rep normals; Manifold ships
279
+ the policy-aware crease pass (`src/framework/geometry/creased-normals.js`).
280
+ - `edges` — flat feature-edge segment pairs (6 floats per segment). An EMPTY
281
+ array means "this solid has no feature edges"; it is not "unknown". OCCT
282
+ ships true B-rep edges with tangent edges (fillet blends, seam lines)
283
+ filtered out; Manifold ships policy-gated sharp/seam segments.
284
+
285
+ `loft` accepts `shading?: "smooth" | "faceted"` to override facet-vs-smooth
286
+ inference: by default, rings with fewer than 32 sides shade as intentional flat
287
+ facets with no same-surface edge lines, while rings with 32+ sides (and
288
+ `ruled: false` lofts) shade smooth. `shading: "smooth"` forces smooth shading;
289
+ `shading: "faceted"` forces facets; any other non-nullish value throws.
290
+ Thresholds live in `src/framework/geometry/shading-policy.js`.
291
+
292
+ Known limitation: the OCCT backend ignores `shading` — a loft forced to OCCT
293
+ via `meta.backend` draws its facet corner edges as B-rep feature lines. The
294
+ hint is honored on the Manifold path, which is where lofts preview by default.
295
+
296
+ `label()`ing a compound solid (one spanning more than one original surface)
297
+ collapses it to a single shading surface that inherits the majority policy of
298
+ its registered constituent surfaces, weighted by triangle count. A constituent
299
+ with no registered policy of its own (e.g. a plain boolean tool) still votes,
300
+ as SMOOTH — the policy it actually renders with — and an exact tie resolves to
301
+ the no-lines (faceted) policy.
302
+
270
303
  **Selectors** (`fillet`/`chamfer` `edges` selector, `shell` `open` face selector) are
271
304
  declarative objects, criteria AND-combined:
272
305
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.45.0",
3
+ "version": "0.46.2",
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",
@@ -0,0 +1,124 @@
1
+ // Filter replicad meshEdges() down to genuinely sharp feature edges using the
2
+ // analytic per-vertex normals from mesh(). A B-rep edge whose adjacent faces
3
+ // meet tangentially (fillet blend boundaries, closed-surface seam lines) is
4
+ // not a visual feature — drop it, so a sphere or fillet never draws phantom
5
+ // lines. Plain arrays in, plain arrays out; no OCCT required (unit-testable).
6
+ //
7
+ // Format facts this relies on (replicad):
8
+ // - mesh() vertices are concatenated per face: boundary points are duplicated,
9
+ // one copy per adjacent face, each carrying that face's analytic normal, and
10
+ // edge polyline nodes reuse the exact face-triangulation coordinates — so an
11
+ // exact-position key connects an edge point to every adjacent face normal.
12
+ // - mesh() also returns `triangles` (flat vertex-index list) and `faceGroups`
13
+ // ({start,count,faceId} spans into `triangles`, in index units) — every
14
+ // vertex index belongs to exactly one face, so this gives a vertex→faceId map.
15
+ // - meshEdges().lines is already flat segment PAIRS ((p0,p1),(p1,p2),…);
16
+ // edgeGroups {start,count} span one B-rep edge, in points (count = 2·segs).
17
+ import { TANGENT_ANGLE, MIN_EDGE, cosDeg } from "./shading-policy.js";
18
+
19
+ const TANGENT_COS = cosDeg(TANGENT_ANGLE);
20
+ const MIN_EDGE2 = MIN_EDGE * MIN_EDGE;
21
+
22
+ export function filterBrepEdges(mesh, meshEdges) {
23
+ const { vertices, normals, triangles = [], faceGroups = [] } = mesh;
24
+ const { lines, edgeGroups } = meshEdges;
25
+
26
+ // vertex index → owning face's id (-1 if unknown/not supplied).
27
+ const vface = new Int32Array(vertices.length / 3).fill(-1);
28
+ for (const fg of faceGroups)
29
+ for (let i = fg.start; i < fg.start + fg.count; i++) vface[triangles[i]] = fg.faceId;
30
+
31
+ // exact-position key → [nx, ny, nz, faceId] for every face copy of that vertex
32
+ const byPos = new Map();
33
+ for (let i = 0; i + 2 < vertices.length; i += 3) {
34
+ const key = `${vertices[i]},${vertices[i + 1]},${vertices[i + 2]}`;
35
+ let arr = byPos.get(key);
36
+ if (!arr) byPos.set(key, arr = []);
37
+ arr.push([normals[i], normals[i + 1], normals[i + 2], vface[i / 3]]);
38
+ }
39
+
40
+ // Any pair of entries in `ns` whose normals disagree past TANGENT_COS makes the sample sharp.
41
+ const disagrees = (ns) => {
42
+ for (let a = 0; a < ns.length; a++)
43
+ for (let b = a + 1; b < ns.length; b++) {
44
+ const dot = ns[a][0] * ns[b][0] + ns[a][1] * ns[b][1] + ns[a][2] * ns[b][2];
45
+ if (dot < TANGENT_COS) return true;
46
+ }
47
+ return false;
48
+ };
49
+ const normalsAt = (g, p) => {
50
+ const o = (g.start + p) * 3;
51
+ return byPos.get(`${lines[o]},${lines[o + 1]},${lines[o + 2]}`);
52
+ };
53
+
54
+ const out = [];
55
+ for (const g of edgeGroups) {
56
+ if (g.count < 2) continue;
57
+ // Sharp iff a sample sees two adjacent-face normals disagreeing past
58
+ // TANGENT_COS. A point with fewer than two known normals is inconclusive;
59
+ // an edge with no conclusive point is KEPT: a spurious line is visible and
60
+ // debuggable, a missing feature edge is not.
61
+ let sharp = false, conclusive = false;
62
+ if (g.count > 2) {
63
+ // Interior points are free of corner contamination — group ENDPOINTS are
64
+ // corners that also touch a third face, so skip them here.
65
+ for (let p = 1; p <= g.count - 2 && !sharp; p++) {
66
+ const ns = normalsAt(g, p);
67
+ if (!ns || ns.length < 2) continue;
68
+ conclusive = true;
69
+ if (disagrees(ns)) sharp = true;
70
+ }
71
+ } else {
72
+ // No interior points exist — BOTH samples are corners, each possibly
73
+ // touching its OWN unrelated third face (e.g. a fillet seam's top rim
74
+ // touches the top cap, its bottom rim touches the bottom cap). Matching
75
+ // normals BY DIRECTION across the two corners (near-parallel) is unsound:
76
+ // a non-developable adjacent face's normal can legitimately swing along
77
+ // the edge, so it fails to "match itself" between corners, while an
78
+ // unrelated coplanar fragment touching only one corner can spuriously
79
+ // match — silently reading a genuinely sharp edge as tangent (fail-
80
+ // invisible, which this module must never do). Key persistence on FACE
81
+ // IDENTITY instead: the edge's two true adjacent faces are whichever
82
+ // faceIds are actually present at BOTH corners, independent of how much
83
+ // their normal varies between the two samples.
84
+ //
85
+ // A closed surface's seam ruling (cylinder/cone/bore) has the SAME
86
+ // faceId on both sides of the seam, so a corner can carry two copies of
87
+ // one persisting id rather than two distinct ids. Count persistence as a
88
+ // MULTISET intersection (tally occurrences per corner, sum the min per
89
+ // shared id) so that case still reaches persisting >= 2, instead of a
90
+ // Set intersection whose size tops out at 1 for a single repeated id.
91
+ // This assumes every adjacent face contributes a vertex copy at each
92
+ // corner it touches (replicad guarantees this); if that ever didn't
93
+ // hold, a corner could show persistence only against itself and this
94
+ // could drop a genuinely sharp edge.
95
+ const n0 = normalsAt(g, 0), n1 = normalsAt(g, 1);
96
+ if (n0 && n1) {
97
+ const count0 = new Map();
98
+ for (const e of n0) if (e[3] !== -1) count0.set(e[3], (count0.get(e[3]) || 0) + 1);
99
+ const count1 = new Map();
100
+ for (const e of n1) if (e[3] !== -1) count1.set(e[3], (count1.get(e[3]) || 0) + 1);
101
+ const sharedIds = new Set();
102
+ let persisting = 0;
103
+ for (const [id, c0] of count0) {
104
+ const c1 = count1.get(id);
105
+ if (c1) { persisting += Math.min(c0, c1); sharedIds.add(id); }
106
+ }
107
+ if (persisting >= 2) {
108
+ conclusive = true;
109
+ const atSharedFaces = (ns) => ns.filter((e) => sharedIds.has(e[3]));
110
+ sharp = disagrees(atSharedFaces(n0)) || disagrees(atSharedFaces(n1));
111
+ } // else: fewer than 2 confirmed persisting face copies — inconclusive, KEPT
112
+ } // else: one or both corners have no evidence at all — inconclusive, KEPT (fail-open)
113
+ }
114
+ if (conclusive && !sharp) continue; // tangent edge — not a visual feature
115
+
116
+ for (let p = 0; p + 1 < g.count; p += 2) { // lines is segment pairs — step 2 points
117
+ const a = (g.start + p) * 3, b = (g.start + p + 1) * 3;
118
+ const dx = lines[a] - lines[b], dy = lines[a + 1] - lines[b + 1], dz = lines[a + 2] - lines[b + 2];
119
+ if (dx * dx + dy * dy + dz * dz < MIN_EDGE2) continue; // degenerate sliver / pole edge
120
+ out.push(lines[a], lines[a + 1], lines[a + 2], lines[b], lines[b + 1], lines[b + 2]);
121
+ }
122
+ }
123
+ return Float32Array.from(out);
124
+ }
@@ -0,0 +1,131 @@
1
+ // Policy-aware crease pass for Manifold meshes — moved out of the backend so it
2
+ // is unit-testable on plain arrays without booting WASM. Builds a non-indexed
3
+ // mesh with normals that are smooth within a single original surface but HARD
4
+ // across boolean-cut seams. Manifold's runOriginalID tells us which input solid
5
+ // each triangle came from; we average a corner's face normals only over
6
+ // incident triangles of the SAME original surface that also meet within that
7
+ // surface's policy creaseAngle — so cut seams stay crisp at any angle (even
8
+ // near-tangent), and a surface's own sharp edges stay crisp too. Each original
9
+ // surface may carry a shading policy (shading-policy.js); surfaces without one
10
+ // use SMOOTH, which reproduces the pre-policy behavior exactly.
11
+ import { SMOOTH, COPLANAR_ANGLE, MIN_EDGE, cosDeg } from "./shading-policy.js";
12
+
13
+ const COPLANAR_COS = cosDeg(COPLANAR_ANGLE);
14
+ const MIN_EDGE2 = MIN_EDGE * MIN_EDGE;
15
+
16
+ export function creasedNormals(g, { policies = null, featureLabels = null } = {}) {
17
+ const np = g.numProp, vp = g.vertProperties, tris = g.triVerts;
18
+ const nTri = (tris.length / 3) | 0, nVert = (vp.length / np) | 0;
19
+
20
+ // per-OID policy lookup with a cached cosine per OID
21
+ const polFor = (oid) => (policies && policies.get(oid)) || SMOOTH;
22
+ const cosCache = new Map();
23
+ const cosFor = (oid) => {
24
+ let c = cosCache.get(oid);
25
+ if (c === undefined) { c = cosDeg(polFor(oid).creaseAngle); cosCache.set(oid, c); }
26
+ return c;
27
+ };
28
+
29
+ // unify any coincident vertices Manifold kept separate, for adjacency
30
+ const remap = new Uint32Array(nVert);
31
+ for (let i = 0; i < nVert; i++) remap[i] = i;
32
+ const mf = g.mergeFromVert, mt = g.mergeToVert;
33
+ if (mf && mt) for (let i = 0; i < mf.length; i++) remap[mf[i]] = mt[i];
34
+
35
+ // per-triangle original-surface id, from the run table
36
+ const triOID = new Uint32Array(nTri);
37
+ const ri = g.runIndex, roid = g.runOriginalID;
38
+ for (let r = 0; r < roid.length; r++)
39
+ for (let t = ri[r] / 3; t < ri[r + 1] / 3; t++) triOID[t] = roid[r];
40
+
41
+ // per-triangle face normals
42
+ const fn = new Float32Array(nTri * 3);
43
+ for (let t = 0; t < nTri; t++) {
44
+ const a = tris[t * 3] * np, b = tris[t * 3 + 1] * np, c = tris[t * 3 + 2] * np;
45
+ const ux = vp[b] - vp[a], uy = vp[b + 1] - vp[a + 1], uz = vp[b + 2] - vp[a + 2];
46
+ const vx = vp[c] - vp[a], vy = vp[c + 1] - vp[a + 1], vz = vp[c + 2] - vp[a + 2];
47
+ const nx = uy * vz - uz * vy, ny = uz * vx - ux * vz, nz = ux * vy - uy * vx;
48
+ const L = Math.hypot(nx, ny, nz) || 1;
49
+ fn[t * 3] = nx / L; fn[t * 3 + 1] = ny / L; fn[t * 3 + 2] = nz / L;
50
+ }
51
+
52
+ // canonical vertex → incident triangles
53
+ const incident = new Map();
54
+ for (let t = 0; t < nTri; t++)
55
+ for (let k = 0; k < 3; k++) {
56
+ const cv = remap[tris[t * 3 + k]];
57
+ const arr = incident.get(cv);
58
+ if (arr) arr.push(t); else incident.set(cv, [t]);
59
+ }
60
+
61
+ const positions = new Float32Array(nTri * 9);
62
+ const normals = new Float32Array(nTri * 9);
63
+ for (let t = 0; t < nTri; t++) {
64
+ const fx = fn[t * 3], fy = fn[t * 3 + 1], fz = fn[t * 3 + 2], oid = triOID[t];
65
+ const sharpCos = cosFor(oid); // per-surface crease threshold
66
+ for (let k = 0; k < 3; k++) {
67
+ const v = tris[t * 3 + k];
68
+ let nx = 0, ny = 0, nz = 0;
69
+ for (const t2 of incident.get(remap[v])) {
70
+ if (triOID[t2] !== oid) continue; // different cut surface → hard
71
+ if (fn[t2 * 3] * fx + fn[t2 * 3 + 1] * fy + fn[t2 * 3 + 2] * fz < sharpCos) continue; // sharp same-surface edge → hard
72
+ nx += fn[t2 * 3]; ny += fn[t2 * 3 + 1]; nz += fn[t2 * 3 + 2];
73
+ }
74
+ const L = Math.hypot(nx, ny, nz) || 1;
75
+ const o = (t * 3 + k) * 3, vv = v * np;
76
+ positions[o] = vp[vv]; positions[o + 1] = vp[vv + 1]; positions[o + 2] = vp[vv + 2];
77
+ normals[o] = nx / L; normals[o + 1] = ny / L; normals[o + 2] = nz / L;
78
+ }
79
+ }
80
+
81
+ // Feature edge segments for CAD-style edge lines: draw a line where the
82
+ // surface actually BENDS. Same-surface edges draw per the surface's policy
83
+ // (sharper than creaseAngle, and only if the policy wants same-surface lines
84
+ // at all — intentional facets shade flat with no wireframe). Cut seams
85
+ // (different original surface) draw when they bend more than COPLANAR_ANGLE;
86
+ // coplanar seams get no line, and curved-surface facets are skipped.
87
+ const edges = [];
88
+ const seenEdge = new Map(); // edge key → first incident triangle
89
+ for (let t = 0; t < nTri; t++)
90
+ for (let e = 0; e < 3; e++) {
91
+ const i = remap[tris[t * 3 + e]], j = remap[tris[t * 3 + ((e + 1) % 3)]];
92
+ if (i === j) continue;
93
+ const key = i < j ? i * nVert + j : j * nVert + i;
94
+ const prev = seenEdge.get(key);
95
+ if (prev === undefined) { seenEdge.set(key, t); continue; }
96
+ seenEdge.delete(key);
97
+ const dot = fn[prev * 3] * fn[t * 3] + fn[prev * 3 + 1] * fn[t * 3 + 1] + fn[prev * 3 + 2] * fn[t * 3 + 2];
98
+ // policy-gated same-surface lines; seam rule unchanged
99
+ const hard = triOID[prev] === triOID[t]
100
+ ? polFor(triOID[t]).sameSurfaceLines && dot < cosFor(triOID[t])
101
+ : dot < COPLANAR_COS;
102
+ if (hard) {
103
+ const ai = i * np, bj = j * np;
104
+ const dx = vp[ai] - vp[bj], dy = vp[ai + 1] - vp[bj + 1], dz = vp[ai + 2] - vp[bj + 2];
105
+ if (dx * dx + dy * dy + dz * dz >= MIN_EDGE2) // skip degenerate sliver segments (noise)
106
+ edges.push(vp[ai], vp[ai + 1], vp[ai + 2], vp[bj], vp[bj + 1], vp[bj + 2]);
107
+ }
108
+ }
109
+
110
+ // Per-triangle feature attribution: map each triangle's original-surface id
111
+ // through the label registry. Same label string → same feature entry, so a
112
+ // pattern of solids labeled alike reads as one feature.
113
+ let featureIds = null, features = null;
114
+ if (featureLabels?.size) {
115
+ const indexOf = new Map(); // label string -> 1-based feature index
116
+ features = [];
117
+ featureIds = new Uint16Array(nTri);
118
+ for (let t = 0; t < nTri; t++) {
119
+ const label = featureLabels.get(triOID[t]);
120
+ if (label === undefined) continue;
121
+ let fi = indexOf.get(label);
122
+ if (fi === undefined) { features.push(label); fi = features.length; indexOf.set(label, fi); }
123
+ featureIds[t] = fi;
124
+ }
125
+ if (features.length === 0) { featureIds = features = null; } // labels exist in the kernel, none in THIS mesh
126
+ }
127
+
128
+ const out = { positions, normals, triangles: nTri, edges: Float32Array.from(edges) }; // mesh non-indexed
129
+ if (featureIds) { out.featureIds = featureIds; out.features = features; }
130
+ return out;
131
+ }
@@ -76,7 +76,7 @@ export const OCCT_ONLY_OPS = ["fillet", "chamfer", "shell"];
76
76
  * @property {(factor:number, center?:number[]) => Solid} scale uniform scale about center (default origin)
77
77
  * @property {() => number} volume solid volume in mm³ (both backends; used by collision/overlap tests)
78
78
  * @property {(opts?: {quality?: "preview"|"print"}) => {positions:Float32Array, normals:Float32Array, indices?:Uint32Array, triangles:number, edges?:Float32Array}} toMesh
79
- * `edges` = feature-edge line segments (Manifold); quality is advisory — the Manifold kernel bakes it at creation
79
+ * `normals`/`edges` are authoritative shading intent from both backends — see docs/KERNEL-CONTRACT.md "Shading intent"; quality is advisory — the Manifold kernel bakes it at creation
80
80
  * @property {(opts?: {quality?: "preview"|"print"}) => Promise<ArrayBuffer>} toSTL
81
81
  * @property {() => {positions:Float32Array, indices:Uint32Array}} toIndexedMesh indexed mesh, for 3MF
82
82
  * @property {(r:number|{r:number,edges?:object}) => Solid} fillet round edges (OCCT only); fillet(3) or fillet({r,edges}); legacy (r,selector) accepted until v2
@@ -106,7 +106,7 @@ export const OCCT_ONLY_OPS = ["fillet", "chamfer", "shell"];
106
106
  * @property {(o:{size?:number[],center?:boolean,min?:number[],max?:number[]}) => Solid} box {size} = centered X/Y, base z=0 ({center:true} centers Z too) or {min,max}; legacy (min,max) accepted until v2
107
107
  * @property {(o:{points:number[][],h:number,twist?:number,scaleTop?:number}) => Solid} prism extrude polygon from z=0; legacy (points,h,opts) accepted until v2
108
108
  * @property {(o:{profile:number[][]|{outer:number[][],holes?:number[][][]},h:number,twist?:number,scaleTop?:number,bevel?:number|{bottom?:number,top?:number}}) => Solid} extrude polygon-with-holes region from z=0; bevel = 45° rim bevel (any profile form incl. Shape2D, materialized to point rings; no twist/scaleTop); legacy (profile,h,opts) accepted until v2
109
- * @property {(o:{rings:{polygon?:number[][],sides?:number,radius?:number,z:number,rotate?:number,scale?:number|number[]}[],ruled?:boolean,closed?:boolean}) => Solid} loft stack polygon cross-sections; legacy (rings,opts) accepted until v2
109
+ * @property {(o:{rings:{polygon?:number[][],sides?:number,radius?:number,z:number,rotate?:number,scale?:number|number[]}[],ruled?:boolean,closed?:boolean,shading?:"smooth"|"faceted"}) => Solid} loft stack polygon cross-sections; shading overrides facet-vs-smooth shading inference; legacy (rings,opts) accepted until v2
110
110
  * @property {(o:{profile:number[][],path:number[][],closed?:boolean,cornerRadius?:number,ruled?:boolean,smooth?:boolean}) => Solid} sweep sweep a 2-D profile along a 3-D polyline; legacy (profile,path,opts) accepted until v2
111
111
  * @property {(o:{profile:number[][],degrees?:number}) => Solid} revolve revolve a lathe profile [[r,z],…] around Z; legacy (points,opts) accepted until v2
112
112
  * @property {(o:{pathR:number,profileR:number,pitch:number,turns:number,z0:number,lefthand:boolean}) => Solid} helixSweptTube
@@ -10,15 +10,14 @@ import { addShape2dSugar } from "./shape2d-sugar.js";
10
10
  import { assembleRegions } from "./shape2d-regions.js";
11
11
  import { finishKernel } from "./kernel-front.js";
12
12
  import { meshToStl } from "./mesh-stl.js";
13
+ import { creasedNormals } from "./creased-normals.js";
14
+ import { loftShadingPolicy, SMOOTH } from "./shading-policy.js";
13
15
 
14
16
  const PLANE_NORMAL = { XY: [0, 0, 1], XZ: [0, 1, 0], YZ: [1, 0, 0] };
15
17
  // 'preview' = interactive view (fast); 'print' = STL export (high-res, used only
16
18
  // by the export path — Manifold meshing is cheap, so we tessellate generously).
17
19
  const SEGS = { preview: 116, print: 480 }; // circular segments
18
20
  const TUBE = { preview: { stationsPerTurn: 38, ringSegs: 24 }, print: { stationsPerTurn: 160, ringSegs: 40 } };
19
- const SHARP_ANGLE = 35; // deg — same-surface edges sharper than this shade hard (cut seams are always hard)
20
- const COPLANAR_COS = Math.cos((5 * Math.PI) / 180); // edge lines: skip cut seams that bend less than 5° (coplanar)
21
- const MIN_EDGE2 = 0.01 * 0.01; // edge lines: drop sub-0.01mm segments (degenerate boolean slivers, not real features)
22
21
 
23
22
  // true axis-angle rotation as a column-major 4x4 (manifold Mat4), translation 0
24
23
  function axisAngleMat4(axis, deg) {
@@ -46,6 +45,7 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
46
45
 
47
46
  const cache = createSolidCache();
48
47
  const featureLabels = new Map(); // originalID -> label string (grows per label(); tiny)
48
+ const oidPolicies = new Map(); // originalID -> shading policy (grows per faceted/hinted loft; tiny)
49
49
  // Boundary ops route through cache.lookup; on a miss `make` runs the WASM op,
50
50
  // tracks the result, and returns the triple the cache needs to pin/dispose it.
51
51
  const cached = (hash, computeM) => cache.lookup(hash, () => {
@@ -114,7 +114,7 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
114
114
  // transient mesh handle.
115
115
  function meshOut(m, asStl) {
116
116
  const g = m.getMesh();
117
- const r = asStl ? stlFromMesh(g) : creasedNormals(g, Math.cos((SHARP_ANGLE * Math.PI) / 180), featureLabels);
117
+ const r = asStl ? stlFromMesh(g) : creasedNormals(g, { policies: oidPolicies, featureLabels });
118
118
  g.delete?.();
119
119
  return r;
120
120
  }
@@ -153,10 +153,56 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
153
153
  label: (name) => {
154
154
  const lh = h("label", hash, name);
155
155
  return cache.lookup(lh, () => {
156
+ const prevId = typeof m.originalID === "function" ? m.originalID() : -1;
156
157
  const o = T(m.asOriginal());
157
158
  const id = o.originalID();
158
159
  featureLabels.set(id, name);
159
- return { value: wrap(o, lh), pin: o, dispose: () => { featureLabels.delete(id); o.delete?.(); } };
160
+ // labeling re-stamps the originalID carry the surface's shading policy along.
161
+ // A boolean upstream of this label() (e.g. a faceted loft().intersect(tool),
162
+ // as the vase does to hollow itself) leaves the solid spanning more than one
163
+ // original surface, so originalID() reports -1 ("mixed") rather than a single
164
+ // id — the direct lookup below misses even though the loft's policy is right
165
+ // there. Fall back to the mesh's own run table and recover it via a
166
+ // triangle-count-weighted majority vote across all surfaces feeding this
167
+ // mesh (a plain tool like a box has no registered policy of its own, but
168
+ // still votes SMOOTH — see below).
169
+ let inherited = prevId !== -1 ? oidPolicies.get(prevId) : undefined;
170
+ // Skip the mesh scan entirely when no surface anywhere has a registered
171
+ // policy (e.g. planter's labeled prism compound) — getMesh() forces a
172
+ // full mesh materialization and parts with no lofts must not pay for it.
173
+ if (inherited === undefined && prevId === -1 && oidPolicies.size > 0) {
174
+ const g = m.getMesh();
175
+ // Triangle-count-weighted majority: walk the run table with runIndex
176
+ // (each run r spans triangles ri[r]/3..ri[r+1]/3 — same arithmetic
177
+ // creased-normals.js uses) and tally triangle counts per registered
178
+ // policy, keyed by VALUE (creaseAngle/sameSurfaceLines), not object
179
+ // reference — a majority-by-object-identity check would silently
180
+ // break if policies were ever constructed per-op instead of shared
181
+ // singletons. A run whose original surface has NO registered policy
182
+ // (a plain boolean tool, e.g. a box) still gets a vote: at render
183
+ // time an unregistered surface shades SMOOTH (buildGeometry's
184
+ // default), so counting it as an abstention would let the vote
185
+ // disagree with what's actually drawn — it contributes its triangle
186
+ // weight to SMOOTH instead. The policy spanning the most triangles
187
+ // wins; an exact tie favors the FACETED-like policy
188
+ // (sameSurfaceLines: false) — deterministic, and biased toward
189
+ // honest-print rendering over silently smoothing facets away.
190
+ const ri = g.runIndex, roid = g.runOriginalID;
191
+ const weightByKey = new Map(); // policy key -> triangle count
192
+ let bestWeight = -1, bestPol;
193
+ for (let r = 0; r < roid.length; r++) {
194
+ const pol = oidPolicies.get(roid[r]) ?? SMOOTH;
195
+ const key = `${pol.creaseAngle}/${pol.sameSurfaceLines}`;
196
+ const weight = (weightByKey.get(key) || 0) + (ri[r + 1] / 3 - ri[r] / 3);
197
+ weightByKey.set(key, weight);
198
+ const better = weight > bestWeight || (weight === bestWeight && !pol.sameSurfaceLines && bestPol?.sameSurfaceLines);
199
+ if (better) { bestWeight = weight; bestPol = pol; }
200
+ }
201
+ g.delete?.();
202
+ inherited = bestPol;
203
+ }
204
+ if (inherited !== undefined) oidPolicies.set(id, inherited);
205
+ return { value: wrap(o, lh), pin: o, dispose: () => { featureLabels.delete(id); oidPolicies.delete(id); o.delete?.(); } };
160
206
  });
161
207
  },
162
208
  boundingBox: () => {
@@ -240,8 +286,21 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
240
286
  });
241
287
  },
242
288
  // Ring loft: hand-meshed via the shared ring-mesh helpers (helix-tube recipe).
243
- // Cached atomically; the hash folds every ring's points/z/rotate/scale and the opts.
244
- loft: (rings, opts = {}) => cached(h("loft", rings, opts), () => T(loftMesh(wasm, rings, opts))),
289
+ // Cached atomically; the hash folds every ring's points/z/rotate/scale and the
290
+ // opts (including `shading`, so toggling the hint is a fresh cache node).
291
+ // asOriginal() stamps a stable originalID; the shading policy (inferred from
292
+ // the rings, or forced by `shading`) registers under it for the crease pass
293
+ // and lives exactly as long as the cache pins the solid.
294
+ loft: (rings, opts = {}) => {
295
+ const key = h("loft", rings, opts);
296
+ return cache.lookup(key, () => {
297
+ const raw = T(loftMesh(wasm, rings, opts));
298
+ const m = T(raw.asOriginal());
299
+ const id = m.originalID();
300
+ oidPolicies.set(id, loftShadingPolicy(rings, opts));
301
+ return { value: wrap(m, key), pin: m, dispose: () => { oidPolicies.delete(id); m.delete?.(); } };
302
+ });
303
+ },
245
304
  // Sweep a fixed 2-D profile along a 3-D polyline: hand-meshed from the shared station
246
305
  // list (sweep.js), so it agrees with OCCT's ruled loft of the same stations by
247
306
  // construction. Cached atomically; the hash folds profile pts, path pts, and opts
@@ -253,7 +312,12 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
253
312
  return cached(h("revolve", pts._hash, degrees, segs), () => T(pts._cs.revolve(segs, degrees)));
254
313
  return cached(h("revolve", pts, degrees, segs), () => T(Manifold.revolve([pts], segs, degrees)));
255
314
  },
256
- union: (solids) => cached(h("union", solids.map((s) => s._hash)), () => unionRaw(solids.map((s) => s._m))),
315
+ // A one-solid union is an identity no new WASM / cache entry (avoids double-free):
316
+ // unionRaw's reduce returns the operand's own Manifold untouched, so caching it
317
+ // would pin one WASM object under two entries and eviction would dispose it twice.
318
+ union: (solids) => solids.length === 1
319
+ ? solids[0]
320
+ : cached(h("union", solids.map((s) => s._hash)), () => unionRaw(solids.map((s) => s._m))),
257
321
  shape2d,
258
322
  beginSubPart: (name) => cache.begin(name),
259
323
  endSubPart: () => cache.end(),
@@ -267,114 +331,6 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
267
331
  return kernel;
268
332
  }
269
333
 
270
- // Build a non-indexed mesh with normals that are smooth within a single original
271
- // surface but HARD across boolean-cut seams. Manifold's runOriginalID tells us
272
- // which input solid each triangle came from; we average a corner's face normals
273
- // only over incident triangles of the SAME original surface that also meet within
274
- // `sharpCos` — so cut seams stay crisp at any angle (even near-tangent), and a
275
- // surface's own sharp edges (e.g. a face meeting a side) stay crisp too.
276
- function creasedNormals(g, sharpCos, featureLabels) {
277
- const np = g.numProp, vp = g.vertProperties, tris = g.triVerts;
278
- const nTri = (tris.length / 3) | 0, nVert = (vp.length / np) | 0;
279
-
280
- // unify any coincident vertices Manifold kept separate, for adjacency
281
- const remap = new Uint32Array(nVert);
282
- for (let i = 0; i < nVert; i++) remap[i] = i;
283
- const mf = g.mergeFromVert, mt = g.mergeToVert;
284
- if (mf && mt) for (let i = 0; i < mf.length; i++) remap[mf[i]] = mt[i];
285
-
286
- // per-triangle original-surface id, from the run table
287
- const triOID = new Uint32Array(nTri);
288
- const ri = g.runIndex, roid = g.runOriginalID;
289
- for (let r = 0; r < roid.length; r++)
290
- for (let t = ri[r] / 3; t < ri[r + 1] / 3; t++) triOID[t] = roid[r];
291
-
292
- // per-triangle face normals
293
- const fn = new Float32Array(nTri * 3);
294
- for (let t = 0; t < nTri; t++) {
295
- const a = tris[t * 3] * np, b = tris[t * 3 + 1] * np, c = tris[t * 3 + 2] * np;
296
- const ux = vp[b] - vp[a], uy = vp[b + 1] - vp[a + 1], uz = vp[b + 2] - vp[a + 2];
297
- const vx = vp[c] - vp[a], vy = vp[c + 1] - vp[a + 1], vz = vp[c + 2] - vp[a + 2];
298
- const nx = uy * vz - uz * vy, ny = uz * vx - ux * vz, nz = ux * vy - uy * vx;
299
- const L = Math.hypot(nx, ny, nz) || 1;
300
- fn[t * 3] = nx / L; fn[t * 3 + 1] = ny / L; fn[t * 3 + 2] = nz / L;
301
- }
302
-
303
- // canonical vertex → incident triangles
304
- const incident = new Map();
305
- for (let t = 0; t < nTri; t++)
306
- for (let k = 0; k < 3; k++) {
307
- const cv = remap[tris[t * 3 + k]];
308
- const arr = incident.get(cv);
309
- if (arr) arr.push(t); else incident.set(cv, [t]);
310
- }
311
-
312
- const positions = new Float32Array(nTri * 9);
313
- const normals = new Float32Array(nTri * 9);
314
- for (let t = 0; t < nTri; t++) {
315
- const fx = fn[t * 3], fy = fn[t * 3 + 1], fz = fn[t * 3 + 2], oid = triOID[t];
316
- for (let k = 0; k < 3; k++) {
317
- const v = tris[t * 3 + k];
318
- let nx = 0, ny = 0, nz = 0;
319
- for (const t2 of incident.get(remap[v])) {
320
- if (triOID[t2] !== oid) continue; // different cut surface → hard
321
- if (fn[t2 * 3] * fx + fn[t2 * 3 + 1] * fy + fn[t2 * 3 + 2] * fz < sharpCos) continue; // sharp same-surface edge → hard
322
- nx += fn[t2 * 3]; ny += fn[t2 * 3 + 1]; nz += fn[t2 * 3 + 2];
323
- }
324
- const L = Math.hypot(nx, ny, nz) || 1;
325
- const o = (t * 3 + k) * 3, vv = v * np;
326
- positions[o] = vp[vv]; positions[o + 1] = vp[vv + 1]; positions[o + 2] = vp[vv + 2];
327
- normals[o] = nx / L; normals[o + 1] = ny / L; normals[o + 2] = nz / L;
328
- }
329
- }
330
-
331
- // Feature edge segments for CAD-style edge lines: draw a line where the surface
332
- // actually BENDS — a sharp same-surface edge (dihedral past sharpCos), or a cut
333
- // seam (different original surface) that bends more than COPLANAR_COS. Coplanar
334
- // faces — even across a cut seam — get no line, and curved-surface facets are skipped.
335
- const edges = [];
336
- const seenEdge = new Map(); // edge key → first incident triangle
337
- for (let t = 0; t < nTri; t++)
338
- for (let e = 0; e < 3; e++) {
339
- const i = remap[tris[t * 3 + e]], j = remap[tris[t * 3 + ((e + 1) % 3)]];
340
- if (i === j) continue;
341
- const key = i < j ? i * nVert + j : j * nVert + i;
342
- const prev = seenEdge.get(key);
343
- if (prev === undefined) { seenEdge.set(key, t); continue; }
344
- seenEdge.delete(key);
345
- const dot = fn[prev * 3] * fn[t * 3] + fn[prev * 3 + 1] * fn[t * 3 + 1] + fn[prev * 3 + 2] * fn[t * 3 + 2];
346
- const hard = dot < sharpCos || (triOID[prev] !== triOID[t] && dot < COPLANAR_COS);
347
- if (hard) {
348
- const ai = i * np, bj = j * np;
349
- const dx = vp[ai] - vp[bj], dy = vp[ai + 1] - vp[bj + 1], dz = vp[ai + 2] - vp[bj + 2];
350
- if (dx * dx + dy * dy + dz * dz >= MIN_EDGE2) // skip degenerate sliver segments (noise)
351
- edges.push(vp[ai], vp[ai + 1], vp[ai + 2], vp[bj], vp[bj + 1], vp[bj + 2]);
352
- }
353
- }
354
-
355
- // Per-triangle feature attribution: map each triangle's original-surface id
356
- // through the label registry. Same label string → same feature entry, so a
357
- // pattern of solids labeled alike reads as one feature.
358
- let featureIds = null, features = null;
359
- if (featureLabels?.size) {
360
- const indexOf = new Map(); // label string -> 1-based feature index
361
- features = [];
362
- featureIds = new Uint16Array(nTri);
363
- for (let t = 0; t < nTri; t++) {
364
- const label = featureLabels.get(triOID[t]);
365
- if (label === undefined) continue;
366
- let fi = indexOf.get(label);
367
- if (fi === undefined) { features.push(label); fi = features.length; indexOf.set(label, fi); }
368
- featureIds[t] = fi;
369
- }
370
- if (features.length === 0) { featureIds = features = null; } // labels exist in the kernel, none in THIS mesh
371
- }
372
-
373
- const out = { positions, normals, triangles: nTri, edges: Float32Array.from(edges) }; // mesh non-indexed
374
- if (featureIds) { out.featureIds = featureIds; out.features = features; }
375
- return out;
376
- }
377
-
378
334
  function stlFromMesh(g) {
379
335
  const vp = g.vertProperties, np = g.numProp;
380
336
  const nVert = (vp.length / np) | 0;
@@ -29,9 +29,10 @@ import { normalizeProfile } from "./profile.js";
29
29
  import { roundedRectContour } from "./rounded-solids.js";
30
30
  import { h } from "./solid-hash.js";
31
31
  import { createSolidCache } from "./solid-cache.js";
32
- import { composePose, transformPositions } from "./pose.js";
32
+ import { composePose, transformPositions, rotateNormals } from "./pose.js";
33
+ import { filterBrepEdges } from "./brep-edges.js";
33
34
  import { meshToStl } from "./mesh-stl.js";
34
- const MESH = { preview: { tolerance: 0.1, angularTolerance: 0.5 }, print: { tolerance: 0.01, angularTolerance: 0.1 } };
35
+ const MESH = { preview: { tolerance: 0.1, angularTolerance: 0.25 }, print: { tolerance: 0.01, angularTolerance: 0.1 } };
35
36
 
36
37
  export function createOcctKernel(replicad) {
37
38
  const { makeCylinder, makeBox, makeCircle, makeHelix, assembleWire, genericSweep,
@@ -87,6 +88,8 @@ export function createOcctKernel(replicad) {
87
88
  const m = shape.mesh(MESH[quality]);
88
89
  const out = {
89
90
  positions: Float32Array.from(m.vertices),
91
+ normals: Float32Array.from(m.normals), // analytic per-face-vertex normals — smooth by construction
92
+ edges: filterBrepEdges(m, shape.meshEdges(MESH[quality])), // true B-rep edges, tangent-filtered
90
93
  indices: Uint32Array.from(m.triangles),
91
94
  triangles: m.triangles.length / 3,
92
95
  };
@@ -106,6 +109,16 @@ export function createOcctKernel(replicad) {
106
109
  if (pose.length) transformPositions(positions, composePose(pose));
107
110
  return positions;
108
111
  };
112
+ const posedNormals = (base) => {
113
+ const normals = Float32Array.from(base.normals);
114
+ if (pose.length) rotateNormals(normals, composePose(pose)); // rotation only — normals are directions
115
+ return normals;
116
+ };
117
+ const posedEdges = (base) => {
118
+ const edges = Float32Array.from(base.edges);
119
+ if (pose.length) transformPositions(edges, composePose(pose)); // segment endpoints pose like positions
120
+ return edges;
121
+ };
109
122
 
110
123
  const self = addSugar({
111
124
  _s: shape,
@@ -179,7 +192,8 @@ export function createOcctKernel(replicad) {
179
192
  const base = baseMesh(quality);
180
193
  const out = {
181
194
  positions: posedPositions(base),
182
- normals: new Float32Array(0), // let the main thread crease (matches prior look)
195
+ normals: posedNormals(base), // analytic the viewer must NOT re-crease these
196
+ edges: posedEdges(base), // empty array = "no feature edges", not "unknown"
183
197
  indices: Uint32Array.from(base.indices),
184
198
  triangles: base.triangles,
185
199
  };
@@ -116,8 +116,8 @@ export function revolveArgs(o) {
116
116
  }
117
117
 
118
118
  export function loftArgs(o) {
119
- checkKeys("loft", o, ["rings", "ruled", "closed"]);
120
- return [req("loft", o, "rings"), ...tail(o, ["ruled", "closed"])];
119
+ checkKeys("loft", o, ["rings", "ruled", "closed", "shading"]);
120
+ return [req("loft", o, "rings"), ...tail(o, ["ruled", "closed", "shading"])];
121
121
  }
122
122
 
123
123
  export function sweepArgs(o) {
@@ -77,3 +77,16 @@ export const poseDelta = (newSteps, oldSteps) => {
77
77
  const inv = invertRigid(composePose(oldSteps));
78
78
  return mulMat4(target, inv);
79
79
  };
80
+
81
+ // Apply ONLY the rotation block of a rigid mat4 to interleaved xyz normals, in
82
+ // place. composePose matrices are rigid (orthonormal 3x3 block), so normals
83
+ // transform by the same block — no inverse-transpose — and stay unit length.
84
+ // Translation columns are deliberately ignored: normals are directions.
85
+ export function rotateNormals(normals, m) {
86
+ for (let i = 0; i < normals.length; i += 3) {
87
+ const x = normals[i], y = normals[i + 1], z = normals[i + 2];
88
+ normals[i] = m[0] * x + m[4] * y + m[8] * z;
89
+ normals[i + 1] = m[1] * x + m[5] * y + m[9] * z;
90
+ normals[i + 2] = m[2] * x + m[6] * y + m[10] * z;
91
+ }
92
+ }
@@ -102,17 +102,24 @@ const bevelRegion = (k, region, h, bottom, top) => {
102
102
  let s = k.extrude({ profile: holes.length ? { outer, holes } : outer, h });
103
103
  const b = bottom > 0 ? fit(outer, -bottom, "profile") : null;
104
104
  const t = top > 0 ? fit(outer, -top, "profile") : null;
105
- if (b || t) s = s.intersect(k.loft({ rings: outerRings(outer, h, b, t) }));
105
+ // shading: "smooth" on all three internal lofts a bevel band inherits the
106
+ // profile's own shading intent (sharp corners at the bevel's start/end
107
+ // rings, as a real chamfer would look), not the loft op's own facet-vs-
108
+ // smooth ring-count inference. Left to infer, a <32-point profile (any
109
+ // ordinary polygon) registers FACETED, which drops the bevel band's own
110
+ // corner crease lines and, via label()'s majority vote, can strip ALL
111
+ // edge lines from a labeled beveled solid.
112
+ if (b || t) s = s.intersect(k.loft({ rings: outerRings(outer, h, b, t), shading: "smooth" }));
106
113
  const cutters = [];
107
114
  for (const hole of holes) {
108
115
  const hb = bottom > 0 ? fit(hole, bottom, "hole") : null;
109
116
  if (hb) cutters.push(k.loft({ rings: [
110
117
  { polygon: hb.ring, z: -1 }, { polygon: hb.ring, z: 0 }, { polygon: hole, z: hb.c },
111
- ] }));
118
+ ], shading: "smooth" }));
112
119
  const ht = top > 0 ? fit(hole, top, "hole") : null;
113
120
  if (ht) cutters.push(k.loft({ rings: [
114
121
  { polygon: hole, z: h - ht.c }, { polygon: ht.ring, z: h }, { polygon: ht.ring, z: h + 1 },
115
- ] }));
122
+ ], shading: "smooth" }));
116
123
  }
117
124
  return cutters.length ? s.cutAll(cutters) : s;
118
125
  };
@@ -0,0 +1,36 @@
1
+ // Shading-intent policies — the single home for every edge/shading threshold.
2
+ // A policy says how one original surface (a Manifold originalID) wants its
3
+ // SAME-surface edges treated by creased-normals.js:
4
+ // creaseAngle deg — same-surface edges bending more than this shade hard
5
+ // sameSurfaceLines whether same-surface edges past creaseAngle also draw lines
6
+ // Cross-surface (boolean cut seam) behavior is not policy: seams always shade
7
+ // hard, and draw a line when bent more than COPLANAR_ANGLE.
8
+
9
+ export const SMOOTH = Object.freeze({ creaseAngle: 35, sameSurfaceLines: true });
10
+ export const FACETED = Object.freeze({ creaseAngle: 10, sameSurfaceLines: false });
11
+
12
+ export const COPLANAR_ANGLE = 5; // deg — cut seams bending less than this are coplanar: no line
13
+ export const TANGENT_ANGLE = 5; // deg — B-rep edges whose faces agree within this are tangent: no line
14
+ export const MIN_EDGE = 0.01; // mm — drop shorter segments (degenerate slivers, pole edges)
15
+
16
+ // Loft rings with at least this many sides read as an approximation of a smooth
17
+ // surface (e.g. a 64-gon "circle"), not as 64 intentional facets.
18
+ export const SMOOTH_SIDES_MIN = 32;
19
+
20
+ export const cosDeg = (deg) => Math.cos((deg * Math.PI) / 180);
21
+
22
+ // Loft shading inference. An explicit `shading` hint wins; `ruled:false` asks
23
+ // OCCT for a smoothly blended surface, so the Manifold preview of the same part
24
+ // must shade smooth too; otherwise low-side-count rings are intentional facets.
25
+ export function loftShadingPolicy(rings, { shading, ruled } = {}) {
26
+ if (shading === "smooth") return SMOOTH;
27
+ if (shading === "faceted") return FACETED;
28
+ if (shading != null) throw new Error('loft: shading must be "smooth" | "faceted"');
29
+ if (ruled === false) return SMOOTH;
30
+ let maxSides = 0;
31
+ if (Array.isArray(rings)) for (const r of rings) {
32
+ const n = Array.isArray(r?.polygon) ? r.polygon.length : (Number.isFinite(r?.sides) ? r.sides : 0);
33
+ if (n > maxSides) maxSides = n;
34
+ }
35
+ return maxSides >= SMOOTH_SIDES_MIN ? SMOOTH : FACETED;
36
+ }
@@ -166,7 +166,7 @@ export function createViewer(container, part) {
166
166
  }
167
167
 
168
168
  // CAD-style feature edge lines (anti-aliased "fat" lines), one per sub-part.
169
- const EDGE_ANGLE = 35; // deg — OCCT fallback threshold (Manifold supplies seam-aware edges)
169
+ const EDGE_ANGLE = 35; // deg — last-ditch threshold for payloads with no kernel edge data
170
170
  const lineMaterial = new LineMaterial({ color: THEME.dark.line, linewidth: 1.0 }); // ~10% lighter, 1 px
171
171
  lineMaterial.resolution.set(1, 1); // real size set by resize() below
172
172
  const subLines = Object.fromEntries(
@@ -246,10 +246,10 @@ export function createViewer(container, part) {
246
246
  controls.addEventListener("start", onControlsStart);
247
247
  function onCameraStart(cb) { cameraStartListeners.add(cb); return () => cameraStartListeners.delete(cb); }
248
248
 
249
- // Smooth shading within CREASE_ANGLE of a shared edge, hard edge past it — so the
250
- // round body and helical groove read smooth while bore rims, drum faces, and
251
- // groove walls stay crisp. Lower = more hard edges; raise toward Math.PI/3 for
252
- // softer. (Worker normals are ignored; we recompute per the crease threshold.)
249
+ // Fallback creasing for payloads with no kernel normals. Both backends now
250
+ // ship authoritative normals (Manifold: policy-aware crease pass; OCCT:
251
+ // analytic B-rep normals), so this path is last-ditch only it must not be
252
+ // "improved" in lieu of fixing a backend that stopped sending normals.
253
253
  const CREASE_ANGLE = Math.PI / 6; // 30°
254
254
 
255
255
  // --- geometry builder -----------------------------------------------------
@@ -262,20 +262,23 @@ export function createViewer(container, part) {
262
262
  const triCount = triangles ?? (indices ? indices.length : positions.length / 3) / 3;
263
263
  let out;
264
264
  if (normals?.length) {
265
- // kernel-computed normals (Manifold) — smooth within a surface, hard at cut seams
265
+ // kernel-computed normals (both backends) — smooth within a surface, hard at cut seams
266
266
  geo.setAttribute("normal", new THREE.BufferAttribute(normals, 3));
267
267
  geo.computeBoundingBox();
268
268
  out = geo;
269
269
  } else {
270
- // fallback (no kernel normals, e.g. OCCT): crease from the triangle soup
270
+ // fallback (payload with no kernel normals no current backend does this): crease from the triangle soup
271
271
  out = toCreasedNormals(geo, CREASE_ANGLE);
272
272
  out.computeBoundingBox();
273
273
  }
274
274
  out.userData.triangles = triCount;
275
275
  if (featureIds) { out.userData.featureIds = featureIds; out.userData.features = features; }
276
- // feature edge lines: Manifold supplies seam-aware segments; else derive by angle
276
+ // feature edge lines: kernel-supplied segments are authoritative an EMPTY
277
+ // array means "this solid has no feature edges" (e.g. a lone sphere), so
278
+ // draw none rather than falling back. Only a payload with NO edge data at
279
+ // all (edges === undefined; no current backend does this) derives by angle.
277
280
  const lg = new LineSegmentsGeometry();
278
- if (edges?.length) lg.setPositions(edges);
281
+ if (edges) lg.setPositions(edges); // edges is already a well-formed (possibly zero-length) Float32Array
279
282
  else lg.fromEdgesGeometry(new THREE.EdgesGeometry(out, EDGE_ANGLE));
280
283
  out.userData.edges = lg;
281
284
  return out;
@@ -86,8 +86,8 @@ export async function renderViews(kernel, part, view = Object.keys(part.views)[0
86
86
 
87
87
  for (const m of meshes) {
88
88
  const P = m.positions, N = m.normals, ind = m.indices;
89
- // Manifold meshes are a non-indexed soup (3 consecutive verts/triangle) with
90
- // per-vertex normals; OCCT meshes are indexed and carry no normals.
89
+ // Manifold meshes are a non-indexed soup (3 consecutive verts/triangle);
90
+ // OCCT meshes are indexed. Both carry per-vertex normals.
91
91
  const triCount = ind?.length ? ind.length / 3 : P.length / 9;
92
92
  for (let t = 0; t < triCount; t++) {
93
93
  const ai = ind?.length ? ind[t * 3] * 3 : t * 9;
package/types/kernel.d.ts CHANGED
@@ -267,6 +267,8 @@ export interface LoftOptions {
267
267
  ruled?: boolean;
268
268
  /** Capless loop — Manifold only. */
269
269
  closed?: boolean;
270
+ /** Overrides the facet-vs-smooth shading inference. */
271
+ shading?: "smooth" | "faceted";
270
272
  }
271
273
 
272
274
  export interface SweepOptions {