fluidcad 0.0.32 → 0.0.34

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 (279) hide show
  1. package/README.md +3 -2
  2. package/bin/commands/init.js +55 -0
  3. package/bin/commands/mcp.js +33 -0
  4. package/bin/commands/serve.js +77 -0
  5. package/bin/fluidcad.js +15 -107
  6. package/lib/dist/common/scene-object.d.ts +4 -1
  7. package/lib/dist/common/scene-object.js +9 -2
  8. package/lib/dist/common/solid.d.ts +4 -1
  9. package/lib/dist/common/solid.js +13 -0
  10. package/lib/dist/core/2d/tarc.d.ts +20 -2
  11. package/lib/dist/core/2d/tarc.js +24 -0
  12. package/lib/dist/core/index.d.ts +2 -1
  13. package/lib/dist/core/index.js +1 -0
  14. package/lib/dist/core/interfaces.d.ts +107 -2
  15. package/lib/dist/core/load.d.ts +2 -2
  16. package/lib/dist/core/repeat.js +62 -46
  17. package/lib/dist/core/rib.d.ts +18 -0
  18. package/lib/dist/core/rib.js +37 -0
  19. package/lib/dist/features/2d/arc.d.ts +8 -2
  20. package/lib/dist/features/2d/arc.js +94 -17
  21. package/lib/dist/features/2d/back.js +3 -2
  22. package/lib/dist/features/2d/sketch.d.ts +4 -0
  23. package/lib/dist/features/2d/sketch.js +21 -0
  24. package/lib/dist/features/2d/tarc-constrained.d.ts +2 -0
  25. package/lib/dist/features/2d/tarc-constrained.js +8 -0
  26. package/lib/dist/features/2d/tarc-radius-to-object.d.ts +16 -0
  27. package/lib/dist/features/2d/tarc-radius-to-object.js +58 -0
  28. package/lib/dist/features/2d/tarc-to-object.d.ts +18 -0
  29. package/lib/dist/features/2d/tarc-to-object.js +66 -0
  30. package/lib/dist/features/2d/tarc-to-point-tangent.d.ts +2 -0
  31. package/lib/dist/features/2d/tarc-to-point-tangent.js +3 -0
  32. package/lib/dist/features/2d/tarc-to-point.d.ts +2 -0
  33. package/lib/dist/features/2d/tarc-to-point.js +3 -0
  34. package/lib/dist/features/2d/tarc-with-tangent.d.ts +2 -0
  35. package/lib/dist/features/2d/tarc-with-tangent.js +3 -0
  36. package/lib/dist/features/2d/tarc.d.ts +2 -0
  37. package/lib/dist/features/2d/tarc.js +3 -0
  38. package/lib/dist/features/extrude-base.d.ts +9 -0
  39. package/lib/dist/features/extrude-base.js +22 -0
  40. package/lib/dist/features/extrude-to-face.js +1 -5
  41. package/lib/dist/features/extrude-two-distances.js +1 -2
  42. package/lib/dist/features/extrude.js +1 -2
  43. package/lib/dist/features/load.d.ts +6 -0
  44. package/lib/dist/features/load.js +53 -1
  45. package/lib/dist/features/mirror-feature.d.ts +3 -2
  46. package/lib/dist/features/mirror-feature.js +1 -1
  47. package/lib/dist/features/repeat-circular.d.ts +3 -3
  48. package/lib/dist/features/repeat-circular.js +8 -1
  49. package/lib/dist/features/repeat-linear.d.ts +4 -2
  50. package/lib/dist/features/repeat-linear.js +10 -1
  51. package/lib/dist/features/repeat-matrix.d.ts +3 -1
  52. package/lib/dist/features/repeat-matrix.js +7 -2
  53. package/lib/dist/features/rib.d.ts +31 -0
  54. package/lib/dist/features/rib.js +321 -0
  55. package/lib/dist/features/select.d.ts +1 -0
  56. package/lib/dist/features/select.js +81 -10
  57. package/lib/dist/features/shell.d.ts +4 -1
  58. package/lib/dist/features/shell.js +14 -3
  59. package/lib/dist/filters/edge/belongs-to-face.d.ts +12 -9
  60. package/lib/dist/filters/edge/belongs-to-face.js +64 -15
  61. package/lib/dist/filters/filter-builder-base.d.ts +25 -0
  62. package/lib/dist/filters/filter-builder-base.js +47 -0
  63. package/lib/dist/filters/filter.js +39 -14
  64. package/lib/dist/filters/from-object.d.ts +4 -0
  65. package/lib/dist/filters/from-object.js +10 -0
  66. package/lib/dist/helpers/clone-transform.d.ts +2 -1
  67. package/lib/dist/helpers/scene-helpers.d.ts +1 -1
  68. package/lib/dist/helpers/scene-helpers.js +146 -12
  69. package/lib/dist/index.d.ts +7 -1
  70. package/lib/dist/index.js +3 -3
  71. package/lib/dist/io/file-import.d.ts +5 -1
  72. package/lib/dist/io/file-import.js +29 -18
  73. package/lib/dist/math/lazy-matrix.d.ts +31 -0
  74. package/lib/dist/math/lazy-matrix.js +66 -0
  75. package/lib/dist/oc/color-transfer.d.ts +19 -8
  76. package/lib/dist/oc/color-transfer.js +70 -12
  77. package/lib/dist/oc/constraints/constraint-solver-adaptor.d.ts +5 -0
  78. package/lib/dist/oc/constraints/constraint-solver-adaptor.js +16 -0
  79. package/lib/dist/oc/constraints/constraint-solver.d.ts +4 -0
  80. package/lib/dist/oc/constraints/curve/curve-constraint-solver.d.ts +4 -0
  81. package/lib/dist/oc/constraints/curve/curve-constraint-solver.js +3 -0
  82. package/lib/dist/oc/constraints/geometric/geometric-constraint-solver.d.ts +6 -1
  83. package/lib/dist/oc/constraints/geometric/geometric-constraint-solver.js +4 -0
  84. package/lib/dist/oc/constraints/geometric/tangent-arc-from-point-tangent.d.ts +8 -0
  85. package/lib/dist/oc/constraints/geometric/tangent-arc-from-point-tangent.js +111 -0
  86. package/lib/dist/oc/constraints/geometric/tangent-arc-radius-to-object.d.ts +8 -0
  87. package/lib/dist/oc/constraints/geometric/tangent-arc-radius-to-object.js +161 -0
  88. package/lib/dist/oc/extrude-ops.d.ts +2 -1
  89. package/lib/dist/oc/extrude-ops.js +51 -2
  90. package/lib/dist/oc/mesh.d.ts +9 -4
  91. package/lib/dist/oc/mesh.js +14 -13
  92. package/lib/dist/oc/rib-ops.d.ts +35 -0
  93. package/lib/dist/oc/rib-ops.js +619 -0
  94. package/lib/dist/oc/shell-ops.d.ts +2 -1
  95. package/lib/dist/oc/shell-ops.js +5 -2
  96. package/lib/dist/oc/topology-index.d.ts +6 -0
  97. package/lib/dist/oc/topology-index.js +36 -0
  98. package/lib/dist/rendering/mesh-builder.d.ts +3 -0
  99. package/lib/dist/rendering/mesh-builder.js +8 -4
  100. package/lib/dist/rendering/render-edge.d.ts +2 -1
  101. package/lib/dist/rendering/render-edge.js +2 -2
  102. package/lib/dist/rendering/render-face.d.ts +2 -1
  103. package/lib/dist/rendering/render-face.js +2 -2
  104. package/lib/dist/rendering/render-solid.d.ts +2 -1
  105. package/lib/dist/rendering/render-solid.js +3 -5
  106. package/lib/dist/rendering/render-wire.d.ts +2 -1
  107. package/lib/dist/rendering/render-wire.js +2 -2
  108. package/lib/dist/rendering/render.d.ts +4 -0
  109. package/lib/dist/rendering/render.js +50 -2
  110. package/lib/dist/rendering/scene-compare.js +3 -0
  111. package/lib/dist/rendering/scene.d.ts +1 -0
  112. package/lib/dist/rendering/scene.js +4 -0
  113. package/lib/dist/scene-manager.d.ts +4 -2
  114. package/lib/dist/scene-manager.js +12 -4
  115. package/lib/dist/tests/features/2d/arc.test.js +64 -0
  116. package/lib/dist/tests/features/2d/back.test.js +17 -1
  117. package/lib/dist/tests/features/2d/tarc.test.js +157 -0
  118. package/lib/dist/tests/features/color-lineage.test.js +18 -0
  119. package/lib/dist/tests/features/filter-positional.test.d.ts +1 -0
  120. package/lib/dist/tests/features/filter-positional.test.js +129 -0
  121. package/lib/dist/tests/features/repeat-user-repro.test.d.ts +1 -0
  122. package/lib/dist/tests/features/repeat-user-repro.test.js +60 -0
  123. package/lib/dist/tests/features/rib.test.d.ts +1 -0
  124. package/lib/dist/tests/features/rib.test.js +598 -0
  125. package/lib/dist/tests/features/shell.test.js +36 -0
  126. package/lib/dist/tests/global-setup.js +2 -1
  127. package/lib/dist/tests/helpers/extract-blocks.d.ts +9 -0
  128. package/lib/dist/tests/helpers/extract-blocks.js +56 -0
  129. package/lib/dist/tests/llm-docs-examples.test.d.ts +1 -0
  130. package/lib/dist/tests/llm-docs-examples.test.js +62 -0
  131. package/lib/dist/tests/scene-compare.test.d.ts +1 -0
  132. package/lib/dist/tests/scene-compare.test.js +77 -0
  133. package/lib/dist/tests/setup.js +2 -1
  134. package/lib/dist/tsconfig.tsbuildinfo +1 -1
  135. package/llm-docs/.coverage-allowlist.txt +9 -0
  136. package/llm-docs/api/arc.md +48 -0
  137. package/llm-docs/api/axis.md +42 -0
  138. package/llm-docs/api/bezier.md +42 -0
  139. package/llm-docs/api/booleans.md +44 -0
  140. package/llm-docs/api/chamfer.md +40 -0
  141. package/llm-docs/api/circle.md +36 -0
  142. package/llm-docs/api/color.md +34 -0
  143. package/llm-docs/api/connect.md +41 -0
  144. package/llm-docs/api/constraint-qualifiers.md +48 -0
  145. package/llm-docs/api/copy.md +63 -0
  146. package/llm-docs/api/cursor-lines.md +50 -0
  147. package/llm-docs/api/cursor-move.md +61 -0
  148. package/llm-docs/api/cut.md +55 -0
  149. package/llm-docs/api/draft.md +36 -0
  150. package/llm-docs/api/edge-filter.md +57 -0
  151. package/llm-docs/api/ellipse.md +34 -0
  152. package/llm-docs/api/extrude.md +74 -0
  153. package/llm-docs/api/face-filter.md +61 -0
  154. package/llm-docs/api/fillet.md +51 -0
  155. package/llm-docs/api/index.json +139 -0
  156. package/llm-docs/api/line.md +42 -0
  157. package/llm-docs/api/load.md +37 -0
  158. package/llm-docs/api/local.md +38 -0
  159. package/llm-docs/api/loft.md +37 -0
  160. package/llm-docs/api/mirror.md +44 -0
  161. package/llm-docs/api/offset.md +36 -0
  162. package/llm-docs/api/part.md +40 -0
  163. package/llm-docs/api/plane.md +44 -0
  164. package/llm-docs/api/polygon.md +37 -0
  165. package/llm-docs/api/primitive-solids.md +39 -0
  166. package/llm-docs/api/project-intersect.md +48 -0
  167. package/llm-docs/api/rect.md +48 -0
  168. package/llm-docs/api/remove.md +32 -0
  169. package/llm-docs/api/repeat.md +79 -0
  170. package/llm-docs/api/revolve.md +38 -0
  171. package/llm-docs/api/rib.md +40 -0
  172. package/llm-docs/api/rotate.md +37 -0
  173. package/llm-docs/api/select.md +41 -0
  174. package/llm-docs/api/shell.md +41 -0
  175. package/llm-docs/api/sketch.md +76 -0
  176. package/llm-docs/api/slot.md +36 -0
  177. package/llm-docs/api/split-trim.md +42 -0
  178. package/llm-docs/api/sweep.md +43 -0
  179. package/llm-docs/api/tarc.md +45 -0
  180. package/llm-docs/api/tcircle.md +38 -0
  181. package/llm-docs/api/tline.md +42 -0
  182. package/llm-docs/api/translate.md +40 -0
  183. package/llm-docs/api/types/aline.md +35 -0
  184. package/llm-docs/api/types/arc-angles.md +29 -0
  185. package/llm-docs/api/types/arc-points.md +48 -0
  186. package/llm-docs/api/types/axis-like.md +38 -0
  187. package/llm-docs/api/types/axis.md +21 -0
  188. package/llm-docs/api/types/boolean-operation.md +50 -0
  189. package/llm-docs/api/types/circular-repeat-options.md +31 -0
  190. package/llm-docs/api/types/common.md +32 -0
  191. package/llm-docs/api/types/cut.md +125 -0
  192. package/llm-docs/api/types/draft.md +21 -0
  193. package/llm-docs/api/types/extrudable-geometry.md +23 -0
  194. package/llm-docs/api/types/extrude.md +194 -0
  195. package/llm-docs/api/types/geometry.md +51 -0
  196. package/llm-docs/api/types/hline.md +35 -0
  197. package/llm-docs/api/types/linear-repeat-options.md +31 -0
  198. package/llm-docs/api/types/loft.md +154 -0
  199. package/llm-docs/api/types/mirror.md +35 -0
  200. package/llm-docs/api/types/offset.md +31 -0
  201. package/llm-docs/api/types/plane-like.md +35 -0
  202. package/llm-docs/api/types/plane-transform-options.md +29 -0
  203. package/llm-docs/api/types/plane.md +21 -0
  204. package/llm-docs/api/types/point-like.md +22 -0
  205. package/llm-docs/api/types/point2dlike.md +26 -0
  206. package/llm-docs/api/types/polygon.md +46 -0
  207. package/llm-docs/api/types/rect.md +128 -0
  208. package/llm-docs/api/types/revolve.md +102 -0
  209. package/llm-docs/api/types/rib.md +133 -0
  210. package/llm-docs/api/types/scene-object.md +33 -0
  211. package/llm-docs/api/types/select.md +21 -0
  212. package/llm-docs/api/types/shell.md +54 -0
  213. package/llm-docs/api/types/slot.md +43 -0
  214. package/llm-docs/api/types/sweep.md +189 -0
  215. package/llm-docs/api/types/tangent-arc-two-objects.md +46 -0
  216. package/llm-docs/api/types/transformable.md +93 -0
  217. package/llm-docs/api/types/trim.md +27 -0
  218. package/llm-docs/api/types/two-objects-tangent-line.md +46 -0
  219. package/llm-docs/api/types/vertex.md +17 -0
  220. package/llm-docs/api/types/vline.md +35 -0
  221. package/llm-docs/concepts/coordinate-system.md +45 -0
  222. package/llm-docs/concepts/history-and-rollback.md +40 -0
  223. package/llm-docs/concepts/last-selection.md +49 -0
  224. package/llm-docs/concepts/scene-graph.md +37 -0
  225. package/llm-docs/index.json +1750 -0
  226. package/mcp/dist/client.d.ts +64 -0
  227. package/mcp/dist/client.js +248 -0
  228. package/mcp/dist/discovery.d.ts +11 -0
  229. package/mcp/dist/discovery.js +78 -0
  230. package/mcp/dist/docs-index.d.ts +81 -0
  231. package/mcp/dist/docs-index.js +261 -0
  232. package/mcp/dist/resources.d.ts +4 -0
  233. package/mcp/dist/resources.js +115 -0
  234. package/mcp/dist/server.d.ts +12 -0
  235. package/mcp/dist/server.js +489 -0
  236. package/mcp/dist/tools/coordination.d.ts +9 -0
  237. package/mcp/dist/tools/coordination.js +46 -0
  238. package/mcp/dist/tools/docs.d.ts +66 -0
  239. package/mcp/dist/tools/docs.js +122 -0
  240. package/mcp/dist/tools/engine.d.ts +56 -0
  241. package/mcp/dist/tools/engine.js +145 -0
  242. package/mcp/dist/tools/inspection.d.ts +75 -0
  243. package/mcp/dist/tools/inspection.js +121 -0
  244. package/mcp/dist/tools/screenshot.d.ts +63 -0
  245. package/mcp/dist/tools/screenshot.js +263 -0
  246. package/mcp/dist/tools/source.d.ts +84 -0
  247. package/mcp/dist/tools/source.js +434 -0
  248. package/mcp/dist/tools/workspaces.d.ts +13 -0
  249. package/mcp/dist/tools/workspaces.js +33 -0
  250. package/mcp/dist/types.d.ts +18 -0
  251. package/mcp/dist/types.js +11 -0
  252. package/package.json +19 -5
  253. package/server/dist/code-editor.d.ts +36 -0
  254. package/server/dist/code-editor.js +8 -0
  255. package/server/dist/fluidcad-server.d.ts +50 -0
  256. package/server/dist/fluidcad-server.js +153 -1
  257. package/server/dist/global-registry.d.ts +30 -0
  258. package/server/dist/global-registry.js +126 -0
  259. package/server/dist/index.js +171 -26
  260. package/server/dist/instance-file.d.ts +31 -0
  261. package/server/dist/instance-file.js +73 -0
  262. package/server/dist/lint-fluid-js.d.ts +15 -0
  263. package/server/dist/lint-fluid-js.js +271 -0
  264. package/server/dist/routes/editor.d.ts +24 -0
  265. package/server/dist/routes/editor.js +44 -0
  266. package/server/dist/routes/export.d.ts +1 -1
  267. package/server/dist/routes/export.js +45 -8
  268. package/server/dist/routes/health.d.ts +7 -0
  269. package/server/dist/routes/health.js +14 -0
  270. package/server/dist/routes/lint.d.ts +10 -0
  271. package/server/dist/routes/lint.js +28 -0
  272. package/server/dist/routes/render.d.ts +33 -0
  273. package/server/dist/routes/render.js +34 -0
  274. package/server/dist/routes/scene.d.ts +5 -0
  275. package/server/dist/routes/scene.js +48 -0
  276. package/server/dist/routes/screenshot.js +68 -1
  277. package/server/dist/ws-protocol.d.ts +56 -2
  278. package/ui/dist/assets/{index-DMw0OYCF.js → index-BdqrMDRu.js} +30 -30
  279. package/ui/dist/index.html +1 -1
