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.
- package/docs/AUTHORING-PARTS.md +12 -1
- package/docs/ERROR-PATTERNS.md +19 -0
- package/docs/KERNEL-CONTRACT.md +34 -1
- package/package.json +1 -1
- package/src/framework/geometry/brep-edges.js +124 -0
- package/src/framework/geometry/creased-normals.js +131 -0
- package/src/framework/geometry/kernel.js +2 -2
- package/src/framework/geometry/manifold-backend.js +72 -116
- package/src/framework/geometry/occt-backend.js +17 -3
- package/src/framework/geometry/op-options.js +2 -2
- package/src/framework/geometry/pose.js +13 -0
- package/src/framework/geometry/rim-bevel.js +10 -3
- package/src/framework/geometry/shading-policy.js +36 -0
- package/src/framework/viewer.js +12 -9
- package/src/testing/render.js +2 -2
- package/types/kernel.d.ts +2 -0
package/docs/AUTHORING-PARTS.md
CHANGED
|
@@ -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
|
|
package/docs/ERROR-PATTERNS.md
CHANGED
|
@@ -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.
|
package/docs/KERNEL-CONTRACT.md
CHANGED
|
@@ -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`
|
|
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
|
@@ -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`
|
|
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,
|
|
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
|
-
|
|
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
|
|
244
|
-
|
|
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
|
-
|
|
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.
|
|
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:
|
|
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
|
-
|
|
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
|
+
}
|
package/src/framework/viewer.js
CHANGED
|
@@ -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 —
|
|
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
|
-
//
|
|
250
|
-
//
|
|
251
|
-
//
|
|
252
|
-
//
|
|
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 (
|
|
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
|
|
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:
|
|
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
|
|
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;
|
package/src/testing/render.js
CHANGED
|
@@ -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)
|
|
90
|
-
//
|
|
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 {
|