partforge 0.6.0 → 0.7.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/README.md +63 -14
- package/bin/cli.js +82 -58
- package/docs/AUTHORING-PARTS.md +48 -23
- package/package.json +1 -1
- package/src/app-planter.js +10 -0
- package/src/framework/app.css +94 -29
- package/src/framework/assembly.js +6 -9
- package/src/framework/download.js +23 -0
- package/src/framework/geometry/feature-attribution.js +102 -0
- package/src/framework/geometry/kernel-front.js +31 -0
- package/src/framework/geometry/kernel.js +53 -10
- package/src/framework/geometry/manifold-backend.js +48 -25
- package/src/framework/geometry/occt-backend.js +47 -96
- package/src/framework/geometry/occt-repair.js +83 -0
- package/src/framework/geometry/probe.js +37 -30
- package/src/framework/geometry/solid-sugar.js +32 -5
- package/src/framework/geometry-service.js +4 -6
- package/src/framework/jobs.js +40 -18
- package/src/framework/mesh-cache.js +41 -0
- package/src/framework/mount.js +103 -240
- package/src/framework/param-deps.js +9 -18
- package/src/framework/pick-request/server.js +10 -0
- package/src/framework/regen-loop.js +45 -0
- package/src/framework/selection/format.js +2 -6
- package/src/framework/selection/hover.js +128 -0
- package/src/framework/selection/index.js +3 -0
- package/src/framework/selection/pick-toggle.js +34 -0
- package/src/framework/selection/pick.js +7 -30
- package/src/framework/selection/raycast.js +43 -0
- package/src/framework/selection/resolve.js +3 -8
- package/src/framework/status-ui.js +18 -0
- package/src/framework/view-state.js +11 -1
- package/src/framework/view-tabs.js +33 -0
- package/src/framework/viewer-controls.js +48 -0
- package/src/framework/viewer.js +9 -9
- package/src/framework/worker.js +12 -20
- package/src/parts/filleted-box.js +1 -1
- package/src/parts/planter.js +120 -0
- package/src/planter-worker.js +3 -0
- package/src/testing/build.js +3 -6
- package/src/testing/manifold.js +11 -0
- package/src/testing.js +1 -0
- package/src/framework/geometry/fuzzy-cut.js +0 -32
package/src/framework/app.css
CHANGED
|
@@ -8,14 +8,15 @@
|
|
|
8
8
|
--bg: #15181d; --surface: #1f242c; --surface-2: #20262e; --border: #2c333d;
|
|
9
9
|
--text: #d6dbe2; --text-strong: #e7ebf1; --text-2: #cdd4dd;
|
|
10
10
|
--muted: #7d8794; --muted-2: #aab2bd; --status: #8b94a0; --hint: #6b7480;
|
|
11
|
-
--accent: #
|
|
11
|
+
--accent: #3f7bf0; --accent-soft: #26314a; --on-accent: #fff; --input-bg: #161a20; --err: #f8746c;
|
|
12
|
+
--mono: ui-monospace, "SF Mono", SFMono-Regular, "JetBrains Mono", "Cascadia Code", Menlo, Consolas, monospace;
|
|
12
13
|
}
|
|
13
14
|
:root[data-theme="light"] {
|
|
14
15
|
color-scheme: light;
|
|
15
16
|
--bg: #eef1f5; --surface: #ffffff; --surface-2: #f4f6f9; --border: #d4dae2;
|
|
16
17
|
--text: #2b333d; --text-strong: #1a2129; --text-2: #3a434e;
|
|
17
18
|
--muted: #6b7480; --muted-2: #59636f; --status: #6b7480; --hint: #8a93a0;
|
|
18
|
-
--accent: #
|
|
19
|
+
--accent: #1f5bd6; --accent-soft: #e6edfc; --on-accent: #fff; --input-bg: #ffffff; --err: #d8453d;
|
|
19
20
|
}
|
|
20
21
|
* { box-sizing: border-box; }
|
|
21
22
|
html, body { margin: 0; height: 100%; overflow: hidden;
|
|
@@ -24,59 +25,86 @@ html, body { margin: 0; height: 100%; overflow: hidden;
|
|
|
24
25
|
canvas { display: block; }
|
|
25
26
|
|
|
26
27
|
#panel {
|
|
27
|
-
position: fixed; top: 12px; left: 12px; width:
|
|
28
|
+
position: fixed; top: 12px; left: 12px; width: 256px;
|
|
28
29
|
max-height: calc(100vh - 24px); overflow-y: auto; z-index: 10;
|
|
29
30
|
background: var(--surface); border: 1px solid var(--border); border-radius: 10px;
|
|
30
31
|
padding: 14px; color: var(--text); box-shadow: 0 6px 24px rgba(0,0,0,.35);
|
|
31
32
|
}
|
|
32
|
-
#panel h1 { font-size: 14px; margin: 0 0 2px; }
|
|
33
|
-
#panel .sub {
|
|
33
|
+
#panel h1 { font-size: 14px; margin: 0 0 2px; color: var(--text-strong); letter-spacing: -0.01em; }
|
|
34
|
+
#panel .sub {
|
|
35
|
+
font-family: var(--mono); color: var(--muted); font-size: 10px;
|
|
36
|
+
letter-spacing: 0.04em; text-transform: uppercase;
|
|
37
|
+
margin: 0 0 12px; padding-bottom: 12px; border-bottom: 1px solid var(--border);
|
|
38
|
+
}
|
|
34
39
|
|
|
35
40
|
.seg { display: flex; gap: 4px; margin-bottom: 12px; }
|
|
36
41
|
.seg button {
|
|
37
42
|
flex: 1; padding: 7px 0; border: 1px solid var(--border); border-radius: 7px;
|
|
38
|
-
background: var(--surface-2); color: var(--muted-2); cursor: pointer;
|
|
43
|
+
background: var(--surface-2); color: var(--muted-2); cursor: pointer;
|
|
44
|
+
font-family: var(--mono); font-size: 11px; letter-spacing: 0.02em;
|
|
39
45
|
}
|
|
40
46
|
.seg button.on { background: var(--accent); color: var(--on-accent); border-color: var(--accent); }
|
|
41
47
|
|
|
42
48
|
.section {
|
|
43
|
-
border: 1px solid var(--border); border-radius: 8px; padding:
|
|
49
|
+
border: 1px solid var(--border); border-radius: 8px; padding: 10px;
|
|
44
50
|
margin-bottom: 8px; background: var(--surface-2);
|
|
45
51
|
}
|
|
46
|
-
.sec-title {
|
|
52
|
+
.sec-title {
|
|
53
|
+
font-family: var(--mono); font-size: 10px; font-weight: 600;
|
|
54
|
+
letter-spacing: 0.14em; text-transform: uppercase; color: var(--muted-2); margin-bottom: 9px;
|
|
55
|
+
}
|
|
47
56
|
select.preset {
|
|
48
57
|
width: 100%; background: var(--input-bg); color: var(--text-2);
|
|
49
|
-
border: 1px solid var(--border); border-radius: 6px; padding:
|
|
58
|
+
border: 1px solid var(--border); border-radius: 6px; padding: 6px 8px;
|
|
59
|
+
font-family: var(--mono); font-size: 11px;
|
|
50
60
|
}
|
|
51
61
|
.feat { display: flex; align-items: center; gap: 8px; margin: 6px 0;
|
|
52
62
|
color: var(--text-2); cursor: pointer; }
|
|
53
|
-
.feat input { cursor: pointer; }
|
|
54
|
-
.feat-group { margin: 2px 0 8px; padding-left:
|
|
63
|
+
.feat input { cursor: pointer; accent-color: var(--accent); }
|
|
64
|
+
.feat-group { margin: 2px 0 8px; padding-left: 10px; border-left: 2px solid var(--border); }
|
|
55
65
|
.feat-group.hidden { display: none; }
|
|
56
66
|
.adv-toggle {
|
|
57
|
-
margin-top: 8px; padding:
|
|
58
|
-
background: transparent; color: var(--muted); cursor: pointer;
|
|
59
|
-
text-align: left;
|
|
67
|
+
margin-top: 8px; padding: 4px 0; width: 100%; border: 0; border-radius: 5px;
|
|
68
|
+
background: transparent; color: var(--muted); cursor: pointer;
|
|
69
|
+
font-family: var(--mono); font-size: 10px; letter-spacing: 0.08em; text-transform: uppercase; text-align: left;
|
|
60
70
|
}
|
|
61
71
|
.adv-toggle:hover { color: var(--muted-2); }
|
|
62
72
|
.adv.hidden { display: none; }
|
|
63
73
|
.adv { margin-top: 4px; }
|
|
64
74
|
|
|
65
|
-
.slider { margin:
|
|
75
|
+
.slider { margin: 9px 0; }
|
|
66
76
|
.row { display: flex; justify-content: space-between; align-items: center;
|
|
67
|
-
margin: 0 0
|
|
68
|
-
.row label { color: var(--muted-2); }
|
|
77
|
+
margin: 0 0 4px; gap: 8px; }
|
|
78
|
+
.row label { font-family: var(--mono); font-size: 11px; color: var(--muted-2); letter-spacing: 0.01em; }
|
|
69
79
|
.row .val { display: flex; align-items: baseline; gap: 4px; flex: none; }
|
|
70
80
|
.row .num {
|
|
71
|
-
width:
|
|
81
|
+
width: 54px; text-align: right; font-family: var(--mono); font-size: 12px; font-variant-numeric: tabular-nums;
|
|
72
82
|
background: var(--input-bg); color: var(--text-strong);
|
|
73
|
-
border: 1px solid var(--border); border-radius: 5px; padding:
|
|
83
|
+
border: 1px solid var(--border); border-radius: 5px; padding: 3px 6px;
|
|
74
84
|
}
|
|
75
|
-
.row .num:focus { outline: none; border-color: var(--accent); }
|
|
85
|
+
.row .num:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
|
|
76
86
|
.row .num::-webkit-outer-spin-button, .row .num::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
|
|
77
87
|
.row .num { -moz-appearance: textfield; }
|
|
78
|
-
.row .unit { color: var(--muted); font-size:
|
|
79
|
-
|
|
88
|
+
.row .unit { font-family: var(--mono); color: var(--muted); font-size: 10px; }
|
|
89
|
+
|
|
90
|
+
/* crafted range slider — hairline track + CAD-blue handle (the panel's signature control) */
|
|
91
|
+
input[type="range"] { -webkit-appearance: none; appearance: none; width: 100%; height: 18px; margin: 0; background: transparent; cursor: pointer; }
|
|
92
|
+
input[type="range"]::-webkit-slider-runnable-track { height: 3px; border-radius: 2px; background: var(--border); }
|
|
93
|
+
input[type="range"]::-moz-range-track { height: 3px; border-radius: 2px; background: var(--border); }
|
|
94
|
+
input[type="range"]::-moz-range-progress { height: 3px; border-radius: 2px; background: var(--accent); }
|
|
95
|
+
input[type="range"]::-webkit-slider-thumb {
|
|
96
|
+
-webkit-appearance: none; appearance: none; width: 14px; height: 14px; margin-top: -5.5px;
|
|
97
|
+
border-radius: 50%; background: var(--accent); border: 2px solid var(--surface-2); box-shadow: 0 0 0 1px var(--accent);
|
|
98
|
+
transition: box-shadow .12s ease;
|
|
99
|
+
}
|
|
100
|
+
input[type="range"]::-moz-range-thumb {
|
|
101
|
+
width: 14px; height: 14px; border-radius: 50%; background: var(--accent);
|
|
102
|
+
border: 2px solid var(--surface-2); box-shadow: 0 0 0 1px var(--accent);
|
|
103
|
+
}
|
|
104
|
+
input[type="range"]:hover::-webkit-slider-thumb { box-shadow: 0 0 0 5px var(--accent-soft); }
|
|
105
|
+
input[type="range"]:focus-visible { outline: none; }
|
|
106
|
+
input[type="range"]:focus-visible::-webkit-slider-thumb { box-shadow: 0 0 0 5px var(--accent-soft); }
|
|
107
|
+
input[type="range"]:focus-visible::-moz-range-thumb { box-shadow: 0 0 0 5px var(--accent-soft); }
|
|
80
108
|
|
|
81
109
|
button.action {
|
|
82
110
|
width: 100%; margin-top: 8px; padding: 9px; border: 0; border-radius: 7px;
|
|
@@ -85,19 +113,29 @@ button.action {
|
|
|
85
113
|
button.ghost { background: var(--border); color: var(--text-2); font-weight: 500; }
|
|
86
114
|
button.action:disabled { opacity: .5; cursor: default; }
|
|
87
115
|
|
|
88
|
-
.dl { margin-top:
|
|
89
|
-
.dl-head {
|
|
116
|
+
.dl { margin-top: 14px; }
|
|
117
|
+
.dl-head {
|
|
118
|
+
font-family: var(--mono); font-size: 10px; font-weight: 600;
|
|
119
|
+
letter-spacing: 0.14em; text-transform: uppercase; color: var(--muted-2); margin-bottom: 7px;
|
|
120
|
+
}
|
|
90
121
|
.dl-row { display: flex; gap: 6px; }
|
|
91
122
|
.dl-row button {
|
|
92
123
|
flex: 1; padding: 8px 0; border: 1px solid var(--border); border-radius: 7px;
|
|
93
|
-
background: var(--surface-2); color: var(--text-2);
|
|
94
|
-
font-size:
|
|
124
|
+
background: var(--surface-2); color: var(--text-2);
|
|
125
|
+
font-family: var(--mono); font-weight: 600; font-size: 11px; letter-spacing: 0.06em; cursor: pointer;
|
|
95
126
|
}
|
|
96
127
|
.dl-row button:hover:not(:disabled) { border-color: var(--accent); color: var(--text-strong); }
|
|
97
128
|
.dl-row button:disabled { opacity: .45; cursor: default; }
|
|
98
|
-
#status { margin-top:
|
|
129
|
+
#status { font-family: var(--mono); margin-top: 12px; min-height: 16px; color: var(--status);
|
|
130
|
+
font-size: 11px; font-variant-numeric: tabular-nums; }
|
|
99
131
|
#status.err { color: var(--err); }
|
|
100
|
-
.hint { margin-top: 8px; color: var(--hint); font-size: 10px; }
|
|
132
|
+
.hint { font-family: var(--mono); margin-top: 8px; color: var(--hint); font-size: 10px; letter-spacing: 0.02em; }
|
|
133
|
+
|
|
134
|
+
/* keyboard focus ring shared across the panel's interactive controls */
|
|
135
|
+
.seg button:focus-visible, select.preset:focus-visible, .dl-row button:focus-visible,
|
|
136
|
+
button.action:focus-visible, .adv-toggle:focus-visible, #viewbar button:focus-visible {
|
|
137
|
+
outline: 2px solid var(--accent); outline-offset: 2px;
|
|
138
|
+
}
|
|
101
139
|
|
|
102
140
|
/* part tabs, floated top-centre over the viewport */
|
|
103
141
|
#topbar {
|
|
@@ -156,6 +194,7 @@ button.action:disabled { opacity: .5; cursor: default; }
|
|
|
156
194
|
}
|
|
157
195
|
.popover[hidden] { display: none; }
|
|
158
196
|
.popover img { max-width: 100%; height: auto; border-radius: 4px; }
|
|
197
|
+
.popover code { font-family: var(--mono); background: var(--surface-2); padding: 0.05em 0.35em; border-radius: 4px; font-size: 0.9em; }
|
|
159
198
|
.popover a { color: var(--accent); }
|
|
160
199
|
.popover p:first-child { margin-top: 0; }
|
|
161
200
|
.popover p:last-child { margin-bottom: 0; }
|
|
@@ -165,6 +204,18 @@ button.action:disabled { opacity: .5; cursor: default; }
|
|
|
165
204
|
.irrelevant:hover { opacity: 0.7; }
|
|
166
205
|
.section-hidden { display: none; }
|
|
167
206
|
|
|
207
|
+
/* hover feature-label tooltip (selection/hover.js) */
|
|
208
|
+
#pf-hover-tip {
|
|
209
|
+
position: fixed; z-index: 30; pointer-events: none; display: none;
|
|
210
|
+
padding: 4px 9px; border-radius: 6px; max-width: 260px;
|
|
211
|
+
background: var(--surface); border: 1px solid var(--border);
|
|
212
|
+
color: var(--text-strong); font-size: 12px; line-height: 1.35;
|
|
213
|
+
box-shadow: 0 2px 10px rgba(0, 0, 0, .25);
|
|
214
|
+
}
|
|
215
|
+
#pf-hover-tip.show { display: block; }
|
|
216
|
+
#pf-hover-tip .pf-hover-sub { color: var(--muted); font-size: 11px; margin-left: 7px; }
|
|
217
|
+
#pf-hover-tip .pf-hover-sub:empty { display: none; }
|
|
218
|
+
|
|
168
219
|
/* request-a-pick: agent prompt banner, floated top-centre well below the part tabs,
|
|
169
220
|
laid out like a chat message (avatar + text). Slides/fades in when shown; themed. */
|
|
170
221
|
#pf-pick-banner {
|
|
@@ -198,4 +249,18 @@ button.action:disabled { opacity: .5; cursor: default; }
|
|
|
198
249
|
@keyframes pf-pick-in {
|
|
199
250
|
from { opacity: 0; transform: translateX(-50%) translateY(-10px); }
|
|
200
251
|
to { opacity: 1; transform: translateX(-50%) translateY(0); }
|
|
201
|
-
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/* ?pick clipboard mode: a bottom-left toggle button + a transient token toast. */
|
|
255
|
+
#pf-pick {
|
|
256
|
+
position: fixed; left: 12px; bottom: 12px; z-index: 9999;
|
|
257
|
+
font: 12px system-ui, sans-serif; padding: 6px 10px; cursor: pointer;
|
|
258
|
+
}
|
|
259
|
+
#pf-pick.on { outline: 2px solid #ffcc33; }
|
|
260
|
+
#pf-pick-toast {
|
|
261
|
+
position: fixed; left: 12px; bottom: 48px; z-index: 9999; max-width: 60ch;
|
|
262
|
+
font: 12px ui-monospace, monospace; padding: 6px 10px; border-radius: 4px;
|
|
263
|
+
background: rgba(20,24,29,0.92); color: #d8e0ea;
|
|
264
|
+
white-space: pre-wrap; word-break: break-word; display: none;
|
|
265
|
+
}
|
|
266
|
+
#pf-pick-toast.show { display: block; }
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { viewSubParts } from "./jobs.js";
|
|
1
|
+
import { viewSubParts, resolveParams, buildPosed } from "./jobs.js";
|
|
2
2
|
|
|
3
3
|
// Collision check for an assembled view: build each sub-part in its display
|
|
4
4
|
// (assembly) pose and return the pairs whose solid-intersection volume exceeds
|
|
@@ -8,14 +8,11 @@ import { viewSubParts } from "./jobs.js";
|
|
|
8
8
|
// part tests so an author/LLM editing a part sees collisions fail.
|
|
9
9
|
// → [{ a, b, volume }] for each offending pair (empty = no collisions)
|
|
10
10
|
export function assemblyOverlaps(kernel, part, view, params = {}, { tolerance = 1 } = {}) {
|
|
11
|
-
const p =
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
if (sp.place) solid = sp.place(solid, { view, purpose: "display", p, d });
|
|
17
|
-
return { name, solid };
|
|
18
|
-
});
|
|
11
|
+
const { p, d } = resolveParams(part, params);
|
|
12
|
+
const posed = viewSubParts(part, view, p).map((name) => ({
|
|
13
|
+
name,
|
|
14
|
+
solid: buildPosed(kernel, part, name, { purpose: "display", view, p, d }),
|
|
15
|
+
}));
|
|
19
16
|
|
|
20
17
|
const overlaps = [];
|
|
21
18
|
for (let i = 0; i < posed.length; i++) {
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { zipSync } from "fflate";
|
|
2
|
+
|
|
3
|
+
// Browser file-download helpers. Pure DOM/Blob utilities with no app state — the
|
|
4
|
+
// worker produces the bytes; these just hand them to the browser as a download.
|
|
5
|
+
|
|
6
|
+
// Trigger a download of one binary blob under `filename`.
|
|
7
|
+
export function triggerDownload(data, filename, mime) {
|
|
8
|
+
const url = URL.createObjectURL(new Blob([data], { type: mime }));
|
|
9
|
+
const a = document.createElement("a");
|
|
10
|
+
a.href = url;
|
|
11
|
+
a.download = filename;
|
|
12
|
+
a.click();
|
|
13
|
+
URL.revokeObjectURL(url);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Download a set of built parts: a single part downloads directly; multiple parts
|
|
17
|
+
// are bundled into one flat, store-only (level 0) zip named `zipName`.
|
|
18
|
+
export function downloadParts({ parts, ext, mime }, zipName) {
|
|
19
|
+
if (parts.length === 1) return triggerDownload(parts[0].data, `${parts[0].name}.${ext}`, mime);
|
|
20
|
+
const entries = {};
|
|
21
|
+
for (const p of parts) entries[`${p.name}.${ext}`] = new Uint8Array(p.data);
|
|
22
|
+
triggerDownload(zipSync(entries, { level: 0 }), zipName, "application/zip");
|
|
23
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// Pure classification math for OCCT feature labels. The OCCT backend meshes each
|
|
2
|
+
// labeled solid snapshot into a triangle soup; a face of the RESULT mesh belongs to
|
|
3
|
+
// a label when its sampled triangle centroids all lie on that soup's surface (a cut
|
|
4
|
+
// face lies exactly on its tool's surface, up to the two meshes' tolerances).
|
|
5
|
+
// No OCCT, no three.js — unit-testable with hand-built soups.
|
|
6
|
+
|
|
7
|
+
// Result mesh (preview) tolerance 0.1 + snapshot mesh tolerance 0.1 + slack.
|
|
8
|
+
const DEFAULT_TOL = 0.35; // mm — surfaces closer than this to another labeled surface can misattribute
|
|
9
|
+
const SAMPLES_PER_FACE = 4;
|
|
10
|
+
|
|
11
|
+
// Distance from point p to triangle (a,b,c) — the classic region-based projection.
|
|
12
|
+
export function pointTriDist(p, a, b, c) {
|
|
13
|
+
const sub = (u, v) => [u[0] - v[0], u[1] - v[1], u[2] - v[2]];
|
|
14
|
+
const dot = (u, v) => u[0] * v[0] + u[1] * v[1] + u[2] * v[2];
|
|
15
|
+
const ab = sub(b, a), ac = sub(c, a), ap = sub(p, a);
|
|
16
|
+
const d1 = dot(ab, ap), d2 = dot(ac, ap);
|
|
17
|
+
if (d1 <= 0 && d2 <= 0) return Math.hypot(...ap); // vertex a
|
|
18
|
+
const bp = sub(p, b);
|
|
19
|
+
const d3 = dot(ab, bp), d4 = dot(ac, bp);
|
|
20
|
+
if (d3 >= 0 && d4 <= d3) return Math.hypot(...bp); // vertex b
|
|
21
|
+
const vc = d1 * d4 - d3 * d2;
|
|
22
|
+
if (vc <= 0 && d1 >= 0 && d3 <= 0) { // edge ab
|
|
23
|
+
const t = d1 / (d1 - d3);
|
|
24
|
+
return Math.hypot(...sub(p, [a[0] + ab[0] * t, a[1] + ab[1] * t, a[2] + ab[2] * t]));
|
|
25
|
+
}
|
|
26
|
+
const cp = sub(p, c);
|
|
27
|
+
const d5 = dot(ab, cp), d6 = dot(ac, cp);
|
|
28
|
+
if (d6 >= 0 && d5 <= d6) return Math.hypot(...cp); // vertex c
|
|
29
|
+
const vb = d5 * d2 - d1 * d6;
|
|
30
|
+
if (vb <= 0 && d2 >= 0 && d6 <= 0) { // edge ac
|
|
31
|
+
const t = d2 / (d2 - d6);
|
|
32
|
+
return Math.hypot(...sub(p, [a[0] + ac[0] * t, a[1] + ac[1] * t, a[2] + ac[2] * t]));
|
|
33
|
+
}
|
|
34
|
+
const va = d3 * d6 - d5 * d4;
|
|
35
|
+
if (va <= 0 && d4 - d3 >= 0 && d5 - d6 >= 0) { // edge bc
|
|
36
|
+
const t = (d4 - d3) / (d4 - d3 + (d5 - d6));
|
|
37
|
+
const bc = sub(c, b);
|
|
38
|
+
return Math.hypot(...sub(p, [b[0] + bc[0] * t, b[1] + bc[1] * t, b[2] + bc[2] * t]));
|
|
39
|
+
}
|
|
40
|
+
const denom = 1 / (va + vb + vc); // interior
|
|
41
|
+
const v = vb * denom, w = vc * denom;
|
|
42
|
+
return Math.hypot(...sub(p, [a[0] + ab[0] * v + ac[0] * w, a[1] + ab[1] * v + ac[1] * w, a[2] + ab[2] * v + ac[2] * w]));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const centroid = (V, T, t) => {
|
|
46
|
+
const i = T[t * 3] * 3, j = T[t * 3 + 1] * 3, k = T[t * 3 + 2] * 3;
|
|
47
|
+
return [(V[i] + V[j] + V[k]) / 3, (V[i + 1] + V[j + 1] + V[k + 1]) / 3, (V[i + 2] + V[j + 2] + V[k + 2]) / 3];
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
function distToSoup(p, soup) {
|
|
51
|
+
const V = soup.vertices, T = soup.triangles;
|
|
52
|
+
let best = Infinity;
|
|
53
|
+
for (let t = 0; t < T.length / 3; t++) {
|
|
54
|
+
const a = [V[T[t * 3] * 3], V[T[t * 3] * 3 + 1], V[T[t * 3] * 3 + 2]];
|
|
55
|
+
const b = [V[T[t * 3 + 1] * 3], V[T[t * 3 + 1] * 3 + 1], V[T[t * 3 + 1] * 3 + 2]];
|
|
56
|
+
const c = [V[T[t * 3 + 2] * 3], V[T[t * 3 + 2] * 3 + 1], V[T[t * 3 + 2] * 3 + 2]];
|
|
57
|
+
const d = pointTriDist(p, a, b, c);
|
|
58
|
+
if (d < best) best = d;
|
|
59
|
+
}
|
|
60
|
+
return best;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// resultMesh: replicad ShapeMesh {vertices, triangles, faceGroups}; soups: labeled
|
|
64
|
+
// snapshots meshed by the caller. Returns {} when attribution isn't possible.
|
|
65
|
+
export function classifyFaceGroups(resultMesh, soups, tol = DEFAULT_TOL) {
|
|
66
|
+
const groups = resultMesh.faceGroups;
|
|
67
|
+
const nTri = resultMesh.triangles.length / 3;
|
|
68
|
+
if (!groups?.length || !soups.length) return {};
|
|
69
|
+
|
|
70
|
+
// faceGroups start/count units differ across replicad versions: triangle counts
|
|
71
|
+
// sum to nTri, index counts to nTri*3. Detect which this is.
|
|
72
|
+
const total = groups.reduce((s, g) => s + g.count, 0);
|
|
73
|
+
const div = total === nTri * 3 ? 3 : 1;
|
|
74
|
+
|
|
75
|
+
const indexOf = new Map(); // label -> 1-based feature index (same-label merge)
|
|
76
|
+
const features = [];
|
|
77
|
+
const featureIds = new Uint16Array(nTri);
|
|
78
|
+
|
|
79
|
+
for (const g of groups) {
|
|
80
|
+
const start = g.start / div, count = g.count / div;
|
|
81
|
+
if (count <= 0) continue; // degenerate group: no triangles, nothing to attribute
|
|
82
|
+
// sample a few spread triangles of the face
|
|
83
|
+
const picks = [];
|
|
84
|
+
const denom = Math.max(1, Math.min(SAMPLES_PER_FACE, count) - 1);
|
|
85
|
+
for (let s = 0; s < Math.min(SAMPLES_PER_FACE, count); s++) {
|
|
86
|
+
picks.push(start + Math.floor((s * (count - 1)) / denom));
|
|
87
|
+
}
|
|
88
|
+
// last matching soup wins (most recently applied label)
|
|
89
|
+
let winner = null;
|
|
90
|
+
for (const soup of soups) {
|
|
91
|
+
const onSurface = picks.every(
|
|
92
|
+
(t) => distToSoup(centroid(resultMesh.vertices, resultMesh.triangles, t), soup) <= tol
|
|
93
|
+
);
|
|
94
|
+
if (onSurface) winner = soup.label;
|
|
95
|
+
}
|
|
96
|
+
if (winner == null) continue;
|
|
97
|
+
let fi = indexOf.get(winner);
|
|
98
|
+
if (fi === undefined) { features.push(winner); fi = features.length; indexOf.set(winner, fi); }
|
|
99
|
+
for (let t = start; t < start + count; t++) featureIds[t] = fi;
|
|
100
|
+
}
|
|
101
|
+
return features.length ? { featureIds, features } : {};
|
|
102
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// The backend-shared kernel front. Each backend builds its primitive mapping and
|
|
2
|
+
// returns finishKernel(kernel), which layers on everything that is NOT
|
|
3
|
+
// backend-specific:
|
|
4
|
+
// - argument validation (previously copy-pasted into both backends);
|
|
5
|
+
// - default compound-op compositions — a backend only overrides one when it has
|
|
6
|
+
// a reason to (Manifold's boredCylinder hashes atomically for its solid cache);
|
|
7
|
+
// - a KernelCapabilityError stub for toSTEP when the backend can't write B-rep.
|
|
8
|
+
// The per-Solid twin of this layer is addSugar() in solid-sugar.js.
|
|
9
|
+
import { KernelCapabilityError } from "./errors.js";
|
|
10
|
+
|
|
11
|
+
export function finishKernel(k) {
|
|
12
|
+
const rawPrism = k.prism;
|
|
13
|
+
k.prism = (pts, h, opts) => {
|
|
14
|
+
if ((opts?.scaleTop ?? 1) < 0) throw new Error("prism: scaleTop must be ≥ 0");
|
|
15
|
+
return rawPrism(pts, h, opts);
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const rawRevolve = k.revolve;
|
|
19
|
+
k.revolve = (pts, opts) => {
|
|
20
|
+
for (const [r] of pts) if (r < 0) throw new Error("revolve: profile radius must be ≥ 0");
|
|
21
|
+
return rawRevolve(pts, opts);
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
// Compound: bored-through cylinder (tool overshoots 2 mm each end for a clean cut).
|
|
25
|
+
k.boredCylinder ??= ({ od, h, bore }) =>
|
|
26
|
+
k.cylinder(od / 2, od / 2, h).cut(k.cylinder(bore / 2, bore / 2, h + 4).translate([0, 0, -2]));
|
|
27
|
+
|
|
28
|
+
k.toSTEP ??= () => { throw new KernelCapabilityError("toSTEP requires the OCCT backend"); };
|
|
29
|
+
|
|
30
|
+
return k;
|
|
31
|
+
}
|
|
@@ -1,15 +1,48 @@
|
|
|
1
|
-
// The GeometryKernel contract
|
|
2
|
-
//
|
|
1
|
+
// The GeometryKernel contract. The op lists below are DATA, not just docs: the
|
|
2
|
+
// parity tests (test/kernel-contract.test.js and the OCCT twin in
|
|
3
|
+
// test/occt-backend.test.js) assert each backend exposes exactly these ops, so the
|
|
4
|
+
// contract can't silently drift from the implementations — the drift class that
|
|
5
|
+
// once broke the probe kernel (see probe.js). The @typedefs document signatures.
|
|
6
|
+
// (2-D polygon helpers live in ./polygon.js.)
|
|
7
|
+
|
|
8
|
+
// Ops every backend kernel must implement.
|
|
9
|
+
export const KERNEL_OPS = [
|
|
10
|
+
"cylinder", "boredCylinder", "sphere", "box", "prism", "revolve",
|
|
11
|
+
"helixSweptTube", "union", "toSTEP",
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
// Backend-optional kernel ops: the Manifold cache brackets + WASM lifetime hooks.
|
|
15
|
+
// jobs.js calls all of these via `?.`, so a backend may simply omit them.
|
|
16
|
+
export const KERNEL_OPTIONAL_OPS = [
|
|
17
|
+
"beginSubPart", "endSubPart", "cacheStats", "resetCacheStats", "cleanup",
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
// Ops every Solid must implement (including the sugar addSugar() attaches).
|
|
21
|
+
export const SOLID_OPS = [
|
|
22
|
+
"cut", "cutAll", "intersect", "clone", "label", "boundingBox", "volume",
|
|
23
|
+
"translate", "rotate", "rotateX", "rotateY", "rotateZ", "rotateAbout", "along", "at",
|
|
24
|
+
"mirror", "scale", "toMesh", "toSTL", "toIndexedMesh",
|
|
25
|
+
"fillet", "chamfer", "shell",
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
// Backend-optional Solid queries: Manifold mesh-topology numbers (measure.js
|
|
29
|
+
// guards with `typeof`); OCCT has no cheap equivalent.
|
|
30
|
+
export const SOLID_OPTIONAL_OPS = ["genus", "isEmpty"];
|
|
31
|
+
|
|
32
|
+
// Solid ops only OCCT implements natively. Single source of truth: probe.js routes
|
|
33
|
+
// a part to OCCT when its build uses one of these, and the Manifold backend
|
|
34
|
+
// generates its KernelCapabilityError stubs from the same list — adding an op here
|
|
35
|
+
// wires up both automatically.
|
|
36
|
+
export const OCCT_ONLY_OPS = ["fillet", "chamfer", "shell"];
|
|
3
37
|
|
|
4
38
|
/**
|
|
5
|
-
* @typedef {Object} Solid An opaque handle to a backend solid.
|
|
6
|
-
* @property {string} _hash content hash (Manifold backend only; drives the worker solid cache)
|
|
39
|
+
* @typedef {Object} Solid An opaque handle to a backend solid. `_`-prefixed keys are backend internals.
|
|
7
40
|
* @property {(tool: Solid) => Solid} cut
|
|
8
41
|
* @property {(tools: Solid[]) => Solid} cutAll batch subtract (backend-optimized)
|
|
9
|
-
* @property {(other: Solid) => Solid} intersect boolean intersection (
|
|
42
|
+
* @property {(other: Solid) => Solid} intersect boolean intersection (both backends)
|
|
10
43
|
* @property {() => Solid} clone independent copy (replicad consumes solids on transform)
|
|
44
|
+
* @property {(name: string) => Solid} label name this solid's surface for hover/pick feature attribution (survives transforms + booleans; same name on several solids merges into one feature)
|
|
11
45
|
* @property {() => {min:number[],max:number[],center:number[],size:number[]}} boundingBox axis-aligned bounds (query)
|
|
12
|
-
* @property {(thickness:number, openFaces:object) => Solid} shell hollow inward (OCCT only); openFaces selector required
|
|
13
46
|
* @property {(v: number[]) => Solid} translate
|
|
14
47
|
* @property {(deg: number, center: number[], axis: number[]) => Solid} rotate internal primitive — prefer rotateX/Y/Z / rotateAbout
|
|
15
48
|
* @property {(deg: number) => Solid} rotateX rotate about world X through the origin
|
|
@@ -20,10 +53,16 @@
|
|
|
20
53
|
* @property {(v:number[]) => Solid} at place an origin-built solid at point v (alias of translate)
|
|
21
54
|
* @property {(plane: "XY"|"XZ"|"YZ") => Solid} mirror
|
|
22
55
|
* @property {(factor:number, center?:number[]) => Solid} scale uniform scale about center (default origin)
|
|
23
|
-
* @property {() => number} volume solid volume in mm³ (
|
|
24
|
-
* @property {(opts?: {quality?: "preview"|"print"}) => {positions:Float32Array, normals:Float32Array, indices
|
|
56
|
+
* @property {() => number} volume solid volume in mm³ (both backends; used by collision/overlap tests)
|
|
57
|
+
* @property {(opts?: {quality?: "preview"|"print"}) => {positions:Float32Array, normals:Float32Array, indices?:Uint32Array, triangles:number, edges?:Float32Array}} toMesh
|
|
58
|
+
* `edges` = feature-edge line segments (Manifold); quality is advisory — the Manifold kernel bakes it at creation
|
|
25
59
|
* @property {(opts?: {quality?: "preview"|"print"}) => Promise<ArrayBuffer>} toSTL
|
|
26
|
-
* @property {() => {positions:Float32Array, indices:Uint32Array}} toIndexedMesh indexed mesh, for 3MF
|
|
60
|
+
* @property {() => {positions:Float32Array, indices:Uint32Array}} toIndexedMesh indexed mesh, for 3MF
|
|
61
|
+
* @property {(radius:number, selector?:object) => Solid} fillet round edges (OCCT only; Manifold throws KernelCapabilityError)
|
|
62
|
+
* @property {(distance:number, selector?:object) => Solid} chamfer bevel edges (OCCT only; Manifold throws KernelCapabilityError)
|
|
63
|
+
* @property {(thickness:number, openFaces:object) => Solid} shell hollow inward (OCCT only); openFaces selector required
|
|
64
|
+
* @property {() => number} [genus] through-hole count (Manifold only)
|
|
65
|
+
* @property {() => boolean} [isEmpty] no geometry at all (Manifold only)
|
|
27
66
|
*
|
|
28
67
|
* @typedef {Object} GeometryKernel
|
|
29
68
|
* @property {(rBottom:number, rTop:number, h:number, opts?:{center?:boolean}) => Solid} cylinder
|
|
@@ -34,6 +73,10 @@
|
|
|
34
73
|
* @property {(points2D:number[][], opts?:{degrees?:number}) => Solid} revolve revolve a lathe profile [[r,z],…] around Z
|
|
35
74
|
* @property {(o:{pathR:number,profileR:number,pitch:number,turns:number,z0:number,lefthand:boolean}) => Solid} helixSweptTube
|
|
36
75
|
* @property {(solids:Solid[]) => Solid} union
|
|
37
|
-
* @property {(named:{name:string,solid:Solid}[]) => Promise<ArrayBuffer>} toSTEP OCCT only
|
|
76
|
+
* @property {(named:{name:string,solid:Solid}[]) => Promise<ArrayBuffer>} toSTEP OCCT only (Manifold throws KernelCapabilityError)
|
|
77
|
+
* @property {(name:string) => void} [beginSubPart] open a per-sub-part solid-cache round (Manifold only)
|
|
78
|
+
* @property {() => void} [endSubPart] close the cache round (always pair with beginSubPart)
|
|
79
|
+
* @property {() => {hits:number,misses:number}} [cacheStats]
|
|
80
|
+
* @property {() => void} [resetCacheStats]
|
|
38
81
|
* @property {() => void} [cleanup] free per-job WASM objects (Manifold backend); call after each job
|
|
39
82
|
*/
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { helixTube } from "./helix-tube.js";
|
|
2
|
-
import { KernelCapabilityError } from "./errors.js";
|
|
3
2
|
import { h } from "./solid-hash.js";
|
|
4
3
|
import { createSolidCache } from "./solid-cache.js";
|
|
5
4
|
import { addSugar } from "./solid-sugar.js";
|
|
5
|
+
import { finishKernel } from "./kernel-front.js";
|
|
6
6
|
|
|
7
7
|
const PLANE_NORMAL = { XY: [0, 0, 1], XZ: [0, 1, 0], YZ: [1, 0, 0] };
|
|
8
8
|
// 'preview' = interactive view (fast); 'print' = STL export (high-res, used only
|
|
@@ -38,6 +38,7 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
|
|
|
38
38
|
const unionRaw = (ms) => ms.reduce((a, b) => T(a.add(b))); // track each reduce step
|
|
39
39
|
|
|
40
40
|
const cache = createSolidCache();
|
|
41
|
+
const featureLabels = new Map(); // originalID -> label string (grows per label(); tiny)
|
|
41
42
|
// Boundary ops route through cache.lookup; on a miss `make` runs the WASM op,
|
|
42
43
|
// tracks the result, and returns the triple the cache needs to pin/dispose it.
|
|
43
44
|
const cached = (hash, computeM) => cache.lookup(hash, () => {
|
|
@@ -49,7 +50,7 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
|
|
|
49
50
|
// transient mesh handle.
|
|
50
51
|
function meshOut(m, asStl) {
|
|
51
52
|
const g = m.getMesh();
|
|
52
|
-
const r = asStl ? stlFromMesh(g) : creasedNormals(g, Math.cos((SHARP_ANGLE * Math.PI) / 180));
|
|
53
|
+
const r = asStl ? stlFromMesh(g) : creasedNormals(g, Math.cos((SHARP_ANGLE * Math.PI) / 180), featureLabels);
|
|
53
54
|
g.delete?.();
|
|
54
55
|
return r;
|
|
55
56
|
}
|
|
@@ -79,14 +80,23 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
|
|
|
79
80
|
() => T(m.subtract(unionRaw(tools.map((t) => t._m))))),
|
|
80
81
|
intersect: (t) => cached(h("intersect", hash, t._hash), () => T(m.intersect(t._m))),
|
|
81
82
|
clone: () => wrap(m, hash),
|
|
83
|
+
// Name this solid's surface for hover/pick feature attribution. asOriginal()
|
|
84
|
+
// stamps a fresh originalID that survives transforms and booleans, so every
|
|
85
|
+
// surviving triangle of this surface can be traced back to the label. The
|
|
86
|
+
// registry entry lives exactly as long as the cache pins the solid — eviction
|
|
87
|
+
// disposes both, so the registry can't grow unboundedly across regenerates.
|
|
88
|
+
label: (name) => {
|
|
89
|
+
const lh = h("label", hash, name);
|
|
90
|
+
return cache.lookup(lh, () => {
|
|
91
|
+
const o = T(m.asOriginal());
|
|
92
|
+
const id = o.originalID();
|
|
93
|
+
featureLabels.set(id, name);
|
|
94
|
+
return { value: wrap(o, lh), pin: o, dispose: () => { featureLabels.delete(id); o.delete?.(); } };
|
|
95
|
+
});
|
|
96
|
+
},
|
|
82
97
|
boundingBox: () => {
|
|
83
|
-
const b = m.boundingBox(); // { min: Vec3, max: Vec3 }
|
|
84
|
-
|
|
85
|
-
return {
|
|
86
|
-
min, max,
|
|
87
|
-
center: [(min[0] + max[0]) / 2, (min[1] + max[1]) / 2, (min[2] + max[2]) / 2],
|
|
88
|
-
size: [max[0] - min[0], max[1] - min[1], max[2] - min[2]],
|
|
89
|
-
};
|
|
98
|
+
const b = m.boundingBox(); // { min: Vec3, max: Vec3 } — addSugar derives center/size
|
|
99
|
+
return { min: [...b.min], max: [...b.max] };
|
|
90
100
|
},
|
|
91
101
|
volume: () => m.volume(),
|
|
92
102
|
genus: () => m.genus(),
|
|
@@ -101,8 +111,7 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
|
|
|
101
111
|
return wrap(T(b.translate(center)), h("rotate", hash, deg, center, axis));
|
|
102
112
|
},
|
|
103
113
|
mirror: (plane) => wrap(T(m.mirror(PLANE_NORMAL[plane])), h("mirror", hash, plane)),
|
|
104
|
-
scale: (factor, center
|
|
105
|
-
if (!(factor > 0)) throw new Error("scale: factor must be > 0");
|
|
114
|
+
scale: (factor, center) => { // factor validated (and center defaulted) by addSugar
|
|
106
115
|
const a = T(m.translate([-center[0], -center[1], -center[2]]));
|
|
107
116
|
const b = T(a.scale([factor, factor, factor]));
|
|
108
117
|
return wrap(T(b.translate(center)), h("scale", hash, factor, center));
|
|
@@ -110,12 +119,9 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
|
|
|
110
119
|
toMesh: () => meshOut(m, false),
|
|
111
120
|
toSTL: () => Promise.resolve(meshOut(m, true)),
|
|
112
121
|
toIndexedMesh: () => indexedMeshOut(m),
|
|
113
|
-
fillet: () => { throw new KernelCapabilityError("fillet requires the OCCT backend"); },
|
|
114
|
-
chamfer: () => { throw new KernelCapabilityError("chamfer requires the OCCT backend"); },
|
|
115
|
-
shell: () => { throw new KernelCapabilityError("shell requires the OCCT backend"); },
|
|
116
122
|
});
|
|
117
123
|
|
|
118
|
-
return {
|
|
124
|
+
return finishKernel({
|
|
119
125
|
cylinder: (rb, rt, h2, { center = false } = {}) =>
|
|
120
126
|
wrap(T(Manifold.cylinder(h2, rb, rt, segs, center)), h("cylinder", rb, rt, h2, center, segs)),
|
|
121
127
|
// Compound op: hashed ATOMICALLY from its own args, so it is a single cache
|
|
@@ -134,20 +140,17 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
|
|
|
134
140
|
},
|
|
135
141
|
prism: (pts, height, { twist = 0, scaleTop = 1 } = {}) =>
|
|
136
142
|
cached(h("prism", pts, height, twist, scaleTop, segs), () => {
|
|
137
|
-
if (scaleTop < 0) throw new Error("prism: scaleTop must be ≥ 0");
|
|
138
143
|
const cs = T(CrossSection.ofPolygons([pts]));
|
|
139
144
|
if (twist === 0 && scaleTop === 1) return T(cs.extrude(height));
|
|
140
145
|
const nDiv = Math.max(1, Math.ceil(Math.abs(twist) / 5));
|
|
141
|
-
|
|
146
|
+
// Manifold's extrude scaleTop is a Vec2 — a scalar is NOT broadcast (it scales
|
|
147
|
+
// X and drives Y to 0, squishing the top to a line). Broadcast for a uniform taper.
|
|
148
|
+
return T(cs.extrude(height, nDiv, twist, [scaleTop, scaleTop]));
|
|
142
149
|
}),
|
|
143
150
|
helixSweptTube: (o) => cached(h("helixSweptTube", o, tube), () => T(helixTube(wasm, { ...o, ...tube }))),
|
|
144
151
|
revolve: (pts, { degrees = 360 } = {}) =>
|
|
145
|
-
cached(h("revolve", pts, degrees, segs), () =>
|
|
146
|
-
for (const [r] of pts) if (r < 0) throw new Error("revolve: profile radius must be ≥ 0");
|
|
147
|
-
return T(Manifold.revolve([pts], segs, degrees));
|
|
148
|
-
}),
|
|
152
|
+
cached(h("revolve", pts, degrees, segs), () => T(Manifold.revolve([pts], segs, degrees))),
|
|
149
153
|
union: (solids) => cached(h("union", solids.map((s) => s._hash)), () => unionRaw(solids.map((s) => s._m))),
|
|
150
|
-
toSTEP: () => { throw new Error("STEP export not supported by the Manifold backend"); },
|
|
151
154
|
beginSubPart: (name) => cache.begin(name),
|
|
152
155
|
endSubPart: () => cache.end(),
|
|
153
156
|
cacheStats: () => cache.stats(),
|
|
@@ -155,7 +158,7 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
|
|
|
155
158
|
// Free every WASM object created since the last cleanup EXCEPT solids the cache
|
|
156
159
|
// still pins (they must survive for the next build to resume from them).
|
|
157
160
|
cleanup: () => { for (const o of tracked) if (!cache.isPinned(o)) o.delete?.(); tracked.length = 0; },
|
|
158
|
-
};
|
|
161
|
+
});
|
|
159
162
|
}
|
|
160
163
|
|
|
161
164
|
// Build a non-indexed mesh with normals that are smooth within a single original
|
|
@@ -164,7 +167,7 @@ export function createManifoldKernel(wasm, { quality = "preview" } = {}) {
|
|
|
164
167
|
// only over incident triangles of the SAME original surface that also meet within
|
|
165
168
|
// `sharpCos` — so cut seams stay crisp at any angle (even near-tangent), and a
|
|
166
169
|
// surface's own sharp edges (e.g. a face meeting a side) stay crisp too.
|
|
167
|
-
function creasedNormals(g, sharpCos) {
|
|
170
|
+
function creasedNormals(g, sharpCos, featureLabels) {
|
|
168
171
|
const np = g.numProp, vp = g.vertProperties, tris = g.triVerts;
|
|
169
172
|
const nTri = (tris.length / 3) | 0, nVert = (vp.length / np) | 0;
|
|
170
173
|
|
|
@@ -243,7 +246,27 @@ function creasedNormals(g, sharpCos) {
|
|
|
243
246
|
}
|
|
244
247
|
}
|
|
245
248
|
|
|
246
|
-
|
|
249
|
+
// Per-triangle feature attribution: map each triangle's original-surface id
|
|
250
|
+
// through the label registry. Same label string → same feature entry, so a
|
|
251
|
+
// pattern of solids labeled alike reads as one feature.
|
|
252
|
+
let featureIds = null, features = null;
|
|
253
|
+
if (featureLabels?.size) {
|
|
254
|
+
const indexOf = new Map(); // label string -> 1-based feature index
|
|
255
|
+
features = [];
|
|
256
|
+
featureIds = new Uint16Array(nTri);
|
|
257
|
+
for (let t = 0; t < nTri; t++) {
|
|
258
|
+
const label = featureLabels.get(triOID[t]);
|
|
259
|
+
if (label === undefined) continue;
|
|
260
|
+
let fi = indexOf.get(label);
|
|
261
|
+
if (fi === undefined) { features.push(label); fi = features.length; indexOf.set(label, fi); }
|
|
262
|
+
featureIds[t] = fi;
|
|
263
|
+
}
|
|
264
|
+
if (features.length === 0) { featureIds = features = null; } // labels exist in the kernel, none in THIS mesh
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const out = { positions, normals, triangles: nTri, edges: Float32Array.from(edges) }; // mesh non-indexed
|
|
268
|
+
if (featureIds) { out.featureIds = featureIds; out.features = features; }
|
|
269
|
+
return out;
|
|
247
270
|
}
|
|
248
271
|
|
|
249
272
|
function stlFromMesh(g) {
|