fluidcad 0.0.36 → 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.
Files changed (188) hide show
  1. package/LICENSE.txt +21 -504
  2. package/README.md +1 -1
  3. package/lib/dist/common/edge.d.ts +1 -1
  4. package/lib/dist/common/face.d.ts +1 -1
  5. package/lib/dist/common/scene-object.d.ts +6 -0
  6. package/lib/dist/common/scene-object.js +8 -0
  7. package/lib/dist/common/shape-factory.d.ts +1 -1
  8. package/lib/dist/common/shape-history-tracker.d.ts +1 -1
  9. package/lib/dist/common/shape.d.ts +1 -1
  10. package/lib/dist/common/solid.d.ts +1 -1
  11. package/lib/dist/common/transformable-primitive.d.ts +12 -1
  12. package/lib/dist/common/transformable-primitive.js +27 -0
  13. package/lib/dist/common/vertex.d.ts +1 -1
  14. package/lib/dist/common/wire.d.ts +1 -1
  15. package/lib/dist/core/2d/index.d.ts +1 -0
  16. package/lib/dist/core/2d/index.js +1 -0
  17. package/lib/dist/core/2d/text.d.ts +30 -0
  18. package/lib/dist/core/2d/text.js +37 -0
  19. package/lib/dist/core/helix.d.ts +20 -0
  20. package/lib/dist/core/helix.js +36 -0
  21. package/lib/dist/core/index.d.ts +3 -1
  22. package/lib/dist/core/index.js +2 -0
  23. package/lib/dist/core/interfaces.d.ts +180 -0
  24. package/lib/dist/core/plane.d.ts +26 -6
  25. package/lib/dist/core/plane.js +21 -44
  26. package/lib/dist/core/wrap.d.ts +17 -0
  27. package/lib/dist/core/wrap.js +39 -0
  28. package/lib/dist/features/2d/offset.js +2 -2
  29. package/lib/dist/features/2d/text.d.ts +67 -0
  30. package/lib/dist/features/2d/text.js +320 -0
  31. package/lib/dist/features/cylinder.d.ts +3 -1
  32. package/lib/dist/features/cylinder.js +5 -2
  33. package/lib/dist/features/extrude-base.d.ts +1 -0
  34. package/lib/dist/features/extrude-to-face.d.ts +1 -0
  35. package/lib/dist/features/extrude-to-face.js +6 -0
  36. package/lib/dist/features/fillet.d.ts +1 -1
  37. package/lib/dist/features/helix.d.ts +41 -0
  38. package/lib/dist/features/helix.js +337 -0
  39. package/lib/dist/features/plane-from-object.d.ts +16 -4
  40. package/lib/dist/features/plane-from-object.js +101 -8
  41. package/lib/dist/features/select.js +32 -8
  42. package/lib/dist/features/simple-extruder.d.ts +1 -1
  43. package/lib/dist/features/simple-extruder.js +7 -2
  44. package/lib/dist/features/sphere.d.ts +3 -1
  45. package/lib/dist/features/sphere.js +5 -2
  46. package/lib/dist/features/sweep.js +7 -2
  47. package/lib/dist/features/wrap.d.ts +39 -0
  48. package/lib/dist/features/wrap.js +116 -0
  49. package/lib/dist/filters/edge/belongs-to-face.d.ts +3 -1
  50. package/lib/dist/filters/edge/belongs-to-face.js +14 -10
  51. package/lib/dist/filters/filter.d.ts +1 -1
  52. package/lib/dist/filters/from-object.d.ts +1 -1
  53. package/lib/dist/filters/tangent-expander.d.ts +1 -1
  54. package/lib/dist/filters/tangent-expander.js +57 -40
  55. package/lib/dist/helpers/scene-helpers.d.ts +2 -0
  56. package/lib/dist/helpers/scene-helpers.js +1 -1
  57. package/lib/dist/index.d.ts +2 -0
  58. package/lib/dist/index.js +3 -1
  59. package/lib/dist/io/file-import.d.ts +7 -0
  60. package/lib/dist/io/file-import.js +28 -1
  61. package/lib/dist/io/font-registry.d.ts +45 -0
  62. package/lib/dist/io/font-registry.js +272 -0
  63. package/lib/dist/math/bspline-interpolation.d.ts +29 -0
  64. package/lib/dist/math/bspline-interpolation.js +194 -0
  65. package/lib/dist/oc/boolean-ops.d.ts +3 -1
  66. package/lib/dist/oc/boolean-ops.js +15 -1
  67. package/lib/dist/oc/color-transfer.d.ts +1 -1
  68. package/lib/dist/oc/constraints/constraint-helpers.d.ts +4 -4
  69. package/lib/dist/oc/constraints/curve/tangent-circle-solver.js +10 -9
  70. package/lib/dist/oc/constraints/curve/tangent-line-solver.js +5 -6
  71. package/lib/dist/oc/convert.d.ts +1 -1
  72. package/lib/dist/oc/draft-ops.d.ts +1 -1
  73. package/lib/dist/oc/edge-ops.d.ts +2 -2
  74. package/lib/dist/oc/edge-ops.js +13 -14
  75. package/lib/dist/oc/edge-props.d.ts +1 -1
  76. package/lib/dist/oc/edge-query.d.ts +1 -1
  77. package/lib/dist/oc/edge-query.js +3 -8
  78. package/lib/dist/oc/errors.d.ts +8 -0
  79. package/lib/dist/oc/errors.js +27 -0
  80. package/lib/dist/oc/explorer.d.ts +2 -2
  81. package/lib/dist/oc/extrude-ops.d.ts +28 -2
  82. package/lib/dist/oc/extrude-ops.js +56 -7
  83. package/lib/dist/oc/face-ops.d.ts +2 -1
  84. package/lib/dist/oc/face-ops.js +11 -0
  85. package/lib/dist/oc/face-props.d.ts +1 -1
  86. package/lib/dist/oc/face-query.d.ts +12 -1
  87. package/lib/dist/oc/face-query.js +39 -0
  88. package/lib/dist/oc/fillet-ops.d.ts +1 -1
  89. package/lib/dist/oc/fillet-ops.js +4 -4
  90. package/lib/dist/oc/geometry.d.ts +1 -1
  91. package/lib/dist/oc/geometry.js +12 -14
  92. package/lib/dist/oc/helix-ops.d.ts +37 -0
  93. package/lib/dist/oc/helix-ops.js +88 -0
  94. package/lib/dist/oc/hit-test.d.ts +1 -1
  95. package/lib/dist/oc/index.d.ts +4 -0
  96. package/lib/dist/oc/index.js +2 -0
  97. package/lib/dist/oc/init.d.ts +1 -1
  98. package/lib/dist/oc/init.js +1 -1
  99. package/lib/dist/oc/intersection.js +1 -1
  100. package/lib/dist/oc/io.d.ts +6 -6
  101. package/lib/dist/oc/io.js +31 -24
  102. package/lib/dist/oc/measure/classify.d.ts +34 -0
  103. package/lib/dist/oc/measure/classify.js +246 -0
  104. package/lib/dist/oc/measure/measure-ops.d.ts +9 -0
  105. package/lib/dist/oc/measure/measure-ops.js +210 -0
  106. package/lib/dist/oc/measure/measure-types.d.ts +39 -0
  107. package/lib/dist/oc/measure/measure-types.js +1 -0
  108. package/lib/dist/oc/measure/sampling.d.ts +9 -0
  109. package/lib/dist/oc/measure/sampling.js +77 -0
  110. package/lib/dist/oc/measure/vec.d.ts +13 -0
  111. package/lib/dist/oc/measure/vec.js +23 -0
  112. package/lib/dist/oc/mesh.d.ts +1 -1
  113. package/lib/dist/oc/mesh.js +40 -28
  114. package/lib/dist/oc/path-sampler.d.ts +29 -0
  115. package/lib/dist/oc/path-sampler.js +63 -0
  116. package/lib/dist/oc/props.d.ts +1 -1
  117. package/lib/dist/oc/props.js +4 -1
  118. package/lib/dist/oc/shape-hash.d.ts +26 -0
  119. package/lib/dist/oc/shape-hash.js +32 -0
  120. package/lib/dist/oc/shape-ops.d.ts +5 -3
  121. package/lib/dist/oc/shape-ops.js +6 -5
  122. package/lib/dist/oc/sweep-ops.d.ts +13 -1
  123. package/lib/dist/oc/sweep-ops.js +174 -18
  124. package/lib/dist/oc/text-outline.d.ts +62 -0
  125. package/lib/dist/oc/text-outline.js +212 -0
  126. package/lib/dist/oc/thin-face-maker.d.ts +0 -19
  127. package/lib/dist/oc/thin-face-maker.js +3 -68
  128. package/lib/dist/oc/topology-index.d.ts +1 -1
  129. package/lib/dist/oc/vertex-ops.d.ts +1 -1
  130. package/lib/dist/oc/wire-ops.d.ts +18 -3
  131. package/lib/dist/oc/wire-ops.js +56 -5
  132. package/lib/dist/oc/wrap-development.d.ts +105 -0
  133. package/lib/dist/oc/wrap-development.js +179 -0
  134. package/lib/dist/oc/wrap-ops.d.ts +100 -0
  135. package/lib/dist/oc/wrap-ops.js +406 -0
  136. package/lib/dist/rendering/render-solid.js +10 -2
  137. package/lib/dist/scene-manager.d.ts +2 -0
  138. package/lib/dist/scene-manager.js +29 -0
  139. package/lib/dist/tests/features/2d/offset.test.js +74 -1
  140. package/lib/dist/tests/features/cylinder-curve-filter.test.js +3 -3
  141. package/lib/dist/tests/features/extrude-to-face.test.js +38 -1
  142. package/lib/dist/tests/features/helix.test.d.ts +1 -0
  143. package/lib/dist/tests/features/helix.test.js +295 -0
  144. package/lib/dist/tests/features/plane.test.js +95 -0
  145. package/lib/dist/tests/features/repeat-primitive.test.d.ts +1 -0
  146. package/lib/dist/tests/features/repeat-primitive.test.js +60 -0
  147. package/lib/dist/tests/features/rib.test.js +6 -1
  148. package/lib/dist/tests/features/sweep.test.js +170 -1
  149. package/lib/dist/tests/features/text.test.d.ts +1 -0
  150. package/lib/dist/tests/features/text.test.js +347 -0
  151. package/lib/dist/tests/features/wrap-development.test.d.ts +1 -0
  152. package/lib/dist/tests/features/wrap-development.test.js +130 -0
  153. package/lib/dist/tests/features/wrap-extruded-target.test.d.ts +1 -0
  154. package/lib/dist/tests/features/wrap-extruded-target.test.js +106 -0
  155. package/lib/dist/tests/features/wrap-repeat.test.d.ts +1 -0
  156. package/lib/dist/tests/features/wrap-repeat.test.js +93 -0
  157. package/lib/dist/tests/features/wrap.test.d.ts +1 -0
  158. package/lib/dist/tests/features/wrap.test.js +331 -0
  159. package/lib/dist/tests/math/bspline-interpolation.test.d.ts +1 -0
  160. package/lib/dist/tests/math/bspline-interpolation.test.js +119 -0
  161. package/lib/dist/tests/measure.test.d.ts +1 -0
  162. package/lib/dist/tests/measure.test.js +288 -0
  163. package/lib/dist/tsconfig.tsbuildinfo +1 -1
  164. package/llm-docs/api/helix.md +64 -0
  165. package/llm-docs/api/index.json +11 -2
  166. package/llm-docs/api/text.md +52 -0
  167. package/llm-docs/api/types/helix.md +105 -0
  168. package/llm-docs/api/types/text.md +138 -0
  169. package/llm-docs/api/types/wrap.md +131 -0
  170. package/llm-docs/api/wrap.md +62 -0
  171. package/llm-docs/index.json +121 -1
  172. package/mcp/dist/server.js +20 -1
  173. package/mcp/dist/tools/inspection.d.ts +17 -0
  174. package/mcp/dist/tools/inspection.js +14 -0
  175. package/package.json +7 -3
  176. package/server/dist/fluidcad-server.d.ts +11 -1
  177. package/server/dist/fluidcad-server.js +21 -1
  178. package/server/dist/index.js +4 -2
  179. package/server/dist/preferences.d.ts +4 -0
  180. package/server/dist/preferences.js +2 -0
  181. package/server/dist/routes/measure.d.ts +3 -0
  182. package/server/dist/routes/measure.js +32 -0
  183. package/server/dist/routes/params.js +1 -1
  184. package/server/dist/routes/preferences.js +6 -0
  185. package/server/dist/routes/sketch-edits.js +2 -1
  186. package/ui/dist/assets/{index-MRqwG9Vh.js → index-D8zV21wB.js} +149 -102
  187. package/ui/dist/assets/{index-CDJmUpFI.css → index-dAFdg2Un.css} +1 -1
  188. package/ui/dist/index.html +2 -2