@@ -0,0 +1,598 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { setupOC, render } from "../setup.js";
3
+ import sketch from "../../core/sketch.js";
4
+ import extrude from "../../core/extrude.js";
5
+ import shell from "../../core/shell.js";
6
+ import fillet from "../../core/fillet.js";
7
+ import rib from "../../core/rib.js";
8
+ import { rect, circle, move, aLine, hLine } from "../../core/2d/index.js";
9
+ import { Rib } from "../../features/rib.js";
10
+ import { ShapeOps } from "../../oc/shape-ops.js";
11
+ import { Explorer } from "../../oc/explorer.js";
12
+ import { getOC } from "../../oc/init.js";
13
+ // Y span of the rib's start face (the spine-plane cap). Exact thickness
14
+ // preservation lives on the start face by construction; the solid bbox
15
+ // can drift by face tolerance after boolean cuts, so we measure the
16
+ // face geometry directly.
17
+ function startFaceYSpan(rib) {
18
+ const startFaces = rib.getState('start-faces');
19
+ if (!startFaces || startFaces.length === 0) {
20
+ throw new Error("rib has no start faces");
21
+ }
22
+ const oc = getOC();
23
+ const bb = new oc.Bnd_Box();
24
+ for (const f of startFaces) {
25
+ oc.BRepBndLib.Add(f.getShape(), bb, false);
26
+ }
27
+ const minP = bb.CornerMin();
28
+ const maxP = bb.CornerMax();
29
+ const span = maxP.Y() - minP.Y();
30
+ bb.delete();
31
+ return span;
32
+ }
33
+ describe("rib", () => {
34
+ setupOC();
35
+ function makeBox() {
36
+ sketch("top", () => {
37
+ rect(100, 50).centered();
38
+ });
39
+ const box = extrude(30);
40
+ const s = shell(-4, box.endFaces());
41
+ return s;
42
+ }
43
+ describe("basic rib (normal direction)", () => {
44
+ it("should create a rib from a straight line", () => {
45
+ const s = makeBox();
46
+ sketch("front", () => {
47
+ move([-20, 15]);
48
+ hLine(40);
49
+ });
50
+ const r = rib(5).scope(s);
51
+ render();
52
+ const shapes = r.getShapes();
53
+ expect(shapes.length).toBeGreaterThan(0);
54
+ });
55
+ it("should create a standalone rib with .new()", () => {
56
+ const s = makeBox();
57
+ sketch("front", () => {
58
+ move([-20, 15]);
59
+ hLine(40);
60
+ });
61
+ const r = rib(5).new().scope(s);
62
+ render();
63
+ const shapes = r.getShapes();
64
+ expect(shapes.length).toBeGreaterThan(0);
65
+ const sShapes = s.getShapes();
66
+ expect(sShapes.length).toBeGreaterThan(0);
67
+ });
68
+ it("rib should be contained within scope solid bounds", () => {
69
+ const s = makeBox();
70
+ sketch("front", () => {
71
+ move([-20, 15]);
72
+ hLine(40);
73
+ });
74
+ const r = rib(5).new().scope(s);
75
+ render();
76
+ const scopeBBox = ShapeOps.getBoundingBox(s.getShapes()[0]);
77
+ for (const shape of r.getShapes()) {
78
+ const ribBBox = ShapeOps.getBoundingBox(shape);
79
+ expect(ribBBox.minX).toBeGreaterThanOrEqual(scopeBBox.minX - 0.1);
80
+ expect(ribBBox.maxX).toBeLessThanOrEqual(scopeBBox.maxX + 0.1);
81
+ expect(ribBBox.minY).toBeGreaterThanOrEqual(scopeBBox.minY - 0.1);
82
+ expect(ribBBox.maxY).toBeLessThanOrEqual(scopeBBox.maxY + 0.1);
83
+ expect(ribBBox.minZ).toBeGreaterThanOrEqual(scopeBBox.minZ - 0.1);
84
+ expect(ribBBox.maxZ).toBeLessThanOrEqual(scopeBBox.maxZ + 0.1);
85
+ }
86
+ });
87
+ });
88
+ describe("parallel direction", () => {
89
+ it("should create a parallel rib with .new()", () => {
90
+ const s = makeBox();
91
+ sketch("front", () => {
92
+ move([-20, 15]);
93
+ hLine(40);
94
+ });
95
+ const r = rib(5).parallel().new().scope(s);
96
+ render();
97
+ const shapes = r.getShapes();
98
+ expect(shapes.length).toBeGreaterThan(0);
99
+ });
100
+ it("parallel rib with .add() should not extend beyond scope bounds", () => {
101
+ const s = makeBox();
102
+ sketch("front", () => {
103
+ move([-20, 15]);
104
+ hLine(40);
105
+ });
106
+ const r = rib(5).parallel().add().scope(s);
107
+ render();
108
+ const shapes = r.getShapes();
109
+ expect(shapes.length).toBeGreaterThan(0);
110
+ });
111
+ it("parallel rib on diagonal line", () => {
112
+ const s = makeBox();
113
+ sketch("front", () => {
114
+ move([-40, 20]);
115
+ aLine(45, 20);
116
+ });
117
+ const r = rib(5).parallel().new().scope(s);
118
+ render();
119
+ const shapes = r.getShapes();
120
+ expect(shapes.length).toBeGreaterThan(0);
121
+ const scopeBBox = ShapeOps.getBoundingBox(s.getShapes()[0]);
122
+ for (const shape of r.getShapes()) {
123
+ const ribBBox = ShapeOps.getBoundingBox(shape);
124
+ expect(ribBBox.minX).toBeGreaterThanOrEqual(scopeBBox.minX - 0.1);
125
+ expect(ribBBox.maxX).toBeLessThanOrEqual(scopeBBox.maxX + 0.1);
126
+ }
127
+ });
128
+ });
129
+ describe("extend", () => {
130
+ it("extended rib should still be within scope bounds", () => {
131
+ const s = makeBox();
132
+ sketch("front", () => {
133
+ move([-40, 20]);
134
+ aLine(45, 20);
135
+ });
136
+ const r = rib(5).parallel().new().scope(s).extend();
137
+ render();
138
+ const shapes = r.getShapes();
139
+ expect(shapes.length).toBeGreaterThan(0);
140
+ const scopeBBox = ShapeOps.getBoundingBox(s.getShapes()[0]);
141
+ for (const shape of r.getShapes()) {
142
+ const ribBBox = ShapeOps.getBoundingBox(shape);
143
+ expect(ribBBox.minX).toBeGreaterThanOrEqual(scopeBBox.minX - 0.1);
144
+ expect(ribBBox.maxX).toBeLessThanOrEqual(scopeBBox.maxX + 0.1);
145
+ expect(ribBBox.minY).toBeGreaterThanOrEqual(scopeBBox.minY - 0.1);
146
+ expect(ribBBox.maxY).toBeLessThanOrEqual(scopeBBox.maxY + 0.1);
147
+ expect(ribBBox.minZ).toBeGreaterThanOrEqual(scopeBBox.minZ - 0.1);
148
+ expect(ribBBox.maxZ).toBeLessThanOrEqual(scopeBBox.maxZ + 0.1);
149
+ }
150
+ });
151
+ it("extended rib with .add() should fuse correctly", () => {
152
+ const s = makeBox();
153
+ sketch("front", () => {
154
+ move([-40, 20]);
155
+ aLine(45, 20);
156
+ });
157
+ const r = rib(5).parallel().add().scope(s).extend();
158
+ render();
159
+ const shapes = r.getShapes();
160
+ expect(shapes.length).toBeGreaterThan(0);
161
+ });
162
+ it("extended rib with fillet on scope should blend through fillet", () => {
163
+ sketch("top", () => {
164
+ rect(100, 50).centered();
165
+ });
166
+ const box = extrude(30);
167
+ const shelled = shell(-4, box.endFaces());
168
+ const s = fillet(2, shelled.internalEdges());
169
+ sketch("front", () => {
170
+ move([-40, 20]);
171
+ aLine(-45, 20);
172
+ });
173
+ const r = rib(5).parallel().new().scope(s).extend();
174
+ render();
175
+ const shapes = r.getShapes();
176
+ expect(shapes.length).toBeGreaterThan(0);
177
+ const scopeBBox = ShapeOps.getBoundingBox(s.getShapes()[0]);
178
+ for (const shape of r.getShapes()) {
179
+ const ribBBox = ShapeOps.getBoundingBox(shape);
180
+ expect(ribBBox.minX).toBeGreaterThanOrEqual(scopeBBox.minX - 0.1);
181
+ expect(ribBBox.maxX).toBeLessThanOrEqual(scopeBBox.maxX + 0.1);
182
+ }
183
+ });
184
+ it("extended rib should blend with drafted cone in cavity", () => {
185
+ // Reproduces rib5.fluid.js: shelled+filleted box with a drafted-cone
186
+ // boss inside the cavity. The rib spine threads past the cone, so the
187
+ // extended rib must blend conformally with the cone's slanted surface
188
+ // AND with the bottom fillets — the case the original ray-cast extend
189
+ // could not handle.
190
+ sketch("top", () => {
191
+ rect(100, 50).centered();
192
+ });
193
+ const box = extrude(30);
194
+ const shelled = shell(-4, box.endFaces());
195
+ let s = fillet(2, shelled.internalEdges());
196
+ sketch("top", () => {
197
+ circle(30);
198
+ });
199
+ // Drafted cone — the geometry that defeats single-ray-cast extension.
200
+ s = extrude(50)
201
+ .draft(-5);
202
+ sketch("front", () => {
203
+ move([-40, 20]);
204
+ aLine(45, 20);
205
+ });
206
+ const r = rib(5).parallel().extend();
207
+ render();
208
+ // The rib must produce non-empty geometry and must not crash with the
209
+ // BOP "unwind" the prior algorithm hit on this combination.
210
+ const shapes = r.getShapes();
211
+ expect(shapes.length).toBeGreaterThan(0);
212
+ // The conformal blend should manifest as at least one new (cut-created)
213
+ // internal face on the rib — the surface that touches a cavity wall.
214
+ const internalFaces = r.getState('internal-faces');
215
+ expect(internalFaces).toBeDefined();
216
+ expect(internalFaces.length).toBeGreaterThan(0);
217
+ // Stays within the scope bbox (over-extension was clipped).
218
+ const scopeShapes = s.getShapes();
219
+ for (const shape of r.getShapes()) {
220
+ const ribBBox = ShapeOps.getBoundingBox(shape);
221
+ for (const sShape of scopeShapes) {
222
+ const sBBox = ShapeOps.getBoundingBox(sShape);
223
+ expect(ribBBox.minX).toBeGreaterThanOrEqual(Math.min(sBBox.minX, ribBBox.minX) - 0.1);
224
+ }
225
+ }
226
+ });
227
+ it("repeat circular on extended rib should produce valid copies", async () => {
228
+ const repeatModule = await import("../../core/repeat.js");
229
+ const repeat = repeatModule.default;
230
+ sketch("top", () => {
231
+ rect(100, 50).centered();
232
+ });
233
+ const box = extrude(30);
234
+ const shelled = shell(-4, box.endFaces());
235
+ let s = fillet(2, shelled.internalEdges());
236
+ sketch("top", () => {
237
+ circle(30);
238
+ });
239
+ s = extrude(50)
240
+ .draft(-5);
241
+ sketch("front", () => {
242
+ move([-40, 20]);
243
+ aLine(45, 20);
244
+ });
245
+ const r = rib(5).parallel().new().scope(s).extend();
246
+ repeat("circular", "z", { count: 4, angle: 360 }, r);
247
+ render();
248
+ // The original rib must still produce geometry.
249
+ expect(r.getShapes().length).toBeGreaterThan(0);
250
+ const origBBox = ShapeOps.getBoundingBox(r.getShapes()[0]);
251
+ const origDx = origBBox.maxX - origBBox.minX;
252
+ const origDy = origBBox.maxY - origBBox.minY;
253
+ const origDz = origBBox.maxZ - origBBox.minZ;
254
+ const origVol = origDx * origDy * origDz;
255
+ // Find all rib clones in the scene.
256
+ const sceneMod = await import("../../scene-manager.js");
257
+ const scene = sceneMod.getCurrentScene();
258
+ const allObjs = scene.getSceneObjects();
259
+ const ribClones = allObjs.filter(o => o instanceof Rib && o !== r && o.getCloneSource() === r);
260
+ // 3 clones expected (count=4 minus the original).
261
+ expect(ribClones.length).toBe(3);
262
+ // Each clone must produce a solid of similar bbox volume to the
263
+ // original — rotation around Z preserves the rib's geometry, so any
264
+ // significant size mismatch means a build flag (e.g. parallel/extend)
265
+ // wasn't propagated through createCopy.
266
+ for (const clone of ribClones) {
267
+ const cloneShapes = clone.getShapes();
268
+ expect(cloneShapes.length).toBeGreaterThan(0);
269
+ const cBBox = ShapeOps.getBoundingBox(cloneShapes[0]);
270
+ const cVol = (cBBox.maxX - cBBox.minX) * (cBBox.maxY - cBBox.minY) * (cBBox.maxZ - cBBox.minZ);
271
+ expect(cVol / origVol).toBeGreaterThan(0.7);
272
+ expect(cVol / origVol).toBeLessThan(1.3);
273
+ }
274
+ });
275
+ it("extended rib in .new() mode unifies coplanar artifact faces", () => {
276
+ // The slab clips and scope cut leave coplanar sub-faces and seam edges
277
+ // on flat walls of the rib. UnifySameDomain post-pass should merge
278
+ // them. For this geometry (parallel rib in a shelled box), the rib
279
+ // should have well under 30 faces — the unmerged version had 40+.
280
+ const s = makeBox();
281
+ sketch("front", () => {
282
+ move([-40, 20]);
283
+ aLine(45, 20);
284
+ });
285
+ const r = rib(5).parallel().new().scope(s).extend();
286
+ render();
287
+ const shapes = r.getShapes();
288
+ expect(shapes.length).toBeGreaterThan(0);
289
+ // Count distinct faces on the rib shape; should be well below the
290
+ // pre-cleanup count.
291
+ const faceCount = Explorer.findShapes(shapes[0].getShape(), Explorer.getOcShapeType('face')).length;
292
+ expect(faceCount).toBeLessThan(30);
293
+ // Side and start/end face buckets must still be populated after the
294
+ // cleanup remapping (i.e. lineage was applied correctly).
295
+ const startFaces = r.getState('start-faces');
296
+ const sideFaces = r.getState('side-faces');
297
+ expect(startFaces?.length ?? 0).toBeGreaterThan(0);
298
+ expect(sideFaces?.length ?? 0).toBeGreaterThan(0);
299
+ });
300
+ it("parallel + extend + draft should not throw an OCC error", () => {
301
+ // Reported case: parallel rib with extend and a -5° draft on a shelled
302
+ // box with filleted internals throws an OCC exception during build.
303
+ sketch("top", () => {
304
+ rect(100, 50).centered();
305
+ });
306
+ const box = extrude(30);
307
+ const shelled = shell(-4, box.endFaces());
308
+ const s = fillet(2, shelled.internalEdges());
309
+ sketch("front", () => {
310
+ move([-40, 20]);
311
+ aLine(-45, 20);
312
+ });
313
+ const r = rib(5).parallel().new().scope(s).extend().draft(2);
314
+ render();
315
+ const shapes = r.getShapes();
316
+ expect(shapes.length).toBeGreaterThan(0);
317
+ // Cleanup must apply to drafted ribs too — slab-cut artifact faces
318
+ // remain coplanar after the draft (just tilted as a group), so
319
+ // UnifySameDomain should still merge them.
320
+ const faceCount = Explorer.findShapes(shapes[0].getShape(), Explorer.getOcShapeType('face')).length;
321
+ expect(faceCount).toBeLessThan(30);
322
+ });
323
+ it("rib with .add() and draft fuses cleanly into the target solid", async () => {
324
+ // Reported case: with .add() (default) and .draft(), the rib fuses
325
+ // into the box but coplanar wall pieces split by the boolean fuse
326
+ // appear as visible "artifact seams" on the box's outer side and
327
+ // bottom faces. UnifySameDomain on the fuse output unifies them.
328
+ sketch("top", () => {
329
+ rect(100, 50).centered();
330
+ });
331
+ const box = extrude(30);
332
+ const shelled = shell(-4, box.endFaces());
333
+ const s = fillet(2, shelled.internalEdges());
334
+ sketch("front", () => {
335
+ move([-40, 20]);
336
+ aLine(-45, 20);
337
+ });
338
+ rib(5).parallel().scope(s).extend().draft(-5);
339
+ render();
340
+ // After fuse, the result lives on the rib's caller side. Pull all
341
+ // solid shapes from the scene and confirm none of them carry a wild
342
+ // face count — a typical pre-cleanup shape would have 60+ faces from
343
+ // slab + fuse splits; cleaned should be well under that.
344
+ const sceneMod = await import("../../scene-manager.js");
345
+ const scene = sceneMod.getCurrentScene();
346
+ let totalFaces = 0;
347
+ for (const obj of scene.getSceneObjects()) {
348
+ for (const shape of obj.getShapes({}, 'solid')) {
349
+ totalFaces += Explorer.findShapes(shape.getShape(), Explorer.getOcShapeType('face')).length;
350
+ }
351
+ }
352
+ expect(totalFaces).toBeGreaterThan(0);
353
+ expect(totalFaces).toBeLessThan(60);
354
+ });
355
+ it("parallel rib draft preserves the spine-plane thickness exactly (5mm @ 5°)", () => {
356
+ sketch("top", () => {
357
+ rect(100, 50).centered();
358
+ });
359
+ const box = extrude(30);
360
+ const shelled = shell(-4, box.endFaces());
361
+ const filleted = fillet(2, shelled.internalEdges());
362
+ sketch("front", () => {
363
+ move([-40, 20]);
364
+ aLine(0, 30);
365
+ });
366
+ const r = rib(5).parallel().draft(5).new().scope(filleted);
367
+ render();
368
+ const shapes = r.getShapes();
369
+ expect(shapes.length).toBe(1);
370
+ // The start face (base wire face on the spine plane) lies on the
371
+ // spine plane by construction with the exact nominal thickness.
372
+ const ySpan = startFaceYSpan(r);
373
+ expect(Math.abs(ySpan - 5)).toBeLessThanOrEqual(1e-4);
374
+ });
375
+ it("parallel + extend + draft on cylindrical scope produces a single rib (no phantom shell)", () => {
376
+ // Reported case: cylinder with shell+fillet, parallel+extend rib
377
+ // with draft(3°). Conformance was emitting an L-shaped second
378
+ // solid that traced part of the cavity outer + bottom alongside
379
+ // the actual rib.
380
+ sketch("top", () => {
381
+ circle(80);
382
+ });
383
+ const box = extrude(30);
384
+ const shelled = shell(-4, box.endFaces());
385
+ const filleted = fillet(2, shelled.internalEdges());
386
+ sketch("front", () => {
387
+ move([-40, 20]);
388
+ aLine(-45, 20);
389
+ });
390
+ const r = rib(5).parallel().extend().draft(3).new().scope(filleted);
391
+ render();
392
+ const shapes = r.getShapes();
393
+ expect(shapes.length).toBe(1);
394
+ });
395
+ it("repeat circular preserves draft on every rotated clone", async () => {
396
+ const repeatModule = await import("../../core/repeat.js");
397
+ const repeat = repeatModule.default;
398
+ sketch("top", () => {
399
+ rect(100).centered();
400
+ });
401
+ const box = extrude(30);
402
+ const shelled = shell(-4, box.endFaces());
403
+ let s = fillet(2, shelled.internalEdges());
404
+ sketch("top", () => {
405
+ circle(30);
406
+ });
407
+ s = extrude(50)
408
+ .draft(-5);
409
+ sketch("front", () => {
410
+ move([-40, 20]);
411
+ aLine(45, 20);
412
+ });
413
+ const r = rib(5).parallel().extend().new().scope(s).draft(-4);
414
+ repeat("circular", "z", { count: 4, angle: 360 }, r);
415
+ render();
416
+ const sceneMod = await import("../../scene-manager.js");
417
+ const scene = sceneMod.getCurrentScene();
418
+ const ribClones = scene.getSceneObjects().filter(o => o instanceof Rib && o !== r && o.getCloneSource() === r);
419
+ expect(ribClones.length).toBe(3);
420
+ // Original has draft → its bbox spans more in plane.normal than
421
+ // the slab thickness 5mm at one end. Each clone must show the same
422
+ // draft signature: the post-conform ribs all came from the same
423
+ // build, so their bbox volumes must agree within ~5%.
424
+ const origVol = ShapeOps.getBoundingBox(r.getShapes()[0]);
425
+ const origBboxVol = (origVol.maxX - origVol.minX) * (origVol.maxY - origVol.minY) * (origVol.maxZ - origVol.minZ);
426
+ for (const clone of ribClones) {
427
+ const cBBox = ShapeOps.getBoundingBox(clone.getShapes()[0]);
428
+ const cBboxVol = (cBBox.maxX - cBBox.minX) * (cBBox.maxY - cBBox.minY) * (cBBox.maxZ - cBBox.minZ);
429
+ const ratio = cBboxVol / origBboxVol;
430
+ expect(ratio).toBeGreaterThan(0.95);
431
+ expect(ratio).toBeLessThan(1.05);
432
+ }
433
+ });
434
+ it("normal-mode draft does not tilt the rib's end cap (perpendicular to spine)", () => {
435
+ // Reported case: rib ends inside the cavity (spine endpoint at
436
+ // X=-16, not at any wall). The cap face at X=-16 (perpendicular
437
+ // to the spine direction) should stay flat — only the rib's
438
+ // long side walls should taper. Currently OCC tilts every
439
+ // non-first/last face that isn't excluded, so the cap drifts.
440
+ sketch("top", () => {
441
+ rect(100, 50).centered();
442
+ });
443
+ const box = extrude(30);
444
+ const shelled = shell(-4, box.endFaces());
445
+ const filleted = fillet(2, shelled.internalEdges());
446
+ sketch(box.endFaces(), () => {
447
+ hLine([-50 + 4, 0], 30);
448
+ });
449
+ const r = rib(5).draft(2).new().scope(filleted);
450
+ render();
451
+ const shapes = r.getShapes();
452
+ expect(shapes.length).toBe(1);
453
+ // The rib's spine ends at X = -50 + 4 + 30 = -16. The end cap
454
+ // (perpendicular to the X spine direction) should stay at X=-16
455
+ // throughout the prism's depth — its bbox maxX should match -16
456
+ // within tolerance regardless of draft.
457
+ const bbox = ShapeOps.getBoundingBox(shapes[0]);
458
+ expect(bbox.maxX).toBeLessThanOrEqual(-16 + 0.05);
459
+ expect(bbox.maxX).toBeGreaterThanOrEqual(-16 - 0.05);
460
+ });
461
+ it("parallel rib draft preserves the spine-plane thickness exactly (0.25mm @ 8°)", () => {
462
+ // Reported case: a 0.25mm-thick rib drafted at 8°. With the prior
463
+ // shifted-neutral-plane workaround the start-face thickness
464
+ // drifted from 0.250 to 0.278 (11% off) on a fixed shift, and
465
+ // even the scaled shift left ~0.3% drift. The loft-based prism
466
+ // sets the start face on the spine plane by construction — drift
467
+ // is zero.
468
+ sketch("top", () => {
469
+ rect(7, 5).centered();
470
+ });
471
+ const box = extrude(-1.5);
472
+ const shelled = shell(-0.25, box.endFaces());
473
+ const filleted = fillet(0.5, shelled.internalEdges());
474
+ // Spine sits inside the shelled cavity (z∈[-1.5, -0.25]) so the
475
+ // base profile face survives conformance and the bbox can be
476
+ // measured against nominal thickness.
477
+ sketch("front", () => {
478
+ move([-2, -1.0]);
479
+ hLine(0.5);
480
+ });
481
+ const r = rib(0.25).parallel().extend().draft(8).new().scope(filleted);
482
+ render();
483
+ const shapes = r.getShapes();
484
+ expect(shapes.length).toBeGreaterThan(0);
485
+ // The start face (base wire face on the spine plane) is the
486
+ // target of "exact preservation". Solid bbox can drift by ~1e-3
487
+ // from boolean-cut face tolerances, but the start-face geometry
488
+ // itself is unaffected.
489
+ const ySpan = startFaceYSpan(r);
490
+ expect(Math.abs(ySpan - 0.25)).toBeLessThanOrEqual(1e-4);
491
+ });
492
+ it("normal-mode rib spine starting at cavity wall: positive draft must not throw", () => {
493
+ // Reported case: rib spine starts AT the inner cavity wall (X=-46
494
+ // after shell -4 from a box centred at X=0, half-width 50). With
495
+ // positive draft, OCC's BRepOffsetAPI_DraftAngle fails because it
496
+ // tries to tilt the rib's cap face that sits flush with the wall.
497
+ sketch("top", () => {
498
+ rect(100, 50).centered();
499
+ });
500
+ const box = extrude(30);
501
+ const shelled = shell(-4, box.endFaces());
502
+ const filleted = fillet(2, shelled.internalEdges());
503
+ sketch(box.endFaces(), () => {
504
+ hLine([-50 + 4, 0], 30);
505
+ });
506
+ const r = rib(5).draft(1).new().scope(filleted);
507
+ render();
508
+ const shapes = r.getShapes();
509
+ expect(shapes.length).toBeGreaterThan(0);
510
+ });
511
+ it("normal-mode rib spine at cavity wall: negative draft does not tilt the wall-touching face", () => {
512
+ // The cap face flush with the cavity wall should keep its X
513
+ // position (= -46) before AND after draft. Drafting it would push
514
+ // it inward, leaving a gap between the rib and the wall.
515
+ sketch("top", () => {
516
+ rect(100, 50).centered();
517
+ });
518
+ const box = extrude(30);
519
+ const shelled = shell(-4, box.endFaces());
520
+ const filleted = fillet(2, shelled.internalEdges());
521
+ sketch(box.endFaces(), () => {
522
+ hLine([-50 + 4, 0], 30);
523
+ });
524
+ const r = rib(5).draft(-1).new().scope(filleted);
525
+ render();
526
+ const shapes = r.getShapes();
527
+ expect(shapes.length).toBeGreaterThan(0);
528
+ // The wall-touching cap is at X = -46 (= -50 + 4 shell thickness).
529
+ // After draft it must STILL touch the wall — bbox minX should sit
530
+ // at -46 within tolerance, not be pushed inward by the draft.
531
+ const bbox = ShapeOps.getBoundingBox(shapes[0]);
532
+ // -46.012 in practice (numerical precision around the wall plane).
533
+ expect(bbox.minX).toBeLessThanOrEqual(-46 + 0.05);
534
+ expect(bbox.minX).toBeGreaterThanOrEqual(-46 - 0.05);
535
+ });
536
+ it("normal-mode rib with .draft() and .new() should not produce a degenerate sliver solid", () => {
537
+ // Reported case: normal-mode rib drafted at 4° with .new() and a
538
+ // spine starting at the box wall (x=-50) produces a main rib plus a
539
+ // thin L-shaped sliver next to it. The sliver is a separate solid
540
+ // that survives the spine-proximity filter.
541
+ sketch("top", () => {
542
+ rect(100, 50).centered();
543
+ });
544
+ const box = extrude(30);
545
+ const shelled = shell(-4, box.endFaces());
546
+ const filleted = fillet(2, shelled.internalEdges());
547
+ sketch(box.endFaces(), () => {
548
+ hLine([-50, 0], 30);
549
+ });
550
+ const r = rib(5).draft(4).new().scope(filleted);
551
+ render();
552
+ const shapes = r.getShapes();
553
+ // Should be exactly one solid — no sliver fragments.
554
+ expect(shapes.length).toBe(1);
555
+ });
556
+ it("rib without .extend() does not over-extend the spine", () => {
557
+ // Same scope as the basic parallel rib, but no .extend(). The rib must
558
+ // stay within the original spine extents (the +bbox-diagonal extension
559
+ // is gated on .extend()).
560
+ const s = makeBox();
561
+ sketch("front", () => {
562
+ move([-10, 15]);
563
+ hLine(20);
564
+ });
565
+ const r = rib(5).parallel().new().scope(s);
566
+ render();
567
+ const shapes = r.getShapes();
568
+ expect(shapes.length).toBeGreaterThan(0);
569
+ // The original spine spans X in [-10, 10]; without extend, the rib
570
+ // shouldn't reach the box walls (X ≈ ±50). Use a generous bound: the
571
+ // rib bbox should be tighter than the scope bbox in X.
572
+ const scopeBBox = ShapeOps.getBoundingBox(s.getShapes()[0]);
573
+ const scopeWidth = scopeBBox.maxX - scopeBBox.minX;
574
+ for (const shape of r.getShapes()) {
575
+ const ribBBox = ShapeOps.getBoundingBox(shape);
576
+ const ribWidth = ribBBox.maxX - ribBBox.minX;
577
+ expect(ribWidth).toBeLessThan(scopeWidth);
578
+ }
579
+ });
580
+ });
581
+ describe("scope", () => {
582
+ it("should only interact with scoped objects", () => {
583
+ const s = makeBox();
584
+ sketch("top", () => {
585
+ circle(10);
586
+ });
587
+ const cyl = extrude(50).new();
588
+ sketch("front", () => {
589
+ move([-20, 15]);
590
+ hLine(40);
591
+ });
592
+ const r = rib(5).new().scope(s);
593
+ render();
594
+ const shapes = r.getShapes();
595
+ expect(shapes.length).toBeGreaterThan(0);
596
+ });
597
+ });
598
+ });
@@ -6,6 +6,7 @@ import shell from "../../core/shell.js";
6
6
  import select from "../../core/select.js";
