fluidcad 0.0.37 → 0.0.38

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.
@@ -1,6 +1,11 @@
1
1
  import { PlaneLike, PlaneTransformOptions } from "../math/plane.js";
2
2
  import { IPlane, ISceneObject } from "./interfaces.js";
3
3
  export type PlaneRenderableOptions = PlaneTransformOptions;
4
+ /**
5
+ * Where to place a plane along an edge: a named position, or a normalized
6
+ * distance from the edge start (`0`) to its end (`1`). `0.5` is the midpoint.
7
+ */
8
+ export type EdgePlanePosition = 'start' | 'middle' | 'end' | number;
4
9
  interface PlaneFunction {
5
10
  /**
6
11
  * Creates a plane from a standard plane or normal vector.
@@ -20,8 +25,10 @@ interface PlaneFunction {
20
25
  */
21
26
  (plane: PlaneLike, offset: number): IPlane;
22
27
  /**
23
- * Creates a plane from a face selection.
24
- * @param selection - The selected face to create a plane from
28
+ * Creates a plane from a selection. A selected face yields that face's
29
+ * plane; a selected edge yields a plane at the edge start, oriented normal
30
+ * to the edge and facing outward (away from the edge body), like a start cap.
31
+ * @param selection - The selected face or edge to create a plane from
25
32
  */
26
33
  (selection: ISceneObject): IPlane;
27
34
  /**
@@ -31,11 +38,24 @@ interface PlaneFunction {
31
38
  */
32
39
  (selection: ISceneObject, options: PlaneRenderableOptions): IPlane;
33
40
  /**
34
- * Creates a plane from a face selection with an offset.
35
- * @param selection - The selected face to create a plane from
36
- * @param offset - The offset distance
41
+ * Creates a plane from a selection with a numeric second argument. For a
42
+ * face, the number is the offset distance along the face normal. For an
43
+ * edge, it is a normalized position from `0` (start) to `1` (end), and the
44
+ * plane is created at that point oriented normal to the edge. The normal
45
+ * follows the edge's forward direction, except at the start (`0`) where it
46
+ * faces outward (away from the edge body), so both ends read like caps.
47
+ * @param selection - The selected face or edge
48
+ * @param offsetOrPosition - Face: offset distance. Edge: normalized 0–1 position.
49
+ */
50
+ (selection: ISceneObject, offsetOrPosition: number): IPlane;
51
+ /**
52
+ * Creates a plane at a named position along an edge, oriented normal to the
53
+ * edge. At `'start'` the normal faces outward (away from the edge body);
54
+ * `'middle'` and `'end'` follow the edge's forward direction.
55
+ * @param edge - The selected edge
56
+ * @param position - `'start'`, `'middle'`, or `'end'`
37
57
  */
38
- (selection: ISceneObject, offset: number): IPlane;
58
+ (edge: ISceneObject, position: 'start' | 'middle' | 'end'): IPlane;
39
59
  /**
40
60
  * Transforms an existing plane with options.
41
61
  * @param plane - The existing plane to transform
@@ -23,56 +23,33 @@ function build(context) {
23
23
  }
24
24
  }
25
25
  if (arguments.length === 2) {
26
- if (typeof arguments[1] === 'number') {
27
- const options = { offset: arguments[1] };
28
- if (arguments[0] instanceof SceneObject) {
29
- context.addSceneObject(arguments[0]);
30
- const pln = new PlaneFromObject(arguments[0], options);
31
- context.addSceneObject(pln);
32
- return pln;
33
- }
34
- else if (isPlaneLike(arguments[0])) {
35
- const axis = normalizePlane(arguments[0]);
36
- const pln = new PlaneObject(axis, options);
37
- context.addSceneObject(pln);
38
- return pln;
39
- }
40
- }
41
- if ((arguments[0] instanceof PlaneObjectBase || isPlaneLike(arguments[0])) &&
42
- (arguments[1] instanceof PlaneObjectBase || isPlaneLike(arguments[1]))) {
43
- // axis between two others
44
- let a1;
45
- let a2;
46
- if (arguments[0] instanceof PlaneObjectBase) {
47
- a1 = arguments[0];
48
- }
49
- else {
50
- const axis = normalizePlane(arguments[0]);
51
- a1 = new PlaneObject(axis);
52
- }
53
- if (arguments[1] instanceof PlaneObjectBase) {
54
- a2 = arguments[1];
55
- }
56
- else {
57
- const axis = normalizePlane(arguments[1]);
58
- a2 = new PlaneObject(axis);
59
- }
60
- context.addSceneObject(a1);
61
- context.addSceneObject(a2);
62
- const pln = new PlaneMiddleRenderable(a1, a2);
26
+ const a0 = arguments[0];
27
+ const a1 = arguments[1];
28
+ // Plane midway between two planes / plane-likes.
29
+ if ((a0 instanceof PlaneObjectBase || isPlaneLike(a0)) &&
30
+ (a1 instanceof PlaneObjectBase || isPlaneLike(a1))) {
31
+ const p1 = a0 instanceof PlaneObjectBase ? a0 : new PlaneObject(normalizePlane(a0));
32
+ const p2 = a1 instanceof PlaneObjectBase ? a1 : new PlaneObject(normalizePlane(a1));
33
+ context.addSceneObject(p1);
34
+ context.addSceneObject(p2);
35
+ const pln = new PlaneMiddleRenderable(p1, p2);
63
36
  context.addSceneObject(pln);
64
37
  return pln;
65
38
  }
66
- else if (arguments[0] instanceof SceneObject) {
67
- context.addSceneObject(arguments[0]);
68
- const pln = new PlaneFromObject(arguments[0], arguments[1]);
39
+ // From a scene object. A face reads the second argument as an offset
40
+ // distance / transform options; an edge reads it as a normalized 0–1
41
+ // position (or 'start'/'middle'/'end'). The face-vs-edge decision is
42
+ // deferred to PlaneFromObject.build(), where the source shape is known.
43
+ if (a0 instanceof SceneObject) {
44
+ context.addSceneObject(a0);
45
+ const pln = new PlaneFromObject(a0, a1);
69
46
  context.addSceneObject(pln);
70
47
  return pln;
71
48
  }
72
- else if (isPlaneLike(arguments[0])) {
73
- const axis1 = normalizePlane(arguments[0]);
74
- const options = arguments[1];
75
- const pln = new PlaneObject(axis1, options);
49
+ // From a plane-like: number → offset, object → transform options.
50
+ if (isPlaneLike(a0)) {
51
+ const options = typeof a1 === 'number' ? { offset: a1 } : a1;
52
+ const pln = new PlaneObject(normalizePlane(a0), options);
76
53
  context.addSceneObject(pln);
77
54
  return pln;
78
55
  }
@@ -55,9 +55,9 @@ export class Offset extends ExtrudableGeometryBase {
55
55
  });
56
56
  }
57
57
  let lastOffsetWire = null;
58
+ const plane = this.getPlane();
58
59
  for (const wireInfo of wires) {
59
- const isOpen = !wireInfo.wire.isClosed();
60
- const offsetWire = WireOps.offsetWire(wireInfo.wire, this.distance, isOpen);
60
+ const offsetWire = WireOps.offsetWireOnPlane(wireInfo.wire, this.distance, wireInfo.wire.isClosed(), plane);
61
61
  lastOffsetWire = offsetWire;
62
62
  const edges = offsetWire.getEdges();
63
63
  for (const edge of edges) {
@@ -1,15 +1,27 @@
1
1
  import { BuildSceneObjectContext, SceneObject } from "../common/scene-object.js";
2
- import { PlaneRenderableOptions } from "../core/plane.js";
2
+ import { EdgePlanePosition, PlaneRenderableOptions } from "../core/plane.js";
3
3
  import { PlaneObjectBase } from "./plane-renderable-base.js";
4
4
  import { Face } from "../common/face.js";
5
5
  import { Point } from "../math/point.js";
6
6
  import { Plane } from "../math/plane.js";
7
7
  export declare class PlaneFromObject extends PlaneObjectBase {
8
8
  sourceObject: SceneObject;
9
- options?: PlaneRenderableOptions;
10
- constructor(sourceObject: SceneObject, options?: PlaneRenderableOptions);
9
+ optionsOrPosition?: PlaneRenderableOptions | EdgePlanePosition;
10
+ constructor(sourceObject: SceneObject, optionsOrPosition?: PlaneRenderableOptions | EdgePlanePosition);
11
11
  validate(): void;
12
12
  build(context?: BuildSceneObjectContext): void;
13
+ /**
14
+ * Builds a plane normal to `edge` at the configured position. The edge
15
+ * tangent at that point becomes the plane normal; the in-plane axes are
16
+ * an arbitrary (but deterministic) basis around it.
17
+ */
18
+ private buildFromEdge;
19
+ /**
20
+ * Resolves the second argument for a face/plane source. A bare number is a
21
+ * normal-offset distance; a string position is only meaningful for edges
22
+ * and is rejected here.
23
+ */
24
+ private faceOptions;
13
25
  getFromSceneObject(sceneObject: SceneObject): {
14
26
  plane: Plane;
15
27
  sourceFace: Face;
@@ -24,7 +36,7 @@ export declare class PlaneFromObject extends PlaneObjectBase {
24
36
  xDirection: import("../math/vector3d.js").Vector3d;
25
37
  yDirection: import("../math/vector3d.js").Vector3d;
26
38
  normal: import("../math/vector3d.js").Vector3d;
27
- options: import("../math/plane.js").PlaneTransformOptions;
39
+ options: import("../math/plane.js").PlaneTransformOptions | EdgePlanePosition;
28
40
  center: any;
29
41
  };
30
42
  }
@@ -1,15 +1,18 @@
1
1
  import { PlaneObjectBase } from "./plane-renderable-base.js";
2
2
  import { FaceOps } from "../oc/face-ops.js";
3
3
  import { ShapeOps } from "../oc/shape-ops.js";
4
+ import { WireOps } from "../oc/wire-ops.js";
5
+ import { PathSampler } from "../oc/path-sampler.js";
4
6
  import { Point } from "../math/point.js";
7
+ import { Plane } from "../math/plane.js";
5
8
  import { requireShapes } from "../common/operand-check.js";
6
9
  export class PlaneFromObject extends PlaneObjectBase {
7
10
  sourceObject;
8
- options;
9
- constructor(sourceObject, options) {
11
+ optionsOrPosition;
12
+ constructor(sourceObject, optionsOrPosition) {
10
13
  super();
11
14
  this.sourceObject = sourceObject;
12
- this.options = options;
15
+ this.optionsOrPosition = optionsOrPosition;
13
16
  }
14
17
  validate() {
15
18
  // PlaneObjectBase sources expose the plane directly — no shapes required.
@@ -19,6 +22,17 @@ export class PlaneFromObject extends PlaneObjectBase {
19
22
  requireShapes(this.sourceObject, "source", "plane");
20
23
  }
21
24
  build(context) {
25
+ // An edge source produces a plane normal to the edge at a position along
26
+ // it. The face-vs-edge decision is deferred to here (rather than the
27
+ // plane() builder) because the source shape type is only known once the
28
+ // selection has been resolved.
29
+ if (!(this.sourceObject instanceof PlaneObjectBase)) {
30
+ const shapes = this.sourceObject.getShapes({ excludeGuide: false });
31
+ if (shapes.length === 1 && shapes[0].isEdge()) {
32
+ this.buildFromEdge(context, shapes[0]);
33
+ return;
34
+ }
35
+ }
22
36
  let plane;
23
37
  let sourceFace;
24
38
  let center;
@@ -36,10 +50,11 @@ export class PlaneFromObject extends PlaneObjectBase {
36
50
  const bbox = ShapeOps.getBoundingBox(sourceFace.getShape());
37
51
  center = new Point(bbox.centerX, bbox.centerY, bbox.centerZ);
38
52
  }
39
- if (this.options) {
53
+ const options = this.faceOptions();
54
+ if (options) {
40
55
  // Apply the same transform to the center so the preview face stays on
41
56
  // the rotated plane instead of floating at its pre-rotation position.
42
- const matrix = plane.getTransformMatrix(this.options);
57
+ const matrix = plane.getTransformMatrix(options);
43
58
  plane = plane.applyMatrix(matrix);
44
59
  if (center) {
45
60
  center = center.transform(matrix);
@@ -60,6 +75,53 @@ export class PlaneFromObject extends PlaneObjectBase {
60
75
  face.markAsMetaShape();
61
76
  this.addShape(face);
62
77
  }
78
+ /**
79
+ * Builds a plane normal to `edge` at the configured position. The edge
80
+ * tangent at that point becomes the plane normal; the in-plane axes are
81
+ * an arbitrary (but deterministic) basis around it.
82
+ */
83
+ buildFromEdge(context, edge) {
84
+ const t = normalizeEdgePosition(this.optionsOrPosition);
85
+ const frame = sampleEdgeFrame(edge, t);
86
+ // The forward tangent points *into* the edge at the start, so the plane
87
+ // would face inward there. Flip it at the start endpoint so it faces
88
+ // outward — like an extrude's start cap (the end already faces outward via
89
+ // the forward tangent). Interior/end positions keep the forward tangent.
90
+ const normal = t <= 0 ? frame.tangent.negate() : frame.tangent;
91
+ let plane = Plane.fromPointAndNormal(frame.point, normal);
92
+ let center = frame.point;
93
+ // Unlike the face path, an edge is only *referenced* to derive the plane —
94
+ // it is not consumed, so it stays available to its owning solid and to
95
+ // other features.
96
+ const transform = context?.getTransform() ?? null;
97
+ if (transform) {
98
+ plane = plane.applyMatrix(transform);
99
+ center = center.transform(transform);
100
+ }
101
+ this.setState('plane-center', center);
102
+ this.setState('plane', plane);
103
+ const face = FaceOps.planeToFace(plane, center);
104
+ face.markAsMetaShape();
105
+ this.addShape(face);
106
+ }
107
+ /**
108
+ * Resolves the second argument for a face/plane source. A bare number is a
109
+ * normal-offset distance; a string position is only meaningful for edges
110
+ * and is rejected here.
111
+ */
112
+ faceOptions() {
113
+ const value = this.optionsOrPosition;
114
+ if (value == null) {
115
+ return undefined;
116
+ }
117
+ if (typeof value === 'number') {
118
+ return { offset: value };
119
+ }
120
+ if (typeof value === 'string') {
121
+ throw new Error(`Plane: position '${value}' is only valid for an edge source`);
122
+ }
123
+ return value;
124
+ }
63
125
  getFromSceneObject(sceneObject) {
64
126
  const shapes = sceneObject.getShapes();
65
127
  console.log(`Plane: Retrieved ${shapes.length} shapes from selection`, shapes);
@@ -82,7 +144,7 @@ export class PlaneFromObject extends PlaneObjectBase {
82
144
  return [];
83
145
  }
84
146
  createCopy(remap) {
85
- return new PlaneFromObject(this, this.options);
147
+ return new PlaneFromObject(this, this.optionsOrPosition);
86
148
  }
87
149
  compareTo(other) {
88
150
  if (!(other instanceof PlaneFromObject)) {
@@ -94,7 +156,7 @@ export class PlaneFromObject extends PlaneObjectBase {
94
156
  if (!this.sourceObject.compareTo(other.sourceObject)) {
95
157
  return false;
96
158
  }
97
- if (JSON.stringify(this.options) !== JSON.stringify(other.options)) {
159
+ if (JSON.stringify(this.optionsOrPosition) !== JSON.stringify(other.optionsOrPosition)) {
98
160
  return false;
99
161
  }
100
162
  return true;
@@ -109,8 +171,39 @@ export class PlaneFromObject extends PlaneObjectBase {
109
171
  xDirection: plane.xDirection,
110
172
  yDirection: plane.yDirection,
111
173
  normal: plane.normal,
112
- options: this.options,
174
+ options: this.optionsOrPosition,
113
175
  center: this.getState('plane-center') || plane.origin,
114
176
  };
115
177
  }
116
178
  }
179
+ /**
180
+ * Evaluates the point and unit (forward) tangent on `edge` at a normalized
181
+ * position `t` (`0` = start, `1` = end), measured by arc length.
182
+ */
183
+ function sampleEdgeFrame(edge, t) {
184
+ const wire = WireOps.makeWireFromEdges([edge]);
185
+ const sampler = new PathSampler(wire);
186
+ try {
187
+ return sampler.evalAt(t * sampler.length);
188
+ }
189
+ finally {
190
+ sampler.dispose();
191
+ }
192
+ }
193
+ function normalizeEdgePosition(position) {
194
+ if (position === undefined) {
195
+ return 0;
196
+ }
197
+ if (typeof position === 'number') {
198
+ return position;
199
+ }
200
+ switch (position) {
201
+ case 'start':
202
+ return 0;
203
+ case 'middle':
204
+ return 0.5;
205
+ case 'end':
206
+ return 1;
207
+ }
208
+ throw new Error("Plane: an edge plane takes a 0–1 position or 'start'/'middle'/'end', not transform options");
209
+ }
@@ -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,