@code3d/core 0.0.1-alpha.11 → 0.0.1-alpha.12

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 (36) hide show
  1. package/README.md +18 -13
  2. package/bld/chunks/{chunk-JBVIAMJ6.js → chunk-CQKOD54C.js} +552 -25
  3. package/bld/chunks/chunk-CQKOD54C.js.map +7 -0
  4. package/bld/chunks/{chunk-CL3E2DE4.js → chunk-JF6QRICU.js} +2 -2
  5. package/bld/chunks/{chunk-HZRQUHM2.js → chunk-L4V6X7RH.js} +2 -2
  6. package/bld/chunks/{chunk-QK6ZET47.js → chunk-QJVTVAYM.js} +3 -3
  7. package/bld/library/extrude.d.ts +5 -0
  8. package/bld/library/extrude.d.ts.map +1 -1
  9. package/bld/library/index.d.ts +2 -2
  10. package/bld/library/index.d.ts.map +1 -1
  11. package/bld/library/index.js +11 -1
  12. package/bld/library/inspect.d.ts +3 -1
  13. package/bld/library/inspect.d.ts.map +1 -1
  14. package/bld/library/loft.d.ts +7 -1
  15. package/bld/library/loft.d.ts.map +1 -1
  16. package/bld/library/replicad.js +2 -2
  17. package/bld/library/runtime.d.ts +68 -7
  18. package/bld/library/runtime.d.ts.map +1 -1
  19. package/bld/node/index.js +13 -3
  20. package/bld/node/replicad.js +4 -4
  21. package/bld/tooling/index.js +2 -2
  22. package/docs/api.md +134 -13
  23. package/docs/local-coordinates.md +2 -0
  24. package/docs/runtime.md +42 -2
  25. package/docs/topology.md +6 -0
  26. package/docs/values.md +9 -0
  27. package/package.json +1 -1
  28. package/src/library/extrude.ts +129 -2
  29. package/src/library/index.ts +6 -0
  30. package/src/library/inspect.ts +4 -2
  31. package/src/library/loft.ts +67 -2
  32. package/src/library/runtime.ts +599 -7
  33. package/bld/chunks/chunk-JBVIAMJ6.js.map +0 -7
  34. /package/bld/chunks/{chunk-CL3E2DE4.js.map → chunk-JF6QRICU.js.map} +0 -0
  35. /package/bld/chunks/{chunk-HZRQUHM2.js.map → chunk-L4V6X7RH.js.map} +0 -0
  36. /package/bld/chunks/{chunk-QK6ZET47.js.map → chunk-QJVTVAYM.js.map} +0 -0
package/docs/api.md CHANGED
@@ -31,6 +31,7 @@ radius. For coils, `coilRadius` is measured to the wire centerline and
31
31
  `pitch` is the advance per turn. Fractional turns are supported; the wire
32
32
  must fit inside the coil radius and neighboring turns must remain separated.
33
33
  Use [`@code3d/screws`](../../screws/docs/assembly.mdx) for standard fasteners and matching hole tools.
34
+ Use [`@code3d/gears`](../../gears/README.md) for nominal spur, helical and internal gear parts.
34
35
 
35
36
  To build a solid beyond these primitives, import `definePrimitive` and
36
37
  `replicad` from `@code3d/core/replicad`. See
@@ -40,19 +41,21 @@ To build a solid beyond these primitives, import `definePrimitive` and
40
41
 
41
42
  Planar profiles lie in the local XZ plane with a +Y normal.
42
43
 