@@ -0,0 +1,337 @@
1
+ import { SceneObject } from "../common/scene-object.js";
2
+ import { BuildError } from "../common/build-error.js";
3
+ import { requireShapes } from "../common/operand-check.js";
4
+ import { AxisObjectBase } from "./axis-renderable-base.js";
5
+ import { HelixOps } from "../oc/helix-ops.js";
6
+ import { FaceQuery } from "../oc/face-query.js";
7
+ import { EdgeQuery } from "../oc/edge-query.js";
8
+ import { EdgeOps } from "../oc/edge-ops.js";
9
+ import { Convert } from "../oc/convert.js";
10
+ import { CoordinateSystem } from "../math/coordinate-system.js";
11
+ import { Vector3d } from "../math/vector3d.js";
12
+ import { Axis } from "../math/axis.js";
13
+ const DEFAULT_RADIUS = 20;
14
+ const DEFAULT_HEIGHT = 50;
15
+ const DEFAULT_TURNS = 1;
16
+ const EPS = 1e-7;
17
+ const TANGENCY_BREAK_EPSILON = 1e-6;
18
+ export class Helix extends SceneObject {
19
+ source;
20
+ _pitch;
21
+ _turns;
22
+ _startOffset = 0;
23
+ _endOffset = 0;
24
+ _height;
25
+ _radius;
26
+ _endRadius;
27
+ constructor(source) {
28
+ super();
29
+ this.source = source;
30
+ }
31
+ pitch(value) {
32
+ this._pitch = value;
33
+ return this;
34
+ }
35
+ turns(value) {
36
+ this._turns = value;
37
+ return this;
38
+ }
39
+ startOffset(value) {
40
+ this._startOffset = value;
41
+ return this;
42
+ }
43
+ endOffset(value) {
44
+ this._endOffset = value;
45
+ return this;
46
+ }
47
+ height(value) {
48
+ this._height = value;
49
+ return this;
50
+ }
51
+ radius(value) {
52
+ this._radius = value;
53
+ return this;
54
+ }
55
+ endRadius(value) {
56
+ this._endRadius = value;
57
+ return this;
58
+ }
59
+ validate() {
60
+ if (this.source instanceof AxisObjectBase) {
61
+ return;
62
+ }
63
+ requireShapes(this.source, "source", "helix");
64
+ }
65
+ build(_context) {
66
+ const resolved = this.resolveSource();
67
+ let startRadius;
68
+ let endRadius;
69
+ let cs;
70
+ let zStart;
71
+ let zEnd;
72
+ let offsetsAlreadyApplied = false;
73
+ switch (resolved.kind) {
74
+ case 'axis': {
75
+ cs = HelixOps_csFromAxis(resolved.axis);
76
+ startRadius = this._radius ?? DEFAULT_RADIUS;
77
+ endRadius = this._endRadius ?? startRadius;
78
+ const { height } = resolveAxisHeightAndPitch(this._height, this._pitch, this._turns);
79
+ zStart = 0;
80
+ zEnd = height;
81
+ break;
82
+ }
83
+ case 'cylinder-face': {
84
+ if (this._endRadius !== undefined && this._endRadius !== (this._radius ?? resolved.radius)) {
85
+ console.warn("helix: .endRadius() is ignored when source is a cylindrical face — for a tapered helix, use a conical face or axis input.");
86
+ }
87
+ cs = resolved.cs;
88
+ // Nudge inward by TANGENCY_BREAK_EPSILON when falling back to the
89
+ // face's natural radius. A helix exactly on the cylinder's surface
90
+ // produces a swept tube that's tangent to the cylinder along helical
91
+ // curves, and OCC's BOPAlgo (BRepAlgoAPI_Fuse/Cut) silently fails on
92
+ // tangent contact along curves — fuse returns the inputs as a
93
+ // compound, cut is a no-op. The 1e-6mm nudge is sub-nanometer
94
+ // (visually identical) but produces transversal intersections that
95
+ // BOPAlgo handles cleanly. Sweep also passes skipSimplify=true to
96
+ // avoid SimplifyResult/UnifySameDomain hanging on the resulting
97
+ // tangent-curve topology.
98
+ startRadius = this._radius ?? (resolved.radius - TANGENCY_BREAK_EPSILON);
99
+ endRadius = startRadius;
100
+ if (this._height !== undefined) {
101
+ zStart = 0;
102
+ zEnd = this._height;
103
+ }
104
+ else {
105
+ zStart = resolved.vMin;
106
+ zEnd = resolved.vMax;
107
+ }
108
+ break;
109
+ }
110
+ case 'cone-face': {
111
+ if (this._radius !== undefined || this._endRadius !== undefined) {
112
+ console.warn("helix: .radius()/.endRadius() are ignored when source is a conical face — radii are derived from the face geometry.");
113
+ }
114
+ cs = resolved.cs;
115
+ const cosA = Math.cos(resolved.semiAngle);
116
+ const sinA = Math.sin(resolved.semiAngle);
117
+ const zMinFace = resolved.vMin * cosA;
118
+ const zMaxFace = resolved.vMax * cosA;
119
+ const zLow = Math.min(zMinFace, zMaxFace);
120
+ const zHigh = Math.max(zMinFace, zMaxFace);
121
+ if (this._height !== undefined) {
122
+ zStart = zLow;
123
+ zEnd = zLow + this._height;
124
+ }
125
+ else {
126
+ zStart = zLow;
127
+ zEnd = zHigh;
128
+ }
129
+ // Apply offsets here so the radii follow the cone's surface at the
130
+ // extended positions (offsets extend along the cone's natural taper,
131
+ // not as a cylindrical extension).
132
+ zStart += this._startOffset;
133
+ zEnd += this._endOffset;
134
+ startRadius = resolved.refRadius + (zStart / cosA) * sinA;
135
+ endRadius = resolved.refRadius + (zEnd / cosA) * sinA;
136
+ offsetsAlreadyApplied = true;
137
+ break;
138
+ }
139
+ case 'line-edge': {
140
+ cs = HelixOps_csFromAxis(resolved.axis);
141
+ startRadius = this._radius ?? DEFAULT_RADIUS;
142
+ endRadius = this._endRadius ?? startRadius;
143
+ zStart = 0;
144
+ zEnd = this._height ?? resolved.length;
145
+ break;
146
+ }
147
+ case 'circle-edge': {
148
+ if (this._endRadius !== undefined) {
149
+ console.warn("helix: .endRadius() is ignored when source is a circular edge — both radii equal the circle radius.");
150
+ }
151
+ cs = resolved.cs;
152
+ startRadius = this._radius ?? resolved.radius;
153
+ endRadius = startRadius;
154
+ zStart = 0;
155
+ zEnd = this._height ?? DEFAULT_HEIGHT;
156
+ break;
157
+ }
158
+ }
159
+ if (!offsetsAlreadyApplied) {
160
+ zStart += this._startOffset;
161
+ zEnd += this._endOffset;
162
+ }
163
+ if (this._pitch !== undefined && Math.abs(this._pitch) < EPS) {
164
+ throw new BuildError(`helix: .pitch() must be non-zero.`);
165
+ }
166
+ if (this._turns !== undefined && this._turns <= 0) {
167
+ throw new BuildError(`helix: .turns() must be > 0, got ${this._turns}.`, `Pass a positive number to .turns().`);
168
+ }
169
+ const turns = this._turns ?? this.deriveTurnsFromHeight(zEnd - zStart);
170
+ if (!Number.isFinite(turns) || turns <= 0) {
171
+ throw new BuildError(`helix: turns must be > 0, got ${turns}.`, `Pass a positive number to .turns() or .pitch().`);
172
+ }
173
+ if (Math.abs(zEnd - zStart) < EPS) {
174
+ throw new BuildError(`helix: resulting axial height is zero (zStart=${zStart}, zEnd=${zEnd}).`, `Check .startOffset()/.endOffset()/.height() values.`);
175
+ }
176
+ if (startRadius <= 0) {
177
+ throw new BuildError(`helix: start radius must be > 0, got ${startRadius}.`);
178
+ }
179
+ if (endRadius <= 0) {
180
+ throw new BuildError(`helix: end radius would be ≤ 0 (got ${endRadius}). For a conical helix, the end radius must stay positive.`, `Reduce .endOffset() or use a smaller turns/height combination.`);
181
+ }
182
+ const edge = HelixOps.makeHelix(cs, startRadius, endRadius, zStart, zEnd, turns);
183
+ this.addShape(edge);
184
+ this.source.removeShapes(this);
185
+ }
186
+ deriveTurnsFromHeight(height) {
187
+ if (this._pitch === undefined) {
188
+ return DEFAULT_TURNS;
189
+ }
190
+ if (Math.abs(this._pitch) < EPS) {
191
+ throw new BuildError(`helix: .pitch() must be non-zero.`);
192
+ }
193
+ return Math.abs(height / this._pitch);
194
+ }
195
+ resolveSource() {
196
+ if (this.source instanceof AxisObjectBase) {
197
+ return { kind: 'axis', axis: this.source.getAxis() };
198
+ }
199
+ const shapes = this.source.getShapes({ excludeGuide: false });
200
+ if (shapes.length !== 1) {
201
+ throw new BuildError(`helix: source must contain exactly one shape (got ${shapes.length}).`, `Wrap multi-shape sources in select(...) to pick a single face or edge.`);
202
+ }
203
+ const shape = shapes[0];
204
+ if (shape.isFace()) {
205
+ return this.resolveFace(shape);
206
+ }
207
+ if (shape.isEdge()) {
208
+ return this.resolveEdge(shape);
209
+ }
210
+ throw new BuildError(`helix: source shape must be a face or edge, got '${shape.getType()}'.`);
211
+ }
212
+ resolveFace(face) {
213
+ const surfaceType = FaceQuery.getSurfaceType(face);
214
+ if (surfaceType === 'cylinder') {
215
+ const cylinder = FaceQuery.getSurfaceAdaptorCylinderRaw(face.getShape());
216
+ const ax3 = cylinder.Position();
217
+ const cs = Convert.toCoordinateSystemFromGpAx3(ax3, true);
218
+ const radius = cylinder.Radius();
219
+ cylinder.delete();
220
+ const { vMin, vMax } = FaceQuery.getSurfaceVBoundsRaw(face.getShape());
221
+ const canon = canonicalizeAxialBounds(cs, vMin, vMax);
222
+ return { kind: 'cylinder-face', cs: canon.cs, radius, vMin: canon.vMin, vMax: canon.vMax };
223
+ }
224
+ if (surfaceType === 'cone') {
225
+ const cone = FaceQuery.getSurfaceAdaptorConeRaw(face.getShape());
226
+ const ax3 = cone.Position();
227
+ const cs = Convert.toCoordinateSystemFromGpAx3(ax3, true);
228
+ const semiAngle = cone.SemiAngle();
229
+ const refRadius = cone.RefRadius();
230
+ cone.delete();
231
+ const { vMin, vMax } = FaceQuery.getSurfaceVBoundsRaw(face.getShape());
232
+ // For a cone, the V-axis lies along the slant; canonicalizing the
233
+ // CS direction here doesn't simplify the math the same way it does for
234
+ // a cylinder, so leave the cone's frame alone.
235
+ return { kind: 'cone-face', cs, semiAngle, refRadius, vMin, vMax };
236
+ }
237
+ throw new BuildError(`helix: face must be cylindrical or conical (got '${surfaceType}').`);
238
+ }
239
+ resolveEdge(edge) {
240
+ const curveType = EdgeQuery.getEdgeCurveType(edge);
241
+ if (curveType === 'line') {
242
+ const axis = EdgeOps.edgeToAxis(edge);
243
+ const params = EdgeQuery.getEdgeCurveParams(edge);
244
+ const length = Math.abs(params.last - params.first);
245
+ return { kind: 'line-edge', axis, length };
246
+ }
247
+ if (curveType === 'circle') {
248
+ const data = EdgeQuery.getCircleDataFromEdge(edge);
249
+ const axis = new Axis(data.center, data.axisDirection);
250
+ const cs = HelixOps_csFromAxis(axis);
251
+ return { kind: 'circle-edge', cs, radius: data.radius };
252
+ }
253
+ throw new BuildError(`helix: edge must be a line or circle (got '${curveType}').`);
254
+ }
255
+ getType() {
256
+ return 'helix';
257
+ }
258
+ getDependencies() {
259
+ return [this.source];
260
+ }
261
+ createCopy(remap) {
262
+ const newSource = (remap.get(this.source) ?? this.source);
263
+ const copy = new Helix(newSource);
264
+ copy._pitch = this._pitch;
265
+ copy._turns = this._turns;
266
+ copy._startOffset = this._startOffset;
267
+ copy._endOffset = this._endOffset;
268
+ copy._height = this._height;
269
+ copy._radius = this._radius;
270
+ copy._endRadius = this._endRadius;
271
+ return copy;
272
+ }
273
+ compareTo(other) {
274
+ if (!(other instanceof Helix)) {
275
+ return false;
276
+ }
277
+ if (!super.compareTo(other)) {
278
+ return false;
279
+ }
280
+ if (!this.source.compareTo(other.source)) {
281
+ return false;
282
+ }
283
+ return this._pitch === other._pitch
284
+ && this._turns === other._turns
285
+ && this._startOffset === other._startOffset
286
+ && this._endOffset === other._endOffset
287
+ && this._height === other._height
288
+ && this._radius === other._radius
289
+ && this._endRadius === other._endRadius;
290
+ }
291
+ serialize() {
292
+ return {
293
+ source: this.source.serialize(),
294
+ pitch: this._pitch,
295
+ turns: this._turns,
296
+ startOffset: this._startOffset,
297
+ endOffset: this._endOffset,
298
+ height: this._height,
299
+ radius: this._radius,
300
+ endRadius: this._endRadius,
301
+ };
302
+ }
303
+ }
304
+ function resolveAxisHeightAndPitch(height, pitch, turns) {
305
+ if (height !== undefined) {
306
+ return { height };
307
+ }
308
+ if (pitch !== undefined && turns !== undefined) {
309
+ return { height: Math.abs(pitch * turns) };
310
+ }
311
+ if (pitch !== undefined) {
312
+ return { height: Math.abs(pitch * DEFAULT_TURNS) };
313
+ }
314
+ return { height: DEFAULT_HEIGHT };
315
+ }
316
+ function HelixOps_csFromAxis(axis) {
317
+ const dir = axis.direction.normalize();
318
+ const seed = Math.abs(dir.z) < 0.9 ? Vector3d.unitZ() : Vector3d.unitX();
319
+ const xDir = seed.cross(dir).normalize();
320
+ return new CoordinateSystem(axis.origin, dir, xDir);
321
+ }
322
+ /**
323
+ * A cylindrical face's `Position()` Ax3 may have its main direction pointing
324
+ * "into" the face's V-extent rather than "out of it" (e.g. an extruded cylinder
325
+ * built from a sketch on z=0 yields mainDir = (0,0,-1) with V-bounds [-50, 0]).
326
+ * Pass through unchanged when V naturally extends in the +mainDir direction;
327
+ * otherwise flip mainDir and negate V-bounds so that V grows along the body's
328
+ * axial extent. This keeps `.startOffset()`/`.endOffset()` semantics intuitive
329
+ * (positive end-offset extends past the cylinder's "top").
330
+ */
331
+ function canonicalizeAxialBounds(cs, vMin, vMax) {
332
+ if (Math.abs(vMin) <= Math.abs(vMax)) {
333
+ return { cs, vMin, vMax };
334
+ }
335
+ const flipped = new CoordinateSystem(cs.origin, cs.mainDirection.negate(), cs.xDirection);
336
+ return { cs: flipped, vMin: -vMax, vMax: -vMin };
337
+ }
@@ -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
+ }
@@ -4,6 +4,7 @@ import { SceneObject } from "../common/scene-object.js";
4
4
  import { BelongsToFaceFilter, NotBelongsToFaceFilter } from "../filters/edge/belongs-to-face.js";