7
7
  import cylinder from "../../core/cylinder.js";
8
8
  import { rect } from "../../core/2d/index.js";
9
+ import { Shell } from "../../features/shell.js";
9
10
  import { countShapes } from "../utils.js";
10
11
  import { ShapeProps } from "../../oc/props.js";
11
12
  import { face } from "../../filters/index.js";
@@ -198,6 +199,41 @@ describe("shell", () => {
198
199
  expect(first.getShapes()[0].isSame(allShapes[0])).toBe(true);
199
200
  });
200
201
  });
202
+ describe("join type", () => {
203
+ const buildShelledBox = (apply) => {
204
+ sketch("xy", () => {
205
+ rect(100, 100);
206
+ });
207
+ extrude(50);
208
+ select(face().onPlane("xy", 50));
209
+ const s = shell(5);
210
+ apply(s);
211
+ const scene = render();
212
+ return scene.getAllSceneObjects()
213
+ .flatMap(o => o.getShapes())
214
+ .find(sh => sh.getType() === "solid");
215
+ };
216
+ it("should produce a valid solid with default join type (arc)", () => {
217
+ const solid = buildShelledBox(() => { });
218
+ expect(solid).toBeDefined();
219
+ expect(ShapeProps.getProperties(solid.getShape()).volumeMm3).toBeGreaterThan(0);
220
+ });
221
+ it("should produce a valid solid with join('intersection')", () => {
222
+ const solid = buildShelledBox(s => s.join('intersection'));
223
+ expect(solid).toBeDefined();
224
+ expect(ShapeProps.getProperties(solid.getShape()).volumeMm3).toBeGreaterThan(0);
225
+ });
226
+ it("should produce a valid solid with join('tangent')", () => {
227
+ const solid = buildShelledBox(s => s.join('tangent'));
228
+ expect(solid).toBeDefined();
229
+ expect(ShapeProps.getProperties(solid.getShape()).volumeMm3).toBeGreaterThan(0);
230
+ });
231
+ it("should treat shells with different join types as not equal", () => {
232
+ const a = new Shell(5);
233
+ const b = new Shell(5).join('intersection');
234
+ expect(a.compareTo(b)).toBe(false);
235
+ });
236
+ });
201
237
  describe("shell with multiple selections", () => {
202
238
  it("should shell a box by removing two faces", () => {
203
239
  sketch("xy", () => {
@@ -1,5 +1,6 @@
1
1
  import { beforeAll } from "vitest";
2
2
  import { init } from "../index.js";
3
3
  beforeAll(async () => {
4
- await init("/tmp/fluidcad-test");
4
+ process.env.FLUIDCAD_WORKSPACE_PATH = "/tmp/fluidcad-test";
5
+ await init();
5
6
  });
@@ -0,0 +1,9 @@
1
+ export type FluidBlock = {
2
+ /** Path relative to the docs root, forward-slash. */
3
+ file: string;
4
+ /** 1-based line number where the block body starts (the line after the opening fence). */
5
+ line: number;
6
+ /** Block body — no fences. */
7
+ block: string;
8
+ };
9
+ export declare function extractFluidJsBlocks(docsRootRel: string): FluidBlock[];