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.
Files changed (178) hide show
  1. package/LICENSE.txt +21 -504
  2. package/README.md +1 -1
  3. package/lib/dist/common/edge.d.ts +1 -1
  4. package/lib/dist/common/face.d.ts +1 -1
  5. package/lib/dist/common/scene-object.d.ts +6 -0
  6. package/lib/dist/common/scene-object.js +8 -0
  7. package/lib/dist/common/shape-factory.d.ts +1 -1
  8. package/lib/dist/common/shape-history-tracker.d.ts +1 -1
  9. package/lib/dist/common/shape.d.ts +1 -1
  10. package/lib/dist/common/solid.d.ts +1 -1
  11. package/lib/dist/common/transformable-primitive.d.ts +12 -1
  12. package/lib/dist/common/transformable-primitive.js +27 -0
  13. package/lib/dist/common/vertex.d.ts +1 -1
  14. package/lib/dist/common/wire.d.ts +1 -1
  15. package/lib/dist/core/2d/index.d.ts +1 -0
  16. package/lib/dist/core/2d/index.js +1 -0
  17. package/lib/dist/core/2d/text.d.ts +30 -0
  18. package/lib/dist/core/2d/text.js +37 -0
  19. package/lib/dist/core/helix.d.ts +20 -0
  20. package/lib/dist/core/helix.js +36 -0
  21. package/lib/dist/core/index.d.ts +3 -1
  22. package/lib/dist/core/index.js +2 -0
  23. package/lib/dist/core/interfaces.d.ts +180 -0
  24. package/lib/dist/core/wrap.d.ts +17 -0
  25. package/lib/dist/core/wrap.js +39 -0
  26. package/lib/dist/features/2d/text.d.ts +67 -0
  27. package/lib/dist/features/2d/text.js +320 -0
  28. package/lib/dist/features/cylinder.d.ts +3 -1
  29. package/lib/dist/features/cylinder.js +5 -2
  30. package/lib/dist/features/extrude-base.d.ts +1 -0
  31. package/lib/dist/features/extrude-to-face.d.ts +1 -0
  32. package/lib/dist/features/extrude-to-face.js +6 -0
  33. package/lib/dist/features/fillet.d.ts +1 -1
  34. package/lib/dist/features/helix.d.ts +41 -0
  35. package/lib/dist/features/helix.js +337 -0
  36. package/lib/dist/features/select.js +32 -8
  37. package/lib/dist/features/simple-extruder.d.ts +1 -1
  38. package/lib/dist/features/simple-extruder.js +7 -2
  39. package/lib/dist/features/sphere.d.ts +3 -1
  40. package/lib/dist/features/sphere.js +5 -2
  41. package/lib/dist/features/sweep.js +7 -2
  42. package/lib/dist/features/wrap.d.ts +39 -0
  43. package/lib/dist/features/wrap.js +116 -0
  44. package/lib/dist/filters/edge/belongs-to-face.d.ts +3 -1
  45. package/lib/dist/filters/edge/belongs-to-face.js +14 -10
  46. package/lib/dist/filters/filter.d.ts +1 -1
  47. package/lib/dist/filters/from-object.d.ts +1 -1
  48. package/lib/dist/filters/tangent-expander.d.ts +1 -1
  49. package/lib/dist/filters/tangent-expander.js +57 -40
  50. package/lib/dist/helpers/scene-helpers.d.ts +2 -0
  51. package/lib/dist/helpers/scene-helpers.js +1 -1
  52. package/lib/dist/index.d.ts +2 -0
  53. package/lib/dist/index.js +3 -1
  54. package/lib/dist/io/file-import.d.ts +7 -0
  55. package/lib/dist/io/file-import.js +28 -1
  56. package/lib/dist/io/font-registry.d.ts +45 -0
  57. package/lib/dist/io/font-registry.js +272 -0
  58. package/lib/dist/math/bspline-interpolation.d.ts +29 -0
  59. package/lib/dist/math/bspline-interpolation.js +194 -0
  60. package/lib/dist/oc/boolean-ops.d.ts +3 -1
  61. package/lib/dist/oc/boolean-ops.js +15 -1
  62. package/lib/dist/oc/color-transfer.d.ts +1 -1
  63. package/lib/dist/oc/constraints/constraint-helpers.d.ts +4 -4
  64. package/lib/dist/oc/constraints/curve/tangent-circle-solver.js +10 -9
  65. package/lib/dist/oc/constraints/curve/tangent-line-solver.js +5 -6
  66. package/lib/dist/oc/convert.d.ts +1 -1
  67. package/lib/dist/oc/draft-ops.d.ts +1 -1
  68. package/lib/dist/oc/edge-ops.d.ts +2 -2
  69. package/lib/dist/oc/edge-ops.js +13 -14
  70. package/lib/dist/oc/edge-props.d.ts +1 -1
  71. package/lib/dist/oc/edge-query.d.ts +1 -1
  72. package/lib/dist/oc/edge-query.js +3 -8
  73. package/lib/dist/oc/errors.d.ts +8 -0
  74. package/lib/dist/oc/errors.js +27 -0
  75. package/lib/dist/oc/explorer.d.ts +2 -2
  76. package/lib/dist/oc/extrude-ops.d.ts +28 -2
  77. package/lib/dist/oc/extrude-ops.js +56 -7
  78. package/lib/dist/oc/face-ops.d.ts +2 -1
  79. package/lib/dist/oc/face-ops.js +11 -0
  80. package/lib/dist/oc/face-props.d.ts +1 -1
  81. package/lib/dist/oc/face-query.d.ts +12 -1
  82. package/lib/dist/oc/face-query.js +39 -0
  83. package/lib/dist/oc/fillet-ops.d.ts +1 -1
  84. package/lib/dist/oc/fillet-ops.js +4 -4
  85. package/lib/dist/oc/geometry.d.ts +1 -1
  86. package/lib/dist/oc/geometry.js +12 -14
  87. package/lib/dist/oc/helix-ops.d.ts +37 -0
  88. package/lib/dist/oc/helix-ops.js +88 -0
  89. package/lib/dist/oc/hit-test.d.ts +1 -1
  90. package/lib/dist/oc/index.d.ts +4 -0
  91. package/lib/dist/oc/index.js +2 -0
  92. package/lib/dist/oc/init.d.ts +1 -1
  93. package/lib/dist/oc/init.js +1 -1
  94. package/lib/dist/oc/intersection.js +1 -1
  95. package/lib/dist/oc/io.d.ts +6 -6
  96. package/lib/dist/oc/io.js +31 -24
  97. package/lib/dist/oc/measure/classify.d.ts +34 -0
  98. package/lib/dist/oc/measure/classify.js +246 -0
  99. package/lib/dist/oc/measure/measure-ops.d.ts +9 -0
  100. package/lib/dist/oc/measure/measure-ops.js +210 -0
  101. package/lib/dist/oc/measure/measure-types.d.ts +39 -0
  102. package/lib/dist/oc/measure/measure-types.js +1 -0
  103. package/lib/dist/oc/measure/sampling.d.ts +9 -0
  104. package/lib/dist/oc/measure/sampling.js +77 -0
  105. package/lib/dist/oc/measure/vec.d.ts +13 -0
  106. package/lib/dist/oc/measure/vec.js +23 -0
  107. package/lib/dist/oc/mesh.d.ts +1 -1
  108. package/lib/dist/oc/mesh.js +40 -28
  109. package/lib/dist/oc/path-sampler.d.ts +29 -0
  110. package/lib/dist/oc/path-sampler.js +63 -0
  111. package/lib/dist/oc/props.d.ts +1 -1
  112. package/lib/dist/oc/props.js +4 -1
  113. package/lib/dist/oc/shape-hash.d.ts +26 -0
  114. package/lib/dist/oc/shape-hash.js +32 -0
  115. package/lib/dist/oc/shape-ops.d.ts +5 -3
  116. package/lib/dist/oc/shape-ops.js +6 -5
  117. package/lib/dist/oc/sweep-ops.d.ts +22 -1
  118. package/lib/dist/oc/sweep-ops.js +206 -18
  119. package/lib/dist/oc/text-outline.d.ts +62 -0
  120. package/lib/dist/oc/text-outline.js +212 -0
  121. package/lib/dist/oc/topology-index.d.ts +1 -1
  122. package/lib/dist/oc/vertex-ops.d.ts +1 -1
  123. package/lib/dist/oc/wire-ops.d.ts +1 -1
  124. package/lib/dist/oc/wire-ops.js +1 -1
  125. package/lib/dist/oc/wrap-development.d.ts +105 -0
  126. package/lib/dist/oc/wrap-development.js +179 -0
  127. package/lib/dist/oc/wrap-ops.d.ts +100 -0
  128. package/lib/dist/oc/wrap-ops.js +406 -0
  129. package/lib/dist/rendering/render-solid.js +10 -2
  130. package/lib/dist/scene-manager.d.ts +2 -0
  131. package/lib/dist/scene-manager.js +29 -0
  132. package/lib/dist/tests/features/cylinder-curve-filter.test.js +3 -3
  133. package/lib/dist/tests/features/extrude-to-face.test.js +38 -1
  134. package/lib/dist/tests/features/helix.test.d.ts +1 -0
  135. package/lib/dist/tests/features/helix.test.js +295 -0
  136. package/lib/dist/tests/features/repeat-primitive.test.d.ts +1 -0
  137. package/lib/dist/tests/features/repeat-primitive.test.js +60 -0
  138. package/lib/dist/tests/features/rib.test.js +6 -1
  139. package/lib/dist/tests/features/sweep.test.js +125 -1
  140. package/lib/dist/tests/features/text.test.d.ts +1 -0
  141. package/lib/dist/tests/features/text.test.js +347 -0
  142. package/lib/dist/tests/features/wrap-development.test.d.ts +1 -0
  143. package/lib/dist/tests/features/wrap-development.test.js +130 -0
  144. package/lib/dist/tests/features/wrap-extruded-target.test.d.ts +1 -0
  145. package/lib/dist/tests/features/wrap-extruded-target.test.js +106 -0
  146. package/lib/dist/tests/features/wrap-repeat.test.d.ts +1 -0
  147. package/lib/dist/tests/features/wrap-repeat.test.js +93 -0
  148. package/lib/dist/tests/features/wrap.test.d.ts +1 -0
  149. package/lib/dist/tests/features/wrap.test.js +331 -0
  150. package/lib/dist/tests/math/bspline-interpolation.test.d.ts +1 -0
  151. package/lib/dist/tests/math/bspline-interpolation.test.js +119 -0
  152. package/lib/dist/tests/measure.test.d.ts +1 -0
  153. package/lib/dist/tests/measure.test.js +288 -0
  154. package/lib/dist/tsconfig.tsbuildinfo +1 -1
  155. package/llm-docs/api/helix.md +64 -0
  156. package/llm-docs/api/index.json +11 -2
  157. package/llm-docs/api/text.md +52 -0
  158. package/llm-docs/api/types/helix.md +105 -0
  159. package/llm-docs/api/types/text.md +138 -0
  160. package/llm-docs/api/types/wrap.md +131 -0
  161. package/llm-docs/api/wrap.md +62 -0
  162. package/llm-docs/index.json +121 -1
  163. package/mcp/dist/server.js +20 -1
  164. package/mcp/dist/tools/inspection.d.ts +17 -0
  165. package/mcp/dist/tools/inspection.js +14 -0
  166. package/package.json +7 -3
  167. package/server/dist/fluidcad-server.d.ts +10 -0
  168. package/server/dist/fluidcad-server.js +10 -0
  169. package/server/dist/index.js +4 -2
  170. package/server/dist/preferences.d.ts +4 -0
  171. package/server/dist/preferences.js +2 -0
  172. package/server/dist/routes/measure.d.ts +3 -0
  173. package/server/dist/routes/measure.js +32 -0
  174. package/server/dist/routes/preferences.js +6 -0
  175. package/server/dist/routes/sketch-edits.js +2 -1
  176. package/ui/dist/assets/{index-CDJmUpFI.css → index-dAFdg2Un.css} +1 -1
  177. package/ui/dist/assets/{index-MRqwG9Vh.js → index-no7mtr5s.js} +149 -102
  178. 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, 50).centered();
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 { circle, rect, vLine, hLine, arc } from "../../core/2d/index.js";
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 {};