5
5
  import { FromSceneObjectFilter } from "../filters/from-object.js";
6
6
  import { TopologyIndex } from "../oc/topology-index.js";
7
+ import { ShapeHasher } from "../oc/shape-hash.js";
7
8
  export class SelectSceneObject extends SceneObject {
8
9
  filters;
9
10
  constraintObject;
@@ -26,12 +27,20 @@ export class SelectSceneObject extends SceneObject {
26
27
  let filters = this.filters;
27
28
  let sceneObjects = context.getSceneObjects();
28
29
  let excludedObjects = [];
30
+ let narrowedToCloneGroup = false;
29
31
  if (transform) {
30
32
  filters = filters.map(f => f.transform(transform));
31
33
  if (!this.constraintObject && parent) {
32
34
  const snapshot = parent.getSnapshot();
33
35
  excludedObjects = snapshot ? Array.from(snapshot.values()).flat() : [];
34
- sceneObjects = context.getSceneObjectsFromTo(parent, this);
36
+ // Restrict to this clone instance's own siblings. Other instances of
37
+ // the same repeat share the parent container but carry a different
38
+ // clone transform, and the container itself would re-expose their
39
+ // shapes through getChildShapes.
40
+ const transformRef = this.getTransformRef();
41
+ sceneObjects = context.getSceneObjectsFromTo(parent, this)
42
+ .filter(o => o.getTransformRef() === transformRef);
43
+ narrowedToCloneGroup = true;
35
44
  }
36
45
  }
37
46
  // Objects passed explicitly via `from(...)` bypass the part scope so that
@@ -48,12 +57,23 @@ export class SelectSceneObject extends SceneObject {
48
57
  }
49
58
  }
50
59
  const allShapes = this.constraintObject ? this.constraintObject.getShapes() : this.getAllShapes(sceneObjects, excludedObjects);
60
+ let scopeHasher = null;
51
61
  if (this.type === "edge") {
52
- this.injectScopeFaces(filters, sceneObjects);
62
+ scopeHasher = this.injectScopeFaces(filters, sceneObjects);
53
63
  }
54
64
  const fromFilters = this.injectFromMembershipSets(filters);
55
65
  try {
56
- const filteredShapes = this.applyFilters(allShapes, filters);
66
+ let filteredShapes = this.applyFilters(allShapes, filters);
67
+ if (filteredShapes.length === 0 && narrowedToCloneGroup) {
68
+ // Nothing matched within the cloned group: the original selection
69
+ // resolved to geometry outside the repeated objects (e.g. a wrap
70
+ // target face on a base solid). Reuse that resolution — re-running
71
+ // the transformed filters against base geometry cannot match.
72
+ const source = this.getCloneSource();
73
+ if (source instanceof SelectSceneObject) {
74
+ filteredShapes = source.getAddedShapes();
75
+ }
76
+ }
57
77
  this.addShapes(filteredShapes);
58
78
  }
59
79
  finally {
@@ -61,6 +81,7 @@ export class SelectSceneObject extends SceneObject {
61
81
  filter.setMembershipSet(null);
62
82
  set.delete();
63
83
  }
84
+ scopeHasher?.delete();
64
85
  }
65
86
  }