43
- | Function | Meaning |
44
- | ------------------------------------------ | -------------------------------------------- |
45
- | `circle(radius)` | Circular face |
46
- | `ellipse(xRadius, zRadius)` | Elliptical face |
47
- | `rectangle(x, z)` | Rectangular face |
48
- | `regularPolygon(radius, sides, rotation?)` | Regular polygonal face |
49
- | `point()` or `point([x, y, z])` | Vertex model |
50
- | `line([x, y, z])` or `line(start, end)` | Straight edge |
51
- | `arc(start, middle, end)` | Arc through three points |
52
- | `bezier(points)` | Bézier curve |
53
- | `spline(points)` | Interpolating spline |
54
- | `loft(sections, options?)` | Solid through sections; optional curve spine |
55
- | `extrude(faceOrFaces, distance)` | Solid extruded along one face's local normal |
44
+ | Function | Meaning |
45
+ | ------------------------------------------ | ------------------------------------------------------------------------- |
46
+ | `circle(radius)` | Circular face |
47
+ | `ellipse(xRadius, zRadius)` | Elliptical face |
48
+ | `rectangle(x, z)` | Rectangular face |
49
+ | `regularPolygon(radius, sides, rotation?)` | Regular polygonal face |
50
+ | `point()` or `point([x, y, z])` | Vertex model |
51
+ | `line([x, y, z])` or `line(start, end)` | Straight edge |
52
+ | `arc(start, middle, end)` | Arc through three points |
53
+ | `bezier(points)` | Bézier curve |
54
+ | `spline(points)` | Interpolating spline |
55
+ | `loft(sections, options?)` | Solid through sections; optional curve spine |
56
+ | `extrude(faceOrFaces, distance)` | Solid extruded along one face's local normal |
57
+ | `revolve(profile, axis, config)` | Solid rotated about a straight directed axis, with optional axial advance |
58
+ | `sweep(profile, spine)` | Solid formed by carrying one face along an open curve |
56
59
 
57
60
  See [local coordinates and placement](local-coordinates.md) for
58
61
  the coordinate frame of a model, reference, or composition.
@@ -83,8 +86,126 @@ export const plate = rectangle(30, 20).extrude(3).fillet(0.5);
83
86
  export const pin = extrude(circle(2), -10);
