fluidcad 0.0.30 → 0.0.32

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 (135) hide show
  1. package/lib/dist/common/build-error.d.ts +13 -0
  2. package/lib/dist/common/build-error.js +18 -0
  3. package/lib/dist/common/describe-error.d.ts +6 -0
  4. package/lib/dist/common/describe-error.js +26 -0
  5. package/lib/dist/common/operand-check.d.ts +19 -0
  6. package/lib/dist/common/operand-check.js +38 -0
  7. package/lib/dist/common/scene-object.d.ts +8 -0
  8. package/lib/dist/common/scene-object.js +10 -0
  9. package/lib/dist/common/shape-factory.d.ts +1 -1
  10. package/lib/dist/core/2d/arc.d.ts +4 -2
  11. package/lib/dist/core/2d/hmove.d.ts +8 -1
  12. package/lib/dist/core/2d/hmove.js +6 -2
  13. package/lib/dist/core/2d/pmove.d.ts +13 -3
  14. package/lib/dist/core/2d/pmove.js +6 -2
  15. package/lib/dist/core/2d/vmove.d.ts +8 -1
  16. package/lib/dist/core/2d/vmove.js +8 -4
  17. package/lib/dist/core/extrude.d.ts +17 -4
  18. package/lib/dist/core/extrude.js +8 -6
  19. package/lib/dist/core/interfaces.d.ts +16 -6
  20. package/lib/dist/core/mirror.d.ts +5 -5
  21. package/lib/dist/features/2d/aline.js +6 -2
  22. package/lib/dist/features/2d/arc.d.ts +3 -0
  23. package/lib/dist/features/2d/arc.js +28 -1
  24. package/lib/dist/features/2d/hline.js +5 -1
  25. package/lib/dist/features/2d/hmove.d.ts +2 -2
  26. package/lib/dist/features/2d/hmove.js +32 -7
  27. package/lib/dist/features/2d/intersect.js +17 -10
  28. package/lib/dist/features/2d/line.d.ts +2 -0
  29. package/lib/dist/features/2d/line.js +4 -0
  30. package/lib/dist/features/2d/pmove.d.ts +2 -2
  31. package/lib/dist/features/2d/pmove.js +47 -7
  32. package/lib/dist/features/2d/projection.d.ts +1 -1
  33. package/lib/dist/features/2d/projection.js +25 -15
  34. package/lib/dist/features/2d/sketch.d.ts +2 -2
  35. package/lib/dist/features/2d/sketch.js +10 -4
  36. package/lib/dist/features/2d/tarc-to-point.js +0 -3
  37. package/lib/dist/features/2d/tarc.js +0 -3
  38. package/lib/dist/features/2d/tline.js +0 -3
  39. package/lib/dist/features/2d/vline.js +5 -1
  40. package/lib/dist/features/2d/vmove.d.ts +2 -2
  41. package/lib/dist/features/2d/vmove.js +32 -7
  42. package/lib/dist/features/axis-from-edge.d.ts +1 -0
  43. package/lib/dist/features/axis-from-edge.js +8 -0
  44. package/lib/dist/features/chamfer.d.ts +1 -0
  45. package/lib/dist/features/chamfer.js +6 -0
  46. package/lib/dist/features/color.d.ts +1 -0
  47. package/lib/dist/features/color.js +6 -0
  48. package/lib/dist/features/common.d.ts +1 -0
  49. package/lib/dist/features/common.js +9 -0
  50. package/lib/dist/features/common2d.d.ts +1 -0
  51. package/lib/dist/features/common2d.js +9 -0
  52. package/lib/dist/features/draft.d.ts +1 -0
  53. package/lib/dist/features/draft.js +6 -0
  54. package/lib/dist/features/extrude-to-face.d.ts +5 -1
  55. package/lib/dist/features/extrude-to-face.js +50 -8
  56. package/lib/dist/features/extrude.js +19 -28
  57. package/lib/dist/features/fillet.d.ts +1 -0
  58. package/lib/dist/features/fillet.js +6 -0
  59. package/lib/dist/features/fillet2d.d.ts +1 -0
  60. package/lib/dist/features/fillet2d.js +9 -0
  61. package/lib/dist/features/fuse.d.ts +1 -0
  62. package/lib/dist/features/fuse.js +6 -0
  63. package/lib/dist/features/fuse2d.d.ts +1 -0
  64. package/lib/dist/features/fuse2d.js +9 -0
  65. package/lib/dist/features/loft.d.ts +1 -0
  66. package/lib/dist/features/loft.js +6 -0
  67. package/lib/dist/features/mirror-shape.d.ts +1 -0
  68. package/lib/dist/features/mirror-shape.js +28 -8
  69. package/lib/dist/features/plane-from-object.d.ts +1 -0
  70. package/lib/dist/features/plane-from-object.js +8 -0
  71. package/lib/dist/features/rotate.d.ts +1 -0
  72. package/lib/dist/features/rotate.js +11 -2
  73. package/lib/dist/features/select.d.ts +1 -0
  74. package/lib/dist/features/select.js +40 -12
  75. package/lib/dist/features/shell.d.ts +1 -0
  76. package/lib/dist/features/shell.js +6 -0
  77. package/lib/dist/features/simple-extruder.js +6 -3
  78. package/lib/dist/features/subtract.d.ts +1 -0
  79. package/lib/dist/features/subtract.js +5 -0
  80. package/lib/dist/features/subtract2d.d.ts +1 -0
  81. package/lib/dist/features/subtract2d.js +5 -0
  82. package/lib/dist/features/sweep.d.ts +1 -0
  83. package/lib/dist/features/sweep.js +4 -0
  84. package/lib/dist/features/translate.d.ts +1 -0
  85. package/lib/dist/features/translate.js +9 -0
  86. package/lib/dist/filters/face/above-below.d.ts +20 -0
  87. package/lib/dist/filters/face/above-below.js +57 -0
  88. package/lib/dist/filters/face/face-filter.d.ts +26 -0
  89. package/lib/dist/filters/face/face-filter.js +64 -0
  90. package/lib/dist/filters/face/planar-filter.d.ts +15 -0
  91. package/lib/dist/filters/face/planar-filter.js +30 -0
  92. package/lib/dist/filters/from-object.d.ts +1 -0
  93. package/lib/dist/filters/from-object.js +3 -0
  94. package/lib/dist/oc/boolean-ops.d.ts +2 -2
  95. package/lib/dist/oc/boolean-ops.js +8 -3
  96. package/lib/dist/oc/edge-ops.d.ts +17 -0
  97. package/lib/dist/oc/edge-ops.js +60 -0
  98. package/lib/dist/oc/face-maker2.d.ts +8 -0
  99. package/lib/dist/oc/face-maker2.js +42 -1
  100. package/lib/dist/oc/face-ops.d.ts +6 -1
  101. package/lib/dist/oc/face-ops.js +3 -2
  102. package/lib/dist/oc/face-query.js +19 -15
  103. package/lib/dist/oc/ray-intersect.d.ts +3 -2
  104. package/lib/dist/oc/ray-intersect.js +2 -4
  105. package/lib/dist/oc/shell-ops.js +15 -2
  106. package/lib/dist/oc/thin-face-maker.d.ts +15 -0
  107. package/lib/dist/oc/thin-face-maker.js +48 -7
  108. package/lib/dist/oc/wire-ops.d.ts +14 -0
  109. package/lib/dist/oc/wire-ops.js +38 -0
  110. package/lib/dist/rendering/render.js +6 -4
  111. package/lib/dist/tests/common/describe-error.test.d.ts +1 -0
  112. package/lib/dist/tests/common/describe-error.test.js +36 -0
  113. package/lib/dist/tests/features/2d/intersect.test.js +43 -0
  114. package/lib/dist/tests/features/2d/move.test.js +72 -1
  115. package/lib/dist/tests/features/2d/project-regression.test.js +35 -0
  116. package/lib/dist/tests/features/color-lineage.test.js +24 -0
  117. package/lib/dist/tests/features/cut.test.js +40 -0
  118. package/lib/dist/tests/features/cylinder-curve-filter.test.d.ts +1 -0
  119. package/lib/dist/tests/features/cylinder-curve-filter.test.js +99 -0
  120. package/lib/dist/tests/features/extrude-to-face.test.js +52 -0
  121. package/lib/dist/tests/features/extrude.test.js +46 -8
  122. package/lib/dist/tests/features/mirror.test.js +74 -0
  123. package/lib/dist/tests/features/select.test.js +141 -0
  124. package/lib/dist/tests/features/subtract-consumed-input.test.d.ts +1 -0
  125. package/lib/dist/tests/features/subtract-consumed-input.test.js +28 -0
  126. package/lib/dist/tests/features/thin-extrude-offset-fix.test.d.ts +1 -0
  127. package/lib/dist/tests/features/thin-extrude-offset-fix.test.js +34 -0
  128. package/lib/dist/tsconfig.tsbuildinfo +1 -1
  129. package/package.json +2 -3
  130. package/ui/dist/assets/{index-6Ep4GPxf.js → index-DMw0OYCF.js} +70 -70
  131. package/ui/dist/assets/index-DR7c2Qk9.css +2 -0
  132. package/ui/dist/index.html +2 -2
  133. package/lib/dist/features/infinite-extrude.d.ts +0 -13
  134. package/lib/dist/features/infinite-extrude.js +0 -79
  135. package/ui/dist/assets/index-DRKfe6N9.css +0 -2
