From c8e0f874f8d231b5e58d485e769c257b6a9ca8cf Mon Sep 17 00:00:00 2001 From: Dave Snider Date: Sun, 22 Feb 2026 09:03:16 -0500 Subject: [PATCH 1/5] Triangle support --- src/lib/components/GlobalsPanel.svelte | 37 +++++++++++- src/lib/components/TrayScene.svelte | 70 ++++++++++++++++++++-- src/lib/models/box.ts | 17 ++++++ src/lib/models/counterTray.ts | 81 ++++++++++++++++++++++++-- src/lib/utils/pdfGenerator.ts | 2 +- src/lib/utils/svgDiagramGenerator.ts | 15 ++++- 6 files changed, 207 insertions(+), 15 deletions(-) diff --git a/src/lib/components/GlobalsPanel.svelte b/src/lib/components/GlobalsPanel.svelte index 6973b71..5d23c2e 100644 --- a/src/lib/components/GlobalsPanel.svelte +++ b/src/lib/components/GlobalsPanel.svelte @@ -12,7 +12,8 @@ { value: 'rectangle', label: 'Rectangle' }, { value: 'square', label: 'Square' }, { value: 'circle', label: 'Circle' }, - { value: 'hex', label: 'Hex' } + { value: 'hex', label: 'Hex' }, + { value: 'triangle', label: 'Triangle' } ]; function updateParam(key: K, value: CounterTrayParams[K]) { @@ -158,6 +159,27 @@ class="mt-1 block w-full rounded border-gray-600 bg-gray-700 px-2 py-1 text-sm" /> + + + {:else if baseShape === 'triangle'} + {/if} diff --git a/src/lib/components/TrayScene.svelte b/src/lib/components/TrayScene.svelte index cc708b4..d3a10dd 100644 --- a/src/lib/components/TrayScene.svelte +++ b/src/lib/components/TrayScene.svelte @@ -476,7 +476,7 @@ - {:else} + {:else if effectiveShape === 'hex'} + {:else} + + + + + {/if} {:else} @@ -509,7 +521,7 @@ - {:else} + {:else if effectiveShape === 'hex'} + {:else} + + + + + {/if} {/if} {/each} @@ -545,7 +568,7 @@ - {:else} + {:else if effectiveShape === 'hex'} + {:else} + + + + + {/if} {/each} {/if} @@ -618,7 +647,7 @@ /> - {:else} + {:else if effectiveShape === 'hex'} + {:else} + + + + + {/if} {:else} @@ -654,7 +695,7 @@ /> - {:else} + {:else if effectiveShape === 'hex'} + {:else} + + + + + {/if} {/if} {/each} @@ -691,7 +743,7 @@ - {:else} + {:else if effectiveShape === 'hex'} + {:else} + + + + + {/if} {/each} {/if} diff --git a/src/lib/models/box.ts b/src/lib/models/box.ts index c3933e6..dab96b3 100644 --- a/src/lib/models/box.ts +++ b/src/lib/models/box.ts @@ -46,6 +46,7 @@ export function getTrayDimensions(params: CounterTrayParams): TrayDimensions { squareLength, hexFlatToFlat: hexFlatToFlatBase, circleDiameter: circleDiameterBase, + triangleSide: triangleSideBase, counterThickness, hexPointyTop, clearance, @@ -74,6 +75,12 @@ export function getTrayDimensions(params: CounterTrayParams): TrayDimensions { const circlePocketWidth = circleDiameter; const circlePocketLength = circleDiameter; + // Triangle: equilateral, side length is the base (X), height is side * sqrt(3)/2 (Y) + const triangleSide = triangleSideBase + clearance * 2; + const triangleHeight = triangleSide * (Math.sqrt(3) / 2); + const trianglePocketWidth = triangleSide; + const trianglePocketLength = triangleHeight; + // Helper to get custom shape by name from 'custom:ShapeName' reference const getCustomShape = (shapeRef: string) => { if (!shapeRef.startsWith('custom:')) return null; @@ -96,6 +103,12 @@ export function getTrayDimensions(params: CounterTrayParams): TrayDimensions { const l = hexPointyTop ? pointToPoint : flatToFlat; return [w, l]; } + if (baseShape === 'triangle') { + // width stores side length, height = side * sqrt(3)/2 + const side = custom.width; + const height = side * (Math.sqrt(3) / 2); + return [side, height]; // Base (X) x Height (Y) + } if (baseShape === 'circle' || baseShape === 'square') { // Both dimensions are equal (diameter or size) return [custom.width, custom.width]; @@ -108,6 +121,7 @@ export function getTrayDimensions(params: CounterTrayParams): TrayDimensions { const getPocketWidth = (shape: string): number => { if (shape === 'square') return squarePocketWidth; if (shape === 'hex') return hexPocketWidth; + if (shape === 'triangle') return trianglePocketWidth; const custom = getCustomShape(shape); if (custom) { const [w, l] = getCustomEffectiveDims(custom); @@ -119,6 +133,7 @@ export function getTrayDimensions(params: CounterTrayParams): TrayDimensions { const getPocketLength = (shape: string): number => { if (shape === 'square') return squarePocketLength; if (shape === 'hex') return hexPocketLength; + if (shape === 'triangle') return trianglePocketLength; const custom = getCustomShape(shape); if (custom) { const [w, l] = getCustomEffectiveDims(custom); @@ -163,6 +178,7 @@ export function getTrayDimensions(params: CounterTrayParams): TrayDimensions { if (shape === 'square') return squareWidth; if (shape === 'hex') return hexPointyTop ? hexFlatToFlatBase : hexFlatToFlatBase / Math.cos(Math.PI / 6); + if (shape === 'triangle') return triangleSideBase; const custom = getCustomShape(shape); if (custom) { const [w, l] = getCustomEffectiveDims(custom); @@ -175,6 +191,7 @@ export function getTrayDimensions(params: CounterTrayParams): TrayDimensions { if (shape === 'square') return squareLength; if (shape === 'hex') return hexPointyTop ? hexFlatToFlatBase / Math.cos(Math.PI / 6) : hexFlatToFlatBase; + if (shape === 'triangle') return triangleSideBase * (Math.sqrt(3) / 2); const custom = getCustomShape(shape); if (custom) { const [w, l] = getCustomEffectiveDims(custom); diff --git a/src/lib/models/counterTray.ts b/src/lib/models/counterTray.ts index 8beb9d9..22c1801 100644 --- a/src/lib/models/counterTray.ts +++ b/src/lib/models/counterTray.ts @@ -1,8 +1,9 @@ import jscad from '@jscad/modeling'; -const { cuboid, cylinder, roundedCuboid, sphere } = jscad.primitives; +const { cuboid, cylinder, roundedCuboid, sphere, circle } = jscad.primitives; const { subtract, union } = jscad.booleans; const { translate, rotateY, rotateZ, scale, mirrorY } = jscad.transforms; +const { hull } = jscad.hulls; const { vectorText } = jscad.text; const { path2 } = jscad.geometries; const { expand } = jscad.expansions; @@ -18,7 +19,7 @@ export type TopLoadedStackDef = [string, number, string?]; export type EdgeLoadedStackDef = [string, number, EdgeOrientation?, string?]; // Base shape types for custom shapes -export type CustomBaseShape = 'rectangle' | 'square' | 'circle' | 'hex'; +export type CustomBaseShape = 'rectangle' | 'square' | 'circle' | 'hex' | 'triangle'; // Custom shape definition export interface CustomShape { @@ -33,6 +34,8 @@ export interface CounterTrayParams { squareLength: number; hexFlatToFlat: number; circleDiameter: number; + triangleSide: number; + triangleCornerRadius: number; counterThickness: number; hexPointyTop: boolean; clearance: number; @@ -55,6 +58,8 @@ export const defaultParams: CounterTrayParams = { squareLength: 15.9, hexFlatToFlat: 15.9, circleDiameter: 15.9, + triangleSide: 15.9, + triangleCornerRadius: 1.5, counterThickness: 1.3, hexPointyTop: false, clearance: 0.3, @@ -72,7 +77,8 @@ export const defaultParams: CounterTrayParams = { ['hex', 15], ['square', 6], ['hex', 10], - ['circle', 20] + ['circle', 20], + ['triangle', 10] ], edgeLoadedStacks: [], customShapes: [], @@ -81,7 +87,7 @@ export const defaultParams: CounterTrayParams = { // Counter preview data for visualization export interface CounterStack { - shape: 'square' | 'hex' | 'circle' | 'custom'; + shape: 'square' | 'hex' | 'circle' | 'triangle' | 'custom'; customShapeName?: string; // Only set when shape === 'custom' customBaseShape?: CustomBaseShape; // The base shape type when shape === 'custom' x: number; // Center X position in tray (or slot start X for edge-loaded) @@ -118,6 +124,7 @@ export function getCounterPositions( squareLength, hexFlatToFlat: hexFlatToFlatBase, circleDiameter: circleDiameterBase, + triangleSide: triangleSideBase, counterThickness, hexPointyTop, clearance, @@ -148,6 +155,12 @@ export function getCounterPositions( const circleDiameter = circleDiameterBase + clearance * 2; + // Triangle: equilateral, side length is the base (X), height is side * sqrt(3)/2 (Y) + const triangleSide = triangleSideBase + clearance * 2; + const triangleHeight = triangleSide * (Math.sqrt(3) / 2); + const trianglePocketWidth = triangleSide; // Base along X + const trianglePocketLength = triangleHeight; // Point towards inside (Y) + // Helper to get custom shape by name from 'custom:ShapeName' reference const getCustomShape = (shapeRef: string): CustomShape | null => { if (!shapeRef.startsWith('custom:')) return null; @@ -168,6 +181,12 @@ export function getCounterPositions( const l = hexPointyTop ? pointToPoint : flatToFlat; return [w, l]; } + if (baseShape === 'triangle') { + // width stores side length, height = side * sqrt(3)/2 + const side = custom.width; + const height = side * (Math.sqrt(3) / 2); + return [side, height]; // Base (X) x Height (Y) + } if (baseShape === 'circle' || baseShape === 'square') { // Both dimensions are equal (diameter or size) return [custom.width, custom.width]; @@ -180,6 +199,7 @@ export function getCounterPositions( const getPocketWidth = (shape: string): number => { if (shape === 'square') return squarePocketWidth; if (shape === 'hex') return hexPocketWidth; + if (shape === 'triangle') return trianglePocketWidth; // Base along X const custom = getCustomShape(shape); if (custom) { const [w, l] = getCustomEffectiveDims(custom); @@ -193,6 +213,7 @@ export function getCounterPositions( const getPocketLength = (shape: string): number => { if (shape === 'square') return squarePocketLength; if (shape === 'hex') return hexPocketLength; + if (shape === 'triangle') return trianglePocketLength; // Height along Y (point towards inside) const custom = getCustomShape(shape); if (custom) { const [w, l] = getCustomEffectiveDims(custom); @@ -218,6 +239,7 @@ export function getCounterPositions( if (shape === 'square') return squareWidth; if (shape === 'hex') return hexPointyTop ? hexFlatToFlatBase : hexFlatToFlatBase / Math.cos(Math.PI / 6); + if (shape === 'triangle') return triangleSideBase; // Base along X const custom = getCustomShape(shape); if (custom) { const [w, l] = getCustomEffectiveDims(custom); @@ -230,6 +252,7 @@ export function getCounterPositions( if (shape === 'square') return squareLength; if (shape === 'hex') return hexPointyTop ? hexFlatToFlatBase / Math.cos(Math.PI / 6) : hexFlatToFlatBase; + if (shape === 'triangle') return triangleSideBase * (Math.sqrt(3) / 2); // Height along Y const custom = getCustomShape(shape); if (custom) { const [w, l] = getCustomEffectiveDims(custom); @@ -287,7 +310,7 @@ export function getCounterPositions( const parseShapeRef = ( shapeRef: string ): { - shapeType: 'square' | 'hex' | 'circle' | 'custom'; + shapeType: 'square' | 'hex' | 'circle' | 'triangle' | 'custom'; customName?: string; customBaseShape?: CustomBaseShape; } => { @@ -300,7 +323,7 @@ export function getCounterPositions( customBaseShape: customShape?.baseShape ?? 'rectangle' }; } - return { shapeType: shapeRef as 'square' | 'hex' | 'circle' }; + return { shapeType: shapeRef as 'square' | 'hex' | 'circle' | 'triangle' }; }; // Calculate edge-loaded slot dimensions @@ -695,6 +718,8 @@ export function createCounterTray( squareLength, hexFlatToFlat: hexFlatToFlatBase, circleDiameter: circleDiameterBase, + triangleSide: triangleSideBase, + triangleCornerRadius, counterThickness, hexPointyTop, clearance, @@ -739,6 +764,12 @@ export function createCounterTray( const circlePocketWidth = circleDiameter; const circlePocketLength = circleDiameter; + // Triangle: equilateral, side length is the base (X), height is side * sqrt(3)/2 (Y) + const triangleSide = triangleSideBase + clearance * 2; + const triangleHeight = triangleSide * (Math.sqrt(3) / 2); + const trianglePocketWidth = triangleSide; // Base along X + const trianglePocketLength = triangleHeight; // Point towards inside (Y) + // Helper to get custom shape by name from 'custom:ShapeName' reference const getCustomShape = (shapeRef: string): CustomShape | null => { if (!shapeRef.startsWith('custom:')) return null; @@ -759,6 +790,12 @@ export function createCounterTray( const l = hexPointyTop ? pointToPoint : flatToFlat; return [w, l]; } + if (baseShape === 'triangle') { + // width stores side length, height = side * sqrt(3)/2 + const side = custom.width; + const height = side * (Math.sqrt(3) / 2); + return [side, height]; // Base (X) x Height (Y) + } if (baseShape === 'circle' || baseShape === 'square') { // Both dimensions are equal (diameter or size) return [custom.width, custom.width]; @@ -772,6 +809,7 @@ export function createCounterTray( const getPocketWidth = (shape: string): number => { if (shape === 'square') return squarePocketWidth; if (shape === 'hex') return hexPocketWidth; + if (shape === 'triangle') return trianglePocketWidth; // Base along X const custom = getCustomShape(shape); if (custom) { const [w, l] = getCustomEffectiveDims(custom); @@ -783,6 +821,7 @@ export function createCounterTray( const getPocketLength = (shape: string): number => { if (shape === 'square') return squarePocketLength; if (shape === 'hex') return hexPocketLength; + if (shape === 'triangle') return trianglePocketLength; // Height along Y (point towards inside) const custom = getCustomShape(shape); if (custom) { const [w, l] = getCustomEffectiveDims(custom); @@ -807,6 +846,7 @@ export function createCounterTray( if (shape === 'square') return squareWidth; if (shape === 'hex') return hexPointyTop ? hexFlatToFlatBase : hexFlatToFlatBase / Math.cos(Math.PI / 6); + if (shape === 'triangle') return triangleSideBase; // Base along X const custom = getCustomShape(shape); if (custom) { const [w, l] = getCustomEffectiveDims(custom); @@ -819,6 +859,7 @@ export function createCounterTray( if (shape === 'square') return squareLength; if (shape === 'hex') return hexPointyTop ? hexFlatToFlatBase / Math.cos(Math.PI / 6) : hexFlatToFlatBase; + if (shape === 'triangle') return triangleSideBase * (Math.sqrt(3) / 2); // Height along Y const custom = getCustomShape(shape); if (custom) { const [w, l] = getCustomEffectiveDims(custom); @@ -1164,6 +1205,25 @@ export function createCounterTray( const pw = getPocketWidth(shape); const pl = getPocketLength(shape); + // Helper to create equilateral triangle prism with rounded corners (base along X, point towards +Y) + const createTrianglePrism = (side: number, h: number) => { + // Use hull of three 2D circles at triangle vertices, then extrude + const r = triangleCornerRadius; + const triHeight = side * (Math.sqrt(3) / 2); + // Inset vertices so the rounded shape has correct overall dimensions + const insetX = side / 2 - r; + const insetYBottom = -triHeight / 2 + r; + const insetYTop = triHeight / 2 - r * 2; // Top point needs more inset + + const corners2D = [ + translate([-insetX, insetYBottom, 0], circle({ radius: r, segments: 16 })), + translate([insetX, insetYBottom, 0], circle({ radius: r, segments: 16 })), + translate([0, insetYTop, 0], circle({ radius: r, segments: 16 })) + ]; + const roundedTriangle2D = hull(...corners2D); + return extrudeLinear({ height: h }, roundedTriangle2D); + }; + // Check if this is a custom shape and get its base shape type if (shape.startsWith('custom:')) { const custom = getCustomShape(shape); @@ -1181,6 +1241,11 @@ export function createCounterTray( }); const rotated = hexPointyTop ? rotateZ(Math.PI / 6, hex) : hex; return translate([pw / 2, pl / 2, 0], rotated); + } else if (baseShape === 'triangle') { + // Custom triangle: use width as side length + const side = (custom?.width ?? 15) + clearance * 2; + const tri = createTrianglePrism(side, height); + return translate([pw / 2, pl / 2, 0], tri); } else if (baseShape === 'circle') { // Custom circle: use width as diameter const diameter = (custom?.width ?? 15) + clearance * 2; @@ -1211,6 +1276,10 @@ export function createCounterTray( }); const rotated = hexPointyTop ? rotateZ(Math.PI / 6, hex) : hex; return translate([pw / 2, pl / 2, 0], rotated); + } else if (shape === 'triangle') { + // Built-in triangle: use triangleSide + const tri = createTrianglePrism(triangleSide, height); + return translate([pw / 2, pl / 2, 0], tri); } else { // circle return translate( diff --git a/src/lib/utils/pdfGenerator.ts b/src/lib/utils/pdfGenerator.ts index adb9525..58a2219 100644 --- a/src/lib/utils/pdfGenerator.ts +++ b/src/lib/utils/pdfGenerator.ts @@ -14,7 +14,7 @@ export interface PdfStackData { refCode: string; label: string; count: number; - shape: 'square' | 'hex' | 'circle' | 'custom'; + shape: 'square' | 'hex' | 'circle' | 'triangle' | 'custom'; x: number; // Center X position in tray y: number; // Center Y position in tray width: number; // X dimension of the counter diff --git a/src/lib/utils/svgDiagramGenerator.ts b/src/lib/utils/svgDiagramGenerator.ts index daebbe5..094d63c 100644 --- a/src/lib/utils/svgDiagramGenerator.ts +++ b/src/lib/utils/svgDiagramGenerator.ts @@ -102,7 +102,7 @@ export function getBoxDiagramDimensions( } function drawShapeMarker( - shape: 'square' | 'hex' | 'circle' | 'custom', + shape: 'square' | 'hex' | 'circle' | 'triangle' | 'custom', cx: number, cy: number, width: number, @@ -124,6 +124,11 @@ function drawShapeMarker( const hexRadius = Math.min(halfW, halfL); return ``; } + case 'triangle': { + // Equilateral triangle with base at bottom, point at top + const triPoints = generateTrianglePoints(cx, cy, paddedWidth, paddedLength); + return ``; + } case 'circle': default: { const circleRadius = Math.min(halfW, halfL); @@ -141,6 +146,14 @@ function generateHexagonPoints(cx: number, cy: number, size: number): string { return points.join(' '); } +function generateTrianglePoints(cx: number, cy: number, width: number, height: number): string { + // Equilateral triangle: base at bottom (along X), point at top (towards -Y in SVG coords) + const halfW = width / 2; + const halfH = height / 2; + // Bottom left, bottom right, top center + return `${cx - halfW},${cy + halfH} ${cx + halfW},${cy + halfH} ${cx},${cy - halfH}`; +} + function escapeXml(str: string): string { return str .replace(/&/g, '&') From 085d5b9e3a8b4ac7899cdc785b851214a6aa7b2a Mon Sep 17 00:00:00 2001 From: Dave Snider Date: Sun, 22 Feb 2026 09:19:46 -0500 Subject: [PATCH 2/5] edge tray cutouts --- src/lib/components/TrayScene.svelte | 16 +++-- src/lib/components/TraysPanel.svelte | 2 +- src/lib/models/counterTray.ts | 101 ++++++++++++++++++++++++--- 3 files changed, 104 insertions(+), 15 deletions(-) diff --git a/src/lib/components/TrayScene.svelte b/src/lib/components/TrayScene.svelte index d3a10dd..fe36741 100644 --- a/src/lib/components/TrayScene.svelte +++ b/src/lib/components/TrayScene.svelte @@ -448,9 +448,11 @@ {@const effectiveShape = stack.shape === 'custom' ? (stack.customBaseShape ?? 'rectangle') : stack.shape} {@const standingHeight = - stack.shape === 'custom' - ? Math.min(stack.width, stack.length) - : Math.max(stack.width, stack.length)} + effectiveShape === 'triangle' + ? stack.length // Triangle geometric height (point down) + : stack.shape === 'custom' + ? Math.min(stack.width, stack.length) + : Math.max(stack.width, stack.length)} {@const counterY = stack.z + standingHeight / 2} {@const isAlt = counterIdx % 2 === 1} {@const counterColor = isAlt ? `hsl(${(stackIdx * 137.508) % 360}, 50%, 40%)` : stack.color} @@ -615,9 +617,11 @@ {@const effectiveShape = stack.shape === 'custom' ? (stack.customBaseShape ?? 'rectangle') : stack.shape} {@const standingHeight = - stack.shape === 'custom' - ? Math.min(stack.width, stack.length) - : Math.max(stack.width, stack.length)} + effectiveShape === 'triangle' + ? stack.length // Triangle geometric height (point down) + : stack.shape === 'custom' + ? Math.min(stack.width, stack.length) + : Math.max(stack.width, stack.length)} {@const counterY = trayYOffset + stack.z + standingHeight / 2} {@const isAlt = counterIdx % 2 === 1} {@const counterColor = isAlt diff --git a/src/lib/components/TraysPanel.svelte b/src/lib/components/TraysPanel.svelte index dc6a761..b8683b7 100644 --- a/src/lib/components/TraysPanel.svelte +++ b/src/lib/components/TraysPanel.svelte @@ -68,7 +68,7 @@ handleDragEnd(); } - const builtinShapes = ['square', 'hex', 'circle'] as const; + const builtinShapes = ['square', 'hex', 'circle', 'triangle'] as const; let shapeOptions = $derived([ ...builtinShapes, ...(selectedTray?.params.customShapes.map((s) => `custom:${s.name}`) ?? []) diff --git a/src/lib/models/counterTray.ts b/src/lib/models/counterTray.ts index 22c1801..2590913 100644 --- a/src/lib/models/counterTray.ts +++ b/src/lib/models/counterTray.ts @@ -2,7 +2,7 @@ import jscad from '@jscad/modeling'; const { cuboid, cylinder, roundedCuboid, sphere, circle } = jscad.primitives; const { subtract, union } = jscad.booleans; -const { translate, rotateY, rotateZ, scale, mirrorY } = jscad.transforms; +const { translate, rotateX, rotateY, rotateZ, scale, mirrorY } = jscad.transforms; const { hull } = jscad.hulls; const { vectorText } = jscad.text; const { path2 } = jscad.geometries; @@ -80,7 +80,7 @@ export const defaultParams: CounterTrayParams = { ['circle', 20], ['triangle', 10] ], - edgeLoadedStacks: [], + edgeLoadedStacks: [['triangle', 5, 'lengthwise']], customShapes: [], printBedSize: 256 }; @@ -282,14 +282,24 @@ export function getCounterPositions( }; // Standing height for edge-loaded counters - // For crosswise: uses longer dimension as height - const getStandingHeight = (shape: string): number => - Math.max(getCounterWidth(shape), getCounterLength(shape)); + // For triangles standing with point down, height is the triangle's geometric height + const getStandingHeight = (shape: string): number => { + if (shape === 'triangle') { + return triangleSideBase * (Math.sqrt(3) / 2); // Triangle geometric height + } + return Math.max(getCounterWidth(shape), getCounterLength(shape)); + }; // For lengthwise custom shapes: shorter dimension is height (longer runs along Y) const getStandingHeightLengthwise = (shape: string): number => { + if (shape === 'triangle') { + return triangleSideBase * (Math.sqrt(3) / 2); // Triangle geometric height + } const custom = getCustomShape(shape); if (custom) { + if (custom.baseShape === 'triangle') { + return custom.width * (Math.sqrt(3) / 2); // Custom triangle geometric height + } const [w, l] = getCustomEffectiveDims(custom); return Math.min(w, l); // Shorter side is height } @@ -298,8 +308,14 @@ export function getCounterPositions( // For crosswise custom shapes: shorter dimension is height (longer runs along X) const getStandingHeightCrosswise = (shape: string): number => { + if (shape === 'triangle') { + return triangleSideBase * (Math.sqrt(3) / 2); // Triangle geometric height + } const custom = getCustomShape(shape); if (custom) { + if (custom.baseShape === 'triangle') { + return custom.width * (Math.sqrt(3) / 2); // Custom triangle geometric height + } const [w, l] = getCustomEffectiveDims(custom); return Math.min(w, l); // Shorter side is height } @@ -869,13 +885,24 @@ export function createCounterTray( }; // Standing height for edge-loaded counters (actual counter size, not pocket size) - const getStandingHeight = (shape: string): number => - Math.max(getCounterWidth(shape), getCounterLength(shape)); + // For triangles standing with point down, height is the triangle's geometric height + const getStandingHeight = (shape: string): number => { + if (shape === 'triangle') { + return triangleSideBase * (Math.sqrt(3) / 2); // Triangle geometric height + } + return Math.max(getCounterWidth(shape), getCounterLength(shape)); + }; // For lengthwise custom shapes: shorter dimension is height (longer runs along Y) const getStandingHeightLengthwise = (shape: string): number => { + if (shape === 'triangle') { + return triangleSideBase * (Math.sqrt(3) / 2); // Triangle geometric height + } const custom = getCustomShape(shape); if (custom) { + if (custom.baseShape === 'triangle') { + return custom.width * (Math.sqrt(3) / 2); // Custom triangle geometric height + } const [w, l] = getCustomEffectiveDims(custom); return Math.min(w, l); // Shorter side is height } @@ -884,8 +911,14 @@ export function createCounterTray( // For crosswise custom shapes: shorter dimension is height (longer runs along X) const getStandingHeightCrosswise = (shape: string): number => { + if (shape === 'triangle') { + return triangleSideBase * (Math.sqrt(3) / 2); // Triangle geometric height + } const custom = getCustomShape(shape); if (custom) { + if (custom.baseShape === 'triangle') { + return custom.width * (Math.sqrt(3) / 2); // Custom triangle geometric height + } const [w, l] = getCustomEffectiveDims(custom); return Math.min(w, l); // Shorter side is height } @@ -1338,12 +1371,64 @@ export function createCounterTray( return translate([0, 0, trayHeight], halfSphere); }; - // Create edge-loaded pocket (rectangular slot for counters standing on edge) + // Create edge-loaded pocket (slot for counters standing on edge) + // For triangles: creates a triangular prism with point facing down const createEdgeLoadedPocket = (slot: EdgeLoadedSlot, xPos: number, yPos: number) => { const pocketHeight = slot.standingHeight; const pocketFloorZ = trayHeight - rimHeight - pocketHeight; const pocketCutHeight = pocketHeight + rimHeight + 1; + // Check if this is a triangle shape + const isTriangle = slot.shape === 'triangle'; + const custom = getCustomShape(slot.shape); + const isCustomTriangle = custom?.baseShape === 'triangle'; + + if (isTriangle || isCustomTriangle) { + // Get the triangle side length (with clearance) + const side = isTriangle ? triangleSide : (custom?.width ?? 15) + clearance * 2; + const triHeight = side * (Math.sqrt(3) / 2); + const r = triangleCornerRadius; + + // Create triangle with point facing DOWN (-Z) and flat side UP + // For edge-loaded: only round the bottom point, keep top corners sharp for clean cut + // slotDepth = triangle base (side length) + + // Bottom point gets rounded, top corners are sharp (tiny radius for hull) + const tinyR = 0.01; // Essentially a point + const insetYBottom = -triHeight / 2 + r * 2; // Point (bottom) - inset for rounding + const topY = triHeight / 2; // Top corners at full height (no inset) + + // Create 2D triangle: rounded bottom point, sharp top corners + const corners2D = [ + translate([0, insetYBottom, 0], circle({ radius: r, segments: 16 })), // Rounded bottom point + translate([-side / 2, topY, 0], circle({ radius: tinyR, segments: 4 })), // Sharp top left + translate([side / 2, topY, 0], circle({ radius: tinyR, segments: 4 })) // Sharp top right + ]; + const roundedTriangle2D = hull(...corners2D); + + // Extrude along the slot width (counter stack direction) + const extruded = extrudeLinear({ height: slot.slotWidth }, roundedTriangle2D); + + // The extruded triangle is in XY plane (point at -Y, base at +Y), extruded along Z + // We need: point at -Z (bottom), base at +Z (top), extrusion along X + // Transform: rotateX(PI/2) maps Y→Z (point -Y→-Z, base +Y→+Z), Z→-Y + // Then rotateZ(-PI/2) maps the extrusion from -Y to +X + const rotated = rotateZ( + -Math.PI / 2, + rotateX(Math.PI / 2, translate([0, 0, -slot.slotWidth / 2], extruded)) + ); + + // Position: base (top of triangle) at tray surface + 1mm to ensure clean cut + // After rotation, triangle center is at Z=0, so top is at +triHeight/2 + // We want top at trayHeight + 1, so center should be at trayHeight + 1 - triHeight/2 + const triCenterZ = trayHeight + 1 - triHeight / 2; + return translate( + [xPos + slot.slotWidth / 2, yPos + slot.slotDepth / 2, triCenterZ], + rotated + ); + } + + // Default: rectangular slot return translate( [xPos, yPos, pocketFloorZ], cuboid({ From 0701934294cc87d4042d819c3720cf674dce0c28 Mon Sep 17 00:00:00 2001 From: Dave Snider Date: Sun, 22 Feb 2026 09:28:14 -0500 Subject: [PATCH 3/5] circle edge --- src/lib/models/counterTray.ts | 36 +++++++++++++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/src/lib/models/counterTray.ts b/src/lib/models/counterTray.ts index 2590913..6141eec 100644 --- a/src/lib/models/counterTray.ts +++ b/src/lib/models/counterTray.ts @@ -1,6 +1,6 @@ import jscad from '@jscad/modeling'; -const { cuboid, cylinder, roundedCuboid, sphere, circle } = jscad.primitives; +const { cuboid, cylinder, roundedCuboid, sphere, circle, rectangle } = jscad.primitives; const { subtract, union } = jscad.booleans; const { translate, rotateX, rotateY, rotateZ, scale, mirrorY } = jscad.transforms; const { hull } = jscad.hulls; @@ -1422,8 +1422,40 @@ export function createCounterTray( // After rotation, triangle center is at Z=0, so top is at +triHeight/2 // We want top at trayHeight + 1, so center should be at trayHeight + 1 - triHeight/2 const triCenterZ = trayHeight + 1 - triHeight / 2; + return translate([xPos + slot.slotWidth / 2, yPos + slot.slotDepth / 2, triCenterZ], rotated); + } + + // Check if this is a circle shape + const isCircle = slot.shape === 'circle'; + const isCustomCircle = custom?.baseShape === 'circle'; + + if (isCircle || isCustomCircle) { + // Get the circle diameter (with clearance) + const diameter = isCircle ? circleDiameter : (custom?.width ?? 15) + clearance * 2; + const radius = diameter / 2; + + // Create shape with semicircular bottom and flat top for easy loading + // Union of: circle (for bottom half) + rectangle (for top half with straight sides) + const circle2D = circle({ radius, segments: 64 }); + const rect2D = rectangle({ size: [diameter, radius], center: [0, radius / 2] }); + const combinedShape2D = union(circle2D, rect2D); + + // Extrude along the slot width (counter stack direction) + const extruded = extrudeLinear({ height: slot.slotWidth }, combinedShape2D); + + // Rotate so: semicircle at bottom (-Z), flat top at +Z, extrusion along X + const rotated = rotateZ( + -Math.PI / 2, + rotateX(Math.PI / 2, translate([0, 0, -slot.slotWidth / 2], extruded)) + ); + + // Position: flat top at tray surface + 1mm to ensure clean cut + // Shape extends from -radius to +radius (diameter total), plus rectangle adds radius on top + // Total height = radius + radius = diameter, centered at radius/2 above circle center + // After rotation, top is at +radius (from rectangle top) + const shapeCenterZ = trayHeight + 1 - radius; return translate( - [xPos + slot.slotWidth / 2, yPos + slot.slotDepth / 2, triCenterZ], + [xPos + slot.slotWidth / 2, yPos + slot.slotDepth / 2, shapeCenterZ], rotated ); } From a5980fa659b64541a3f4e43633a16f5e4c36da7d Mon Sep 17 00:00:00 2001 From: Dave Snider Date: Sun, 22 Feb 2026 09:33:09 -0500 Subject: [PATCH 4/5] hex edge --- src/lib/models/counterTray.ts | 38 +++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/src/lib/models/counterTray.ts b/src/lib/models/counterTray.ts index 6141eec..a87b34d 100644 --- a/src/lib/models/counterTray.ts +++ b/src/lib/models/counterTray.ts @@ -1460,6 +1460,44 @@ export function createCounterTray( ); } + // Check if this is a hex shape + const isHex = slot.shape === 'hex'; + const isCustomHex = custom?.baseShape === 'hex'; + + if (isHex || isCustomHex) { + // Get the hex flat-to-flat dimension (with clearance) + const flatToFlat = isHex ? hexFlatToFlat : (custom?.width ?? 15) + clearance * 2; + const pointToPoint = flatToFlat / Math.cos(Math.PI / 6); + const radius = pointToPoint / 2; + + // Create shape with hex bottom (flat side down) and flat top for easy loading + // 2D hex with 6 segments, rotated π/6 so flat edge is at bottom + const hex2D = rotateZ(Math.PI / 6, circle({ radius, segments: 6 })); + // Rectangle covers top half (from center to top) + // Width must match hex width at the flat edge (flatToFlat, not pointToPoint) + const rectHeight = flatToFlat / 2; + const rect2D = rectangle({ size: [flatToFlat, rectHeight], center: [0, rectHeight / 2] }); + const combinedShape2D = union(hex2D, rect2D); + + // Extrude along the slot width (counter stack direction) + const extruded = extrudeLinear({ height: slot.slotWidth }, combinedShape2D); + + // Rotate so: hex bottom at -Z, flat top at +Z, extrusion along X + const rotated = rotateZ( + -Math.PI / 2, + rotateX(Math.PI / 2, translate([0, 0, -slot.slotWidth / 2], extruded)) + ); + + // Position: flat top at tray surface + 1mm to ensure clean cut + // Hex with flat-down orientation: bottom at -flatToFlat/2, top at +flatToFlat/2 + // Rectangle adds rectHeight on top, so total top is at flatToFlat/2 + const shapeCenterZ = trayHeight + 1 - flatToFlat / 2; + return translate( + [xPos + slot.slotWidth / 2, yPos + slot.slotDepth / 2, shapeCenterZ], + rotated + ); + } + // Default: rectangular slot return translate( [xPos, yPos, pocketFloorZ], From 1af251b3b146ddfa34d936fbfb6b1643173e8b64 Mon Sep 17 00:00:00 2001 From: Dave Snider Date: Sun, 22 Feb 2026 09:38:38 -0500 Subject: [PATCH 5/5] make sure crossway stacks work as well --- src/lib/models/counterTray.ts | 119 ++++++++++++++++++++-------------- 1 file changed, 69 insertions(+), 50 deletions(-) diff --git a/src/lib/models/counterTray.ts b/src/lib/models/counterTray.ts index a87b34d..8da0933 100644 --- a/src/lib/models/counterTray.ts +++ b/src/lib/models/counterTray.ts @@ -1391,7 +1391,6 @@ export function createCounterTray( // Create triangle with point facing DOWN (-Z) and flat side UP // For edge-loaded: only round the bottom point, keep top corners sharp for clean cut - // slotDepth = triangle base (side length) // Bottom point gets rounded, top corners are sharp (tiny radius for hull) const tinyR = 0.01; // Essentially a point @@ -1406,23 +1405,30 @@ export function createCounterTray( ]; const roundedTriangle2D = hull(...corners2D); - // Extrude along the slot width (counter stack direction) - const extruded = extrudeLinear({ height: slot.slotWidth }, roundedTriangle2D); - - // The extruded triangle is in XY plane (point at -Y, base at +Y), extruded along Z - // We need: point at -Z (bottom), base at +Z (top), extrusion along X - // Transform: rotateX(PI/2) maps Y→Z (point -Y→-Z, base +Y→+Z), Z→-Y - // Then rotateZ(-PI/2) maps the extrusion from -Y to +X - const rotated = rotateZ( - -Math.PI / 2, - rotateX(Math.PI / 2, translate([0, 0, -slot.slotWidth / 2], extruded)) - ); - - // Position: base (top of triangle) at tray surface + 1mm to ensure clean cut - // After rotation, triangle center is at Z=0, so top is at +triHeight/2 - // We want top at trayHeight + 1, so center should be at trayHeight + 1 - triHeight/2 const triCenterZ = trayHeight + 1 - triHeight / 2; - return translate([xPos + slot.slotWidth / 2, yPos + slot.slotDepth / 2, triCenterZ], rotated); + + if (slot.orientation === 'crosswise') { + // Crosswise: extrude along slotDepth (Y direction), counters stack front-to-back + const extruded = extrudeLinear({ height: slot.slotDepth }, roundedTriangle2D); + // Rotate so: point at -Z, base at +Z, extrusion along Y + const rotated = rotateX(Math.PI / 2, translate([0, 0, -slot.slotDepth / 2], extruded)); + return translate( + [xPos + slot.slotWidth / 2, yPos + slot.slotDepth / 2, triCenterZ], + rotated + ); + } else { + // Lengthwise: extrude along slotWidth (X direction), counters stack left-to-right + const extruded = extrudeLinear({ height: slot.slotWidth }, roundedTriangle2D); + // Rotate so: point at -Z, base at +Z, extrusion along X + const rotated = rotateZ( + -Math.PI / 2, + rotateX(Math.PI / 2, translate([0, 0, -slot.slotWidth / 2], extruded)) + ); + return translate( + [xPos + slot.slotWidth / 2, yPos + slot.slotDepth / 2, triCenterZ], + rotated + ); + } } // Check if this is a circle shape @@ -1440,24 +1446,30 @@ export function createCounterTray( const rect2D = rectangle({ size: [diameter, radius], center: [0, radius / 2] }); const combinedShape2D = union(circle2D, rect2D); - // Extrude along the slot width (counter stack direction) - const extruded = extrudeLinear({ height: slot.slotWidth }, combinedShape2D); - - // Rotate so: semicircle at bottom (-Z), flat top at +Z, extrusion along X - const rotated = rotateZ( - -Math.PI / 2, - rotateX(Math.PI / 2, translate([0, 0, -slot.slotWidth / 2], extruded)) - ); - - // Position: flat top at tray surface + 1mm to ensure clean cut - // Shape extends from -radius to +radius (diameter total), plus rectangle adds radius on top - // Total height = radius + radius = diameter, centered at radius/2 above circle center - // After rotation, top is at +radius (from rectangle top) const shapeCenterZ = trayHeight + 1 - radius; - return translate( - [xPos + slot.slotWidth / 2, yPos + slot.slotDepth / 2, shapeCenterZ], - rotated - ); + + if (slot.orientation === 'crosswise') { + // Crosswise: extrude along slotDepth (Y direction), counters stack front-to-back + const extruded = extrudeLinear({ height: slot.slotDepth }, combinedShape2D); + // Rotate so: semicircle at bottom (-Z), flat top at +Z, extrusion along Y + const rotated = rotateX(Math.PI / 2, translate([0, 0, -slot.slotDepth / 2], extruded)); + return translate( + [xPos + slot.slotWidth / 2, yPos + slot.slotDepth / 2, shapeCenterZ], + rotated + ); + } else { + // Lengthwise: extrude along slotWidth (X direction), counters stack left-to-right + const extruded = extrudeLinear({ height: slot.slotWidth }, combinedShape2D); + // Rotate so: semicircle at bottom (-Z), flat top at +Z, extrusion along X + const rotated = rotateZ( + -Math.PI / 2, + rotateX(Math.PI / 2, translate([0, 0, -slot.slotWidth / 2], extruded)) + ); + return translate( + [xPos + slot.slotWidth / 2, yPos + slot.slotDepth / 2, shapeCenterZ], + rotated + ); + } } // Check if this is a hex shape @@ -1479,23 +1491,30 @@ export function createCounterTray( const rect2D = rectangle({ size: [flatToFlat, rectHeight], center: [0, rectHeight / 2] }); const combinedShape2D = union(hex2D, rect2D); - // Extrude along the slot width (counter stack direction) - const extruded = extrudeLinear({ height: slot.slotWidth }, combinedShape2D); - - // Rotate so: hex bottom at -Z, flat top at +Z, extrusion along X - const rotated = rotateZ( - -Math.PI / 2, - rotateX(Math.PI / 2, translate([0, 0, -slot.slotWidth / 2], extruded)) - ); - - // Position: flat top at tray surface + 1mm to ensure clean cut - // Hex with flat-down orientation: bottom at -flatToFlat/2, top at +flatToFlat/2 - // Rectangle adds rectHeight on top, so total top is at flatToFlat/2 const shapeCenterZ = trayHeight + 1 - flatToFlat / 2; - return translate( - [xPos + slot.slotWidth / 2, yPos + slot.slotDepth / 2, shapeCenterZ], - rotated - ); + + if (slot.orientation === 'crosswise') { + // Crosswise: extrude along slotDepth (Y direction), counters stack front-to-back + const extruded = extrudeLinear({ height: slot.slotDepth }, combinedShape2D); + // Rotate so: hex bottom at -Z, flat top at +Z, extrusion along Y + const rotated = rotateX(Math.PI / 2, translate([0, 0, -slot.slotDepth / 2], extruded)); + return translate( + [xPos + slot.slotWidth / 2, yPos + slot.slotDepth / 2, shapeCenterZ], + rotated + ); + } else { + // Lengthwise: extrude along slotWidth (X direction), counters stack left-to-right + const extruded = extrudeLinear({ height: slot.slotWidth }, combinedShape2D); + // Rotate so: hex bottom at -Z, flat top at +Z, extrusion along X + const rotated = rotateZ( + -Math.PI / 2, + rotateX(Math.PI / 2, translate([0, 0, -slot.slotWidth / 2], extruded)) + ); + return translate( + [xPos + slot.slotWidth / 2, yPos + slot.slotDepth / 2, shapeCenterZ], + rotated + ); + } } // Default: rectangular slot