fluidcad 0.0.37 → 0.0.39

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 (113) hide show
  1. package/lib/dist/core/2d/aline.d.ts +0 -8
  2. package/lib/dist/core/2d/aline.js +3 -22
  3. package/lib/dist/core/2d/arc.d.ts +1 -23
  4. package/lib/dist/core/2d/arc.js +11 -28
  5. package/lib/dist/core/2d/circle.d.ts +1 -8
  6. package/lib/dist/core/2d/circle.js +7 -24
  7. package/lib/dist/core/2d/ellipse.d.ts +1 -17
  8. package/lib/dist/core/2d/ellipse.js +7 -45
  9. package/lib/dist/core/2d/hline.d.ts +0 -7
  10. package/lib/dist/core/2d/hline.js +5 -27
  11. package/lib/dist/core/2d/intersect.d.ts +0 -7
  12. package/lib/dist/core/2d/intersect.js +0 -13
  13. package/lib/dist/core/2d/line.d.ts +1 -8
  14. package/lib/dist/core/2d/line.js +6 -24
  15. package/lib/dist/core/2d/offset.d.ts +1 -11
  16. package/lib/dist/core/2d/offset.js +0 -16
  17. package/lib/dist/core/2d/polygon.d.ts +1 -17
  18. package/lib/dist/core/2d/polygon.js +18 -37
  19. package/lib/dist/core/2d/project.d.ts +0 -7
  20. package/lib/dist/core/2d/project.js +0 -13
  21. package/lib/dist/core/2d/rect.d.ts +1 -9
  22. package/lib/dist/core/2d/rect.js +0 -19
  23. package/lib/dist/core/2d/slot.d.ts +0 -23
  24. package/lib/dist/core/2d/slot.js +18 -37
  25. package/lib/dist/core/2d/text.d.ts +0 -7
  26. package/lib/dist/core/2d/text.js +0 -14
  27. package/lib/dist/core/2d/vline.d.ts +0 -7
  28. package/lib/dist/core/2d/vline.js +5 -23
  29. package/lib/dist/core/fillet.js +7 -0
  30. package/lib/dist/core/index.d.ts +1 -1
  31. package/lib/dist/core/interfaces.d.ts +43 -0
  32. package/lib/dist/core/plane.d.ts +26 -6
  33. package/lib/dist/core/plane.js +21 -44
  34. package/lib/dist/features/2d/connect.d.ts +2 -0
  35. package/lib/dist/features/2d/connect.js +3 -0
  36. package/lib/dist/features/2d/intersect.js +4 -1
  37. package/lib/dist/features/2d/offset.js +2 -2
  38. package/lib/dist/features/frozen-geometry.d.ts +29 -0
  39. package/lib/dist/features/frozen-geometry.js +62 -0
  40. package/lib/dist/features/loft.d.ts +38 -1
  41. package/lib/dist/features/loft.js +133 -8
  42. package/lib/dist/features/plane-from-object.d.ts +16 -4
  43. package/lib/dist/features/plane-from-object.js +101 -8
  44. package/lib/dist/features/rib.js +11 -1
  45. package/lib/dist/features/shell.d.ts +1 -1
  46. package/lib/dist/features/shell.js +9 -0
  47. package/lib/dist/features/sweep.d.ts +11 -1
  48. package/lib/dist/features/sweep.js +43 -7
  49. package/lib/dist/helpers/clone-transform.js +62 -5
  50. package/lib/dist/helpers/scene-helpers.d.ts +12 -0
  51. package/lib/dist/helpers/scene-helpers.js +26 -0
  52. package/lib/dist/math/bspline-interpolation.d.ts +33 -6
  53. package/lib/dist/math/bspline-interpolation.js +187 -84
  54. package/lib/dist/oc/loft/constrained-loft.d.ts +32 -0
  55. package/lib/dist/oc/loft/constrained-loft.js +61 -0
  56. package/lib/dist/oc/loft/curve-data.d.ts +18 -0
  57. package/lib/dist/oc/loft/curve-data.js +54 -0
  58. package/lib/dist/oc/loft/curve-eval.d.ts +23 -0
  59. package/lib/dist/oc/loft/curve-eval.js +84 -0
  60. package/lib/dist/oc/loft/guided-loft.d.ts +89 -0
  61. package/lib/dist/oc/loft/guided-loft.js +368 -0
  62. package/lib/dist/oc/loft/section-compatibility.d.ts +131 -0
  63. package/lib/dist/oc/loft/section-compatibility.js +481 -0
  64. package/lib/dist/oc/loft/section-curve.d.ts +53 -0
  65. package/lib/dist/oc/loft/section-curve.js +264 -0
  66. package/lib/dist/oc/loft/skinning.d.ts +104 -0
  67. package/lib/dist/oc/loft/skinning.js +299 -0
  68. package/lib/dist/oc/loft-ops.d.ts +28 -1
  69. package/lib/dist/oc/loft-ops.js +31 -1
  70. package/lib/dist/oc/ncollection.d.ts +19 -0
  71. package/lib/dist/oc/ncollection.js +69 -0
  72. package/lib/dist/oc/rib-ops.js +5 -17
  73. package/lib/dist/oc/sweep-ops.d.ts +7 -16
  74. package/lib/dist/oc/sweep-ops.js +67 -99
  75. package/lib/dist/oc/thin-face-maker.d.ts +0 -19
  76. package/lib/dist/oc/thin-face-maker.js +3 -68
  77. package/lib/dist/oc/wire-extend-ops.d.ts +12 -0
  78. package/lib/dist/oc/wire-extend-ops.js +42 -0
  79. package/lib/dist/oc/wire-ops.d.ts +28 -2
  80. package/lib/dist/oc/wire-ops.js +88 -4
  81. package/lib/dist/oc/wrap-ops.js +8 -15
  82. package/lib/dist/tests/features/2d/circle.test.js +0 -10
  83. package/lib/dist/tests/features/2d/ellipse.test.js +0 -30
  84. package/lib/dist/tests/features/2d/line.test.js +0 -5
  85. package/lib/dist/tests/features/2d/offset.test.js +74 -11
  86. package/lib/dist/tests/features/2d/polygon.test.js +0 -16
  87. package/lib/dist/tests/features/2d/rect.test.js +0 -11
  88. package/lib/dist/tests/features/2d/slot.test.js +0 -8
  89. package/lib/dist/tests/features/fillet2d.test.js +37 -1
  90. package/lib/dist/tests/features/loft-conditions.test.d.ts +1 -0
  91. package/lib/dist/tests/features/loft-conditions.test.js +246 -0
  92. package/lib/dist/tests/features/loft-guides.test.d.ts +1 -0
  93. package/lib/dist/tests/features/loft-guides.test.js +276 -0
  94. package/lib/dist/tests/features/plane.test.js +95 -0
  95. package/lib/dist/tests/features/repeat-sweep-borrowed-path.test.d.ts +1 -0
  96. package/lib/dist/tests/features/repeat-sweep-borrowed-path.test.js +35 -0
  97. package/lib/dist/tests/features/rib.test.js +50 -0
  98. package/lib/dist/tests/features/shell.test.js +35 -1
  99. package/lib/dist/tests/features/sweep.test.js +127 -1
  100. package/lib/dist/tests/features/text.test.js +33 -14
  101. package/lib/dist/tests/math/bspline-interpolation.test.js +97 -1
  102. package/lib/dist/tsconfig.tsbuildinfo +1 -1
  103. package/llm-docs/api/index.json +1 -1
  104. package/llm-docs/api/types/loft.md +36 -1
  105. package/llm-docs/api/types/sweep.md +13 -1
  106. package/llm-docs/api/types/text.md +2 -2
  107. package/llm-docs/index.json +5 -5
  108. package/package.json +1 -1
  109. package/server/dist/fluidcad-server.d.ts +1 -1
  110. package/server/dist/fluidcad-server.js +11 -1
  111. package/server/dist/routes/params.js +1 -1
  112. package/ui/dist/assets/{index-no7mtr5s.js → index-D8zV21wB.js} +83 -83
  113. package/ui/dist/index.html +1 -1