84
87
  ```
85
88
 
89
+ ### Rotational solids
90
+
91
+ `revolve(profile, axis, config: RevolveConfig)` and
92
+ `profile.revolve(axis, config)` rotate one face about a straight directed axis.
93
+ `line(...)` can be passed directly; an existing straight edge or axis reference
94
+ also works.
95
+ `config.angle` is a required finite, non-zero angle in degrees. `config.advance`
96
+ is the signed total distance traveled along the directed axis during that angle;
97
+ it defaults to zero. A positive angle follows the axis's right-hand direction.
98
+ Reversing the axis reverses both the rotation sense and the direction of a positive
99
+ advance. With zero advance, the angle may cover at most one turn. With non-zero
100
+ advance, it may cover multiple turns to form a simple screw-motion solid.
101
+
102
+ ```ts
103
+ import {circle, line, rectangle, revolve} from '@code3d/core';
104
+
105
+ const axis = line([0, -20, 0], [0, 20, 0]);
106
+ const ringSection = rectangle(4, 6).rotate(90, 0, 0).originOffset(-8, 0, 0);
107
+ export const ring = revolve(ringSection, axis, {angle: 360});
108
+
109
+ const wireSection = circle(1).rotate(90, 0, 0).originOffset(-8, 0, 0);
110
+ export const spring = wireSection.revolve(axis, {angle: 5 * 360, advance: 25});
111
+ ```
112
+
113
+ The authoring signature requires `config`. While editing an incomplete call,
114
+ the App uses 360 degrees and zero advance so its parameter tool can add the
115
+ config object.
116
+
117
+ The result keeps the profile's local frame and is an ordinary `SolidModel`.
118
+ The axis participates in the same relation solve as the profile; its own model
119
+ placement is respected. A helical profile must have one outer boundary without
120
+ holes. Intersecting turns and profiles that cross the axis may fail to produce a
121
+ valid solid; leave clearance between turns and keep the profile off the axis.
122
+ For a multi-turn coil with round wire and automatic pitch clearance checks,
123
+ [`coil`](#solid-primitives) remains the shorter constructor.
124
+
125
+ ### Path sweeps
126
+
127
+ `sweep(profile, spine)` and `profile.sweep(spine)` carry one planar face along a
128
+ continuous open `EdgeModel`, such as a line or Bézier curve. The face's local
129
+ origin must meet the path's start, and its normal must point along the starting
130
+ tangent. The operation respects the solved placement of both inputs and returns
131
+ a solid in the profile's local frame; it does not move or rotate the supplied
132
+ profile to fit the path.
133
+
134
+ ```ts
135
+ import {bezier, circle, sweep} from '@code3d/core';
136
+
137
+ const profile = circle(2);
138
+ const spine = bezier([
139
+ [0, 0, 0],
140
+ [0, 8, 0],
141
+ [5, 16, 0],
142
+ [5, 24, 0],
143
+ ]);
144
+ export const bentRod = sweep(profile, spine);
145
+ ```
146
+
147
+ The path must be open with a non-zero starting tangent. The output is an ordinary
148
+ `SolidModel` that supports subsequent Boolean and finishing operations. Very
149
+ tight bends or self-intersections may prevent the kernel from producing a valid
150
+ solid. One through hole in the profile is supported; profiles with multiple
151
+ holes currently need explicit contour correspondence. Use the
152
+ [App example](../../app/examples/operations/sweep.ts) to inspect
153
+ the profile, path and result.
154
+
86
155
  ## Measurements
87
156
 
157
+ ### Length and area
158
+
159
+ Read `edge.length` or `line(...).length` for a finite edge's actual arc length.
160
+ A straight edge uses its endpoint distance; a closed edge uses its circumference.
161
+ Read `surface.area` or `faceModel.area` for a finite face's area, including curved
162
+ surfaces and trimming holes. `solid.area` / `solidModel.area` includes every
163
+ boundary face, including inner walls and cavity faces.
164
+
165
+ These properties return ordinary numbers in model units (area in square model
166
+ units). They are read-only. Rotation, origin changes, placement and reversing an
167
+ edge or flipping a face preserve the result. `scaled(s)` multiplies lengths by
168
+ `s` and areas by `s²`; exposed references use the scale of their actual geometry.
169
+ `LineAnchor` and `FaceAnchor` can describe infinite references and have no length
170
+ or area. Groups have no aggregate area.
171
+
172
+ ```ts
173
+ import {line, rectangle, box} from '@code3d/core';
174
+ const length = line([3, 4, 0]).length; // 5
175
+ const area = rectangle(4, 6).area; // 24
176
+ const surfaceArea = box(2, 3, 4).area; // 52
177
+ ```
178
+
179
+ Select `.length` or `.area` in App to inspect the measured geometry and value.
180
+ Straight lengths use a dimension line; curves highlight their actual path with an
181
+ arc-length label. Area highlights the finite face or whole solid with an area
182
+ label. The read-only display does not create editable size constraints.
183
+ Try the [length example](../../app/examples/operations/length.ts) and
184
+ [area example](../../app/examples/operations/area.ts).
185
+
186
+ ### Volume
187
+
188
+ Read `solid.volume` or `solidModel.volume` for the space occupied by the solid's
189
+ material. Holes and enclosed cavities are excluded. The result is a read-only
190
+ number in cubic model units. Rotation, origin changes and placement preserve it;
191
+ `scaled(s)` multiplies it by `s³`. Exposed solid references include the scale of
192
+ their actual geometry. Faces, edges, infinite references and groups have no volume
193
+ property.
194
+
195
+ ```ts
196
+ import {box, tube} from '@code3d/core';
197
+ const blockVolume = box(2, 3, 4).volume; // 24
198
+ const pipeVolume = tube(5, 3, 7).volume; // 112 * Math.PI
199
+ const enlargedVolume = box(2, 3, 4).scaled(2).volume; // 192
200
+ ```
201
+
202
+ Select `.volume` in App to inspect the whole solid with a volume label at its
203
+ volume centroid. This read-only display uses the getter's recorded result and
204
+ does not create an editable size constraint.
205
+ Try the [volume example](../../app/examples/operations/volume.ts).
206
+
207
+ ### Distance between references
208
+
88
209
  `distance(a, b, axis?)` returns a non-negative `number` from the models and
89
210
  relations available at the call. It accepts vertex, edge, face and solid models,
90
211
  non-empty groups, finite topology references, directional bounds, and point
@@ -47,6 +47,8 @@ Only an explicit origin operation chooses a different local zero.
47
47
  | `text` | All returned faces share the text layout's baseline origin, including its glyph advances and offsets. Individual letters are not centered. |
48
48
  | `definePrimitive` | Retain the frame used by the returned geometry; no automatic recentering. |
49
49
  | `extrude` | Inherit the input face's frame, without centering the extrusion. |
50
+ | `revolve` | Inherit the input profile's frame; the axis's solved placement is converted into that frame. |
51
+ | `sweep` | Inherit the input profile's frame; the spine's solved placement is converted into that frame. |
50
52
  | `union`, `intersect` | Inherit the first operand's frame. |
51
53
  | `cut` | Inherit the stock's frame. |
52
54
  | `loft` | Inherit the first section's frame. |
package/docs/runtime.md CHANGED
@@ -79,9 +79,9 @@ shows the selected inputs and ambient operands without inventing a result.
79
79
  These region inspectors use ordinary unlit materials with depth testing disabled,
80
80
  so their colors remain visible through the translucent inputs.
81
81
 
82
- Core uses this mechanism for distance measurements, relate calls and their
82
+ Core uses this mechanism for length/area/volume properties, distance measurements, relate calls and their
83
83
  closures, on/align references, relative transformation stages, group children,
84
- expose sources, Boolean operands and loft sections/spines, plus box and extrusion
84
+ expose sources, Boolean operands, loft sections/spines and sweep profiles/spines, plus box and extrusion
85
85
  dimensions. Selecting a normal constructor or Boolean function name
86
86
  still previews its return value. Inspectors retain the original operation frame,
87
87
  so a later relation or a different consumer cannot move its inspection.
@@ -108,6 +108,46 @@ the owner as `ambient` when the call fails or the reference collection is empty.
108
108
  The owner therefore has the same background appearance before and after a
109
109
  selection; missing or invalid IDs still produce their normal modeling errors.
110
110
 
111
+ ### Getter inspection
112
+
113
+ A getter can declare `@code3d.inspect callback` in its JSDoc. A package can put
114
+ the same annotation on its public `readonly` property declaration when the
115
+ implementation getter is not present in its declarations. The callback receives
116
+ `[]`, with the actual receiver in `context.receiver`, the recorded property value
117
+ in `context.return`, and any `captureInspectData` payload in `context.data`.
118
+
119
+ ```ts
120
+ import {
121
+ captureInspectData,
122
+ type InspectContext,
123
+ type Model,
124
+ } from '@code3d/core';
125
+
126
+ function inspectSize(_args: readonly [], context: InspectContext<number>) {
127
+ return {target: [context.data as Model]};
128
+ }
129
+ class Part {
130
+ constructor(readonly body: Model) {}
131
+ /** @code3d.inspect inspectSize */
132
+ get size() {
133
+ captureInspectData(this.body);
134
+ return 42;
135
+ }
136
+ }
137
+ ```
138
+
139
+ Read `part.size` normally to record it. Selecting the property runs only its
140
+ inspector; it never runs the getter again. Each reached read keeps its own
141
+ receiver, return and data, even if several reads return the same number. Local
142
+ getters retain their declaration's lexical inspector binding; published callbacks
143
+ must be runtime exports of the package. Optional reads that short-circuit do not
144
+ invoke an inspector. A failed getter can still inspect its captured data, with
145
+ `context.return` undefined. Merely enumerating an object never invokes its getters.
146
+
147
+ Core's `.length`, `.area` and `.volume` use this mechanism. Passive `dimension` annotations
148
+ accept either `start`/`end`, alternative `candidates`, or `at: [x, y, z]` to show
149
+ only a value at an owner's local position. Labels do not add CAD geometry.
150
+
111
151
  ### Call data
112
152
 
113
153
  The App runs JSDoc inspection callbacks when the corresponding source is selected.
package/docs/topology.md CHANGED
@@ -127,6 +127,12 @@ const corners = boundary[0].vertices();
127
127
  const center = face.center;
128
128
  ```