@@ -178,28 +178,32 @@ export class FaceQuery {
178
178
  const ocFace = oc.TopoDS.Face(face);
179
179
  const faceAdaptor = new oc.BRepAdaptor_Surface(ocFace, true);
180
180
  const type = faceAdaptor.GetType();
181
- faceAdaptor.delete();
182
181
  if (type !== oc.GeomAbs_SurfaceType.GeomAbs_Cylinder) {
182
+ faceAdaptor.delete();
183
+ return false;
184
+ }
185
+ const cylinder = faceAdaptor.Cylinder();
186
+ const radius = cylinder.Radius();
187
+ cylinder.delete();
188
+ faceAdaptor.delete();
189
+ if (diameter !== undefined && Math.abs(radius - diameter / 2) > oc.Precision.Confusion()) {
183
190
  return false;
184
191
  }
192
+ // A "cylinder curve" is a partial cylinder: it does not wrap fully around its axis.
193
+ // A full cylinder has at least one closed circular edge (the rim); a fillet/partial
194
+ // cylinder does not. Bounding edges may be lines, arcs, ellipses, or B-splines
195
+ // depending on adjacent geometry (e.g. drafted faces produce ellipse boundaries).
185
196
  const edges = Explorer.findShapes(ocFace, oc.TopAbs_ShapeEnum.TopAbs_EDGE);
186
197
  for (const edge of edges) {
187
198
  const curveAdaptor = new oc.BRepAdaptor_Curve(oc.TopoDS.Edge(edge));
188
199
  const curveType = curveAdaptor.GetType();
189
- if (curveType === oc.GeomAbs_CurveType.GeomAbs_Circle && !curveAdaptor.IsClosed()) {
190
- if (diameter === undefined) {
191
- curveAdaptor.delete();
192
- return true;
193
- }
194
- const circle = curveAdaptor.Circle();
195
- const r = circle.Radius();
196
- circle.delete();
197
- curveAdaptor.delete();
198
- return Math.abs(r - diameter / 2) <= oc.Precision.Confusion();
199
- }
200
+ const isClosedCircle = curveType === oc.GeomAbs_CurveType.GeomAbs_Circle && curveAdaptor.IsClosed();
200
201
  curveAdaptor.delete();
202
+ if (isClosedCircle) {
203
+ return false;
204
+ }
201
205
  }
202
- return false;
206
+ return true;
203
207
  }
