fluidcad 0.0.28 → 0.0.30

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.
Files changed (145) hide show
  1. package/lib/dist/common/profiler.d.ts +12 -0
  2. package/lib/dist/common/profiler.js +35 -0
  3. package/lib/dist/common/scene-object.d.ts +3 -0
  4. package/lib/dist/common/scene-object.js +3 -0
  5. package/lib/dist/common/shape-history-tracker.d.ts +9 -1
  6. package/lib/dist/common/shape-history-tracker.js +37 -23
  7. package/lib/dist/core/2d/aline.d.ts +13 -13
  8. package/lib/dist/core/2d/aline.js +20 -11
  9. package/lib/dist/core/2d/arc.d.ts +6 -6
  10. package/lib/dist/core/2d/arc.js +19 -15
  11. package/lib/dist/core/2d/back.d.ts +12 -0
  12. package/lib/dist/core/2d/back.js +11 -0
  13. package/lib/dist/core/2d/circle.d.ts +2 -2
  14. package/lib/dist/core/2d/circle.js +14 -10
  15. package/lib/dist/core/2d/ellipse.d.ts +35 -0
  16. package/lib/dist/core/2d/ellipse.js +65 -0
  17. package/lib/dist/core/2d/hline.d.ts +20 -13
  18. package/lib/dist/core/2d/hline.js +33 -15
  19. package/lib/dist/core/2d/index.d.ts +2 -0
  20. package/lib/dist/core/2d/index.js +2 -0
  21. package/lib/dist/core/2d/intersect.d.ts +2 -2
  22. package/lib/dist/core/2d/intersect.js +7 -3
  23. package/lib/dist/core/2d/line.d.ts +2 -2
  24. package/lib/dist/core/2d/line.js +14 -10
  25. package/lib/dist/core/2d/offset.d.ts +4 -4
  26. package/lib/dist/core/2d/offset.js +9 -5
  27. package/lib/dist/core/2d/polygon.d.ts +4 -4
  28. package/lib/dist/core/2d/polygon.js +24 -20
  29. package/lib/dist/core/2d/project.d.ts +2 -2
  30. package/lib/dist/core/2d/project.js +7 -3
  31. package/lib/dist/core/2d/rect.d.ts +2 -2
  32. package/lib/dist/core/2d/rect.js +22 -21
  33. package/lib/dist/core/2d/slot.d.ts +6 -6
  34. package/lib/dist/core/2d/slot.js +29 -32
  35. package/lib/dist/core/2d/vline.d.ts +20 -13
  36. package/lib/dist/core/2d/vline.js +29 -15
  37. package/lib/dist/core/interfaces.d.ts +62 -0
  38. package/lib/dist/core/mirror.d.ts +7 -7
  39. package/lib/dist/core/mirror.js +17 -11
  40. package/lib/dist/core/part.d.ts +3 -1
  41. package/lib/dist/core/part.js +1 -1
  42. package/lib/dist/core/rotate.d.ts +5 -5
  43. package/lib/dist/core/rotate.js +4 -1
  44. package/lib/dist/core/sketch.d.ts +3 -1
  45. package/lib/dist/core/sketch.js +1 -1
  46. package/lib/dist/core/translate.d.ts +9 -9
  47. package/lib/dist/features/2d/aline.d.ts +8 -5
  48. package/lib/dist/features/2d/aline.js +70 -18
  49. package/lib/dist/features/2d/back.d.ts +14 -0
  50. package/lib/dist/features/2d/back.js +35 -0
  51. package/lib/dist/features/2d/ellipse.d.ts +23 -0
  52. package/lib/dist/features/2d/ellipse.js +75 -0
  53. package/lib/dist/features/2d/hline.d.ts +9 -4
  54. package/lib/dist/features/2d/hline.js +65 -14
  55. package/lib/dist/features/2d/offset.d.ts +3 -0
  56. package/lib/dist/features/2d/offset.js +27 -3
  57. package/lib/dist/features/2d/sketch.d.ts +1 -0
  58. package/lib/dist/features/2d/sketch.js +15 -0
  59. package/lib/dist/features/2d/vline.d.ts +9 -4
  60. package/lib/dist/features/2d/vline.js +67 -15
  61. package/lib/dist/features/common.js +2 -1
  62. package/lib/dist/features/extrude-base.d.ts +19 -1
  63. package/lib/dist/features/extrude-base.js +75 -12
  64. package/lib/dist/features/extrude-two-distances.js +32 -27
  65. package/lib/dist/features/extrude.d.ts +39 -0
  66. package/lib/dist/features/extrude.js +196 -156
  67. package/lib/dist/features/fuse.js +2 -1
  68. package/lib/dist/features/lazy-scene-object.d.ts +1 -0
  69. package/lib/dist/features/lazy-scene-object.js +3 -0
  70. package/lib/dist/features/lazy-vertex.d.ts +1 -0
  71. package/lib/dist/features/lazy-vertex.js +3 -0
  72. package/lib/dist/features/loft.js +11 -8
  73. package/lib/dist/features/mirror-shape.d.ts +2 -0
  74. package/lib/dist/features/mirror-shape.js +16 -0
  75. package/lib/dist/features/mirror-shape2d.d.ts +2 -0
  76. package/lib/dist/features/mirror-shape2d.js +22 -1
  77. package/lib/dist/features/revolve.d.ts +31 -0
  78. package/lib/dist/features/revolve.js +178 -95
  79. package/lib/dist/features/rotate.d.ts +2 -0
  80. package/lib/dist/features/rotate.js +16 -0
  81. package/lib/dist/features/rotate2d.d.ts +2 -0
  82. package/lib/dist/features/rotate2d.js +16 -0
  83. package/lib/dist/features/select.js +2 -1
  84. package/lib/dist/features/simple-extruder.d.ts +3 -1
  85. package/lib/dist/features/simple-extruder.js +13 -9
  86. package/lib/dist/features/subtract.d.ts +2 -2
  87. package/lib/dist/features/subtract.js +3 -3
  88. package/lib/dist/features/sweep.d.ts +14 -0
  89. package/lib/dist/features/sweep.js +93 -80
  90. package/lib/dist/features/translate.d.ts +2 -0
  91. package/lib/dist/features/translate.js +23 -2
  92. package/lib/dist/filters/edge/edge-filter.d.ts +6 -0
  93. package/lib/dist/filters/edge/edge-filter.js +11 -0
  94. package/lib/dist/filters/face/face-filter.d.ts +6 -0
  95. package/lib/dist/filters/face/face-filter.js +11 -0
  96. package/lib/dist/filters/filter-base.d.ts +7 -1
  97. package/lib/dist/filters/filter-base.js +8 -0
  98. package/lib/dist/filters/filter-builder-base.js +11 -0
  99. package/lib/dist/filters/from-object.d.ts +14 -0
  100. package/lib/dist/filters/from-object.js +40 -0
  101. package/lib/dist/helpers/scene-helpers.d.ts +2 -0
  102. package/lib/dist/helpers/scene-helpers.js +68 -48
  103. package/lib/dist/oc/color-transfer.js +6 -0
  104. package/lib/dist/oc/edge-ops.d.ts +1 -0
  105. package/lib/dist/oc/edge-ops.js +17 -0
  106. package/lib/dist/oc/extrude-ops.d.ts +18 -1
  107. package/lib/dist/oc/extrude-ops.js +34 -1
  108. package/lib/dist/oc/geometry.d.ts +1 -0
  109. package/lib/dist/oc/geometry.js +27 -0
  110. package/lib/dist/oc/mesh.js +11 -9
  111. package/lib/dist/oc/ray-intersect.d.ts +16 -0
  112. package/lib/dist/oc/ray-intersect.js +91 -0
  113. package/lib/dist/oc/thin-face-maker.d.ts +0 -1
  114. package/lib/dist/oc/thin-face-maker.js +2 -20
  115. package/lib/dist/rendering/render.d.ts +2 -1
  116. package/lib/dist/rendering/render.js +72 -33
  117. package/lib/dist/rendering/scene.d.ts +4 -0
  118. package/lib/dist/tests/features/2d/back.test.d.ts +1 -0
  119. package/lib/dist/tests/features/2d/back.test.js +60 -0
  120. package/lib/dist/tests/features/2d/circle.test.js +1 -1
  121. package/lib/dist/tests/features/2d/constrained.test.js +4 -4
  122. package/lib/dist/tests/features/2d/ellipse.test.d.ts +1 -0
  123. package/lib/dist/tests/features/2d/ellipse.test.js +100 -0
  124. package/lib/dist/tests/features/2d/line.test.js +89 -3
  125. package/lib/dist/tests/features/2d/offset.test.js +1 -1
  126. package/lib/dist/tests/features/2d/polygon.test.js +2 -2
  127. package/lib/dist/tests/features/2d/rect.test.js +1 -1
  128. package/lib/dist/tests/features/2d/slot-from-edge.test.js +1 -1
  129. package/lib/dist/tests/features/2d/slot.test.js +1 -1
  130. package/lib/dist/tests/features/mirror.test.js +58 -0
  131. package/lib/dist/tests/features/mirror2d.test.js +63 -0
  132. package/lib/dist/tests/features/rotate.test.js +62 -0
  133. package/lib/dist/tests/features/rotate2d.test.js +47 -0
  134. package/lib/dist/tests/features/thin-revolve.test.js +37 -1
  135. package/lib/dist/tests/features/translate.test.js +63 -0
  136. package/lib/dist/tests/perf/record-fusion-history.bench.test.d.ts +1 -0
  137. package/lib/dist/tests/perf/record-fusion-history.bench.test.js +77 -0
  138. package/lib/dist/tsconfig.tsbuildinfo +1 -1
  139. package/package.json +1 -1
  140. package/server/dist/index.js +77 -45
  141. package/server/dist/ws-protocol.d.ts +11 -0
  142. package/ui/dist/assets/{index-BrW_x4uc.js → index-6Ep4GPxf.js} +131 -77
  143. package/ui/dist/assets/index-DRKfe6N9.css +2 -0
  144. package/ui/dist/index.html +2 -2
  145. package/ui/dist/assets/index-gPoNOiIs.css +0 -2
