diff --git a/.changeset/fix-oval-cross.md b/.changeset/fix-oval-cross.md new file mode 100644 index 0000000..d9eeee4 --- /dev/null +++ b/.changeset/fix-oval-cross.md @@ -0,0 +1,5 @@ +--- +'basekit': patch +--- + +Keep saved five-pocket magnet layouts in a cross on elongated bases and matching holders. diff --git a/README.md b/README.md index ec3dc56..0975d77 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ Make a round, square, or hex token for objectives, Oath of Moment, or anything e - Printable 15, 20, 30, and 35mm flying stems with adjustable peg or ball-joint connections. - Round, square, and hex tokens with wrapped text and traced image silhouettes. - Shared magnet dimensions across bases, holders, movement trays, and spray trays. -- Balanced or five-pocket cross magnet arrangements for bases and holders. +- Balanced or five-pocket cross magnet arrangements for bases and holders. Saved cross layouts keep one centre pocket and four outer pockets on both axes, including elongated footprints. - Exact size labels, filenames, dimensions, and high-quality exports. - Browser-saved settings with shared base and holder preferences, and a confirmed reset for each generator or for the shared settings. - Copyable links that open the current generator with the same settings. diff --git a/src/geometry/base.test.ts b/src/geometry/base.test.ts index aa3afcb..199ab48 100644 --- a/src/geometry/base.test.ts +++ b/src/geometry/base.test.ts @@ -197,6 +197,45 @@ describe('buildBase', () => { expect(positions.filter(({ x, y }) => Math.hypot(x, y) > 1e-6)).toHaveLength(4) }) + it.each([ + [83, 52.5], + [52.5, 83], + ])('keeps five-pocket crosses on both axes of an elongated %s × %s well', (halfWidth, halfLength) => { + expect(magnetPositions(5, halfWidth, halfLength, 4.9, { layout: 'five-cross', ellipticalRow: true })).toEqual([ + { x: 0, y: 0, r: 0 }, + { x: 0, y: 26.25, r: 26.25 }, + { x: -26.25, y: 0, r: 26.25 }, + { x: 0, y: -26.25, r: 26.25 }, + { x: 26.25, y: 0, r: 26.25 }, + ]) + }) + + it('drills the legacy 170 × 109 oval cross on both axes', () => { + const base = presetFor({ shape: 'oval', width: 170, length: 109, label: '170×109', use: '' }, 8, 1) + const config = { + ...base, + wallThickness: 2, + magnets: { ...base.magnets, layout: 'five-cross' as const, count: 5, diameter: 6 }, + ribs: { ...base.ribs, count: 4 }, + segments: exportSegmentsFor(170), + } + const { mesh } = build(config) + const centers = [ + [0, 0], + [0, 26.25], + [-26.25, 0], + [0, -26.25], + [26.25, 0], + ] + for (const [x, y] of centers) { + const pocket = pocketAt(mesh, x, y, 3.1) + expect(pocket.maxRadius).toBeCloseTo(3.1, 1) + expect(pocket.minZ).toBeCloseTo(config.height - config.magnets.thickness - config.magnets.depthClearance, 5) + expect(pocket.maxZ).toBeCloseTo(config.height, 5) + } + expect(duplicateVertices(mesh)).toBe(0) + }) + it.for([RECT_SIZES[0], RECT_SIZES[1], ROUND_SIZES[0], ROUND_SIZES[1]])('still fits a size label on a cramped $label base', (size) => { // The well of a small base is mostly boss and ribs, and the label used to be // dropped silently when the first direction tried had no room. diff --git a/src/geometry/base.ts b/src/geometry/base.ts index 6d1c531..4224d71 100644 --- a/src/geometry/base.ts +++ b/src/geometry/base.ts @@ -48,13 +48,20 @@ export function magnetPositions( options: MagnetPositionOptions = {}, ): Circle[] { if (count <= 0) return [] + const short = Math.min(halfWidth, halfLength) + const radius = Math.max(Math.min(short * 0.5, short - clear), 0) if (options.layout === 'five-cross') { - return [{ x: 0, y: 0, r: 0 }, ...magnetPositions(4, halfWidth, halfLength, clear, { ...options, layout: 'balanced' })] + return [ + { x: 0, y: 0, r: 0 }, + { x: 0, y: radius, r: radius }, + { x: -radius, y: 0, r: radius }, + { x: 0, y: -radius, r: radius }, + { x: radius, y: 0, r: radius }, + ] } if (count === 1) return [{ x: 0, y: 0, r: 0 }] const long = Math.max(halfWidth, halfLength) - const short = Math.min(halfWidth, halfLength) if (!magnetsRing(halfWidth, halfLength)) { const ellipseReach = long * Math.sqrt(Math.max(0, 1 - (clear / short) ** 2)) - clear const reach = Math.max(options.ellipticalRow ? ellipseReach : long - clear, 0) @@ -65,7 +72,6 @@ export function magnetPositions( }) } - const radius = Math.max(Math.min(short * 0.5, short - clear), 0) return Array.from({ length: count }, (_, i) => { const a = Math.PI / 2 + (2 * Math.PI * i) / count return { x: radius * Math.cos(a), y: radius * Math.sin(a), r: radius } diff --git a/src/geometry/holder.test.ts b/src/geometry/holder.test.ts index 62bdf68..a67c3d1 100644 --- a/src/geometry/holder.test.ts +++ b/src/geometry/holder.test.ts @@ -173,7 +173,13 @@ describe('holderLayout', () => { } const oval = holderGroup('oval', 1, { shape: 'oval', width: 60, length: 35 }) - expect(holderSlotMagnetCenters(oval, settings)).toHaveLength(5) + expect(holderSlotMagnetCenters(oval, settings)).toEqual([ + { x: 0, y: 0 }, + { x: 0, y: 7.75 }, + { x: -7.75, y: 0 }, + { x: 0, y: -7.75 }, + { x: 7.75, y: 0 }, + ]) }) it('fits forty 32mm models within a 7×5 box without false overflow', () => {