fluidcad 0.0.36 → 0.0.37
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/LICENSE.txt +21 -504
- package/README.md +1 -1
- package/lib/dist/common/edge.d.ts +1 -1
- package/lib/dist/common/face.d.ts +1 -1
- package/lib/dist/common/scene-object.d.ts +6 -0
- package/lib/dist/common/scene-object.js +8 -0
- package/lib/dist/common/shape-factory.d.ts +1 -1
- package/lib/dist/common/shape-history-tracker.d.ts +1 -1
- package/lib/dist/common/shape.d.ts +1 -1
- package/lib/dist/common/solid.d.ts +1 -1
- package/lib/dist/common/transformable-primitive.d.ts +12 -1
- package/lib/dist/common/transformable-primitive.js +27 -0
- package/lib/dist/common/vertex.d.ts +1 -1
- package/lib/dist/common/wire.d.ts +1 -1
- package/lib/dist/core/2d/index.d.ts +1 -0
- package/lib/dist/core/2d/index.js +1 -0
- package/lib/dist/core/2d/text.d.ts +30 -0
- package/lib/dist/core/2d/text.js +37 -0
- package/lib/dist/core/helix.d.ts +20 -0
- package/lib/dist/core/helix.js +36 -0
- package/lib/dist/core/index.d.ts +3 -1
- package/lib/dist/core/index.js +2 -0
- package/lib/dist/core/interfaces.d.ts +180 -0
- package/lib/dist/core/wrap.d.ts +17 -0
- package/lib/dist/core/wrap.js +39 -0
- package/lib/dist/features/2d/text.d.ts +67 -0
- package/lib/dist/features/2d/text.js +320 -0
- package/lib/dist/features/cylinder.d.ts +3 -1
- package/lib/dist/features/cylinder.js +5 -2
- package/lib/dist/features/extrude-base.d.ts +1 -0
- package/lib/dist/features/extrude-to-face.d.ts +1 -0
- package/lib/dist/features/extrude-to-face.js +6 -0
- package/lib/dist/features/fillet.d.ts +1 -1
- package/lib/dist/features/helix.d.ts +41 -0
- package/lib/dist/features/helix.js +337 -0
- package/lib/dist/features/select.js +32 -8
- package/lib/dist/features/simple-extruder.d.ts +1 -1
- package/lib/dist/features/simple-extruder.js +7 -2
- package/lib/dist/features/sphere.d.ts +3 -1
- package/lib/dist/features/sphere.js +5 -2
- package/lib/dist/features/sweep.js +7 -2
- package/lib/dist/features/wrap.d.ts +39 -0
- package/lib/dist/features/wrap.js +116 -0
- package/lib/dist/filters/edge/belongs-to-face.d.ts +3 -1
- package/lib/dist/filters/edge/belongs-to-face.js +14 -10
- package/lib/dist/filters/filter.d.ts +1 -1
- package/lib/dist/filters/from-object.d.ts +1 -1
- package/lib/dist/filters/tangent-expander.d.ts +1 -1
- package/lib/dist/filters/tangent-expander.js +57 -40
- package/lib/dist/helpers/scene-helpers.d.ts +2 -0
- package/lib/dist/helpers/scene-helpers.js +1 -1
- package/lib/dist/index.d.ts +2 -0
- package/lib/dist/index.js +3 -1
- package/lib/dist/io/file-import.d.ts +7 -0
- package/lib/dist/io/file-import.js +28 -1
- package/lib/dist/io/font-registry.d.ts +45 -0
- package/lib/dist/io/font-registry.js +272 -0
- package/lib/dist/math/bspline-interpolation.d.ts +29 -0
- package/lib/dist/math/bspline-interpolation.js +194 -0
- package/lib/dist/oc/boolean-ops.d.ts +3 -1
- package/lib/dist/oc/boolean-ops.js +15 -1
- package/lib/dist/oc/color-transfer.d.ts +1 -1
- package/lib/dist/oc/constraints/constraint-helpers.d.ts +4 -4
- package/lib/dist/oc/constraints/curve/tangent-circle-solver.js +10 -9
- package/lib/dist/oc/constraints/curve/tangent-line-solver.js +5 -6
- package/lib/dist/oc/convert.d.ts +1 -1
- package/lib/dist/oc/draft-ops.d.ts +1 -1
- package/lib/dist/oc/edge-ops.d.ts +2 -2
- package/lib/dist/oc/edge-ops.js +13 -14
- package/lib/dist/oc/edge-props.d.ts +1 -1
- package/lib/dist/oc/edge-query.d.ts +1 -1
- package/lib/dist/oc/edge-query.js +3 -8
- package/lib/dist/oc/errors.d.ts +8 -0
- package/lib/dist/oc/errors.js +27 -0
- package/lib/dist/oc/explorer.d.ts +2 -2
- package/lib/dist/oc/extrude-ops.d.ts +28 -2
- package/lib/dist/oc/extrude-ops.js +56 -7
- package/lib/dist/oc/face-ops.d.ts +2 -1
- package/lib/dist/oc/face-ops.js +11 -0
- package/lib/dist/oc/face-props.d.ts +1 -1
- package/lib/dist/oc/face-query.d.ts +12 -1
- package/lib/dist/oc/face-query.js +39 -0
- package/lib/dist/oc/fillet-ops.d.ts +1 -1
- package/lib/dist/oc/fillet-ops.js +4 -4
- package/lib/dist/oc/geometry.d.ts +1 -1
- package/lib/dist/oc/geometry.js +12 -14
- package/lib/dist/oc/helix-ops.d.ts +37 -0
- package/lib/dist/oc/helix-ops.js +88 -0
- package/lib/dist/oc/hit-test.d.ts +1 -1
- package/lib/dist/oc/index.d.ts +4 -0
- package/lib/dist/oc/index.js +2 -0
- package/lib/dist/oc/init.d.ts +1 -1
- package/lib/dist/oc/init.js +1 -1
- package/lib/dist/oc/intersection.js +1 -1
- package/lib/dist/oc/io.d.ts +6 -6
- package/lib/dist/oc/io.js +31 -24
- package/lib/dist/oc/measure/classify.d.ts +34 -0
- package/lib/dist/oc/measure/classify.js +246 -0
- package/lib/dist/oc/measure/measure-ops.d.ts +9 -0
- package/lib/dist/oc/measure/measure-ops.js +210 -0
- package/lib/dist/oc/measure/measure-types.d.ts +39 -0
- package/lib/dist/oc/measure/measure-types.js +1 -0
- package/lib/dist/oc/measure/sampling.d.ts +9 -0
- package/lib/dist/oc/measure/sampling.js +77 -0
- package/lib/dist/oc/measure/vec.d.ts +13 -0
- package/lib/dist/oc/measure/vec.js +23 -0
- package/lib/dist/oc/mesh.d.ts +1 -1
- package/lib/dist/oc/mesh.js +40 -28
- package/lib/dist/oc/path-sampler.d.ts +29 -0
- package/lib/dist/oc/path-sampler.js +63 -0
- package/lib/dist/oc/props.d.ts +1 -1
- package/lib/dist/oc/props.js +4 -1
- package/lib/dist/oc/shape-hash.d.ts +26 -0
- package/lib/dist/oc/shape-hash.js +32 -0
- package/lib/dist/oc/shape-ops.d.ts +5 -3
- package/lib/dist/oc/shape-ops.js +6 -5
- package/lib/dist/oc/sweep-ops.d.ts +22 -1
- package/lib/dist/oc/sweep-ops.js +206 -18
- package/lib/dist/oc/text-outline.d.ts +62 -0
- package/lib/dist/oc/text-outline.js +212 -0
- package/lib/dist/oc/topology-index.d.ts +1 -1
- package/lib/dist/oc/vertex-ops.d.ts +1 -1
- package/lib/dist/oc/wire-ops.d.ts +1 -1
- package/lib/dist/oc/wire-ops.js +1 -1
- package/lib/dist/oc/wrap-development.d.ts +105 -0
- package/lib/dist/oc/wrap-development.js +179 -0
- package/lib/dist/oc/wrap-ops.d.ts +100 -0
- package/lib/dist/oc/wrap-ops.js +406 -0
- package/lib/dist/rendering/render-solid.js +10 -2
- package/lib/dist/scene-manager.d.ts +2 -0
- package/lib/dist/scene-manager.js +29 -0
- package/lib/dist/tests/features/cylinder-curve-filter.test.js +3 -3
- package/lib/dist/tests/features/extrude-to-face.test.js +38 -1
- package/lib/dist/tests/features/helix.test.d.ts +1 -0
- package/lib/dist/tests/features/helix.test.js +295 -0
- package/lib/dist/tests/features/repeat-primitive.test.d.ts +1 -0
- package/lib/dist/tests/features/repeat-primitive.test.js +60 -0
- package/lib/dist/tests/features/rib.test.js +6 -1
- package/lib/dist/tests/features/sweep.test.js +125 -1
- package/lib/dist/tests/features/text.test.d.ts +1 -0
- package/lib/dist/tests/features/text.test.js +347 -0
- package/lib/dist/tests/features/wrap-development.test.d.ts +1 -0
- package/lib/dist/tests/features/wrap-development.test.js +130 -0
- package/lib/dist/tests/features/wrap-extruded-target.test.d.ts +1 -0
- package/lib/dist/tests/features/wrap-extruded-target.test.js +106 -0
- package/lib/dist/tests/features/wrap-repeat.test.d.ts +1 -0
- package/lib/dist/tests/features/wrap-repeat.test.js +93 -0
- package/lib/dist/tests/features/wrap.test.d.ts +1 -0
- package/lib/dist/tests/features/wrap.test.js +331 -0
- package/lib/dist/tests/math/bspline-interpolation.test.d.ts +1 -0
- package/lib/dist/tests/math/bspline-interpolation.test.js +119 -0
- package/lib/dist/tests/measure.test.d.ts +1 -0
- package/lib/dist/tests/measure.test.js +288 -0
- package/lib/dist/tsconfig.tsbuildinfo +1 -1
- package/llm-docs/api/helix.md +64 -0
- package/llm-docs/api/index.json +11 -2
- package/llm-docs/api/text.md +52 -0
- package/llm-docs/api/types/helix.md +105 -0
- package/llm-docs/api/types/text.md +138 -0
- package/llm-docs/api/types/wrap.md +131 -0
- package/llm-docs/api/wrap.md +62 -0
- package/llm-docs/index.json +121 -1
- package/mcp/dist/server.js +20 -1
- package/mcp/dist/tools/inspection.d.ts +17 -0
- package/mcp/dist/tools/inspection.js +14 -0
- package/package.json +7 -3
- package/server/dist/fluidcad-server.d.ts +10 -0
- package/server/dist/fluidcad-server.js +10 -0
- package/server/dist/index.js +4 -2
- package/server/dist/preferences.d.ts +4 -0
- package/server/dist/preferences.js +2 -0
- package/server/dist/routes/measure.d.ts +3 -0
- package/server/dist/routes/measure.js +32 -0
- package/server/dist/routes/preferences.js +6 -0
- package/server/dist/routes/sketch-edits.js +2 -1
- package/ui/dist/assets/{index-CDJmUpFI.css → index-dAFdg2Un.css} +1 -1
- package/ui/dist/assets/{index-MRqwG9Vh.js → index-no7mtr5s.js} +149 -102
- package/ui/dist/index.html +2 -2
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { setupOC, render } from "../setup.js";
|
|
3
|
+
import sketch from "../../core/sketch.js";
|
|
4
|
+
import helix from "../../core/helix.js";
|
|
5
|
+
import sweep from "../../core/sweep.js";
|
|
6
|
+
import cylinder from "../../core/cylinder.js";
|
|
7
|
+
import select from "../../core/select.js";
|
|
8
|
+
import { face } from "../../filters/index.js";
|
|
9
|
+
import { circle, hLine } from "../../core/2d/index.js";
|
|
10
|
+
import { ShapeOps } from "../../oc/shape-ops.js";
|
|
11
|
+
import { Edge } from "../../common/edge.js";
|
|
12
|
+
import { getOC } from "../../oc/init.js";
|
|
13
|
+
// Bounding boxes are computed from the triangulated mesh of the helix edge,
|
|
14
|
+
// so they're slightly larger than the analytic extents (~0.2 mm typical).
|
|
15
|
+
const MESH_TOL = 0.5;
|
|
16
|
+
describe("helix", () => {
|
|
17
|
+
setupOC();
|
|
18
|
+
describe("axis input", () => {
|
|
19
|
+
it("should create a helix wire with default radius and height", () => {
|
|
20
|
+
const h = helix("z").pitch(5).turns(4);
|
|
21
|
+
render();
|
|
22
|
+
const shapes = h.getShapes();
|
|
23
|
+
expect(shapes).toHaveLength(1);
|
|
24
|
+
expect(shapes[0]).toBeInstanceOf(Edge);
|
|
25
|
+
});
|
|
26
|
+
it("should produce a helix whose axial extent equals pitch * turns", () => {
|
|
27
|
+
const h = helix("z").pitch(5).turns(4);
|
|
28
|
+
render();
|
|
29
|
+
const bbox = ShapeOps.getBoundingBox(h.getShapes()[0]);
|
|
30
|
+
expect(bbox.maxZ - bbox.minZ).toBeGreaterThanOrEqual(20 - MESH_TOL);
|
|
31
|
+
expect(bbox.maxZ - bbox.minZ).toBeLessThanOrEqual(20 + MESH_TOL);
|
|
32
|
+
});
|
|
33
|
+
it("should use the provided .radius() for cylindrical helix", () => {
|
|
34
|
+
const h = helix("z").radius(15).pitch(5).turns(4);
|
|
35
|
+
render();
|
|
36
|
+
const bbox = ShapeOps.getBoundingBox(h.getShapes()[0]);
|
|
37
|
+
expect(bbox.maxX - bbox.minX).toBeGreaterThanOrEqual(30 - MESH_TOL);
|
|
38
|
+
expect(bbox.maxX - bbox.minX).toBeLessThanOrEqual(30 + MESH_TOL);
|
|
39
|
+
expect(bbox.maxY - bbox.minY).toBeGreaterThanOrEqual(30 - MESH_TOL);
|
|
40
|
+
expect(bbox.maxY - bbox.minY).toBeLessThanOrEqual(30 + MESH_TOL);
|
|
41
|
+
});
|
|
42
|
+
it("should default radius to 20 when not specified", () => {
|
|
43
|
+
const h = helix("z").pitch(5).turns(4);
|
|
44
|
+
render();
|
|
45
|
+
const bbox = ShapeOps.getBoundingBox(h.getShapes()[0]);
|
|
46
|
+
expect(bbox.maxX - bbox.minX).toBeGreaterThanOrEqual(40 - MESH_TOL);
|
|
47
|
+
expect(bbox.maxX - bbox.minX).toBeLessThanOrEqual(40 + MESH_TOL);
|
|
48
|
+
});
|
|
49
|
+
it("should default height to 50 when only turns is given", () => {
|
|
50
|
+
const h = helix("z").turns(4);
|
|
51
|
+
render();
|
|
52
|
+
const bbox = ShapeOps.getBoundingBox(h.getShapes()[0]);
|
|
53
|
+
expect(bbox.maxZ - bbox.minZ).toBeGreaterThanOrEqual(50 - MESH_TOL);
|
|
54
|
+
expect(bbox.maxZ - bbox.minZ).toBeLessThanOrEqual(50 + MESH_TOL);
|
|
55
|
+
});
|
|
56
|
+
it("should respect explicit .height() over pitch * turns", () => {
|
|
57
|
+
const h = helix("z").pitch(5).turns(4).height(30);
|
|
58
|
+
render();
|
|
59
|
+
const bbox = ShapeOps.getBoundingBox(h.getShapes()[0]);
|
|
60
|
+
expect(bbox.maxZ - bbox.minZ).toBeGreaterThanOrEqual(30 - MESH_TOL);
|
|
61
|
+
expect(bbox.maxZ - bbox.minZ).toBeLessThanOrEqual(30 + MESH_TOL);
|
|
62
|
+
});
|
|
63
|
+
it("should produce a conical helix when endRadius differs from radius", () => {
|
|
64
|
+
const h = helix("z").radius(20).endRadius(10).turns(4).height(30);
|
|
65
|
+
render();
|
|
66
|
+
const bbox = ShapeOps.getBoundingBox(h.getShapes()[0]);
|
|
67
|
+
// The helix curve is bounded by the cone's start radius (20) at the base;
|
|
68
|
+
// its diameter is at most 40 and strictly greater than the end diameter (20).
|
|
69
|
+
const diameterX = bbox.maxX - bbox.minX;
|
|
70
|
+
expect(diameterX).toBeGreaterThan(20);
|
|
71
|
+
expect(diameterX).toBeLessThanOrEqual(40 + MESH_TOL);
|
|
72
|
+
expect(bbox.maxZ - bbox.minZ).toBeGreaterThanOrEqual(30 - MESH_TOL);
|
|
73
|
+
expect(bbox.maxZ - bbox.minZ).toBeLessThanOrEqual(30 + MESH_TOL);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
describe("tapered approximation (regression)", () => {
|
|
77
|
+
// A many-turn TAPERED helix must follow its cone profile along its WHOLE
|
|
78
|
+
// length, not just within its bounding box. The earlier implementation used
|
|
79
|
+
// HelixGeom_Tools.ApprHelix, whose hardcoded 150-segment budget saturates for
|
|
80
|
+
// ~10+ turn tapers: the single B-spline fit oscillated wildly over the final
|
|
81
|
+
// turns (radius collapsing toward the axis) while still reporting success — a
|
|
82
|
+
// bbox check can't see an inward excursion. Sample the curve and assert the
|
|
83
|
+
// radius tracks the ideal cone r(z) = startR + (endR - startR) * z / height.
|
|
84
|
+
it("should track the cone profile over the full length of a 10-turn taper", () => {
|
|
85
|
+
const startR = 15;
|
|
86
|
+
const endR = 25;
|
|
87
|
+
const height = 100;
|
|
88
|
+
const h = helix("z").radius(startR).endRadius(endR).height(height).pitch(10); // 10 turns
|
|
89
|
+
render();
|
|
90
|
+
expect(h.getError()).toBeFalsy();
|
|
91
|
+
const oc = getOC();
|
|
92
|
+
const edge = h.getShapes()[0];
|
|
93
|
+
const adaptor = new oc.BRepAdaptor_Curve(edge.getShape());
|
|
94
|
+
const first = adaptor.FirstParameter();
|
|
95
|
+
const last = adaptor.LastParameter();
|
|
96
|
+
let maxDev = 0;
|
|
97
|
+
let maxDevZ = 0;
|
|
98
|
+
const samples = 400;
|
|
99
|
+
for (let k = 0; k <= samples; k++) {
|
|
100
|
+
const t = first + (last - first) * (k / samples);
|
|
101
|
+
const p = adaptor.Value(t);
|
|
102
|
+
const radius = Math.hypot(p.X(), p.Y());
|
|
103
|
+
const ideal = startR + (endR - startR) * (p.Z() / height);
|
|
104
|
+
const dev = Math.abs(radius - ideal);
|
|
105
|
+
if (dev > maxDev) {
|
|
106
|
+
maxDev = dev;
|
|
107
|
+
maxDevZ = p.Z();
|
|
108
|
+
}
|
|
109
|
+
p.delete();
|
|
110
|
+
}
|
|
111
|
+
adaptor.delete();
|
|
112
|
+
// The fit holds to its 1e-4 mm tolerance; 0.1 mm is a generous bound that
|
|
113
|
+
// still fails hard on the old saturated fit (which deviated by ~24 mm).
|
|
114
|
+
expect(maxDev, `max radial deviation ${maxDev.toFixed(3)} mm at z=${maxDevZ.toFixed(1)}`).toBeLessThan(0.1);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
describe("offsets", () => {
|
|
118
|
+
it("should extend the helix axially by .endOffset()", () => {
|
|
119
|
+
const base = helix("z").pitch(5).turns(4);
|
|
120
|
+
render();
|
|
121
|
+
const baseHeight = ShapeOps.getBoundingBox(base.getShapes()[0]).maxZ
|
|
122
|
+
- ShapeOps.getBoundingBox(base.getShapes()[0]).minZ;
|
|
123
|
+
const extended = helix("z").pitch(5).turns(4).endOffset(10);
|
|
124
|
+
render();
|
|
125
|
+
const extendedHeight = ShapeOps.getBoundingBox(extended.getShapes()[0]).maxZ
|
|
126
|
+
- ShapeOps.getBoundingBox(extended.getShapes()[0]).minZ;
|
|
127
|
+
expect(extendedHeight - baseHeight).toBeGreaterThanOrEqual(10 - MESH_TOL);
|
|
128
|
+
expect(extendedHeight - baseHeight).toBeLessThanOrEqual(10 + MESH_TOL);
|
|
129
|
+
});
|
|
130
|
+
it("should trim the helix start by positive .startOffset()", () => {
|
|
131
|
+
const base = helix("z").pitch(5).turns(4);
|
|
132
|
+
render();
|
|
133
|
+
const baseHeight = ShapeOps.getBoundingBox(base.getShapes()[0]).maxZ
|
|
134
|
+
- ShapeOps.getBoundingBox(base.getShapes()[0]).minZ;
|
|
135
|
+
const trimmed = helix("z").pitch(5).turns(4).startOffset(5);
|
|
136
|
+
render();
|
|
137
|
+
const trimmedHeight = ShapeOps.getBoundingBox(trimmed.getShapes()[0]).maxZ
|
|
138
|
+
- ShapeOps.getBoundingBox(trimmed.getShapes()[0]).minZ;
|
|
139
|
+
expect(baseHeight - trimmedHeight).toBeGreaterThanOrEqual(5 - MESH_TOL);
|
|
140
|
+
expect(baseHeight - trimmedHeight).toBeLessThanOrEqual(5 + MESH_TOL);
|
|
141
|
+
});
|
|
142
|
+
it("should extend the helix start by negative .startOffset()", () => {
|
|
143
|
+
const base = helix("z").pitch(5).turns(4);
|
|
144
|
+
render();
|
|
145
|
+
const baseHeight = ShapeOps.getBoundingBox(base.getShapes()[0]).maxZ
|
|
146
|
+
- ShapeOps.getBoundingBox(base.getShapes()[0]).minZ;
|
|
147
|
+
const extended = helix("z").pitch(5).turns(4).startOffset(-10);
|
|
148
|
+
render();
|
|
149
|
+
const extendedHeight = ShapeOps.getBoundingBox(extended.getShapes()[0]).maxZ
|
|
150
|
+
- ShapeOps.getBoundingBox(extended.getShapes()[0]).minZ;
|
|
151
|
+
expect(extendedHeight - baseHeight).toBeGreaterThanOrEqual(10 - MESH_TOL);
|
|
152
|
+
expect(extendedHeight - baseHeight).toBeLessThanOrEqual(10 + MESH_TOL);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
describe("cylindrical face input", () => {
|
|
156
|
+
it("should derive axis, radius, and height from a cylinder face", () => {
|
|
157
|
+
cylinder(15, 60);
|
|
158
|
+
const sel = select(face().cylinder());
|
|
159
|
+
const h = helix(sel).turns(6);
|
|
160
|
+
render();
|
|
161
|
+
const shapes = h.getShapes();
|
|
162
|
+
expect(shapes).toHaveLength(1);
|
|
163
|
+
const bbox = ShapeOps.getBoundingBox(shapes[0]);
|
|
164
|
+
expect(bbox.maxX - bbox.minX).toBeGreaterThanOrEqual(30 - MESH_TOL);
|
|
165
|
+
expect(bbox.maxX - bbox.minX).toBeLessThanOrEqual(30 + MESH_TOL);
|
|
166
|
+
expect(bbox.maxZ - bbox.minZ).toBeGreaterThanOrEqual(60 - MESH_TOL);
|
|
167
|
+
expect(bbox.maxZ - bbox.minZ).toBeLessThanOrEqual(60 + MESH_TOL);
|
|
168
|
+
});
|
|
169
|
+
it("should extend below/above the cylinder with offsets", () => {
|
|
170
|
+
cylinder(15, 60);
|
|
171
|
+
const sel = select(face().cylinder());
|
|
172
|
+
const h = helix(sel).turns(6).startOffset(-10).endOffset(10);
|
|
173
|
+
render();
|
|
174
|
+
const bbox = ShapeOps.getBoundingBox(h.getShapes()[0]);
|
|
175
|
+
// Cylinder is 0..60 in Z; offsets extend by 10 on each side.
|
|
176
|
+
expect(bbox.minZ).toBeGreaterThanOrEqual(-10 - MESH_TOL);
|
|
177
|
+
expect(bbox.minZ).toBeLessThanOrEqual(-10 + MESH_TOL);
|
|
178
|
+
expect(bbox.maxZ).toBeGreaterThanOrEqual(70 - MESH_TOL);
|
|
179
|
+
expect(bbox.maxZ).toBeLessThanOrEqual(70 + MESH_TOL);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
describe("conical face input", () => {
|
|
183
|
+
it("should follow a cone surface with the face's natural taper", async () => {
|
|
184
|
+
const { default: extrude } = await import("../../core/extrude.js");
|
|
185
|
+
sketch("xy", () => {
|
|
186
|
+
circle(60); // radius 30
|
|
187
|
+
});
|
|
188
|
+
extrude(50).draft(10); // 10° draft → top radius widens
|
|
189
|
+
const sel = select(face().cone());
|
|
190
|
+
const h = helix(sel).turns(6);
|
|
191
|
+
render();
|
|
192
|
+
const shapes = h.getShapes();
|
|
193
|
+
expect(shapes).toHaveLength(1);
|
|
194
|
+
const bbox = ShapeOps.getBoundingBox(shapes[0]);
|
|
195
|
+
// Cone widens from r=30 at z=0 to r=~38.8 at z=50 (10° draft).
|
|
196
|
+
// Max helix diameter = ~77.6.
|
|
197
|
+
expect(bbox.maxX - bbox.minX).toBeGreaterThanOrEqual(60);
|
|
198
|
+
expect(bbox.maxX - bbox.minX).toBeLessThanOrEqual(78 + MESH_TOL);
|
|
199
|
+
expect(bbox.maxZ - bbox.minZ).toBeGreaterThanOrEqual(50 - MESH_TOL);
|
|
200
|
+
expect(bbox.maxZ - bbox.minZ).toBeLessThanOrEqual(50 + MESH_TOL);
|
|
201
|
+
});
|
|
202
|
+
it("should extend the helix following the cone's natural taper with offsets", async () => {
|
|
203
|
+
const { default: extrude } = await import("../../core/extrude.js");
|
|
204
|
+
sketch("xy", () => {
|
|
205
|
+
circle(60); // radius 30 at z=0
|
|
206
|
+
});
|
|
207
|
+
extrude(50).draft(10); // cone widens toward top (tanθ = tan(10°) ≈ 0.176)
|
|
208
|
+
const sel = select(face().cone());
|
|
209
|
+
const h = helix(sel).turns(6).startOffset(-10).endOffset(10);
|
|
210
|
+
render();
|
|
211
|
+
const bbox = ShapeOps.getBoundingBox(h.getShapes()[0]);
|
|
212
|
+
// Z extends 10 below (z=-10) and 10 above (z=60) the cone.
|
|
213
|
+
expect(bbox.maxZ - bbox.minZ).toBeGreaterThanOrEqual(70 - MESH_TOL);
|
|
214
|
+
expect(bbox.maxZ - bbox.minZ).toBeLessThanOrEqual(70 + MESH_TOL);
|
|
215
|
+
// At z=60 (top extension), radius extrapolates: r = 30 + 60*tan(10°) ≈ 40.6
|
|
216
|
+
// Max diameter ≈ 81.2 — strictly larger than the un-offset top diameter (~77.6).
|
|
217
|
+
expect(bbox.maxX - bbox.minX).toBeGreaterThan(78);
|
|
218
|
+
expect(bbox.maxX - bbox.minX).toBeLessThanOrEqual(82 + MESH_TOL);
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
describe("line edge input", () => {
|
|
222
|
+
it("should treat a line edge as the helix axis and derive height from length", () => {
|
|
223
|
+
const s = sketch("xz", () => {
|
|
224
|
+
hLine(40);
|
|
225
|
+
});
|
|
226
|
+
const h = helix(s).turns(4);
|
|
227
|
+
render();
|
|
228
|
+
const shapes = h.getShapes();
|
|
229
|
+
expect(shapes).toHaveLength(1);
|
|
230
|
+
const bbox = ShapeOps.getBoundingBox(shapes[0]);
|
|
231
|
+
// Line is along world X (in the xz plane, hLine = X). Height ≈ 40 in X.
|
|
232
|
+
expect(bbox.maxX - bbox.minX).toBeGreaterThanOrEqual(40 - MESH_TOL);
|
|
233
|
+
expect(bbox.maxX - bbox.minX).toBeLessThanOrEqual(40 + MESH_TOL);
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
describe("circular edge input", () => {
|
|
237
|
+
it("should derive axis from circle normal and use circle radius", () => {
|
|
238
|
+
const s = sketch("xy", () => {
|
|
239
|
+
circle(30);
|
|
240
|
+
});
|
|
241
|
+
const h = helix(s).turns(4);
|
|
242
|
+
render();
|
|
243
|
+
const shapes = h.getShapes();
|
|
244
|
+
expect(shapes).toHaveLength(1);
|
|
245
|
+
const bbox = ShapeOps.getBoundingBox(shapes[0]);
|
|
246
|
+
expect(bbox.maxX - bbox.minX).toBeGreaterThanOrEqual(30 - MESH_TOL);
|
|
247
|
+
expect(bbox.maxX - bbox.minX).toBeLessThanOrEqual(30 + MESH_TOL);
|
|
248
|
+
expect(bbox.maxZ - bbox.minZ).toBeGreaterThanOrEqual(50 - MESH_TOL);
|
|
249
|
+
expect(bbox.maxZ - bbox.minZ).toBeLessThanOrEqual(50 + MESH_TOL);
|
|
250
|
+
});
|
|
251
|
+
it("should respect .height() override on circular edge", () => {
|
|
252
|
+
const s = sketch("xy", () => {
|
|
253
|
+
circle(30);
|
|
254
|
+
});
|
|
255
|
+
const h = helix(s).turns(4).height(80);
|
|
256
|
+
render();
|
|
257
|
+
const bbox = ShapeOps.getBoundingBox(h.getShapes()[0]);
|
|
258
|
+
expect(bbox.maxZ - bbox.minZ).toBeGreaterThanOrEqual(80 - MESH_TOL);
|
|
259
|
+
expect(bbox.maxZ - bbox.minZ).toBeLessThanOrEqual(80 + MESH_TOL);
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
describe("validation", () => {
|
|
263
|
+
it("should record an error when pitch is zero", () => {
|
|
264
|
+
const h = helix("z").pitch(0).turns(4);
|
|
265
|
+
render();
|
|
266
|
+
expect(h.getError()).toMatch(/pitch/i);
|
|
267
|
+
});
|
|
268
|
+
it("should record an error when turns is zero", () => {
|
|
269
|
+
const h = helix("z").pitch(5).turns(0);
|
|
270
|
+
render();
|
|
271
|
+
expect(h.getError()).toMatch(/turns/i);
|
|
272
|
+
});
|
|
273
|
+
it("should record an error when source has no faces or edges", () => {
|
|
274
|
+
const s = sketch("xy", () => {
|
|
275
|
+
// empty sketch
|
|
276
|
+
});
|
|
277
|
+
const h = helix(s).turns(2);
|
|
278
|
+
render();
|
|
279
|
+
expect(h.getError()).toBeTruthy();
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
describe("sweep integration", () => {
|
|
283
|
+
it("should be sweepable by a small profile to build a spring", () => {
|
|
284
|
+
const profile = sketch("xz", () => {
|
|
285
|
+
circle(2);
|
|
286
|
+
});
|
|
287
|
+
const path = helix("z").radius(15).pitch(5).turns(4);
|
|
288
|
+
const spring = sweep(path, profile);
|
|
289
|
+
render();
|
|
290
|
+
const shapes = spring.getShapes();
|
|
291
|
+
expect(shapes).toHaveLength(1);
|
|
292
|
+
expect(shapes[0].getType()).toBe("solid");
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { setupOC, render } from "../setup.js";
|
|
3
|
+
import cylinder from "../../core/cylinder.js";
|
|
4
|
+
import sphere from "../../core/sphere.js";
|
|
5
|
+
import repeat from "../../core/repeat.js";
|
|
6
|
+
import { ShapeProps } from "../../oc/props.js";
|
|
7
|
+
function buildErrors(scene) {
|
|
8
|
+
return scene.getSceneObjects()
|
|
9
|
+
.map(o => ({ type: o.getType(), err: o.getError() }))
|
|
10
|
+
.filter(e => e.err);
|
|
11
|
+
}
|
|
12
|
+
function solidCentroids(scene) {
|
|
13
|
+
return scene.getSceneObjects()
|
|
14
|
+
.filter(o => !o.isContainer())
|
|
15
|
+
.flatMap(o => o.getShapes())
|
|
16
|
+
.filter(sh => sh.isSolid())
|
|
17
|
+
.map(sh => {
|
|
18
|
+
const c = ShapeProps.getProperties(sh.getShape()).centroid;
|
|
19
|
+
return { x: Math.round(c.x) + 0, y: Math.round(c.y) + 0, z: Math.round(c.z) + 0 };
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
describe("repeat of primitives", () => {
|
|
23
|
+
setupOC();
|
|
24
|
+
it("linear-repeats a cylinder", () => {
|
|
25
|
+
const c = cylinder(10, 30);
|
|
26
|
+
repeat("linear", "x", { count: 2, offset: 60 }, c);
|
|
27
|
+
const scene = render();
|
|
28
|
+
expect(buildErrors(scene)).toEqual([]);
|
|
29
|
+
const centroids = solidCentroids(scene).sort((a, b) => a.x - b.x);
|
|
30
|
+
expect(centroids).toEqual([
|
|
31
|
+
{ x: 0, y: 0, z: 15 },
|
|
32
|
+
{ x: 60, y: 0, z: 15 },
|
|
33
|
+
]);
|
|
34
|
+
});
|
|
35
|
+
it("circular-repeats a translated cylinder, applying the rotation after the translate", () => {
|
|
36
|
+
const c = cylinder(5, 20).translate(30, 0, 0);
|
|
37
|
+
repeat("circular", "z", { count: 4, angle: 360 }, c);
|
|
38
|
+
const scene = render();
|
|
39
|
+
expect(buildErrors(scene)).toEqual([]);
|
|
40
|
+
const centroids = solidCentroids(scene)
|
|
41
|
+
.sort((a, b) => (a.x - b.x) || (a.y - b.y));
|
|
42
|
+
expect(centroids).toEqual([
|
|
43
|
+
{ x: -30, y: 0, z: 10 },
|
|
44
|
+
{ x: 0, y: -30, z: 10 },
|
|
45
|
+
{ x: 0, y: 30, z: 10 },
|
|
46
|
+
{ x: 30, y: 0, z: 10 },
|
|
47
|
+
]);
|
|
48
|
+
});
|
|
49
|
+
it("mirror-repeats a sphere", () => {
|
|
50
|
+
const s = sphere(8).translate(20, 0, 0);
|
|
51
|
+
repeat("mirror", "yz", s);
|
|
52
|
+
const scene = render();
|
|
53
|
+
expect(buildErrors(scene)).toEqual([]);
|
|
54
|
+
const centroids = solidCentroids(scene).sort((a, b) => a.x - b.x);
|
|
55
|
+
expect(centroids).toEqual([
|
|
56
|
+
{ x: -20, y: 0, z: 0 },
|
|
57
|
+
{ x: 20, y: 0, z: 0 },
|
|
58
|
+
]);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -227,8 +227,13 @@ describe("rib", () => {
|
|
|
227
227
|
it("repeat circular on extended rib should produce valid copies", async () => {
|
|
228
228
|
const repeatModule = await import("../../core/repeat.js");
|
|
229
229
|
const repeat = repeatModule.default;
|
|
230
|
+
// The scope (box + boss) must be rotationally symmetric about Z so that a
|
|
231
|
+
// 90°/180°/270° clone really is congruent to the original — otherwise the
|
|
232
|
+
// rib legitimately conforms to a different wall distance per orientation
|
|
233
|
+
// and the volume-equality check below would measure scope asymmetry rather
|
|
234
|
+
// than flag propagation. A square box keeps the comparison about the flags.
|
|
230
235
|
sketch("top", () => {
|
|
231
|
-
rect(100,
|
|
236
|
+
rect(100, 100).centered();
|
|
232
237
|
});
|
|
233
238
|
const box = extrude(30);
|
|
234
239
|
const shelled = shell(-4, box.endFaces());
|
|
@@ -3,7 +3,9 @@ import { setupOC, render, addToScene } from "../setup.js";
|
|
|
3
3
|
import sketch from "../../core/sketch.js";
|
|
4
4
|
import sweep from "../../core/sweep.js";
|
|
5
5
|
import extrude from "../../core/extrude.js";
|
|
6
|
-
import
|
|
6
|
+
import helix from "../../core/helix.js";
|
|
7
|
+
import cylinder from "../../core/cylinder.js";
|
|
8
|
+
import { circle, rect, vLine, hLine, arc, move, hMove } from "../../core/2d/index.js";
|
|
7
9
|
import { countShapes } from "../utils.js";
|
|
8
10
|
import { ShapeOps } from "../../oc/shape-ops.js";
|
|
9
11
|
import { ShapeProps } from "../../oc/props.js";
|
|
@@ -309,4 +311,126 @@ describe("sweep", () => {
|
|
|
309
311
|
expect(countShapes(scene)).toBe(1);
|
|
310
312
|
});
|
|
311
313
|
});
|
|
314
|
+
describe("helix sweep with cone fuse/cut", () => {
|
|
315
|
+
it(".add() with helix on cone face fuses to a single solid", () => {
|
|
316
|
+
sketch("xy", () => { circle(30); });
|
|
317
|
+
const c = extrude(50).draft(10);
|
|
318
|
+
const path = helix(c.sideFaces()).turns(10);
|
|
319
|
+
const profile = sketch("xz", () => {
|
|
320
|
+
move([15, 0]);
|
|
321
|
+
circle(2);
|
|
322
|
+
});
|
|
323
|
+
const s = sweep(path, profile).add();
|
|
324
|
+
render();
|
|
325
|
+
const sShapes = s.getShapes();
|
|
326
|
+
const totalVol = sShapes.reduce((acc, sh) => acc + ShapeProps.getProperties(sh.getShape()).volumeMm3, 0);
|
|
327
|
+
expect(c.getShapes().length).toBe(0);
|
|
328
|
+
expect(sShapes.length).toBe(1);
|
|
329
|
+
expect(totalVol).toBeGreaterThan(60000);
|
|
330
|
+
expect(totalVol).toBeLessThan(64000);
|
|
331
|
+
});
|
|
332
|
+
it(".remove() with helix on cone face cuts a groove", () => {
|
|
333
|
+
sketch("xy", () => { circle(30); });
|
|
334
|
+
const c = extrude(50).draft(10);
|
|
335
|
+
const path = helix(c.sideFaces()).turns(10);
|
|
336
|
+
const profile = sketch("xz", () => {
|
|
337
|
+
move([15, 0]);
|
|
338
|
+
circle(2);
|
|
339
|
+
});
|
|
340
|
+
const s = sweep(path, profile).remove();
|
|
341
|
+
render();
|
|
342
|
+
const sShapes = s.getShapes();
|
|
343
|
+
const totalVol = sShapes.reduce((acc, sh) => acc + ShapeProps.getProperties(sh.getShape()).volumeMm3, 0);
|
|
344
|
+
expect(c.getShapes().length).toBe(0);
|
|
345
|
+
expect(sShapes.length).toBe(1);
|
|
346
|
+
expect(totalVol).toBeGreaterThan(56000);
|
|
347
|
+
expect(totalVol).toBeLessThan(62000);
|
|
348
|
+
});
|
|
349
|
+
});
|
|
350
|
+
describe("conical (tapered) helix sweep", () => {
|
|
351
|
+
// A tapered helical spine (endRadius ≠ radius) produces a swept surface
|
|
352
|
+
// that needs many approximation spans; at MakePipeShell's small default
|
|
353
|
+
// segment budget the build silently fails (PipeNotDone). SweepOps raises
|
|
354
|
+
// the budget (MAX_PIPE_SEGMENTS), so these build with the fixed binormal.
|
|
355
|
+
it("sweeps a circle along an outward-tapering helix", () => {
|
|
356
|
+
const path = helix("z").height(100).pitch(10).radius(15).endRadius(25);
|
|
357
|
+
const profile = sketch("left", () => {
|
|
358
|
+
hMove(15);
|
|
359
|
+
circle(2);
|
|
360
|
+
});
|
|
361
|
+
const s = sweep(path, profile);
|
|
362
|
+
render();
|
|
363
|
+
expect(s.getError()).toBeNull();
|
|
364
|
+
const shapes = s.getShapes();
|
|
365
|
+
expect(shapes).toHaveLength(1);
|
|
366
|
+
expect(shapes[0].getType()).toBe("solid");
|
|
367
|
+
const props = ShapeProps.getProperties(shapes[0].getShape());
|
|
368
|
+
expect(props.volumeMm3).toBeGreaterThan(0);
|
|
369
|
+
const bbox = ShapeOps.getBoundingBox(shapes[0]);
|
|
370
|
+
// End radius 25 + tube radius 2 ⇒ ~54mm across; height 100 ⇒ ~100mm tall.
|
|
371
|
+
expect(bbox.maxX - bbox.minX).toBeGreaterThan(50);
|
|
372
|
+
expect(bbox.maxZ - bbox.minZ).toBeGreaterThan(95);
|
|
373
|
+
});
|
|
374
|
+
it("sweeps a circle along an inward-tapering helix", () => {
|
|
375
|
+
const path = helix("z").height(80).pitch(8).radius(25).endRadius(12);
|
|
376
|
+
const profile = sketch("left", () => {
|
|
377
|
+
hMove(25);
|
|
378
|
+
circle(1.5);
|
|
379
|
+
});
|
|
380
|
+
const s = sweep(path, profile);
|
|
381
|
+
render();
|
|
382
|
+
expect(s.getError()).toBeNull();
|
|
383
|
+
const shapes = s.getShapes();
|
|
384
|
+
expect(shapes).toHaveLength(1);
|
|
385
|
+
expect(shapes[0].getType()).toBe("solid");
|
|
386
|
+
expect(ShapeProps.getProperties(shapes[0].getShape()).volumeMm3).toBeGreaterThan(0);
|
|
387
|
+
});
|
|
388
|
+
});
|
|
389
|
+
describe("helix sweep tangent to a cylinder (fuzzy boolean)", () => {
|
|
390
|
+
// A helix at the cylinder's own radius makes a swept thread that touches
|
|
391
|
+
// the cylinder tangentially along the contact curves. At zero boolean fuzz
|
|
392
|
+
// OCCT's BOPAlgo silently no-ops (cut removes nothing; fuse returns an empty
|
|
393
|
+
// compound). BooleanOps' small fuzzy value resolves the contact. Volume
|
|
394
|
+
// ≈ a radius-15 / height-50 cylinder = π·225·50 ≈ 35343 mm³.
|
|
395
|
+
const CYL_VOL = Math.PI * 225 * 50;
|
|
396
|
+
it("removes a helical groove from the cylinder surface", () => {
|
|
397
|
+
cylinder(15, 50);
|
|
398
|
+
const path = helix("z").height(50).radius(15).pitch(5).startOffset(-5).endOffset(5);
|
|
399
|
+
const profile = sketch("left", () => { move([15, 0]); circle(3); });
|
|
400
|
+
const s = sweep(path, profile).remove();
|
|
401
|
+
render();
|
|
402
|
+
expect(s.getError()).toBeNull();
|
|
403
|
+
const shapes = s.getShapes();
|
|
404
|
+
expect(shapes).toHaveLength(1);
|
|
405
|
+
const vol = ShapeProps.getProperties(shapes[0].getShape()).volumeMm3;
|
|
406
|
+
// A real groove was carved: less than the full cylinder, but most remains.
|
|
407
|
+
expect(vol).toBeGreaterThan(CYL_VOL * 0.8);
|
|
408
|
+
expect(vol).toBeLessThan(CYL_VOL - 100);
|
|
409
|
+
});
|
|
410
|
+
it("fuses a helical thread onto the cylinder surface", () => {
|
|
411
|
+
cylinder(15, 50);
|
|
412
|
+
const path = helix("z").height(50).radius(15).pitch(5).startOffset(-5).endOffset(5);
|
|
413
|
+
const profile = sketch("left", () => { move([15, 0]); circle(3); });
|
|
414
|
+
const s = sweep(path, profile).add();
|
|
415
|
+
render();
|
|
416
|
+
expect(s.getError()).toBeNull();
|
|
417
|
+
const shapes = s.getShapes();
|
|
418
|
+
expect(shapes).toHaveLength(1);
|
|
419
|
+
// A thread was added: more than the bare cylinder.
|
|
420
|
+
expect(ShapeProps.getProperties(shapes[0].getShape()).volumeMm3).toBeGreaterThan(CYL_VOL + 100);
|
|
421
|
+
});
|
|
422
|
+
it("removes a groove when the helix has no start/end offset", () => {
|
|
423
|
+
cylinder(15, 50);
|
|
424
|
+
const path = helix("z").height(50).radius(15).pitch(5);
|
|
425
|
+
const profile = sketch("left", () => { move([15, 0]); circle(3); });
|
|
426
|
+
const s = sweep(path, profile).remove();
|
|
427
|
+
render();
|
|
428
|
+
expect(s.getError()).toBeNull();
|
|
429
|
+
const shapes = s.getShapes();
|
|
430
|
+
expect(shapes).toHaveLength(1);
|
|
431
|
+
const vol = ShapeProps.getProperties(shapes[0].getShape()).volumeMm3;
|
|
432
|
+
expect(vol).toBeGreaterThan(CYL_VOL * 0.8);
|
|
433
|
+
expect(vol).toBeLessThan(CYL_VOL - 100);
|
|
434
|
+
});
|
|
435
|
+
});
|
|
312
436
|
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|