66
87
  injectFromMembershipSets(filters) {
@@ -141,6 +162,7 @@ export class SelectSceneObject extends SceneObject {
141
162
  let scopeSolids = null;
142
163
  let extraFaces = null;
143
164
  let faceByHash = null;
165
+ let hasher = null;
144
166
  for (const builder of filters) {
145
167
  for (const filter of builder.getFilters()) {
146
168
  if (filter instanceof BelongsToFaceFilter || filter instanceof NotBelongsToFaceFilter) {
@@ -159,19 +181,21 @@ export class SelectSceneObject extends SceneObject {
159
181
  extraFaces = [];
160
182
  }
161
183
  faceByHash = new Map();
184
+ hasher = new ShapeHasher();
162
185
  for (const solid of scopeSolids) {
163
186
  for (const face of solid.getFaces()) {
164
- addToBucket(faceByHash, face);
187
+ addToBucket(faceByHash, face, hasher);
165
188
  }
166
189
  }
167
190
  for (const face of extraFaces) {
168
- addToBucket(faceByHash, face);
191
+ addToBucket(faceByHash, face, hasher);
169
192
  }
170
193
  }
171
- filter.setScopeIndex(scopeSolids, extraFaces, faceByHash);
194
+ filter.setScopeIndex(scopeSolids, extraFaces, faceByHash, hasher);
172
195
  }
173
196
  }
174
197
  }
198
+ return hasher;
175
199
  }
