@jwc/jscad-utils 5.2.0 → 5.5.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.
@@ -0,0 +1,335 @@
1
+ /* globals jscadUtilsAssertValidCSG jscadUtilsAssertValidCSGWarnings */
2
+ /**
3
+ * @typedef {Object} CSG
4
+ * @property {Array<{vertices: Array<{pos: any}>}>} polygons
5
+ * @property {function(): CSG} [canonicalized]
6
+ * @property {function(): CSG} [reTesselated]
7
+ * @property {function(): CSG} [fixTJunctions]
8
+ * @property {function(): Array} [getBounds]
9
+ * @property {Object} [properties]
10
+ */
11
+ /**
12
+ * Validate that a CSG object represents a solid, watertight mesh
13
+ * without degenerate faces. Returns an object with an `ok` boolean
14
+ * and an `errors` array describing any problems found.
15
+ *
16
+ * Checks performed:
17
+ * - **No empty mesh** – the object must contain at least one polygon.
18
+ * - **No degenerate polygons** – every polygon must have ≥ 3 vertices
19
+ * and a computable area greater than `EPS²`.
20
+ * - **Watertight / manifold edges** – every directed edge A→B in the
21
+ * mesh must be matched by exactly one reverse edge B→A in another
22
+ * polygon. Unmatched edges indicate holes; edges shared more than
23
+ * twice indicate non-manifold geometry.
24
+ *
25
+ * By default, the mesh is canonicalized and T-junctions are repaired
26
+ * before validation so that results from boolean operations (union,
27
+ * subtract, intersect) can be validated successfully. Pass
28
+ * `{ fixTJunctions: false }` to skip this step and validate the raw
29
+ * mesh.
30
+ *
31
+ * @param {CSG} csg The CSG object to validate.
32
+ * @param {object} [options] Validation options.
33
+ * @param {boolean} [options.fixTJunctions=true] Whether to canonicalize and fix T-junctions before validation.
34
+ * @return {{ ok: boolean, errors: string[], warnings: string[] }} Validation result.
35
+ * @function validateCSG
36
+ */
37
+ export function validateCSG(csg, options) {
38
+ /** @type {string[]} */
39
+ var errors = [];
40
+ /** @type {string[]} */
41
+ var warnings = [];
42
+
43
+ if (!csg || !csg.polygons || csg.polygons.length === 0) {
44
+ errors.push('Empty mesh: no polygons');
45
+ return { ok: false, errors, warnings };
46
+ }
47
+
48
+ const opts = { fixTJunctions: true, ...options };
49
+
50
+ // Optionally canonicalize and fix T-junctions so that boolean-op
51
+ // output can pass the watertight check.
52
+ if (opts.fixTJunctions && typeof csg.canonicalized === 'function') {
53
+ csg = csg.canonicalized();
54
+ if (typeof csg.reTesselated === 'function') {
55
+ csg = csg.reTesselated();
56
+ }
57
+ if (typeof csg.fixTJunctions === 'function') {
58
+ csg = csg.fixTJunctions();
59
+ }
60
+ }
61
+
62
+ var AREA_EPS = 1e-10;
63
+ var KEY_EPS = 1e-5;
64
+ var degenerateCount = 0;
65
+ var invalidVertexCount = 0;
66
+
67
+ // Check for NaN/Infinity vertex coordinates which cause WebGL errors
68
+ // (GL_INVALID_VALUE: glVertexAttribPointer: Vertex attribute size must be 1, 2, 3, or 4)
69
+ for (const npoly of csg.polygons) {
70
+ for (const nvert of npoly.vertices) {
71
+ const np = nvert.pos;
72
+ if (
73
+ !Number.isFinite(np.x) || !Number.isFinite(np.y) || !Number.isFinite(np.z) ||
74
+ Number.isNaN(np.x) || Number.isNaN(np.y) || Number.isNaN(np.z)
75
+ ) {
76
+ invalidVertexCount++;
77
+ break;
78
+ }
79
+ }
80
+ }
81
+ if (invalidVertexCount > 0) {
82
+ errors.push(
83
+ invalidVertexCount +
84
+ ' polygon(s) with invalid vertex coordinates (NaN or Infinity)'
85
+ );
86
+ }
87
+
88
+ // Position-based vertex key (shared vertices across polygons have different
89
+ // object tags but the same position, so we round coordinates to match them).
90
+ /** @param {{ pos: { x: number, y: number, z: number } }} v */
91
+ function vtxKey(v) {
92
+ var p = v.pos;
93
+ return (
94
+ Math.round(p.x / KEY_EPS) +
95
+ ',' +
96
+ Math.round(p.y / KEY_EPS) +
97
+ ',' +
98
+ Math.round(p.z / KEY_EPS)
99
+ );
100
+ }
101
+
102
+ // First pass: identify degenerate polygons
103
+ var validPolygons = [];
104
+ for (const poly of csg.polygons) {
105
+ const verts = poly.vertices;
106
+ const nv = verts.length;
107
+
108
+ if (nv < 3) {
109
+ degenerateCount++;
110
+ continue;
111
+ }
112
+
113
+ // Skip polygons with invalid vertex coordinates
114
+ let hasInvalid = false;
115
+ for (const vert of verts) {
116
+ const ip = vert.pos;
117
+ if (!Number.isFinite(ip.x) || !Number.isFinite(ip.y) || !Number.isFinite(ip.z)) {
118
+ hasInvalid = true;
119
+ break;
120
+ }
121
+ }
122
+ if (hasInvalid) continue;
123
+
124
+ // Check degenerate area using cross-product summation
125
+ let area = 0;
126
+ for (let ai = 0; ai < nv - 2; ai++) {
127
+ area += verts[ai + 1].pos
128
+ .minus(verts[0].pos)
129
+ .cross(verts[ai + 2].pos.minus(verts[ai + 1].pos))
130
+ .length();
131
+ }
132
+ area *= 0.5;
133
+ if (area < AREA_EPS) {
134
+ degenerateCount++;
135
+ continue;
136
+ }
137
+
138
+ validPolygons.push(poly);
139
+ }
140
+
141
+ if (degenerateCount > 0) {
142
+ warnings.push(
143
+ degenerateCount +
144
+ ' degenerate polygon(s) (fewer than 3 vertices or near-zero area)'
145
+ );
146
+
147
+ // Rebuild the CSG from valid polygons only and re-run the repair
148
+ // pipeline so that fixTJunctions can close gaps left by the removed
149
+ // degenerate faces.
150
+ /* eslint-disable no-undef */
151
+ // @ts-ignore — CSG is a runtime global injected by the JSCAD compat layer
152
+ if (opts.fixTJunctions && typeof CSG !== 'undefined') {
153
+ // @ts-ignore
154
+ var cleaned = CSG.fromPolygons(validPolygons);
155
+ /* eslint-enable no-undef */
156
+ cleaned = cleaned.canonicalized();
157
+ if (typeof cleaned.reTesselated === 'function') {
158
+ cleaned = cleaned.reTesselated();
159
+ }
160
+ if (typeof cleaned.fixTJunctions === 'function') {
161
+ cleaned = cleaned.fixTJunctions();
162
+ }
163
+ // Re-scan for valid polygons after second repair pass
164
+ validPolygons = [];
165
+ for (const cpoly of cleaned.polygons) {
166
+ const cverts = cpoly.vertices;
167
+ const cnv = cverts.length;
168
+ if (cnv < 3) continue;
169
+ let carea = 0;
170
+ for (let cai = 0; cai < cnv - 2; cai++) {
171
+ carea += cverts[cai + 1].pos
172
+ .minus(cverts[0].pos)
173
+ .cross(cverts[cai + 2].pos.minus(cverts[cai + 1].pos))
174
+ .length();
175
+ }
176
+ carea *= 0.5;
177
+ if (carea < AREA_EPS) continue;
178
+ validPolygons.push(cpoly);
179
+ }
180
+ }
181
+ }
182
+
183
+ // Edge map: key = "vtxKeyA/vtxKeyB", value = count
184
+ /** @type {Record<string, number>} */
185
+ var edgeCounts = {};
186
+
187
+ // Accumulate directed edges from valid polygons only
188
+ for (const vpoly of validPolygons) {
189
+ const vverts = vpoly.vertices;
190
+ const vnv = vverts.length;
191
+ for (let ei = 0; ei < vnv; ei++) {
192
+ const v0 = vverts[ei];
193
+ const v1 = vverts[(ei + 1) % vnv];
194
+ const edgeKey = vtxKey(v0) + '/' + vtxKey(v1);
195
+ edgeCounts[edgeKey] = (edgeCounts[edgeKey] || 0) + 1;
196
+ }
197
+ }
198
+
199
+ // Check edge manifoldness: every edge A→B should be cancelled by B→A
200
+ var unmatchedEdges = 0;
201
+ var nonManifoldEdges = 0;
202
+ /** @type {Record<string, boolean>} */
203
+ var checked = {};
204
+
205
+ for (const edgeKey of Object.keys(edgeCounts)) {
206
+ if (checked[edgeKey]) continue;
207
+
208
+ const parts = edgeKey.split('/');
209
+ const reverseKey = parts[1] + '/' + parts[0];
210
+ const forwardCount = edgeCounts[edgeKey] || 0;
211
+ const reverseCount = edgeCounts[reverseKey] || 0;
212
+
213
+ checked[edgeKey] = true;
214
+ checked[reverseKey] = true;
215
+
216
+ if (forwardCount !== reverseCount) {
217
+ unmatchedEdges += Math.abs(forwardCount - reverseCount);
218
+ }
219
+ if (forwardCount > 1 || reverseCount > 1) {
220
+ nonManifoldEdges++;
221
+ }
222
+ }
223
+
224
+ if (unmatchedEdges > 0) {
225
+ errors.push(
226
+ unmatchedEdges +
227
+ ' unmatched edge(s): mesh is not watertight'
228
+ );
229
+ }
230
+ if (nonManifoldEdges > 0) {
231
+ errors.push(
232
+ nonManifoldEdges +
233
+ ' non-manifold edge(s): edge shared by more than 2 polygons'
234
+ );
235
+ }
236
+
237
+ return { ok: errors.length === 0, errors: errors, warnings: warnings };
238
+ }
239
+
240
+ /** @param {any} csg @returns {any} */
241
+ function _noOp(csg) {
242
+ return csg;
243
+ }
244
+
245
+ /**
246
+ * @param {boolean} warnEnabled
247
+ * @returns {function(*, string=, string=): *}
248
+ */
249
+ function _makeAssertFn(warnEnabled) {
250
+ return function _assert(csg, functionName = 'unknown', moduleName = 'unknown') {
251
+ // Only validate CSG-like objects (they have a polygons array).
252
+ // CAG objects (2D cross-sections) have `sides` instead and are passed through.
253
+ if (!csg || csg.polygons === undefined) return csg;
254
+ var result = validateCSG(csg);
255
+ if (!result.ok) {
256
+ throw new Error(
257
+ moduleName + ':' + functionName + ': ' + 'invalid CSG: ' + result.errors.join(', ')
258
+ );
259
+ }
260
+ if (warnEnabled && result.warnings.length > 0) {
261
+ throw new Error(
262
+ moduleName + ':' + functionName + ': ' + 'CSG warnings: ' + result.warnings.join(', ')
263
+ );
264
+ }
265
+ return csg;
266
+ };
267
+ }
268
+
269
+ // Live pointer that all returned closures call through — swap this and all
270
+ // existing closures immediately pick up the change.
271
+ /** @type {function(*, string=, string=): *} */
272
+ var _assertFn = _noOp;
273
+
274
+ // True once setValidationEnabled() has been called explicitly, preventing
275
+ // compat globals from overriding the programmatic setting.
276
+ var _setExplicitly = false;
277
+
278
+ // Read compat globals set by initJscadutils — mirrors the Debug() pattern
279
+ // in debug.js. Returns _noOp when globals are absent (ESM / test context).
280
+ function _resolveFromGlobals() {
281
+ /* eslint-disable no-undef */
282
+ // @ts-ignore — globals set by the JSCAD compat layer before bundle injection
283
+ var enabled = typeof jscadUtilsAssertValidCSG !== 'undefined' && !!jscadUtilsAssertValidCSG;
284
+ // @ts-ignore
285
+ var warnEnabled = typeof jscadUtilsAssertValidCSGWarnings !== 'undefined' && !!jscadUtilsAssertValidCSGWarnings;
286
+ /* eslint-enable no-undef */
287
+ return enabled ? _makeAssertFn(warnEnabled) : _noOp;
288
+ }
289
+
290
+ /**
291
+ * Enable or disable CSG validation assertions globally.
292
+ * Overrides any compat globals. Because all asserters call through the live
293
+ * `_assertFn` pointer, toggling here takes effect immediately with no
294
+ * per-call conditional overhead.
295
+ * @param {boolean} enabled
296
+ * @param {boolean} [warnEnabled] Also throw on warnings when true.
297
+ */
298
+ export function setValidationEnabled(enabled, warnEnabled) {
299
+ _assertFn = enabled ? _makeAssertFn(!!warnEnabled) : _noOp;
300
+ _setExplicitly = true;
301
+ }
302
+
303
+ /**
304
+ * Returns an asserter function bound to `moduleName`. Call the returned
305
+ * function with a CSG object and the calling function's name to validate it
306
+ * (when enabled) or pass it through unchanged (when disabled).
307
+ *
308
+ * Best practice is to call `AssertValidCSG` once per module at load time and
309
+ * capture the result as a module-level constant so that `moduleName` appears
310
+ * consistently in every error message thrown from that module.
311
+ *
312
+ * On creation, reads compat globals (jscadUtilsAssertValidCSG /
313
+ * jscadUtilsAssertValidCSGWarnings) if setValidationEnabled() has not been
314
+ * called explicitly — identical to how Debug('name') reads jscadUtilsDebug.
315
+ *
316
+ * Error message format: `moduleName:functionName: invalid CSG: <errors>`
317
+ *
318
+ * @example
319
+ * // Once at the top of your module:
320
+ * const assertValidCSG = AssertValidCSG('myModule');
321
+ *
322
+ * export function enlarge(object, ...) {
323
+ * // ...
324
+ * return assertValidCSG(new_object.translate(delta), 'enlarge');
325
+ * }
326
+ *
327
+ * @param {string} [moduleName='unknown'] Module name, included in error messages.
328
+ * @return {function(CSG, string=): CSG}
329
+ */
330
+ export function AssertValidCSG(moduleName = 'unknown') {
331
+ if (!_setExplicitly) {
332
+ _assertFn = _resolveFromGlobals();
333
+ }
334
+ return function (csg, name) { return _assertFn(csg, name, moduleName); };
335
+ }