@@ -1,5 +1,32 @@
1
1
  import { Solid } from "../common/solid.js";
2
2
  import { Wire } from "../common/wire.js";
3
+ import { LoftEndCondition, LoftConditionKind } from "./loft/constrained-loft.js";
4
+ export type { LoftEndCondition, LoftConditionKind };
5
+ export interface LoftOptions {
6
+ /** Constrains how the surface leaves the first profile. */
7
+ startCondition?: LoftEndCondition;
8
+ /** Constrains how the surface arrives at the last profile. */
9
+ endCondition?: LoftEndCondition;
10
+ /** One or two side rails the loft surface must follow. */
11
+ guides?: Wire[];
12
+ }
3
13
  export declare class LoftOps {
4
- static makeLoft(wires: Wire[]): Solid[];
14
+ /**
15
+ * Lofts through the section wires. Plain lofts run OCC's ThruSections;
16
+ * guides dispatch to `GuidedLoft` (virtual sections carried onto the
17
+ * rails) and start/end conditions to `ConstrainedLoft` (derivative-pinned
18
+ * skinning) — both in-house skins, since OCC can neither constrain end
19
+ * tangency nor follow rails without distorting sections. Conditions
20
+ * compose with guides: the condition fades out around each guide contact
21
+ * (the rails own their sides of the surface).
22
+ */
23
+ static makeLoft(wires: Wire[], options?: LoftOptions): Solid[];
24
+ /**
25
+ * Thin-walled loft with start/end conditions: both walls are skinned with
26
+ * the same conditions and assembled directly with ring caps — see
27
+ * `ConstrainedLoft.buildThin`. (The condition-less thin loft stays on the
28
+ * legacy ThruSections + boolean path in the feature layer.)
29
+ */
30
+ static makeThinLoft(outerWires: Wire[], innerWires: Wire[], options: LoftOptions): Solid[];
31
+ private static makeThruSectionsLoft;
5
32
  }
@@ -1,8 +1,38 @@
1
1
  import { getOC } from "./init.js";
2
2
  import { Explorer } from "./explorer.js";
3
3
  import { Solid } from "../common/solid.js";
