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.
@@ -229,6 +229,153 @@ function unionSolids(solids) {
229
229
  return current;
230
230
  }
231
231
 
232
+ function triangleArea(tri) {
233
+ const p0 = tri?.p1;
234
+ const p1 = tri?.p2;
235
+ const p2 = tri?.p3;
236
+ if (!Array.isArray(p0) || !Array.isArray(p1) || !Array.isArray(p2)) return 0;
237
+ const ux = p1[0] - p0[0];
238
+ const uy = p1[1] - p0[1];
239
+ const uz = p1[2] - p0[2];
240
+ const vx = p2[0] - p0[0];
241
+ const vy = p2[1] - p0[1];
242
+ const vz = p2[2] - p0[2];
243
+ const cx = uy * vz - uz * vy;
244
+ const cy = uz * vx - ux * vz;
245
+ const cz = ux * vy - uy * vx;
246
+ return 0.5 * Math.hypot(cx, cy, cz);
247
+ }
248
+
249
+ function faceComponentStats(solid, faceName) {
250
+ let tris = [];
251
+ try {
252
+ tris = typeof solid?.getFace === 'function' ? solid.getFace(faceName) : [];
253
+ } catch {
254
+ tris = [];
255
+ }
256
+ if (!Array.isArray(tris) || tris.length === 0) {
257
+ return { componentCount: 0, componentAreas: [], totalArea: 0 };
258
+ }
259
+
260
+ const edgeToTri = new Map();
261
+ const triAdj = Array.from({ length: tris.length }, () => []);
262
+ const areas = new Float64Array(tris.length);
263
+ const edgeKey = (a, b) => (a < b ? `${a}|${b}` : `${b}|${a}`);
264
+
265
+ for (let t = 0; t < tris.length; t++) {
266
+ const tri = tris[t];
267
+ areas[t] = triangleArea(tri);
268
+ const idx = Array.isArray(tri?.indices) && tri.indices.length === 3 ? tri.indices : null;
269
+ if (!idx) continue;
270
+ const i0 = Number(idx[0]);
271
+ const i1 = Number(idx[1]);
272
+ const i2 = Number(idx[2]);
273
+ if (!Number.isFinite(i0) || !Number.isFinite(i1) || !Number.isFinite(i2)) continue;
274
+ const edges = [[i0, i1], [i1, i2], [i2, i0]];
275
+ for (const [a, b] of edges) {
276
+ const key = edgeKey(a, b);
277
+ let arr = edgeToTri.get(key);
278
+ if (!arr) {
279
+ arr = [];
280
+ edgeToTri.set(key, arr);
281
+ }
282
+ arr.push(t);
283
+ }
284
+ }
285
+
286
+ for (const triList of edgeToTri.values()) {
287
+ if (!Array.isArray(triList) || triList.length !== 2) continue;
288
+ const a = triList[0];
289
+ const b = triList[1];
290
+ triAdj[a].push(b);
291
+ triAdj[b].push(a);
292
+ }
293
+
294
+ const seen = new Uint8Array(tris.length);
295
+ const componentAreas = [];
296
+ for (let i = 0; i < tris.length; i++) {
297
+ if (seen[i]) continue;
298
+ const stack = [i];
299
+ seen[i] = 1;
300
+ let area = 0;
301
+ while (stack.length) {
302
+ const t = stack.pop();
303
+ area += areas[t] || 0;
304
+ const nbrs = triAdj[t];
305
+ for (let j = 0; j < nbrs.length; j++) {
306
+ const u = nbrs[j];
307
+ if (seen[u]) continue;
308
+ seen[u] = 1;
309
+ stack.push(u);
310
+ }
311
+ }
312
+ componentAreas.push(area);
313
+ }
314
+
315
+ componentAreas.sort((a, b) => b - a);
316
+ const totalArea = componentAreas.reduce((sum, a) => sum + a, 0);
317
+ return { componentCount: componentAreas.length, componentAreas, totalArea };
318
+ }
319
+
320
+ function cleanupModeledThreadFaceIslands(tool, faceNames, pitch) {
321
+ if (!tool || typeof tool.cleanupTinyFaceIslands !== 'function') return null;
322
+ const names = Array.isArray(faceNames) ? faceNames.filter(Boolean) : [];
323
+ if (!names.length) return null;
324
+
325
+ const collectStats = () => names.map((name) => {
326
+ const s = faceComponentStats(tool, name);
327
+ return {
328
+ name,
329
+ components: s.componentCount,
330
+ totalArea: s.totalArea,
331
+ largestComponentArea: s.componentAreas[0] || 0,
332
+ smallestIslandArea: s.componentAreas.length > 1 ? s.componentAreas[s.componentAreas.length - 1] : 0,
333
+ };
334
+ });
335
+
336
+ const p = Math.max(1e-4, Math.abs(Number(pitch) || 0));
337
+ let areaThreshold = Math.max(1e-9, p * p * 1e-4);
338
+ const maxThreshold = Math.max(areaThreshold, p * p * 0.05);
339
+ const before = collectStats();
340
+ let passes = 0;
341
+ let totalReassigned = 0;
342
+
343
+ for (let pass = 0; pass < 6; pass++) {
344
+ passes = pass + 1;
345
+ const stats = names.map((name) => ({ name, ...faceComponentStats(tool, name) }));
346
+ const worstComponents = stats.reduce((m, s) => Math.max(m, s.componentCount), 0);
347
+ if (worstComponents <= 1) break;
348
+
349
+ const smallAreas = [];
350
+ for (const s of stats) {
351
+ if (s.componentAreas.length <= 1) continue;
352
+ for (let i = 1; i < s.componentAreas.length; i++) {
353
+ const a = Number(s.componentAreas[i]);
354
+ if (Number.isFinite(a) && a > 0) smallAreas.push(a);
355
+ }
356
+ }
357
+ smallAreas.sort((a, b) => a - b);
358
+ const suggested = smallAreas.length ? Math.max(areaThreshold, smallAreas[0] * 1.5) : areaThreshold;
359
+ const useThreshold = Math.min(maxThreshold, suggested);
360
+ const reassigned = Number(tool.cleanupTinyFaceIslands(useThreshold) || 0);
361
+ totalReassigned += reassigned > 0 ? reassigned : 0;
362
+ if (reassigned <= 0) {
363
+ areaThreshold = Math.min(maxThreshold, areaThreshold * 4);
364
+ if (areaThreshold >= maxThreshold) break;
365
+ } else {
366
+ areaThreshold = Math.min(maxThreshold, Math.max(areaThreshold * 1.25, useThreshold));
367
+ }
368
+ }
369
+ const after = collectStats();
370
+ return {
371
+ before,
372
+ after,
373
+ passes,
374
+ totalReassigned,
375
+ finalAreaThreshold: areaThreshold,
376
+ };
377
+ }
378
+
232
379
  function getWorldPosition(obj) {
233
380
  if (!obj) return null;
234
381
  if (obj.isVector3) return obj.clone();
@@ -336,10 +483,8 @@ function collectSketchVertices(sketch) {
336
483
  if (!sketch || !Array.isArray(sketch.children)) return verts;
337
484
  for (const child of sketch.children) {
338
485
  if (!child) continue;
339
- const sid = child?.userData?.sketchPointId;
340
- const name = child?.name || '';
341
- const isCenter = sid === 0 || sid === '0' || name === 'P0' || name.endsWith(':P0');
342
- if (isCenter) continue;
486
+ const isConstructionPoint = child?.userData?.isConstructionPoint === true;
487
+ if (isConstructionPoint) continue;
343
488
  const isVertexLike = child.type === 'Vertex' || child.isVertex || child.userData?.isVertex || child.userData?.type === 'VERTEX';
344
489
  if (isVertexLike) verts.push(child);
345
490
  }
@@ -357,8 +502,8 @@ function collectSketchVerticesByName(scene, sketchName) {
357
502
  const nm = obj.name || '';
358
503
  const m = nm.match(re);
359
504
  if (m) {
360
- const id = Number(m[1]);
361
- if (id !== 0) verts.push(obj);
505
+ const isConstructionPoint = obj?.userData?.isConstructionPoint === true;
506
+ if (!isConstructionPoint) verts.push(obj);
362
507
  }
363
508
  const children = Array.isArray(obj.children) ? obj.children : [];
364
509
  for (const c of children) walk(c);
@@ -367,6 +512,44 @@ function collectSketchVerticesByName(scene, sketchName) {
367
512
  return verts;
368
513
  }
369
514
 
515
+ function dedupePlacementsByPosition(placements, tolerance = 1e-7) {
516
+ const out = [];
517
+ if (!Array.isArray(placements) || placements.length === 0) return out;
518
+
519
+ const tol = Math.max(1e-12, Number(tolerance) || 1e-7);
520
+ const invTol = 1 / tol;
521
+ const buckets = new Map();
522
+ const keyOf = (p) => `${Math.round(p.x * invTol)},${Math.round(p.y * invTol)},${Math.round(p.z * invTol)}`;
523
+
524
+ for (const item of placements) {
525
+ const p = item?.position;
526
+ if (!p || !Number.isFinite(p.x) || !Number.isFinite(p.y) || !Number.isFinite(p.z)) continue;
527
+ const key = keyOf(p);
528
+ const bucket = buckets.get(key);
529
+ let duplicate = false;
530
+ if (bucket) {
531
+ for (let i = 0; i < bucket.length; i++) {
532
+ const existing = out[bucket[i]]?.position;
533
+ if (!existing) continue;
534
+ if (
535
+ Math.abs(existing.x - p.x) <= tol &&
536
+ Math.abs(existing.y - p.y) <= tol &&
537
+ Math.abs(existing.z - p.z) <= tol
538
+ ) {
539
+ duplicate = true;
540
+ break;
541
+ }
542
+ }
543
+ }
544
+ if (duplicate) continue;
545
+ const outIndex = out.length;
546
+ out.push(item);
547
+ if (bucket) bucket.push(outIndex);
548
+ else buckets.set(key, [outIndex]);
549
+ }
550
+ return out;
551
+ }
552
+
370
553
  function escapeRegExp(str) {
371
554
  return String(str).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
372
555
  }
@@ -634,23 +817,32 @@ export class HoleFeature {
634
817
 
635
818
  const pointObjs = [];
636
819
  const sceneSolids = collectSceneSolids(partHistory?.scene);
637
- let pointPositions = [];
820
+ let pointPlacements = [];
638
821
 
639
- // Use sketch-defined points (excluding the sketch origin) as hole centers.
822
+ // Use sketch-defined points as hole centers (construction points are excluded).
640
823
  const extraPts = collectSketchVertices(sketch);
641
824
  if (extraPts.length) {
642
825
  pointObjs.push(...extraPts);
643
- pointPositions = pointObjs.map((o) => getWorldPosition(o)).filter(Boolean);
826
+ pointPlacements = pointObjs
827
+ .map((o) => ({ pointObj: o, position: getWorldPosition(o) }))
828
+ .filter((entry) => !!entry.position);
644
829
  }
645
- if (!pointPositions.length && partHistory?.scene && sketch?.name) {
830
+ if (!pointPlacements.length && partHistory?.scene && sketch?.name) {
646
831
  const fallbackPts = collectSketchVerticesByName(partHistory.scene, sketch.name);
647
832
  if (fallbackPts.length) {
648
833
  pointObjs.push(...fallbackPts);
649
- pointPositions = pointObjs.map((o) => getWorldPosition(o)).filter(Boolean);
834
+ pointPlacements = pointObjs
835
+ .map((o) => ({ pointObj: o, position: getWorldPosition(o) }))
836
+ .filter((entry) => !!entry.position);
650
837
  }
651
838
  }
839
+ const uniquePointPlacements = dedupePlacementsByPosition(pointPlacements);
840
+ if (pointPlacements.length > uniquePointPlacements.length) {
841
+ const skipped = pointPlacements.length - uniquePointPlacements.length;
842
+ console.log('[HoleFeature] Skipping duplicate coincident hole points:', skipped);
843
+ }
652
844
 
653
- const hasPoints = pointPositions.length > 0;
845
+ const hasPoints = uniquePointPlacements.length > 0;
654
846
  const normal = normalFromSketch(sketch); // keep hole axis perpendicular to the sketch plane
655
847
  const center = centerFromObject(sketch);
656
848
 
@@ -747,14 +939,16 @@ export class HoleFeature {
747
939
 
748
940
  const res = 48;
749
941
  const backOffset = 1e-5; // small pullback to avoid coincident faces in booleans
750
- const centers = hasPoints ? pointPositions : [center];
751
- const sourceNames = hasPoints ? pointObjs.map((o) => o?.name || o?.uuid || null) : [null];
942
+ const centers = hasPoints ? uniquePointPlacements.map((entry) => entry.position) : [center];
943
+ const sourceNames = hasPoints ? uniquePointPlacements.map((entry) => entry?.pointObj?.name || entry?.pointObj?.uuid || null) : [null];
752
944
  const tools = [];
753
945
  const holeRecords = [];
754
946
  const debugVisualizationObjects = []; // Store debug viz objects separately
755
947
  centers.forEach((c, idx) => {
756
948
  const pointName = sourceNames[idx] || null;
757
949
  const holeFacePrefix = pointName || (featureID ? `${featureID}_${idx}` : `HOLE_${idx}`);
950
+ let modeledThreadFaceNames = null;
951
+ let modeledThreadPitch = null;
758
952
  const { solids: toolSolids, descriptors } = makeHoleTool({
759
953
  holeType,
760
954
  radius,
@@ -834,6 +1028,7 @@ export class HoleFeature {
834
1028
  const threadLengthEffective = threadMode === 'MODELED'
835
1029
  ? Math.max(0, threadLength + extraThreadLength * 2)
836
1030
  : threadLength;
1031
+ const threadCutFaceName = `${holeFacePrefix}_THREAD_FACE`;
837
1032
 
838
1033
  const threadSolid = threadGeomScaled.toSolid({
839
1034
  length: threadLengthEffective,
@@ -844,11 +1039,36 @@ export class HoleFeature {
844
1039
  resolution: res,
845
1040
  segmentsPerTurn: threadSegmentsPerTurn,
846
1041
  name: `${holeFacePrefix}_THREAD`,
847
- faceName: `${holeFacePrefix}_THREAD_FACE`,
1042
+ faceName: threadCutFaceName,
848
1043
  axis: [0, 1, 0],
849
1044
  origin: [0, threadStartEffective, 0],
850
1045
  xDirection: [1, 0, 0],
851
1046
  });
1047
+ const canonicalCoreFaceName = `${holeFacePrefix}_THREAD_CORE`;
1048
+ if (threadMode === 'MODELED') {
1049
+ try {
1050
+ modeledThreadPitch = Number(threadGeomScaled?.pitch || threadGeom?.pitch || 0);
1051
+ modeledThreadFaceNames = [
1052
+ `${threadCutFaceName}:FLANK_A`,
1053
+ `${threadCutFaceName}:ROOT`,
1054
+ `${threadCutFaceName}:FLANK_B`,
1055
+ canonicalCoreFaceName,
1056
+ ];
1057
+ const mergeIntoCore = [
1058
+ `${threadCutFaceName}:CREST`,
1059
+ `${threadCutFaceName}:CAP_START`,
1060
+ `${threadCutFaceName}:CAP_END`,
1061
+ ];
1062
+ for (const fromName of mergeIntoCore) {
1063
+ threadSolid.renameFace(fromName, canonicalCoreFaceName);
1064
+ }
1065
+ if (descriptor) {
1066
+ threadSolid.setFaceMetadata(canonicalCoreFaceName, { hole: { ...descriptor } });
1067
+ }
1068
+ } catch {
1069
+ /* best-effort */
1070
+ }
1071
+ }
852
1072
  console.log('[HoleFeature] Thread solid created:', {
853
1073
  type: threadSolid?.constructor?.name,
854
1074
  hasGeometry: !!threadSolid?.geometry,
@@ -873,6 +1093,9 @@ export class HoleFeature {
873
1093
  const coreR0 = minorRadiusAt(0);
874
1094
  const coreR1 = minorRadiusAt(threadLengthEffective);
875
1095
  const coreName = `${holeFacePrefix}_THREAD_CORE`;
1096
+ const coreResolution = threadMode === 'MODELED'
1097
+ ? Math.max(8, Math.min(res, threadSegmentsPerTurn * 2))
1098
+ : res;
876
1099
  const coreHeight = threadLengthEffective;
877
1100
  if (coreHeight > 0) {
878
1101
  const coreSolid = threadGeomScaled.isTapered && Math.abs(coreR0 - coreR1) > 1e-6
@@ -880,13 +1103,13 @@ export class HoleFeature {
880
1103
  r1: coreR0,
881
1104
  r2: coreR1,
882
1105
  h: coreHeight,
883
- resolution: res,
1106
+ resolution: coreResolution,
884
1107
  name: coreName,
885
1108
  })
886
1109
  : new BREP.Cylinder({
887
1110
  radius: coreR0,
888
1111
  height: coreHeight,
889
- resolution: res,
1112
+ resolution: coreResolution,
890
1113
  name: coreName,
891
1114
  });
892
1115
  coreSolid.bakeTRS({
@@ -897,6 +1120,20 @@ export class HoleFeature {
897
1120
  if (descriptor) {
898
1121
  try { coreSolid.setFaceMetadata(`${coreName}_S`, { hole: { ...descriptor } }); } catch { /* best-effort */ }
899
1122
  }
1123
+ if (threadMode === 'MODELED') {
1124
+ try {
1125
+ const coreFaces = typeof coreSolid.getFaceNames === 'function' ? coreSolid.getFaceNames() : [];
1126
+ for (const coreFaceName of coreFaces) {
1127
+ if (!coreFaceName || coreFaceName === canonicalCoreFaceName) continue;
1128
+ coreSolid.renameFace(coreFaceName, canonicalCoreFaceName);
1129
+ }
1130
+ if (descriptor) {
1131
+ coreSolid.setFaceMetadata(canonicalCoreFaceName, { hole: { ...descriptor } });
1132
+ }
1133
+ } catch {
1134
+ /* best-effort */
1135
+ }
1136
+ }
900
1137
  toolSolids.push(coreSolid);
901
1138
  }
902
1139
 
@@ -1011,6 +1248,16 @@ export class HoleFeature {
1011
1248
  console.log('[HoleFeature] Unioning', toolSolids.length, 'solids for hole tool');
1012
1249
  const tool = unionSolids(toolSolids);
1013
1250
  if (!tool) return;
1251
+ if (threadMode === 'MODELED' && modeledThreadFaceNames?.length) {
1252
+ try {
1253
+ const cleanupSummary = cleanupModeledThreadFaceIslands(tool, modeledThreadFaceNames, modeledThreadPitch);
1254
+ if (cleanupSummary) {
1255
+ console.log('[HoleFeature] Modeled thread face cleanup summary:', cleanupSummary);
1256
+ }
1257
+ } catch (cleanupErr) {
1258
+ console.warn('[HoleFeature] Modeled thread face island cleanup failed:', cleanupErr);
1259
+ }
1260
+ }
1014
1261
  if (debugShowSolid) {
1015
1262
  try {
1016
1263
  tool.visualize();
@@ -79,6 +79,39 @@ const inputParamsSchema = {
79
79
  },
80
80
  };
81
81
 
82
+ function normalizeSketchPointAttributes(sketch, externalRefPointIds = null) {
83
+ if (!sketch || typeof sketch !== 'object') return;
84
+ sketch.points = Array.isArray(sketch.points) ? sketch.points : [];
85
+ sketch.geometries = Array.isArray(sketch.geometries) ? sketch.geometries : [];
86
+ sketch.constraints = Array.isArray(sketch.constraints) ? sketch.constraints : [];
87
+ if (sketch.points.length === 0) {
88
+ sketch.points.push({ id: 0, x: 0, y: 0, fixed: true, construction: true, externalReference: false });
89
+ }
90
+
91
+ const extIds = externalRefPointIds instanceof Set ? externalRefPointIds : new Set();
92
+ if (!(externalRefPointIds instanceof Set)) {
93
+ try {
94
+ for (const p of sketch.points) {
95
+ if (p?.externalReference === true && Number.isFinite(Number(p?.id))) {
96
+ extIds.add(Number(p.id));
97
+ }
98
+ }
99
+ } catch { /* ignore */ }
100
+ }
101
+
102
+ for (const p of sketch.points) {
103
+ if (!p) continue;
104
+ p.fixed = p.fixed === true;
105
+ const pid = Number(p.id);
106
+ const isOrigin = Number.isFinite(pid) && pid === 0;
107
+ const isExternalRef = Number.isFinite(pid) && extIds.has(pid);
108
+ p.externalReference = isExternalRef;
109
+ if (typeof p.construction !== 'boolean') {
110
+ p.construction = isOrigin || isExternalRef;
111
+ }
112
+ }
113
+ }
114
+
82
115
  export class SketchFeature {
83
116
  static shortName = "S";
84
117
  static longName = "Sketch";
@@ -342,6 +375,18 @@ export class SketchFeature {
342
375
  y: Array.isArray(basis.y) ? basis.y.slice() : [0, 1, 0],
343
376
  z: Array.isArray(basis.z) ? basis.z.slice() : null,
344
377
  };
378
+ sceneGroup.userData.sketchFeatureId = featureId;
379
+ const externalRefPointIds = new Set();
380
+ try {
381
+ const refs = Array.isArray(this.persistentData?.externalRefs) ? this.persistentData.externalRefs : [];
382
+ for (const ref of refs) {
383
+ const p0 = Number(ref?.p0);
384
+ const p1 = Number(ref?.p1);
385
+ if (Number.isFinite(p0)) externalRefPointIds.add(p0);
386
+ if (Number.isFinite(p1)) externalRefPointIds.add(p1);
387
+ }
388
+ } catch { /* ignore */ }
389
+ sceneGroup.userData.externalRefPointIds = Array.from(externalRefPointIds);
345
390
  const bO = new THREE.Vector3().fromArray(basis.origin);
346
391
  const bX = new THREE.Vector3().fromArray(basis.x);
347
392
  const bY = new THREE.Vector3().fromArray(basis.y);
@@ -366,13 +411,37 @@ export class SketchFeature {
366
411
  };
367
412
 
368
413
  // Start from persisted sketch
369
- let sketch = this.persistentData?.sketch || { points: [{ id:0, x:0, y:0, fixed:true }], geometries: [], constraints: [{ id:0, type:"⏚", points:[0]}] };
414
+ let sketch = this.persistentData?.sketch || { points: [{ id:0, x:0, y:0, fixed:true, construction:true, externalReference:false }], geometries: [], constraints: [{ id:0, type:"⏚", points:[0]}] };
415
+ normalizeSketchPointAttributes(sketch, externalRefPointIds);
370
416
  this.persistentData = this.persistentData || {};
371
417
  this.persistentData.lastProfileDiagnostics = null;
418
+ const solveSketchForFeature = (inputSketch, {
419
+ iterations = 500,
420
+ maxPasses = 1,
421
+ stopWhenConstraintsClear = false,
422
+ } = {}) => {
423
+ let solved = inputSketch;
424
+ let prevSig = null;
425
+ const passCount = Math.max(1, Number(maxPasses) || 1);
426
+ const iters = Math.max(1, Number(iterations) || 500);
427
+ for (let pass = 0; pass < passCount; pass++) {
428
+ const engine = new ConstraintEngine(JSON.stringify(solved));
429
+ solved = engine.solve(iters);
430
+ const pointSig = JSON.stringify(solved?.points || []);
431
+ const hasConstraintErrors = Array.isArray(solved?.constraints)
432
+ ? solved.constraints.some((c) => typeof c?.error === 'string' && c.error.length > 0)
433
+ : false;
434
+ if (stopWhenConstraintsClear && !hasConstraintErrors) break;
435
+ if (pointSig === prevSig) break;
436
+ prevSig = pointSig;
437
+ }
438
+ return solved;
439
+ };
372
440
 
373
441
  // Evaluate any expression-backed values on points/constraints using global expressions
374
442
  try {
375
443
  const exprSrc = partHistory?.expressions || '';
444
+ let expressionDrivenValueChanged = false;
376
445
  const runExpr = (expressions, equation) => {
377
446
  try {
378
447
  const fn = `${expressions}; return ${equation} ;`;
@@ -388,11 +457,19 @@ export class SketchFeature {
388
457
  for (const p of sketch.points) {
389
458
  if (typeof p.x === 'string') {
390
459
  const n = runExpr(exprSrc, p.x);
391
- if (n != null && Number.isFinite(n)) p.x = Number(n);
460
+ if (n != null && Number.isFinite(n)) {
461
+ const next = Number(n);
462
+ if (Number.isFinite(next) && Number(p.x) !== next) expressionDrivenValueChanged = true;
463
+ p.x = next;
464
+ }
392
465
  }
393
466
  if (typeof p.y === 'string') {
394
467
  const n = runExpr(exprSrc, p.y);
395
- if (n != null && Number.isFinite(n)) p.y = Number(n);
468
+ if (n != null && Number.isFinite(n)) {
469
+ const next = Number(n);
470
+ if (Number.isFinite(next) && Number(p.y) !== next) expressionDrivenValueChanged = true;
471
+ p.y = next;
472
+ }
396
473
  }
397
474
  }
398
475
  }
@@ -405,19 +482,29 @@ export class SketchFeature {
405
482
  c?.type === '⟺' &&
406
483
  c?.displayStyle === 'diameter' &&
407
484
  c?.valueExprMode === 'diameter';
408
- c.value = diameterExpr ? Number(n) * 0.5 : Number(n);
485
+ const next = diameterExpr ? Number(n) * 0.5 : Number(n);
486
+ if (Number.isFinite(next) && Number(c.value) !== next) expressionDrivenValueChanged = true;
487
+ c.value = next;
409
488
  }
410
489
  } else if (typeof c?.value === 'string') {
411
490
  const n = runExpr(exprSrc, c.value);
412
- if (n != null && Number.isFinite(n)) c.value = Number(n);
491
+ if (n != null && Number.isFinite(n)) {
492
+ const next = Number(n);
493
+ if (Number.isFinite(next) && Number(c.value) !== next) expressionDrivenValueChanged = true;
494
+ c.value = next;
495
+ }
413
496
  }
414
497
  }
415
498
  }
416
499
  // Re-solve sketch with evaluated values to reflect latest expressions
417
500
  try {
418
- const engine = new ConstraintEngine(JSON.stringify(sketch));
419
- const solved = engine.solve(500);
501
+ const solved = solveSketchForFeature(sketch, {
502
+ iterations: expressionDrivenValueChanged ? 2000 : 500,
503
+ maxPasses: expressionDrivenValueChanged ? 8 : 1,
504
+ stopWhenConstraintsClear: expressionDrivenValueChanged,
505
+ });
420
506
  sketch = solved;
507
+ normalizeSketchPointAttributes(sketch, externalRefPointIds);
421
508
  this.persistentData.sketch = solved;
422
509
  } catch {}
423
510
  } catch {}
@@ -483,7 +570,16 @@ export class SketchFeature {
483
570
  const p1 = ptById.get(r.p1);
484
571
  if (p0 && (p0.x !== uvA.u || p0.y !== uvA.v)) { p0.x = uvA.u; p0.y = uvA.v; changed = true; }
485
572
  if (p1 && (p1.x !== uvB.u || p1.y !== uvB.v)) { p1.x = uvB.u; p1.y = uvB.v; changed = true; }
486
- if (p0) p0.fixed = true; if (p1) p1.fixed = true;
573
+ if (p0) {
574
+ p0.fixed = true;
575
+ p0.externalReference = true;
576
+ if (typeof p0.construction !== 'boolean') p0.construction = true;
577
+ }
578
+ if (p1) {
579
+ p1.fixed = true;
580
+ p1.externalReference = true;
581
+ if (typeof p1.construction !== 'boolean') p1.construction = true;
582
+ }
487
583
  // Ensure ground constraints exist for these points so solver treats them fixed
488
584
  const ensureGround = (pid)=>{
489
585
  if (!sketch.constraints.some(c=>c.type==='⏚' && Array.isArray(c.points) && c.points[0]===pid)){
@@ -498,9 +594,13 @@ export class SketchFeature {
498
594
  }
499
595
  if (changed) {
500
596
  try {
501
- const engine = new ConstraintEngine(JSON.stringify(sketch));
502
- const solved = engine.solve(500);
597
+ const solved = solveSketchForFeature(sketch, {
598
+ iterations: 2000,
599
+ maxPasses: 8,
600
+ stopWhenConstraintsClear: true,
601
+ });
503
602
  sketch = solved;
603
+ normalizeSketchPointAttributes(sketch, externalRefPointIds);
504
604
  this.persistentData.sketch = solved;
505
605
  } catch {}
506
606
  }
@@ -511,7 +611,7 @@ export class SketchFeature {
511
611
  // Helper: 2D → 3D
512
612
  const to3D = (u, v) => new THREE.Vector3().copy(bO).addScaledVector(bX, u).addScaledVector(bY, v);
513
613
 
514
- // Add vertex visuals in 3D for every sketch point (including isolated points)
614
+ // Add vertex visuals in 3D for non-construction sketch points (including isolated points)
515
615
  try {
516
616
  if (Array.isArray(sketch?.points)) {
517
617
  let autoId = 0;
@@ -519,6 +619,7 @@ export class SketchFeature {
519
619
  if (p == null) continue;
520
620
  const u = Number(p.x); const v = Number(p.y);
521
621
  if (!Number.isFinite(u) || !Number.isFinite(v)) continue;
622
+ if (p.construction === true) continue;
522
623
  const w = to3D(u, v);
523
624
  const hasExplicitId = p.id !== undefined && p.id !== null && `${p.id}` !== '';
524
625
  const pointLabel = hasExplicitId ? p.id : autoId++;
@@ -526,8 +627,12 @@ export class SketchFeature {
526
627
  try {
527
628
  const vertex = new BREP.Vertex([w.x, w.y, w.z], { name: vertexName });
528
629
  vertex.userData = vertex.userData || {};
529
- vertex.userData.sketchPointId = hasExplicitId ? p.id : pointLabel;
630
+ const pointId = hasExplicitId ? p.id : pointLabel;
631
+ const pointIdNum = Number(pointId);
632
+ vertex.userData.sketchPointId = pointId;
530
633
  vertex.userData.sketchFeatureId = featureId;
634
+ vertex.userData.isConstructionPoint = p.construction === true;
635
+ vertex.userData.isExternalReference = Number.isFinite(pointIdNum) && externalRefPointIds.has(pointIdNum);
531
636
  sceneGroup.add(vertex);
532
637
  } catch {}
533
638
  }