@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.
- package/README.md +1 -5
- package/dist/compat.js +641 -100
- package/dist/examples/bisect.jscad +471 -93
- package/dist/examples/bisect2.jscad +2614 -0
- package/dist/examples/boxes.jscad +481 -102
- package/dist/examples/chamfer.jscad +471 -93
- package/dist/examples/fillet.jscad +471 -93
- package/dist/examples/fit.jscad +471 -93
- package/dist/examples/groups.jscad +471 -93
- package/dist/examples/midlineTo.jscad +471 -93
- package/dist/examples/parts-hexagon.jscad +471 -93
- package/dist/examples/rabett-tb.jscad +471 -93
- package/dist/examples/rabett.jscad +471 -93
- package/dist/examples/rabett2.jscad +471 -93
- package/dist/examples/rabett3.jscad +2614 -0
- package/dist/examples/retraction-test.jscad +471 -93
- package/dist/examples/size.jscad +471 -93
- package/dist/examples/snap.jscad +471 -93
- package/dist/examples/text.jscad +471 -93
- package/dist/examples/wedge.jscad +471 -93
- package/dist/index.js +638 -100
- package/package.json +8 -4
- package/src/add-prototype.js +5 -1
- package/src/boxes.js +11 -3
- package/src/compat.js +3 -0
- package/src/group.js +13 -11
- package/src/parts.js +25 -23
- package/src/util.js +239 -72
- package/src/validate.js +335 -0
package/src/validate.js
ADDED
|
@@ -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
|
+
}
|