fluidcad 0.0.37 → 0.0.39
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/dist/core/2d/aline.d.ts +0 -8
- package/lib/dist/core/2d/aline.js +3 -22
- package/lib/dist/core/2d/arc.d.ts +1 -23
- package/lib/dist/core/2d/arc.js +11 -28
- package/lib/dist/core/2d/circle.d.ts +1 -8
- package/lib/dist/core/2d/circle.js +7 -24
- package/lib/dist/core/2d/ellipse.d.ts +1 -17
- package/lib/dist/core/2d/ellipse.js +7 -45
- package/lib/dist/core/2d/hline.d.ts +0 -7
- package/lib/dist/core/2d/hline.js +5 -27
- package/lib/dist/core/2d/intersect.d.ts +0 -7
- package/lib/dist/core/2d/intersect.js +0 -13
- package/lib/dist/core/2d/line.d.ts +1 -8
- package/lib/dist/core/2d/line.js +6 -24
- package/lib/dist/core/2d/offset.d.ts +1 -11
- package/lib/dist/core/2d/offset.js +0 -16
- package/lib/dist/core/2d/polygon.d.ts +1 -17
- package/lib/dist/core/2d/polygon.js +18 -37
- package/lib/dist/core/2d/project.d.ts +0 -7
- package/lib/dist/core/2d/project.js +0 -13
- package/lib/dist/core/2d/rect.d.ts +1 -9
- package/lib/dist/core/2d/rect.js +0 -19
- package/lib/dist/core/2d/slot.d.ts +0 -23
- package/lib/dist/core/2d/slot.js +18 -37
- package/lib/dist/core/2d/text.d.ts +0 -7
- package/lib/dist/core/2d/text.js +0 -14
- package/lib/dist/core/2d/vline.d.ts +0 -7
- package/lib/dist/core/2d/vline.js +5 -23
- package/lib/dist/core/fillet.js +7 -0
- package/lib/dist/core/index.d.ts +1 -1
- package/lib/dist/core/interfaces.d.ts +43 -0
- package/lib/dist/core/plane.d.ts +26 -6
- package/lib/dist/core/plane.js +21 -44
- package/lib/dist/features/2d/connect.d.ts +2 -0
- package/lib/dist/features/2d/connect.js +3 -0
- package/lib/dist/features/2d/intersect.js +4 -1
- package/lib/dist/features/2d/offset.js +2 -2
- package/lib/dist/features/frozen-geometry.d.ts +29 -0
- package/lib/dist/features/frozen-geometry.js +62 -0
- package/lib/dist/features/loft.d.ts +38 -1
- package/lib/dist/features/loft.js +133 -8
- package/lib/dist/features/plane-from-object.d.ts +16 -4
- package/lib/dist/features/plane-from-object.js +101 -8
- package/lib/dist/features/rib.js +11 -1
- package/lib/dist/features/shell.d.ts +1 -1
- package/lib/dist/features/shell.js +9 -0
- package/lib/dist/features/sweep.d.ts +11 -1
- package/lib/dist/features/sweep.js +43 -7
- package/lib/dist/helpers/clone-transform.js +62 -5
- package/lib/dist/helpers/scene-helpers.d.ts +12 -0
- package/lib/dist/helpers/scene-helpers.js +26 -0
- package/lib/dist/math/bspline-interpolation.d.ts +33 -6
- package/lib/dist/math/bspline-interpolation.js +187 -84
- package/lib/dist/oc/loft/constrained-loft.d.ts +32 -0
- package/lib/dist/oc/loft/constrained-loft.js +61 -0
- package/lib/dist/oc/loft/curve-data.d.ts +18 -0
- package/lib/dist/oc/loft/curve-data.js +54 -0
- package/lib/dist/oc/loft/curve-eval.d.ts +23 -0
- package/lib/dist/oc/loft/curve-eval.js +84 -0
- package/lib/dist/oc/loft/guided-loft.d.ts +89 -0
- package/lib/dist/oc/loft/guided-loft.js +368 -0
- package/lib/dist/oc/loft/section-compatibility.d.ts +131 -0
- package/lib/dist/oc/loft/section-compatibility.js +481 -0
- package/lib/dist/oc/loft/section-curve.d.ts +53 -0
- package/lib/dist/oc/loft/section-curve.js +264 -0
- package/lib/dist/oc/loft/skinning.d.ts +104 -0
- package/lib/dist/oc/loft/skinning.js +299 -0
- package/lib/dist/oc/loft-ops.d.ts +28 -1
- package/lib/dist/oc/loft-ops.js +31 -1
- package/lib/dist/oc/ncollection.d.ts +19 -0
- package/lib/dist/oc/ncollection.js +69 -0
- package/lib/dist/oc/rib-ops.js +5 -17
- package/lib/dist/oc/sweep-ops.d.ts +7 -16
- package/lib/dist/oc/sweep-ops.js +67 -99
- package/lib/dist/oc/thin-face-maker.d.ts +0 -19
- package/lib/dist/oc/thin-face-maker.js +3 -68
- package/lib/dist/oc/wire-extend-ops.d.ts +12 -0
- package/lib/dist/oc/wire-extend-ops.js +42 -0
- package/lib/dist/oc/wire-ops.d.ts +28 -2
- package/lib/dist/oc/wire-ops.js +88 -4
- package/lib/dist/oc/wrap-ops.js +8 -15
- package/lib/dist/tests/features/2d/circle.test.js +0 -10
- package/lib/dist/tests/features/2d/ellipse.test.js +0 -30
- package/lib/dist/tests/features/2d/line.test.js +0 -5
- package/lib/dist/tests/features/2d/offset.test.js +74 -11
- package/lib/dist/tests/features/2d/polygon.test.js +0 -16
- package/lib/dist/tests/features/2d/rect.test.js +0 -11
- package/lib/dist/tests/features/2d/slot.test.js +0 -8
- package/lib/dist/tests/features/fillet2d.test.js +37 -1
- package/lib/dist/tests/features/loft-conditions.test.d.ts +1 -0
- package/lib/dist/tests/features/loft-conditions.test.js +246 -0
- package/lib/dist/tests/features/loft-guides.test.d.ts +1 -0
- package/lib/dist/tests/features/loft-guides.test.js +276 -0
- package/lib/dist/tests/features/plane.test.js +95 -0
- package/lib/dist/tests/features/repeat-sweep-borrowed-path.test.d.ts +1 -0
- package/lib/dist/tests/features/repeat-sweep-borrowed-path.test.js +35 -0
- package/lib/dist/tests/features/rib.test.js +50 -0
- package/lib/dist/tests/features/shell.test.js +35 -1
- package/lib/dist/tests/features/sweep.test.js +127 -1
- package/lib/dist/tests/features/text.test.js +33 -14
- package/lib/dist/tests/math/bspline-interpolation.test.js +97 -1
- package/lib/dist/tsconfig.tsbuildinfo +1 -1
- package/llm-docs/api/index.json +1 -1
- package/llm-docs/api/types/loft.md +36 -1
- package/llm-docs/api/types/sweep.md +13 -1
- package/llm-docs/api/types/text.md +2 -2
- package/llm-docs/index.json +5 -5
- package/package.json +1 -1
- package/server/dist/fluidcad-server.d.ts +1 -1
- package/server/dist/fluidcad-server.js +11 -1
- package/server/dist/routes/params.js +1 -1
- package/ui/dist/assets/{index-no7mtr5s.js → index-D8zV21wB.js} +83 -83
- package/ui/dist/index.html +1 -1
|
@@ -5,6 +5,7 @@ import extrude from "../../core/extrude.js";
|
|
|
5
5
|
import shell from "../../core/shell.js";
|
|
6
6
|
import fillet from "../../core/fillet.js";
|
|
7
7
|
import rib from "../../core/rib.js";
|
|
8
|
+
import plane from "../../core/plane.js";
|
|
8
9
|
import { rect, circle, move, aLine, hLine } from "../../core/2d/index.js";
|
|
9
10
|
import { Rib } from "../../features/rib.js";
|
|
10
11
|
import { ShapeOps } from "../../oc/shape-ops.js";
|
|
@@ -277,6 +278,55 @@ describe("rib", () => {
|
|
|
277
278
|
expect(cVol / origVol).toBeLessThan(1.3);
|
|
278
279
|
}
|
|
279
280
|
});
|
|
281
|
+
it("repeat mirror keeps the parallel rib on the correct side", async () => {
|
|
282
|
+
const repeatModule = await import("../../core/repeat.js");
|
|
283
|
+
const repeat = repeatModule.default;
|
|
284
|
+
// Box centered on the origin and symmetric across the front (XZ) plane,
|
|
285
|
+
// so mirroring across "front" (Y → −Y) maps the scope onto itself. The
|
|
286
|
+
// correctly-mirrored rib is then congruent to the original reflected in
|
|
287
|
+
// Y only — its X and Z position are unchanged.
|
|
288
|
+
sketch("top", () => {
|
|
289
|
+
rect(100, 100).centered();
|
|
290
|
+
});
|
|
291
|
+
const box = extrude(60);
|
|
292
|
+
const s = shell(-4, box.endFaces());
|
|
293
|
+
// Diagonal spine on an offset front plane (Y = 20), placed off-centre in
|
|
294
|
+
// the XZ plane so that a flipped in-plane extrude direction would throw
|
|
295
|
+
// the rib to a clearly different X/Z position.
|
|
296
|
+
sketch(plane("front", 20), () => {
|
|
297
|
+
move([-30, 10]);
|
|
298
|
+
aLine(45, 25);
|
|
299
|
+
});
|
|
300
|
+
const r = rib(5).parallel().new().scope(s).extend();
|
|
301
|
+
repeat("mirror", "front", r);
|
|
302
|
+
render();
|
|
303
|
+
const bboxCenter = (shape) => {
|
|
304
|
+
const bb = ShapeOps.getBoundingBox(shape);
|
|
305
|
+
return {
|
|
306
|
+
x: (bb.minX + bb.maxX) / 2,
|
|
307
|
+
y: (bb.minY + bb.maxY) / 2,
|
|
308
|
+
z: (bb.minZ + bb.maxZ) / 2,
|
|
309
|
+
};
|
|
310
|
+
};
|
|
311
|
+
expect(r.getShapes().length).toBeGreaterThan(0);
|
|
312
|
+
const orig = bboxCenter(r.getShapes()[0]);
|
|
313
|
+
const sceneMod = await import("../../scene-manager.js");
|
|
314
|
+
const scene = sceneMod.getCurrentScene();
|
|
315
|
+
const ribClones = scene.getSceneObjects().filter(o => o instanceof Rib && o !== r &&
|
|
316
|
+
o.getCloneSource() === r);
|
|
317
|
+
expect(ribClones.length).toBe(1);
|
|
318
|
+
const clone = ribClones[0];
|
|
319
|
+
expect(clone.getShapes().length).toBeGreaterThan(0);
|
|
320
|
+
const cl = bboxCenter(clone.getShapes()[0]);
|
|
321
|
+
// Mirroring across the front (XZ) plane flips Y only: the in-plane (X, Z)
|
|
322
|
+
// position must be preserved. Before the fix the clone's perpendicular
|
|
323
|
+
// extrude direction was negated (a cross product is a pseudovector under
|
|
324
|
+
// reflection), throwing the rib to the opposite side — its X/Z centre
|
|
325
|
+
// lands far from the original.
|
|
326
|
+
expect(Math.abs(cl.x - orig.x)).toBeLessThan(1);
|
|
327
|
+
expect(Math.abs(cl.z - orig.z)).toBeLessThan(1);
|
|
328
|
+
expect(Math.abs(cl.y + orig.y)).toBeLessThan(1);
|
|
329
|
+
});
|
|
280
330
|
it("extended rib in .new() mode unifies coplanar artifact faces", () => {
|
|
281
331
|
// The slab clips and scope cut leave coplanar sub-faces and seam edges
|
|
282
332
|
// on flat walls of the rib. UnifySameDomain post-pass should merge
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
2
|
import { setupOC, render, addToScene } from "../setup.js";
|
|
3
3
|
import sketch from "../../core/sketch.js";
|
|
4
4
|
import extrude from "../../core/extrude.js";
|
|
@@ -8,6 +8,7 @@ import cylinder from "../../core/cylinder.js";
|
|
|
8
8
|
import { rect } from "../../core/2d/index.js";
|
|
9
9
|
import { Shell } from "../../features/shell.js";
|
|
10
10
|
import { countShapes } from "../utils.js";
|
|
11
|
+
import { ShellOps } from "../../oc/shell-ops.js";
|
|
11
12
|
import { ShapeProps } from "../../oc/props.js";
|
|
12
13
|
import { face } from "../../filters/index.js";
|
|
13
14
|
describe("shell", () => {
|
|
@@ -263,4 +264,37 @@ describe("shell", () => {
|
|
|
263
264
|
expect(sel2.getShapes()).toHaveLength(0);
|
|
264
265
|
});
|
|
265
266
|
});
|
|
267
|
+
describe("failure surfacing", () => {
|
|
268
|
+
it("flags an error instead of silently serving the original solid when the offset fails", () => {
|
|
269
|
+
// OCCT's MakeThickSolid throws on geometry it cannot hollow (a wall
|
|
270
|
+
// thicker than a nearby feature, a cut opening onto a removed face,
|
|
271
|
+
// etc.). Force that failure deterministically and assert the feature
|
|
272
|
+
// surfaces it rather than swallowing it into a silent no-op.
|
|
273
|
+
const spy = vi.spyOn(ShellOps, "makeThickSolid").mockImplementation(() => {
|
|
274
|
+
throw new Error("Failed to create thick solid.");
|
|
275
|
+
});
|
|
276
|
+
try {
|
|
277
|
+
sketch("xy", () => {
|
|
278
|
+
rect(100, 100);
|
|
279
|
+
});
|
|
280
|
+
extrude(50);
|
|
281
|
+
select(face().onPlane("xy", 50));
|
|
282
|
+
const s = shell(5);
|
|
283
|
+
const scene = render();
|
|
284
|
+
// The failure is now visible to the user instead of being swallowed.
|
|
285
|
+
expect(s.getError()).toBeTruthy();
|
|
286
|
+
expect(s.getError()).toContain("shell");
|
|
287
|
+
// The original solid is preserved (downstream features still have
|
|
288
|
+
// geometry) — the no-op fallback is intact, just no longer silent.
|
|
289
|
+
const solid = scene.getAllSceneObjects()
|
|
290
|
+
.flatMap(o => o.getShapes())
|
|
291
|
+
.find(sh => sh.getType() === "solid");
|
|
292
|
+
expect(solid).toBeDefined();
|
|
293
|
+
expect(ShapeProps.getProperties(solid.getShape()).volumeMm3).toBeCloseTo(100 * 100 * 50, 1);
|
|
294
|
+
}
|
|
295
|
+
finally {
|
|
296
|
+
spy.mockRestore();
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
});
|
|
266
300
|
});
|
|
@@ -5,7 +5,8 @@ import sweep from "../../core/sweep.js";
|
|
|
5
5
|
import extrude from "../../core/extrude.js";
|
|
6
6
|
import helix from "../../core/helix.js";
|
|
7
7
|
import cylinder from "../../core/cylinder.js";
|
|
8
|
-
import
|
|
8
|
+
import plane from "../../core/plane.js";
|
|
9
|
+
import { circle, rect, vLine, hLine, arc, line, move, hMove } from "../../core/2d/index.js";
|
|
9
10
|
import { countShapes } from "../utils.js";
|
|
10
11
|
import { ShapeOps } from "../../oc/shape-ops.js";
|
|
11
12
|
import { ShapeProps } from "../../oc/props.js";
|
|
@@ -433,4 +434,129 @@ describe("sweep", () => {
|
|
|
433
434
|
expect(vol).toBeLessThan(CYL_VOL - 100);
|
|
434
435
|
});
|
|
435
436
|
});
|
|
437
|
+
describe("helix thread sweep (asymmetric profile)", () => {
|
|
438
|
+
// An asymmetric profile swept along a helix is the case that exposes
|
|
439
|
+
// section twist — a rotationally symmetric circle (used by the tests above)
|
|
440
|
+
// looks identical no matter how the section spins, so it can't catch a
|
|
441
|
+
// wobbling trihedron. This sweeps a thread-like trapezoid drawn on a plane
|
|
442
|
+
// built off the helix (plane(h)), the orientation a user reaches for.
|
|
443
|
+
//
|
|
444
|
+
// The fixed binormal must track the helix axis. With the wrong binormal the
|
|
445
|
+
// section flips ~twice per turn, producing a self-intersecting ribbon whose
|
|
446
|
+
// mass piles up on one side (centroid leaves the axis) and whose volume
|
|
447
|
+
// collapses to a fraction of the real thread. For a whole number of turns a
|
|
448
|
+
// correct thread is axisymmetric: its centroid sits on the coil axis.
|
|
449
|
+
it("produces a clean axisymmetric coil, not a wobbling ribbon", () => {
|
|
450
|
+
const h = helix("z").height(80).radius(25).pitch(10); // 8 full turns
|
|
451
|
+
const p = plane(h);
|
|
452
|
+
const profile = sketch(p, () => {
|
|
453
|
+
line([3, 0], [-3, 0]);
|
|
454
|
+
line([-2, -6]);
|
|
455
|
+
line([2, -6]);
|
|
456
|
+
line([3, 0]);
|
|
457
|
+
});
|
|
458
|
+
const s = sweep(h, profile);
|
|
459
|
+
render();
|
|
460
|
+
expect(s.getError()).toBeNull();
|
|
461
|
+
const shapes = s.getShapes();
|
|
462
|
+
expect(shapes).toHaveLength(1);
|
|
463
|
+
const props = ShapeProps.getProperties(shapes[0].getShape());
|
|
464
|
+
// Centroid on the coil (Z) axis — a wobbling ribbon piled it out at the
|
|
465
|
+
// ~25mm coil radius instead.
|
|
466
|
+
const radialOffset = Math.hypot(props.centroid.x, props.centroid.y);
|
|
467
|
+
expect(radialOffset).toBeLessThan(1);
|
|
468
|
+
expect(props.centroid.z).toBeCloseTo(40, 0);
|
|
469
|
+
// Real thread volume ≈ profile area (30mm²) × coil length (~1260mm).
|
|
470
|
+
// The wobble collapsed this to ~1500mm³; a section that collapses toward
|
|
471
|
+
// the axis (wrong trihedron the other way) drops it to a few thousand.
|
|
472
|
+
expect(props.volumeMm3).toBeGreaterThan(25000);
|
|
473
|
+
expect(props.volumeMm3).toBeLessThan(40000);
|
|
474
|
+
// The coil sits at the helix radius (~25mm), not collapsed onto the axis.
|
|
475
|
+
const bbox = ShapeOps.getBoundingBox(shapes[0]);
|
|
476
|
+
const radialExtent = Math.max(bbox.maxX - bbox.minX, bbox.maxY - bbox.minY) / 2;
|
|
477
|
+
expect(radialExtent).toBeGreaterThan(23);
|
|
478
|
+
expect(radialExtent).toBeLessThan(30);
|
|
479
|
+
});
|
|
480
|
+
});
|
|
481
|
+
describe("extend", () => {
|
|
482
|
+
it("extends the run-out past the path end along the tangent", () => {
|
|
483
|
+
const profile = sketch("xy", () => {
|
|
484
|
+
circle(10);
|
|
485
|
+
});
|
|
486
|
+
const path = sketch("xz", () => {
|
|
487
|
+
vLine(50);
|
|
488
|
+
});
|
|
489
|
+
const s = sweep(path, profile).extend("end", 20);
|
|
490
|
+
render();
|
|
491
|
+
expect(s.getError()).toBeNull();
|
|
492
|
+
const shapes = s.getShapes();
|
|
493
|
+
expect(shapes).toHaveLength(1);
|
|
494
|
+
expect(shapes[0].getType()).toBe("solid");
|
|
495
|
+
// Straight Z sweep with flat caps: Z extent = path length + extension.
|
|
496
|
+
const bbox = ShapeOps.getBoundingBox(shapes[0]);
|
|
497
|
+
expect(bbox.maxZ - bbox.minZ).toBeCloseTo(70, 0);
|
|
498
|
+
});
|
|
499
|
+
it("extends the lead-in before the path start along the tangent", () => {
|
|
500
|
+
const profile = sketch("xy", () => {
|
|
501
|
+
circle(10);
|
|
502
|
+
});
|
|
503
|
+
const path = sketch("xz", () => {
|
|
504
|
+
vLine(50);
|
|
505
|
+
});
|
|
506
|
+
const s = sweep(path, profile).extend("start", 20);
|
|
507
|
+
render();
|
|
508
|
+
expect(s.getError()).toBeNull();
|
|
509
|
+
const bbox = ShapeOps.getBoundingBox(s.getShapes()[0]);
|
|
510
|
+
expect(bbox.maxZ - bbox.minZ).toBeCloseTo(70, 0);
|
|
511
|
+
});
|
|
512
|
+
it("extends both ends when chained", () => {
|
|
513
|
+
const profile = sketch("xy", () => {
|
|
514
|
+
circle(10);
|
|
515
|
+
});
|
|
516
|
+
const path = sketch("xz", () => {
|
|
517
|
+
vLine(50);
|
|
518
|
+
});
|
|
519
|
+
const s = sweep(path, profile).extend("start", 10).extend("end", 20);
|
|
520
|
+
render();
|
|
521
|
+
expect(s.getError()).toBeNull();
|
|
522
|
+
const bbox = ShapeOps.getBoundingBox(s.getShapes()[0]);
|
|
523
|
+
expect(bbox.maxZ - bbox.minZ).toBeCloseTo(80, 0);
|
|
524
|
+
});
|
|
525
|
+
it("adds volume proportional to the extension length", () => {
|
|
526
|
+
const profile = sketch("xy", () => {
|
|
527
|
+
circle(10); // diameter 10 ⇒ radius 5 ⇒ area 25π
|
|
528
|
+
});
|
|
529
|
+
const path = sketch("xz", () => {
|
|
530
|
+
vLine(50);
|
|
531
|
+
});
|
|
532
|
+
const s = sweep(path, profile).extend("end", 30);
|
|
533
|
+
render();
|
|
534
|
+
// Right cylinder: π·5²·(50 + 30).
|
|
535
|
+
const vol = ShapeProps.getProperties(s.getShapes()[0].getShape()).volumeMm3;
|
|
536
|
+
const expected = 25 * Math.PI * 80;
|
|
537
|
+
expect(vol).toBeGreaterThan(expected * 0.98);
|
|
538
|
+
expect(vol).toBeLessThan(expected * 1.02);
|
|
539
|
+
});
|
|
540
|
+
it("is a no-op for a non-positive amount", () => {
|
|
541
|
+
const profile = sketch("xy", () => {
|
|
542
|
+
circle(10);
|
|
543
|
+
});
|
|
544
|
+
const path = sketch("xz", () => {
|
|
545
|
+
vLine(50);
|
|
546
|
+
});
|
|
547
|
+
const s = sweep(path, profile).extend("end", 0);
|
|
548
|
+
render();
|
|
549
|
+
const bbox = ShapeOps.getBoundingBox(s.getShapes()[0]);
|
|
550
|
+
expect(bbox.maxZ - bbox.minZ).toBeCloseTo(50, 0);
|
|
551
|
+
});
|
|
552
|
+
it("throws on an invalid side", () => {
|
|
553
|
+
const profile = sketch("xy", () => {
|
|
554
|
+
circle(10);
|
|
555
|
+
});
|
|
556
|
+
const path = sketch("xz", () => {
|
|
557
|
+
vLine(50);
|
|
558
|
+
});
|
|
559
|
+
expect(() => sweep(path, profile).extend("middle", 10)).toThrow();
|
|
560
|
+
});
|
|
561
|
+
});
|
|
436
562
|
});
|
|
@@ -20,8 +20,10 @@ function solidsOf(e) {
|
|
|
20
20
|
}
|
|
21
21
|
describe("text", () => {
|
|
22
22
|
setupOC();
|
|
23
|
-
it("extrudes
|
|
24
|
-
|
|
23
|
+
it("extrudes text on a plane into a solid", () => {
|
|
24
|
+
sketch("xy", () => {
|
|
25
|
+
text("A").size(20);
|
|
26
|
+
});
|
|
25
27
|
const e = extrude(5);
|
|
26
28
|
render();
|
|
27
29
|
const solids = solidsOf(e);
|
|
@@ -53,19 +55,25 @@ describe("text", () => {
|
|
|
53
55
|
it("builds a letter with a counter (hole) such as 'o'", () => {
|
|
54
56
|
// Outer + inner contours must both build; FaceMaker2 drills the counter.
|
|
55
57
|
// We don't assert exact topology (font-dependent) — only a valid solid.
|
|
56
|
-
|
|
58
|
+
sketch("xy", () => {
|
|
59
|
+
text("o").size(20);
|
|
60
|
+
});
|
|
57
61
|
const e = extrude(4);
|
|
58
62
|
render();
|
|
59
63
|
expect(solidsOf(e).length).toBeGreaterThanOrEqual(1);
|
|
60
64
|
});
|
|
61
65
|
it("falls back to a default font when the named font is missing", () => {
|
|
62
|
-
|
|
66
|
+
sketch("xy", () => {
|
|
67
|
+
text("Z").size(20).font("NoSuchFont__XYZ");
|
|
68
|
+
});
|
|
63
69
|
const e = extrude(4);
|
|
64
70
|
render();
|
|
65
71
|
expect(solidsOf(e).length).toBeGreaterThanOrEqual(1);
|
|
66
72
|
});
|
|
67
73
|
it("honours font weight without throwing", () => {
|
|
68
|
-
|
|
74
|
+
sketch("xy", () => {
|
|
75
|
+
text("B").size(20).weight("bold");
|
|
76
|
+
});
|
|
69
77
|
const e = extrude(4);
|
|
70
78
|
render();
|
|
71
79
|
expect(solidsOf(e).length).toBeGreaterThanOrEqual(1);
|
|
@@ -88,7 +96,9 @@ describe("text", () => {
|
|
|
88
96
|
const root = process.env.FLUIDCAD_WORKSPACE_PATH;
|
|
89
97
|
fs.mkdirSync(join(root, "fonts"), { recursive: true });
|
|
90
98
|
fs.copyFileSync(src, join(root, "fonts", "test.ttf"));
|
|
91
|
-
|
|
99
|
+
sketch("xy", () => {
|
|
100
|
+
text("A").size(20).font("fonts/test.ttf");
|
|
101
|
+
});
|
|
92
102
|
const e = extrude(5);
|
|
93
103
|
render();
|
|
94
104
|
expect(solidsOf(e).length).toBeGreaterThanOrEqual(1);
|
|
@@ -187,9 +197,9 @@ describe("text along a path", () => {
|
|
|
187
197
|
expect(bbox.maxX).toBeLessThan(101);
|
|
188
198
|
expect(bbox.maxX).toBeGreaterThan(85);
|
|
189
199
|
});
|
|
190
|
-
it("follows a
|
|
200
|
+
it("follows a closed circular path and wraps around", () => {
|
|
191
201
|
// Text much longer than the circumference must wrap, not error.
|
|
192
|
-
const ring =
|
|
202
|
+
const ring = sketch("xy", () => circle(30)); // radius 15, circumference ~94
|
|
193
203
|
const t = text("WRAPPING ALL THE WAY AROUND", ring).size(8);
|
|
194
204
|
render();
|
|
195
205
|
expect(t.getError()).toBeFalsy();
|
|
@@ -232,12 +242,15 @@ describe("text along a path", () => {
|
|
|
232
242
|
expect(t.getError()).toMatch(/planar/i);
|
|
233
243
|
});
|
|
234
244
|
it("rejects path-only modifiers without a path", () => {
|
|
235
|
-
|
|
245
|
+
let t;
|
|
246
|
+
sketch("xy", () => {
|
|
247
|
+
t = text("Hi").offset(3);
|
|
248
|
+
});
|
|
236
249
|
render();
|
|
237
250
|
expect(t.getError()).toMatch(/offset/i);
|
|
238
251
|
});
|
|
239
252
|
it("sits on the outside of a closed circle by default", () => {
|
|
240
|
-
const ring =
|
|
253
|
+
const ring = sketch("xy", () => circle(100)); // radius 50
|
|
241
254
|
const t = text("OUTSIDE", ring).size(8);
|
|
242
255
|
render();
|
|
243
256
|
expect(t.getError()).toBeFalsy();
|
|
@@ -249,7 +262,7 @@ describe("text along a path", () => {
|
|
|
249
262
|
expect(mean).toBeGreaterThan(50);
|
|
250
263
|
});
|
|
251
264
|
it("moves closed-path text inside with flip", () => {
|
|
252
|
-
const ring =
|
|
265
|
+
const ring = sketch("xy", () => circle(100)); // radius 50
|
|
253
266
|
const t = text("INSIDE", ring).size(8).flip();
|
|
254
267
|
render();
|
|
255
268
|
expect(t.getError()).toBeFalsy();
|
|
@@ -297,12 +310,18 @@ describe("text along a path", () => {
|
|
|
297
310
|
expect(bbox.maxX).toBeGreaterThan(85);
|
|
298
311
|
});
|
|
299
312
|
it("rejects space-between alignment without a path", () => {
|
|
300
|
-
|
|
313
|
+
let t;
|
|
314
|
+
sketch("xy", () => {
|
|
315
|
+
t = text("Hi").align("space-between");
|
|
316
|
+
});
|
|
301
317
|
render();
|
|
302
318
|
expect(t.getError()).toMatch(/space-between/i);
|
|
303
319
|
});
|
|
304
320
|
it("rejects space-around alignment without a path", () => {
|
|
305
|
-
|
|
321
|
+
let t;
|
|
322
|
+
sketch("xy", () => {
|
|
323
|
+
t = text("Hi").align("space-around");
|
|
324
|
+
});
|
|
306
325
|
render();
|
|
307
326
|
expect(t.getError()).toMatch(/space-around/i);
|
|
308
327
|
});
|
|
@@ -337,7 +356,7 @@ describe("text along a path", () => {
|
|
|
337
356
|
expect(bbox.maxZ - bbox.minZ).toBeCloseTo(4, 0);
|
|
338
357
|
});
|
|
339
358
|
it("multiple texts can share one path", () => {
|
|
340
|
-
const ring =
|
|
359
|
+
const ring = sketch("xy", () => circle(100));
|
|
341
360
|
const outside = text("OUTSIDE", ring).size(8);
|
|
342
361
|
const inside = text("INSIDE", ring).size(8).flip();
|
|
343
362
|
render();
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { interpolateBSpline2d } from "../../math/bspline-interpolation.js";
|
|
2
|
+
import { interpolateBSpline2d, interpolateWithDerivatives, } from "../../math/bspline-interpolation.js";
|
|
3
3
|
/** Evaluates the B-spline at t via Cox–de Boor (independent of OCC). */
|
|
4
4
|
function evaluate(data, t) {
|
|
5
5
|
const flat = [];
|
|
@@ -117,3 +117,99 @@ describe("interpolateBSpline2d", () => {
|
|
|
117
117
|
expect(data.poles.length).toBe(4);
|
|
118
118
|
});
|
|
119
119
|
});
|
|
120
|
+
/** Evaluates an N-dimensional B-spline at t via Cox–de Boor. */
|
|
121
|
+
function evaluateNd(data, t) {
|
|
122
|
+
const flat = [];
|
|
123
|
+
for (let i = 0; i < data.knots.length; i++) {
|
|
124
|
+
for (let m = 0; m < data.multiplicities[i]; m++) {
|
|
125
|
+
flat.push(data.knots[i]);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
const asXY = {
|
|
129
|
+
poles: data.poles.map(p => ({ x: p[0], y: p.length > 1 ? p[1] : 0 })),
|
|
130
|
+
knots: data.knots,
|
|
131
|
+
multiplicities: data.multiplicities,
|
|
132
|
+
degree: data.degree,
|
|
133
|
+
};
|
|
134
|
+
// Reuse the 2D evaluator per dimension pair to keep this helper simple.
|
|
135
|
+
const result = [];
|
|
136
|
+
const dimension = data.poles[0].length;
|
|
137
|
+
for (let d = 0; d < dimension; d += 2) {
|
|
138
|
+
const pair = {
|
|
139
|
+
...asXY,
|
|
140
|
+
poles: data.poles.map(p => ({ x: p[d], y: d + 1 < dimension ? p[d + 1] : 0 })),
|
|
141
|
+
};
|
|
142
|
+
const q = evaluate(pair, t);
|
|
143
|
+
result.push(q.x);
|
|
144
|
+
if (d + 1 < dimension) {
|
|
145
|
+
result.push(q.y);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return result;
|
|
149
|
+
}
|
|
150
|
+
/** Central-difference derivative of the curve at t. */
|
|
151
|
+
function numericDerivative(data, t) {
|
|
152
|
+
const h = 1e-7;
|
|
153
|
+
const t0 = Math.max(data.knots[0], t - h);
|
|
154
|
+
const t1 = Math.min(data.knots[data.knots.length - 1], t + h);
|
|
155
|
+
const a = evaluateNd(data, t0);
|
|
156
|
+
const b = evaluateNd(data, t1);
|
|
157
|
+
return a.map((v, d) => (b[d] - v) / (t1 - t0));
|
|
158
|
+
}
|
|
159
|
+
describe("interpolateWithDerivatives", () => {
|
|
160
|
+
const points = [
|
|
161
|
+
[0, 0, 0],
|
|
162
|
+
[1, 2, 1],
|
|
163
|
+
[3, 1, 2],
|
|
164
|
+
[4, 4, 3],
|
|
165
|
+
];
|
|
166
|
+
const params = [0, 0.3, 0.7, 1];
|
|
167
|
+
it("passes through every point with both end derivatives constrained", () => {
|
|
168
|
+
const data = interpolateWithDerivatives(points, params, [0, 0, 5], [5, 0, 0]);
|
|
169
|
+
expect(data.degree).toBe(3);
|
|
170
|
+
expect(data.poles.length).toBe(points.length + 2);
|
|
171
|
+
for (let k = 0; k < points.length; k++) {
|
|
172
|
+
const q = evaluateNd(data, params[k]);
|
|
173
|
+
for (let d = 0; d < 3; d++) {
|
|
174
|
+
expect(q[d]).toBeCloseTo(points[k][d], 9);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
it("matches the requested end derivatives", () => {
|
|
179
|
+
const startD = [0, 0, 5];
|
|
180
|
+
const endD = [5, 0, 0];
|
|
181
|
+
const data = interpolateWithDerivatives(points, params, startD, endD);
|
|
182
|
+
const atStart = numericDerivative(data, 0);
|
|
183
|
+
const atEnd = numericDerivative(data, 1);
|
|
184
|
+
for (let d = 0; d < 3; d++) {
|
|
185
|
+
expect(atStart[d]).toBeCloseTo(startD[d], 4);
|
|
186
|
+
expect(atEnd[d]).toBeCloseTo(endD[d], 4);
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
it("supports a single constrained end", () => {
|
|
190
|
+
const startD = [0, 0, 4];
|
|
191
|
+
const data = interpolateWithDerivatives(points, params, startD, undefined);
|
|
192
|
+
expect(data.poles.length).toBe(points.length + 1);
|
|
193
|
+
for (let k = 0; k < points.length; k++) {
|
|
194
|
+
const q = evaluateNd(data, params[k]);
|
|
195
|
+
for (let d = 0; d < 3; d++) {
|
|
196
|
+
expect(q[d]).toBeCloseTo(points[k][d], 9);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
const atStart = numericDerivative(data, 0);
|
|
200
|
+
for (let d = 0; d < 3; d++) {
|
|
201
|
+
expect(atStart[d]).toBeCloseTo(startD[d], 4);
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
it("reproduces a cubic Hermite segment for two points with derivatives", () => {
|
|
205
|
+
const data = interpolateWithDerivatives([[0, 0, 0], [1, 0, 0]], [0, 1], [0, 1, 0], [0, -1, 0]);
|
|
206
|
+
expect(data.degree).toBe(3);
|
|
207
|
+
expect(data.poles.length).toBe(4);
|
|
208
|
+
// Bézier form of the Hermite segment: P1 = P0 + D0/3, P2 = P3 - D1/3.
|
|
209
|
+
expect(data.poles[1][1]).toBeCloseTo(1 / 3, 9);
|
|
210
|
+
expect(data.poles[2][1]).toBeCloseTo(1 / 3, 9);
|
|
211
|
+
});
|
|
212
|
+
it("rejects non-increasing parameters", () => {
|
|
213
|
+
expect(() => interpolateWithDerivatives([[0, 0], [1, 1], [2, 2]], [0, 0.5, 0.5])).toThrow(/strictly increasing/);
|
|
214
|
+
});
|
|
215
|
+
});
|