fluidcad 0.0.37 → 0.0.39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/lib/dist/core/2d/aline.d.ts +0 -8
  2. package/lib/dist/core/2d/aline.js +3 -22
  3. package/lib/dist/core/2d/arc.d.ts +1 -23
  4. package/lib/dist/core/2d/arc.js +11 -28
  5. package/lib/dist/core/2d/circle.d.ts +1 -8
  6. package/lib/dist/core/2d/circle.js +7 -24
  7. package/lib/dist/core/2d/ellipse.d.ts +1 -17
  8. package/lib/dist/core/2d/ellipse.js +7 -45
  9. package/lib/dist/core/2d/hline.d.ts +0 -7
  10. package/lib/dist/core/2d/hline.js +5 -27
  11. package/lib/dist/core/2d/intersect.d.ts +0 -7
  12. package/lib/dist/core/2d/intersect.js +0 -13
  13. package/lib/dist/core/2d/line.d.ts +1 -8
  14. package/lib/dist/core/2d/line.js +6 -24
  15. package/lib/dist/core/2d/offset.d.ts +1 -11
  16. package/lib/dist/core/2d/offset.js +0 -16
  17. package/lib/dist/core/2d/polygon.d.ts +1 -17
  18. package/lib/dist/core/2d/polygon.js +18 -37
  19. package/lib/dist/core/2d/project.d.ts +0 -7
  20. package/lib/dist/core/2d/project.js +0 -13
  21. package/lib/dist/core/2d/rect.d.ts +1 -9
  22. package/lib/dist/core/2d/rect.js +0 -19
  23. package/lib/dist/core/2d/slot.d.ts +0 -23
  24. package/lib/dist/core/2d/slot.js +18 -37
  25. package/lib/dist/core/2d/text.d.ts +0 -7
  26. package/lib/dist/core/2d/text.js +0 -14
  27. package/lib/dist/core/2d/vline.d.ts +0 -7
  28. package/lib/dist/core/2d/vline.js +5 -23
  29. package/lib/dist/core/fillet.js +7 -0
  30. package/lib/dist/core/index.d.ts +1 -1
  31. package/lib/dist/core/interfaces.d.ts +43 -0
  32. package/lib/dist/core/plane.d.ts +26 -6
  33. package/lib/dist/core/plane.js +21 -44
  34. package/lib/dist/features/2d/connect.d.ts +2 -0
  35. package/lib/dist/features/2d/connect.js +3 -0
  36. package/lib/dist/features/2d/intersect.js +4 -1
  37. package/lib/dist/features/2d/offset.js +2 -2
  38. package/lib/dist/features/frozen-geometry.d.ts +29 -0
  39. package/lib/dist/features/frozen-geometry.js +62 -0
  40. package/lib/dist/features/loft.d.ts +38 -1
  41. package/lib/dist/features/loft.js +133 -8
  42. package/lib/dist/features/plane-from-object.d.ts +16 -4
  43. package/lib/dist/features/plane-from-object.js +101 -8
  44. package/lib/dist/features/rib.js +11 -1
  45. package/lib/dist/features/shell.d.ts +1 -1
  46. package/lib/dist/features/shell.js +9 -0
  47. package/lib/dist/features/sweep.d.ts +11 -1
  48. package/lib/dist/features/sweep.js +43 -7
  49. package/lib/dist/helpers/clone-transform.js +62 -5
  50. package/lib/dist/helpers/scene-helpers.d.ts +12 -0
  51. package/lib/dist/helpers/scene-helpers.js +26 -0
  52. package/lib/dist/math/bspline-interpolation.d.ts +33 -6
  53. package/lib/dist/math/bspline-interpolation.js +187 -84
  54. package/lib/dist/oc/loft/constrained-loft.d.ts +32 -0
  55. package/lib/dist/oc/loft/constrained-loft.js +61 -0
  56. package/lib/dist/oc/loft/curve-data.d.ts +18 -0
  57. package/lib/dist/oc/loft/curve-data.js +54 -0
  58. package/lib/dist/oc/loft/curve-eval.d.ts +23 -0
  59. package/lib/dist/oc/loft/curve-eval.js +84 -0
  60. package/lib/dist/oc/loft/guided-loft.d.ts +89 -0
  61. package/lib/dist/oc/loft/guided-loft.js +368 -0
  62. package/lib/dist/oc/loft/section-compatibility.d.ts +131 -0
  63. package/lib/dist/oc/loft/section-compatibility.js +481 -0
  64. package/lib/dist/oc/loft/section-curve.d.ts +53 -0
  65. package/lib/dist/oc/loft/section-curve.js +264 -0
  66. package/lib/dist/oc/loft/skinning.d.ts +104 -0
  67. package/lib/dist/oc/loft/skinning.js +299 -0
  68. package/lib/dist/oc/loft-ops.d.ts +28 -1
  69. package/lib/dist/oc/loft-ops.js +31 -1
  70. package/lib/dist/oc/ncollection.d.ts +19 -0
  71. package/lib/dist/oc/ncollection.js +69 -0
  72. package/lib/dist/oc/rib-ops.js +5 -17
  73. package/lib/dist/oc/sweep-ops.d.ts +7 -16
  74. package/lib/dist/oc/sweep-ops.js +67 -99
  75. package/lib/dist/oc/thin-face-maker.d.ts +0 -19
  76. package/lib/dist/oc/thin-face-maker.js +3 -68
  77. package/lib/dist/oc/wire-extend-ops.d.ts +12 -0
  78. package/lib/dist/oc/wire-extend-ops.js +42 -0
  79. package/lib/dist/oc/wire-ops.d.ts +28 -2
  80. package/lib/dist/oc/wire-ops.js +88 -4
  81. package/lib/dist/oc/wrap-ops.js +8 -15
  82. package/lib/dist/tests/features/2d/circle.test.js +0 -10
  83. package/lib/dist/tests/features/2d/ellipse.test.js +0 -30
  84. package/lib/dist/tests/features/2d/line.test.js +0 -5
  85. package/lib/dist/tests/features/2d/offset.test.js +74 -11
  86. package/lib/dist/tests/features/2d/polygon.test.js +0 -16
  87. package/lib/dist/tests/features/2d/rect.test.js +0 -11
  88. package/lib/dist/tests/features/2d/slot.test.js +0 -8
  89. package/lib/dist/tests/features/fillet2d.test.js +37 -1
  90. package/lib/dist/tests/features/loft-conditions.test.d.ts +1 -0
  91. package/lib/dist/tests/features/loft-conditions.test.js +246 -0
  92. package/lib/dist/tests/features/loft-guides.test.d.ts +1 -0
  93. package/lib/dist/tests/features/loft-guides.test.js +276 -0
  94. package/lib/dist/tests/features/plane.test.js +95 -0
  95. package/lib/dist/tests/features/repeat-sweep-borrowed-path.test.d.ts +1 -0
  96. package/lib/dist/tests/features/repeat-sweep-borrowed-path.test.js +35 -0
  97. package/lib/dist/tests/features/rib.test.js +50 -0
  98. package/lib/dist/tests/features/shell.test.js +35 -1
  99. package/lib/dist/tests/features/sweep.test.js +127 -1
  100. package/lib/dist/tests/features/text.test.js +33 -14
  101. package/lib/dist/tests/math/bspline-interpolation.test.js +97 -1
  102. package/lib/dist/tsconfig.tsbuildinfo +1 -1
  103. package/llm-docs/api/index.json +1 -1
  104. package/llm-docs/api/types/loft.md +36 -1
  105. package/llm-docs/api/types/sweep.md +13 -1
  106. package/llm-docs/api/types/text.md +2 -2
  107. package/llm-docs/index.json +5 -5
  108. package/package.json +1 -1
  109. package/server/dist/fluidcad-server.d.ts +1 -1
  110. package/server/dist/fluidcad-server.js +11 -1
  111. package/server/dist/routes/params.js +1 -1
  112. package/ui/dist/assets/{index-no7mtr5s.js → index-D8zV21wB.js} +83 -83
  113. package/ui/dist/index.html +1 -1
