brep-io-kernel 1.0.97 → 1.0.99
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/dist-kernel/brep-kernel.js +24918 -23977
- package/package.json +1 -1
- package/src/BREP/SolidMethods/booleanOps.js +20 -4
- package/src/UI/sketcher/SketchMode3D.js +157 -33
- package/src/UI/sketcher/dimensions.js +59 -0
- package/src/UI/sketcher/glyphs.js +31 -0
- package/src/UI/sketcher/highlights.js +3 -1
- package/src/features/hole/HoleFeature.js +264 -17
- package/src/features/sketch/SketchFeature.js +117 -12
- package/src/features/sketch/sketchSolver2D/ConstraintEngine.js +42 -7
- package/src/features/sketch/sketchSolver2D/constraintDefinitions.js +104 -0
- package/src/tests/fixtures/sketchSolverTopology/README.md +46 -0
- package/src/tests/fixtures/sketchSolverTopology/coincident_chain_fixture.json +48 -0
- package/src/tests/fixtures/sketchSolverTopology/rect_width_height_fixture.json +43 -0
- package/src/tests/fixtures/sketchSolverTopology/sketch_throttel_expression_sequence_fixture.json +25 -0
- package/src/tests/partFiles/sketch_throttel_testing.BREP.json +562 -0
- package/src/tests/sketchSolverTopologyFixtureLoader.js +308 -0
- package/src/tests/test_sketch_solver_topology_stability.js +348 -0
- package/src/tests/tests.js +17 -0
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import { ConstraintSolver } from "../SketchSolver2D.js";
|
|
2
|
+
import { posix as path } from '../path.proxy.js';
|
|
3
|
+
import { fs } from '../fs.proxy.js';
|
|
4
|
+
|
|
5
|
+
const DEFAULT_TOL = 1e-2;
|
|
6
|
+
const FIXTURE_DIR = 'src/tests/fixtures/sketchSolverTopology';
|
|
7
|
+
|
|
8
|
+
function assert(condition, message) {
|
|
9
|
+
if (!condition) throw new Error(message);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function getPoint(sketch, id) {
|
|
13
|
+
const p = sketch.points.find((pt) => Number(pt.id) === Number(id));
|
|
14
|
+
if (!p) throw new Error(`Point ${id} not found`);
|
|
15
|
+
return p;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function getConstraint(sketch, id) {
|
|
19
|
+
const c = sketch.constraints.find((cc) => Number(cc.id) === Number(id));
|
|
20
|
+
if (!c) throw new Error(`Constraint ${id} not found`);
|
|
21
|
+
return c;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function dist(sketch, a, b) {
|
|
25
|
+
const p0 = getPoint(sketch, a);
|
|
26
|
+
const p1 = getPoint(sketch, b);
|
|
27
|
+
return Math.hypot(p1.x - p0.x, p1.y - p0.y);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function signedArea(sketch, pointIds) {
|
|
31
|
+
const pts = pointIds.map((id) => getPoint(sketch, id));
|
|
32
|
+
let area2 = 0;
|
|
33
|
+
for (let i = 0; i < pts.length; i++) {
|
|
34
|
+
const a = pts[i];
|
|
35
|
+
const b = pts[(i + 1) % pts.length];
|
|
36
|
+
area2 += (a.x * b.y) - (b.x * a.y);
|
|
37
|
+
}
|
|
38
|
+
return area2 * 0.5;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function topologySnapshot(sketch) {
|
|
42
|
+
const pointIds = (sketch.points || []).map((p) => Number(p.id)).sort((a, b) => a - b);
|
|
43
|
+
const geometries = (sketch.geometries || [])
|
|
44
|
+
.map((g) => ({
|
|
45
|
+
id: Number(g.id),
|
|
46
|
+
type: String(g.type),
|
|
47
|
+
construction: !!g.construction,
|
|
48
|
+
points: (g.points || []).map((id) => Number(id)),
|
|
49
|
+
}))
|
|
50
|
+
.sort((a, b) => a.id - b.id);
|
|
51
|
+
const constraints = (sketch.constraints || [])
|
|
52
|
+
.map((c) => ({
|
|
53
|
+
id: Number(c.id),
|
|
54
|
+
type: String(c.type),
|
|
55
|
+
points: (c.points || []).map((id) => Number(id)),
|
|
56
|
+
}))
|
|
57
|
+
.sort((a, b) => a.id - b.id);
|
|
58
|
+
return { pointIds, geometries, constraints };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function assertTopologyIntegrity(sketch, contextLabel) {
|
|
62
|
+
const pointIds = new Set((sketch.points || []).map((p) => Number(p.id)));
|
|
63
|
+
for (const g of (sketch.geometries || [])) {
|
|
64
|
+
for (const pid of (g.points || [])) {
|
|
65
|
+
assert(pointIds.has(Number(pid)), `${contextLabel}: geometry ${g.id} references missing point ${pid}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
for (const c of (sketch.constraints || [])) {
|
|
69
|
+
for (const pid of (c.points || [])) {
|
|
70
|
+
assert(pointIds.has(Number(pid)), `${contextLabel}: constraint ${c.id} references missing point ${pid}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function sanitizeName(value, fallback = 'fixture') {
|
|
76
|
+
const raw = String(value || '').trim();
|
|
77
|
+
if (!raw) return fallback;
|
|
78
|
+
const cleaned = raw
|
|
79
|
+
.toLowerCase()
|
|
80
|
+
.replace(/[^a-z0-9._-]+/g, '_')
|
|
81
|
+
.replace(/^_+|_+$/g, '')
|
|
82
|
+
.slice(0, 120);
|
|
83
|
+
return cleaned || fallback;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function assertNear(actual, expected, tol, message) {
|
|
87
|
+
if (!Number.isFinite(actual) || !Number.isFinite(expected) || Math.abs(actual - expected) > tol) {
|
|
88
|
+
throw new Error(`${message}. Expected ${expected}, got ${actual}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function evaluateExpressionWithVars(expression, vars = {}) {
|
|
93
|
+
if (typeof expression !== 'string' || !expression.trim()) return null;
|
|
94
|
+
const keys = Object.keys(vars || {});
|
|
95
|
+
const vals = keys.map((k) => Number(vars[k]));
|
|
96
|
+
if (!vals.every((v) => Number.isFinite(v))) return null;
|
|
97
|
+
try {
|
|
98
|
+
const fn = Function(...keys, `"use strict"; return (${expression});`);
|
|
99
|
+
const out = fn(...vals);
|
|
100
|
+
const num = Number(out);
|
|
101
|
+
return Number.isFinite(num) ? num : null;
|
|
102
|
+
} catch {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function solveWithSettle(solver, {
|
|
108
|
+
maxPasses = 8,
|
|
109
|
+
stopWhenConstraintsClear = true,
|
|
110
|
+
} = {}) {
|
|
111
|
+
const passes = Math.max(1, Number(maxPasses) || 1);
|
|
112
|
+
let prevSig = null;
|
|
113
|
+
for (let pass = 0; pass < passes; pass++) {
|
|
114
|
+
solver.solveSketch("full");
|
|
115
|
+
const s = solver.sketchObject;
|
|
116
|
+
const pointSig = JSON.stringify(s?.points || []);
|
|
117
|
+
const hasConstraintErrors = Array.isArray(s?.constraints)
|
|
118
|
+
? s.constraints.some((c) => typeof c?.error === 'string' && c.error.length > 0)
|
|
119
|
+
: false;
|
|
120
|
+
if (stopWhenConstraintsClear && !hasConstraintErrors) break;
|
|
121
|
+
if (pointSig === prevSig) break;
|
|
122
|
+
prevSig = pointSig;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function applyExpressionValuesToConstraints(solver, exprValues, contextLabel) {
|
|
127
|
+
const vars = (exprValues && typeof exprValues === 'object') ? exprValues : null;
|
|
128
|
+
if (!vars) throw new Error(`${contextLabel}: expressionValues edit requires an object`);
|
|
129
|
+
for (const c of (solver.sketchObject?.constraints || [])) {
|
|
130
|
+
if (typeof c?.valueExpr !== 'string' || !c.valueExpr.length) continue;
|
|
131
|
+
const n = evaluateExpressionWithVars(c.valueExpr, vars);
|
|
132
|
+
if (!Number.isFinite(n)) continue;
|
|
133
|
+
const diameterExpr = c?.type === '⟺' &&
|
|
134
|
+
c?.displayStyle === 'diameter' &&
|
|
135
|
+
c?.valueExprMode === 'diameter';
|
|
136
|
+
c.value = diameterExpr ? Number(n) * 0.5 : Number(n);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function applyEdits(solver, edits, contextLabel) {
|
|
141
|
+
for (const edit of edits || []) {
|
|
142
|
+
if (!edit || typeof edit !== 'object') continue;
|
|
143
|
+
if (Object.prototype.hasOwnProperty.call(edit, 'expressionValues')) {
|
|
144
|
+
applyExpressionValuesToConstraints(solver, edit.expressionValues, contextLabel);
|
|
145
|
+
} else {
|
|
146
|
+
const constraintId = Number(edit.constraintId);
|
|
147
|
+
const value = Number(edit.value);
|
|
148
|
+
if (!Number.isFinite(constraintId)) {
|
|
149
|
+
throw new Error(`${contextLabel}: edit has invalid constraintId`);
|
|
150
|
+
}
|
|
151
|
+
if (!Number.isFinite(value)) {
|
|
152
|
+
throw new Error(`${contextLabel}: edit for constraint ${constraintId} has invalid value`);
|
|
153
|
+
}
|
|
154
|
+
const c = getConstraint(solver.sketchObject, constraintId);
|
|
155
|
+
c.value = value;
|
|
156
|
+
}
|
|
157
|
+
solveWithSettle(solver, {
|
|
158
|
+
maxPasses: Number.isFinite(Number(edit.maxPasses)) ? Number(edit.maxPasses) : 8,
|
|
159
|
+
stopWhenConstraintsClear: edit.stopWhenConstraintsClear !== false,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function runExpectations({ before, after, expect, contextLabel }) {
|
|
165
|
+
assertTopologyIntegrity(after, contextLabel);
|
|
166
|
+
const topologyUnchanged = expect?.topologyUnchanged !== false;
|
|
167
|
+
if (topologyUnchanged) {
|
|
168
|
+
const beforeSig = JSON.stringify(topologySnapshot(before));
|
|
169
|
+
const afterSig = JSON.stringify(topologySnapshot(after));
|
|
170
|
+
assert(beforeSig === afterSig, `${contextLabel}: topology changed`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
for (const d of (expect?.distances || [])) {
|
|
174
|
+
const a = Number(d.a);
|
|
175
|
+
const b = Number(d.b);
|
|
176
|
+
const value = Number(d.value);
|
|
177
|
+
const tol = Number.isFinite(Number(d.tol)) ? Number(d.tol) : DEFAULT_TOL;
|
|
178
|
+
assert(Number.isFinite(a) && Number.isFinite(b), `${contextLabel}: invalid distance pair`);
|
|
179
|
+
assertNear(dist(after, a, b), value, tol, `${contextLabel}: distance [${a},${b}] mismatch`);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
for (const a of (expect?.anchors || [])) {
|
|
183
|
+
const pointId = Number(a.pointId);
|
|
184
|
+
const tol = Number.isFinite(Number(a.tol)) ? Number(a.tol) : DEFAULT_TOL;
|
|
185
|
+
const p = getPoint(after, pointId);
|
|
186
|
+
if (Number.isFinite(Number(a.x))) assertNear(p.x, Number(a.x), tol, `${contextLabel}: anchor ${pointId} x drift`);
|
|
187
|
+
if (Number.isFinite(Number(a.y))) assertNear(p.y, Number(a.y), tol, `${contextLabel}: anchor ${pointId} y drift`);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
for (const c of (expect?.coincidentPairs || [])) {
|
|
191
|
+
const a = Number(c.a);
|
|
192
|
+
const b = Number(c.b);
|
|
193
|
+
const tol = Number.isFinite(Number(c.tol)) ? Number(c.tol) : DEFAULT_TOL;
|
|
194
|
+
assert(Number.isFinite(a) && Number.isFinite(b), `${contextLabel}: invalid coincident pair`);
|
|
195
|
+
assert(dist(after, a, b) <= tol, `${contextLabel}: coincident pair [${a},${b}] broke`);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
for (const loop of (expect?.orientationLoops || [])) {
|
|
199
|
+
const pointIds = Array.isArray(loop?.pointIds) ? loop.pointIds.map((id) => Number(id)) : [];
|
|
200
|
+
assert(pointIds.length >= 3, `${contextLabel}: orientation loop needs at least 3 points`);
|
|
201
|
+
const minAbsArea = Number.isFinite(Number(loop.minAbsArea)) ? Number(loop.minAbsArea) : 1;
|
|
202
|
+
const beforeArea = signedArea(before, pointIds);
|
|
203
|
+
const afterArea = signedArea(after, pointIds);
|
|
204
|
+
assert(Math.abs(afterArea) > minAbsArea, `${contextLabel}: loop collapsed`);
|
|
205
|
+
if (loop.preserveSign !== false) {
|
|
206
|
+
assert(Math.sign(afterArea) === Math.sign(beforeArea), `${contextLabel}: loop orientation flipped`);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function runFixture(fixture, fixturePath) {
|
|
212
|
+
const label = fixture?.name || path.basename(fixturePath);
|
|
213
|
+
const contextLabel = `sketch fixture ${label}`;
|
|
214
|
+
assert(fixture && typeof fixture === 'object', `${contextLabel}: fixture is not an object`);
|
|
215
|
+
assert(Array.isArray(fixture.edits), `${contextLabel}: missing edits array`);
|
|
216
|
+
let sourceSketch = null;
|
|
217
|
+
if (fixture.sketch && typeof fixture.sketch === 'object') {
|
|
218
|
+
sourceSketch = fixture.sketch;
|
|
219
|
+
} else if (typeof fixture.sourcePartFile === 'string' && fixture.sourcePartFile.trim().length) {
|
|
220
|
+
const partPath = fixture.sourcePartFile.trim();
|
|
221
|
+
const raw = await fs.promises.readFile(partPath, 'utf8');
|
|
222
|
+
const data = JSON.parse(raw);
|
|
223
|
+
const features = Array.isArray(data?.features) ? data.features : [];
|
|
224
|
+
let sketchFeature = null;
|
|
225
|
+
if (fixture.sourceFeatureId != null) {
|
|
226
|
+
const wanted = String(fixture.sourceFeatureId);
|
|
227
|
+
sketchFeature = features.find((f) => (
|
|
228
|
+
f?.type === 'S' &&
|
|
229
|
+
(String(f?.inputParams?.id ?? '') === wanted ||
|
|
230
|
+
String(f?.inputParams?.featureID ?? '') === wanted)
|
|
231
|
+
)) || null;
|
|
232
|
+
}
|
|
233
|
+
if (!sketchFeature) sketchFeature = features.find((f) => f?.type === 'S' && f?.persistentData?.sketch) || null;
|
|
234
|
+
sourceSketch = sketchFeature?.persistentData?.sketch || null;
|
|
235
|
+
}
|
|
236
|
+
assert(sourceSketch && typeof sourceSketch === 'object', `${contextLabel}: missing sketch or sourcePartFile sketch`);
|
|
237
|
+
|
|
238
|
+
const solver = new ConstraintSolver({
|
|
239
|
+
sketch: JSON.parse(JSON.stringify(sourceSketch)),
|
|
240
|
+
});
|
|
241
|
+
solveWithSettle(solver, {
|
|
242
|
+
maxPasses: Number.isFinite(Number(fixture.initialSolvePasses)) ? Number(fixture.initialSolvePasses) : 4,
|
|
243
|
+
stopWhenConstraintsClear: true,
|
|
244
|
+
});
|
|
245
|
+
const before = JSON.parse(JSON.stringify(solver.sketchObject));
|
|
246
|
+
|
|
247
|
+
applyEdits(solver, fixture.edits, contextLabel);
|
|
248
|
+
const after = solver.sketchObject;
|
|
249
|
+
|
|
250
|
+
runExpectations({
|
|
251
|
+
before,
|
|
252
|
+
after,
|
|
253
|
+
expect: fixture.expect || {},
|
|
254
|
+
contextLabel,
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export async function registerSketchSolverTopologyFixtureTests(testFunctions) {
|
|
259
|
+
if (!(typeof process !== 'undefined' && process.versions && process.versions.node)) return 0;
|
|
260
|
+
const seenSourceFiles = new Set(
|
|
261
|
+
(testFunctions || [])
|
|
262
|
+
.map((t) => t?._sourceFile)
|
|
263
|
+
.filter((v) => typeof v === 'string' && v.length > 0)
|
|
264
|
+
);
|
|
265
|
+
let entries = [];
|
|
266
|
+
try {
|
|
267
|
+
entries = await fs.promises.readdir(FIXTURE_DIR);
|
|
268
|
+
} catch {
|
|
269
|
+
return 0;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const files = entries
|
|
273
|
+
.filter((f) => typeof f === 'string' && f.toLowerCase().endsWith('.json'))
|
|
274
|
+
.sort((a, b) => a.localeCompare(b));
|
|
275
|
+
|
|
276
|
+
let count = 0;
|
|
277
|
+
for (const file of files) {
|
|
278
|
+
const filePath = path.join(FIXTURE_DIR, file);
|
|
279
|
+
if (seenSourceFiles.has(filePath)) continue;
|
|
280
|
+
let fixture = null;
|
|
281
|
+
try {
|
|
282
|
+
const raw = await fs.promises.readFile(filePath, 'utf8');
|
|
283
|
+
fixture = JSON.parse(raw);
|
|
284
|
+
} catch (error) {
|
|
285
|
+
const message = error?.message || String(error);
|
|
286
|
+
throw new Error(`Failed to read sketch fixture ${filePath}: ${message}`);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const base = String(file).replace(/\.[^.]+$/, '');
|
|
290
|
+
const name = sanitizeName(fixture?.name || base, `fixture_${count}`);
|
|
291
|
+
const testName = `test_sketch_solver_fixture_${name}`;
|
|
292
|
+
const testFn = async function sketchSolverFixtureTest() {
|
|
293
|
+
await runFixture(fixture, filePath);
|
|
294
|
+
};
|
|
295
|
+
try { Object.defineProperty(testFn, 'name', { value: testName, configurable: true }); } catch { }
|
|
296
|
+
testFunctions.push({
|
|
297
|
+
test: testFn,
|
|
298
|
+
printArtifacts: false,
|
|
299
|
+
exportFaces: false,
|
|
300
|
+
exportSolids: false,
|
|
301
|
+
resetHistory: true,
|
|
302
|
+
_sourceFile: filePath,
|
|
303
|
+
});
|
|
304
|
+
seenSourceFiles.add(filePath);
|
|
305
|
+
count += 1;
|
|
306
|
+
}
|
|
307
|
+
return count;
|
|
308
|
+
}
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
import { ConstraintSolver } from "../SketchSolver2D.js";
|
|
2
|
+
|
|
3
|
+
const EPS = 1e-2;
|
|
4
|
+
|
|
5
|
+
function assert(condition, message) {
|
|
6
|
+
if (!condition) throw new Error(message);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function assertNear(actual, expected, tol, message) {
|
|
10
|
+
if (!Number.isFinite(actual) || !Number.isFinite(expected) || Math.abs(actual - expected) > tol) {
|
|
11
|
+
throw new Error(`${message}. Expected ${expected}, got ${actual}`);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function getPoint(sketch, id) {
|
|
16
|
+
const p = sketch.points.find((pt) => Number(pt.id) === Number(id));
|
|
17
|
+
if (!p) throw new Error(`Point ${id} not found`);
|
|
18
|
+
return p;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function getConstraint(sketch, id) {
|
|
22
|
+
const c = sketch.constraints.find((cc) => Number(cc.id) === Number(id));
|
|
23
|
+
if (!c) throw new Error(`Constraint ${id} not found`);
|
|
24
|
+
return c;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function dist(sketch, a, b) {
|
|
28
|
+
const p0 = getPoint(sketch, a);
|
|
29
|
+
const p1 = getPoint(sketch, b);
|
|
30
|
+
return Math.hypot(p1.x - p0.x, p1.y - p0.y);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function signedArea(sketch, pointIds) {
|
|
34
|
+
const pts = pointIds.map((id) => getPoint(sketch, id));
|
|
35
|
+
let area2 = 0;
|
|
36
|
+
for (let i = 0; i < pts.length; i++) {
|
|
37
|
+
const a = pts[i];
|
|
38
|
+
const b = pts[(i + 1) % pts.length];
|
|
39
|
+
area2 += (a.x * b.y) - (b.x * a.y);
|
|
40
|
+
}
|
|
41
|
+
return area2 * 0.5;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function topologySnapshot(sketch) {
|
|
45
|
+
const pointIds = sketch.points.map((p) => Number(p.id)).sort((a, b) => a - b);
|
|
46
|
+
const geometries = (sketch.geometries || [])
|
|
47
|
+
.map((g) => ({
|
|
48
|
+
id: Number(g.id),
|
|
49
|
+
type: String(g.type),
|
|
50
|
+
construction: !!g.construction,
|
|
51
|
+
points: (g.points || []).map((id) => Number(id)),
|
|
52
|
+
}))
|
|
53
|
+
.sort((a, b) => a.id - b.id);
|
|
54
|
+
const constraints = (sketch.constraints || [])
|
|
55
|
+
.map((c) => ({
|
|
56
|
+
id: Number(c.id),
|
|
57
|
+
type: String(c.type),
|
|
58
|
+
points: (c.points || []).map((id) => Number(id)),
|
|
59
|
+
}))
|
|
60
|
+
.sort((a, b) => a.id - b.id);
|
|
61
|
+
return { pointIds, geometries, constraints };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function assertTopologyIntegrity(sketch, contextLabel) {
|
|
65
|
+
const pointIds = new Set((sketch.points || []).map((p) => Number(p.id)));
|
|
66
|
+
for (const g of (sketch.geometries || [])) {
|
|
67
|
+
for (const pid of (g.points || [])) {
|
|
68
|
+
assert(pointIds.has(Number(pid)), `${contextLabel}: geometry ${g.id} references missing point ${pid}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
for (const c of (sketch.constraints || [])) {
|
|
72
|
+
for (const pid of (c.points || [])) {
|
|
73
|
+
assert(pointIds.has(Number(pid)), `${contextLabel}: constraint ${c.id} references missing point ${pid}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function assertTopologyUnchanged(before, after, contextLabel) {
|
|
79
|
+
assertTopologyIntegrity(after, contextLabel);
|
|
80
|
+
const beforeSig = JSON.stringify(topologySnapshot(before));
|
|
81
|
+
const afterSig = JSON.stringify(topologySnapshot(after));
|
|
82
|
+
assert(beforeSig === afterSig, `${contextLabel}: topology changed`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function solveSketch(sketch) {
|
|
86
|
+
const solver = new ConstraintSolver({
|
|
87
|
+
sketch: JSON.parse(JSON.stringify(sketch)),
|
|
88
|
+
});
|
|
89
|
+
solver.solveSketch("full");
|
|
90
|
+
return solver;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function test_sketch_solver_topology_rect_shared_points() {
|
|
94
|
+
const sketch = {
|
|
95
|
+
points: [
|
|
96
|
+
{ id: 0, x: 0, y: 0, fixed: true },
|
|
97
|
+
{ id: 1, x: 40, y: 0, fixed: false },
|
|
98
|
+
{ id: 2, x: 40, y: 20, fixed: false },
|
|
99
|
+
{ id: 3, x: 0, y: 20, fixed: false },
|
|
100
|
+
],
|
|
101
|
+
geometries: [
|
|
102
|
+
{ id: 100, type: "line", points: [0, 1], construction: false },
|
|
103
|
+
{ id: 101, type: "line", points: [1, 2], construction: false },
|
|
104
|
+
{ id: 102, type: "line", points: [2, 3], construction: false },
|
|
105
|
+
{ id: 103, type: "line", points: [3, 0], construction: false },
|
|
106
|
+
],
|
|
107
|
+
constraints: [
|
|
108
|
+
{ id: 0, type: "⏚", points: [0] },
|
|
109
|
+
{ id: 1, type: "━", points: [0, 1] },
|
|
110
|
+
{ id: 2, type: "━", points: [2, 3] },
|
|
111
|
+
{ id: 3, type: "│", points: [1, 2] },
|
|
112
|
+
{ id: 4, type: "│", points: [3, 0] },
|
|
113
|
+
{ id: 5, type: "⟺", points: [0, 1], value: 40 },
|
|
114
|
+
{ id: 6, type: "⟺", points: [1, 2], value: 20 },
|
|
115
|
+
],
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const solver = solveSketch(sketch);
|
|
119
|
+
const before = JSON.parse(JSON.stringify(solver.sketchObject));
|
|
120
|
+
const beforeArea = signedArea(before, [0, 1, 2, 3]);
|
|
121
|
+
|
|
122
|
+
getConstraint(solver.sketchObject, 5).value = 130;
|
|
123
|
+
solver.solveSketch("full");
|
|
124
|
+
const after = solver.sketchObject;
|
|
125
|
+
|
|
126
|
+
assertTopologyUnchanged(before, after, "shared-point rectangle width edit");
|
|
127
|
+
assertNear(dist(after, 0, 1), 130, 5e-2, "shared-point rectangle width changed unexpectedly");
|
|
128
|
+
assertNear(dist(after, 1, 2), 20, 5e-2, "shared-point rectangle height drifted");
|
|
129
|
+
const p0 = getPoint(after, 0);
|
|
130
|
+
assertNear(p0.x, 0, EPS, "shared-point rectangle anchor x moved");
|
|
131
|
+
assertNear(p0.y, 0, EPS, "shared-point rectangle anchor y moved");
|
|
132
|
+
|
|
133
|
+
const afterArea = signedArea(after, [0, 1, 2, 3]);
|
|
134
|
+
assert(Math.abs(afterArea) > 1, "shared-point rectangle collapsed");
|
|
135
|
+
assert(Math.sign(afterArea) === Math.sign(beforeArea), "shared-point rectangle flipped orientation");
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export async function test_sketch_solver_topology_coincident_chain() {
|
|
139
|
+
const sketch = {
|
|
140
|
+
points: [
|
|
141
|
+
{ id: 0, x: 0, y: 0, fixed: true },
|
|
142
|
+
{ id: 1, x: 40, y: 0, fixed: false },
|
|
143
|
+
{ id: 2, x: 40, y: 0, fixed: false },
|
|
144
|
+
{ id: 3, x: 40, y: 30, fixed: false },
|
|
145
|
+
{ id: 4, x: 40, y: 30, fixed: false },
|
|
146
|
+
{ id: 5, x: 80, y: 30, fixed: false },
|
|
147
|
+
],
|
|
148
|
+
geometries: [
|
|
149
|
+
{ id: 200, type: "line", points: [0, 1], construction: false },
|
|
150
|
+
{ id: 201, type: "line", points: [2, 3], construction: false },
|
|
151
|
+
{ id: 202, type: "line", points: [4, 5], construction: false },
|
|
152
|
+
],
|
|
153
|
+
constraints: [
|
|
154
|
+
{ id: 0, type: "⏚", points: [0] },
|
|
155
|
+
{ id: 10, type: "≡", points: [1, 2] },
|
|
156
|
+
{ id: 11, type: "≡", points: [3, 4] },
|
|
157
|
+
{ id: 12, type: "━", points: [0, 1] },
|
|
158
|
+
{ id: 13, type: "│", points: [2, 3] },
|
|
159
|
+
{ id: 14, type: "━", points: [4, 5] },
|
|
160
|
+
{ id: 15, type: "⟺", points: [0, 1], value: 40 },
|
|
161
|
+
{ id: 16, type: "⟺", points: [2, 3], value: 30 },
|
|
162
|
+
{ id: 17, type: "⟺", points: [4, 5], value: 40 },
|
|
163
|
+
],
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const solver = solveSketch(sketch);
|
|
167
|
+
const before = JSON.parse(JSON.stringify(solver.sketchObject));
|
|
168
|
+
getConstraint(solver.sketchObject, 16).value = 90;
|
|
169
|
+
solver.solveSketch("full");
|
|
170
|
+
const after = solver.sketchObject;
|
|
171
|
+
|
|
172
|
+
assertTopologyUnchanged(before, after, "coincident-chain vertical edit");
|
|
173
|
+
assertNear(dist(after, 2, 3), 90, 5e-2, "coincident-chain edited segment length incorrect");
|
|
174
|
+
assertNear(dist(after, 0, 1), 40, 5e-2, "coincident-chain left segment length drifted");
|
|
175
|
+
assertNear(dist(after, 4, 5), 40, 5e-2, "coincident-chain right segment length drifted");
|
|
176
|
+
assert(dist(after, 1, 2) < EPS, "coincident-chain joint 1 broke");
|
|
177
|
+
assert(dist(after, 3, 4) < EPS, "coincident-chain joint 2 broke");
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export async function test_sketch_solver_topology_coincident_loop_no_flip() {
|
|
181
|
+
const sketch = {
|
|
182
|
+
points: [
|
|
183
|
+
{ id: 0, x: 0, y: 0, fixed: true },
|
|
184
|
+
{ id: 1, x: 40, y: 0, fixed: false },
|
|
185
|
+
{ id: 2, x: 40, y: 0, fixed: false },
|
|
186
|
+
{ id: 3, x: 40, y: 20, fixed: false },
|
|
187
|
+
{ id: 4, x: 40, y: 20, fixed: false },
|
|
188
|
+
{ id: 5, x: 0, y: 20, fixed: false },
|
|
189
|
+
{ id: 6, x: 0, y: 20, fixed: false },
|
|
190
|
+
{ id: 7, x: 0, y: 0, fixed: false },
|
|
191
|
+
],
|
|
192
|
+
geometries: [
|
|
193
|
+
{ id: 300, type: "line", points: [0, 1], construction: false },
|
|
194
|
+
{ id: 301, type: "line", points: [2, 3], construction: false },
|
|
195
|
+
{ id: 302, type: "line", points: [4, 5], construction: false },
|
|
196
|
+
{ id: 303, type: "line", points: [6, 7], construction: false },
|
|
197
|
+
],
|
|
198
|
+
constraints: [
|
|
199
|
+
{ id: 0, type: "⏚", points: [0] },
|
|
200
|
+
{ id: 20, type: "≡", points: [1, 2] },
|
|
201
|
+
{ id: 21, type: "≡", points: [3, 4] },
|
|
202
|
+
{ id: 22, type: "≡", points: [5, 6] },
|
|
203
|
+
{ id: 23, type: "≡", points: [7, 0] },
|
|
204
|
+
{ id: 24, type: "━", points: [0, 1] },
|
|
205
|
+
{ id: 25, type: "│", points: [2, 3] },
|
|
206
|
+
{ id: 26, type: "━", points: [4, 5] },
|
|
207
|
+
{ id: 27, type: "│", points: [6, 7] },
|
|
208
|
+
{ id: 28, type: "⟺", points: [0, 1], value: 40 },
|
|
209
|
+
{ id: 29, type: "⟺", points: [2, 3], value: 20 },
|
|
210
|
+
],
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
const solver = solveSketch(sketch);
|
|
214
|
+
const before = JSON.parse(JSON.stringify(solver.sketchObject));
|
|
215
|
+
const beforeArea = signedArea(before, [0, 1, 3, 5]);
|
|
216
|
+
|
|
217
|
+
getConstraint(solver.sketchObject, 28).value = 120;
|
|
218
|
+
getConstraint(solver.sketchObject, 29).value = 55;
|
|
219
|
+
solver.solveSketch("full");
|
|
220
|
+
const after = solver.sketchObject;
|
|
221
|
+
|
|
222
|
+
assertTopologyUnchanged(before, after, "coincident-loop dual edit");
|
|
223
|
+
assertNear(dist(after, 0, 1), 120, 6e-2, "coincident-loop width incorrect after edit");
|
|
224
|
+
assertNear(dist(after, 2, 3), 55, 6e-2, "coincident-loop height incorrect after edit");
|
|
225
|
+
|
|
226
|
+
assert(dist(after, 1, 2) < EPS, "coincident-loop corner 1 broke");
|
|
227
|
+
assert(dist(after, 3, 4) < EPS, "coincident-loop corner 2 broke");
|
|
228
|
+
assert(dist(after, 5, 6) < EPS, "coincident-loop corner 3 broke");
|
|
229
|
+
assert(dist(after, 7, 0) < EPS, "coincident-loop corner 4 broke");
|
|
230
|
+
|
|
231
|
+
const afterArea = signedArea(after, [0, 1, 3, 5]);
|
|
232
|
+
assert(Math.abs(afterArea) > 1, "coincident-loop collapsed");
|
|
233
|
+
assert(Math.sign(afterArea) === Math.sign(beforeArea), "coincident-loop flipped orientation");
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export async function test_sketch_solver_topology_rect_round_trip_sequence() {
|
|
237
|
+
const sketch = {
|
|
238
|
+
points: [
|
|
239
|
+
{ id: 0, x: 0, y: 0, fixed: true },
|
|
240
|
+
{ id: 1, x: 40, y: 0, fixed: false },
|
|
241
|
+
{ id: 2, x: 40, y: 20, fixed: false },
|
|
242
|
+
{ id: 3, x: 0, y: 20, fixed: false },
|
|
243
|
+
],
|
|
244
|
+
geometries: [
|
|
245
|
+
{ id: 100, type: "line", points: [0, 1], construction: false },
|
|
246
|
+
{ id: 101, type: "line", points: [1, 2], construction: false },
|
|
247
|
+
{ id: 102, type: "line", points: [2, 3], construction: false },
|
|
248
|
+
{ id: 103, type: "line", points: [3, 0], construction: false },
|
|
249
|
+
],
|
|
250
|
+
constraints: [
|
|
251
|
+
{ id: 0, type: "⏚", points: [0] },
|
|
252
|
+
{ id: 1, type: "━", points: [0, 1] },
|
|
253
|
+
{ id: 2, type: "━", points: [2, 3] },
|
|
254
|
+
{ id: 3, type: "│", points: [1, 2] },
|
|
255
|
+
{ id: 4, type: "│", points: [3, 0] },
|
|
256
|
+
{ id: 5, type: "⟺", points: [0, 1], value: 40 },
|
|
257
|
+
{ id: 6, type: "⟺", points: [1, 2], value: 20 },
|
|
258
|
+
],
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
const solver = solveSketch(sketch);
|
|
262
|
+
const before = JSON.parse(JSON.stringify(solver.sketchObject));
|
|
263
|
+
const beforeArea = signedArea(before, [0, 1, 2, 3]);
|
|
264
|
+
|
|
265
|
+
const widthTargets = [130, 25, 80, 60];
|
|
266
|
+
for (const targetWidth of widthTargets) {
|
|
267
|
+
getConstraint(solver.sketchObject, 5).value = targetWidth;
|
|
268
|
+
solver.solveSketch("full");
|
|
269
|
+
const after = solver.sketchObject;
|
|
270
|
+
assertTopologyUnchanged(before, after, "shared-point rectangle round-trip edits");
|
|
271
|
+
assertNear(dist(after, 0, 1), targetWidth, 7e-2, "shared-point rectangle round-trip width mismatch");
|
|
272
|
+
assertNear(dist(after, 1, 2), 20, 7e-2, "shared-point rectangle round-trip height drift");
|
|
273
|
+
const p0 = getPoint(after, 0);
|
|
274
|
+
assertNear(p0.x, 0, EPS, "shared-point rectangle round-trip anchor x moved");
|
|
275
|
+
assertNear(p0.y, 0, EPS, "shared-point rectangle round-trip anchor y moved");
|
|
276
|
+
const area = signedArea(after, [0, 1, 2, 3]);
|
|
277
|
+
assert(Math.abs(area) > 1, "shared-point rectangle round-trip collapsed");
|
|
278
|
+
assert(Math.sign(area) === Math.sign(beforeArea), "shared-point rectangle round-trip flipped orientation");
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export async function test_sketch_solver_topology_coincident_chain_multi_step() {
|
|
283
|
+
const sketch = {
|
|
284
|
+
points: [
|
|
285
|
+
{ id: 0, x: 0, y: 0, fixed: true },
|
|
286
|
+
{ id: 1, x: 40, y: 0, fixed: false },
|
|
287
|
+
{ id: 2, x: 40, y: 0, fixed: false },
|
|
288
|
+
{ id: 3, x: 40, y: 30, fixed: false },
|
|
289
|
+
{ id: 4, x: 40, y: 30, fixed: false },
|
|
290
|
+
{ id: 5, x: 80, y: 30, fixed: false },
|
|
291
|
+
],
|
|
292
|
+
geometries: [
|
|
293
|
+
{ id: 200, type: "line", points: [0, 1], construction: false },
|
|
294
|
+
{ id: 201, type: "line", points: [2, 3], construction: false },
|
|
295
|
+
{ id: 202, type: "line", points: [4, 5], construction: false },
|
|
296
|
+
],
|
|
297
|
+
constraints: [
|
|
298
|
+
{ id: 0, type: "⏚", points: [0] },
|
|
299
|
+
{ id: 10, type: "≡", points: [1, 2] },
|
|
300
|
+
{ id: 11, type: "≡", points: [3, 4] },
|
|
301
|
+
{ id: 12, type: "━", points: [0, 1] },
|
|
302
|
+
{ id: 13, type: "│", points: [2, 3] },
|
|
303
|
+
{ id: 14, type: "━", points: [4, 5] },
|
|
304
|
+
{ id: 15, type: "⟺", points: [0, 1], value: 40 },
|
|
305
|
+
{ id: 16, type: "⟺", points: [2, 3], value: 30 },
|
|
306
|
+
{ id: 17, type: "⟺", points: [4, 5], value: 40 },
|
|
307
|
+
],
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
const solver = solveSketch(sketch);
|
|
311
|
+
const before = JSON.parse(JSON.stringify(solver.sketchObject));
|
|
312
|
+
|
|
313
|
+
const targets = [90, 15, 60, 45];
|
|
314
|
+
for (const target of targets) {
|
|
315
|
+
getConstraint(solver.sketchObject, 16).value = target;
|
|
316
|
+
solver.solveSketch("full");
|
|
317
|
+
const after = solver.sketchObject;
|
|
318
|
+
assertTopologyUnchanged(before, after, "coincident-chain multi-step edits");
|
|
319
|
+
assertNear(dist(after, 2, 3), target, 8e-2, "coincident-chain multi-step edited segment mismatch");
|
|
320
|
+
assertNear(dist(after, 0, 1), 40, 8e-2, "coincident-chain multi-step left segment drift");
|
|
321
|
+
assertNear(dist(after, 4, 5), 40, 8e-2, "coincident-chain multi-step right segment drift");
|
|
322
|
+
assert(dist(after, 1, 2) < EPS, "coincident-chain multi-step joint 1 broke");
|
|
323
|
+
assert(dist(after, 3, 4) < EPS, "coincident-chain multi-step joint 2 broke");
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export async function test_sketch_solver_distance_slide_large_drop_settles_single_solve() {
|
|
328
|
+
const sketch = {
|
|
329
|
+
points: [
|
|
330
|
+
{ id: 0, x: 0, y: 0, fixed: true },
|
|
331
|
+
{ id: 1, x: 1000, y: 0, fixed: false },
|
|
332
|
+
],
|
|
333
|
+
geometries: [
|
|
334
|
+
{ id: 400, type: "line", points: [0, 1], construction: false },
|
|
335
|
+
],
|
|
336
|
+
constraints: [
|
|
337
|
+
{ id: 0, type: "⏚", points: [0] },
|
|
338
|
+
{ id: 30, type: "⟺", points: [0, 1], value: 1000 },
|
|
339
|
+
],
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
const solver = solveSketch(sketch);
|
|
343
|
+
const c = getConstraint(solver.sketchObject, 30);
|
|
344
|
+
c.value = 1;
|
|
345
|
+
solver.solveSketch("full");
|
|
346
|
+
const after = solver.sketchObject;
|
|
347
|
+
assertNear(dist(after, 0, 1), 1, 1e-2, "distance slide large-drop did not settle in one solve");
|
|
348
|
+
}
|