@@ -15,41 +15,43 @@ export class ExtrudeTwoDistances extends ExtrudeBase {
15
15
  this.distance2 = distance2;
16
16
  }
17
17
  build(context) {
18
- const sceneObjects = this.resolveFusionScope(context.getSceneObjects());
19
- const plane = this.getSourcePlane();
20
- const pickedFaces = this.resolvePickedFaces(plane);
18
+ const p = context.getProfiler();
19
+ const plane = p.record('Get source plane', () => this.getSourcePlane());
20
+ const pickedFaces = p.record('Resolve picked faces', () => this.resolvePickedFaces(plane));
21
21
  if (pickedFaces !== null && pickedFaces.length === 0) {
22
22
  return;
23
23
  }
24
24
  let faces;
25
25
  let inwardEdges;
26
26
  let outwardEdges;
27
- if (this.isFaceSourced()) {
28
- if (this.isThin()) {
29
- throw new Error("thin() is not supported with a face-sourced extrude");
27
+ faces = p.record('Resolve faces', () => {
28
+ if (this.isFaceSourced()) {
29
+ if (this.isThin()) {
30
+ throw new Error("thin() is not supported with a face-sourced extrude");
31
+ }
32
+ return pickedFaces ?? this.getSourceFaces();
30
33
  }
31
- faces = pickedFaces ?? this.getSourceFaces();
32
- }
33
- else if (this.isThin()) {
34
- const thinResult = ThinFaceMaker.make(this.extrudable.getGeometries(), plane, this._thin[0], this._thin[1]);
35
- faces = thinResult.faces;
36
- inwardEdges = thinResult.inwardEdges;
37
- outwardEdges = thinResult.outwardEdges;
38
- }
39
- else {
40
- faces = pickedFaces ?? FaceMaker2.getRegions(this.extrudable.getGeometries(), plane, this.getDrill());
41
- }
34
+ else if (this.isThin()) {
35
+ const thinResult = ThinFaceMaker.make(this.extrudable.getGeometries(), plane, this._thin[0], this._thin[1]);
36
+ inwardEdges = thinResult.inwardEdges;
37
+ outwardEdges = thinResult.outwardEdges;
38
+ return thinResult.faces;
39
+ }
40
+ else {
41
+ return pickedFaces ?? FaceMaker2.getRegions(this.extrudable.getGeometries(), plane, this.getDrill());
42
+ }
43
+ });
42
44
  const draft = this.getDraft();
43
45
  const draft1 = draft ? [draft[0], draft[0]] : undefined;
44
46
  const draft2 = draft ? [draft[1], draft[1]] : undefined;
45
- const extruder1 = new Extruder(faces, plane, this.distance1, draft1, this.getEndOffset());
46
- const extrusions1 = extruder1.extrude();
47
+ const extruder1 = new Extruder(faces, plane, this.distance1, draft1, this.getEndOffset(), p);
48
+ const extrusions1 = p.record('Extrude direction 1', () => extruder1.extrude());
47
49
  const startFaces = extruder1.getEndFaces();
48
- const extruder2 = new Extruder(faces, plane, -this.distance2, draft2, this.getEndOffset());
49
- const extrusions2 = extruder2.extrude();
50
+ const extruder2 = new Extruder(faces, plane, -this.distance2, draft2, this.getEndOffset(), p);
51
+ const extrusions2 = p.record('Extrude direction 2', () => extruder2.extrude());
50
52
  const endFaces = extruder2.getEndFaces();
51
53
  const all = [...extrusions1, ...extrusions2];
52
- const halvesFuse = BooleanOps.fuse(all);
54
+ const halvesFuse = p.record('Fuse halves', () => BooleanOps.fuse(all));
53
55
  const extrusions = halvesFuse.result;
54
56
  halvesFuse.dispose();
55
57
  const remainingFaces = [];
@@ -122,13 +124,16 @@ export class ExtrudeTwoDistances extends ExtrudeBase {
122
124
  this.setState('cap-faces', capFaces);
123
125
  this.getSource()?.removeShapes(this);
124
126
  if (this._operationMode === 'remove') {
125
- const scope = this.resolveFusionScope(context.getSceneObjects());
126
- cutWithSceneObjects(scope, extrusions, plane, this.distance1 + this.distance2, this, {
127
- recordHistoryFor: this,
127
+ const scope = p.record('Resolve fusion scope', () => this.resolveFusionScope(context.getSceneObjects()));
128
+ p.record('Cut with scene objects', () => {
129
+ cutWithSceneObjects(scope, extrusions, plane, this.distance1 + this.distance2, this, {
130
+ recordHistoryFor: this,
131
+ });
128
132
  });
129
133
  this.setFinalShapes(this.getShapes());
130
134
  return;
131
135
  }
136
+ const sceneObjects = p.record('Resolve fusion scope', () => this.resolveFusionScope(context.getSceneObjects()));
132
137
  if (extrusions.length === 0 || sceneObjects.length === 0) {
133
138
  this.addShapes(extrusions);
134
139
  this.recordShapeFacesAndEdgesAsAdditions(extrusions);
@@ -136,9 +141,9 @@ export class ExtrudeTwoDistances extends ExtrudeBase {
136
141
  this.setFinalShapes(this.getShapes());
137
142
  return;
138
143
  }
139
- const fusionResult = fuseWithSceneObjects(sceneObjects, extrusions, {
144
+ const fusionResult = p.record('Fuse with scene objects', () => fuseWithSceneObjects(sceneObjects, extrusions, {
140
145
  recordHistoryFor: this,
141
- });
146
+ }));
142
147
  for (const modifiedShape of fusionResult.modifiedShapes) {
143
148
  if (!modifiedShape.object) {
144
149
  continue;
@@ -5,8 +5,47 @@ export declare class Extrude extends ExtrudeBase {
5
5
  distance: number;
6
6
  constructor(distance: number, source?: Extrudable | SceneObject);
7
7
  build(context: BuildSceneObjectContext): void;
8
+ /** Resolve the source faces for a non-thin extrude (add / symmetric / remove). */
9
+ private resolveSourceFaces;
10
+ /** Plain extrude: one direction, regular face classification. */
8
11
  private buildAdd;
12
+ /** Thin extrude: shell-like profile with inward/outward offsets. */
13
+ private buildAddThin;
14
+ /**
15
+ * Classify a thin extrusion's faces. For OPEN profiles (`inwardEdges`
16
+ * non-empty) we reclassify side/internal/cap via the inward/outward edge
17
+ * matching. For CLOSED profiles (no inward edges, e.g. a rect with `thin(5)`
18
+ * creating an annulus) the Extruder's own inner-wire detection already
19
+ * separated start/end/side/internal correctly — keep it as-is.
20
+ */
21
+ private classifyThinExtrusion;
22
+ /** Symmetric extrude: two halves fused together, regular face classification. */
9
23
  private buildSymmetric;
24
+ /**
25
+ * Classify symmetric-extrusion remaining faces using inner-wire detection.
26
+ * Used by both `buildSymmetric` and the closed-profile fallback in
27
+ * `buildSymmetricThin`. Faces of the fused solid that share an edge with a
28
+ * detected inner wire (a hole) are internal; the rest are side.
29
+ */
30
+ private classifySymmetricByInnerWires;
31
+ /** Symmetric thin extrude: two halves of a shell-like profile fused. */
32
+ private buildSymmetricThin;
33
+ /**
34
+ * Build the two half-extrusions for a symmetric op, fuse them together, and
35
+ * remap start/end faces onto the fused solid. The "remaining" faces (not
36
+ * start, not end) are returned for the caller to classify per-mode (regular
37
+ * inner-wire detection vs thin reclassification).
38
+ */
39
+ private buildSymmetricHalves;
40
+ /**
41
+ * Find post-fusion edges that came from inner wires of the start face's
42
+ * pre-fusion profile (holes). Used to classify the symmetric fused solid's
43
+ * remaining faces into side vs internal. Inner wires are detected as
44
+ * counter-clockwise wires on the sketch plane; their edges are mapped onto
45
+ * the fused solid by 2D midpoint matching (SimplifyResult breaks TShape
46
+ * identity for merged half-faces).
47
+ */
48
+ private detectFusedInnerEdges;
10
49
  private buildRemove;
11
50
  getDependencies(): SceneObject[];
12
51
  createCopy(remap: Map<SceneObject, SceneObject>): SceneObject;
@@ -1,5 +1,5 @@
1
1
  import { Extruder } from "./simple-extruder.js";
2
- import { fuseWithSceneObjects, cutWithSceneObjects } from "../helpers/scene-helpers.js";
2
+ import { cutWithSceneObjects } from "../helpers/scene-helpers.js";
3
3
  import { ExtrudeBase } from "./extrude-base.js";
4
4
  import { FaceMaker2 } from "../oc/face-maker2.js";
5
5
  import { BooleanOps } from "../oc/boolean-ops.js";
@@ -14,122 +14,193 @@ export class Extrude extends ExtrudeBase {
14
14
  this.distance = distance;
15
15
  }
16
16
  build(context) {
17
- const tBuild = performance.now();
18
- let t = performance.now();
19
- const plane = this.getSourcePlane();
20
- console.log(`[perf] Extrude.getSourcePlane: ${(performance.now() - t).toFixed(1)} ms`);
21
- t = performance.now();
22
- const pickedFaces = this.resolvePickedFaces(plane);
23
- console.log(`[perf] Extrude.resolvePickedFaces: ${(performance.now() - t).toFixed(1)} ms`);
17
+ const p = context.getProfiler();
18
+ const plane = p.record('Get source plane', () => this.getSourcePlane());
19
+ const pickedFaces = p.record('Resolve picked faces', () => this.resolvePickedFaces(plane));
24
20
  if (pickedFaces !== null && pickedFaces.length === 0) {
25
21
  return;
26
22
  }
27
- let faces;
28
- let inwardEdges;
29
- let outwardEdges;
30
- t = performance.now();
31
- if (this.isFaceSourced()) {
32
- if (this.isThin()) {
23
+ if (this.isThin()) {
24
+ if (this.isFaceSourced()) {
33
25
  throw new Error("thin() is not supported with a face-sourced extrude");
34
26
  }
35
- faces = pickedFaces ?? this.getSourceFaces();
36
- }
37
- else if (this.isThin()) {
38
- const thinResult = ThinFaceMaker.make(this.extrudable.getGeometries(), plane, this._thin[0], this._thin[1]);
39
- faces = thinResult.faces;
40
- inwardEdges = thinResult.inwardEdges;
41
- outwardEdges = thinResult.outwardEdges;
42
- }
43
- else {
44
- faces = pickedFaces ?? FaceMaker2.getRegions(this.extrudable.getGeometries(), plane, this.getDrill());
45
- }
46
- console.log(`[perf] Extrude.resolveFaces (faces=${faces.length}, faceSourced=${this.isFaceSourced()}): ${(performance.now() - t).toFixed(1)} ms`);
47
- if (this._operationMode === 'remove') {
48
- this.buildRemove(faces, plane, context);
49
- }
50
- else if (this._symmetric) {
51
- this.buildSymmetric(faces, plane, context, inwardEdges, outwardEdges);
27
+ const thinResult = p.record('Resolve thin faces', () => ThinFaceMaker.make(this.extrudable.getGeometries(), plane, this._thin[0], this._thin[1]));
28
+ if (this._operationMode === 'remove') {
29
+ // Thin + remove: use the thin profile faces as the cut tool source,
30
+ // but the cut path doesn't apply thin face reclassification.
31
+ this.buildRemove(thinResult.faces, plane, context);
32
+ }
33
+ else if (this._symmetric) {
34
+ this.buildSymmetricThin(thinResult, plane, context);
35
+ }
36
+ else {
37
+ this.buildAddThin(thinResult, plane, context);
38
+ }
52
39
  }
53
40
  else {
54
- this.buildAdd(faces, plane, context, inwardEdges, outwardEdges);
41
+ const faces = p.record('Resolve faces', () => this.resolveSourceFaces(plane, pickedFaces));
42
+ if (this._operationMode === 'remove') {
43
+ this.buildRemove(faces, plane, context);
44
+ }
45
+ else if (this._symmetric) {
46
+ this.buildSymmetric(faces, plane, context);
47
+ }
48
+ else {
49
+ this.buildAdd(faces, plane, context);
50
+ }
55
51
  }
56
52
  this.setFinalShapes(this.getShapes());
57
- console.log(`[perf] Extrude.build TOTAL: ${(performance.now() - tBuild).toFixed(1)} ms`);
58
53
  }
59
- buildAdd(faces, plane, context, inwardEdges, outwardEdges) {
60
- let t = performance.now();
61
- const sceneObjects = this.resolveFusionScope(context.getSceneObjects());
62
- console.log(`[perf] Extrude.buildAdd.resolveFusionScope (n=${sceneObjects.length}): ${(performance.now() - t).toFixed(1)} ms`);
63
- t = performance.now();
64
- const extruder = new Extruder(faces, plane, this.distance, this.getDraft(), this.getEndOffset());
65
- let extrusions = extruder.extrude();
66
- console.log(`[perf] Extrude.buildAdd.extruder.extrude (extrusions=${extrusions.length}): ${(performance.now() - t).toFixed(1)} ms`);
67
- let sideFaces = extruder.getSideFaces();
68
- let internalFaces = extruder.getInternalFaces();
69
- let capFaces = [];
70
- if (inwardEdges && inwardEdges.length > 0) {
71
- const result = this.reclassifyThinFaces([...sideFaces, ...internalFaces], extruder.getStartFaces(), plane, inwardEdges, outwardEdges || []);
72
- sideFaces = result.sideFaces;
73
- internalFaces = result.internalFaces;
74
- capFaces = result.capFaces;
54
+ /** Resolve the source faces for a non-thin extrude (add / symmetric / remove). */
55
+ resolveSourceFaces(plane, pickedFaces) {
56
+ if (this.isFaceSourced()) {
57
+ return pickedFaces ?? this.getSourceFaces();
75
58
  }
76
- this.setState('start-faces', extruder.getStartFaces());
77
- this.setState('end-faces', extruder.getEndFaces());
78
- this.setState('side-faces', sideFaces);
79
- this.setState('internal-faces', internalFaces);
80
- this.setState('cap-faces', capFaces);
59
+ return pickedFaces ?? FaceMaker2.getRegions(this.extrudable.getGeometries(), plane, this.getDrill());
60
+ }
61
+ /** Plain extrude: one direction, regular face classification. */
62
+ buildAdd(faces, plane, context) {
63
+ const p = context.getProfiler();
64
+ const extruder = new Extruder(faces, plane, this.distance, this.getDraft(), this.getEndOffset(), p);
65
+ const extrusions = p.record('Extrude faces', () => extruder.extrude());
66
+ const classified = {
67
+ startFaces: extruder.getStartFaces(),
68
+ endFaces: extruder.getEndFaces(),
69
+ sideFaces: extruder.getSideFaces(),
70
+ internalFaces: extruder.getInternalFaces(),
71
+ capFaces: [],
72
+ };
81
73
  this.getSource()?.removeShapes(this);
82
- console.log("Extrusions before fusion:", extrusions.length);
83
- if (extrusions.length === 0 || sceneObjects.length === 0) {
84
- this.addShapes(extrusions);
85
- this.recordShapeFacesAndEdgesAsAdditions(extrusions);
86
- this.classifyExtrudeEdges();
87
- return;
88
- }
89
- const tFuse = performance.now();
90
- const fusionResult = fuseWithSceneObjects(sceneObjects, extrusions, {
74
+ this.finalizeAndFuse(extrusions, classified, context, {
91
75
  glue: this.isFaceSourced() ? 'full' : undefined,
92
- recordHistoryFor: this,
93
76
  });
94
- console.log(`[perf] Extrude.buildAdd.fuseWithSceneObjects: ${(performance.now() - tFuse).toFixed(1)} ms`);
95
- for (const modifiedShape of fusionResult.modifiedShapes) {
96
- if (!modifiedShape.object) {
97
- continue;
77
+ }
78
+ /** Thin extrude: shell-like profile with inward/outward offsets. */
79
+ buildAddThin(thinResult, plane, context) {
80
+ const p = context.getProfiler();
81
+ const extruder = new Extruder(thinResult.faces, plane, this.distance, this.getDraft(), this.getEndOffset(), p);
82
+ const extrusions = p.record('Extrude thin faces', () => extruder.extrude());
83
+ const classified = this.classifyThinExtrusion(extruder.getStartFaces(), extruder.getEndFaces(), extruder.getSideFaces(), extruder.getInternalFaces(), extruder.getStartFaces(), plane, thinResult);
84
+ this.getSource()?.removeShapes(this);
85
+ this.finalizeAndFuse(extrusions, classified, context, {
86
+ glue: this.isFaceSourced() ? 'full' : undefined,
87
+ });
88
+ }
89
+ /**
90
+ * Classify a thin extrusion's faces. For OPEN profiles (`inwardEdges`
91
+ * non-empty) we reclassify side/internal/cap via the inward/outward edge
92
+ * matching. For CLOSED profiles (no inward edges, e.g. a rect with `thin(5)`
93
+ * creating an annulus) the Extruder's own inner-wire detection already
94
+ * separated start/end/side/internal correctly — keep it as-is.
95
+ */
96
+ classifyThinExtrusion(startFaces, endFaces, sideFaces, internalFaces, referenceFaces, plane, thinResult) {
97
+ if (thinResult.inwardEdges.length === 0) {
98
+ return {
99
+ startFaces,
100
+ endFaces,
101
+ sideFaces,
102
+ internalFaces,
103
+ capFaces: [],
104
+ };
105
+ }
106
+ const reclass = this.reclassifyThinFaces([...sideFaces, ...internalFaces], referenceFaces, plane, thinResult.inwardEdges, thinResult.outwardEdges);
107
+ return {
108
+ startFaces,
109
+ endFaces,
110
+ sideFaces: reclass.sideFaces,
111
+ internalFaces: reclass.internalFaces,
112
+ capFaces: reclass.capFaces,
113
+ };
114
+ }
115
+ /** Symmetric extrude: two halves fused together, regular face classification. */
116
+ buildSymmetric(faces, plane, context) {
117
+ const halves = this.buildSymmetricHalves(faces, plane, context);
118
+ const classified = this.classifySymmetricByInnerWires(halves, plane);
119
+ this.getSource()?.removeShapes(this);
120
+ this.finalizeAndFuse(halves.extrusions, classified, context);
121
+ }
122
+ /**
123
+ * Classify symmetric-extrusion remaining faces using inner-wire detection.
124
+ * Used by both `buildSymmetric` and the closed-profile fallback in
125
+ * `buildSymmetricThin`. Faces of the fused solid that share an edge with a
126
+ * detected inner wire (a hole) are internal; the rest are side.
127
+ */
128
+ classifySymmetricByInnerWires(halves, plane) {
129
+ const fusedInnerEdges = this.detectFusedInnerEdges(halves.extruder1, halves.fusedStartFaces, plane);
130
+ const sideFaces = [];
131
+ const internalFaces = [];
132
+ for (const f of halves.remainingFaces) {
133
+ const isInternal = fusedInnerEdges.length > 0 && f.getEdges().some(fe => fusedInnerEdges.some(ie => fe.getShape().IsPartner(ie.getShape())));
134
+ if (isInternal) {
135
+ internalFaces.push(f);
136
+ }
137
+ else {
138
+ sideFaces.push(f);
98
139
  }
99
- modifiedShape.object.removeShape(modifiedShape.shape, this);
100
140
  }
101
- this.addShapes(fusionResult.newShapes);
102
- if (fusionResult.toolHistory) {
103
- this.remapClassifiedFaces(fusionResult.toolHistory);
141
+ return {
142
+ startFaces: halves.startFaces,
143
+ endFaces: halves.endFaces,
144
+ sideFaces,
145
+ internalFaces,
146
+ capFaces: [],
147
+ };
148
+ }
149
+ /** Symmetric thin extrude: two halves of a shell-like profile fused. */
150
+ buildSymmetricThin(thinResult, plane, context) {
151
+ const halves = this.buildSymmetricHalves(thinResult.faces, plane, context);
152
+ let classified;
153
+ if (thinResult.inwardEdges.length > 0) {
154
+ // Open profile: reclassify side/internal/cap via inward/outward edge matching.
155
+ const reclass = this.reclassifyThinFaces(halves.remainingFaces, [...halves.fusedStartFaces, ...halves.fusedEndFaces], plane, thinResult.inwardEdges, thinResult.outwardEdges);
156
+ classified = {
157
+ startFaces: halves.startFaces,
158
+ endFaces: halves.endFaces,
159
+ sideFaces: reclass.sideFaces,
160
+ internalFaces: reclass.internalFaces,
161
+ capFaces: reclass.capFaces,
162
+ };
104
163
  }
105
- this.classifyExtrudeEdges();
164
+ else {
165
+ // Closed profile (e.g. rect.thin(5) producing an annulus): fall back to
166
+ // the regular symmetric inner-wire detection — the Extruder's wire
167
+ // orientation already encoded the hole correctly.
168
+ classified = this.classifySymmetricByInnerWires(halves, plane);
169
+ }
170
+ this.getSource()?.removeShapes(this);
171
+ this.finalizeAndFuse(halves.extrusions, classified, context);
106
172
  }
107
- buildSymmetric(faces, plane, context, inwardEdges, outwardEdges) {
108
- const sceneObjects = this.resolveFusionScope(context.getSceneObjects());
109
- const extruder1 = new Extruder(faces, plane, this.distance / 2, this.getDraft(), this.getEndOffset());
110
- const extrusions1 = extruder1.extrude();
173
+ /**
174
+ * Build the two half-extrusions for a symmetric op, fuse them together, and
175
+ * remap start/end faces onto the fused solid. The "remaining" faces (not
176
+ * start, not end) are returned for the caller to classify per-mode (regular
177
+ * inner-wire detection vs thin reclassification).
178
+ */
179
+ buildSymmetricHalves(faces, plane, context) {
180
+ const p = context.getProfiler();
181
+ const extruder1 = new Extruder(faces, plane, this.distance / 2, this.getDraft(), this.getEndOffset(), p);
182
+ const extrusions1 = p.record('Extrude direction 1', () => extruder1.extrude());
111
183
  const startFaces = extruder1.getEndFaces();
112
- const extruder2 = new Extruder(faces, plane, -this.distance / 2, this.getDraft(), this.getEndOffset());
113
- const extrusions2 = extruder2.extrude();
184
+ const extruder2 = new Extruder(faces, plane, -this.distance / 2, this.getDraft(), this.getEndOffset(), p);
185
+ const extrusions2 = p.record('Extrude direction 2', () => extruder2.extrude());
114
186
  const endFaces = extruder2.getEndFaces();
115
187
  const all = [...extrusions1, ...extrusions2];
116
- const halvesFuse = BooleanOps.fuse(all);
188
+ const halvesFuse = p.record('Fuse halves', () => BooleanOps.fuse(all));
117
189
  const extrusions = halvesFuse.result;
118
190
  halvesFuse.dispose();
119
- // Collect remaining faces and fused start/end faces from the fused solid.
120
- // We need the fused face objects (not pre-fusion) for IsPartner matching.
191
+ // Re-find start/end faces in the fused solid (NonDestructive preserves
192
+ // their TShape, so IsSame matching works) and collect everything else as
193
+ // "remaining" for per-mode classification.
121
194
  const remainingFaces = [];
122
195
  const fusedStartFaces = [];
123
196
  const fusedEndFaces = [];
124
197
  for (const solid of extrusions) {
125
- const allFaces = Explorer.findFacesWrapped(solid);
126
- for (const f of allFaces) {
127
- const isStart = startFaces.some(sf => f.getShape().IsSame(sf.getShape()));
128
- const isEnd = endFaces.some(ef => f.getShape().IsSame(ef.getShape()));
129
- if (isStart) {
198
+ for (const f of Explorer.findFacesWrapped(solid)) {
199
+ const raw = f.getShape();
200
+ if (startFaces.some(sf => raw.IsSame(sf.getShape()))) {
130
201
  fusedStartFaces.push(f);
131
202
  }
132
- else if (isEnd) {
203
+ else if (endFaces.some(ef => raw.IsSame(ef.getShape()))) {
133
204
  fusedEndFaces.push(f);
134
205
  }
135
206
  else {
@@ -137,81 +208,50 @@ export class Extrude extends ExtrudeBase {
137
208
  }
138
209
  }
139
210
  }
140
- let sideFaces;
141
- let internalFaces;
142
- let capFaces = [];
143
- if (inwardEdges && inwardEdges.length > 0) {
144
- // For thin open profiles: reclassify using 2D midpoint matching on the fused solid
145
- const result = this.reclassifyThinFaces(remainingFaces, [...fusedStartFaces, ...fusedEndFaces], plane, inwardEdges, outwardEdges || []);
146
- sideFaces = result.sideFaces;
147
- internalFaces = result.internalFaces;
148
- capFaces = result.capFaces;
149
- }
150
- else {
151
- // Detect inner wire edges from the extruder's firstFaces (at the sketch plane,
152
- // pre-fusion). These have the same wire orientation the Extruder uses internally.
153
- // Then map to fused solid edges using 2D midpoint matching, since SimplifyResult
154
- // merges half-faces and breaks TShape identity.
155
- const preInnerEdges = [];
156
- for (const sf of extruder1.getStartFaces()) {
157
- for (const wire of sf.getWires()) {
158
- if (!wire.isCW(plane.normal)) {
159
- for (const edge of wire.getEdges()) {
160
- preInnerEdges.push(edge);
161
- }
162
- }
163
- }
164
- }
165
- const fusedInnerEdges = [];
166
- if (preInnerEdges.length > 0) {
167
- const innerMids = preInnerEdges.map(e => plane.worldToLocal(EdgeOps.getEdgeMidPointRaw(e.getShape())));
168
- for (const sf of fusedStartFaces) {
169
- for (const sfe of sf.getEdges()) {
170
- const mid = plane.worldToLocal(EdgeOps.getEdgeMidPointRaw(sfe.getShape()));
171
- if (innerMids.some(im => mid.distanceTo(im) < 1e-4)) {
172
- fusedInnerEdges.push(sfe);
173
- }
211
+ return {
212
+ extrusions,
213
+ extruder1,
214
+ extruder2,
215
+ startFaces,
216
+ endFaces,
217
+ fusedStartFaces,
218
+ fusedEndFaces,
219
+ remainingFaces,
220
+ };
221
+ }
222
+ /**
223
+ * Find post-fusion edges that came from inner wires of the start face's
224
+ * pre-fusion profile (holes). Used to classify the symmetric fused solid's
225
+ * remaining faces into side vs internal. Inner wires are detected as
226
+ * counter-clockwise wires on the sketch plane; their edges are mapped onto
227
+ * the fused solid by 2D midpoint matching (SimplifyResult breaks TShape
228
+ * identity for merged half-faces).
229
+ */
230
+ detectFusedInnerEdges(extruder1, fusedStartFaces, plane) {
231
+ const preInnerEdges = [];
232
+ for (const sf of extruder1.getStartFaces()) {
233
+ for (const wire of sf.getWires()) {
234
+ if (!wire.isCW(plane.normal)) {
235
+ for (const edge of wire.getEdges()) {
236
+ preInnerEdges.push(edge);
174
237
  }
175
238
  }
176
239
  }
177
- sideFaces = [];
178
- internalFaces = [];
179
- for (const f of remainingFaces) {
180
- const isInternal = fusedInnerEdges.length > 0 && f.getEdges().some(fe => fusedInnerEdges.some(ie => fe.getShape().IsPartner(ie.getShape())));
181
- if (isInternal) {
182
- internalFaces.push(f);
183
- }
184
- else {
185
- sideFaces.push(f);
186
- }
187
- }
188
240
  }
189
- this.setState('start-faces', startFaces);
190
- this.setState('end-faces', endFaces);
191
- this.setState('side-faces', sideFaces);
192
- this.setState('internal-faces', internalFaces);
193
- this.setState('cap-faces', capFaces);
194
- this.getSource()?.removeShapes(this);
195
- if (extrusions.length === 0 || sceneObjects.length === 0) {
196
- this.addShapes(extrusions);
197
- this.recordShapeFacesAndEdgesAsAdditions(extrusions);
198
- this.classifyExtrudeEdges();
199
- return;
241
+ if (preInnerEdges.length === 0) {
242
+ return [];
200
243
  }
201
- const fusionResult = fuseWithSceneObjects(sceneObjects, extrusions, {
202
- recordHistoryFor: this,
203
- });
204
- for (const modifiedShape of fusionResult.modifiedShapes) {
205
- if (!modifiedShape.object) {
206
- continue;
244
+ const innerMids = preInnerEdges.map(e => plane.worldToLocal(EdgeOps.getEdgeMidPointRaw(e.getShape())));
245
+ const fusedInnerEdges = [];
246
+ for (const sf of fusedStartFaces) {
247
+ for (const sfe of sf.getEdges()) {
248
+ const mid = plane.worldToLocal(EdgeOps.getEdgeMidPointRaw(sfe.getShape()));
249
+ if (innerMids.some(im => mid.distanceTo(im) < 1e-4)) {
250
+ fusedInnerEdges.push(sfe);
251
+ }
207
252
  }
208
- modifiedShape.object.removeShape(modifiedShape.shape, this);
209
- }
210
- this.addShapes(fusionResult.newShapes);
211
- if (fusionResult.toolHistory) {
212
- this.remapClassifiedFaces(fusionResult.toolHistory);
213
253
  }
214
- this.classifyExtrudeEdges();
254
+ return fusedInnerEdges;
215
255
  }
216
256
  buildRemove(faces, plane, context) {
217
257
  const scope = this.resolveFusionScope(context.getSceneObjects());
@@ -11,6 +11,7 @@ export class Fuse extends SceneObject {
11
11
  return this._sceneObjects;
12
12
  }
13
13
  build(context) {
14
+ const p = context.getProfiler();
14
15
  let sceneObjects = this.sceneObjects;
15
16
  if (sceneObjects?.length === 0) {
16
17
  sceneObjects = context.getSceneObjects();
@@ -25,7 +26,7 @@ export class Fuse extends SceneObject {
25
26
  if (allShapes.length < 2) {
26
27
  return;
27
28
  }
28
- const fuseResult = BooleanOps.fuse(allShapes);
29
+ const fuseResult = p.record('Fuse solids', () => BooleanOps.fuse(allShapes));
29
30
  if (fuseResult.result.length === allShapes.length) {
30
31
  fuseResult.dispose();
31
32
  return;
@@ -7,6 +7,7 @@ export declare class LazySelectionSceneObject extends SceneObject {
7
7
  private _originalParent;
8
8
  constructor(uniqueName: string, getShapesFn: (parent: SceneObject) => Shape[], sourceParent: SceneObject);
9
9
  build(): void;
10
+ isLazy(): boolean;
10
11
  getDependencies(): SceneObject[];
11
12
  createCopy(remap: Map<SceneObject, SceneObject>): SceneObject;
12
13
  compareTo(other: LazySelectionSceneObject): boolean;
@@ -14,6 +14,9 @@ export class LazySelectionSceneObject extends SceneObject {
14
14
  const shapes = this.getShapesFn(this.sourceParent);
15
15
  this.addShapes(shapes);
16
16
  }
17
+ isLazy() {
18
+ return true;
19
+ }
17
20
  getDependencies() {
18
21
  return [this.sourceParent];
19
22
  }
@@ -8,6 +8,7 @@ export declare class LazyVertex extends SceneObject {
8
8
  private _isBuilt;
9
9
  constructor(uniqueName: string, getShapesFn: () => Shape[]);
10
10
  build(): void;
11
+ isLazy(): boolean;
11
12
  getShapes(filter?: ShapeFilter, type?: ShapeType): Shape[];
12
13
  asPoint(): import("../math/point.js").Point;
13
14
  asPoint2D(): import("../math/point.js").Point2D;
@@ -19,6 +19,9 @@ export class LazyVertex extends SceneObject {
19
19
  }
20
20
  this.addShapes(shapes);
21
21
  }
22
+ isLazy() {
23
+ return true;
24
+ }
22
25
  getShapes(filter, type) {
23
26
  if (this._isBuilt) {
24
27
  return super.getShapes(filter, type);