204
208
  static isTorusFaceRaw(face, majorRadius, minorRadius) {
205
209
  const oc = getOC();
@@ -342,8 +346,8 @@ export class FaceQuery {
342
346
  let bestFace = null;
343
347
  let bestDistance = mode === 'first' ? Infinity : -Infinity;
344
348
  for (const face of faces) {
345
- const distance = plane.distanceToPoint(face.center());
346
- if (distance < tolerance) {
349
+ const distance = plane.signedDistanceToPoint(face.center());
350
+ if (distance <= tolerance) {
347
351
  continue;
348
352
  }
349
353
  if (mode === 'first' ? distance < bestDistance : distance > bestDistance) {
@@ -11,6 +11,7 @@ import { SceneObject } from "../common/scene-object.js";
11
11
  * `start` (within tolerance) are skipped so a start point already on the
12
12
  * target is not picked.
13
13
  *
14
- * Throws if the target produces no usable edge hits.
14
+ * Returns null when no intersection is found. Callers should throw a
15
+ * context-appropriate error.
15
16
  */
16
- export declare function findNearestRayIntersection(plane: Plane, start: Point2D, direction: Point2D, target: SceneObject): Point2D;
17
+ export declare function findNearestRayIntersection(plane: Plane, start: Point2D, direction: Point2D, target: SceneObject): Point2D | null;
@@ -14,7 +14,8 @@ const ON_TARGET_EPSILON = 1e-7;
14
14
  * `start` (within tolerance) are skipped so a start point already on the
15
15
  * target is not picked.
16
16
  *
17
- * Throws if the target produces no usable edge hits.
17
+ * Returns null when no intersection is found. Callers should throw a
18
+ * context-appropriate error.
18
19
  */
19
20
  export function findNearestRayIntersection(plane, start, direction, target) {
20
21
  const oc = getOC();
@@ -84,8 +85,5 @@ export function findNearestRayIntersection(plane, start, direction, target) {
84
85
  }
85
86
  probeAdaptor.delete();
86
87
  probeEdge.delete();
87
- if (!bestHit) {
88
- throw new Error("Line does not intersect target geometry");
89
- }
90
88
  return bestHit;
91
89
  }
@@ -1,6 +1,7 @@
1
1
  import { getOC } from "./init.js";
2
2
  import { ShapeOps } from "./shape-ops.js";
3
3
  import { ShapeFactory } from "../common/shape-factory.js";
4
+ import { ColorTransfer } from "./color-transfer.js";
4
5
  export class ShellOps {
5
6
  static makeThickSolid(solid, faces, thickness) {
6
7
  const oc = getOC();
@@ -17,9 +18,21 @@ export class ShellOps {
17
18
  listOfFaces.delete();
18
19
  throw new Error("Failed to create thick solid.");
19
20
  }
20
- const newShape = maker.Shape();
21
+ // Wrap the maker output so we can transfer colors before disposing it.
22
+ // The user-painted outer faces are mapped through `Modified()`; new
23
+ // internal walls have no source and stay uncolored — bleeding is
24
+ // intentionally skipped so painting the outside doesn't paint the
25
+ // inside.
26
+ const preClean = ShapeFactory.fromShape(maker.Shape());
27
+ ColorTransfer.applyThroughMaker([solid], [preClean], maker);
21
28
  maker.delete();
22
29
  listOfFaces.delete();
23
- return ShapeFactory.fromShape(ShapeOps.cleanShapeRaw(newShape));
30
+ // Chain colors through the UnifySameDomain cleanup so any merged faces
31
+ // keep the colors that `applyThroughMaker` just placed.
32
+ const cleanup = ShapeOps.cleanShapeWithLineage(preClean);
33
+ ColorTransfer.applyThroughCleanup(preClean, cleanup);
34
+ const cleaned = cleanup.shape;
35
+ cleanup.dispose();
36
+ return cleaned;
24
37
  }
25
38
  }
@@ -16,8 +16,23 @@ export declare class ThinFaceMaker {
16
16
  * For closed wires, WireOps.offsetWire handles negative distances natively.
17
17
  * For open wires, negative distances are handled by reversing the wire,
18
18
  * offsetting with the absolute value, then reversing back.
19
+ *
20
+ * If the wire-only offset throws (e.g. "Offset wire is not closed." on
21
+ * wires whose corners are GeomAbs_OffsetCurve segments from `offset()` over
22
+ * a drafted body's filleted bottom), retries with a planar face as the
23
+ * offset spine — that path supplies an explicit normal which keeps the
24
+ * algorithm stable on the same input.
19
25
  */
20
26
  private static doOffset;
27
+ /**
28
+ * Merges adjacent edges that share the same underlying curve into a single
29
+ * edge (e.g. two conic-arc segments at a filleted corner produced by
30
+ * `offset()` over the section of a drafted body's fillets). Without this,
31
+ * BRepOffsetAPI_MakeOffset chokes on such split arcs with
32
+ * "Offset wire is not closed." Falls back to the original wire if the
33
+ * upgrader produces no usable result.
34
+ */
35
+ private static unifyWireEdges;
21
36
  /**
22
37
  * Offsets an open wire on a given plane, using a planar face as reference
23
38
  * so that BRepOffsetAPI_MakeOffset knows the offset direction.
@@ -24,7 +24,13 @@ export class ThinFaceMaker {
24
24
  const inwardEdges = [];
25
25
  const outwardEdges = [];
26
26
  for (const group of groups) {
27
- const wire = WireOps.makeWireFromEdges(group);
27
+ const rawWire = WireOps.makeWireFromEdges(group);
28
+ // ShapeUpgrade_UnifySameDomain merges adjacent edges that share the
29
+ // same underlying curve into single edges. Without this,
30
+ // BRepOffsetAPI_MakeOffset can choke on wires whose corners are split
31
+ // into multiple same-curve segments (e.g. wires returned by `offset()`
32
+ // over a drafted body's filleted bottom).
33
+ const wire = this.unifyWireEdges(rawWire);
28
34
  const isClosed = wire.isClosed();
29
35
  if (offset2 !== undefined) {
30
36
  const result = this.makeDualOffsetFace(wire, isClosed, plane, offset1, offset2);
@@ -101,17 +107,52 @@ export class ThinFaceMaker {
101
107
  * For closed wires, WireOps.offsetWire handles negative distances natively.
102
108
  * For open wires, negative distances are handled by reversing the wire,
103
109
  * offsetting with the absolute value, then reversing back.
110
+ *
111
+ * If the wire-only offset throws (e.g. "Offset wire is not closed." on
112
+ * wires whose corners are GeomAbs_OffsetCurve segments from `offset()` over
113
+ * a drafted body's filleted bottom), retries with a planar face as the
114
+ * offset spine — that path supplies an explicit normal which keeps the
115
+ * algorithm stable on the same input.
104
116
  */
105
117
  static doOffset(wire, plane, distance, isClosed) {
106
- if (isClosed) {
118
+ if (!isClosed) {
119
+ if (distance < 0) {
120
+ const reversed = WireOps.reverseWire(wire);
121
+ const offsetResult = this.offsetWireOnPlane(reversed, plane, -distance, true);
122
+ return WireOps.reverseWire(offsetResult);
123
+ }
124
+ return this.offsetWireOnPlane(wire, plane, distance, true);
125
+ }
126
+ try {
107
127
  return WireOps.offsetWire(wire, distance, false);
108
128
  }
109
- if (distance < 0) {
110
- const reversed = WireOps.reverseWire(wire);
111
- const offsetResult = this.offsetWireOnPlane(reversed, plane, -distance, true);
112
- return WireOps.reverseWire(offsetResult);
129
+ catch {
130
+ return this.offsetWireOnPlane(wire, plane, distance, false);
131
+ }
132
+ }
133
+ /**
134
+ * Merges adjacent edges that share the same underlying curve into a single
135
+ * edge (e.g. two conic-arc segments at a filleted corner produced by
136
+ * `offset()` over the section of a drafted body's fillets). Without this,
137
+ * BRepOffsetAPI_MakeOffset chokes on such split arcs with
138
+ * "Offset wire is not closed." Falls back to the original wire if the
139
+ * upgrader produces no usable result.
140
+ */
141
+ static unifyWireEdges(wire) {
142
+ const oc = getOC();
143
+ const upgrader = new oc.ShapeUpgrade_UnifySameDomain(wire.getShape(), true, false, true);
144
+ upgrader.AllowInternalEdges(true);
145
+ upgrader.Build();
146
+ const result = upgrader.Shape();
147
+ upgrader.delete();
148
+ if (Explorer.isWire(result)) {
149
+ return Wire.fromTopoDSWire(oc.TopoDS.Wire(result));
150
+ }
151
+ const wires = Explorer.findShapes(result, oc.TopAbs_ShapeEnum.TopAbs_WIRE);
152
+ if (wires.length === 0) {
153
+ return wire;
113
154
  }
114
- return this.offsetWireOnPlane(wire, plane, distance, true);
155
+ return Wire.fromTopoDSWire(oc.TopoDS.Wire(wires[0]));
115
156
  }
116
157
  /**
117
158
  * Offsets an open wire on a given plane, using a planar face as reference
@@ -2,6 +2,7 @@ import type { TopoDS_Edge, TopoDS_Wire, TopoDS_Face } from "occjs-wrapper";
2
2
  import { Vector3d } from "../math/vector3d.js";
3
3
  import { Wire } from "../common/wire.js";
4
4
  import { Edge } from "../common/edge.js";
5
+ import { Vertex } from "../common/vertex.js";
5
6
  import { Plane } from "../math/plane.js";
6
7
  export declare class WireOps {
7
8
  static isCW(wire: Wire, normal: Vector3d): boolean;
@@ -15,6 +16,19 @@ export declare class WireOps {
15
16
  static buildWireRaw(edges: TopoDS_Edge[]): TopoDS_Wire;
16
17
  static fixWire(wire: Wire, plane: Plane): Wire;
17
18
  static fixWireRaw(wire: TopoDS_Wire, face: TopoDS_Face): TopoDS_Wire;
19
+ /**
20
+ * Returns the two chain-end vertices of a connected edge set, or a single
21
+ * vertex (start === end) for a closed loop. Returns null if branching is
22
+ * detected (more than two unique endpoints).
23
+ *
24
+ * "Chain end" is a vertex that appears in exactly one edge; interior junction
25
+ * vertices appear in two or more. Vertex equivalence is tolerance-based so
26
+ * boundary representations with separately-built endpoints still match.
27
+ */
28
+ static findChainEndpoints(edges: Edge[]): {
29
+ start: Vertex;
30
+ end: Vertex;
31
+ } | null;
18
32
  static groupConnectedEdges(edges: Edge[]): Edge[][];
19
33
  private static verticesMatch;
20
34
  static offsetWireRaw(wire: TopoDS_Wire, distance: number, isOpen: boolean): TopoDS_Wire;
@@ -109,6 +109,44 @@ export class WireOps {
109
109
  fixer.delete();
110
110
  return oc.TopoDS.Wire(fixed);
111
111
  }
112
+ /**
113
+ * Returns the two chain-end vertices of a connected edge set, or a single
114
+ * vertex (start === end) for a closed loop. Returns null if branching is
115
+ * detected (more than two unique endpoints).
116
+ *
117
+ * "Chain end" is a vertex that appears in exactly one edge; interior junction
118
+ * vertices appear in two or more. Vertex equivalence is tolerance-based so
119
+ * boundary representations with separately-built endpoints still match.
120
+ */
121
+ static findChainEndpoints(edges) {
122
+ if (edges.length === 0) {
123
+ return null;
124
+ }
125
+ const entries = [];
126
+ const findOrAdd = (vertex) => {
127
+ for (const entry of entries) {
128
+ if (WireOps.verticesMatch(entry.vertex, vertex)) {
129
+ return entry;
130
+ }
131
+ }
132
+ const created = { vertex, count: 0 };
133
+ entries.push(created);
134
+ return created;
135
+ };
136
+ for (const edge of edges) {
137
+ findOrAdd(edge.getFirstVertex()).count++;
138
+ findOrAdd(edge.getLastVertex()).count++;
139
+ }
140
+ const ends = entries.filter(e => e.count === 1);
141
+ if (ends.length === 0) {
142
+ const v = edges[0].getFirstVertex();
143
+ return { start: v, end: v };
144
+ }
145
+ if (ends.length === 2) {
146
+ return { start: ends[0].vertex, end: ends[1].vertex };
147
+ }
148
+ return null;
149
+ }
112
150
  static groupConnectedEdges(edges) {
113
151
  if (edges.length === 0) {
114
152
  return [];
@@ -5,6 +5,7 @@ import { Sketch } from "../features/2d/sketch.js";
5
5
  import { transformMeshes } from "./mesh-transform.js";
6
6
  import { ShapeOps } from "../oc/shape-ops.js";
7
7
  import { Profiler } from "../common/profiler.js";
8
+ import { describeError } from "../common/describe-error.js";
8
9
  export class SceneRenderer {
9
10
  meshBuilder = new MeshBuilder();
10
11
  render(scene) {
@@ -103,8 +104,8 @@ export class SceneRenderer {
103
104
  return { renderedSceneShapes, ownShapeCount: sceneShapes.length };
104
105
  }
105
106
  catch (error) {
106
- const message = error instanceof Error ? error.message : String(error);
107
- console.error(`Error rendering object ${obj.getUniqueType()}:`, error);
107
+ const message = describeError(error);
108
+ console.error(`Error rendering object ${obj.getUniqueType()}:`, message);
108
109
  return { renderedSceneShapes, ownShapeCount: renderedSceneShapes.length, prepError: message };
109
110
  }
110
111
  }
@@ -135,6 +136,7 @@ export class SceneRenderer {
135
136
  const start = performance.now();
136
137
  const profiler = new Profiler();
137
138
  try {
139
+ object.validate();
138
140
  object.build({
139
141
  getSceneObjects: () => scene.getPartScopedObjectsUpTo(object),
140
142
  getActiveSceneObjects: () => scene.getPartScopedActiveObjectsUpTo(object),
@@ -161,8 +163,8 @@ export class SceneRenderer {
161
163
  }
162
164
  }
163
165
  catch (error) {
164
- const message = error instanceof Error ? error.message : String(error);
165
- console.error(`Error building object ${object.getUniqueType()}:`, error);
166
+ const message = describeError(error);
167
+ console.error(`Error building object ${object.getUniqueType()}:`, message);
166
168
  object.setError(message);
167
169
  }
168
170
  const totalMs = performance.now() - start;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,36 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { setupOC } from "../setup.js";
3
+ import { describeError } from "../../common/describe-error.js";
4
+ import { getOC } from "../../oc/init.js";
5
+ describe("describeError", () => {
6
+ setupOC();
7
+ it("returns Error.message for Error instances", () => {
8
+ expect(describeError(new Error("boom"))).toBe("boom");
9
+ });
10
+ it("falls back to String() for non-numeric, non-Error values", () => {
11
+ expect(describeError("plain string")).toBe("plain string");
12
+ expect(describeError(undefined)).toBe("undefined");
13
+ expect(describeError(null)).toBe("null");
14
+ });
15
+ it("decodes a numeric OCC exception pointer to its message string", () => {
16
+ const oc = getOC();
17
+ // Trigger a real OCC throw (BRepBuilderAPI_MakeOffset on an unsupported
18
+ // shape) and capture the numeric pointer.
19
+ let caught = null;
20
+ try {
21
+ const maker = new oc.BRepOffsetAPI_MakeOffset();
22
+ maker.Perform(1, 0); // no wire added — should throw
23
+ }
24
+ catch (e) {
25
+ caught = e;
26
+ }
27
+ // If OCC throws, it comes through as a number; otherwise the test setup
28
+ // didn't trigger it — skip in that case.
29
+ if (typeof caught !== 'number') {
30
+ return;
31
+ }
32
+ const decoded = describeError(caught);
33
+ expect(decoded.startsWith("OCC")).toBe(true);
34
+ expect(decoded).not.toBe(String(caught));
35
+ });
36
+ });
@@ -2,7 +2,9 @@ import { describe, it, expect } from "vitest";
2
2
  import { setupOC, render } from "../../setup.js";
3
3
  import sketch from "../../../core/sketch.js";
4
4
  import extrude from "../../../core/extrude.js";
5
+ import shell from "../../../core/shell.js";
5
6
  import { intersect, rect } from "../../../core/2d/index.js";
7
+ import { Edge } from "../../../common/edge.js";
6
8
  describe("intersect", () => {
7
9
  setupOC();
8
10
  describe("intersect 3D shape with sketch plane", () => {
@@ -18,5 +20,46 @@ describe("intersect", () => {
18
20
  const shapes = s.getShapes();
19
21
  expect(shapes.length).toBeGreaterThan(0);
20
22
  });
23
+ it("start() should be at a chain endpoint, not at an interior junction", () => {
24
+ // Regression: when the section produces multiple edges (one per face),
25
+ // start/end were taken from an arbitrary "last edge". For a closed loop
26
+ // or branching chain that left start at an interior junction vertex
27
+ // instead of a real chain endpoint.
28
+ sketch("xy", () => {
29
+ rect(100, 50).centered().radius(8);
30
+ });
31
+ const e = extrude(20);
32
+ const s = shell(-2, e.endFaces());
33
+ let intersectFeature = null;
34
+ sketch("right", () => {
35
+ intersectFeature = intersect(s.internalFaces());
36
+ });
37
+ render();
38
+ // The plane-local start vertex must coincide with one of the section's
39
+ // edge endpoints. An interior junction would coincide with two edges,
40
+ // so we additionally assert the count of edges meeting at that point.
41
+ const start = intersectFeature.getState('start');
42
+ const end = intersectFeature.getState('end');
43
+ expect(start).toBeDefined();
44
+ expect(end).toBeDefined();
45
+ const edges = intersectFeature.getShapes().filter(s => s instanceof Edge);
46
+ expect(edges.length).toBeGreaterThan(0);
47
+ const plane = intersectFeature.getPlane();
48
+ const TOL_SQ = 1e-8;
49
+ let matchCount = 0;
50
+ const startPoint = start.toPoint2D();
51
+ for (const edge of edges) {
52
+ const v1 = plane.worldToLocal(edge.getFirstVertex().toPoint());
53
+ const v2 = plane.worldToLocal(edge.getLastVertex().toPoint());
54
+ const d1 = (v1.x - startPoint.x) ** 2 + (v1.y - startPoint.y) ** 2;
55
+ const d2 = (v2.x - startPoint.x) ** 2 + (v2.y - startPoint.y) ** 2;
56
+ if (d1 < TOL_SQ || d2 < TOL_SQ) {
57
+ matchCount++;
58
+ }
59
+ }
60
+ // start must lie on at least one edge endpoint (so it's a real corner,
61
+ // not a constructed midpoint).
62
+ expect(matchCount).toBeGreaterThan(0);
63
+ });
21
64
  });
22
65
  });
@@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest";
2
2
  import { setupOC, render } from "../../setup.js";
3
3
  import sketch from "../../../core/sketch.js";
4
4
  import extrude from "../../../core/extrude.js";
5
- import { move, hMove, vMove, rect, circle } from "../../../core/2d/index.js";
5
+ import { move, hMove, vMove, pMove, hLine, rect, circle } from "../../../core/2d/index.js";
6
6
  import { ShapeOps } from "../../../oc/shape-ops.js";
7
7
  describe("move functions", () => {
8
8
  setupOC();
@@ -57,6 +57,77 @@ describe("move functions", () => {
57
57
  expect(bbox.minY).toBeCloseTo(50, 0);
58
58
  });
59
59
  });
60
+ describe("hMove to target geometry", () => {
61
+ it("should move cursor to nearest intersection with a circle", () => {
62
+ sketch("xy", () => {
63
+ const c = circle([100, 0], 50).guide();
64
+ move([0, 0]);
65
+ hMove(c);
66
+ rect(10, 10);
67
+ });
68
+ const e = extrude(5);
69
+ render();
70
+ const bbox = ShapeOps.getBoundingBox(e.getShapes()[0]);
71
+ expect(bbox.minX).toBeCloseTo(75, 1);
72
+ expect(bbox.minY).toBeCloseTo(0, 1);
73
+ });
74
+ it("should pick nearest intersection when target is behind start", () => {
75
+ sketch("xy", () => {
76
+ const c = circle([-100, 0], 50).guide();
77
+ move([0, 0]);
78
+ hMove(c);
79
+ rect(10, 10);
80
+ });
81
+ const e = extrude(5);
82
+ render();
83
+ const bbox = ShapeOps.getBoundingBox(e.getShapes()[0]);
84
+ expect(bbox.minX).toBeCloseTo(-75, 1);
85
+ expect(bbox.minY).toBeCloseTo(0, 1);
86
+ });
87
+ });
88
+ describe("vMove to target geometry", () => {
89
+ it("should move cursor to nearest intersection with a circle above", () => {
90
+ sketch("xy", () => {
91
+ const c = circle([0, 100], 50).guide();
92
+ move([0, 0]);
93
+ vMove(c);
94
+ rect(10, 10);
95
+ });
96
+ const e = extrude(5);
97
+ render();
98
+ const bbox = ShapeOps.getBoundingBox(e.getShapes()[0]);
99
+ expect(bbox.minX).toBeCloseTo(0, 1);
100
+ expect(bbox.minY).toBeCloseTo(75, 1);
101
+ });
102
+ });
103
+ describe("pMove to target geometry", () => {
104
+ it("should move cursor along angle to nearest intersection", () => {
105
+ sketch("xy", () => {
106
+ const h = hLine([-100, 50], 200).guide();
107
+ move([0, 0]);
108
+ pMove(h, 90);
109
+ rect(10, 10);
110
+ });
111
+ const e = extrude(5);
112
+ render();
113
+ const bbox = ShapeOps.getBoundingBox(e.getShapes()[0]);
114
+ expect(bbox.minX).toBeCloseTo(0, 1);
115
+ expect(bbox.minY).toBeCloseTo(50, 1);
116
+ });
117
+ it("should support 45° intersection with a horizontal line", () => {
118
+ sketch("xy", () => {
119
+ const h = hLine([-100, 50], 200).guide();
120
+ move([0, 0]);
121
+ pMove(h, 45);
122
+ rect(10, 10);
123
+ });
124
+ const e = extrude(5);
125
+ render();
126
+ const bbox = ShapeOps.getBoundingBox(e.getShapes()[0]);
127
+ expect(bbox.minX).toBeCloseTo(50, 1);
128
+ expect(bbox.minY).toBeCloseTo(50, 1);
129
+ });
130
+ });
60
131
  describe("combined moves", () => {
61
132
  it("should chain horizontal and vertical moves", () => {
62
133
  sketch("xy", () => {
@@ -6,6 +6,19 @@ import cylinder from "../../../core/cylinder.js";
6
6
  import { project, rect, circle } from "../../../core/2d/index.js";
7
7
  import { Edge } from "../../../common/edge.js";
8
8
  import { EdgeOps } from "../../../oc/edge-ops.js";
9
+ // Asserts no two projected edges share a midpoint within tolerance — a strong
10
+ // signal that overlap-dedup ran and removed coincident projections.
11
+ function assertNoDuplicateEdges(shapes, tol = 1e-5) {
12
+ const mids = shapes
13
+ .filter((s) => s instanceof Edge)
14
+ .map(e => EdgeOps.getEdgeMidPoint(e));
15
+ for (let i = 0; i < mids.length; i++) {
16
+ for (let j = i + 1; j < mids.length; j++) {
17
+ const d = mids[i].distanceTo(mids[j]);
18
+ expect(d, `edges ${i} and ${j} share a midpoint (distance ${d})`).toBeGreaterThan(tol);
19
+ }
20
+ }
21
+ }
9
22
  describe("project — regression: all projected edges land on sketch plane", () => {
10
23
  setupOC();
11
24
  it("projects endFaces of a plain extrude onto z=0 (the sketch plane)", () => {
@@ -26,6 +39,11 @@ describe("project — regression: all projected edges land on sketch plane", ()
26
39
  expect(mid.z).toBeCloseTo(0, 4);
27
40
  }
28
41
  }
42
+ // endFaces() returns top + bottom (both rectangles); they project to the
43
+ // same 4-edge rectangle. Dedup must collapse 8 edges down to 4.
44
+ const edges = shapes.filter(s => s instanceof Edge);
45
+ expect(edges.length).toBe(4);
46
+ assertNoDuplicateEdges(shapes);
29
47
  });
30
48
  it("projects endFaces of an extrude fused with a cylinder onto z=0", () => {
31
49
  // Mirrors the user's scenario: complex shape built by fusing an extrude
@@ -47,6 +65,7 @@ describe("project — regression: all projected edges land on sketch plane", ()
47
65
  expect(mid.z).toBeCloseTo(0, 4);
48
66
  }
49
67
  }
68
+ assertNoDuplicateEdges(shapes);
50
69
  });
51
70
  it("projects sideFaces including a cylindrical one onto z=0", () => {
52
71
  sketch("xy", () => {
@@ -65,5 +84,21 @@ describe("project — regression: all projected edges land on sketch plane", ()
65
84
  expect(mid.z).toBeCloseTo(0, 4);
66
85
  }
67
86
  }
87
+ assertNoDuplicateEdges(shapes);
88
+ });
89
+ it("dedupes when the same source is projected twice", () => {
90
+ sketch("xy", () => {
91
+ rect(100, 50);
92
+ });
93
+ const e = extrude(30);
94
+ const ef = e.endFaces();
95
+ const s = sketch("xy", () => {
96
+ project(ef, ef);
97
+ });
98
+ render();
99
+ // Two endFaces × projected twice = 16 raw edges → 4 unique after dedup.
100
+ const edges = s.getShapes().filter(x => x instanceof Edge);
101
+ expect(edges.length).toBe(4);
102
+ assertNoDuplicateEdges(s.getShapes());
68
103
  });
69
104
  });
@@ -7,6 +7,7 @@ import select from "../../core/select.js";
7
7
  import fillet from "../../core/fillet.js";
8
8
  import chamfer from "../../core/chamfer.js";
9
9
  import fuse from "../../core/fuse.js";
10
+ import shell from "../../core/shell.js";
10
11
  import { circle, rect } from "../../core/2d/index.js";
11
12
  import { face } from "../../filters/index.js";
12
13
  function hasRed(solid) {
@@ -210,4 +211,27 @@ describe("color preservation through operations (Phase 3 lineage)", () => {
210
211
  // another input was red.
211
212
  expect(result.colorMap).toEqual([]);
212
213
  });
214
+ it("shell preserves colored side faces and does not paint the new internal walls", () => {
215
+ sketch("xy", () => {
216
+ rect(100, 50).centered().radius(8);
217
+ });
218
+ const e = extrude(20);
219
+ color("orange", e.sideFaces());
220
+ const sh = shell(-2, e.endFaces());
221
+ render();
222
+ const result = sh.getShapes()[0];
223
+ expect(result).toBeDefined();
224
+ // The original side faces of the extrusion were orange. After shell, the
225
+ // outer side walls should still be orange (history-mapped through
226
+ // BRepOffsetAPI_MakeThickSolid). Internal walls are new geometry — the
227
+ // user only colored the outside, so they must NOT be painted.
228
+ const orangeFaces = result.colorMap.filter(e => e.color === '#ffa500');
229
+ expect(orangeFaces.length).toBeGreaterThan(0);
230
+ // None of the shell's internal faces should carry the orange color.
231
+ const internalFaces = sh.getState('internal-faces') || [];
232
+ expect(internalFaces.length).toBeGreaterThan(0);
233
+ for (const f of internalFaces) {
234
+ expect(result.getColor(f.getShape())).toBeUndefined();
235
+ }
236
+ });
213
237
  });
@@ -78,6 +78,46 @@ describe("cut", () => {
78
78
  // Circle edges at top and bottom of the hole
79
79
  expect(getEdgesByType(solid, "circle").length).toBeGreaterThanOrEqual(2);
80
80
  });
81
+ it("should apply draft to a through-all cut", () => {
82
+ sketch("xy", () => {
83
+ rect(100, 100);
84
+ });
85
+ const e = extrude(50);
86
+ sketch(e.endFaces(), () => {
87
+ move([50, 50]);
88
+ circle(40);
89
+ });
90
+ cut().draft(-5);
91
+ const scene = render();
92
+ expect(countShapes(scene)).toBe(1);
93
+ const solid = scene.getAllSceneObjects()
94
+ .flatMap(o => o.getShapes())
95
+ .find(s => s.getType() === "solid");
96
+ // Drafted through-all cut: hole wall is a cone, not a cylinder
97
+ expect(getFacesByType(solid, "cone").length).toBeGreaterThanOrEqual(1);
98
+ expect(getFacesByType(solid, "cylinder")).toHaveLength(0);
99
+ });
100
+ it("should apply draft to a through-all cut on a small profile", () => {
101
+ // Mirrors the user's repro: small radius (1.5) and steep draft (-8°).
102
+ // With THROUGH_ALL_LENGTH=100000, lateral draft = 100000 * tan(8°) ≈ 14054
103
+ // would invert a 1.5-radius profile if applied over the full prism length.
104
+ sketch("xy", () => {
105
+ rect(7, 5).centered();
106
+ });
107
+ const e = extrude(1.5);
108
+ sketch(e.endFaces(), () => {
109
+ circle(1.5);
110
+ });
111
+ cut().draft(-8);
112
+ const scene = render();
113
+ expect(countShapes(scene)).toBe(1);
114
+ const solid = scene.getAllSceneObjects()
115
+ .flatMap(o => o.getShapes())
116
+ .find(s => s.getType() === "solid");
117
+ // Drafted through-all cut: hole wall is a cone, not a cylinder
118
+ expect(getFacesByType(solid, "cone").length).toBeGreaterThanOrEqual(1);
119
+ expect(getFacesByType(solid, "cylinder")).toHaveLength(0);
120
+ });
81
121
  });
82
122
  describe("section edges", () => {
83
123
  it("should expose section edges", () => {