partforge 0.44.0 → 0.46.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cli.js +43 -5
- package/docs/AUTHORING-PARTS.md +50 -4
- package/docs/ERROR-PATTERNS.md +19 -0
- package/docs/KERNEL-CONTRACT.md +34 -1
- package/package.json +2 -2
- package/src/framework/animation-controls.js +27 -16
- package/src/framework/animation.js +67 -13
- package/src/framework/capture-build.js +59 -0
- 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 +66 -115
- 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/jobs.js +21 -0
- package/src/framework/lint/rules-animations.js +54 -17
- package/src/framework/lint/rules-schema.js +22 -0
- package/src/framework/mount.js +57 -5
- package/src/framework/view-tabs.js +13 -0
- package/src/framework/viewer-lighting.js +8 -1
- package/src/framework/viewer.js +94 -13
- package/src/framework/worker.js +5 -1
- package/src/testing/render.js +2 -2
- package/types/index.d.ts +23 -4
- package/types/part.d.ts +44 -16
|
@@ -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
|
|
@@ -267,114 +326,6 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
|
|
|
267
326
|
return kernel;
|
|
268
327
|
}
|
|
269
328
|
|
|
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
329
|
function stlFromMesh(g) {
|
|
379
330
|
const vp = g.vertProperties, np = g.numProp;
|
|
380
331
|
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
|
+
}
|