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,347 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import * as fs from "fs";
3
+ import { join } from "path";
4
+ import getSystemFonts from "get-system-fonts";
5
+ import { setupOC, render } from "../setup.js";
6
+ import sketch from "../../core/sketch.js";
7
+ import extrude from "../../core/extrude.js";
8
+ import select from "../../core/select.js";
9
+ import cylinder from "../../core/cylinder.js";
10
+ import helix from "../../core/helix.js";
11
+ import { arc, circle, hLine, text } from "../../core/2d/index.js";
12
+ import { edge } from "../../filters/index.js";
13
+ import { getBoundingBoxOfShapes } from "../utils.js";
14
+ import { FontRegistry } from "../../io/font-registry.js";
15
+ const normFamily = (s) => s.trim().toLowerCase().replace(/\s+/g, " ");
16
+ // The extruded solids produced by an extrude scene object (the real geometry,
17
+ // excluding helper shapes like the sketch plane that countShapes would include).
18
+ function solidsOf(e) {
19
+ return e.getShapes();
20
+ }
21
+ describe("text", () => {
22
+ setupOC();
23
+ it("extrudes standalone text on a plane into a solid", () => {
24
+ text("xy", "A").size(20);
25
+ const e = extrude(5);
26
+ render();
27
+ const solids = solidsOf(e);
28
+ expect(solids.length).toBeGreaterThanOrEqual(1);
29
+ const bbox = getBoundingBoxOfShapes(solids);
30
+ expect(bbox.maxX - bbox.minX).toBeGreaterThan(1); // has width
31
+ expect(bbox.maxY - bbox.minY).toBeGreaterThan(1); // has height
32
+ // ~5 (mesh bounding box overshoots exact geometry slightly).
33
+ expect(bbox.maxZ - bbox.minZ).toBeCloseTo(5, 0); // extruded depth
34
+ // Orientation: baseline sits at the plane origin (y=0); an "A" (no
35
+ // descender) must rise into +y and run in +x — i.e. upright, not flipped
36
+ // or mirrored.
37
+ expect(bbox.minY).toBeGreaterThan(-1);
38
+ expect(bbox.maxY).toBeGreaterThan(5);
39
+ expect(bbox.minX).toBeGreaterThan(-1);
40
+ expect(bbox.maxX).toBeGreaterThan(5);
41
+ });
42
+ it("renders text inside a sketch", () => {
43
+ sketch("xy", () => {
44
+ text("Hi").size(12);
45
+ });
46
+ const e = extrude(3);
47
+ render();
48
+ const solids = solidsOf(e);
49
+ expect(solids.length).toBeGreaterThanOrEqual(1);
50
+ const bbox = getBoundingBoxOfShapes(solids);
51
+ expect(bbox.maxZ - bbox.minZ).toBeCloseTo(3, 0); // ~3 (mesh bbox overshoot)
52
+ });
53
+ it("builds a letter with a counter (hole) such as 'o'", () => {
54
+ // Outer + inner contours must both build; FaceMaker2 drills the counter.
55
+ // We don't assert exact topology (font-dependent) — only a valid solid.
56
+ text("xy", "o").size(20);
57
+ const e = extrude(4);
58
+ render();
59
+ expect(solidsOf(e).length).toBeGreaterThanOrEqual(1);
60
+ });
61
+ it("falls back to a default font when the named font is missing", () => {
62
+ text("xy", "Z").size(20).font("NoSuchFont__XYZ");
63
+ const e = extrude(4);
64
+ render();
65
+ expect(solidsOf(e).length).toBeGreaterThanOrEqual(1);
66
+ });
67
+ it("honours font weight without throwing", () => {
68
+ text("xy", "B").size(20).weight("bold");
69
+ const e = extrude(4);
70
+ render();
71
+ expect(solidsOf(e).length).toBeGreaterThanOrEqual(1);
72
+ });
73
+ it("resolves named system fonts (does not collapse every font to one fallback)", () => {
74
+ const families = FontRegistry.availableFamilies();
75
+ expect(families.length).toBeGreaterThan(0);
76
+ // At least one installed family must resolve to itself. If openSync is
77
+ // mis-called (passing a postscriptName as a variation arg), every lookup
78
+ // throws and collapses to a single fallback font — this guards that.
79
+ const matched = families.some(f => normFamily(FontRegistry.resolve({ font: f }).familyName) === normFamily(f));
80
+ expect(matched).toBe(true);
81
+ });
82
+ it("loads a workspace-relative .ttf font file", async () => {
83
+ const files = await getSystemFonts();
84
+ const src = files.find(f => f.toLowerCase().endsWith(".ttf"));
85
+ if (!src) {
86
+ return; // no .ttf available to copy on this machine; skip
87
+ }
88
+ const root = process.env.FLUIDCAD_WORKSPACE_PATH;
89
+ fs.mkdirSync(join(root, "fonts"), { recursive: true });
90
+ fs.copyFileSync(src, join(root, "fonts", "test.ttf"));
91
+ text("xy", "A").size(20).font("fonts/test.ttf");
92
+ const e = extrude(5);
93
+ render();
94
+ expect(solidsOf(e).length).toBeGreaterThanOrEqual(1);
95
+ });
96
+ });
97
+ describe("text along a path", () => {
98
+ setupOC();
99
+ it("lays text upright along a straight sketch line", () => {
100
+ const path = sketch("xy", () => {
101
+ hLine(100);
102
+ });
103
+ const t = text("Hi", path).size(10);
104
+ render();
105
+ expect(t.getError()).toBeFalsy();
106
+ const bbox = getBoundingBoxOfShapes(t.getShapes());
107
+ // Upright on the line: baseline at y=0 rising into +y, running in +x.
108
+ expect(bbox.minY).toBeGreaterThan(-1);
109
+ expect(bbox.maxY).toBeGreaterThan(4);
110
+ expect(bbox.minX).toBeGreaterThan(-1);
111
+ expect(bbox.maxX).toBeLessThan(50);
112
+ // Planar output in the sketch plane (z = 0); the bbox helper pads by 0.1.
113
+ expect(Math.abs(bbox.minZ)).toBeLessThan(0.2);
114
+ expect(Math.abs(bbox.maxZ)).toBeLessThan(0.2);
115
+ });
116
+ it("extrudes path text into a solid", () => {
117
+ const path = sketch("xy", () => {
118
+ hLine(100);
119
+ });
120
+ const t = text("Hi", path).size(10);
121
+ const e = extrude(5, t);
122
+ render();
123
+ const solids = solidsOf(e);
124
+ expect(solids.length).toBeGreaterThanOrEqual(1);
125
+ const bbox = getBoundingBoxOfShapes(solids);
126
+ expect(bbox.maxZ - bbox.minZ).toBeCloseTo(5, 0);
127
+ });
128
+ it("keeps every glyph on the ring when following a circle", () => {
129
+ const path = sketch("xy", () => {
130
+ circle(100); // diameter 100 -> radius 50
131
+ });
132
+ const t = text("FLUIDCAD", path).size(8).align("center");
133
+ render();
134
+ expect(t.getError()).toBeFalsy();
135
+ const shapes = t.getShapes();
136
+ expect(shapes.length).toBeGreaterThan(0);
137
+ // Each outline edge must hug the ring: its center stays within an
138
+ // annulus around radius 50 (giving the glyph height some slack).
139
+ for (const s of shapes) {
140
+ const b = getBoundingBoxOfShapes([s]);
141
+ const r = Math.hypot((b.minX + b.maxX) / 2, (b.minY + b.maxY) / 2);
142
+ expect(r).toBeGreaterThan(38);
143
+ expect(r).toBeLessThan(62);
144
+ }
145
+ });
146
+ it("offsets the baseline away from the path", () => {
147
+ const path = sketch("xy", () => {
148
+ hLine(100);
149
+ });
150
+ const t = text("Hi", path).size(10).offset(5);
151
+ render();
152
+ expect(t.getError()).toBeFalsy();
153
+ const bbox = getBoundingBoxOfShapes(t.getShapes());
154
+ expect(bbox.minY).toBeGreaterThan(4);
155
+ });
156
+ it("flips text to the other side of the path", () => {
157
+ const path = sketch("xy", () => {
158
+ hLine(100);
159
+ });
160
+ const t = text("Hi", path).size(10).flip();
161
+ render();
162
+ expect(t.getError()).toBeFalsy();
163
+ const bbox = getBoundingBoxOfShapes(t.getShapes());
164
+ // Mirrored below the line.
165
+ expect(bbox.maxY).toBeLessThan(1);
166
+ expect(bbox.minY).toBeLessThan(-4);
167
+ });
168
+ it("starts at an arc-length distance along the path", () => {
169
+ const path = sketch("xy", () => {
170
+ hLine(100);
171
+ });
172
+ const t = text("I", path).size(10).startAt(30);
173
+ render();
174
+ expect(t.getError()).toBeFalsy();
175
+ const bbox = getBoundingBoxOfShapes(t.getShapes());
176
+ expect(bbox.minX).toBeGreaterThan(25);
177
+ expect(bbox.maxX).toBeLessThan(45);
178
+ });
179
+ it("aligns right against the path end", () => {
180
+ const path = sketch("xy", () => {
181
+ hLine(100);
182
+ });
183
+ const t = text("Hi", path).size(10).align("right");
184
+ render();
185
+ expect(t.getError()).toBeFalsy();
186
+ const bbox = getBoundingBoxOfShapes(t.getShapes());
187
+ expect(bbox.maxX).toBeLessThan(101);
188
+ expect(bbox.maxX).toBeGreaterThan(85);
189
+ });
190
+ it("follows a standalone planar primitive and wraps on a closed path", () => {
191
+ // Text much longer than the circumference must wrap, not error.
192
+ const ring = circle("xy", 30); // radius 15, circumference ~94
193
+ const t = text("WRAPPING ALL THE WAY AROUND", ring).size(8);
194
+ render();
195
+ expect(t.getError()).toBeFalsy();
196
+ const bbox = getBoundingBoxOfShapes(t.getShapes());
197
+ expect(bbox.minX).toBeGreaterThan(-35);
198
+ expect(bbox.maxX).toBeLessThan(35);
199
+ expect(bbox.minY).toBeGreaterThan(-35);
200
+ expect(bbox.maxY).toBeLessThan(35);
201
+ });
202
+ it("follows a circular edge selected from a solid", () => {
203
+ cylinder(30, 50);
204
+ const rim = select(edge().circle().onPlane("xy", { offset: 50 }));
205
+ const t = text("RIM", rim).size(6);
206
+ render();
207
+ expect(t.getError()).toBeFalsy();
208
+ const shapes = t.getShapes();
209
+ expect(shapes.length).toBeGreaterThan(0);
210
+ // The fitted path plane is the rim's plane: all glyphs at z = 50
211
+ // (the bbox helper pads by 0.1).
212
+ const bbox = getBoundingBoxOfShapes(shapes);
213
+ expect(bbox.minZ).toBeGreaterThan(49.7);
214
+ expect(bbox.maxZ).toBeLessThan(50.3);
215
+ });
216
+ it("stacks multi-line text perpendicular to the path", () => {
217
+ const path = sketch("xy", () => {
218
+ hLine(100);
219
+ });
220
+ const t = text("AB\nCD", path).size(10);
221
+ render();
222
+ expect(t.getError()).toBeFalsy();
223
+ const bbox = getBoundingBoxOfShapes(t.getShapes());
224
+ // Two lines: total height exceeds a single line's cap height.
225
+ expect(bbox.maxY - bbox.minY).toBeGreaterThan(10);
226
+ expect(bbox.minY).toBeLessThan(-4); // second line below the baseline
227
+ });
228
+ it("rejects a non-planar path", () => {
229
+ const h = helix("z").radius(20).pitch(10).turns(2);
230
+ const t = text("Hi", h);
231
+ render();
232
+ expect(t.getError()).toMatch(/planar/i);
233
+ });
234
+ it("rejects path-only modifiers without a path", () => {
235
+ const t = text("xy", "Hi").offset(3);
236
+ render();
237
+ expect(t.getError()).toMatch(/offset/i);
238
+ });
239
+ it("sits on the outside of a closed circle by default", () => {
240
+ const ring = circle("xy", 100); // radius 50
241
+ const t = text("OUTSIDE", ring).size(8);
242
+ render();
243
+ expect(t.getError()).toBeFalsy();
244
+ const radii = t.getShapes().map((s) => {
245
+ const b = getBoundingBoxOfShapes([s]);
246
+ return Math.hypot((b.minX + b.maxX) / 2, (b.minY + b.maxY) / 2);
247
+ });
248
+ const mean = radii.reduce((a, b) => a + b, 0) / radii.length;
249
+ expect(mean).toBeGreaterThan(50);
250
+ });
251
+ it("moves closed-path text inside with flip", () => {
252
+ const ring = circle("xy", 100); // radius 50
253
+ const t = text("INSIDE", ring).size(8).flip();
254
+ render();
255
+ expect(t.getError()).toBeFalsy();
256
+ const radii = t.getShapes().map((s) => {
257
+ const b = getBoundingBoxOfShapes([s]);
258
+ return Math.hypot((b.minX + b.maxX) / 2, (b.minY + b.maxY) / 2);
259
+ });
260
+ const mean = radii.reduce((a, b) => a + b, 0) / radii.length;
261
+ expect(mean).toBeLessThan(50);
262
+ });
263
+ it("justifies text across the full path with space-between", () => {
264
+ const path = sketch("xy", () => {
265
+ hLine(100);
266
+ });
267
+ const t = text("AB", path).size(10).align("space-between");
268
+ render();
269
+ expect(t.getError()).toBeFalsy();
270
+ const bbox = getBoundingBoxOfShapes(t.getShapes());
271
+ expect(bbox.minX).toBeLessThan(12);
272
+ expect(bbox.maxX).toBeGreaterThan(88);
273
+ });
274
+ it("leaves half a gap at each end with space-around", () => {
275
+ const path = sketch("xy", () => {
276
+ hLine(100);
277
+ });
278
+ const t = text("AB", path).size(10).align("space-around");
279
+ render();
280
+ expect(t.getError()).toBeFalsy();
281
+ const bbox = getBoundingBoxOfShapes(t.getShapes());
282
+ // Two glyphs each get half their leftover share at the run's ends, so
283
+ // the text stays well clear of both path ends and centers on the path.
284
+ expect(bbox.minX).toBeGreaterThan(10);
285
+ expect(bbox.maxX).toBeLessThan(90);
286
+ expect((bbox.minX + bbox.maxX) / 2).toBeCloseTo(50, 0);
287
+ });
288
+ it("accepts start/end alignment synonyms", () => {
289
+ const path = sketch("xy", () => {
290
+ hLine(100);
291
+ });
292
+ const t = text("Hi", path).size(10).align("end");
293
+ render();
294
+ expect(t.getError()).toBeFalsy();
295
+ const bbox = getBoundingBoxOfShapes(t.getShapes());
296
+ expect(bbox.maxX).toBeLessThan(101);
297
+ expect(bbox.maxX).toBeGreaterThan(85);
298
+ });
299
+ it("rejects space-between alignment without a path", () => {
300
+ const t = text("xy", "Hi").align("space-between");
301
+ render();
302
+ expect(t.getError()).toMatch(/space-between/i);
303
+ });
304
+ it("rejects space-around alignment without a path", () => {
305
+ const t = text("xy", "Hi").align("space-around");
306
+ render();
307
+ expect(t.getError()).toMatch(/space-around/i);
308
+ });
309
+ it("follows an arc drawn in the same sketch", () => {
310
+ let t;
311
+ sketch("xy", () => {
312
+ const a = arc([0, 0], [100, 0]).center([50, -200]).cw().guide();
313
+ t = text("Marwan", a).size(15);
314
+ });
315
+ render();
316
+ expect(t.getError()).toBeFalsy();
317
+ const bbox = getBoundingBoxOfShapes(t.getShapes());
318
+ // A shallow arc bulging up: text sits along it, above y = 0 won't hold
319
+ // everywhere, but it must stay near the arc band and inside x ∈ [0, 100].
320
+ expect(bbox.minX).toBeGreaterThan(-5);
321
+ expect(bbox.maxX).toBeLessThan(105);
322
+ expect(bbox.maxY).toBeGreaterThan(4);
323
+ // Planar in the sketch plane; the bbox helper pads by 0.1.
324
+ expect(Math.abs(bbox.minZ)).toBeLessThan(0.2);
325
+ expect(Math.abs(bbox.maxZ)).toBeLessThan(0.2);
326
+ });
327
+ it("extrudes in-sketch path text whose guide path stays out of the profile", () => {
328
+ sketch("xy", () => {
329
+ const a = arc([0, 0], [100, 0]).center([50, -200]).cw().guide();
330
+ text("Hi", a).size(12);
331
+ });
332
+ const e = extrude(4);
333
+ render();
334
+ const solids = solidsOf(e);
335
+ expect(solids.length).toBeGreaterThanOrEqual(1);
336
+ const bbox = getBoundingBoxOfShapes(solids);
337
+ expect(bbox.maxZ - bbox.minZ).toBeCloseTo(4, 0);
338
+ });
339
+ it("multiple texts can share one path", () => {
340
+ const ring = circle("xy", 100);
341
+ const outside = text("OUTSIDE", ring).size(8);
342
+ const inside = text("INSIDE", ring).size(8).flip();
343
+ render();
344
+ expect(outside.getError()).toBeFalsy();
345
+ expect(inside.getError()).toBeFalsy();
346
+ });
347
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,130 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { CylinderDevelopment, ConeDevelopment } from "../../oc/wrap-development.js";
3
+ import { Plane } from "../../math/plane.js";
4
+ import { Point, Point2D } from "../../math/point.js";
5
+ import { Vector3d } from "../../math/vector3d.js";
6
+ // Pure-math tests for the wrap development mapping — no OCCT required.
7
+ describe("wrap development", () => {
8
+ describe("cylinder", () => {
9
+ const spec = {
10
+ origin: new Point(0, 0, 0),
11
+ axisDir: new Vector3d(0, 0, 1),
12
+ radius: 25,
13
+ };
14
+ // Tangent plane at (25, 0, 0): local x is circumferential, local y is axial.
15
+ const tangentPlane = new Plane(new Point(25, 0, 0), new Vector3d(0, 1, 0), new Vector3d(1, 0, 0));
16
+ it("maps the sketch origin to its radial projection on the surface", () => {
17
+ const dev = new CylinderDevelopment(spec, tangentPlane);
18
+ const uv = dev.toUV(new Point2D(0, 0));
19
+ expect(uv.u).toBeCloseTo(0, 12);
20
+ expect(uv.v).toBeCloseTo(0, 12);
21
+ const anchor = dev.evalPoint(uv);
22
+ expect(anchor.x).toBeCloseTo(25, 12);
23
+ expect(anchor.y).toBeCloseTo(0, 12);
24
+ expect(anchor.z).toBeCloseTo(0, 12);
25
+ });
26
+ it("preserves arc length around the surface", () => {
27
+ const dev = new CylinderDevelopment(spec, tangentPlane);
28
+ const uv = dev.toUV(new Point2D(10, 0));
29
+ // 10 units of sketch length = 10 units of arc = 10/25 radians.
30
+ expect(Math.abs(uv.u)).toBeCloseTo(10 / 25, 12);
31
+ expect(uv.v).toBeCloseTo(0, 12);
32
+ const point = dev.evalPoint(uv);
33
+ const radial = Math.hypot(point.x, point.y);
34
+ expect(radial).toBeCloseTo(25, 12);
35
+ expect(point.z).toBeCloseTo(0, 12);
36
+ });
37
+ it("maps the axial sketch direction onto the surface axis", () => {
38
+ const dev = new CylinderDevelopment(spec, tangentPlane);
39
+ const point = dev.evalPoint(dev.toUV(new Point2D(0, 7)));
40
+ expect(point.x).toBeCloseTo(25, 12);
41
+ expect(point.y).toBeCloseTo(0, 12);
42
+ expect(point.z).toBeCloseTo(7, 12);
43
+ });
44
+ it("is locally isometric in every direction", () => {
45
+ const dev = new CylinderDevelopment(spec, tangentPlane);
46
+ const delta = 0.01;
47
+ for (const [dx, dy] of [[1, 0], [0, 1], [Math.SQRT1_2, Math.SQRT1_2]]) {
48
+ const a = dev.evalPoint(dev.toUV(new Point2D(5, 3)));
49
+ const b = dev.evalPoint(dev.toUV(new Point2D(5 + dx * delta, 3 + dy * delta)));
50
+ expect(a.distanceTo(b)).toBeCloseTo(delta, 6);
51
+ }
52
+ });
53
+ it("rejects a sketch plane perpendicular to the axis", () => {
54
+ const topPlane = new Plane(new Point(25, 0, 5), new Vector3d(1, 0, 0), new Vector3d(0, 0, 1));
55
+ expect(() => new CylinderDevelopment(spec, topPlane)).toThrow(/perpendicular to the target surface axis/);
56
+ });
57
+ it("rejects a sketch plane containing the axis", () => {
58
+ const radialPlane = new Plane(new Point(25, 0, 0), new Vector3d(1, 0, 0), new Vector3d(0, 1, 0));
59
+ expect(() => new CylinderDevelopment(spec, radialPlane)).toThrow(/must not contain the target surface axis/);
60
+ });
61
+ it("rejects a sketch plane origin on the axis", () => {
62
+ const centeredPlane = new Plane(new Point(0, 0, 0), new Vector3d(0, 1, 0), new Vector3d(1, 0, 0));
63
+ expect(() => new CylinderDevelopment(spec, centeredPlane)).toThrow(/must not lie on the target surface axis/);
64
+ });
65
+ it("measures distance to the surface", () => {
66
+ const dev = new CylinderDevelopment(spec, tangentPlane);
67
+ expect(dev.distanceTo(new Point(27, 0, 10))).toBeCloseTo(2, 12);
68
+ expect(dev.distanceTo(new Point(20, 0, -4))).toBeCloseTo(5, 12);
69
+ });
70
+ });
71
+ describe("cone", () => {
72
+ const semiAngle = Math.PI / 6;
73
+ const spec = {
74
+ origin: new Point(0, 0, 0),
75
+ axisDir: new Vector3d(0, 0, 1),
76
+ refRadius: 20,
77
+ semiAngle,
78
+ };
79
+ // Tangent plane at the anchor (20, 0, 0): local x is circumferential,
80
+ // local y follows the meridian (away from the apex).
81
+ const tangentPlane = new Plane(new Point(20, 0, 0), new Vector3d(0, 1, 0), new Vector3d(Math.cos(semiAngle), 0, -Math.sin(semiAngle)));
82
+ it("maps the sketch origin to its radial projection on the surface", () => {
83
+ const dev = new ConeDevelopment(spec, tangentPlane);
84
+ const uv = dev.toUV(new Point2D(0, 0));
85
+ expect(uv.u).toBeCloseTo(0, 12);
86
+ expect(uv.v).toBeCloseTo(0, 12);
87
+ const anchor = dev.evalPoint(uv);
88
+ expect(anchor.x).toBeCloseTo(20, 12);
89
+ expect(anchor.y).toBeCloseTo(0, 12);
90
+ expect(anchor.z).toBeCloseTo(0, 12);
91
+ });
92
+ it("preserves length along the meridian", () => {
93
+ const dev = new ConeDevelopment(spec, tangentPlane);
94
+ const point = dev.evalPoint(dev.toUV(new Point2D(0, 10)));
95
+ // 10 units along the meridian: radius grows by 10·sin(α), height by 10·cos(α).
96
+ const radial = Math.hypot(point.x, point.y);
97
+ expect(radial).toBeCloseTo(20 + 10 * Math.sin(semiAngle), 12);
98
+ expect(point.z).toBeCloseTo(10 * Math.cos(semiAngle), 12);
99
+ const anchor = dev.evalPoint(dev.toUV(new Point2D(0, 0)));
100
+ expect(anchor.distanceTo(point)).toBeCloseTo(10, 12);
101
+ });
102
+ it("is locally isometric in every direction", () => {
103
+ const dev = new ConeDevelopment(spec, tangentPlane);
104
+ const delta = 0.01;
105
+ for (const [dx, dy] of [[1, 0], [0, 1], [Math.SQRT1_2, -Math.SQRT1_2]]) {
106
+ const a = dev.evalPoint(dev.toUV(new Point2D(4, -2)));
107
+ const b = dev.evalPoint(dev.toUV(new Point2D(4 + dx * delta, -2 + dy * delta)));
108
+ expect(a.distanceTo(b)).toBeCloseTo(delta, 6);
109
+ }
110
+ });
111
+ it("normalizes a negative half-angle to the same surface", () => {
112
+ const flipped = new ConeDevelopment({ ...spec, semiAngle: -semiAngle }, tangentPlane);
113
+ const anchor = flipped.evalPoint(flipped.toUV(new Point2D(0, 0)));
114
+ expect(anchor.x).toBeCloseTo(20, 12);
115
+ expect(anchor.y).toBeCloseTo(0, 12);
116
+ expect(anchor.z).toBeCloseTo(0, 12);
117
+ });
118
+ it("rejects a sketch that reaches the apex", () => {
119
+ const dev = new ConeDevelopment(spec, tangentPlane);
120
+ // The apex develops to 40 units behind the anchor along the meridian.
121
+ expect(() => dev.toUV(new Point2D(0, -40))).toThrow(/apex/);
122
+ });
123
+ it("measures distance to the surface", () => {
124
+ const dev = new ConeDevelopment(spec, tangentPlane);
125
+ // Surface radius at z = 0 is 20; a point 2 beyond it sits at
126
+ // 2·cos(α) normal distance.
127
+ expect(dev.distanceTo(new Point(22, 0, 0))).toBeCloseTo(2 * Math.cos(semiAngle), 12);
128
+ });
129
+ });
130
+ });
@@ -0,0 +1,106 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { setupOC, render } from "../setup.js";
3
+ import sketch from "../../core/sketch.js";
4
+ import wrap from "../../core/wrap.js";
5
+ import cylinder from "../../core/cylinder.js";
6
+ import extrude from "../../core/extrude.js";
7
+ import subtract from "../../core/subtract.js";
8
+ import select from "../../core/select.js";
9
+ import plane from "../../core/plane.js";
10
+ import { circle, rect, text, vMove } from "../../core/2d/index.js";
11
+ import { face } from "../../filters/index.js";
12
+ import { ShapeProps } from "../../oc/props.js";
13
+ // Extruded circle(50) (diameter) → cylinder R=25, z ∈ [0, 80]. Its lateral
14
+ // face carries a reverse-parameterized surface (axis -z, flag REVERSED) —
15
+ // wrap must still emboss away from the material.
16
+ const BASE_VOLUME = Math.PI * 25 * 25 * 80;
17
+ // Developed rect(20, 10) pad, thickness 1 outward: (s/R)/2 · ((R+t)² - R²) · h
18
+ const PAD_OUT = ((20 / 25) / 2) * (26 * 26 - 25 * 25) * 10;
19
+ // Same pocket cut 1 deep into the surface
20
+ const PAD_IN = ((20 / 25) / 2) * (25 * 25 - 24 * 24) * 10;
21
+ function extrudedCylinder() {
22
+ sketch("top", () => {
23
+ circle(50);
24
+ });
25
+ extrude(80);
26
+ }
27
+ function rectSketch() {
28
+ return sketch(plane("front", 30), () => {
29
+ vMove(20);
30
+ rect(20, 10);
31
+ });
32
+ }
33
+ function volumeOf(obj) {
34
+ return obj.getShapes().reduce((sum, shape) => sum + ShapeProps.getProperties(shape.getShape()).volumeMm3, 0);
35
+ }
36
+ function endFaceRadialDistances(w) {
37
+ return (w.getState("end-faces") || []).map(fc => {
38
+ const c = ShapeProps.getProperties(fc.getShape()).centroid;
39
+ return Math.round(Math.hypot(c.x, c.y) * 10) / 10;
40
+ });
41
+ }
42
+ describe("wrap on an extruded (reverse-parameterized) cylinder", () => {
43
+ setupOC();
44
+ it("embosses a rect pad outward", () => {
45
+ extrudedCylinder();
46
+ const s = rectSketch();
47
+ const f = select(face().cylinder());
48
+ const w = wrap(1, s, f);
49
+ const obj = w;
50
+ render();
51
+ expect(w.getError()).toBeNull();
52
+ expect(volumeOf(obj)).toBeCloseTo(BASE_VOLUME + PAD_OUT, 0);
53
+ });
54
+ it("places a standalone pad outside the surface", () => {
55
+ extrudedCylinder();
56
+ const s = rectSketch();
57
+ const f = select(face().cylinder());
58
+ const w = wrap(1, s, f).new();
59
+ const obj = w;
60
+ render();
61
+ expect(w.getError()).toBeNull();
62
+ // 204 only fits the outward shell (25..26); the inward one would be 196
63
+ expect(volumeOf(obj)).toBeCloseTo(PAD_OUT, 0);
64
+ });
65
+ it("engraves a pocket into the surface", () => {
66
+ extrudedCylinder();
67
+ const s = rectSketch();
68
+ const f = select(face().cylinder());
69
+ const w = wrap(1, s, f).remove();
70
+ render();
71
+ expect(w.getError()).toBeNull();
72
+ const total = volumeOf(w);
73
+ expect(total).toBeCloseTo(BASE_VOLUME - PAD_IN, 0);
74
+ });
75
+ it("embosses text decals outward (user repro)", () => {
76
+ extrudedCylinder();
77
+ const s = sketch(plane("front", 30), () => {
78
+ vMove(20);
79
+ text("hello world").align("center");
80
+ });
81
+ const f = select(face().cylinder());
82
+ const w = wrap(1, s, f).new();
83
+ const obj = w;
84
+ render();
85
+ expect(w.getError()).toBeNull();
86
+ const dists = endFaceRadialDistances(obj);
87
+ expect(dists.length).toBeGreaterThan(0);
88
+ // glyph end-face centroids sit just under R+1 = 26; buried glyphs would be ~24
89
+ for (const dist of dists) {
90
+ expect(dist).toBeGreaterThan(25.2);
91
+ }
92
+ });
93
+ it("embosses into a bore toward the axis", () => {
94
+ const c1 = cylinder(50, 80);
95
+ const c2 = cylinder(25, 100).translate(0, 0, -10);
96
+ subtract(c1, c2);
97
+ const s = rectSketch();
98
+ const f = select(face().cylinder(50));
99
+ const w = wrap(1, s, f);
100
+ const obj = w;
101
+ render();
102
+ expect(w.getError()).toBeNull();
103
+ const tubeVolume = Math.PI * (50 * 50 - 25 * 25) * 80;
104
+ expect(volumeOf(obj)).toBeCloseTo(tubeVolume + PAD_IN, 0);
105
+ });
106
+ });
@@ -0,0 +1 @@
1
+ export {};