4
+ import { ConstrainedLoft } from "./loft/constrained-loft.js";
5
+ import { GuidedLoft } from "./loft/guided-loft.js";
4
6
  export class LoftOps {
5
- static makeLoft(wires) {
7
+ /**
8
+ * Lofts through the section wires. Plain lofts run OCC's ThruSections;
9
+ * guides dispatch to `GuidedLoft` (virtual sections carried onto the
10
+ * rails) and start/end conditions to `ConstrainedLoft` (derivative-pinned
11
+ * skinning) — both in-house skins, since OCC can neither constrain end
12
+ * tangency nor follow rails without distorting sections. Conditions
13
+ * compose with guides: the condition fades out around each guide contact
14
+ * (the rails own their sides of the surface).
15
+ */
16
+ static makeLoft(wires, options) {
17
+ const guides = options?.guides ?? [];
18
+ if (guides.length > 0) {
19
+ return GuidedLoft.build(wires, guides, options?.startCondition, options?.endCondition);
20
+ }
21
+ if (options?.startCondition || options?.endCondition) {
22
+ return ConstrainedLoft.build(wires, options.startCondition, options.endCondition);
23
+ }
24
+ return LoftOps.makeThruSectionsLoft(wires);
25
+ }
26
+ /**
27
+ * Thin-walled loft with start/end conditions: both walls are skinned with
28
+ * the same conditions and assembled directly with ring caps — see
29
+ * `ConstrainedLoft.buildThin`. (The condition-less thin loft stays on the
30
+ * legacy ThruSections + boolean path in the feature layer.)
31
+ */
32
+ static makeThinLoft(outerWires, innerWires, options) {
33
+ return ConstrainedLoft.buildThin(outerWires, innerWires, options.startCondition, options.endCondition);
34
+ }
35
+ static makeThruSectionsLoft(wires) {
6
36
  const oc = getOC();
7
37
  const thruSections = new oc.BRepOffsetAPI_ThruSections(true, false, 1e-6);
8
38
  for (const wire of wires) {
@@ -0,0 +1,19 @@
1
+ import type { NCollection_Array1_double, NCollection_Array1_int, NCollection_Array1_gp_Pnt, NCollection_Array1_gp_Pnt2d, NCollection_Array2_gp_Pnt, NCollection_Array2_double } from "ocjs-fluidcad";
2
+ import type { Disposable } from "./convert.js";
3
+ import { Point2D } from "../math/point.js";
4
+ /**
5
+ * Builders for the 1-based NCollection arrays that OCC geometry constructors
6
+ * consume. Each returns the codebase's `Disposable` pair so call sites free
7
+ * the native array (and nothing else) once the consumer has copied it.
8
+ */
9
+ export declare class NCollections {
10
+ static toArray1Double(values: ReadonlyArray<number>): Disposable<NCollection_Array1_double>;
11
+ static toArray1Int(values: ReadonlyArray<number>): Disposable<NCollection_Array1_int>;
12
+ /** Each point is an [x, y, z] triple. */
13
+ static toArray1Pnt(points: ReadonlyArray<ReadonlyArray<number>>): Disposable<NCollection_Array1_gp_Pnt>;
14
+ static toArray1Pnt2d(points: ReadonlyArray<Point2D>): Disposable<NCollection_Array1_gp_Pnt2d>;
15
+ /** Grid of [x, y, z] triples, indexed [row][column]. */
16
+ static toArray2Pnt(grid: ReadonlyArray<ReadonlyArray<ReadonlyArray<number>>>): Disposable<NCollection_Array2_gp_Pnt>;
17
+ /** Grid of numbers, indexed [row][column]. */
18
+ static toArray2Double(grid: ReadonlyArray<ReadonlyArray<number>>): Disposable<NCollection_Array2_double>;
19
+ }
@@ -0,0 +1,69 @@
1
+ import { getOC } from "./init.js";
2
+ /**
3
+ * Builders for the 1-based NCollection arrays that OCC geometry constructors
4
+ * consume. Each returns the codebase's `Disposable` pair so call sites free
5
+ * the native array (and nothing else) once the consumer has copied it.
6
+ */
7
+ export class NCollections {
8
+ static toArray1Double(values) {
9
+ const oc = getOC();
10
+ const array = new oc.NCollection_Array1_double(1, values.length);
11
+ for (let i = 0; i < values.length; i++) {
12
+ array.SetValue(i + 1, values[i]);
13
+ }
14
+ return [array, () => array.delete()];
15
+ }
16
+ static toArray1Int(values) {
17
+ const oc = getOC();
18
+ const array = new oc.NCollection_Array1_int(1, values.length);
19
+ for (let i = 0; i < values.length; i++) {
20
+ array.SetValue(i + 1, values[i]);
21
+ }
22
+ return [array, () => array.delete()];
23
+ }
24
+ /** Each point is an [x, y, z] triple. */
25
+ static toArray1Pnt(points) {
26
+ const oc = getOC();
27
+ const array = new oc.NCollection_Array1_gp_Pnt(1, points.length);
28
+ for (let i = 0; i < points.length; i++) {
29
+ const point = new oc.gp_Pnt(points[i][0], points[i][1], points[i][2]);
30
+ array.SetValue(i + 1, point);
31
+ point.delete();
32
+ }
33
+ return [array, () => array.delete()];
34
+ }
35
+ static toArray1Pnt2d(points) {
36
+ const oc = getOC();
37
+ const array = new oc.NCollection_Array1_gp_Pnt2d(1, points.length);
38
+ for (let i = 0; i < points.length; i++) {
39
+ const point = new oc.gp_Pnt2d(points[i].x, points[i].y);
40
+ array.SetValue(i + 1, point);
41
+ point.delete();
42
+ }
43
+ return [array, () => array.delete()];
44
+ }
45
+ /** Grid of [x, y, z] triples, indexed [row][column]. */
46
+ static toArray2Pnt(grid) {
47
+ const oc = getOC();
48
+ const array = new oc.NCollection_Array2_gp_Pnt(1, grid.length, 1, grid[0].length);
49
+ for (let row = 0; row < grid.length; row++) {
50
+ for (let col = 0; col < grid[row].length; col++) {
51
+ const point = new oc.gp_Pnt(grid[row][col][0], grid[row][col][1], grid[row][col][2]);
52
+ array.SetValue(row + 1, col + 1, point);
53
+ point.delete();
54
+ }
55
+ }
56
+ return [array, () => array.delete()];
57
+ }
58
+ /** Grid of numbers, indexed [row][column]. */
59
+ static toArray2Double(grid) {
60
+ const oc = getOC();
61
+ const array = new oc.NCollection_Array2_double(1, grid.length, 1, grid[0].length);
62
+ for (let row = 0; row < grid.length; row++) {
63
+ for (let col = 0; col < grid[row].length; col++) {
64
+ array.SetValue(row + 1, col + 1, grid[row][col]);
65
+ }
66
+ }
67
+ return [array, () => array.delete()];
68
+ }
69
+ }
@@ -4,6 +4,7 @@ import { Point } from "../math/point.js";
4
4
  import { Vector3d } from "../math/vector3d.js";
5
5
  import { Matrix4 } from "../math/matrix4.js";
6
6
  import { WireOps } from "./wire-ops.js";
7
+ import { WireExtendOps } from "./wire-extend-ops.js";
7
8
  import { FaceOps } from "./face-ops.js";
8
9
  import { EdgeOps } from "./edge-ops.js";
9
10
  import { ShapeOps } from "./shape-ops.js";
@@ -144,17 +145,9 @@ export class RibOps {
144
145
  // overlaps every cavity boundary regardless of curvature (drafted cones,
145
146
  // fillets, etc.) — so the cut produces a clean blend on every face it touches.
146
147
  static extendSpineWire(spineWire, scopeShapes, _plane) {
147
- const edges = spineWire.getEdges();
148
- if (edges.length === 0) {
148
+ if (spineWire.getEdges().length === 0) {
149
149
  return spineWire;
150
150
  }
151
- const firstVertex = spineWire.getFirstVertex().toPoint();
152
- const lastVertex = spineWire.getLastVertex().toPoint();
153
- const lastEdge = edges[edges.length - 1];
154
- const endTangent = EdgeOps.getEdgeTangentAtEnd(lastEdge).normalize();
155
- const firstEdge = edges[0];
156
- const firstEdgeEnd = EdgeOps.getLastVertex(firstEdge).toPoint();
157
- const startTangent = firstVertex.vectorTo(firstEdgeEnd).normalize();
158
151
  let minX = Infinity, minY = Infinity, minZ = Infinity;
159
152
  let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
160
153
  for (const s of scopeShapes) {
@@ -184,14 +177,9 @@ export class RibOps {
184
177
  return spineWire;
185
178
  }
186
179
  const ext = 2 * diag;
187
- const startExtPoint = firstVertex.add(startTangent.multiply(-ext));
188
- const endExtPoint = lastVertex.add(endTangent.multiply(ext));
189
- const newEdges = [
190
- EdgeOps.makeLineEdge(startExtPoint, firstVertex),
191
- ...edges,
192
- EdgeOps.makeLineEdge(lastVertex, endExtPoint),
193
- ];
194
- return WireOps.makeWireFromEdges(newEdges);
180
+ let extended = WireExtendOps.extendWire(spineWire, "start", ext);
181
+ extended = WireExtendOps.extendWire(extended, "end", ext);
182
+ return extended;
195
183
  }
196
184
  static computeExtrudeDistanceAlongDirection(direction, origin, scopeShapes) {
197
185
  let maxDist = 0;
@@ -12,22 +12,13 @@ export declare class SweepOps {
12
12
  static makeSweep(spineWire: Wire, profileFaces: Face[]): SweepResult;
13
13
  /** Sweep a single wire along the spine with a fixed binormal. */
14
14
  private static sweepWire;
15
- /** Unit tangent of the spine wire at its first parameter. */
16
- private static getSpineTangent;
17
- /** World-space centroid of a planar face (uses surface-area properties). */
18
- private static getFaceCentroid;
19
15
  /**
20
- * Build a world-to-world trsf that lays a planar face flat in world XY
21
- * with its centroid at the world origin.
22
- *
23
- * `gp_Trsf::SetTransformation(A, B)` builds the transformation that maps
24
- * frame A onto frame B (i.e., A's origin → B's origin, A's axes → B's
25
- * axes). To move the profile *from* its current frame *to* the canonical
26
- * frame, we pass the canonical frame as A and the profile's frame as B —
27
- * counterintuitive but matches OCC's convention as verified empirically.
28
- *
29
- * The canonical frame is what OCC's MakePipeShell expects when
30
- * WithContact/WithCorrection are both false: `Saxe = gp_Ax3(0, +Z, +X)`.
16
+ * The axis the spine's tangent rotates around, = normalize(Σ Tᵢ × Tᵢ₊₁) over
17
+ * tangents sampled along the spine. For a planar spine this is the plane
18
+ * normal; for a helix it is the coil axis. For a straight spine the tangent
19
+ * is constant, every cross product vanishes, and it returns null.
31
20
  */
32
- private static profileToCanonicalFrameTrsf;
21
+ private static tangentRotationAxis;
22
+ /** Unit tangent of the spine wire at its first parameter. */
23
+ private static getSpineTangent;
33
24
  }
@@ -17,58 +17,40 @@ export class SweepOps {
17
17
  let firstShape = null;
18
18
  let lastShape = null;
19
19
  const profilePlane = profileFaces[0].getPlane();
20
- // Fixed binormal: profile plane's "up" direction (perpendicular to xDir
21
- // and to the face normal). Locking it via SetMode stops the swept profile
22
- // from twisting along helical spines (clean spring instead of a wobbling
23
- // ribbon). The binormal stays in WORLD coords it's the user's intended
24
- // "up" direction even when we pre-canonicalize the profile below.
25
- const binormalVec = profilePlane.yDirection;
26
- const [binormalDir, disposeBinormal] = Convert.toGpDir(binormalVec);
27
- // Decide whether to pre-canonicalize the profile.
28
- //
29
- // OCC's `MakePipeShell.Add(_, false, true)` (no contact, with correction)
30
- // works for most profile orientations: it rotates the profile to align
31
- // its plane normal with the spine tangent, around an axis given by
32
- // `profile.normal × spine.tangent`. That axis is well-defined unless
33
- // the two are *anti-parallel* — in which case the cross product is
34
- // zero, the rotation axis is undefined, and OCC produces a degenerate
35
- // sweep.
20
+ // Fixed binormal for MakePipeShell's `SetMode`: it locks the section's
21
+ // "up", so the profile keeps a constant angle to it instead of twisting
22
+ // along the spine. The correct direction is the axis the spine's tangent
23
+ // rotates around the plane normal for a planar spine, the coil axis for a
24
+ // helix. The tangent keeps a constant, non-zero angle to that axis, so the
25
+ // section never flips and the result is a clean coil.
36
26
  //
37
- // For the well-behaved case we keep the user's drawn position (so the
38
- // swept solid lands where the user expects). For the antiparallel case
39
- // we manually canonicalize to (origin, XY plane), then call
40
- // `Add(_, false, false)`. The canonicalization is necessary because
41
- // `GeomFill_SectionPlacement::Transformation` builds its trsf as
42
- // `Tf.SetTransformation(Saxe = gp_Ax3(0, +Z, +X), Paxe = trihedron)`
43
- // it implicitly assumes the section is at the world origin in XY and
44
- // produces offset placements otherwise.
27
+ // The profile plane's own "up" (used previously) only works when it happens
28
+ // to equal that axis true for a profile sketched on a world plane, but
29
+ // NOT for a plane built off a helix, whose in-plane axes are arbitrary.
30
+ // A wrong (e.g. roughly horizontal) binormal lets the helix tangent rotate
31
+ // into it, collapsing `Normal = BiNormal × Tangent` ~twice per turn and
32
+ // shredding the section into a self-intersecting ribbon. A straight spine
33
+ // has no rotation axis (the cross products vanish); there the profile's up
34
+ // is well-defined and never aligns with the constant tangent, so use it.
35
+ const spineAxis = SweepOps.tangentRotationAxis(spineWire.getShape());
36
+ const binormalVec = spineAxis ?? profilePlane.yDirection;
37
+ const [binormalDir, disposeBinormal] = Convert.toGpDir(binormalVec);
38
+ // `Add(_, false, true)` (no contact, with correction) rotates the profile
39
+ // to sit perpendicular to the spine tangent, about an axis given by
40
+ // `profile.normal × spine.tangent`. That axis is undefined when the two are
41
+ // anti-parallel — but then the profile plane is *already* perpendicular to
42
+ // the spine (its normal is ∥ -tangent), so no correction is needed: skip it
43
+ // and keep the profile's drawn position.
45
44
  const spineTangent = SweepOps.getSpineTangent(spineWire.getShape());
46
45
  const isAntiParallel = profilePlane.normal.dot(spineTangent) < -0.999;
47
- let trsf = null;
48
- let withCorrection = true;
49
- if (isAntiParallel) {
50
- const profileCentroid = SweepOps.getFaceCentroid(profileFaces[0].getShape());
51
- trsf = SweepOps.profileToCanonicalFrameTrsf(profileCentroid, profilePlane.normal, profilePlane.xDirection);
52
- withCorrection = false;
53
- }
46
+ const withCorrection = !isAntiParallel;
54
47
  try {
55
48
  for (const face of profileFaces) {
56
- let workingFace;
57
- let transformer = null;
58
- if (trsf) {
59
- transformer = new oc.BRepBuilderAPI_Transform(trsf);
60
- transformer.Perform(face.getShape(), true);
61
- workingFace = transformer.Shape();
62
- }
63
- else {
64
- workingFace = face.getShape();
65
- }
66
- const ocFace = oc.TopoDS.Face(workingFace);
49
+ const ocFace = oc.TopoDS.Face(face.getShape());
67
50
  const outerWire = oc.BRepTools.OuterWire(ocFace);
68
- const innerWires = trsf
69
- ? Explorer.findShapes(workingFace, Explorer.getOcShapeType("wire"))
70
- .filter(w => !w.IsSame(outerWire))
71
- : face.getWires().map(w => w.getShape()).filter(w => !w.IsSame(outerWire));
51
+ const innerWires = face.getWires()
52
+ .map(w => w.getShape())
53
+ .filter(w => !w.IsSame(outerWire));
72
54
  const outer = SweepOps.sweepWire(spineWire.getShape(), outerWire, binormalDir, withCorrection);
73
55
  let resultSolid = outer.solid;
74
56
  let resultFirst = outer.firstFace;
@@ -115,11 +97,9 @@ export class SweepOps {
115
97
  for (const s of solids) {
116
98
  allSolids.push(Solid.fromTopoDSSolid(Explorer.toSolid(s)));
117
99
  }
118
- transformer?.delete();
119
100
  }
120
101
  }
121
102
  finally {
122
- trsf?.delete();
123
103
  disposeBinormal();
124
104
  }
125
105
  if (allSolids.length === 0) {
@@ -135,10 +115,10 @@ export class SweepOps {
135
115
  static sweepWire(spine, profile, binormalDir, withCorrection) {
136
116
  const oc = getOC();
137
117
  const pipe = new oc.BRepOffsetAPI_MakePipeShell(spine);
138
- // Fixed binormal (the profile plane's "up"): keeps the swept profile from
139
- // twisting along the spine — a clean spring rather than a wobbling ribbon —
140
- // and is well-defined on straight spines, where Frenet is not (zero
141
- // curvature ⇒ undefined normal).
118
+ // Fixed binormal (the spine's tangent-rotation axis; see makeSweep): keeps
119
+ // the swept section from twisting — a clean coil rather than a wobbling
120
+ // ribbon — and is well-defined on straight spines, where Frenet is not
121
+ // (zero curvature ⇒ undefined normal).
142
122
  pipe.SetMode(binormalDir);
143
123
  // Give the swept-surface approximation enough spans for tapered/tight
144
124
  // helical spines (see MAX_PIPE_SEGMENTS) — at OCCT's default budget the
@@ -162,6 +142,41 @@ export class SweepOps {
162
142
  pipe.delete();
163
143
  return { solid, firstFace, lastFace };
164
144
  }
145
+ /**
146
+ * The axis the spine's tangent rotates around, = normalize(Σ Tᵢ × Tᵢ₊₁) over
147
+ * tangents sampled along the spine. For a planar spine this is the plane
148
+ * normal; for a helix it is the coil axis. For a straight spine the tangent
149
+ * is constant, every cross product vanishes, and it returns null.
150
+ */
151
+ static tangentRotationAxis(spine) {
152
+ const oc = getOC();
153
+ const adaptor = new oc.BRepAdaptor_CompCurve(spine, false);
154
+ const u0 = adaptor.FirstParameter();
155
+ const u1 = adaptor.LastParameter();
156
+ const SAMPLES = 64;
157
+ const tangents = [];
158
+ const pnt = new oc.gp_Pnt();
159
+ const vec = new oc.gp_Vec();
160
+ for (let i = 0; i <= SAMPLES; i++) {
161
+ const u = u0 + ((u1 - u0) * i) / SAMPLES;
162
+ adaptor.D1(u, pnt, vec);
163
+ const t = new Vector3d(vec.X(), vec.Y(), vec.Z());
164
+ if (t.length() > 1e-9) {
165
+ tangents.push(t.normalize());
166
+ }
167
+ }
168
+ pnt.delete();
169
+ vec.delete();
170
+ adaptor.delete();
171
+ let axis = new Vector3d(0, 0, 0);
172
+ for (let i = 0; i + 1 < tangents.length; i++) {
173
+ axis = axis.add(tangents[i].cross(tangents[i + 1]));
174
+ }
175
+ if (axis.length() < 1e-6) {
176
+ return null;
177
+ }
178
+ return axis.normalize();
179
+ }
165
180
  /** Unit tangent of the spine wire at its first parameter. */
166
181
  static getSpineTangent(spine) {
167
182
  const oc = getOC();
@@ -177,51 +192,4 @@ export class SweepOps {
177
192
  adaptor.delete();
178
193
  return tangent;
179
194
  }
180
- /** World-space centroid of a planar face (uses surface-area properties). */
181
- static getFaceCentroid(face) {
182
- const oc = getOC();
183
- const ocFace = oc.TopoDS.Face(face);
184
- const props = new oc.GProp_GProps();
185
- oc.BRepGProp.SurfaceProperties(ocFace, props, false, false);
186
- const c = props.CentreOfMass();
187
- const out = new Vector3d(c.X(), c.Y(), c.Z());
188
- c.delete();
189
- props.delete();
190
- return out;
191
- }
192
- /**
193
- * Build a world-to-world trsf that lays a planar face flat in world XY
194
- * with its centroid at the world origin.
195
- *
196
- * `gp_Trsf::SetTransformation(A, B)` builds the transformation that maps
197
- * frame A onto frame B (i.e., A's origin → B's origin, A's axes → B's
198
- * axes). To move the profile *from* its current frame *to* the canonical
199
- * frame, we pass the canonical frame as A and the profile's frame as B —
200
- * counterintuitive but matches OCC's convention as verified empirically.
201
- *
202
- * The canonical frame is what OCC's MakePipeShell expects when
203
- * WithContact/WithCorrection are both false: `Saxe = gp_Ax3(0, +Z, +X)`.
204
- */
205
- static profileToCanonicalFrameTrsf(centroid, normal, xDir) {
206
- const oc = getOC();
207
- const [originPnt, disposeOriginPnt] = Convert.toGpPnt(centroid);
208
- const [normalDirGp, disposeNormalDir] = Convert.toGpDir(normal);
209
- const [xDirGp, disposeXDirGp] = Convert.toGpDir(xDir);
210
- const profileAx3 = new oc.gp_Ax3(originPnt, normalDirGp, xDirGp);
211
- const zeroPnt = new oc.gp_Pnt(0, 0, 0);
212
- const zDir = new oc.gp_Dir(0, 0, 1);
213
- const xDirWorld = new oc.gp_Dir(1, 0, 0);
214
- const canonicalAx3 = new oc.gp_Ax3(zeroPnt, zDir, xDirWorld);
215
- const trsf = new oc.gp_Trsf();
216
- trsf.SetTransformation(canonicalAx3, profileAx3);
217
- profileAx3.delete();
218
- canonicalAx3.delete();
219
- zeroPnt.delete();
220
- zDir.delete();
221
- xDirWorld.delete();
222
- disposeOriginPnt();
223
- disposeNormalDir();
224
- disposeXDirGp();
225
- return trsf;
226
- }
227
195
  }
@@ -11,19 +11,6 @@ export declare class ThinFaceMaker {
11
11
  static make(edges: (Wire | Edge)[], plane: Plane, offset1: number, offset2?: number): ThinFaceResult;
12
12
  private static makeSingleOffsetFace;
13
13
  private static makeDualOffsetFace;
14
- /**
15
- * Offsets a wire by the given distance, handling both closed and open wires.
16
- * For closed wires, WireOps.offsetWire handles negative distances natively.
17
- * For open wires, negative distances are handled by reversing the wire,
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.
25
- */
26
- private static doOffset;
27
14
  /**
28
15
  * Merges adjacent edges that share the same underlying curve into a single
29
16
  * edge (e.g. two conic-arc segments at a filleted corner produced by
@@ -33,12 +20,6 @@ export declare class ThinFaceMaker {
33
20
  * upgrader produces no usable result.
34
21
  */
35
22
  private static unifyWireEdges;
36
- /**
37
- * Offsets an open wire on a given plane, using a planar face as reference
38
- * so that BRepOffsetAPI_MakeOffset knows the offset direction.
39
- * Only handles positive distances — use doOffset for sign handling.
40
- */
41
- private static offsetWireOnPlane;
42
23
  /**
43
24
  * Finds face edges that geometrically match the given wire edges by comparing midpoints.
44
25
  * This is needed because wire reversal (ShapeExtend_WireData.Reverse) creates new TShapes,
@@ -4,7 +4,6 @@ import { WireOps } from "./wire-ops.js";
4
4
  import { FaceOps } from "./face-ops.js";
5
5
  import { EdgeOps } from "./edge-ops.js";
6
6
  import { Explorer } from "./explorer.js";
7
- import { Convert } from "./convert.js";
8
7
  import { getOC } from "./init.js";
9
8
  export class ThinFaceMaker {
10
9
  static make(edges, plane, offset1, offset2) {
@@ -48,7 +47,7 @@ export class ThinFaceMaker {
48
47
  return { faces, inwardEdges, outwardEdges };
49
48
  }
50
49
  static makeSingleOffsetFace(wire, isClosed, plane, offset) {
51
- const offsetWire = this.doOffset(wire, plane, offset, isClosed);
50
+ const offsetWire = WireOps.offsetWireOnPlane(wire, offset, isClosed, plane);
52
51
  if (isClosed) {
53
52
  // Determine which wire is outer (larger) vs inner based on offset sign
54
53
  const [outer, inner] = offset >= 0
@@ -77,8 +76,8 @@ export class ThinFaceMaker {
77
76
  if (Math.sign(offset1) === Math.sign(offset2)) {
78
77
  offset2 = -offset2;
79
78
  }
80
- const wire1 = this.doOffset(wire, plane, offset1, isClosed);
81
- const wire2 = this.doOffset(wire, plane, offset2, isClosed);
79
+ const wire1 = WireOps.offsetWireOnPlane(wire, offset1, isClosed, plane);
80
+ const wire2 = WireOps.offsetWireOnPlane(wire, offset2, isClosed, plane);
82
81
  if (isClosed) {
83
82
  // The wire with the larger offset is the outer boundary
84
83
  const [outer, inner] = offset1 >= offset2
@@ -102,34 +101,6 @@ export class ThinFaceMaker {
102
101
  const outwardEdges = this.matchFaceEdgesByMidpoint(face, outwardWireEdges);
103
102
  return { face, inwardEdges, outwardEdges };
104
103
  }
105
- /**
106
- * Offsets a wire by the given distance, handling both closed and open wires.
107
- * For closed wires, WireOps.offsetWire handles negative distances natively.
108
- * For open wires, negative distances are handled by reversing the wire,
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.
116
- */
117
- static doOffset(wire, plane, distance, 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 {
127
- return WireOps.offsetWire(wire, distance, false);
128
- }
129
- catch {
130
- return this.offsetWireOnPlane(wire, plane, distance, false);
131
- }
132
- }
133
104
  /**
134
105
  * Merges adjacent edges that share the same underlying curve into a single
135
106
  * edge (e.g. two conic-arc segments at a filleted corner produced by
@@ -154,42 +125,6 @@ export class ThinFaceMaker {
154
125
  }
155
126
  return Wire.fromTopoDSWire(oc.TopoDS.Wire(wires[0]));
156
127
  }
157
- /**
158
- * Offsets an open wire on a given plane, using a planar face as reference
159
- * so that BRepOffsetAPI_MakeOffset knows the offset direction.
160
- * Only handles positive distances — use doOffset for sign handling.
161
- */
162
- static offsetWireOnPlane(wire, plane, distance, isOpen) {
163
- const oc = getOC();
164
- const [pln, disposePlane] = Convert.toGpPln(plane);
165
- const faceMaker = new oc.BRepBuilderAPI_MakeFace(pln);
166
- if (!faceMaker.IsDone()) {
167
- faceMaker.delete();
168
- disposePlane();
169
- throw new Error("Failed to create reference face for thin offset");
170
- }
171
- const face = faceMaker.Face();
172
- faceMaker.delete();
173
- disposePlane();
174
- const maker = new oc.BRepOffsetAPI_MakeOffset();
175
- maker.Init(face, oc.GeomAbs_JoinType.GeomAbs_Arc, isOpen);
176
- maker.AddWire(wire.getShape());
177
- maker.Perform(distance, 0);
178
- if (!maker.IsDone()) {
179
- maker.delete();
180
- throw new Error("Failed to offset wire for thin extrude");
181
- }
182
- const result = maker.Shape();
183
- maker.delete();
184
- if (Explorer.isWire(result)) {
185
- return Wire.fromTopoDSWire(oc.TopoDS.Wire(result));
186
- }
187
- const wires = Explorer.findShapes(result, oc.TopAbs_ShapeEnum.TopAbs_WIRE);
188
- if (wires.length === 0) {
189
- throw new Error("Thin offset produced no usable wire");
190
- }
191
- return Wire.fromTopoDSWire(oc.TopoDS.Wire(wires[0]));
192
- }
193
128
  /**
194
129
  * Finds face edges that geometrically match the given wire edges by comparing midpoints.
195
130
  * This is needed because wire reversal (ShapeExtend_WireData.Reverse) creates new TShapes,
@@ -0,0 +1,12 @@
1
+ import { Wire } from "../common/wire.js";
2
+ export type WireEnd = "start" | "end";
3
+ export declare class WireExtendOps {
4
+ /**
5
+ * Extends an open wire beyond one end by `length`, continuing straight along
6
+ * the wire's true tangent there. The new straight edge joins the existing
7
+ * first/last vertex, so the result is a single connected wire.
8
+ *
9
+ * No-op for closed wires, empty wires, or `length <= 0`.
10
+ */
11
+ static extendWire(wire: Wire, side: WireEnd, length: number): Wire;
12
+ }
@@ -0,0 +1,42 @@
1
+ import { EdgeOps } from "./edge-ops.js";
2
+ import { WireOps } from "./wire-ops.js";
3
+ import { PathSampler } from "./path-sampler.js";
4
+ export class WireExtendOps {
5
+ /**
6
+ * Extends an open wire beyond one end by `length`, continuing straight along
7
+ * the wire's true tangent there. The new straight edge joins the existing
8
+ * first/last vertex, so the result is a single connected wire.
9
+ *
10
+ * No-op for closed wires, empty wires, or `length <= 0`.
11
+ */
12
+ static extendWire(wire, side, length) {
13
+ if (length <= 0) {
14
+ return wire;
15
+ }
16
+ const edges = wire.getEdges();
17
+ if (edges.length === 0) {
18
+ return wire;
19
+ }
20
+ const sampler = new PathSampler(wire);
21
+ try {
22
+ if (sampler.closed) {
23
+ return wire;
24
+ }
25
+ if (side === "start") {
26
+ // Tangent at the start points into the wire; extend the opposite way.
27
+ const startPt = wire.getFirstVertex().toPoint();
28
+ const inward = sampler.evalAt(0).tangent;
29
+ const extPt = startPt.add(inward.multiply(-length));
30
+ return WireOps.makeWireFromEdges([EdgeOps.makeLineEdge(extPt, startPt), ...edges]);
31
+ }
32
+ // Tangent at the end already points out of the wire.
33
+ const endPt = wire.getLastVertex().toPoint();
34
+ const outward = sampler.evalAt(sampler.length).tangent;
35
+ const extPt = endPt.add(outward.multiply(length));
36
+ return WireOps.makeWireFromEdges([...edges, EdgeOps.makeLineEdge(endPt, extPt)]);
37
+ }
38
+ finally {
39
+ sampler.dispose();
40
+ }
41
+ }
42
+ }