fluidcad 0.0.38 → 0.0.39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/lib/dist/core/2d/aline.d.ts +0 -8
  2. package/lib/dist/core/2d/aline.js +3 -22
  3. package/lib/dist/core/2d/arc.d.ts +1 -23
  4. package/lib/dist/core/2d/arc.js +11 -28
  5. package/lib/dist/core/2d/circle.d.ts +1 -8
  6. package/lib/dist/core/2d/circle.js +7 -24
  7. package/lib/dist/core/2d/ellipse.d.ts +1 -17
  8. package/lib/dist/core/2d/ellipse.js +7 -45
  9. package/lib/dist/core/2d/hline.d.ts +0 -7
  10. package/lib/dist/core/2d/hline.js +5 -27
  11. package/lib/dist/core/2d/intersect.d.ts +0 -7
  12. package/lib/dist/core/2d/intersect.js +0 -13
  13. package/lib/dist/core/2d/line.d.ts +1 -8
  14. package/lib/dist/core/2d/line.js +6 -24
  15. package/lib/dist/core/2d/offset.d.ts +1 -11
  16. package/lib/dist/core/2d/offset.js +0 -16
  17. package/lib/dist/core/2d/polygon.d.ts +1 -17
  18. package/lib/dist/core/2d/polygon.js +18 -37
  19. package/lib/dist/core/2d/project.d.ts +0 -7
  20. package/lib/dist/core/2d/project.js +0 -13
  21. package/lib/dist/core/2d/rect.d.ts +1 -9
  22. package/lib/dist/core/2d/rect.js +0 -19
  23. package/lib/dist/core/2d/slot.d.ts +0 -23
  24. package/lib/dist/core/2d/slot.js +18 -37
  25. package/lib/dist/core/2d/text.d.ts +0 -7
  26. package/lib/dist/core/2d/text.js +0 -14
  27. package/lib/dist/core/2d/vline.d.ts +0 -7
  28. package/lib/dist/core/2d/vline.js +5 -23
  29. package/lib/dist/core/fillet.js +7 -0
  30. package/lib/dist/core/index.d.ts +1 -1
  31. package/lib/dist/core/interfaces.d.ts +43 -0
  32. package/lib/dist/features/2d/connect.d.ts +2 -0
  33. package/lib/dist/features/2d/connect.js +3 -0
  34. package/lib/dist/features/2d/intersect.js +4 -1
  35. package/lib/dist/features/frozen-geometry.d.ts +29 -0
  36. package/lib/dist/features/frozen-geometry.js +62 -0
  37. package/lib/dist/features/loft.d.ts +38 -1
  38. package/lib/dist/features/loft.js +133 -8
  39. package/lib/dist/features/rib.js +11 -1
  40. package/lib/dist/features/shell.d.ts +1 -1
  41. package/lib/dist/features/shell.js +9 -0
  42. package/lib/dist/features/sweep.d.ts +11 -1
  43. package/lib/dist/features/sweep.js +43 -7
  44. package/lib/dist/helpers/clone-transform.js +62 -5
  45. package/lib/dist/helpers/scene-helpers.d.ts +12 -0
  46. package/lib/dist/helpers/scene-helpers.js +26 -0
  47. package/lib/dist/math/bspline-interpolation.d.ts +33 -6
  48. package/lib/dist/math/bspline-interpolation.js +187 -84
  49. package/lib/dist/oc/loft/constrained-loft.d.ts +32 -0
  50. package/lib/dist/oc/loft/constrained-loft.js +61 -0
  51. package/lib/dist/oc/loft/curve-data.d.ts +18 -0
  52. package/lib/dist/oc/loft/curve-data.js +54 -0
  53. package/lib/dist/oc/loft/curve-eval.d.ts +23 -0
  54. package/lib/dist/oc/loft/curve-eval.js +84 -0
  55. package/lib/dist/oc/loft/guided-loft.d.ts +89 -0
  56. package/lib/dist/oc/loft/guided-loft.js +368 -0
  57. package/lib/dist/oc/loft/section-compatibility.d.ts +131 -0
  58. package/lib/dist/oc/loft/section-compatibility.js +481 -0
  59. package/lib/dist/oc/loft/section-curve.d.ts +53 -0
  60. package/lib/dist/oc/loft/section-curve.js +264 -0
  61. package/lib/dist/oc/loft/skinning.d.ts +104 -0
  62. package/lib/dist/oc/loft/skinning.js +299 -0
  63. package/lib/dist/oc/loft-ops.d.ts +28 -1
  64. package/lib/dist/oc/loft-ops.js +31 -1
  65. package/lib/dist/oc/ncollection.d.ts +19 -0
  66. package/lib/dist/oc/ncollection.js +69 -0
  67. package/lib/dist/oc/rib-ops.js +5 -17
  68. package/lib/dist/oc/wire-extend-ops.d.ts +12 -0
  69. package/lib/dist/oc/wire-extend-ops.js +42 -0
  70. package/lib/dist/oc/wire-ops.d.ts +11 -0
  71. package/lib/dist/oc/wire-ops.js +33 -0
  72. package/lib/dist/oc/wrap-ops.js +8 -15
  73. package/lib/dist/tests/features/2d/circle.test.js +0 -10
  74. package/lib/dist/tests/features/2d/ellipse.test.js +0 -30
  75. package/lib/dist/tests/features/2d/line.test.js +0 -5
  76. package/lib/dist/tests/features/2d/offset.test.js +0 -10
  77. package/lib/dist/tests/features/2d/polygon.test.js +0 -16
  78. package/lib/dist/tests/features/2d/rect.test.js +0 -11
  79. package/lib/dist/tests/features/2d/slot.test.js +0 -8
  80. package/lib/dist/tests/features/fillet2d.test.js +37 -1
  81. package/lib/dist/tests/features/loft-conditions.test.d.ts +1 -0
  82. package/lib/dist/tests/features/loft-conditions.test.js +246 -0
  83. package/lib/dist/tests/features/loft-guides.test.d.ts +1 -0
  84. package/lib/dist/tests/features/loft-guides.test.js +276 -0
  85. package/lib/dist/tests/features/repeat-sweep-borrowed-path.test.d.ts +1 -0
  86. package/lib/dist/tests/features/repeat-sweep-borrowed-path.test.js +35 -0
  87. package/lib/dist/tests/features/rib.test.js +50 -0
  88. package/lib/dist/tests/features/shell.test.js +35 -1
  89. package/lib/dist/tests/features/sweep.test.js +81 -0
  90. package/lib/dist/tests/features/text.test.js +33 -14
  91. package/lib/dist/tests/math/bspline-interpolation.test.js +97 -1
  92. package/lib/dist/tsconfig.tsbuildinfo +1 -1
  93. package/llm-docs/api/index.json +1 -1
  94. package/llm-docs/api/types/loft.md +36 -1
  95. package/llm-docs/api/types/sweep.md +13 -1
  96. package/llm-docs/api/types/text.md +2 -2
  97. package/llm-docs/index.json +5 -5
  98. package/package.json +1 -1