176
200
  applyFilters(shapes, filters) {
177
201
  const shapeFilter = new ShapeFilter(shapes, ...filters);
@@ -220,8 +244,8 @@ export class SelectSceneObject extends SceneObject {
220
244
  };
221
245
  }
222
246
  }
223
- function addToBucket(faceByHash, face) {
224
- const hash = face.getShape().HashCode(2147483647);
247
+ function addToBucket(faceByHash, face, hasher) {
248
+ const hash = hasher.key(face.getShape());
225
249
  let bucket = faceByHash.get(hash);
226
250
  if (!bucket) {
227
251
  bucket = [];
@@ -18,7 +18,7 @@ export declare class Extruder {
18
18
  getEndFaces(): Face[];
19
19
  getSideFaces(): Face[];
20
20
  getInternalFaces(): Face[];
21
- extrude(): Shape<import("occjs-wrapper").TopoDS_Shape>[];
21
+ extrude(): Shape<import("ocjs-fluidcad").TopoDS_Shape>[];
22
22
  private isInternalFace;
23
23
  private applyDraft;
24
24
  }
@@ -49,9 +49,14 @@ export class Extruder {
49
49
  ? p.record('Fuse profile faces', () => BooleanOps.fuseFaces(this.faces))
50
50
  : BooleanOps.fuseFaces(this.faces);
51
51
  for (const face of fusedFaces.result) {
52
+ // Canonicalize the sweep direction (so anti-parallel halves of a
53
+ // symmetric / two-distance extrude fuse with a single merged lateral
54
+ // face) only when undrafted — drafting is sensitive to the sweep
55
+ // parametrization and must keep the literal direction.
56
+ const canonicalizeSweep = !this.draft;
52
57
  let { solid, firstFace, lastFace } = p
53
- ? p.record('Make prism from face', () => ExtrudeOps.makePrismFromVec(face, vec))
54
- : ExtrudeOps.makePrismFromVec(face, vec);
58
+ ? p.record('Make prism from face', () => ExtrudeOps.makePrismFromVec(face, vec, canonicalizeSweep))
59
+ : ExtrudeOps.makePrismFromVec(face, vec, canonicalizeSweep);
55
60
  if (this.draft) {
56
61
  const draftResult = p
57
62
  ? p.record('Apply draft', () => this.applyDraft(solid, firstFace, lastFace, this.plane))