129
129
 
130
+ Edges expose readonly `.length` for actual arc length; surfaces expose `.area`
131
+ for their trimmed finite area. Exposed solids provide total boundary `.area` and
132
+ material `.volume`, excluding holes and cavities. These measurements include the
133
+ scale of the selected geometry. See [length and area](api.md#length-and-area)
134
+ and [volume](api.md#volume).
135
+
130
136
  Queries use the original model's IDs and stay within the selected element.
131
137
  `face.edge(id)` reports an error if that edge is outside the face. The viewport
132
138
  picker offers only the eligible edges or vertices, including when the geometry
package/docs/values.md CHANGED
@@ -63,6 +63,15 @@ finite geometry, nested occurrences, axis frames and source-order dependencies.
63
63
 
64
64
  ## Geometry measurements
65
65
 
66
+ Finite edges and edge models provide readonly `.length`; finite surfaces and
67
+ face models provide `.area`. Solids provide `.area` for their total boundary
68
+ surface, including inner walls, and `.volume` for material volume, excluding holes
69
+ and cavities. These are plain numbers, follow geometry scaling, and retain the
70
+ original value when later operations create a new model. Infinite axes/planes
71
+ and groups do not have these measurements. See [length and area](api.md#length-and-area)
72
+ and [volume](api.md#volume), and select these properties in App for a read-only
73
+ visual measurement.
74
+
66
75
  `model.bounds(relativeTo?)` returns readonly `minimum`, `maximum` and `size`
67
76
  XYZ vectors for tight finite geometry bounds. By default it uses the model's
68
77
  own local frame. An explicit reference includes solved placement and nested
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@code3d/core",
3
- "version": "0.0.1-alpha.11",
3
+ "version": "0.0.1-alpha.12",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -1,5 +1,14 @@
1
- import {getOC, Vector, type Shape3D} from 'replicad';
2
- import {castOwnedShape3D} from './kernel-shapes.js';
1
+ import {
2
+ assembleWire,
3
+ getOC,
4
+ makeHelix,
5
+ makeLine,
6
+ measureVolume,
7
+ Vector,
8
+ type Face,
9
+ type Shape3D,
10
+ } from 'replicad';
11
+ import {castOwnedShape3D, shapeSubshapes} from './kernel-shapes.js';
3
12
  import {
4
13
  transferShapeTopology,
5
14
  type TopologyInput,
@@ -36,3 +45,121 @@ export function extrudeWithTopology(
36
45
  vector.delete();
37
46
  }
38
47
  }
48
+
49
+ /** Sweep a face around a directed axis, optionally advancing along it. */
50
+ export function revolveWithTopology(
51
+ source: TopologyInput,
52
+ center: Vec3,
53
+ direction: Vec3,
54
+ angle: number,
55
+ advance: number,
56
+ ): {shape: Shape3D; topology?: ShapeTopology} {
57
+ const oc = getOC();
58
+ if (advance === 0) {
59
+ const point = new oc.gp_Pnt(...center);
60
+ let dir: InstanceType<typeof oc.gp_Dir> | undefined;
61
+ let axis: InstanceType<typeof oc.gp_Ax1> | undefined;
62
+ let shape: Shape3D | undefined;
63
+ try {
64
+ dir = new oc.gp_Dir(...direction);
65
+ axis = new oc.gp_Ax1(point, dir);
66
+ const builder = new oc.BRepPrimAPI_MakeRevol(
67
+ source.shape.wrapped,
68
+ axis,
69
+ angle * (Math.PI / 180),
70
+ false,
71
+ );
72
+ try {
73
+ if (!builder.IsDone()) throw new Error('Could not revolve the face.');
74
+ shape = castOwnedShape3D(builder.Shape());
75
+ requireRevolvedVolume(shape);
76
+ return {
77
+ shape,
78
+ topology: transferShapeTopology([source], shape, builder),
79
+ };
80
+ } finally {
81
+ builder.delete();
82
+ }
83
+ } catch (error) {
84
+ shape?.delete();
85
+ throw error;
86
+ } finally {
87
+ axis?.delete();
88
+ dir?.delete();
89
+ point.delete();
90
+ }
91
+ }
92
+
93
+ const boundaries = shapeSubshapes(source.shape, 'wire');
94
+ try {
95
+ if (boundaries.length !== 1)
96
+ throw new Error('Helical revolution requires a face without holes.');
97
+ } finally {
98
+ boundaries.forEach(boundary => boundary.delete());
99
+ }
100
+ const end: Vec3 = [
101
+ center[0] + direction[0] * advance,
102
+ center[1] + direction[1] * advance,
103
+ center[2] + direction[2] * advance,
104
+ ];
105
+ let startVector: Vector | undefined;
106
+ let endVector: Vector | undefined;
107
+ let line: ReturnType<typeof makeLine> | undefined;
108
+ let spine: ReturnType<typeof assembleWire> | undefined;
109
+ let guide: ReturnType<typeof makeHelix> | undefined;
110
+ let wire: ReturnType<Face['outerWire']> | undefined;
111
+ let shape: Shape3D | undefined;
112
+ try {
113
+ startVector = new Vector([...center]);
114
+ endVector = new Vector([...end]);
115
+ line = makeLine(startVector, endVector);
116
+ spine = assembleWire([line]);
117
+ guide = makeHelix(
118
+ (advance * 360) / angle,
119
+ advance,
120
+ 1,
121
+ [...center],
122
+ [...direction],
123
+ );
124
+ // Replicad's outerWire consumes its cloned Face wrapper.
125
+ wire = (source.shape as Face).clone().outerWire();
126
+ const builder = new oc.BRepOffsetAPI_MakePipeShell(spine.wrapped);
127
+ try {
128
+ builder.SetMode(
129
+ guide.wrapped,
130
+ false,
131
+ oc.BRepFill_TypeOfContact.BRepFill_NoContact,
132
+ );
133
+ builder.Add(wire.wrapped, false, false);
134
+ if (!builder.IsReady())
135
+ throw new Error(
136
+ 'Could not associate the profile with the rotation axis.',
137
+ );
138
+ builder.Build();
139
+ if (!builder.IsDone() || !builder.MakeSolid())
140
+ throw new Error('Could not construct a solid helical revolution.');
141
+ shape = castOwnedShape3D(builder.Shape());
142
+ requireRevolvedVolume(shape);
143
+ // PipeShell does not expose face-cap history for an input face. Assign
144
+ // stable output IDs without guessing an inherited input correspondence.
145
+ return {shape};
146
+ } finally {
147
+ builder.delete();
148
+ }
149
+ } catch (error) {
150
+ shape?.delete();
151
+ throw error;
152
+ } finally {
153
+ wire?.delete();
154
+ guide?.delete();
155
+ spine?.delete();
156
+ line?.delete();
157
+ endVector?.delete();
158
+ startVector?.delete();
159
+ }
160
+ }
161
+
162
+ function requireRevolvedVolume(shape: Shape3D): void {
163
+ if (!(Math.abs(measureVolume(shape)) > 0))
164
+ throw new Error('Revolution did not produce a non-degenerate solid.');
165
+ }
@@ -13,6 +13,9 @@ export {
13
13
  align,
14
14
  expose,
15
15
  inspectTopologyReference,
16
+ inspectLength,
17
+ inspectArea,
18
+ inspectVolume,
16
19
  } from './runtime.js';
17
20
  export type {
18
21
  Dimension,
@@ -71,7 +74,9 @@ export {
71
74
  rectangle,
72
75
  regularPolygon,
73
76
  regularPrism,
77
+ revolve,
74
78
  spline,
79
+ sweep,
75
80
  sphere,
76
81
  tube,
77
82
  union,
@@ -117,6 +122,7 @@ export type {
117
122
  NamedElements,
118
123
  PlanarElements,
119
124
  PointAnchor,
125
+ RevolveConfig,
120
126
  Solid,
121
127
  SolidModel,
122
128
  SolidModificationCapabilities,
@@ -49,14 +49,16 @@ export function recordInspectionCalls(
49
49
  export type DimensionSegment = Readonly<{start: Vec3; end: Vec3}>;
50
50
 
51
51
  type DimensionLines =
52
- DimensionSegment | Readonly<{candidates: readonly DimensionSegment[]}>;
52
+ | DimensionSegment
53
+ | Readonly<{candidates: readonly DimensionSegment[]}>
54
+ | Readonly<{at: Vec3}>;
53
55
  type DimensionOptions = Readonly<{
54
56
  owner: Model;
55
57
  value: number;
56
58
  axisLabel?: string;
57
59
  }>;
58
60
 
59
- /** A passive dimension; a candidate is selected on entry and retained while inspecting it. */
61
+ /** A passive measurement. `at` labels a point without a dimension line. */
60
62
  export type Dimension = Readonly<{kind: 'dimension'}> &
61
63
  DimensionOptions &
62
64
  DimensionLines;
@@ -1,7 +1,10 @@
1
1
  import {
2
+ assembleWire,
2
3
  cast,
3
4
  getOC,
5
+ measureVolume,
4
6
  type AnyShape,
7
+ type Edge,
5
8
  type Face,
6
9
  type Wire,
7
10
  type Shape3D,
@@ -19,6 +22,66 @@ import {
19
22
  type TopologyInput,
20
23
  type ShapeTopology,
21
24
  } from './topology.js';
25
+ import type {Vec3} from './spatial.js';
26
+
27
+ /** Sweep a face along one open edge, keeping the authored starting frame. */
28
+ export function sweepWithTopology(
29
+ profile: TopologyInput,
30
+ path: Edge,
31
+ normal: Vec3,
32
+ ): Readonly<{shape: Shape3D; topology: ShapeTopology}> {
33
+ if (path.isClosed || !(path.length > 1e-9))
34
+ throw new Error('Sweep spine must be a non-degenerate open curve.');
35
+ const start = path.pointAt(0);
36
+ let tangent: ReturnType<Edge['tangentAt']> | undefined;
37
+ try {
38
+ tangent = path.tangentAt(0);
39
+ const position = start.toTuple();
40
+ const direction = tangent.toTuple();
41
+ const magnitude = Math.hypot(...direction);
42
+ if (!(magnitude > 1e-9))
43
+ throw new Error('Sweep spine must have a non-zero starting tangent.');
44
+ if (Math.hypot(...position) > 1e-6)
45
+ throw new Error(
46
+ 'Sweep profile origin must coincide with the spine start.',
47
+ );
48
+ if (
49
+ (normal[0] * direction[0] +
50
+ normal[1] * direction[1] +
51
+ normal[2] * direction[2]) /
52
+ magnitude <
53
+ 1 - 1e-6
54
+ )
55
+ throw new Error(
56
+ 'Sweep profile normal must point along the spine start tangent.',
57
+ );
58
+ } finally {
59
+ tangent?.delete();
60
+ start.delete();
61
+ }
62
+ const spine = assembleWire([path]);
63
+ let result: Readonly<{shape: Shape3D; topology: ShapeTopology}> | undefined;
64
+ try {
65
+ result = loftWithTopology([profile], spine, false);
66
+ if (!(Math.abs(measureVolume(result.shape)) > 0))
67
+ throw new Error('Sweep did not produce a non-degenerate solid.');
68
+ const validation = new (getOC().BRepCheck_Analyzer)(result.shape.wrapped);
69
+ try {
70
+ if (!validation.IsValid())
71
+ throw new Error(
72
+ 'Sweep produced an invalid solid. Adjust the profile or path to avoid tight bends and intersections.',
73
+ );
74
+ } finally {
75
+ validation.delete();
76
+ }
77
+ return result;
78
+ } catch (error) {
79
+ result?.shape.delete();
80
+ throw error;
81
+ } finally {
82
+ spine.delete();
83
+ }
84
+ }
22
85
 
23
86
  export function loftWithTopology(
24
87
  sections: readonly TopologyInput[],
@@ -48,7 +111,7 @@ export function loftWithTopology(
48
111
  throw new Error('Loft sections must have matching hole counts.');
49
112
  if (holes[0].length > 1)
50
113
  throw new Error(
51
- 'Loft currently supports at most one hole per section; multiple holes need explicit correspondence.',
114
+ 'A swept or lofted profile currently supports at most one hole; multiple holes need explicit correspondence.',
52
115
  );
53
116
  outer = loftContoursWithTopology(sections, spine, ruled);
54
117
  if (!holes[0].length) {
@@ -115,7 +178,9 @@ function loftContoursWithTopology(
115
178
  }
116
179
  result = castOwnedShape3D(builder.Shape());
117
180
  caps[0] = castOwnedShape(builder.FirstShape()) as Face;
118
- caps[sections.length - 1] = castOwnedShape(builder.LastShape()) as Face;
181
+ // A single profile has one inherited start cap; its end cap is new topology.
182
+ if (sections.length > 1)
183
+ caps[sections.length - 1] = castOwnedShape(builder.LastShape()) as Face;
119
184
  const topology = transferShapeTopology(
120
185
  sections,
121
186
  result,