@@ -1,21 +1,58 @@
1
1
  import { BuildSceneObjectContext, SceneObject } from "../common/scene-object.js";
2
- import { ILoft } from "../core/interfaces.js";
2
+ import { LoftEndCondition } from "../oc/loft-ops.js";
3
+ import { ILoft, LoftConditionType } from "../core/interfaces.js";
4
+ import { type NumberParam } from "../core/param.js";
3
5
  import { ExtrudeBase } from "./extrude-base.js";
4
6
  export declare class Loft extends ExtrudeBase implements ILoft {
5
7
  private _profiles;
8
+ private _guides;
9
+ private _startCondition?;
10
+ private _endCondition?;
6
11
  constructor(...profiles: SceneObject[]);
7
12
  get profiles(): SceneObject[];
13
+ /**
14
+ * Adds side guide curves the loft surface must follow. FluidCAD supports
15
+ * one or two guides (the underlying OCC algorithm has no notion of more);
16
+ * each guide must pass through every profile.
17
+ */
18
+ guides(...guides: SceneObject[]): this;
19
+ /**
20
+ * Constrains how the surface leaves the first profile: 'normal' takes off
21
+ * perpendicular to the profile plane, 'tangent' takes off inside the
22
+ * profile plane (outward), 'none' clears the constraint. The magnitude
23
+ * scales the takeoff strength (default 1).
24
+ */
25
+ startCondition(type: LoftConditionType, magnitude?: NumberParam): this;
26
+ /**
27
+ * Constrains how the surface arrives at the last profile — see
28
+ * {@link startCondition}.
29
+ */
30
+ endCondition(type: LoftConditionType, magnitude?: NumberParam): this;
31
+ private static resolveCondition;
32
+ private hasConditions;
8
33
  validate(): void;
9
34
  build(context: BuildSceneObjectContext): void;
35
+ /**
36
+ * The options for `LoftOps.makeLoft`, with guide objects resolved to wires.
37
+ * A single guide argument may carry several separate curves (e.g. a sketch
38
+ * with a curve and its mirror) — each connected chain counts as one guide.
39
+ * Returns undefined for a plain loft, keeping the legacy multi-wire path
40
+ * untouched.
41
+ */
42
+ private resolveLoftOptions;
10
43
  private buildThinLoft;
11
44
  private getProfilePlane;
12
45
  private getWiresFromSceneObject;
13
46
  getDependencies(): SceneObject[];
14
47
  createCopy(remap: Map<SceneObject, SceneObject>): SceneObject;
15
48
  compareTo(other: Loft): boolean;
49
+ private static conditionsEqual;
16
50
  getType(): string;
17
51
  serialize(): {
18
52
  profiles: any[];
53
+ guides: any[];
54
+ startCondition: LoftEndCondition;
55
+ endCondition: LoftEndCondition;
19
56
  operationMode: "new" | "remove";
20
57
  thin: [number, number] | [number];
21
58
  };
@@ -3,12 +3,16 @@ import { LoftOps } from "../oc/loft-ops.js";
3
3
  import { FaceMaker2 } from "../oc/face-maker2.js";
4
4
  import { FaceOps } from "../oc/face-ops.js";
5
5
  import { BooleanOps } from "../oc/boolean-ops.js";
6
- import { fuseWithSceneObjects, cutWithSceneObjects } from "../helpers/scene-helpers.js";
6
+ import { resolveParam } from "../core/param.js";
7
+ import { fuseWithSceneObjects, cutWithSceneObjects, wiresFromSceneObjectEdges } from "../helpers/scene-helpers.js";
7
8
  import { ExtrudeBase } from "./extrude-base.js";
8
9
  import { ThinFaceMaker } from "../oc/thin-face-maker.js";
9
10
  import { requireShapes } from "../common/operand-check.js";
10
11
  export class Loft extends ExtrudeBase {
11
12
  _profiles = [];
13
+ _guides = [];
14
+ _startCondition;
15
+ _endCondition;
12
16
  constructor(...profiles) {
13
17
  super();
14
18
  this._profiles = profiles;
@@ -16,19 +20,78 @@ export class Loft extends ExtrudeBase {
16
20
  get profiles() {
17
21
  return this._profiles;
18
22
  }
23
+ /**
24
+ * Adds side guide curves the loft surface must follow. FluidCAD supports
25
+ * one or two guides (the underlying OCC algorithm has no notion of more);
26
+ * each guide must pass through every profile.
27
+ */
28
+ guides(...guides) {
29
+ if (guides.length === 0) {
30
+ throw new Error("loft.guides: provide at least one guide curve.");
31
+ }
32
+ this._guides = guides;
33
+ return this;
34
+ }
35
+ /**
36
+ * Constrains how the surface leaves the first profile: 'normal' takes off
37
+ * perpendicular to the profile plane, 'tangent' takes off inside the
38
+ * profile plane (outward), 'none' clears the constraint. The magnitude
39
+ * scales the takeoff strength (default 1).
40
+ */
41
+ startCondition(type, magnitude) {
42
+ this._startCondition = Loft.resolveCondition("startCondition", type, magnitude);
43
+ return this;
44
+ }
45
+ /**
46
+ * Constrains how the surface arrives at the last profile — see
47
+ * {@link startCondition}.
48
+ */
49
+ endCondition(type, magnitude) {
50
+ this._endCondition = Loft.resolveCondition("endCondition", type, magnitude);
51
+ return this;
52
+ }
53
+ static resolveCondition(method, type, magnitude) {
54
+ if (type === "none") {
55
+ if (magnitude !== undefined) {
56
+ throw new Error(`loft.${method}: magnitude has no effect with 'none'.`);
57
+ }
58
+ return undefined;
59
+ }
60
+ if (type !== "normal" && type !== "tangent") {
61
+ throw new Error(`loft.${method}: expected 'none', 'normal' or 'tangent', got '${type}'.`);
62
+ }
63
+ const value = magnitude === undefined ? 1 : resolveParam(magnitude);
64
+ if (value === 0) {
65
+ throw new Error(`loft.${method}: magnitude must be non-zero.`);
66
+ }
67
+ return { kind: type, magnitude: value };
68
+ }
69
+ hasConditions() {
70
+ return this._startCondition !== undefined || this._endCondition !== undefined;
71
+ }
19
72
  validate() {
20
73
  for (let i = 0; i < this._profiles.length; i++) {
21
74
  requireShapes(this._profiles[i], `profile ${i + 1}`, "loft");
22
75
  }
76
+ for (let i = 0; i < this._guides.length; i++) {
77
+ requireShapes(this._guides[i], `guide ${i + 1}`, "loft");
78
+ }
79
+ if (this._guides.length > 2) {
80
+ throw new Error("Loft supports at most two guide curves.");
81
+ }
82
+ if (this._guides.length > 0 && this.isThin()) {
83
+ throw new Error("Loft guides cannot be combined with thin mode.");
84
+ }
23
85
  }
24
86
  build(context) {
25
87
  if (this.profiles.length < 2) {
26
88
  throw new Error("Loft requires at least two profiles.");
27
89
  }
28
90
  const p = context.getProfiler();
91
+ const options = p.record('Resolve loft options', () => this.resolveLoftOptions());
29
92
  let newShapes;
30
93
  if (this.isThin()) {
31
- newShapes = p.record('Build thin loft', () => this.buildThinLoft());
94
+ newShapes = p.record('Build thin loft', () => this.buildThinLoft(options));
32
95
  }
33
96
  else {
34
97
  const allWires = [];
@@ -37,15 +100,21 @@ export class Loft extends ExtrudeBase {
37
100
  if (wires.length === 0) {
38
101
  throw new Error("Could not extract wire from profile.");
39
102
  }
103
+ if (options && wires.length !== 1) {
104
+ throw new Error("Loft with guides or start/end conditions requires exactly one region per profile.");
105
+ }
40
106
  for (const wire of wires) {
41
107
  allWires.push(wire);
42
108
  }
43
109
  }
44
- newShapes = p.record('Make loft', () => LoftOps.makeLoft(allWires));
110
+ newShapes = p.record('Make loft', () => LoftOps.makeLoft(allWires, options));
45
111
  }
46
112
  for (const profile of this.profiles) {
47
113
  profile.removeShapes(this);
48
114
  }
115
+ for (const guide of this._guides) {
116
+ guide.removeShapes(this);
117
+ }
49
118
  // Classify faces into start/end/side using profile planes
50
119
  const firstPlane = this.getProfilePlane(this.profiles[0]);
51
120
  const lastPlane = this.getProfilePlane(this.profiles[this.profiles.length - 1]);
@@ -102,7 +171,31 @@ export class Loft extends ExtrudeBase {
102
171
  this.classifyExtrudeEdges();
103
172
  this.setFinalShapes(this.getShapes());
104
173
  }
105
- buildThinLoft() {
174
+ /**
175
+ * The options for `LoftOps.makeLoft`, with guide objects resolved to wires.
176
+ * A single guide argument may carry several separate curves (e.g. a sketch
177
+ * with a curve and its mirror) — each connected chain counts as one guide.
178
+ * Returns undefined for a plain loft, keeping the legacy multi-wire path
179
+ * untouched.
180
+ */
181
+ resolveLoftOptions() {
182
+ if (this._guides.length === 0 && !this.hasConditions()) {
183
+ return undefined;
184
+ }
185
+ let guides;
186
+ if (this._guides.length > 0) {
187
+ guides = this._guides.flatMap((guide, i) => wiresFromSceneObjectEdges(guide, `loft guide ${i + 1}`));
188
+ if (guides.length > 2) {
189
+ throw new Error(`Loft supports at most two guide curves, got ${guides.length}.`);
190
+ }
191
+ }
192
+ return {
193
+ startCondition: this._startCondition,
194
+ endCondition: this._endCondition,
195
+ guides,
196
+ };
197
+ }
198
+ buildThinLoft(options) {
106
199
  const outerWires = [];
107
200
  const innerWires = [];
108
201
  for (const profile of this.profiles) {
@@ -120,9 +213,15 @@ export class Loft extends ExtrudeBase {
120
213
  }
121
214
  }
122
215
  }
123
- const outerSolids = LoftOps.makeLoft(outerWires);
216
+ // With conditions, both walls come from the in-house skin — assemble the
217
+ // thin solid directly (walls + ring caps). Booleans between two
218
+ // nearly-parallel B-spline shells take OCC seconds.
219
+ if (options && innerWires.length > 0 && innerWires.length === outerWires.length) {
220
+ return LoftOps.makeThinLoft(outerWires, innerWires, options);
221
+ }
222
+ const outerSolids = LoftOps.makeLoft(outerWires, options);
124
223
  if (innerWires.length > 0 && innerWires.length === outerWires.length) {
125
- const innerSolids = LoftOps.makeLoft(innerWires);
224
+ const innerSolids = LoftOps.makeLoft(innerWires, options);
126
225
  const outerFuse = BooleanOps.fuse(outerSolids);
127
226
  const innerFuse = BooleanOps.fuse(innerSolids);
128
227
  const cutResult = BooleanOps.cutShapes(outerFuse.result[0], innerFuse.result[0]);
@@ -187,11 +286,16 @@ export class Loft extends ExtrudeBase {
187
286
  return [];
188
287
  }
189
288
  getDependencies() {
190
- return [...this._profiles];
289
+ return [...this._profiles, ...this._guides];
191
290
  }
192
291
  createCopy(remap) {
193
292
  const profiles = this._profiles.map(p => remap.get(p) || p);
194
- return new Loft(...profiles).syncWith(this);
293
+ const copy = new Loft(...profiles);
294
+ copy.syncWith(this);
295
+ copy._guides = this._guides.map(g => remap.get(g) || g);
296
+ copy._startCondition = this._startCondition ? { ...this._startCondition } : undefined;
297
+ copy._endCondition = this._endCondition ? { ...this._endCondition } : undefined;
298
+ return copy;
195
299
  }
196
300
  compareTo(other) {
197
301
  if (!(other instanceof Loft)) {
@@ -208,14 +312,35 @@ export class Loft extends ExtrudeBase {
208
312
  return false;
209
313
  }
210
314
  }
315
+ if (this._guides.length !== other._guides.length) {
316
+ return false;
317
+ }
318
+ for (let i = 0; i < this._guides.length; i++) {
319
+ if (!this._guides[i].compareTo(other._guides[i])) {
320
+ return false;
321
+ }
322
+ }
323
+ if (!Loft.conditionsEqual(this._startCondition, other._startCondition)
324
+ || !Loft.conditionsEqual(this._endCondition, other._endCondition)) {
325
+ return false;
326
+ }
211
327
  return true;
212
328
  }
329
+ static conditionsEqual(a, b) {
330
+ if (a === undefined || b === undefined) {
331
+ return a === b;
332
+ }
333
+ return a.kind === b.kind && a.magnitude === b.magnitude;
334
+ }
213
335
  getType() {
214
336
  return "loft";
215
337
  }
216
338
  serialize() {
217
339
  return {
218
340
  profiles: this.profiles.map(f => f.serialize()),
341
+ guides: this._guides.length > 0 ? this._guides.map(g => g.serialize()) : undefined,
342
+ startCondition: this._startCondition,
343
+ endCondition: this._endCondition,
219
344
  operationMode: this._operationMode !== 'add' ? this._operationMode : undefined,
220
345
  thin: this._thin,
221
346
  };
@@ -57,8 +57,18 @@ export class Rib extends ExtrudeBase {
57
57
  let direction;
58
58
  let distance;
59
59
  if (this._parallel) {
60
+ // The in-plane extrude direction is plane.normal × spineDir — a cross
61
+ // product, hence a pseudovector. Under a mirror clone (repeat("mirror",
62
+ // …)) both operands are already reflected, and for a reflection
63
+ // M(a) × M(b) = −M(a × b): the recomputed perpendicular points to the
64
+ // opposite side, sending the mirrored rib the wrong way. Flip it back
65
+ // when this instance's clone transform is a reflection. Proper
66
+ // transforms (linear/circular/rotate repeats, det > 0) need no fix, and
67
+ // normal-mode direction (±plane.normal) is linear so it is never
68
+ // affected.
69
+ const mirrorSign = context.getTransform()?.isMirroring() ? -1 : 1;
60
70
  const perpDir = RibOps.computeSpinePerpendicularDirection(spineWire, plane);
61
- direction = perpDir.multiply(dirSign);
71
+ direction = perpDir.multiply(dirSign * mirrorSign);
62
72
  distance = p.record('Compute extrude distance', () => RibOps.computeExtrudeDistanceAlongDirection(direction, plane.origin, scopeShapes));
63
73
  }
64
74
  else {
@@ -23,6 +23,6 @@ export declare class Shell extends SceneObject implements IShell {
23
23
  getType(): string;
24
24
  serialize(): {
25
25
  thickness: number;
26
- joinType: "intersection" | "tangent";
26
+ joinType: "tangent" | "intersection";
27
27
  };
28
28
  }
@@ -63,8 +63,17 @@ export class Shell extends SceneObject {
63
63
  originalObj.removeShape(shape, this);
64
64
  }
65
65
  catch {
66
+ // OCCT's MakeThickSolid could not offset the inner wall. Keep the
67
+ // original (un-shelled) solid in the scene so downstream features
68
+ // still have geometry to work with, but surface the failure instead
69
+ // of silently serving an unchanged part.
66
70
  newShapes.push(shape);
67
71
  console.warn("Shell: Failed to create thick solid.");
72
+ this.setError("shell: could not hollow the solid — wall offset failed.\n" +
73
+ "Hint: the wall may be thicker than a nearby feature or radius of " +
74
+ "curvature, or a cut/groove may open onto a removed face. Try a " +
75
+ "thinner wall, fewer/simpler open faces, or shelling before cutting " +
76
+ "small features.");
68
77
  }
69
78
  }
70
79
  for (const sel of this.faceSelections) {
@@ -1,11 +1,19 @@
1
1
  import { BuildSceneObjectContext, SceneObject } from "../common/scene-object.js";
2
2
  import { Extrudable } from "../helpers/types.js";
3
3
  import { ExtrudeBase } from "./extrude-base.js";
4
- import { ISweep } from "../core/interfaces.js";
4
+ import { ISweep, SweepSide } from "../core/interfaces.js";
5
+ import { type NumberParam } from "../core/param.js";
5
6
  export declare class Sweep extends ExtrudeBase implements ISweep {
6
7
  private _path;
8
+ private _extendStart?;
9
+ private _extendEnd?;
7
10
  constructor(path: SceneObject, extrudable?: Extrudable);
8
11
  get path(): SceneObject;
12
+ /**
13
+ * Extends the swept solid beyond the path at the given end by `amount`,
14
+ * continuing straight along the path's tangent there. Chain twice for both ends.
15
+ */
16
+ extend(side: SweepSide, amount: NumberParam): this;
9
17
  validate(): void;
10
18
  build(context: BuildSceneObjectContext): void;
11
19
  /** Plain sweep: classify by inner-wire detection on the start face. */
@@ -41,5 +49,7 @@ export declare class Sweep extends ExtrudeBase implements ISweep {
41
49
  extrudable: any;
42
50
  operationMode: "new" | "remove";
43
51
  thin: [number, number] | [number];
52
+ extendStart: number;
53
+ extendEnd: number;
44
54
  };
45
55
  }
@@ -1,13 +1,16 @@
1
1
  import { Explorer } from "../oc/explorer.js";
2
2
  import { SweepOps } from "../oc/sweep-ops.js";
3
- import { WireOps } from "../oc/wire-ops.js";
3
+ import { WireExtendOps } from "../oc/wire-extend-ops.js";
4
4
  import { FaceMaker2 } from "../oc/face-maker2.js";
5
5
  import { ExtrudeBase } from "./extrude-base.js";
6
- import { cutWithSceneObjects } from "../helpers/scene-helpers.js";
6
+ import { resolveParam } from "../core/param.js";
7
+ import { cutWithSceneObjects, wireFromSceneObjectEdges } from "../helpers/scene-helpers.js";
7
8
  import { ThinFaceMaker } from "../oc/thin-face-maker.js";
8
9
  import { requireShapes } from "../common/operand-check.js";
9
10
  export class Sweep extends ExtrudeBase {
10
11
  _path;
12
+ _extendStart;
13
+ _extendEnd;
11
14
  constructor(path, extrudable) {
12
15
  super(extrudable);
13
16
  this._path = path;
@@ -15,6 +18,23 @@ export class Sweep extends ExtrudeBase {
15
18
  get path() {
16
19
  return this._path;
17
20
  }
21
+ /**
22
+ * Extends the swept solid beyond the path at the given end by `amount`,
23
+ * continuing straight along the path's tangent there. Chain twice for both ends.
24
+ */
25
+ extend(side, amount) {
26
+ const value = resolveParam(amount);
27
+ if (side === "start") {
28
+ this._extendStart = value;
29
+ }
30
+ else if (side === "end") {
31
+ this._extendEnd = value;
32
+ }
33
+ else {
34
+ throw new Error(`sweep.extend: side must be 'start' or 'end', got '${side}'.`);
35
+ }
36
+ return this;
37
+ }
18
38
  validate() {
19
39
  requireShapes(this._path, "path", "sweep");
20
40
  }
@@ -152,10 +172,14 @@ export class Sweep extends ExtrudeBase {
152
172
  this.finalizeAndFuse(solids, classified, context, { skipSimplify: true });
153
173
  }
154
174
  getSpineWire(pathObj) {
155
- const shapes = pathObj.getShapes({ excludeMeta: false });
156
- const edges = shapes.flatMap(s => s.getSubShapes('edge'));
157
- console.log(`Sweep: Extracted ${edges.length} edges from path object for spine wire.`);
158
- return WireOps.makeWireFromEdges(edges);
175
+ let wire = wireFromSceneObjectEdges(pathObj, "sweep path");
176
+ if (this._extendStart !== undefined) {
177
+ wire = WireExtendOps.extendWire(wire, "start", this._extendStart);
178
+ }
179
+ if (this._extendEnd !== undefined) {
180
+ wire = WireExtendOps.extendWire(wire, "end", this._extendEnd);
181
+ }
182
+ return wire;
159
183
  }
160
184
  getDependencies() {
161
185
  const deps = [];
@@ -172,7 +196,11 @@ export class Sweep extends ExtrudeBase {
172
196
  ? (remap.get(this.extrudable) || this.extrudable)
173
197
  : undefined;
174
198
  const path = remap.get(this._path) || this._path;
175
- return new Sweep(path, extrudable).syncWith(this);
199
+ const copy = new Sweep(path, extrudable);
200
+ copy.syncWith(this);
201
+ copy._extendStart = this._extendStart;
202
+ copy._extendEnd = this._extendEnd;
203
+ return copy;
176
204
  }
177
205
  compareTo(other) {
178
206
  if (!(other instanceof Sweep)) {
@@ -187,6 +215,12 @@ export class Sweep extends ExtrudeBase {
187
215
  if (!this.extrudable.compareTo(other.extrudable)) {
188
216
  return false;
189
217
  }
218
+ if (this._extendStart !== other._extendStart) {
219
+ return false;
220
+ }
221
+ if (this._extendEnd !== other._extendEnd) {
222
+ return false;
223
+ }
190
224
  return true;
191
225
  }
192
226
  getType() {
@@ -198,6 +232,8 @@ export class Sweep extends ExtrudeBase {
198
232
  extrudable: this.extrudable.serialize(),
199
233
  operationMode: this._operationMode !== 'add' ? this._operationMode : undefined,
200
234
  thin: this._thin,
235
+ extendStart: this._extendStart,
236
+ extendEnd: this._extendEnd,
201
237
  ...this.serializePickFields(),
202
238
  };
203
239
  }
@@ -1,3 +1,6 @@
1
+ import { GeometrySceneObject } from "../features/2d/geometry.js";
2
+ import { Sketch } from "../features/2d/sketch.js";
3
+ import { FrozenGeometry } from "../features/frozen-geometry.js";
1
4
  export function cloneWithTransform(objects, transform, container) {
2
5
  const visited = new Set();
3
6
  const ordered = [];
@@ -27,17 +30,71 @@ export function cloneWithTransform(objects, transform, container) {
27
30
  for (const obj of objects) {
28
31
  collectDeps(obj);
29
32
  }
33
+ // Sketch geometry whose owning sketch is part of the clone set rebuilds
34
+ // normally (on the cloned, transformed plane). But a repeated feature can
35
+ // also reference a bare curve that lives in a sketch *outside* the clone set
36
+ // (e.g. a sweep path `otherSketch.regions.foo`). That curve has no sketch
37
+ // ancestor among the clones, so it can't rebuild — `this.sketch` would be
38
+ // null. Freeze such geometry's built edges instead (see FrozenGeometry).
39
+ const clonedSketches = new Set();
40
+ for (const obj of ordered) {
41
+ if (obj instanceof Sketch) {
42
+ clonedSketches.add(obj);
43
+ }
44
+ }
45
+ const owningSketchInCloneSet = (geom) => {
46
+ let parent = geom.getParent();
47
+ while (parent && !(parent instanceof Sketch)) {
48
+ parent = parent.getParent();
49
+ }
50
+ return parent ? clonedSketches.has(parent) : false;
51
+ };
52
+ const isOrphanGeometry = (obj) => obj instanceof GeometrySceneObject && !owningSketchInCloneSet(obj);
53
+ // A boundary orphan is one a kept (non-orphan) clone — or a repeat root —
54
+ // actually references. Those get frozen; orphan geometry reachable only as
55
+ // the source curves of another orphan (e.g. the lines behind a fillet) is
56
+ // dropped, since the frozen edges already incorporate it.
57
+ const roots = new Set(objects);
58
+ const frozenSources = new Set();
59
+ for (const obj of ordered) {
60
+ if (isOrphanGeometry(obj)) {
61
+ if (roots.has(obj)) {
62
+ frozenSources.add(obj);
63
+ }
64
+ continue;
65
+ }
66
+ for (const dep of obj.getDependencies()) {
67
+ if (isOrphanGeometry(dep)) {
68
+ frozenSources.add(dep);
69
+ }
70
+ }
71
+ }
30
72
  const remap = new Map();
31
73
  const allCloned = [];
32
- for (const obj of ordered) {
33
- const copy = obj.createCopy(remap);
34
- remap.set(obj, copy);
74
+ const registerClone = (source, copy) => {
75
+ remap.set(source, copy);
35
76
  copy.setTransform(transform);
36
- copy.setCloneSource(obj);
37
- if (obj.isReusable()) {
77
+ copy.setCloneSource(source);
78
+ if (source.isReusable()) {
38
79
  copy.reusable();
39
80
  }
40
81
  allCloned.push(copy);
82
+ };
83
+ // Freeze boundary orphans first so referring clones remap onto them. They
84
+ // hold baked edges and need no sketch parent, so they live under the
85
+ // container directly.
86
+ for (const source of frozenSources) {
87
+ const frozen = new FrozenGeometry(source);
88
+ registerClone(source, frozen);
89
+ container.addChildObject(frozen);
90
+ }
91
+ for (const obj of ordered) {
92
+ if (isOrphanGeometry(obj)) {
93
+ // Frozen above, or dropped as the source of a frozen curve.
94
+ continue;
95
+ }
96
+ const copy = obj.createCopy(remap);
97
+ registerClone(obj, copy);
41
98
  const parent = obj.getParent();
42
99
  if (parent && remap.has(parent)) {
43
100
  remap.get(parent).addChildObject(copy);
@@ -3,6 +3,18 @@ import { Shape } from "../common/shapes.js";
3
3
  import { Plane } from "../math/plane.js";
4
4
  import { ShapeHistory } from "../common/shape-history-tracker.js";
5
5
  import { Profiler } from "../common/profiler.js";
6
+ import { Wire } from "../common/wire.js";
7
+ /**
8
+ * Collects every edge of the object's shapes into one connected wire.
9
+ * Used by path-style inputs that need a single curve (sweep paths).
10
+ */
11
+ export declare function wireFromSceneObjectEdges(obj: SceneObject, label: string): Wire;
12
+ /**
13
+ * Groups the object's edges into connected chains — one wire per chain.
14
+ * Used by inputs that may carry several separate curves (e.g. a loft guide
15
+ * sketch holding a curve and its mirror).
16
+ */
17
+ export declare function wiresFromSceneObjectEdges(obj: SceneObject, label: string): Wire[];
6
18
  export declare function fuseWithSceneObjects(sceneObjects: SceneObject[], extrusions: Shape<any>[], opts?: {
7
19
  glue?: 'full' | 'shift';
8
20
  recordHistoryFor?: SceneObject;
@@ -7,6 +7,32 @@ import { Face } from "../common/face.js";
7
7
  import { Edge } from "../common/edge.js";
8
8
  import { getOC } from "../oc/init.js";
9
9
  import { ColorTransfer } from "../oc/color-transfer.js";
10
+ import { WireOps } from "../oc/wire-ops.js";
11
+ /**
12
+ * Collects every edge of the object's shapes into one connected wire.
13
+ * Used by path-style inputs that need a single curve (sweep paths).
14
+ */
15
+ export function wireFromSceneObjectEdges(obj, label) {
16
+ const shapes = obj.getShapes({ excludeMeta: false });
17
+ const edges = shapes.flatMap(s => s.getSubShapes('edge'));
18
+ if (edges.length === 0) {
19
+ throw new Error(`${label} has no edges to build a curve from.`);
20
+ }
21
+ return WireOps.makeWireFromEdges(edges);
22
+ }
23
+ /**
24
+ * Groups the object's edges into connected chains — one wire per chain.
25
+ * Used by inputs that may carry several separate curves (e.g. a loft guide
26
+ * sketch holding a curve and its mirror).
27
+ */
28
+ export function wiresFromSceneObjectEdges(obj, label) {
29
+ const shapes = obj.getShapes({ excludeMeta: false });
30
+ const edges = shapes.flatMap(s => s.getSubShapes('edge'));
31
+ if (edges.length === 0) {
32
+ throw new Error(`${label} has no edges to build a curve from.`);
33
+ }
34
+ return WireOps.connectEdgesToWires(edges);
35
+ }
10
36
  export function fuseWithSceneObjects(sceneObjects, extrusions, opts) {
11
37
  const p = opts?.profiler;
12
38
  const modified = [];
@@ -1,13 +1,15 @@
1
1
  /**
2
- * Global B-spline interpolation of planar points (The NURBS Book, §9.2.1):
3
- * chord-length parameters, knots by averaging, and a banded linear system
4
- * solved densely. The resulting curve passes through every input point and
5
- * is C2 (for the cubic case).
2
+ * Global B-spline interpolation (The NURBS Book, §9.2.1–9.2.2): chord-length
3
+ * or caller-supplied parameters, knots by averaging, and a banded linear
4
+ * system solved densely. The resulting curve passes through every input
5
+ * point and is C2 (for the cubic case). Optional end-derivative constraints
6
+ * (§9.2.2) pin the takeoff/arrival tangent vectors exactly — used by the
7
+ * loft end conditions to control how a skinned surface leaves its profiles.
6
8
  *
7
9
  * Exists because fluidcad-ocjs currently miscompiles the `Geom2dAPI` fitting
8
10
  * classes (both their constructors and `Init` produce corrupted curves);
9
- * `Geom2d_BSplineCurve`'s array constructor is unaffected, so we compute the
10
- * poles/knots ourselves and hand them over.
11
+ * the `Geom_BSplineCurve`/`Geom2d_BSplineCurve` array constructors are
12
+ * unaffected, so we compute the poles/knots ourselves and hand them over.
11
13
  */
12
14
  export interface XY {
13
15
  x: number;
@@ -21,6 +23,31 @@ export interface BSpline2dData {
21
23
  multiplicities: number[];
22
24
  degree: number;
23
25
  }
26
+ /** Dimension-agnostic interpolation result; each pole is one point (length = dimension). */
27
+ export interface BSplineCurveData {
28
+ poles: number[][];
29
+ /** Distinct knot values (OCC convention). */
30
+ knots: number[];
31
+ /** Multiplicity per distinct knot. */
32
+ multiplicities: number[];
33
+ degree: number;
34
+ }
35
+ /** Largest knot span index whose half-open interval contains t. */
36
+ export declare function findSpan(poleCount: number, degree: number, t: number, flatKnots: number[]): number;
37
+ /** Nonzero basis function values N[span-degree .. span] at t (Cox–de Boor). */
38
+ export declare function basisFunctions(span: number, t: number, degree: number, flatKnots: number[]): number[];
39
+ /**
40
+ * Interpolates points (of any dimension) with a B-spline of degree up to 3,
41
+ * passing exactly through every point at the given parameters. Optional end
42
+ * derivatives constrain the curve's takeoff / arrival vectors (with respect
43
+ * to the same parameterization as `params`), adding one pole per constraint
44
+ * (The NURBS Book §9.2.2).
45
+ *
46
+ * With an end derivative D at a clamped end, the neighbouring pole is pinned
47
+ * directly (P1 = P0 + D·Δu/p and its mirror), so the constraint is exact and
48
+ * the linear system only solves for the remaining interior poles.
49
+ */
50
+ export declare function interpolateWithDerivatives(points: ReadonlyArray<ReadonlyArray<number>>, params: ReadonlyArray<number>, startDerivative?: ReadonlyArray<number>, endDerivative?: ReadonlyArray<number>): BSplineCurveData;
24
51
  /**
25
52
  * Interpolates the given planar points with a B-spline of degree up to 3
26
53
  * (lower for 2 or 3 points). The curve passes exactly through every point,