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.
- package/lib/dist/core/2d/aline.d.ts +0 -8
- package/lib/dist/core/2d/aline.js +3 -22
- package/lib/dist/core/2d/arc.d.ts +1 -23
- package/lib/dist/core/2d/arc.js +11 -28
- package/lib/dist/core/2d/circle.d.ts +1 -8
- package/lib/dist/core/2d/circle.js +7 -24
- package/lib/dist/core/2d/ellipse.d.ts +1 -17
- package/lib/dist/core/2d/ellipse.js +7 -45
- package/lib/dist/core/2d/hline.d.ts +0 -7
- package/lib/dist/core/2d/hline.js +5 -27
- package/lib/dist/core/2d/intersect.d.ts +0 -7
- package/lib/dist/core/2d/intersect.js +0 -13
- package/lib/dist/core/2d/line.d.ts +1 -8
- package/lib/dist/core/2d/line.js +6 -24
- package/lib/dist/core/2d/offset.d.ts +1 -11
- package/lib/dist/core/2d/offset.js +0 -16
- package/lib/dist/core/2d/polygon.d.ts +1 -17
- package/lib/dist/core/2d/polygon.js +18 -37
- package/lib/dist/core/2d/project.d.ts +0 -7
- package/lib/dist/core/2d/project.js +0 -13
- package/lib/dist/core/2d/rect.d.ts +1 -9
- package/lib/dist/core/2d/rect.js +0 -19
- package/lib/dist/core/2d/slot.d.ts +0 -23
- package/lib/dist/core/2d/slot.js +18 -37
- package/lib/dist/core/2d/text.d.ts +0 -7
- package/lib/dist/core/2d/text.js +0 -14
- package/lib/dist/core/2d/vline.d.ts +0 -7
- package/lib/dist/core/2d/vline.js +5 -23
- package/lib/dist/core/fillet.js +7 -0
- package/lib/dist/core/index.d.ts +1 -1
- package/lib/dist/core/interfaces.d.ts +43 -0
- package/lib/dist/features/2d/connect.d.ts +2 -0
- package/lib/dist/features/2d/connect.js +3 -0
- package/lib/dist/features/2d/intersect.js +4 -1
- package/lib/dist/features/frozen-geometry.d.ts +29 -0
- package/lib/dist/features/frozen-geometry.js +62 -0
- package/lib/dist/features/loft.d.ts +38 -1
- package/lib/dist/features/loft.js +133 -8
- package/lib/dist/features/rib.js +11 -1
- package/lib/dist/features/shell.d.ts +1 -1
- package/lib/dist/features/shell.js +9 -0
- package/lib/dist/features/sweep.d.ts +11 -1
- package/lib/dist/features/sweep.js +43 -7
- package/lib/dist/helpers/clone-transform.js +62 -5
- package/lib/dist/helpers/scene-helpers.d.ts +12 -0
- package/lib/dist/helpers/scene-helpers.js +26 -0
- package/lib/dist/math/bspline-interpolation.d.ts +33 -6
- package/lib/dist/math/bspline-interpolation.js +187 -84
- package/lib/dist/oc/loft/constrained-loft.d.ts +32 -0
- package/lib/dist/oc/loft/constrained-loft.js +61 -0
- package/lib/dist/oc/loft/curve-data.d.ts +18 -0
- package/lib/dist/oc/loft/curve-data.js +54 -0
- package/lib/dist/oc/loft/curve-eval.d.ts +23 -0
- package/lib/dist/oc/loft/curve-eval.js +84 -0
- package/lib/dist/oc/loft/guided-loft.d.ts +89 -0
- package/lib/dist/oc/loft/guided-loft.js +368 -0
- package/lib/dist/oc/loft/section-compatibility.d.ts +131 -0
- package/lib/dist/oc/loft/section-compatibility.js +481 -0
- package/lib/dist/oc/loft/section-curve.d.ts +53 -0
- package/lib/dist/oc/loft/section-curve.js +264 -0
- package/lib/dist/oc/loft/skinning.d.ts +104 -0
- package/lib/dist/oc/loft/skinning.js +299 -0
- package/lib/dist/oc/loft-ops.d.ts +28 -1
- package/lib/dist/oc/loft-ops.js +31 -1
- package/lib/dist/oc/ncollection.d.ts +19 -0
- package/lib/dist/oc/ncollection.js +69 -0
- package/lib/dist/oc/rib-ops.js +5 -17
- package/lib/dist/oc/wire-extend-ops.d.ts +12 -0
- package/lib/dist/oc/wire-extend-ops.js +42 -0
- package/lib/dist/oc/wire-ops.d.ts +11 -0
- package/lib/dist/oc/wire-ops.js +33 -0
- package/lib/dist/oc/wrap-ops.js +8 -15
- package/lib/dist/tests/features/2d/circle.test.js +0 -10
- package/lib/dist/tests/features/2d/ellipse.test.js +0 -30
- package/lib/dist/tests/features/2d/line.test.js +0 -5
- package/lib/dist/tests/features/2d/offset.test.js +0 -10
- package/lib/dist/tests/features/2d/polygon.test.js +0 -16
- package/lib/dist/tests/features/2d/rect.test.js +0 -11
- package/lib/dist/tests/features/2d/slot.test.js +0 -8
- package/lib/dist/tests/features/fillet2d.test.js +37 -1
- package/lib/dist/tests/features/loft-conditions.test.d.ts +1 -0
- package/lib/dist/tests/features/loft-conditions.test.js +246 -0
- package/lib/dist/tests/features/loft-guides.test.d.ts +1 -0
- package/lib/dist/tests/features/loft-guides.test.js +276 -0
- package/lib/dist/tests/features/repeat-sweep-borrowed-path.test.d.ts +1 -0
- package/lib/dist/tests/features/repeat-sweep-borrowed-path.test.js +35 -0
- package/lib/dist/tests/features/rib.test.js +50 -0
- package/lib/dist/tests/features/shell.test.js +35 -1
- package/lib/dist/tests/features/sweep.test.js +81 -0
- package/lib/dist/tests/features/text.test.js +33 -14
- package/lib/dist/tests/math/bspline-interpolation.test.js +97 -1
- package/lib/dist/tsconfig.tsbuildinfo +1 -1
- package/llm-docs/api/index.json +1 -1
- package/llm-docs/api/types/loft.md +36 -1
- package/llm-docs/api/types/sweep.md +13 -1
- package/llm-docs/api/types/text.md +2 -2
- package/llm-docs/index.json +5 -5
- package/package.json +1 -1
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { setupOC, render, addToScene } from "../setup.js";
|
|
3
|
+
import sketch from "../../core/sketch.js";
|
|
4
|
+
import plane from "../../core/plane.js";
|
|
5
|
+
import loft from "../../core/loft.js";
|
|
6
|
+
import extrude from "../../core/extrude.js";
|
|
7
|
+
import { rect, circle } from "../../core/2d/index.js";
|
|
8
|
+
import { countShapes } from "../utils.js";
|
|
9
|
+
import { ShapeProps } from "../../oc/props.js";
|
|
10
|
+
/** Volume of the loft's single solid. */
|
|
11
|
+
function volumeOf(l) {
|
|
12
|
+
const shapes = l.getShapes();
|
|
13
|
+
expect(shapes).toHaveLength(1);
|
|
14
|
+
return ShapeProps.getProperties(shapes[0].getShape()).volumeMm3;
|
|
15
|
+
}
|
|
16
|
+
function circlePair(diameter1, diameter2, height) {
|
|
17
|
+
const s1 = sketch("xy", () => {
|
|
18
|
+
circle(diameter1);
|
|
19
|
+
});
|
|
20
|
+
const s2 = sketch(plane("xy", { offset: height }), () => {
|
|
21
|
+
circle(diameter2);
|
|
22
|
+
});
|
|
23
|
+
return [s1, s2];
|
|
24
|
+
}
|
|
25
|
+
describe("loft start/end conditions", () => {
|
|
26
|
+
setupOC();
|
|
27
|
+
describe("normal condition", () => {
|
|
28
|
+
it("reproduces an exact cylinder for identical stacked circles", () => {
|
|
29
|
+
const [s1, s2] = circlePair(80, 80, 50);
|
|
30
|
+
const l = loft(s1, s2).startCondition("normal").endCondition("normal");
|
|
31
|
+
render();
|
|
32
|
+
// Perpendicular takeoff + identical profiles ⇒ straight sides.
|
|
33
|
+
const expected = Math.PI * 40 * 40 * 50;
|
|
34
|
+
expect(Math.abs(volumeOf(l) - expected) / expected).toBeLessThan(1e-4);
|
|
35
|
+
});
|
|
36
|
+
it("reproduces an exact box for identical stacked rectangles", () => {
|
|
37
|
+
const s1 = sketch("xy", () => {
|
|
38
|
+
rect(100, 50).centered();
|
|
39
|
+
});
|
|
40
|
+
const s2 = sketch(plane("xy", { offset: 40 }), () => {
|
|
41
|
+
rect(100, 50).centered();
|
|
42
|
+
});
|
|
43
|
+
const l = loft(s1, s2).startCondition("normal").endCondition("normal");
|
|
44
|
+
const sides = l.sideFaces();
|
|
45
|
+
addToScene(sides);
|
|
46
|
+
render();
|
|
47
|
+
const expected = 100 * 50 * 40;
|
|
48
|
+
expect(Math.abs(volumeOf(l) - expected) / expected).toBeLessThan(1e-4);
|
|
49
|
+
// Profile corners split the wall into one face per side, so the
|
|
50
|
+
// corners are real edges (renderable, selectable, filletable).
|
|
51
|
+
expect(sides.getShapes()).toHaveLength(4);
|
|
52
|
+
});
|
|
53
|
+
it("bulges a tapered loft outward compared to the unconstrained loft", () => {
|
|
54
|
+
const [p1, p2] = circlePair(80, 40, 50);
|
|
55
|
+
const plain = loft(p1, p2);
|
|
56
|
+
render();
|
|
57
|
+
const plainVolume = volumeOf(plain);
|
|
58
|
+
const [c1, c2] = circlePair(80, 40, 50);
|
|
59
|
+
const conditioned = loft(c1, c2).startCondition("normal").endCondition("normal");
|
|
60
|
+
render();
|
|
61
|
+
// Perpendicular takeoff at both ends swells the taper's waist.
|
|
62
|
+
expect(volumeOf(conditioned)).toBeGreaterThan(plainVolume * 1.02);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
describe("tangent condition", () => {
|
|
66
|
+
it("turns identical stacked circles into a barrel", () => {
|
|
67
|
+
const [s1, s2] = circlePair(80, 80, 50);
|
|
68
|
+
const l = loft(s1, s2).startCondition("tangent").endCondition("tangent");
|
|
69
|
+
render();
|
|
70
|
+
const cylinder = Math.PI * 40 * 40 * 50;
|
|
71
|
+
const volume = volumeOf(l);
|
|
72
|
+
expect(volume).toBeGreaterThan(cylinder * 1.1);
|
|
73
|
+
expect(volume).toBeLessThan(cylinder * 2.0);
|
|
74
|
+
});
|
|
75
|
+
it("bulges more with a larger magnitude", () => {
|
|
76
|
+
const [s1, s2] = circlePair(80, 80, 50);
|
|
77
|
+
const smaller = loft(s1, s2).startCondition("tangent", 0.5).endCondition("tangent", 0.5);
|
|
78
|
+
render();
|
|
79
|
+
const smallerVolume = volumeOf(smaller);
|
|
80
|
+
const [b1, b2] = circlePair(80, 80, 50);
|
|
81
|
+
const bigger = loft(b1, b2).startCondition("tangent", 1.5).endCondition("tangent", 1.5);
|
|
82
|
+
render();
|
|
83
|
+
expect(volumeOf(bigger)).toBeGreaterThan(smallerVolume * 1.05);
|
|
84
|
+
});
|
|
85
|
+
it("pinches inward with a negative magnitude", () => {
|
|
86
|
+
const [s1, s2] = circlePair(80, 80, 50);
|
|
87
|
+
const l = loft(s1, s2).startCondition("tangent", -1).endCondition("tangent", -1);
|
|
88
|
+
render();
|
|
89
|
+
const cylinder = Math.PI * 40 * 40 * 50;
|
|
90
|
+
expect(volumeOf(l)).toBeLessThan(cylinder * 0.9);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
describe("single-ended and mixed conditions", () => {
|
|
94
|
+
it("supports a start condition alone", () => {
|
|
95
|
+
const [s1, s2] = circlePair(80, 40, 50);
|
|
96
|
+
const l = loft(s1, s2).startCondition("normal");
|
|
97
|
+
render();
|
|
98
|
+
expect(volumeOf(l)).toBeGreaterThan(0);
|
|
99
|
+
});
|
|
100
|
+
it("supports an end condition alone", () => {
|
|
101
|
+
const [s1, s2] = circlePair(80, 40, 50);
|
|
102
|
+
const l = loft(s1, s2).endCondition("tangent");
|
|
103
|
+
render();
|
|
104
|
+
expect(volumeOf(l)).toBeGreaterThan(0);
|
|
105
|
+
});
|
|
106
|
+
it("resets a condition with 'none'", () => {
|
|
107
|
+
const [p1, p2] = circlePair(80, 80, 50);
|
|
108
|
+
const plain = loft(p1, p2);
|
|
109
|
+
render();
|
|
110
|
+
const plainVolume = volumeOf(plain);
|
|
111
|
+
const [s1, s2] = circlePair(80, 80, 50);
|
|
112
|
+
const l = loft(s1, s2).startCondition("tangent").startCondition("none");
|
|
113
|
+
render();
|
|
114
|
+
expect(Math.abs(volumeOf(l) - plainVolume) / plainVolume).toBeLessThan(1e-3);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
describe("mixed profile kinds", () => {
|
|
118
|
+
it("lofts a rectangle to a circle with conditions (polynomial fallback)", () => {
|
|
119
|
+
const s1 = sketch("xy", () => {
|
|
120
|
+
rect(60, 60).centered();
|
|
121
|
+
});
|
|
122
|
+
const s2 = sketch(plane("xy", { offset: 50 }), () => {
|
|
123
|
+
circle(60);
|
|
124
|
+
});
|
|
125
|
+
const l = loft(s1, s2).startCondition("normal").endCondition("normal");
|
|
126
|
+
render();
|
|
127
|
+
const volume = volumeOf(l);
|
|
128
|
+
// Between the two prisms the shape must live inside.
|
|
129
|
+
expect(volume).toBeGreaterThan(Math.PI * 30 * 30 * 50 * 0.5);
|
|
130
|
+
expect(volume).toBeLessThan(60 * 60 * 50 * 1.5);
|
|
131
|
+
});
|
|
132
|
+
it("lofts through three profiles with end conditions", () => {
|
|
133
|
+
const s1 = sketch("xy", () => {
|
|
134
|
+
circle(60);
|
|
135
|
+
});
|
|
136
|
+
const s2 = sketch(plane("xy", { offset: 25 }), () => {
|
|
137
|
+
circle(100);
|
|
138
|
+
});
|
|
139
|
+
const s3 = sketch(plane("xy", { offset: 50 }), () => {
|
|
140
|
+
circle(60);
|
|
141
|
+
});
|
|
142
|
+
const l = loft(s1, s2, s3).startCondition("normal").endCondition("normal");
|
|
143
|
+
render();
|
|
144
|
+
expect(volumeOf(l)).toBeGreaterThan(0);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
describe("classification and scene interplay", () => {
|
|
148
|
+
it("classifies start, end and side faces", () => {
|
|
149
|
+
const [s1, s2] = circlePair(80, 40, 50);
|
|
150
|
+
const l = loft(s1, s2).startCondition("normal").endCondition("normal");
|
|
151
|
+
const sf = l.startFaces();
|
|
152
|
+
const ef = l.endFaces();
|
|
153
|
+
const sidf = l.sideFaces();
|
|
154
|
+
addToScene(sf);
|
|
155
|
+
addToScene(ef);
|
|
156
|
+
addToScene(sidf);
|
|
157
|
+
render();
|
|
158
|
+
expect(sf.getShapes()).toHaveLength(1);
|
|
159
|
+
expect(ef.getShapes()).toHaveLength(1);
|
|
160
|
+
expect(sidf.getShapes().length).toBeGreaterThan(0);
|
|
161
|
+
});
|
|
162
|
+
it("fuses with existing scene geometry", () => {
|
|
163
|
+
const base = sketch("xy", () => {
|
|
164
|
+
rect(200, 200).centered();
|
|
165
|
+
});
|
|
166
|
+
extrude(-20, base);
|
|
167
|
+
const [s1, s2] = circlePair(80, 40, 50);
|
|
168
|
+
loft(s1, s2).startCondition("tangent");
|
|
169
|
+
const scene = render();
|
|
170
|
+
expect(countShapes(scene)).toBe(1);
|
|
171
|
+
});
|
|
172
|
+
it("cuts with remove mode", () => {
|
|
173
|
+
const base = sketch("xy", () => {
|
|
174
|
+
rect(200, 200).centered();
|
|
175
|
+
});
|
|
176
|
+
extrude(50, base);
|
|
177
|
+
const [s1, s2] = circlePair(80, 80, 50);
|
|
178
|
+
const l = loft(s1, s2).startCondition("normal").endCondition("normal").remove();
|
|
179
|
+
render();
|
|
180
|
+
// Normal-normal between identical circles is an exact cylinder, so the
|
|
181
|
+
// cut leaves the box volume minus the cylinder.
|
|
182
|
+
const shapes = l.getShapes();
|
|
183
|
+
expect(shapes).toHaveLength(1);
|
|
184
|
+
const remaining = ShapeProps.getProperties(shapes[0].getShape()).volumeMm3;
|
|
185
|
+
const expected = 200 * 200 * 50 - Math.PI * 40 * 40 * 50;
|
|
186
|
+
expect(Math.abs(remaining - expected) / expected).toBeLessThan(1e-3);
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
describe("thin loft with conditions", () => {
|
|
190
|
+
it("builds a thin square-to-circle transition without booleans", () => {
|
|
191
|
+
const p1 = sketch("xy", () => {
|
|
192
|
+
rect(70, 70).centered();
|
|
193
|
+
});
|
|
194
|
+
const p2 = sketch(plane("xy", { offset: 80 }), () => {
|
|
195
|
+
circle(30);
|
|
196
|
+
});
|
|
197
|
+
const l = loft(p1, p2).startCondition("normal", 1).thin(2);
|
|
198
|
+
const sides = l.sideFaces();
|
|
199
|
+
const caps = l.startFaces();
|
|
200
|
+
addToScene(sides);
|
|
201
|
+
addToScene(caps);
|
|
202
|
+
render();
|
|
203
|
+
expect(l.getError()).toBeNull();
|
|
204
|
+
const shapes = l.getShapes();
|
|
205
|
+
expect(shapes).toHaveLength(1);
|
|
206
|
+
// A ~2mm wall over an ~80-tall transition: far less material than the
|
|
207
|
+
// filled loft, far more than nothing. (The walls + ring caps are
|
|
208
|
+
// assembled directly — a boolean between the two shells used to take
|
|
209
|
+
// seconds.)
|
|
210
|
+
const volume = ShapeProps.getProperties(shapes[0].getShape()).volumeMm3;
|
|
211
|
+
expect(volume).toBeGreaterThan(10000);
|
|
212
|
+
expect(volume).toBeLessThan(60000);
|
|
213
|
+
// The outward offset rounds the outer wall's corners into arcs; the
|
|
214
|
+
// line→arc curvature jumps must still split faces (8 outer: 4 flats +
|
|
215
|
+
// 4 corner bands) alongside the sharp inner wall's 4 — otherwise the
|
|
216
|
+
// outer corners render smeared with no edges to select.
|
|
217
|
+
expect(sides.getShapes()).toHaveLength(12);
|
|
218
|
+
expect(caps.getShapes()).toHaveLength(1);
|
|
219
|
+
});
|
|
220
|
+
it("builds a thin-walled barrel", () => {
|
|
221
|
+
const [s1, s2] = circlePair(80, 80, 50);
|
|
222
|
+
const l = loft(s1, s2).thin(-4).startCondition("tangent").endCondition("tangent");
|
|
223
|
+
render();
|
|
224
|
+
const shapes = l.getShapes();
|
|
225
|
+
expect(shapes).toHaveLength(1);
|
|
226
|
+
const volume = ShapeProps.getProperties(shapes[0].getShape()).volumeMm3;
|
|
227
|
+
const solidBarrelUpperBound = Math.PI * 40 * 40 * 50 * 2;
|
|
228
|
+
expect(volume).toBeGreaterThan(0);
|
|
229
|
+
expect(volume).toBeLessThan(solidBarrelUpperBound * 0.5);
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
describe("validation", () => {
|
|
233
|
+
it("rejects a magnitude with 'none'", () => {
|
|
234
|
+
const [s1, s2] = circlePair(80, 40, 50);
|
|
235
|
+
expect(() => loft(s1, s2).startCondition("none", 2)).toThrow(/magnitude has no effect/);
|
|
236
|
+
});
|
|
237
|
+
it("rejects unknown condition types", () => {
|
|
238
|
+
const [s1, s2] = circlePair(80, 40, 50);
|
|
239
|
+
expect(() => loft(s1, s2).startCondition("smooth")).toThrow(/expected 'none', 'normal' or 'tangent'/);
|
|
240
|
+
});
|
|
241
|
+
it("rejects a zero magnitude", () => {
|
|
242
|
+
const [s1, s2] = circlePair(80, 40, 50);
|
|
243
|
+
expect(() => loft(s1, s2).endCondition("normal", 0)).toThrow(/non-zero/);
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { setupOC, render, addToScene } from "../setup.js";
|
|
3
|
+
import sketch from "../../core/sketch.js";
|
|
4
|
+
import plane from "../../core/plane.js";
|
|
5
|
+
import loft from "../../core/loft.js";
|
|
6
|
+
import local from "../../core/local.js";
|
|
7
|
+
import mirror from "../../core/mirror.js";
|
|
8
|
+
import { rect, circle, move, vLine, bezier, polygon } from "../../core/2d/index.js";
|
|
9
|
+
import { ShapeOps } from "../../oc/shape-ops.js";
|
|
10
|
+
import { ShapeProps } from "../../oc/props.js";
|
|
11
|
+
import { getOC } from "../../oc/init.js";
|
|
12
|
+
function volumeOf(l) {
|
|
13
|
+
const shapes = l.getShapes();
|
|
14
|
+
expect(shapes).toHaveLength(1);
|
|
15
|
+
return ShapeProps.getProperties(shapes[0].getShape()).volumeMm3;
|
|
16
|
+
}
|
|
17
|
+
/** Two identical circles (diameter 80) at z = 0 and z = 60. */
|
|
18
|
+
function circleProfiles() {
|
|
19
|
+
const s1 = sketch("xy", () => {
|
|
20
|
+
circle(80);
|
|
21
|
+
});
|
|
22
|
+
const s2 = sketch(plane("xy", { offset: 60 }), () => {
|
|
23
|
+
circle(80);
|
|
24
|
+
});
|
|
25
|
+
return [s1, s2];
|
|
26
|
+
}
|
|
27
|
+
describe("loft guides", () => {
|
|
28
|
+
setupOC();
|
|
29
|
+
describe("single guide", () => {
|
|
30
|
+
it("reproduces a cylinder with a straight guide along the side", () => {
|
|
31
|
+
const [s1, s2] = circleProfiles();
|
|
32
|
+
// Straight rail touching both circles at (40, 0, z).
|
|
33
|
+
const guide = sketch("xz", () => {
|
|
34
|
+
move([40, 0]);
|
|
35
|
+
vLine(60);
|
|
36
|
+
});
|
|
37
|
+
const l = loft(s1, s2).guides(guide);
|
|
38
|
+
render();
|
|
39
|
+
const cylinder = Math.PI * 40 * 40 * 60;
|
|
40
|
+
expect(Math.abs(volumeOf(l) - cylinder) / cylinder).toBeLessThan(0.02);
|
|
41
|
+
});
|
|
42
|
+
it("follows a bulged guide sideways", () => {
|
|
43
|
+
const [s1, s2] = circleProfiles();
|
|
44
|
+
// Rail bowing out to x ≈ 52.5 at mid-height.
|
|
45
|
+
const guide = sketch("xz", () => {
|
|
46
|
+
bezier([40, 0], [65, 30], [40, 60]);
|
|
47
|
+
});
|
|
48
|
+
const l = loft(s1, s2).guides(guide);
|
|
49
|
+
render();
|
|
50
|
+
const shapes = l.getShapes();
|
|
51
|
+
expect(shapes).toHaveLength(1);
|
|
52
|
+
// The section rides the rail: the mid of the solid shifts in +x,
|
|
53
|
+
// so it must reach past the straight cylinder's x = 40 side.
|
|
54
|
+
const bbox = ShapeOps.getBoundingBox(shapes[0]);
|
|
55
|
+
expect(bbox.maxX).toBeGreaterThan(46);
|
|
56
|
+
expect(bbox.minZ).toBeCloseTo(0, 0);
|
|
57
|
+
expect(bbox.maxZ).toBeCloseTo(60, 0);
|
|
58
|
+
// Riding a rail translates sections without distorting them, so the
|
|
59
|
+
// volume stays near the straight cylinder's (and a broken sweep frame
|
|
60
|
+
// would show up here as a degenerate or wildly wrong solid).
|
|
61
|
+
const cylinder = Math.PI * 40 * 40 * 60;
|
|
62
|
+
const volume = ShapeProps.getProperties(shapes[0].getShape()).volumeMm3;
|
|
63
|
+
expect(volume).toBeGreaterThan(cylinder * 0.85);
|
|
64
|
+
expect(volume).toBeLessThan(cylinder * 1.3);
|
|
65
|
+
});
|
|
66
|
+
it("consumes the guide sketch", () => {
|
|
67
|
+
const [s1, s2] = circleProfiles();
|
|
68
|
+
const guide = sketch("xz", () => {
|
|
69
|
+
move([40, 0]);
|
|
70
|
+
vLine(60);
|
|
71
|
+
});
|
|
72
|
+
loft(s1, s2).guides(guide);
|
|
73
|
+
render();
|
|
74
|
+
expect(guide.getShapes()).toHaveLength(0);
|
|
75
|
+
});
|
|
76
|
+
it("classifies start and end faces", () => {
|
|
77
|
+
const [s1, s2] = circleProfiles();
|
|
78
|
+
const guide = sketch("xz", () => {
|
|
79
|
+
move([40, 0]);
|
|
80
|
+
vLine(60);
|
|
81
|
+
});
|
|
82
|
+
const l = loft(s1, s2).guides(guide);
|
|
83
|
+
const sf = l.startFaces();
|
|
84
|
+
const ef = l.endFaces();
|
|
85
|
+
addToScene(sf);
|
|
86
|
+
addToScene(ef);
|
|
87
|
+
render();
|
|
88
|
+
expect(sf.getShapes()).toHaveLength(1);
|
|
89
|
+
expect(ef.getShapes()).toHaveLength(1);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
describe("two guides", () => {
|
|
93
|
+
it("accepts one sketch carrying two separate guide curves (mirror)", () => {
|
|
94
|
+
// Square (corners at ±25√2 on the sketch axes) lofted to a circle,
|
|
95
|
+
// steered by a bezier rail and its mirror sketched together.
|
|
96
|
+
const p1 = sketch("top", () => {
|
|
97
|
+
polygon(4, 50, "circumscribed");
|
|
98
|
+
});
|
|
99
|
+
const p2 = sketch(plane("top", 80), () => {
|
|
100
|
+
circle(30);
|
|
101
|
+
});
|
|
102
|
+
const g1 = sketch("right", () => {
|
|
103
|
+
bezier([Math.sqrt(2) * 25, 0], [50, 40], [15, 80]);
|
|
104
|
+
mirror(local("y"));
|
|
105
|
+
}).reusable();
|
|
106
|
+
const l = loft(p1, p2).guides(g1);
|
|
107
|
+
const sides = l.sideFaces();
|
|
108
|
+
addToScene(sides);
|
|
109
|
+
render();
|
|
110
|
+
expect(l.getError()).toBeNull();
|
|
111
|
+
const shapes = l.getShapes();
|
|
112
|
+
expect(shapes).toHaveLength(1);
|
|
113
|
+
expect(ShapeProps.getProperties(shapes[0].getShape()).volumeMm3).toBeGreaterThan(0);
|
|
114
|
+
// The square's corners must be real edges: one wall face per side.
|
|
115
|
+
expect(sides.getShapes()).toHaveLength(4);
|
|
116
|
+
// Both rails bow out past the square's corners (±25√2 ≈ ±35.36).
|
|
117
|
+
const bbox = ShapeOps.getBoundingBox(shapes[0]);
|
|
118
|
+
expect(Math.max(bbox.maxX, bbox.maxY)).toBeGreaterThan(36);
|
|
119
|
+
expect(Math.min(bbox.minX, bbox.minY)).toBeLessThan(-36);
|
|
120
|
+
expect(bbox.maxZ).toBeCloseTo(80, 0);
|
|
121
|
+
});
|
|
122
|
+
it("keeps the railed corner edges exactly on both rails (vertex matching)", () => {
|
|
123
|
+
const p1 = sketch("top", () => {
|
|
124
|
+
polygon(4, 50, "circumscribed");
|
|
125
|
+
});
|
|
126
|
+
const p2 = sketch(plane("top", 80), () => {
|
|
127
|
+
circle(30);
|
|
128
|
+
});
|
|
129
|
+
const g1 = sketch("right", () => {
|
|
130
|
+
bezier([Math.sqrt(2) * 25, 0], [50, 40], [15, 80]);
|
|
131
|
+
mirror(local("y"));
|
|
132
|
+
}).reusable();
|
|
133
|
+
const l = loft(p1, p2).guides(g1);
|
|
134
|
+
const sideEdges = l.sideEdges();
|
|
135
|
+
addToScene(sideEdges);
|
|
136
|
+
render();
|
|
137
|
+
// Rail radius at height z for bezier [(25√2,0),(50,40),(15,80)] (z = 80t).
|
|
138
|
+
const railRadius = (z) => {
|
|
139
|
+
const t = z / 80;
|
|
140
|
+
return (1 - t) * (1 - t) * Math.sqrt(2) * 25 + 2 * t * (1 - t) * 50 + t * t * 15;
|
|
141
|
+
};
|
|
142
|
+
// Exactly two corner edges (one per rail) must lie on the rails all
|
|
143
|
+
// the way up — no sideways drift, no radial deviation. The two
|
|
144
|
+
// un-railed corners taper straight and must not match.
|
|
145
|
+
const oc = getOC();
|
|
146
|
+
const point = new oc.gp_Pnt();
|
|
147
|
+
let railedEdges = 0;
|
|
148
|
+
for (const edge of sideEdges.getShapes()) {
|
|
149
|
+
const raw = oc.TopoDS.Edge(edge.getShape());
|
|
150
|
+
const info = oc.BRep_Tool.Curve(raw, 0, 1);
|
|
151
|
+
let maxDeviation = 0;
|
|
152
|
+
for (let i = 0; i <= 16; i++) {
|
|
153
|
+
const t = info.First + ((info.Last - info.First) * i) / 16;
|
|
154
|
+
info.returnValue.D0(t, point);
|
|
155
|
+
const inPlane = Math.max(Math.abs(point.X()), Math.abs(point.Y()));
|
|
156
|
+
const offPlane = Math.min(Math.abs(point.X()), Math.abs(point.Y()));
|
|
157
|
+
maxDeviation = Math.max(maxDeviation, Math.abs(inPlane - railRadius(point.Z())), offPlane);
|
|
158
|
+
}
|
|
159
|
+
if (maxDeviation < 0.1) {
|
|
160
|
+
railedEdges++;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
point.delete();
|
|
164
|
+
expect(railedEdges).toBe(2);
|
|
165
|
+
});
|
|
166
|
+
it("rides both rails, bulging both sides of a rectangular loft", () => {
|
|
167
|
+
const s1 = sketch("xy", () => {
|
|
168
|
+
rect(80, 40).centered();
|
|
169
|
+
});
|
|
170
|
+
const s2 = sketch(plane("xy", { offset: 60 }), () => {
|
|
171
|
+
rect(80, 40).centered();
|
|
172
|
+
});
|
|
173
|
+
const right = sketch("xz", () => {
|
|
174
|
+
bezier([40, 0], [55, 30], [40, 60]);
|
|
175
|
+
});
|
|
176
|
+
const left = sketch("xz", () => {
|
|
177
|
+
bezier([-40, 0], [-55, 30], [-40, 60]);
|
|
178
|
+
});
|
|
179
|
+
const l = loft(s1, s2).guides(right, left);
|
|
180
|
+
render();
|
|
181
|
+
const shapes = l.getShapes();
|
|
182
|
+
expect(shapes).toHaveLength(1);
|
|
183
|
+
const prism = 80 * 40 * 60;
|
|
184
|
+
const volume = ShapeProps.getProperties(shapes[0].getShape()).volumeMm3;
|
|
185
|
+
expect(volume).toBeGreaterThan(prism * 1.02);
|
|
186
|
+
expect(volume).toBeLessThan(prism * 1.5);
|
|
187
|
+
const bbox = ShapeOps.getBoundingBox(shapes[0]);
|
|
188
|
+
expect(bbox.maxX).toBeGreaterThan(42);
|
|
189
|
+
expect(bbox.minX).toBeLessThan(-42);
|
|
190
|
+
// The rails only pull in ±x — the section must not rotate or smear
|
|
191
|
+
// into y (B-rep bounds overshoot control hulls, hence the slack).
|
|
192
|
+
expect(bbox.maxY).toBeLessThan(26);
|
|
193
|
+
expect(bbox.minY).toBeGreaterThan(-26);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
describe("guides with conditions", () => {
|
|
197
|
+
function guidedSquareToCircle(withCondition) {
|
|
198
|
+
const p1 = sketch("top", () => {
|
|
199
|
+
polygon(4, 50, "circumscribed");
|
|
200
|
+
});
|
|
201
|
+
const p2 = sketch(plane("top", 80), () => {
|
|
202
|
+
circle(30);
|
|
203
|
+
});
|
|
204
|
+
const g1 = sketch("right", () => {
|
|
205
|
+
bezier([Math.sqrt(2) * 25, 0], [50, 40], [15, 80]);
|
|
206
|
+
mirror(local("y"));
|
|
207
|
+
}).reusable();
|
|
208
|
+
// `.new()` — the two variants overlap almost everywhere; fusing two
|
|
209
|
+
// nearly-coincident B-spline solids is exactly the boolean OCC hates.
|
|
210
|
+
const l = loft(p1, p2).guides(g1).new();
|
|
211
|
+
if (withCondition) {
|
|
212
|
+
l.startCondition("normal");
|
|
213
|
+
}
|
|
214
|
+
return l;
|
|
215
|
+
}
|
|
216
|
+
it("applies a start condition away from the guide contacts", () => {
|
|
217
|
+
const plain = guidedSquareToCircle(false);
|
|
218
|
+
render();
|
|
219
|
+
expect(plain.getError()).toBeNull();
|
|
220
|
+
const plainVolume = ShapeProps.getProperties(plain.getShapes()[0].getShape()).volumeMm3;
|
|
221
|
+
const conditioned = guidedSquareToCircle(true);
|
|
222
|
+
render();
|
|
223
|
+
expect(conditioned.getError()).toBeNull();
|
|
224
|
+
const shapes = conditioned.getShapes();
|
|
225
|
+
expect(shapes).toHaveLength(1);
|
|
226
|
+
// The normal takeoff swells the un-guided sides of the square, so the
|
|
227
|
+
// conditioned loft encloses more material than the guided-only one —
|
|
228
|
+
// while the rails still pin the bulge to the same overall envelope.
|
|
229
|
+
const volume = ShapeProps.getProperties(shapes[0].getShape()).volumeMm3;
|
|
230
|
+
expect(volume).toBeGreaterThan(plainVolume * 1.01);
|
|
231
|
+
expect(volume).toBeLessThan(plainVolume * 1.5);
|
|
232
|
+
});
|
|
233
|
+
it("supports conditions on both ends alongside a guide", () => {
|
|
234
|
+
const [s1, s2] = circleProfiles();
|
|
235
|
+
const guide = sketch("xz", () => {
|
|
236
|
+
move([40, 0]);
|
|
237
|
+
vLine(60);
|
|
238
|
+
});
|
|
239
|
+
const l = loft(s1, s2)
|
|
240
|
+
.guides(guide)
|
|
241
|
+
.startCondition("tangent")
|
|
242
|
+
.endCondition("tangent");
|
|
243
|
+
render();
|
|
244
|
+
expect(l.getError()).toBeNull();
|
|
245
|
+
const shapes = l.getShapes();
|
|
246
|
+
expect(shapes).toHaveLength(1);
|
|
247
|
+
// Straight rail + tangent bulge: more volume than the plain cylinder,
|
|
248
|
+
// but the rail keeps its side straight so less than the free barrel.
|
|
249
|
+
const cylinder = Math.PI * 40 * 40 * 60;
|
|
250
|
+
const volume = ShapeProps.getProperties(shapes[0].getShape()).volumeMm3;
|
|
251
|
+
expect(volume).toBeGreaterThan(cylinder * 1.05);
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
describe("validation", () => {
|
|
255
|
+
it("rejects calling guides() with no arguments", () => {
|
|
256
|
+
const [s1, s2] = circleProfiles();
|
|
257
|
+
expect(() => loft(s1, s2).guides()).toThrow(/at least one guide/);
|
|
258
|
+
});
|
|
259
|
+
it("rejects more than two guides", () => {
|
|
260
|
+
const [s1, s2] = circleProfiles();
|
|
261
|
+
const g1 = sketch("xz", () => { move([40, 0]); vLine(60); });
|
|
262
|
+
const g2 = sketch("xz", () => { move([-40, 0]); vLine(60); });
|
|
263
|
+
const g3 = sketch("yz", () => { move([40, 0]); vLine(60); });
|
|
264
|
+
const l = loft(s1, s2).guides(g1, g2, g3);
|
|
265
|
+
render();
|
|
266
|
+
expect(l.getError()).toContain("at most two guide curves");
|
|
267
|
+
});
|
|
268
|
+
it("rejects combining guides with thin mode", () => {
|
|
269
|
+
const [s1, s2] = circleProfiles();
|
|
270
|
+
const guide = sketch("xz", () => { move([40, 0]); vLine(60); });
|
|
271
|
+
const l = loft(s1, s2).guides(guide).thin(-3);
|
|
272
|
+
render();
|
|
273
|
+
expect(l.getError()).toContain("thin");
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { setupOC, render } from "../setup.js";
|
|
3
|
+
import sketch from "../../core/sketch.js";
|
|
4
|
+
import sweep from "../../core/sweep.js";
|
|
5
|
+
import repeat from "../../core/repeat.js";
|
|
6
|
+
import fillet from "../../core/fillet.js";
|
|
7
|
+
import { hLine, vLine, circle } from "../../core/2d/index.js";
|
|
8
|
+
import { countShapes } from "../utils.js";
|
|
9
|
+
// Regression: circular-repeating a sweep whose PATH is a bare curve borrowed
|
|
10
|
+
// from another sketch (`pathSketch.regions.corner`). cloneWithTransform used to
|
|
11
|
+
// deep-clone the path's 2D source chain (the fillet and the lines behind it)
|
|
12
|
+
// out of its sketch and parent it under the repeat container. With no Sketch
|
|
13
|
+
// ancestor those clones threw "Cannot read properties of null (reading
|
|
14
|
+
// 'getPlane')". The borrowed path is now frozen into a transformed shape clone
|
|
15
|
+
// instead of being rebuilt.
|
|
16
|
+
describe("repeat circular of a sweep with a borrowed in-sketch path", () => {
|
|
17
|
+
setupOC();
|
|
18
|
+
it("renders every instance without orphaning the path's 2D source curves", () => {
|
|
19
|
+
const pathSketch = sketch("front", () => {
|
|
20
|
+
const up = vLine(20);
|
|
21
|
+
const across = hLine(20);
|
|
22
|
+
const corner = fillet(5, up, across).reusable();
|
|
23
|
+
return { corner };
|
|
24
|
+
});
|
|
25
|
+
sketch("top", () => {
|
|
26
|
+
circle(2);
|
|
27
|
+
});
|
|
28
|
+
const s1 = sweep(pathSketch.regions.corner);
|
|
29
|
+
repeat("circular", "z", { count: 3, angle: 360 }, s1);
|
|
30
|
+
const scene = render();
|
|
31
|
+
const errored = scene.getAllSceneObjects().filter(o => o.getError());
|
|
32
|
+
expect(errored.map(o => `${o.getType()}: ${o.getError()}`)).toEqual([]);
|
|
33
|
+
expect(countShapes(scene)).toBeGreaterThan(0);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
@@ -5,6 +5,7 @@ import extrude from "../../core/extrude.js";
|
|
|
5
5
|
import shell from "../../core/shell.js";
|
|
6
6
|
import fillet from "../../core/fillet.js";
|
|
7
7
|
import rib from "../../core/rib.js";
|
|
8
|
+
import plane from "../../core/plane.js";
|
|
8
9
|
import { rect, circle, move, aLine, hLine } from "../../core/2d/index.js";
|
|
9
10
|
import { Rib } from "../../features/rib.js";
|
|
10
11
|
import { ShapeOps } from "../../oc/shape-ops.js";
|
|
@@ -277,6 +278,55 @@ describe("rib", () => {
|
|
|
277
278
|
expect(cVol / origVol).toBeLessThan(1.3);
|
|
278
279
|
}
|
|
279
280
|
});
|
|
281
|
+
it("repeat mirror keeps the parallel rib on the correct side", async () => {
|
|
282
|
+
const repeatModule = await import("../../core/repeat.js");
|
|
283
|
+
const repeat = repeatModule.default;
|
|
284
|
+
// Box centered on the origin and symmetric across the front (XZ) plane,
|
|
285
|
+
// so mirroring across "front" (Y → −Y) maps the scope onto itself. The
|
|
286
|
+
// correctly-mirrored rib is then congruent to the original reflected in
|
|
287
|
+
// Y only — its X and Z position are unchanged.
|
|
288
|
+
sketch("top", () => {
|
|
289
|
+
rect(100, 100).centered();
|
|
290
|
+
});
|
|
291
|
+
const box = extrude(60);
|
|
292
|
+
const s = shell(-4, box.endFaces());
|
|
293
|
+
// Diagonal spine on an offset front plane (Y = 20), placed off-centre in
|
|
294
|
+
// the XZ plane so that a flipped in-plane extrude direction would throw
|
|
295
|
+
// the rib to a clearly different X/Z position.
|
|
296
|
+
sketch(plane("front", 20), () => {
|
|
297
|
+
move([-30, 10]);
|
|
298
|
+
aLine(45, 25);
|
|
299
|
+
});
|
|
300
|
+
const r = rib(5).parallel().new().scope(s).extend();
|
|
301
|
+
repeat("mirror", "front", r);
|
|
302
|
+
render();
|
|
303
|
+
const bboxCenter = (shape) => {
|
|
304
|
+
const bb = ShapeOps.getBoundingBox(shape);
|
|
305
|
+
return {
|
|
306
|
+
x: (bb.minX + bb.maxX) / 2,
|
|
307
|
+
y: (bb.minY + bb.maxY) / 2,
|
|
308
|
+
z: (bb.minZ + bb.maxZ) / 2,
|
|
309
|
+
};
|
|
310
|
+
};
|
|
311
|
+
expect(r.getShapes().length).toBeGreaterThan(0);
|
|
312
|
+
const orig = bboxCenter(r.getShapes()[0]);
|
|
313
|
+
const sceneMod = await import("../../scene-manager.js");
|
|
314
|
+
const scene = sceneMod.getCurrentScene();
|
|
315
|
+
const ribClones = scene.getSceneObjects().filter(o => o instanceof Rib && o !== r &&
|
|
316
|
+
o.getCloneSource() === r);
|
|
317
|
+
expect(ribClones.length).toBe(1);
|
|
318
|
+
const clone = ribClones[0];
|
|
319
|
+
expect(clone.getShapes().length).toBeGreaterThan(0);
|
|
320
|
+
const cl = bboxCenter(clone.getShapes()[0]);
|
|
321
|
+
// Mirroring across the front (XZ) plane flips Y only: the in-plane (X, Z)
|
|
322
|
+
// position must be preserved. Before the fix the clone's perpendicular
|
|
323
|
+
// extrude direction was negated (a cross product is a pseudovector under
|
|
324
|
+
// reflection), throwing the rib to the opposite side — its X/Z centre
|
|
325
|
+
// lands far from the original.
|
|
326
|
+
expect(Math.abs(cl.x - orig.x)).toBeLessThan(1);
|
|
327
|
+
expect(Math.abs(cl.z - orig.z)).toBeLessThan(1);
|
|
328
|
+
expect(Math.abs(cl.y + orig.y)).toBeLessThan(1);
|
|
329
|
+
});
|
|
280
330
|
it("extended rib in .new() mode unifies coplanar artifact faces", () => {
|
|
281
331
|
// The slab clips and scope cut leave coplanar sub-faces and seam edges
|
|
282
332
|
// on flat walls of the rib. UnifySameDomain post-pass should merge
|