@@ -1,9 +1,11 @@
1
+ import { SceneObject } from "../../common/scene-object.js";
1
2
  import { GeometrySceneObject } from "./geometry.js";
2
3
  export type ConnectMode = 'line' | 'arc';
3
4
  export declare class Connect extends GeometrySceneObject {
4
5
  private mode;
5
6
  constructor(mode?: ConnectMode);
6
7
  build(): void;
8
+ createCopy(remap: Map<SceneObject, SceneObject>): SceneObject;
7
9
  compareTo(other: Connect): boolean;
8
10
  getType(): string;
9
11
  serialize(): {
@@ -85,6 +85,9 @@ export class Connect extends GeometrySceneObject {
85
85
  }
86
86
  this.addShape(wire);
87
87
  }
88
+ createCopy(remap) {
89
+ return new Connect(this.mode);
90
+ }
88
91
  compareTo(other) {
89
92
  if (!(other instanceof Connect)) {
90
93
  return false;
@@ -2,6 +2,7 @@ import { Vertex } from "../../common/vertex.js";
2
2
  import { SectionOps } from "../../oc/section-ops.js";
3
3
  import { WireOps } from "../../oc/wire-ops.js";
4
4
  import { ExtrudableGeometryBase } from "./extrudable-base.js";
5
+ import { SelectSceneObject } from "../select.js";
5
6
  export class Intersect extends ExtrudableGeometryBase {
6
7
  sourceObjects;
7
8
  constructor(sourceObjects, targetPlane = null) {
@@ -33,7 +34,9 @@ export class Intersect extends ExtrudableGeometryBase {
33
34
  }
34
35
  }
35
36
  for (const obj of this.sourceObjects) {
36
- obj.removeShapes(this);
37
+ if (obj instanceof SelectSceneObject) {
38
+ obj.removeShapes(this);
39
+ }
37
40
  }
38
41
  if (this.targetPlane) {
39
42
  this.targetPlane.removeShapes(this);
@@ -55,9 +55,9 @@ export class Offset extends ExtrudableGeometryBase {
55
55
  });
56
56
  }
57
57
  let lastOffsetWire = null;
58
+ const plane = this.getPlane();
58
59
  for (const wireInfo of wires) {
59
- const isOpen = !wireInfo.wire.isClosed();
60
- const offsetWire = WireOps.offsetWire(wireInfo.wire, this.distance, isOpen);
60
+ const offsetWire = WireOps.offsetWireOnPlane(wireInfo.wire, this.distance, wireInfo.wire.isClosed(), plane);
61
61
  lastOffsetWire = offsetWire;
62
62
  const edges = offsetWire.getEdges();
63
63
  for (const edge of edges) {
@@ -0,0 +1,29 @@
1
+ import { BuildSceneObjectContext, SceneObject } from "../common/scene-object.js";
2
+ import { TransformablePrimitive } from "../common/transformable-primitive.js";
3
+ /**
4
+ * A clone-only stand-in for sketch geometry that a repeated feature references
5
+ * but that lives in a sketch *outside* the cloned set — e.g. a `sweep` whose
6
+ * path is `otherSketch.regions.foo`.
7
+ *
8
+ * Such geometry cannot be rebuilt during the clone: a 2D curve resolves its
9
+ * plane by walking up to its owning `Sketch` (see GeometrySceneObject.sketch),
10
+ * and its pen position depends on sibling curves that are not part of the
11
+ * clone. Cloning it out of its sketch leaves it parented under the repeat
12
+ * container with no sketch ancestor, so `this.sketch` is `null` and build
13
+ * throws "Cannot read properties of null (reading 'getPlane')".
14
+ *
15
+ * Instead we freeze the source's already-built edges (which are final,
16
+ * world-space, and already incorporate any fillet/trim) and bake in the
17
+ * per-instance clone transform — exactly like cloned primitives
18
+ * (cylinder/sphere) do via {@link TransformablePrimitive.addPrimitiveShape}.
19
+ */
20
+ export declare class FrozenGeometry extends TransformablePrimitive {
21
+ private source;
22
+ constructor(source: SceneObject);
23
+ build(context?: BuildSceneObjectContext): void;
24
+ getDependencies(): SceneObject[];
25
+ createCopy(remap: Map<SceneObject, SceneObject>): SceneObject;
26
+ compareTo(other: SceneObject): boolean;
27
+ getType(): string;
28
+ serialize(): {};
29
+ }
@@ -0,0 +1,62 @@
1
+ import { TransformablePrimitive } from "../common/transformable-primitive.js";
2
+ /**
3
+ * A clone-only stand-in for sketch geometry that a repeated feature references
4
+ * but that lives in a sketch *outside* the cloned set — e.g. a `sweep` whose
5
+ * path is `otherSketch.regions.foo`.
6
+ *
7
+ * Such geometry cannot be rebuilt during the clone: a 2D curve resolves its
8
+ * plane by walking up to its owning `Sketch` (see GeometrySceneObject.sketch),
9
+ * and its pen position depends on sibling curves that are not part of the
10
+ * clone. Cloning it out of its sketch leaves it parented under the repeat
11
+ * container with no sketch ancestor, so `this.sketch` is `null` and build
12
+ * throws "Cannot read properties of null (reading 'getPlane')".
13
+ *
14
+ * Instead we freeze the source's already-built edges (which are final,
15
+ * world-space, and already incorporate any fillet/trim) and bake in the
16
+ * per-instance clone transform — exactly like cloned primitives
17
+ * (cylinder/sphere) do via {@link TransformablePrimitive.addPrimitiveShape}.
18
+ */
19
+ export class FrozenGeometry extends TransformablePrimitive {
20
+ source;
21
+ constructor(source) {
22
+ super();
23
+ this.source = source;
24
+ }
25
+ build(context) {
26
+ // Read the source's raw built edges rather than getShapes(): the original
27
+ // feature that consumes this geometry (e.g. the un-cloned sweep) may have
28
+ // marked it removed via removeShapes(), which getShapes() filters out. The
29
+ // edges themselves are unchanged, so we still want them — skipping only
30
+ // meta/guide shapes, matching how a spine/region is normally read.
31
+ for (const shape of this.source.getAddedShapes()) {
32
+ if (shape.isMetaShape() || shape.isGuideShape()) {
33
+ continue;
34
+ }
35
+ this.addPrimitiveShape(shape, context);
36
+ }
37
+ }
38
+ getDependencies() {
39
+ // The source is the pre-existing original, not part of the clone set; it is
40
+ // built earlier in the scene order, so we read its shapes at build time
41
+ // rather than depending on a clone of it.
42
+ return [];
43
+ }
44
+ createCopy(remap) {
45
+ return new FrozenGeometry(this.source).syncPrimitiveWith(this);
46
+ }
47
+ compareTo(other) {
48
+ if (!(other instanceof FrozenGeometry)) {
49
+ return false;
50
+ }
51
+ if (!super.compareTo(other)) {
52
+ return false;
53
+ }
54
+ return this.source.compareTo(other.source);
55
+ }
56
+ getType() {
57
+ return "frozen-geometry";
58
+ }
59
+ serialize() {
60
+ return {};
61
+ }
62
+ }
@@ -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
  };
@@ -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
+ }
@@ -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
  }