From 0a93755766bf5a30c232bf1fcadac7f4e62ddf60 Mon Sep 17 00:00:00 2001 From: Dave Snider Date: Mon, 29 Jun 2026 14:35:52 -0400 Subject: [PATCH 1/9] standees mostly working --- scripts/generate-geometry.ts | 24 +- src/lib/components/EditorPanel.svelte | 10 + src/lib/components/GlobalsPanel.svelte | 216 ++++++++++++- src/lib/components/NavigationMenu.svelte | 58 ++++ src/lib/components/TraysPanel.svelte | 61 +++- .../panels/StandeeTrayEditor.svelte | 212 ++++++++++++ src/lib/models/box.ts | 59 +++- src/lib/models/layer.ts | 36 ++- src/lib/models/lid.ts | 18 +- src/lib/models/standeeTray.ts | 303 ++++++++++++++++++ src/lib/stores/project.svelte.ts | 116 ++++++- src/lib/types/project.ts | 27 +- src/lib/utils/geometryWorker.ts | 3 +- src/lib/utils/storage.ts | 27 +- src/lib/workers/geometry.worker.ts | 78 +++-- 15 files changed, 1172 insertions(+), 76 deletions(-) create mode 100644 src/lib/components/panels/StandeeTrayEditor.svelte create mode 100644 src/lib/models/standeeTray.ts diff --git a/scripts/generate-geometry.ts b/scripts/generate-geometry.ts index 9ad7c6f..fe40a0e 100644 --- a/scripts/generate-geometry.ts +++ b/scripts/generate-geometry.ts @@ -20,8 +20,16 @@ import { createCardWellTray } from '../src/lib/models/cardWellTray.js'; import { createCounterTray } from '../src/lib/models/counterTray.js'; import { createCupTray } from '../src/lib/models/cupTray.js'; import { createBoxWithLidGrooves, createLid } from '../src/lib/models/lid.js'; +import { createStandeeTray } from '../src/lib/models/standeeTray.js'; import type { Box, Tray } from '../src/lib/types/project.js'; -import { isCardDividerTray, isCardTray, isCardWellTray, isCounterTray, isCupTray } from '../src/lib/types/project.js'; +import { + isCardDividerTray, + isCardTray, + isCardWellTray, + isCounterTray, + isCupTray, + isStandeeTray +} from '../src/lib/types/project.js'; // eslint-disable-next-line @typescript-eslint/no-explicit-any const generalize = (jscad.modifiers as any).generalize as ( @@ -81,6 +89,8 @@ async function main() { let trayGeom: Geom3 | null = null; if (isCupTray(looseTray)) { trayGeom = createCupTray(looseTray.params, looseTray.name, maxHeight, 0); + } else if (isStandeeTray(looseTray)) { + trayGeom = createStandeeTray(looseTray.params, project.standees ?? [], looseTray.name, maxHeight, 0); } else if (isCardWellTray(looseTray)) { trayGeom = createCardWellTray(looseTray.params, project.cardSizes, looseTray.name, maxHeight, 0); } else if (isCardTray(looseTray)) { @@ -125,7 +135,13 @@ async function main() { // Generate box geometry console.log('\nGenerating box geometry...'); try { - const boxGeom = createBoxWithLidGrooves(box); + const boxGeom = createBoxWithLidGrooves( + box, + project.cardSizes, + project.counterShapes, + undefined, + project.standees ?? [] + ); if (boxGeom) { const cleanedGeom = cleanGeometryForExport(boxGeom); const boxStl = stlSerializer.serialize({ binary: true }, cleanedGeom); @@ -142,7 +158,7 @@ async function main() { // Generate lid geometry console.log('Generating lid geometry...'); try { - const lidGeom = createLid(box); + const lidGeom = createLid(box, project.cardSizes, project.counterShapes, project.standees ?? []); if (lidGeom) { const cleanedGeom = cleanGeometryForExport(lidGeom); const lidStl = stlSerializer.serialize({ binary: true }, cleanedGeom); @@ -174,6 +190,8 @@ async function main() { let trayGeom: Geom3 | null = null; if (isCupTray(tray)) { trayGeom = createCupTray(tray.params, tray.name, maxHeight, 0); + } else if (isStandeeTray(tray)) { + trayGeom = createStandeeTray(tray.params, project.standees ?? [], tray.name, maxHeight, 0); } else if (isCardWellTray(tray)) { trayGeom = createCardWellTray(tray.params, project.cardSizes, tray.name, maxHeight, 0); } else if (isCardTray(tray)) { diff --git a/src/lib/components/EditorPanel.svelte b/src/lib/components/EditorPanel.svelte index 45552fe..03b25a2 100644 --- a/src/lib/components/EditorPanel.svelte +++ b/src/lib/components/EditorPanel.svelte @@ -16,6 +16,7 @@ updateCardDividerTrayParams, updateCardWellTrayParams, updateCupTrayParams, + updateStandeeTrayParams, updateLayer, getTrayLetterById, isCounterTray, @@ -23,6 +24,7 @@ isCardDividerTray, isCardWellTray, isCupTray, + isStandeeTray, getGlobalSettings, updateGlobalSettings, deleteLayer, @@ -42,6 +44,7 @@ import type { CardDividerTrayParams } from '$lib/models/cardDividerTray'; import type { CardWellTrayParams } from '$lib/models/cardWellTray'; import type { CupTrayParams } from '$lib/models/cupTray'; + import type { StandeeTrayParams } from '$lib/models/standeeTray'; import { countCups } from '$lib/types/cupLayout'; import { countCells } from '$lib/types/cardWellLayout'; import { layoutEditorState } from '$lib/stores/layoutEditor.svelte'; @@ -130,6 +133,12 @@ } } + function handleStandeeParamsChange(newParams: StandeeTrayParams) { + if (selectedTray && isStandeeTray(selectedTray)) { + updateStandeeTrayParams(selectedTray.id, newParams); + } + } + // Get tray stats for display function getTrayStats(tray: Tray): { stacks: number; @@ -401,6 +410,7 @@ onUpdateCardDividerParams={handleCardDividerParamsChange} onUpdateCardWellParams={handleCardWellParamsChange} onUpdateCupParams={handleCupParamsChange} + onUpdateStandeeParams={handleStandeeParamsChange} hideList={true} /> {:else} diff --git a/src/lib/components/GlobalsPanel.svelte b/src/lib/components/GlobalsPanel.svelte index 6da0e5a..3ddc7d1 100644 --- a/src/lib/components/GlobalsPanel.svelte +++ b/src/lib/components/GlobalsPanel.svelte @@ -13,21 +13,34 @@ ConfirmActionButton, Text } from '@tableslayer/ui'; - import { IconSquare, IconCircle, IconHexagon, IconTriangle, IconRectangle, IconCards } from '@tabler/icons-svelte'; - import type { CounterShape, CounterBaseShape, CardSize } from '$lib/types/project'; + import { + IconSquare, + IconCircle, + IconHexagon, + IconTriangle, + IconRectangle, + IconCards, + IconUser + } from '@tabler/icons-svelte'; + import type { CounterShape, CounterBaseShape, CardSize, Standee } from '$lib/types/project'; import { getProject, isCounterTray, isCardDrawTray, isCardDividerTray, + isStandeeTray, getCounterShapes, getCardSizes, + getStandees, addCounterShape, updateCounterShape, deleteCounterShape, addCardSize, updateCardSize, deleteCardSize, + addStandee, + updateStandee, + deleteStandee, DEFAULT_COUNTER_THICKNESS } from '$lib/stores/project.svelte'; @@ -42,10 +55,13 @@ let expandedIndex: number | null = $state(null); // Track which card size is expanded (null = none) let expandedCardIndex: number | null = $state(null); + // Track which standee is expanded (null = none) + let expandedStandeeIndex: number | null = $state(null); - // Get shapes and card sizes from project level + // Get shapes, card sizes and standees from project level let counterShapes = $derived(getCounterShapes()); let cardSizes = $derived(getCardSizes()); + let standees = $derived(getStandees()); // Get the shape icon component for a base shape function getShapeIcon(baseShape: CounterBaseShape) { @@ -242,6 +258,54 @@ deleteCardSize(cardSizeId); expandedCardIndex = null; } + + // Standee handlers - using project-level store functions + function handleAddStandee() { + const newName = `Custom Standee ${standees.length + 1}`; + const newStandee = addStandee({ + name: newName, + baseRadius: 9, + baseThickness: 3, + standeeHeight: 40, + standeeWidth: 25, + standeeThickness: 1.5 + }); + const newIndex = getStandees().findIndex((s) => s.id === newStandee.id); + expandedStandeeIndex = newIndex; + } + + function handleUpdateStandee(standeeId: string, field: keyof Standee, value: string | number) { + if (field === 'name') { + const newName = value as string; + // Don't allow duplicate names + if (standees.some((s) => s.id !== standeeId && s.name === newName)) { + return; + } + } + updateStandee(standeeId, { [field]: value }); + } + + // Count standee trays using a given standee + function countTraysUsingStandee(standeeId: string): number { + const project = getProject(); + let count = 0; + for (const layer of project.layers) { + for (const box of layer.boxes) { + for (const tray of box.trays) { + if (isStandeeTray(tray) && tray.params.standeeId === standeeId) count++; + } + } + for (const tray of layer.looseTrays) { + if (isStandeeTray(tray) && tray.params.standeeId === standeeId) count++; + } + } + return count; + } + + function handleRemoveStandee(standeeId: string) { + deleteStandee(standeeId); + expandedStandeeIndex = null; + }
@@ -600,6 +664,152 @@
+ +
+

Standees

+ +
+ {#each standees as standee, index (standee.id)} + {@const isExpanded = expandedStandeeIndex === index} + {#if isExpanded} + + +
+
+ + {#snippet input({ inputProps })} + handleUpdateStandee(standee.id, 'name', e.currentTarget.value)} + placeholder="Name" + /> + {/snippet} + + + {#snippet input({ inputProps })} + handleUpdateStandee(standee.id, 'baseRadius', parseFloat(e.currentTarget.value))} + /> + {/snippet} + {#snippet end()}mm{/snippet} + + + {#snippet input({ inputProps })} + + handleUpdateStandee(standee.id, 'baseThickness', parseFloat(e.currentTarget.value))} + /> + {/snippet} + {#snippet end()}mm{/snippet} + + + {#snippet input({ inputProps })} + + handleUpdateStandee(standee.id, 'standeeHeight', parseFloat(e.currentTarget.value))} + /> + {/snippet} + {#snippet end()}mm{/snippet} + + + {#snippet input({ inputProps })} + + handleUpdateStandee(standee.id, 'standeeWidth', parseFloat(e.currentTarget.value))} + /> + {/snippet} + {#snippet end()}mm{/snippet} + + + {#snippet input({ inputProps })} + + handleUpdateStandee(standee.id, 'standeeThickness', parseFloat(e.currentTarget.value))} + /> + {/snippet} + {#snippet end()}mm{/snippet} + +
+
+
+ {@const trayCount = countTraysUsingStandee(standee.id)} +
+ + handleRemoveStandee(standee.id)} actionButtonText="Delete standee"> + {#snippet trigger({ triggerProps })} + + {/snippet} + {#snippet actionMessage()} +
+ Warning + + {#if trayCount > 0} + + Deleting "{standee.name}" will affect + + {trayCount} + standee tray{trayCount === 1 ? '' : 's'} + using it. + + {:else} + Delete the "{standee.name}" standee? + {/if} + +
+ {/snippet} +
+
+
+ {:else} +
+ + +
+ {/if} + {/each} +
+ + + New standee +
+ +
diff --git a/src/lib/models/box.ts b/src/lib/models/box.ts index 2314211..32016e7 100644 --- a/src/lib/models/box.ts +++ b/src/lib/models/box.ts @@ -1,5 +1,5 @@ -import type { Box, CardSize, CounterShape, Tray } from '$lib/types/project'; -import { isCardDividerTray, isCardTray, isCardWellTray, isCupTray } from '$lib/types/project'; +import type { Box, CardSize, CounterShape, Standee, Tray } from '$lib/types/project'; +import { isCardDividerTray, isCardTray, isCardWellTray, isCupTray, isStandeeTray } from '$lib/types/project'; import { packItems, stackItemsVertically, type PackingItem } from '$lib/utils/binPacking'; import jscad from '@jscad/modeling'; import type { Geom3 } from '@jscad/modeling/src/geometries/types'; @@ -9,6 +9,7 @@ import { getCardWellTrayDimensions } from './cardWellTray'; import type { CounterTrayParams } from './counterTray'; import { getCupTrayDimensions } from './cupTray'; import { createHoneycombUnion, defaultHoneycombParams } from './honeycomb'; +import { getStandeeTrayDimensions } from './standeeTray'; const { cylinder } = jscad.primitives; const { subtract } = jscad.booleans; @@ -51,7 +52,8 @@ export interface TraySpacerInfo { export function getTrayDimensionsForTray( tray: Tray, cardSizes: CardSize[] = [], - counterShapes: CounterShape[] = [] + counterShapes: CounterShape[] = [], + standees: Standee[] = [] ): TrayDimensions { if (isCupTray(tray)) { return getCupTrayDimensions(tray.params); @@ -65,6 +67,9 @@ export function getTrayDimensionsForTray( if (isCardTray(tray)) { return getCardDrawTrayDimensions(tray.params, cardSizes); } + if (isStandeeTray(tray)) { + return getStandeeTrayDimensions(tray.params, standees); + } // Default to counter tray return getCounterTrayDimensions(tray.params, counterShapes); } @@ -473,6 +478,7 @@ export function arrangeTrays( tolerance?: number; cardSizes?: CardSize[]; counterShapes?: CounterShape[]; + standees?: Standee[]; manualLayout?: ManualTrayPlacement[]; printBedSize?: number; // Legacy - use gameContainerWidth/gameContainerDepth gameContainerWidth?: number; @@ -488,7 +494,12 @@ export function arrangeTrays( const tray = trays.find((t) => t.id === manual.trayId); if (!tray) continue; - const dims = getTrayDimensionsForTray(tray, options?.cardSizes ?? [], options?.counterShapes ?? []); + const dims = getTrayDimensionsForTray( + tray, + options?.cardSizes ?? [], + options?.counterShapes ?? [], + options?.standees ?? [] + ); // Apply rotation: 90° and 270° swap width/depth const swapDims = manual.rotation === 90 || manual.rotation === 270; const effectiveDims: TrayDimensions = swapDims @@ -542,6 +553,7 @@ function arrangeTraysAuto( tolerance?: number; cardSizes?: CardSize[]; counterShapes?: CounterShape[]; + standees?: Standee[]; printBedSize?: number; // Legacy - use gameContainerWidth/gameContainerDepth gameContainerWidth?: number; gameContainerDepth?: number; @@ -551,7 +563,12 @@ function arrangeTraysAuto( // Get dimensions for each tray const packingItems: PackingItem[] = trays.map((tray) => { - const dims = getTrayDimensionsForTray(tray, options?.cardSizes ?? [], options?.counterShapes ?? []); + const dims = getTrayDimensionsForTray( + tray, + options?.cardSizes ?? [], + options?.counterShapes ?? [], + options?.standees ?? [] + ); return { data: { tray, height: dims.height }, width: dims.width, @@ -689,7 +706,12 @@ function createRoundedBox( const POKE_HOLE_DIAMETER = 15; // Create box geometry with rounded corners -export function createBox(box: Box, cardSizes: CardSize[] = [], counterShapes: CounterShape[] = []): Geom3 | null { +export function createBox( + box: Box, + cardSizes: CardSize[] = [], + counterShapes: CounterShape[] = [], + standees: Standee[] = [] +): Geom3 | null { if (box.trays.length === 0) return null; const placements = arrangeTrays(box.trays, { @@ -698,6 +720,7 @@ export function createBox(box: Box, cardSizes: CardSize[] = [], counterShapes: C tolerance: box.tolerance, cardSizes, counterShapes, + standees, manualLayout: box.manualLayout }); const interior = getBoxInteriorDimensions(placements, box.tolerance); @@ -768,7 +791,8 @@ export function createBox(box: Box, cardSizes: CardSize[] = [], counterShapes: C export function calculateMinimumBoxDimensions( box: Box, cardSizes: CardSize[] = [], - counterShapes: CounterShape[] = [] + counterShapes: CounterShape[] = [], + standees: Standee[] = [] ): BoxMinimumDimensions { if (box.trays.length === 0) { return { minWidth: 0, minDepth: 0, minHeight: 0 }; @@ -780,6 +804,7 @@ export function calculateMinimumBoxDimensions( tolerance: box.tolerance, cardSizes, counterShapes, + standees, manualLayout: box.manualLayout }); const interior = getBoxInteriorDimensions(placements, box.tolerance); @@ -795,7 +820,8 @@ export function calculateMinimumBoxDimensions( export function validateCustomDimensions( box: Box, cardSizes: CardSize[] = [], - counterShapes: CounterShape[] = [] + counterShapes: CounterShape[] = [], + standees: Standee[] = [] ): ValidationResult { const errors: string[] = []; @@ -805,7 +831,7 @@ export function validateCustomDimensions( const interiorWidth = box.customWidth - box.wallThickness * 2 - box.tolerance * 2; for (const tray of box.trays) { - const dims = getTrayDimensionsForTray(tray, cardSizes, counterShapes); + const dims = getTrayDimensionsForTray(tray, cardSizes, counterShapes, standees); // Check both orientations - tray can be rotated to fit const minWidth = Math.min(dims.width, dims.depth); @@ -822,7 +848,7 @@ export function validateCustomDimensions( const interiorDepth = box.customDepth - box.wallThickness * 2 - box.tolerance * 2; for (const tray of box.trays) { - const dims = getTrayDimensionsForTray(tray, cardSizes, counterShapes); + const dims = getTrayDimensionsForTray(tray, cardSizes, counterShapes, standees); // Check both orientations const minDepth = Math.min(dims.width, dims.depth); @@ -835,7 +861,7 @@ export function validateCustomDimensions( } // Calculate minimums based on actual arrangement (which now respects customWidth) - const minimums = calculateMinimumBoxDimensions(box, cardSizes, counterShapes); + const minimums = calculateMinimumBoxDimensions(box, cardSizes, counterShapes, standees); // Only validate customDepth if it's explicitly set (width can grow to accommodate) // The arrangement algorithm handles width constraints by using more rows @@ -863,7 +889,8 @@ export function validateCustomDimensions( export function calculateTraySpacers( box: Box, cardSizes: CardSize[] = [], - counterShapes: CounterShape[] = [] + counterShapes: CounterShape[] = [], + standees: Standee[] = [] ): TraySpacerInfo[] { if (box.trays.length === 0) return []; @@ -873,9 +900,10 @@ export function calculateTraySpacers( tolerance: box.tolerance, cardSizes, counterShapes, + standees, manualLayout: box.manualLayout }); - const minimums = calculateMinimumBoxDimensions(box, cardSizes, counterShapes); + const minimums = calculateMinimumBoxDimensions(box, cardSizes, counterShapes, standees); // Target exterior height (custom or auto) const targetExteriorHeight = box.customBoxHeight ?? minimums.minHeight; @@ -907,13 +935,14 @@ export function getBoxDimensions(box: Box): TrayDimensions | null { export function getBoxExteriorDimensions( box: Box, cardSizes: CardSize[] = [], - counterShapes: CounterShape[] = [] + counterShapes: CounterShape[] = [], + standees: Standee[] = [] ): TrayDimensions { if (box.trays.length === 0) { return { width: 0, depth: 0, height: 0 }; } - const minimums = calculateMinimumBoxDimensions(box, cardSizes, counterShapes); + const minimums = calculateMinimumBoxDimensions(box, cardSizes, counterShapes, standees); const lidHeight = getLidHeight(box); return { diff --git a/src/lib/models/layer.ts b/src/lib/models/layer.ts index be80383..bd5cb16 100644 --- a/src/lib/models/layer.ts +++ b/src/lib/models/layer.ts @@ -10,6 +10,7 @@ import type { Layer, ManualBoxPlacement, ManualLooseTrayPlacement, + Standee, Tray } from '$lib/types/project'; import { packItems, stackItemsVertically, type PackingItem } from '$lib/utils/binPacking'; @@ -54,19 +55,20 @@ export function calculateLayerHeight( options: { cardSizes: CardSize[]; counterShapes: CounterShape[]; + standees?: Standee[]; } ): number { - const { cardSizes, counterShapes } = options; + const { cardSizes, counterShapes, standees = [] } = options; // Get all box exterior heights const boxHeights = layer.boxes.map((box) => { - const dims = getBoxExteriorDimensions(box, cardSizes, counterShapes); + const dims = getBoxExteriorDimensions(box, cardSizes, counterShapes, standees); return dims.height; }); // Get all loose tray content heights (minimum required height) const looseTrayHeights = layer.looseTrays.map((tray) => { - const dims = getTrayDimensionsForTray(tray, cardSizes, counterShapes); + const dims = getTrayDimensionsForTray(tray, cardSizes, counterShapes, standees); return dims.height; }); @@ -78,8 +80,13 @@ export function calculateLayerHeight( * Get dimensions of a box's exterior (including walls, floor, lid) * This is used for layer-level arrangement */ -export function getBoxDimensions(box: Box, cardSizes: CardSize[], counterShapes: CounterShape[]): BoxDimensions { - return getBoxExteriorDimensions(box, cardSizes, counterShapes); +export function getBoxDimensions( + box: Box, + cardSizes: CardSize[], + counterShapes: CounterShape[], + standees: Standee[] = [] +): BoxDimensions { + return getBoxExteriorDimensions(box, cardSizes, counterShapes, standees); } /** @@ -93,13 +100,14 @@ export function arrangeLayerContents( gameContainerDepth: number; cardSizes: CardSize[]; counterShapes: CounterShape[]; + standees?: Standee[]; gap?: number; } ): LayerArrangement { - const { cardSizes, counterShapes } = options; + const { cardSizes, counterShapes, standees = [] } = options; // Calculate layer height first - const layerHeight = calculateLayerHeight(layer, { cardSizes, counterShapes }); + const layerHeight = calculateLayerHeight(layer, { cardSizes, counterShapes, standees }); // If manual layout exists, use it if (layer.manualLayout) { @@ -121,9 +129,10 @@ function arrangeLayerManual( gameContainerDepth: number; cardSizes: CardSize[]; counterShapes: CounterShape[]; + standees?: Standee[]; } ): LayerArrangement { - const { cardSizes, counterShapes } = options; + const { cardSizes, counterShapes, standees = [] } = options; const boxPlacements: BoxPlacement[] = []; const looseTrayPlacements: LooseTrayPlacement[] = []; @@ -133,7 +142,7 @@ function arrangeLayerManual( const box = layer.boxes.find((b) => b.id === manual.boxId); if (!box) continue; - const dims = getBoxDimensions(box, cardSizes, counterShapes); + const dims = getBoxDimensions(box, cardSizes, counterShapes, standees); // Apply rotation: 90° and 270° swap width/depth // Use layerHeight for consistent layer stacking const swapDims = manual.rotation === 90 || manual.rotation === 270; @@ -157,7 +166,7 @@ function arrangeLayerManual( const tray = layer.looseTrays.find((t) => t.id === manual.trayId); if (!tray) continue; - const dims = getTrayDimensionsForTray(tray, cardSizes, counterShapes); + const dims = getTrayDimensionsForTray(tray, cardSizes, counterShapes, standees); // Apply rotation: 90° and 270° swap width/depth const swapDims = manual.rotation === 90 || manual.rotation === 270; const effectiveDims = swapDims @@ -255,9 +264,10 @@ function arrangeLayerAuto( gameContainerDepth: number; cardSizes: CardSize[]; counterShapes: CounterShape[]; + standees?: Standee[]; } ): LayerArrangement { - const { gameContainerWidth, gameContainerDepth, cardSizes, counterShapes } = options; + const { gameContainerWidth, gameContainerDepth, cardSizes, counterShapes, standees = [] } = options; if (layer.boxes.length === 0 && layer.looseTrays.length === 0) { return { @@ -274,7 +284,7 @@ function arrangeLayerAuto( // Add boxes for (const box of layer.boxes) { - const dims = getBoxDimensions(box, cardSizes, counterShapes); + const dims = getBoxDimensions(box, cardSizes, counterShapes, standees); packingItems.push({ data: { itemType: 'box', item: box, originalWidth: dims.width, originalDepth: dims.depth }, width: dims.width, @@ -284,7 +294,7 @@ function arrangeLayerAuto( // Add loose trays for (const tray of layer.looseTrays) { - const dims = getTrayDimensionsForTray(tray, cardSizes, counterShapes); + const dims = getTrayDimensionsForTray(tray, cardSizes, counterShapes, standees); packingItems.push({ data: { itemType: 'looseTray', item: tray, originalWidth: dims.width, originalDepth: dims.depth }, width: dims.width, diff --git a/src/lib/models/lid.ts b/src/lib/models/lid.ts index 947a270..79de0f5 100644 --- a/src/lib/models/lid.ts +++ b/src/lib/models/lid.ts @@ -1,4 +1,4 @@ -import type { Box, CardSize, CounterShape, LidParams } from '$lib/types/project'; +import type { Box, CardSize, CounterShape, LidParams, Standee } from '$lib/types/project'; import jscad from '@jscad/modeling'; import type { Geom3 } from '@jscad/modeling/src/geometries/types'; import { arrangeTrays, calculateMinimumBoxDimensions, getBoxInteriorDimensions } from './box'; @@ -104,7 +104,8 @@ export function createBoxWithLidGrooves( box: Box, cardSizes: CardSize[] = [], counterShapes: CounterShape[] = [], - targetExteriorHeight?: number + targetExteriorHeight?: number, + standees: Standee[] = [] ): Geom3 | null { if (box.trays.length === 0) return null; @@ -114,6 +115,7 @@ export function createBoxWithLidGrooves( tolerance: box.tolerance, cardSizes, counterShapes, + standees, manualLayout: box.manualLayout }); const interior = getBoxInteriorDimensions(placements, box.tolerance); @@ -128,7 +130,7 @@ export function createBoxWithLidGrooves( const recessDepth = wall; // How deep the lid lip goes // Calculate minimum (auto) dimensions - const minimums = calculateMinimumBoxDimensions(box, cardSizes, counterShapes); + const minimums = calculateMinimumBoxDimensions(box, cardSizes, counterShapes, standees); // Box exterior dimensions (use custom if set, otherwise auto) // If targetExteriorHeight is provided (for layer unification), use it minus lid VISIBLE height for box height @@ -971,7 +973,12 @@ export function createBoxWithLidGrooves( * |___| |___| * (open here) */ -export function createLid(box: Box, cardSizes: CardSize[] = [], counterShapes: CounterShape[] = []): Geom3 | null { +export function createLid( + box: Box, + cardSizes: CardSize[] = [], + counterShapes: CounterShape[] = [], + standees: Standee[] = [] +): Geom3 | null { if (box.trays.length === 0) return null; const placements = arrangeTrays(box.trays, { @@ -980,6 +987,7 @@ export function createLid(box: Box, cardSizes: CardSize[] = [], counterShapes: C tolerance: box.tolerance, cardSizes, counterShapes, + standees, manualLayout: box.manualLayout }); const interior = getBoxInteriorDimensions(placements, box.tolerance); @@ -1005,7 +1013,7 @@ export function createLid(box: Box, cardSizes: CardSize[] = [], counterShapes: C const rampLengthOut = box.lidParams?.rampLengthOut ?? 1.5; // Calculate minimum (auto) dimensions - const minimums = calculateMinimumBoxDimensions(box, cardSizes, counterShapes); + const minimums = calculateMinimumBoxDimensions(box, cardSizes, counterShapes, standees); // Lid exterior matches box exterior (uses custom dimensions if set) const extWidth = box.customWidth ?? minimums.minWidth; diff --git a/src/lib/models/standeeTray.ts b/src/lib/models/standeeTray.ts new file mode 100644 index 0000000..857bd0a --- /dev/null +++ b/src/lib/models/standeeTray.ts @@ -0,0 +1,303 @@ +import jscad from '@jscad/modeling'; +import type { Geom3 } from '@jscad/modeling/src/geometries/types'; + +const { cuboid } = jscad.primitives; +const { subtract, union } = jscad.booleans; +const { translate, rotateX, scale, mirrorY } = jscad.transforms; +const { vectorText } = jscad.text; +const { path2 } = jscad.geometries; +const { expand } = jscad.expansions; +const { extrudeLinear } = jscad.extrusions; + +// Import types from project +import type { Standee } from '$lib/types/project'; + +// Angle of the slot fan, in degrees. Slots on the two inner walls tilt in +// opposite directions by this amount so opposing standees interlock and resist +// being jostled out. +const SLOT_ANGLE_DEG = 20; +const SLOT_ANGLE = (SLOT_ANGLE_DEG * Math.PI) / 180; + +export interface StandeeTrayParams { + standeeId: string; // Reference to a Standee by ID + count: number; // Total number of standees stored (split across the two walls) + wallThickness: number; // Outer wall + floor wall thickness + innerWallThickness: number; // Thickness of the two slotted inner walls + floorThickness: number; + clearance: number; // Tolerance around standees + rimHeight: number; // Extra height above the tallest content +} + +export const defaultStandeeTrayParams: StandeeTrayParams = { + standeeId: '', // Filled in with the first available standee at creation time + count: 12, + wallThickness: 2.0, + innerWallThickness: 2.0, + floorThickness: 2.0, + clearance: 0.5, + rimHeight: 2.0 +}; + +// Helper to get a standee from the global standees by ID. +// Falls back to matching by name, then first available, then a minimal default. +export function getStandee(standeeId: string, standees: Standee[]): Standee { + let standee = standees.find((s) => s.id === standeeId); + if (standee) return standee; + + standee = standees.find((s) => s.name === standeeId); + if (standee) { + console.warn(`Standee ID "${standeeId}" not found, matched by name instead`); + return standee; + } + + if (standees.length > 0) { + console.warn(`Standee "${standeeId}" not found by ID or name, using first available standee`); + return standees[0]; + } + + return { + id: 'default', + name: 'Default', + baseRadius: 9, + baseThickness: 3, + standeeHeight: 40, + standeeWidth: 25, + standeeThickness: 1.5 + }; +} + +// Shared layout math so dimensions and geometry stay in sync. +interface StandeeLayout { + trayWidth: number; + trayDepth: number; + trayHeight: number; + // X positions (front face of each inner wall) + leftWallX: number; + rightWallX: number; + innerWallThickness: number; + // Slot rows + leftRowCount: number; + rightRowCount: number; + firstSlotY: number; // Y of the first slot center + slotPitch: number; + staggerY: number; // Y offset applied to the right wall's slots + slotWidth: number; + outerCavityWidth: number; + middleCavityWidth: number; + baseRadius: number; + baseDiameter: number; +} + +function computeLayout( + params: StandeeTrayParams, + standee: Standee, + targetHeight?: number, + floorSpacerHeight?: number +): StandeeLayout { + const { wallThickness, innerWallThickness, floorThickness, clearance, rimHeight } = params; + const count = Math.max(0, Math.floor(params.count)); + + const { baseRadius, standeeHeight, standeeWidth } = standee; + const baseDiameter = baseRadius * 2; + + // Slots split across the two walls (left gets the extra one for odd counts). + const leftRowCount = Math.ceil(count / 2); + const rightRowCount = Math.floor(count / 2); + const maxRowCount = Math.max(leftRowCount, rightRowCount, 1); + + // Along depth (Y): one slot per standee, spaced by base diameter + 1mm. + const slotPitch = baseDiameter + 1; + const slotWidth = standee.standeeThickness + 1; + const staggerY = slotPitch / 2; // right wall offset so figures interleave + + // Margin so the base disc (radius baseRadius) clears the front/back walls. + const endMargin = wallThickness + baseRadius + clearance; + const firstSlotY = endMargin; + const lastSlotY = endMargin + (maxRowCount - 1) * slotPitch + staggerY; + const trayDepth = lastSlotY + baseRadius + clearance + wallThickness; + + // Across width (X): outer cavity holds the base; drop-in clearance requires the + // inner wall to sit at least baseRadius + 1mm from the outer wall. + const outerCavityWidth = baseRadius + 1 + clearance; + + // Horizontal reach of the figure (tilted SLOT_ANGLE off the wall normal). + const figureXReach = standeeHeight * Math.cos(SLOT_ANGLE); + + const leftWallX = wallThickness + outerCavityWidth; + // Left figure tip lands inside the middle cavity, just short of the right wall. + const leftTipX = wallThickness + figureXReach; + const rightWallX = Math.max(leftTipX + clearance, leftWallX + innerWallThickness + clearance); + const middleCavityWidth = rightWallX - (leftWallX + innerWallThickness); + const trayWidth = rightWallX + innerWallThickness + outerCavityWidth + wallThickness; + + // Height: the vertical base disc (baseDiameter tall, resting on the floor) and the + // figure (standeeWidth tall, centered on the disc center) both stand vertically. + const spacerHeight = floorSpacerHeight ?? 0; + const baseCenterZ = floorThickness + baseRadius; + const contentTopZ = Math.max(floorThickness + baseDiameter, baseCenterZ + standeeWidth / 2); + let trayHeight = contentTopZ + rimHeight + spacerHeight; + if (targetHeight && targetHeight > trayHeight) { + trayHeight = targetHeight; + } + + return { + trayWidth, + trayDepth, + trayHeight, + leftWallX, + rightWallX, + innerWallThickness, + leftRowCount, + rightRowCount, + firstSlotY, + slotPitch, + staggerY, + slotWidth, + outerCavityWidth, + middleCavityWidth, + baseRadius, + baseDiameter + }; +} + +export function getStandeeTrayDimensions( + params: StandeeTrayParams, + standees: Standee[] +): { + width: number; + depth: number; + height: number; +} { + const standee = getStandee(params.standeeId, standees); + const layout = computeLayout(params, standee); + return { width: layout.trayWidth, depth: layout.trayDepth, height: layout.trayHeight }; +} + +export function createStandeeTray( + params: StandeeTrayParams, + standees: Standee[], + trayName?: string, + targetHeight?: number, + floorSpacerHeight?: number, + showEmboss: boolean = true +): Geom3 { + const { wallThickness, innerWallThickness, floorThickness } = params; + const standee = getStandee(params.standeeId, standees); + const layout = computeLayout(params, standee, targetHeight, floorSpacerHeight); + + const { trayWidth, trayDepth, trayHeight, leftWallX, rightWallX } = layout; + const wallHeight = trayHeight - floorThickness; + + // === OPEN-TOP BOX (floor + 4 outer walls) === + const outerBox = translate( + [trayWidth / 2, trayDepth / 2, trayHeight / 2], + cuboid({ size: [trayWidth, trayDepth, trayHeight] }) + ); + const innerCavity = translate( + [trayWidth / 2, trayDepth / 2, floorThickness + wallHeight / 2 + 0.1], + cuboid({ size: [trayWidth - wallThickness * 2, trayDepth - wallThickness * 2, wallHeight + 0.2] }) + ); + let tray = subtract(outerBox, innerCavity); + + // === TWO INNER WALLS spanning the depth === + const innerCavityDepth = trayDepth - wallThickness * 2; + const makeInnerWall = (frontFaceX: number): Geom3 => + translate( + [frontFaceX + innerWallThickness / 2, trayDepth / 2, floorThickness + wallHeight / 2], + cuboid({ size: [innerWallThickness, innerCavityDepth, wallHeight] }) + ); + tray = union(tray, makeInnerWall(leftWallX), makeInnerWall(rightWallX)); + + // === ANGLED SLOTS cut into each inner wall === + // Slot: a channel that pierces the wall across its thickness (X) so the standee figure can pass + // through, narrow along the wall (Y = figure thickness + clearance). It is rotated SLOT_ANGLE + // about the X axis so it runs diagonally on the wall's face — from the bottom of the wall up to + // the top while moving along the wall's length (Y). This tilts the standee toward an end wall so + // it resists falling out, while its base still sits flush at the side. The two walls tilt in + // opposite directions and the rows are staggered so opposing standees interleave. The slot is + // tall (open at the top so the standee drops in) and raised so it never cuts through the floor. + const slotHeight = wallHeight + 6; + const slotThrough = innerWallThickness * 4; // pierces the full wall thickness + // Raise the slot so its lowest (tilted) corner sits just above the floor. + const centerZ = + floorThickness + 0.5 + (slotHeight / 2) * Math.cos(SLOT_ANGLE) + (layout.slotWidth / 2) * Math.sin(SLOT_ANGLE); + const makeSlot = (centerX: number, centerY: number, angle: number): Geom3 => + translate( + [centerX, centerY, centerZ], + rotateX(angle, cuboid({ size: [slotThrough, layout.slotWidth, slotHeight] })) + ); + + const slots: Geom3[] = []; + for (let i = 0; i < layout.leftRowCount; i++) { + const y = layout.firstSlotY + i * layout.slotPitch; + slots.push(makeSlot(leftWallX + innerWallThickness / 2, y, SLOT_ANGLE)); + } + for (let i = 0; i < layout.rightRowCount; i++) { + const y = layout.firstSlotY + layout.staggerY + i * layout.slotPitch; + slots.push(makeSlot(rightWallX + innerWallThickness / 2, y, -SLOT_ANGLE)); + } + if (slots.length > 0) { + tray = subtract(tray, ...slots); + } + + // === EMBOSS TRAY NAME ON BOTTOM === + if (showEmboss && trayName && trayName.trim().length > 0) { + const textDepth = 0.6; + const strokeWidth = 1.2; + const textHeightParam = 6; + const margin = wallThickness * 2; + + const textSegments = vectorText({ height: textHeightParam, align: 'center' }, trayName.trim().toUpperCase()); + + if (textSegments.length > 0) { + const textShapes: ReturnType[] = []; + for (const segment of textSegments) { + if (segment.length >= 2) { + const pathObj = path2.fromPoints({ closed: false }, segment); + const expanded = expand({ delta: strokeWidth / 2, corners: 'round', segments: 32 }, pathObj); + const extruded = extrudeLinear({ height: textDepth + 0.1 }, expanded); + textShapes.push(extruded); + } + } + + if (textShapes.length > 0) { + let minX = Infinity, + maxX = -Infinity; + let minY = Infinity, + maxY = -Infinity; + for (const segment of textSegments) { + for (const point of segment) { + minX = Math.min(minX, point[0]); + maxX = Math.max(maxX, point[0]); + minY = Math.min(minY, point[1]); + maxY = Math.max(maxY, point[1]); + } + } + const textWidthCalc = maxX - minX + strokeWidth; + const textHeightY = maxY - minY + strokeWidth; + + const availableWidth = trayWidth - margin * 2; + const availableDepth = trayDepth - margin * 2; + const scaleX = Math.min(1, availableWidth / textWidthCalc); + const scaleY = Math.min(1, availableDepth / textHeightY); + const textScale = Math.min(scaleX, scaleY); + + const centerX = trayWidth / 2; + const centerY = trayDepth / 2; + const textCenterX = (minX + maxX) / 2; + const textCenterY = (minY + maxY) / 2; + + let combinedText = union(...textShapes); + combinedText = mirrorY(combinedText); + + const positionedText = translate( + [centerX - textCenterX * textScale, centerY + textCenterY * textScale, -0.1], + scale([textScale, textScale, 1], combinedText) + ); + tray = subtract(tray, positionedText); + } + } + } + + return tray; +} diff --git a/src/lib/stores/project.svelte.ts b/src/lib/stores/project.svelte.ts index 7b2dfa9..3493f08 100644 --- a/src/lib/stores/project.svelte.ts +++ b/src/lib/stores/project.svelte.ts @@ -10,6 +10,7 @@ import { } from '$lib/models/counterTray'; import { defaultCupTrayParams, type CupTrayParams } from '$lib/models/cupTray'; import { defaultLidParams } from '$lib/models/lid'; +import { defaultStandeeTrayParams, type StandeeTrayParams } from '$lib/models/standeeTray'; import { saveNow, scheduleSave } from '$lib/stores/saveManager'; import type { Box, @@ -26,6 +27,8 @@ import type { ManualBoxPlacement, ManualLooseTrayPlacement, Project, + Standee, + StandeeTray, Tray } from '$lib/types/project'; import { @@ -36,7 +39,8 @@ import { isCardWellTray, isCounterTray, isCupTray, - isLooseTray + isLooseTray, + isStandeeTray } from '$lib/types/project'; import { loadProject, migrateProjectData } from '$lib/utils/storage'; @@ -48,7 +52,8 @@ export { isCardWellTray, isCounterTray, isCupTray, - isLooseTray + isLooseTray, + isStandeeTray }; export type { Box, @@ -65,6 +70,8 @@ export type { ManualBoxPlacement, ManualLooseTrayPlacement, Project, + Standee, + StandeeTray, Tray }; @@ -132,6 +139,24 @@ export const DEFAULT_CARD_SIZES: CardSize[] = [ { id: DEFAULT_CARD_SIZE_IDS.square, name: 'Square', width: 73, length: 73, thickness: 0.5 } ]; +// Default standee IDs (stable so references survive re-imports) +export const DEFAULT_STANDEE_IDS = { + standard: 'standee-standard' +}; + +// Default standees (global) +export const DEFAULT_STANDEES: Standee[] = [ + { + id: DEFAULT_STANDEE_IDS.standard, + name: 'Standard', + baseRadius: 9, + baseThickness: 3, + standeeHeight: 40, + standeeWidth: 25, + standeeThickness: 1.5 + } +]; + function generateId(): string { return Math.random().toString(36).substring(2, 9); } @@ -331,6 +356,19 @@ function createDefaultCardWellTray(name: string, color: string, cardSizes?: Card }; } +function createDefaultStandeeTray(name: string, color: string, standees?: Standee[]): StandeeTray { + // Use the first available standee, falling back to the default ID + const standeeId = standees?.[0]?.id ?? DEFAULT_STANDEE_IDS.standard; + return { + id: generateId(), + type: 'standee', + name, + color, + rotationOverride: 'auto', + params: { ...defaultStandeeTrayParams, standeeId } + }; +} + // Legacy alias for backwards compatibility function _createDefaultCardTray(name: string, color: string): CardDrawTray { return createDefaultCardDrawTray(name, color); @@ -591,6 +629,8 @@ export function addBox(layerId?: string, trayType: TrayType = 'counter'): Box { tray = createDefaultCupTray('Cup Tray 1', color); } else if (trayType === 'cardWell') { tray = createDefaultCardWellTray('Card Well 1', color, project.cardSizes); + } else if (trayType === 'standee') { + tray = createDefaultStandeeTray('Standee Tray 1', color, project.standees); } else { tray = createDefaultCounterTray('Tray 1', color, project.counterShapes); // Inherit global params (including customShapes) from existing counter trays @@ -689,7 +729,7 @@ function getGlobalParamsFromExisting(): Partial { } // Tray type for addTray function -export type TrayType = 'counter' | 'cardDraw' | 'cardDivider' | 'cup' | 'cardWell' | 'card'; +export type TrayType = 'counter' | 'cardDraw' | 'cardDivider' | 'cup' | 'cardWell' | 'standee' | 'card'; // Loose tray operations export function addLooseTray(layerId?: string, trayType: TrayType = 'counter'): Tray | null { @@ -710,6 +750,8 @@ export function addLooseTray(layerId?: string, trayType: TrayType = 'counter'): tray = createDefaultCupTray(`Loose Cups ${trayNumber}`, color); } else if (trayType === 'cardWell') { tray = createDefaultCardWellTray(`Loose Well ${trayNumber}`, color, project.cardSizes); + } else if (trayType === 'standee') { + tray = createDefaultStandeeTray(`Loose Standees ${trayNumber}`, color, project.standees); } else { tray = createDefaultCounterTray(`Loose Tray ${trayNumber}`, color, project.counterShapes); // Inherit global params (including customShapes) from existing counter trays @@ -768,6 +810,8 @@ export function addTray(boxId: string, trayType: TrayType = 'counter'): Tray | n tray = createDefaultCupTray(`Cup Tray ${trayNumber}`, color); } else if (trayType === 'cardWell') { tray = createDefaultCardWellTray(`Card Well ${trayNumber}`, color, project.cardSizes); + } else if (trayType === 'standee') { + tray = createDefaultStandeeTray(`Standee Tray ${trayNumber}`, color, project.standees); } else { tray = createDefaultCounterTray(`Tray ${trayNumber}`, color, project.counterShapes); // Inherit global params (including customShapes) from existing counter trays @@ -976,6 +1020,52 @@ export function deleteCardSize(id: string): void { } } +// Standee operations (global) +export function getStandees(): Standee[] { + return project.standees; +} + +export function getStandee(id: string): Standee | null { + return project.standees.find((s) => s.id === id) ?? null; +} + +export function addStandee(standee: Omit): Standee { + const newStandee: Standee = { ...standee, id: generateId() }; + project.standees.push(newStandee); + autosave(); + return newStandee; +} + +export function updateStandee(id: string, updates: Partial>): void { + const standee = project.standees.find((s) => s.id === id); + if (standee) { + Object.assign(standee, updates); + autosave(); + } +} + +export function deleteStandee(id: string): void { + const index = project.standees.findIndex((s) => s.id === id); + if (index < 0) return; + project.standees.splice(index, 1); + + // Re-point any standee trays that referenced the deleted standee to the first + // remaining standee so geometry keeps generating. + const fallbackId = project.standees[0]?.id ?? ''; + const repoint = (tray: Tray) => { + if (isStandeeTray(tray) && tray.params.standeeId === id) { + tray.params.standeeId = fallbackId; + } + }; + for (const layer of project.layers) { + for (const box of layer.boxes) { + for (const tray of box.trays) repoint(tray); + } + for (const tray of layer.looseTrays) repoint(tray); + } + autosave(); +} + // Default global settings export const DEFAULT_GLOBAL_SETTINGS = { gameContainerWidth: 256, @@ -1140,6 +1230,26 @@ export function updateCardWellTrayParams(trayId: string, params: CardWellTrayPar } } +// Update standee tray params +export function updateStandeeTrayParams(trayId: string, params: StandeeTrayParams): void { + for (const layer of project.layers) { + for (const box of layer.boxes) { + const tray = box.trays.find((t) => t.id === trayId); + if (tray && isStandeeTray(tray)) { + tray.params = params; + autosave(); + return; + } + } + const looseTray = layer.looseTrays.find((t) => t.id === trayId); + if (looseTray && isStandeeTray(looseTray)) { + looseTray.params = params; + autosave(); + return; + } + } +} + // Reset project export function resetProject(): void { project = createDefaultProject(); diff --git a/src/lib/types/project.ts b/src/lib/types/project.ts index e15d737..49ec0af 100644 --- a/src/lib/types/project.ts +++ b/src/lib/types/project.ts @@ -3,6 +3,7 @@ import type { CardDrawTrayParams } from '$lib/models/cardTray'; import type { CardWellTrayParams } from '$lib/models/cardWellTray'; import type { CounterTrayParams } from '$lib/models/counterTray'; import type { CupTrayParams } from '$lib/models/cupTray'; +import type { StandeeTrayParams } from '$lib/models/standeeTray'; // Base shape types for counter shapes export type CounterBaseShape = 'rectangle' | 'square' | 'circle' | 'hex' | 'triangle'; @@ -28,6 +29,18 @@ export interface CardSize { thickness: number; // Sleeved thickness in mm } +// Standee definition (global, referenced by ID) +// A standee is a cardboard figure that slots into a plastic base. +export interface Standee { + id: string; + name: string; + baseRadius: number; // Radius of the circular base in mm + baseThickness: number; // Thickness of the base disc in mm + standeeHeight: number; // Height of the figure (base to top) in mm + standeeWidth: number; // Width of the figure in mm + standeeThickness: number; // Thickness of the cardboard figure in mm +} + // Base tray interface shared by all tray types interface BaseTray { id: string; @@ -68,11 +81,17 @@ export interface CardWellTray extends BaseTray { params: CardWellTrayParams; } +// Standee tray for cardboard standees lying on their side in a herringbone of slotted walls +export interface StandeeTray extends BaseTray { + type: 'standee'; + params: StandeeTrayParams; +} + // Legacy alias for backwards compatibility export type CardTray = CardDrawTray; // Discriminated union of all tray types -export type Tray = CounterTray | CardDrawTray | CardDividerTray | CupTray | CardWellTray; +export type Tray = CounterTray | CardDrawTray | CardDividerTray | CupTray | CardWellTray | StandeeTray; // Type guards for tray types export function isCounterTray(tray: Tray): tray is CounterTray { @@ -95,6 +114,10 @@ export function isCardWellTray(tray: Tray): tray is CardWellTray { return tray.type === 'cardWell'; } +export function isStandeeTray(tray: Tray): tray is StandeeTray { + return tray.type === 'standee'; +} + // Legacy alias - also matches old 'card' type for migration export function isCardTray(tray: Tray): tray is CardDrawTray { return tray.type === 'cardDraw' || (tray as { type: string }).type === 'card'; @@ -194,6 +217,7 @@ export interface Project { layers: Layer[]; counterShapes: CounterShape[]; cardSizes: CardSize[]; + standees: Standee[]; selectedLayerId: string | null; selectedBoxId: string | null; selectedTrayId: string | null; @@ -205,6 +229,7 @@ export interface LegacyProject { boxes: Box[]; counterShapes: CounterShape[]; cardSizes: CardSize[]; + standees?: Standee[]; selectedBoxId: string | null; selectedTrayId: string | null; globalSettings?: GlobalSettings; diff --git a/src/lib/utils/geometryWorker.ts b/src/lib/utils/geometryWorker.ts index 26ab5f4..bbb437e 100644 --- a/src/lib/utils/geometryWorker.ts +++ b/src/lib/utils/geometryWorker.ts @@ -300,7 +300,8 @@ export class GeometryWorkerManager { JSON.stringify({ layers: project.layers, cardSizes: project.cardSizes, - counterShapes: project.counterShapes + counterShapes: project.counterShapes, + standees: project.standees }) ); diff --git a/src/lib/utils/storage.ts b/src/lib/utils/storage.ts index fdf2840..9ff7c4a 100644 --- a/src/lib/utils/storage.ts +++ b/src/lib/utils/storage.ts @@ -11,11 +11,22 @@ import { DEFAULT_CARD_SIZES, DEFAULT_COUNTER_SHAPES, DEFAULT_COUNTER_THICKNESS, + DEFAULT_STANDEES, TRAY_COLORS } from '$lib/stores/project.svelte'; import type { CupLayout } from '$lib/types/cupLayout'; import { gridToSplitLayout } from '$lib/types/cupLayout'; -import type { Box, CardSize, CounterShape, Layer, LegacyProject, LidParams, Project, Tray } from '$lib/types/project'; +import type { + Box, + CardSize, + CounterShape, + Layer, + LegacyProject, + LidParams, + Project, + Standee, + Tray +} from '$lib/types/project'; import { isLegacyProject } from '$lib/types/project'; const STORAGE_KEY = 'counter-tray-project'; @@ -495,6 +506,18 @@ export function migrateProjectData(project: Project | LegacyProject): Project { } } + // Standees are a newer global - older projects won't have them. Start from any + // existing array and backfill the defaults. + let standees: Standee[] = Array.isArray((project as { standees?: unknown }).standees) + ? (project as { standees: Standee[] }).standees.map((s) => (s.id ? s : { ...s, id: generateId() })) + : []; + const existingStandeeIds = new Set(standees.map((s) => s.id)); + for (const defaultStandee of DEFAULT_STANDEES) { + if (!existingStandeeIds.has(defaultStandee.id)) { + standees.push({ ...defaultStandee }); + } + } + // Migrate counterShapes to include thickness if missing // Get default thickness from first counter tray's params, or use default let defaultThickness = DEFAULT_COUNTER_THICKNESS; @@ -555,6 +578,7 @@ export function migrateProjectData(project: Project | LegacyProject): Project { layers: [layer], counterShapes, cardSizes, + standees, selectedLayerId: layerId, selectedBoxId: project.selectedBoxId, selectedTrayId: project.selectedTrayId, @@ -590,6 +614,7 @@ export function migrateProjectData(project: Project | LegacyProject): Project { layers: migratedLayers, counterShapes, cardSizes, + standees, globalSettings }; } diff --git a/src/lib/workers/geometry.worker.ts b/src/lib/workers/geometry.worker.ts index d5ae7b4..f691cdf 100644 --- a/src/lib/workers/geometry.worker.ts +++ b/src/lib/workers/geometry.worker.ts @@ -22,8 +22,9 @@ import { } from '$lib/models/counterTray'; import { createCupTray } from '$lib/models/cupTray'; import { createBoxWithLidGrooves, createLid } from '$lib/models/lid'; -import type { Box, CardSize, CounterShape, Layer, Tray } from '$lib/types/project'; -import { isCardDividerTray, isCardTray, isCardWellTray, isCupTray } from '$lib/types/project'; +import { createStandeeTray } from '$lib/models/standeeTray'; +import type { Box, CardSize, CounterShape, Layer, Standee, Tray } from '$lib/types/project'; +import { isCardDividerTray, isCardTray, isCardWellTray, isCupTray, isStandeeTray } from '$lib/types/project'; import threemfSerializer from '@jscad/3mf-serializer'; import jscad from '@jscad/modeling'; import type { Geom3 } from '@jscad/modeling/src/geometries/types'; @@ -108,16 +109,21 @@ function getCumulativeTrayIndexForTray(layers: Layer[], trayId: string): number * For loose trays to match box height, they are generated at the layer height. * For boxes to match a taller loose tray, their interior trays need to grow. */ -function calculateUnifiedLayerHeight(layer: Layer, cardSizes: CardSize[], counterShapes: CounterShape[]): number { +function calculateUnifiedLayerHeight( + layer: Layer, + cardSizes: CardSize[], + counterShapes: CounterShape[], + standees: Standee[] = [] +): number { // Get all box exterior heights const boxHeights = layer.boxes.map((box) => { - const dims = getBoxExteriorDimensions(box, cardSizes, counterShapes); + const dims = getBoxExteriorDimensions(box, cardSizes, counterShapes, standees); return dims.height; }); // Get all loose tray content heights const looseTrayHeights = layer.looseTrays.map((tray) => { - const dims = getTrayDimensionsForTray(tray, cardSizes, counterShapes); + const dims = getTrayDimensionsForTray(tray, cardSizes, counterShapes, standees); return dims.height; }); @@ -153,6 +159,7 @@ interface GenerateMessage { layers: Layer[]; cardSizes?: CardSize[]; counterShapes?: CounterShape[]; + standees?: Standee[]; }; selectedBoxId: string; selectedTrayId: string; @@ -305,12 +312,16 @@ function createTrayGeometry( cardSizes: CustomCardSize[], counterShapes: CounterShape[], maxHeight: number, - spacerHeight: number + spacerHeight: number, + standees: Standee[] = [] ): Geom3 { const showEmboss = tray.showEmboss ?? true; if (isCupTray(tray)) { return createCupTray(tray.params, tray.name, maxHeight, spacerHeight, showEmboss); } + if (isStandeeTray(tray)) { + return createStandeeTray(tray.params, standees, tray.name, maxHeight, spacerHeight, showEmboss); + } if (isCardWellTray(tray)) { return createCardWellTray(tray.params, cardSizes, tray.name, maxHeight, spacerHeight, showEmboss); } @@ -347,6 +358,10 @@ function getTrayPositions( // Cup trays don't have counter previews - the cups themselves are the containers return []; } + if (isStandeeTray(tray)) { + // Standee trays don't render content previews yet + return []; + } if (isCardWellTray(tray)) { // Convert card well positions to CounterStack format for visualization const wellStacks = getCardWellPositions(tray.params, cardSizes, maxHeight, spacerHeight); @@ -547,15 +562,16 @@ function handleGenerate(msg: GenerateMessage): void { return; } - // Get card sizes and counter shapes from project level (global) + // Get card sizes, counter shapes and standees from project level (global) const cardSizes = project.cardSizes ?? []; const counterShapes = project.counterShapes ?? []; + const standees = project.standees ?? []; // Pre-calculate unified layer heights for all layers FIRST // All items in a layer should have the same total exterior height for proper stacking const layerHeights = new Map(); for (const layer of project.layers) { - const layerHeight = calculateUnifiedLayerHeight(layer, cardSizes, counterShapes); + const layerHeight = calculateUnifiedLayerHeight(layer, cardSizes, counterShapes, standees); layerHeights.set(layer.id, layerHeight); } @@ -578,7 +594,7 @@ function handleGenerate(msg: GenerateMessage): void { if (box) { // Validate custom dimensions - const validation = validateCustomDimensions(box, cardSizes, counterShapes); + const validation = validateCustomDimensions(box, cardSizes, counterShapes, standees); if (!validation.valid) { self.postMessage({ type: 'generate-result', @@ -602,10 +618,11 @@ function handleGenerate(msg: GenerateMessage): void { tolerance: box.tolerance, cardSizes, counterShapes, + standees, manualLayout: box.manualLayout }); - const spacerInfo = calculateTraySpacers(box, cardSizes, counterShapes); + const spacerInfo = calculateTraySpacers(box, cardSizes, counterShapes, standees); // Use the layer-adjusted tray height instead of natural height const naturalMaxHeight = Math.max(...placements.map((p) => p.dimensions.height)); const maxHeight = selectedLayerHeight > 0 ? requiredTrayHeight : naturalMaxHeight; @@ -615,7 +632,14 @@ function handleGenerate(msg: GenerateMessage): void { const selectedSpacerHeight = selectedSpacer?.floorSpacerHeight ?? 0; // Generate selected tray - cachedSelectedTray = createTrayGeometry(tray, cardSizes, counterShapes, maxHeight, selectedSpacerHeight); + cachedSelectedTray = createTrayGeometry( + tray, + cardSizes, + counterShapes, + maxHeight, + selectedSpacerHeight, + standees + ); selectedTrayGeometry = jscadToArrays(cachedSelectedTray); selectedTrayCounters = getTrayPositions(tray, cardSizes, counterShapes, maxHeight, selectedSpacerHeight); @@ -626,7 +650,7 @@ function handleGenerate(msg: GenerateMessage): void { const spacerHeight = spacer?.floorSpacerHeight ?? 0; let jscadGeom!: Geom3; time(`createTray (${placement.tray.name})`, () => { - jscadGeom = createTrayGeometry(placement.tray, cardSizes, counterShapes, maxHeight, spacerHeight); + jscadGeom = createTrayGeometry(placement.tray, cardSizes, counterShapes, maxHeight, spacerHeight, standees); }); cachedAllTrays.push({ jscadGeom, name: placement.tray.name }); @@ -650,10 +674,10 @@ function handleGenerate(msg: GenerateMessage): void { // Generate box and lid - pass layer height so box exterior matches layer time(`createBoxWithLidGrooves (${box.name})`, () => { - cachedBox = createBoxWithLidGrooves(box, cardSizes, counterShapes, selectedLayerHeight); + cachedBox = createBoxWithLidGrooves(box, cardSizes, counterShapes, selectedLayerHeight, standees); }); time(`createLid (${box.name})`, () => { - cachedLid = createLid(box, cardSizes, counterShapes); + cachedLid = createLid(box, cardSizes, counterShapes, standees); }); cachedBoxName = box.name; @@ -666,14 +690,14 @@ function handleGenerate(msg: GenerateMessage): void { const looseTrayLayerHeight = looseTrayLayer ? (layerHeights.get(looseTrayLayer.id) ?? 0) : 0; // Calculate tray dimensions for proper sizing - const trayDims = getTrayDimensionsForTray(tray, cardSizes, counterShapes); + const trayDims = getTrayDimensionsForTray(tray, cardSizes, counterShapes, standees); const naturalHeight = trayDims.height; // Use layer height if available, otherwise use natural height const maxHeight = looseTrayLayerHeight > 0 ? looseTrayLayerHeight : naturalHeight; const spacerHeight = 0; // No spacer for loose trays // Generate standalone tray - cachedSelectedTray = createTrayGeometry(tray, cardSizes, counterShapes, maxHeight, spacerHeight); + cachedSelectedTray = createTrayGeometry(tray, cardSizes, counterShapes, maxHeight, spacerHeight, standees); selectedTrayGeometry = jscadToArrays(cachedSelectedTray); selectedTrayCounters = getTrayPositions(tray, cardSizes, counterShapes, maxHeight, spacerHeight); @@ -724,7 +748,7 @@ function handleGenerate(msg: GenerateMessage): void { total: totalOperations, currentItem: projectBox.name } as GenerationProgressMessage); - const boxValidation = validateCustomDimensions(projectBox, cardSizes, counterShapes); + const boxValidation = validateCustomDimensions(projectBox, cardSizes, counterShapes, standees); if (!boxValidation.valid) { console.warn(`Box "${projectBox.name}" validation failed:`, boxValidation.errors); } @@ -741,12 +765,12 @@ function handleGenerate(msg: GenerateMessage): void { let boxJscad: Geom3 | null = null; let lidJscad: Geom3 | null = null; time(`createBoxWithLidGrooves (${projectBox.name})`, () => { - boxJscad = createBoxWithLidGrooves(projectBox, cardSizes, counterShapes, layerHeight); + boxJscad = createBoxWithLidGrooves(projectBox, cardSizes, counterShapes, layerHeight, standees); }); const boxBufferGeom = boxJscad ? jscadToArrays(boxJscad) : null; // Lid dimensions are fixed (2x wall thickness) and don't depend on layer height time(`createLid (${projectBox.name})`, () => { - lidJscad = createLid(projectBox, cardSizes, counterShapes); + lidJscad = createLid(projectBox, cardSizes, counterShapes, standees); }); const lidBufferGeom = lidJscad ? jscadToArrays(lidJscad) : null; @@ -757,10 +781,11 @@ function handleGenerate(msg: GenerateMessage): void { tolerance: projectBox.tolerance, cardSizes, counterShapes, + standees, manualLayout: projectBox.manualLayout }); - const boxSpacerInfo = calculateTraySpacers(projectBox, cardSizes, counterShapes); + const boxSpacerInfo = calculateTraySpacers(projectBox, cardSizes, counterShapes, standees); // Use the required tray height from layer calculation, not just the box's natural height const naturalTrayHeights = boxPlacements.map((p) => p.dimensions.height); const maxNaturalTrayHeight = Math.max(...naturalTrayHeights, 0); @@ -772,7 +797,14 @@ function handleGenerate(msg: GenerateMessage): void { const trayGeoms: TrayGeometryResult[] = boxPlacements.map((placement) => { const spacer = boxSpacerInfo.find((s) => s.trayId === placement.tray.id); const spacerHeight = spacer?.floorSpacerHeight ?? 0; - const jscadGeom = createTrayGeometry(placement.tray, cardSizes, counterShapes, boxMaxHeight, spacerHeight); + const jscadGeom = createTrayGeometry( + placement.tray, + cardSizes, + counterShapes, + boxMaxHeight, + spacerHeight, + standees + ); // Cache for STL export cachedTraysForBox.push({ jscadGeom, name: placement.tray.name }); @@ -833,13 +865,13 @@ function handleGenerate(msg: GenerateMessage): void { } as GenerationProgressMessage); // Calculate tray dimensions for width/depth - const trayDims = getTrayDimensionsForTray(looseTray, cardSizes, counterShapes); + const trayDims = getTrayDimensionsForTray(looseTray, cardSizes, counterShapes, standees); // Use layer height for the tray height so loose trays match box exterior height const maxHeight = layerHeight > 0 ? layerHeight : trayDims.height; const spacerHeight = 0; // No spacer for loose trays // Generate tray geometry at the layer height - const jscadGeom = createTrayGeometry(looseTray, cardSizes, counterShapes, maxHeight, spacerHeight); + const jscadGeom = createTrayGeometry(looseTray, cardSizes, counterShapes, maxHeight, spacerHeight, standees); // Cache for STL export cachedAllLooseTrays.push({ From 79fb701507bf5da06e98d55c435da2a544e16a2b Mon Sep 17 00:00:00 2001 From: Dave Snider Date: Mon, 29 Jun 2026 15:48:18 -0400 Subject: [PATCH 2/9] cleanup standees --- src/lib/data/defaultProject.json | 11 +++ src/lib/models/standeeTray.ts | 155 ++++++++++++++++++++----------- 2 files changed, 111 insertions(+), 55 deletions(-) diff --git a/src/lib/data/defaultProject.json b/src/lib/data/defaultProject.json index f278d0a..e63bed3 100644 --- a/src/lib/data/defaultProject.json +++ b/src/lib/data/defaultProject.json @@ -392,6 +392,17 @@ "thickness": 0.5 } ], + "standees": [ + { + "id": "standee-standard", + "name": "Standard", + "baseRadius": 9, + "baseThickness": 3, + "standeeHeight": 40, + "standeeWidth": 25, + "standeeThickness": 1.5 + } + ], "selectedLayerId": "7hvj1k8", "selectedBoxId": null, "selectedTrayId": "nrme206", diff --git a/src/lib/models/standeeTray.ts b/src/lib/models/standeeTray.ts index 857bd0a..16e3375 100644 --- a/src/lib/models/standeeTray.ts +++ b/src/lib/models/standeeTray.ts @@ -18,6 +18,10 @@ import type { Standee } from '$lib/types/project'; const SLOT_ANGLE_DEG = 20; const SLOT_ANGLE = (SLOT_ANGLE_DEG * Math.PI) / 180; +// Clearance (mm) kept between opposing standees in the depth direction, on top of the geometric +// minimum, so staggered rows never touch. +const STANDEE_GAP = 2; + export interface StandeeTrayParams { standeeId: string; // Reference to a Standee by ID count: number; // Total number of standees stored (split across the two walls) @@ -82,6 +86,10 @@ interface StandeeLayout { slotPitch: number; staggerY: number; // Y offset applied to the right wall's slots slotWidth: number; + // Slot vertical geometry (Z) + axisZ: number; // figure centre height — the slot pivots about this so the base stays aligned + slotBottomZ: number; // lowest Z the slot reaches (below the floor when it cuts all the way through) + slotTopZ: number; // top of the slot (above the rim so it is open for inserting the standee) outerCavityWidth: number; middleCavityWidth: number; baseRadius: number; @@ -99,47 +107,69 @@ function computeLayout( const { baseRadius, standeeHeight, standeeWidth } = standee; const baseDiameter = baseRadius * 2; + const slotWidth = standee.standeeThickness + 1; // Slots split across the two walls (left gets the extra one for odd counts). const leftRowCount = Math.ceil(count / 2); const rightRowCount = Math.floor(count / 2); const maxRowCount = Math.max(leftRowCount, rightRowCount, 1); - // Along depth (Y): one slot per standee, spaced by base diameter + 1mm. - const slotPitch = baseDiameter + 1; - const slotWidth = standee.standeeThickness + 1; + // --- Height (Z) --- + // The base lies on its side as a vertical disc (baseDiameter tall) and the figure (standeeWidth + // tall) is centred on the disc centre. The figure axis is at floor + baseRadius. + const spacerHeight = floorSpacerHeight ?? 0; + const axisZ = floorThickness + baseRadius; + const contentTopZ = Math.max(floorThickness + baseDiameter, axisZ + standeeWidth / 2); + let trayHeight = contentTopZ + rimHeight + spacerHeight; + if (targetHeight && targetHeight > trayHeight) { + trayHeight = targetHeight; + } + + // --- Slot vertical extent (Z) --- + // The slot holds the figure (centred on the axis) and stays open at the top so the standee drops + // in. It plunges only as deep as the figure reaches: if the figure bottom is at or below the + // floor (standee as wide as / wider than the base) it cuts all the way through — extended a few + // mm below the floor so the angled cut leaves no sliver; otherwise it stops at the figure bottom. + const figureBottomZ = axisZ - standeeWidth / 2; + const cutsThrough = figureBottomZ <= floorThickness; + const slotBottomZ = cutsThrough ? floorThickness - 4 : figureBottomZ; + const slotTopZ = trayHeight + 1; + + // --- Depth (Y): one slot per standee --- + // Spacing must clear both the base discs (baseDiameter + 1) and the angled slots. A slot tilted + // SLOT_ANGLE sweeps sideways (Y) above the figure axis (topSweep) and below it (botSweep). The + // opposing row tilts the other way and is staggered by half a pitch, so each slot approaches the + // next slot on the other wall on whichever side sweeps farther. The half-pitch must therefore + // exceed that larger one-sided sweep plus the slot width and a clearance gap, otherwise the + // staggered slots — and the standees in them — would touch. + const topSweep = (Math.min(slotTopZ, trayHeight) - axisZ) * Math.tan(SLOT_ANGLE); + const botSweep = (axisZ - floorThickness) * Math.tan(SLOT_ANGLE); + const requiredStagger = 2 * Math.max(topSweep, botSweep) + slotWidth + STANDEE_GAP; + const slotPitch = Math.max(baseDiameter + 1, 2 * requiredStagger); const staggerY = slotPitch / 2; // right wall offset so figures interleave - // Margin so the base disc (radius baseRadius) clears the front/back walls. - const endMargin = wallThickness + baseRadius + clearance; + // End margin so the end standees still slide in past the end walls. The base disc needs + // baseRadius, and because the standee enters from the (tilted) top of the slot its base swings + // toward the end by the slot's sweep before seating — so add that sweep on top of the radius. + const endSweep = Math.max(topSweep, botSweep); + const endMargin = wallThickness + clearance + baseRadius + endSweep; const firstSlotY = endMargin; const lastSlotY = endMargin + (maxRowCount - 1) * slotPitch + staggerY; - const trayDepth = lastSlotY + baseRadius + clearance + wallThickness; - - // Across width (X): outer cavity holds the base; drop-in clearance requires the - // inner wall to sit at least baseRadius + 1mm from the outer wall. - const outerCavityWidth = baseRadius + 1 + clearance; + const trayDepth = lastSlotY + baseRadius + endSweep + clearance + wallThickness; - // Horizontal reach of the figure (tilted SLOT_ANGLE off the wall normal). - const figureXReach = standeeHeight * Math.cos(SLOT_ANGLE); + // --- Width (X) --- + // The base is a thin vertical disc against the side wall, so the outer cavity only needs the base + // thickness plus a little room (baseThickness + 5mm). The figure points inward across the inner + // wall into the middle cavity. A lying standee's full length is baseThickness + standeeHeight, and + // it must fit across its outer cavity plus the middle cavity (opposing rows interleave there). + const standeeLength = standee.baseThickness + standeeHeight; + const outerCavityWidth = standee.baseThickness + 5; + const middleCavityWidth = Math.max(standeeLength + clearance - outerCavityWidth, outerCavityWidth); const leftWallX = wallThickness + outerCavityWidth; - // Left figure tip lands inside the middle cavity, just short of the right wall. - const leftTipX = wallThickness + figureXReach; - const rightWallX = Math.max(leftTipX + clearance, leftWallX + innerWallThickness + clearance); - const middleCavityWidth = rightWallX - (leftWallX + innerWallThickness); + const rightWallX = leftWallX + innerWallThickness + middleCavityWidth; const trayWidth = rightWallX + innerWallThickness + outerCavityWidth + wallThickness; - // Height: the vertical base disc (baseDiameter tall, resting on the floor) and the - // figure (standeeWidth tall, centered on the disc center) both stand vertically. - const spacerHeight = floorSpacerHeight ?? 0; - const baseCenterZ = floorThickness + baseRadius; - const contentTopZ = Math.max(floorThickness + baseDiameter, baseCenterZ + standeeWidth / 2); - let trayHeight = contentTopZ + rimHeight + spacerHeight; - if (targetHeight && targetHeight > trayHeight) { - trayHeight = targetHeight; - } - return { trayWidth, trayDepth, @@ -153,6 +183,9 @@ function computeLayout( slotPitch, staggerY, slotWidth, + axisZ, + slotBottomZ, + slotTopZ, outerCavityWidth, middleCavityWidth, baseRadius, @@ -206,39 +239,51 @@ export function createStandeeTray( [frontFaceX + innerWallThickness / 2, trayDepth / 2, floorThickness + wallHeight / 2], cuboid({ size: [innerWallThickness, innerCavityDepth, wallHeight] }) ); - tray = union(tray, makeInnerWall(leftWallX), makeInnerWall(rightWallX)); - // === ANGLED SLOTS cut into each inner wall === + // === ANGLED SLOTS === // Slot: a channel that pierces the wall across its thickness (X) so the standee figure can pass // through, narrow along the wall (Y = figure thickness + clearance). It is rotated SLOT_ANGLE - // about the X axis so it runs diagonally on the wall's face — from the bottom of the wall up to - // the top while moving along the wall's length (Y). This tilts the standee toward an end wall so - // it resists falling out, while its base still sits flush at the side. The two walls tilt in - // opposite directions and the rows are staggered so opposing standees interleave. The slot is - // tall (open at the top so the standee drops in) and raised so it never cuts through the floor. - const slotHeight = wallHeight + 6; - const slotThrough = innerWallThickness * 4; // pierces the full wall thickness - // Raise the slot so its lowest (tilted) corner sits just above the floor. - const centerZ = - floorThickness + 0.5 + (slotHeight / 2) * Math.cos(SLOT_ANGLE) + (layout.slotWidth / 2) * Math.sin(SLOT_ANGLE); - const makeSlot = (centerX: number, centerY: number, angle: number): Geom3 => - translate( - [centerX, centerY, centerZ], - rotateX(angle, cuboid({ size: [slotThrough, layout.slotWidth, slotHeight] })) - ); + // about the X axis so it runs diagonally on the wall's face — from lower on the wall up to the + // top while moving along the wall's length (Y). This tilts the standee toward an end wall so it + // resists falling out, while its base still sits flush at the side. The two walls tilt in + // opposite directions and the rows are staggered so opposing standees interleave. + // + // The tilt PIVOTS ABOUT THE FIGURE AXIS (Z = floor + baseRadius), so the slot stays centred on + // the standee's nominal Y at the figure's centre height — the cut reaches the right depth there + // and the base stays aligned. The slot runs from slotBottomZ (the figure bottom, or below the + // floor when it cuts all the way through) up to slotTopZ (above the rim, open for insertion). + // Slots are cut from the walls ALONE and the walls are then unioned onto the box, so a full-depth + // cut never touches the floor. + const { axisZ, slotBottomZ, slotTopZ } = layout; + const cos = Math.cos(SLOT_ANGLE); + const lengthBelowAxis = (axisZ - slotBottomZ) / cos; // along the tilted bar, below the pivot + const lengthAboveAxis = (slotTopZ - axisZ) / cos; // above the pivot + const barLen = lengthBelowAxis + lengthAboveAxis; + const barZShift = barLen / 2 - lengthBelowAxis; // moves the pivot to the bar's origin + const slotThrough = innerWallThickness * 6; // generous so the angled cut always pierces fully + const makeSlot = (centerX: number, centerY: number, angle: number): Geom3 => { + let s: Geom3 = cuboid({ size: [slotThrough, layout.slotWidth, barLen] }); + s = translate([0, 0, barZShift], s); // figure axis now at the origin + s = rotateX(angle, s); // pivot the tilt about the figure axis + s = translate([centerX, centerY, axisZ], s); + return s; + }; - const slots: Geom3[] = []; - for (let i = 0; i < layout.leftRowCount; i++) { - const y = layout.firstSlotY + i * layout.slotPitch; - slots.push(makeSlot(leftWallX + innerWallThickness / 2, y, SLOT_ANGLE)); - } - for (let i = 0; i < layout.rightRowCount; i++) { - const y = layout.firstSlotY + layout.staggerY + i * layout.slotPitch; - slots.push(makeSlot(rightWallX + innerWallThickness / 2, y, -SLOT_ANGLE)); - } - if (slots.length > 0) { - tray = subtract(tray, ...slots); - } + const buildWall = (frontFaceX: number, rowCount: number, angle: number, yOffset: number): Geom3 => { + let wall = makeInnerWall(frontFaceX); + const centerX = frontFaceX + innerWallThickness / 2; + const slots: Geom3[] = []; + for (let i = 0; i < rowCount; i++) { + const y = layout.firstSlotY + yOffset + i * layout.slotPitch; + slots.push(makeSlot(centerX, y, angle)); + } + if (slots.length > 0) wall = subtract(wall, ...slots); + return wall; + }; + + const leftWall = buildWall(leftWallX, layout.leftRowCount, SLOT_ANGLE, 0); + const rightWall = buildWall(rightWallX, layout.rightRowCount, -SLOT_ANGLE, layout.staggerY); + tray = union(tray, leftWall, rightWall); // === EMBOSS TRAY NAME ON BOTTOM === if (showEmboss && trayName && trayName.trim().length > 0) { From 902ff4be794c49366b1db47fe6eff4c79f9a77d9 Mon Sep 17 00:00:00 2001 From: Dave Snider Date: Mon, 29 Jun 2026 22:44:45 -0400 Subject: [PATCH 3/9] standees with preview --- scripts/capture-view.ts | 7 ++ src/lib/components/NavigationMenu.svelte | 6 +- src/lib/components/TrayScene.svelte | 33 +++++++++- src/lib/components/three/StandeeMesh.svelte | 59 +++++++++++++++++ src/lib/components/three/TrayInBox.svelte | 18 ++++- .../three/TrayTypePreviewScene.svelte | 3 +- src/lib/models/counterTray.ts | 6 ++ src/lib/models/standeeTray.ts | 62 ++++++++++++++++++ src/lib/workers/geometry.worker.ts | 52 ++++++++++++--- src/routes/+page.svelte | 4 ++ static/stls/standees.stl | Bin 0 -> 316034 bytes 11 files changed, 235 insertions(+), 15 deletions(-) create mode 100644 src/lib/components/three/StandeeMesh.svelte create mode 100644 static/stls/standees.stl diff --git a/scripts/capture-view.ts b/scripts/capture-view.ts index 859e7ad..46915e8 100644 --- a/scripts/capture-view.ts +++ b/scripts/capture-view.ts @@ -29,6 +29,7 @@ function parseArgs() { debugExport?: boolean; view?: string; trayId?: string; + counters?: boolean; } = {}; for (let i = 0; i < args.length; i++) { @@ -72,6 +73,9 @@ function parseArgs() { result.trayId = next; i++; break; + case '--counters': + result.counters = true; + break; case '--debug-export': result.debugExport = true; break; @@ -194,6 +198,9 @@ async function captureView() { if (args.trayId) { params.set('trayId', args.trayId); } + if (args.counters) { + params.set('counters', '1'); + } // Load markers from file if specified if (args.markers) { diff --git a/src/lib/components/NavigationMenu.svelte b/src/lib/components/NavigationMenu.svelte index 1b9339d..edaa675 100644 --- a/src/lib/components/NavigationMenu.svelte +++ b/src/lib/components/NavigationMenu.svelte @@ -485,7 +485,7 @@ onmouseleave={handleTrayTypeLeave} > Standees - Cardboard standees on their sides + Slotted tray for vertical standees {/snippet} @@ -623,7 +623,7 @@ onmouseleave={handleTrayTypeLeave} > Standees - Cardboard standees on their sides + Slotted tray for vertical standees {/snippet} @@ -707,7 +707,7 @@ onmouseleave={handleTrayTypeLeave} > Standees - Cardboard standees on their sides + Slotted tray for vertical standees {/snippet} diff --git a/src/lib/components/TrayScene.svelte b/src/lib/components/TrayScene.svelte index de69b74..f00c8d0 100644 --- a/src/lib/components/TrayScene.svelte +++ b/src/lib/components/TrayScene.svelte @@ -4,6 +4,7 @@ import { OrbitControls, Grid, Text, interactivity, type IntersectionEvent } from '@threlte/extras'; import PrintBed from './PrintBed.svelte'; import CounterMesh from './three/CounterMesh.svelte'; + import StandeeMesh from './three/StandeeMesh.svelte'; import SceneLighting from './three/SceneLighting.svelte'; import BoxAssembly from './three/BoxAssembly.svelte'; import LayerContent from './three/LayerContent.svelte'; @@ -1543,6 +1544,21 @@ {/each} + {:else if stack.isStandee} + + {:else if stack.isEdgeLoaded} {#each Array(stack.count) as _counterItem, counterIdx (counterIdx)} @@ -1659,7 +1675,22 @@ {@const groupZ = exploded ? meshOffset.z - interiorStartOffset - placement.y : traysGroupDepth - placement.y} {#each trayData.counterStacks as stack, stackIdx (stackIdx)} - {#if stack.isEdgeLoaded} + {#if stack.isStandee} + + + {:else if stack.isEdgeLoaded} {#each Array(stack.count) as _counterItem, counterIdx (counterIdx)} {@const effectiveShape = stack.shape === 'custom' ? (stack.customBaseShape ?? 'rectangle') : stack.shape} diff --git a/src/lib/components/three/StandeeMesh.svelte b/src/lib/components/three/StandeeMesh.svelte new file mode 100644 index 0000000..b8827bc --- /dev/null +++ b/src/lib/components/three/StandeeMesh.svelte @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + diff --git a/src/lib/components/three/TrayInBox.svelte b/src/lib/components/three/TrayInBox.svelte index 87a943b..6cdfcba 100644 --- a/src/lib/components/three/TrayInBox.svelte +++ b/src/lib/components/three/TrayInBox.svelte @@ -8,6 +8,7 @@ import type { IntersectionEvent } from '@threlte/extras'; import * as THREE from 'three'; import CounterMesh from './CounterMesh.svelte'; + import StandeeMesh from './StandeeMesh.svelte'; import { getAlternateColor, getSleeveColors } from '$lib/three/materials'; import type { CounterStack } from '$lib/models/counterTray'; @@ -135,7 +136,22 @@ {#if showCounters && counterStacks.length > 0} {#each counterStacks as stack, stackIdx (stackIdx)} - {#if stack.isEdgeLoaded} + {#if stack.isStandee} + + + {:else if stack.isEdgeLoaded} {#each Array(stack.count) as _, counterIdx (counterIdx)} {@const effectiveShape = stack.shape === 'custom' ? (stack.customBaseShape ?? 'rectangle') : stack.shape} diff --git a/src/lib/components/three/TrayTypePreviewScene.svelte b/src/lib/components/three/TrayTypePreviewScene.svelte index a033092..945ea48 100644 --- a/src/lib/components/three/TrayTypePreviewScene.svelte +++ b/src/lib/components/three/TrayTypePreviewScene.svelte @@ -25,7 +25,8 @@ cardDraw: '/stls/card-draw.stl', cardDivider: '/stls/card-divider.stl', cardWell: '/stls/card-well.stl', - cup: '/stls/cups.stl' + cup: '/stls/cups.stl', + standee: '/stls/standees.stl' }; // Uniform tray color diff --git a/src/lib/models/counterTray.ts b/src/lib/models/counterTray.ts index 5a6f7c2..07dcbe6 100644 --- a/src/lib/models/counterTray.ts +++ b/src/lib/models/counterTray.ts @@ -117,6 +117,12 @@ export interface CounterStack { // Card divider specific fields isCardDivider?: boolean; // True if this is a card divider stack cardDividerHeight?: number; // Standing height for card divider cards + // Standee specific fields (a round base disc with a perpendicular rectangular figure) + isStandee?: boolean; + standeeBaseRadius?: number; + standeeBaseThickness?: number; + standeeFigureDir?: number; // +1 / -1: X direction the figure points + standeeTilt?: number; // signed lean angle (radians) } // Generate harmonious colors for counter stacks (warm earth tones matching primary red) diff --git a/src/lib/models/standeeTray.ts b/src/lib/models/standeeTray.ts index 16e3375..f735e76 100644 --- a/src/lib/models/standeeTray.ts +++ b/src/lib/models/standeeTray.ts @@ -206,6 +206,68 @@ export function getStandeeTrayDimensions( return { width: layout.trayWidth, depth: layout.trayDepth, height: layout.trayHeight }; } +// One placed standee for the 3D content preview: a round base disc against a side wall with a +// rectangular figure perpendicular to it, leaning by the slot angle. Positions are in the model's +// Z-up coordinates (x = width, y = depth, z = height). +export interface StandeePosition { + x: number; // base disc centre (against the side wall) + y: number; // slot position along the depth + z: number; // figure axis height (disc centre) + figureDir: number; // +1 / -1: the X direction the figure points (toward the centre) + tilt: number; // signed lean angle (radians) + baseRadius: number; + baseThickness: number; + figureWidth: number; // standee width (vertical extent) + figureLength: number; // standee height (length the figure reaches inward) + figureThickness: number; +} + +export function getStandeePositions( + params: StandeeTrayParams, + standees: Standee[], + targetHeight?: number, + floorSpacerHeight?: number +): StandeePosition[] { + const standee = getStandee(params.standeeId, standees); + const layout = computeLayout(params, standee, targetHeight, floorSpacerHeight); + const { wallThickness } = params; + const { baseRadius, baseThickness, standeeWidth, standeeHeight, standeeThickness } = standee; + + // Base disc sits flush against the side wall in the outer cavity. + const leftX = wallThickness + baseThickness / 2; + const rightX = layout.trayWidth - wallThickness - baseThickness / 2; + + const common = { + z: layout.axisZ, + baseRadius, + baseThickness, + figureWidth: standeeWidth, + figureLength: standeeHeight, + figureThickness: standeeThickness + }; + + const positions: StandeePosition[] = []; + for (let i = 0; i < layout.leftRowCount; i++) { + positions.push({ + ...common, + x: leftX, + y: layout.firstSlotY + i * layout.slotPitch, + figureDir: 1, + tilt: SLOT_ANGLE + }); + } + for (let i = 0; i < layout.rightRowCount; i++) { + positions.push({ + ...common, + x: rightX, + y: layout.firstSlotY + layout.staggerY + i * layout.slotPitch, + figureDir: -1, + tilt: -SLOT_ANGLE + }); + } + return positions; +} + export function createStandeeTray( params: StandeeTrayParams, standees: Standee[], diff --git a/src/lib/workers/geometry.worker.ts b/src/lib/workers/geometry.worker.ts index f691cdf..442bf8a 100644 --- a/src/lib/workers/geometry.worker.ts +++ b/src/lib/workers/geometry.worker.ts @@ -22,7 +22,7 @@ import { } from '$lib/models/counterTray'; import { createCupTray } from '$lib/models/cupTray'; import { createBoxWithLidGrooves, createLid } from '$lib/models/lid'; -import { createStandeeTray } from '$lib/models/standeeTray'; +import { createStandeeTray, getStandeePositions } from '$lib/models/standeeTray'; import type { Box, CardSize, CounterShape, Layer, Standee, Tray } from '$lib/types/project'; import { isCardDividerTray, isCardTray, isCardWellTray, isCupTray, isStandeeTray } from '$lib/types/project'; import threemfSerializer from '@jscad/3mf-serializer'; @@ -352,15 +352,35 @@ function getTrayPositions( cardSizes: CustomCardSize[], counterShapes: CounterShape[], maxHeight: number, - spacerHeight: number + spacerHeight: number, + standees: Standee[] = [] ): CounterStack[] { if (isCupTray(tray)) { // Cup trays don't have counter previews - the cups themselves are the containers return []; } if (isStandeeTray(tray)) { - // Standee trays don't render content previews yet - return []; + // Each standee = a round base disc with a perpendicular rectangular figure, leaning in its slot. + const placed = getStandeePositions(tray.params, standees, maxHeight, spacerHeight); + return placed.map((s) => ({ + shape: 'custom' as const, + customShapeName: 'Standee', + customBaseShape: 'rectangle' as const, + x: s.x, + y: s.y, + z: s.z, + width: s.figureWidth, + length: s.figureLength, + thickness: s.figureThickness, + count: 1, + hexPointyTop: false, + color: '#c9a36a', + isStandee: true, + standeeBaseRadius: s.baseRadius, + standeeBaseThickness: s.baseThickness, + standeeFigureDir: s.figureDir, + standeeTilt: s.tilt + })); } if (isCardWellTray(tray)) { // Convert card well positions to CounterStack format for visualization @@ -641,7 +661,14 @@ function handleGenerate(msg: GenerateMessage): void { standees ); selectedTrayGeometry = jscadToArrays(cachedSelectedTray); - selectedTrayCounters = getTrayPositions(tray, cardSizes, counterShapes, maxHeight, selectedSpacerHeight); + selectedTrayCounters = getTrayPositions( + tray, + cardSizes, + counterShapes, + maxHeight, + selectedSpacerHeight, + standees + ); // Generate all trays for selected box cachedAllTrays = []; @@ -667,7 +694,7 @@ function handleGenerate(msg: GenerateMessage): void { height: maxHeight } }, - counterStacks: getTrayPositions(placement.tray, cardSizes, counterShapes, maxHeight, spacerHeight), + counterStacks: getTrayPositions(placement.tray, cardSizes, counterShapes, maxHeight, spacerHeight, standees), trayLetter: getTrayLetter(getCumulativeTrayIndexForTray(project.layers, placement.tray.id)) }; }); @@ -699,7 +726,7 @@ function handleGenerate(msg: GenerateMessage): void { // Generate standalone tray cachedSelectedTray = createTrayGeometry(tray, cardSizes, counterShapes, maxHeight, spacerHeight, standees); selectedTrayGeometry = jscadToArrays(cachedSelectedTray); - selectedTrayCounters = getTrayPositions(tray, cardSizes, counterShapes, maxHeight, spacerHeight); + selectedTrayCounters = getTrayPositions(tray, cardSizes, counterShapes, maxHeight, spacerHeight, standees); cachedAllTrays = [{ jscadGeom: cachedSelectedTray, name: tray.name }]; cachedBox = null; @@ -821,7 +848,14 @@ function handleGenerate(msg: GenerateMessage): void { height: boxMaxHeight } }, - counterStacks: getTrayPositions(placement.tray, cardSizes, counterShapes, boxMaxHeight, spacerHeight), + counterStacks: getTrayPositions( + placement.tray, + cardSizes, + counterShapes, + boxMaxHeight, + spacerHeight, + standees + ), trayLetter: getTrayLetter(getCumulativeTrayIndexForTray(project.layers, placement.tray.id)) }; }); @@ -887,7 +921,7 @@ function handleGenerate(msg: GenerateMessage): void { color: looseTray.color, geometry: jscadToArrays(jscadGeom), dimensions: { width: trayDims.width, depth: trayDims.depth, height: maxHeight }, - counterStacks: getTrayPositions(looseTray, cardSizes, counterShapes, maxHeight, spacerHeight), + counterStacks: getTrayPositions(looseTray, cardSizes, counterShapes, maxHeight, spacerHeight, standees), trayLetter: getTrayLetter(getCumulativeTrayIndexForTray(project.layers, looseTray.id)) }); } diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index f2e4091..7d04dfd 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -181,6 +181,10 @@ const zoomStr = params.get('zoom'); const markersStr = params.get('markers'); const hideUI = params.get('hideUI') === '1'; + // Allow enabling the content preview from the URL (used by capture-view for debugging) + if (params.get('counters') === '1') { + showCounters = true; + } const viewParam = params.get('view') as ViewMode | null; const trayIdParam = params.get('trayId'); const boxIdParam = params.get('boxId'); diff --git a/static/stls/standees.stl b/static/stls/standees.stl new file mode 100644 index 0000000000000000000000000000000000000000..82d4fab75896f1e6d552af0b4c1601baf0600322 GIT binary patch literal 316034 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 literal 0 HcmV?d00001 From 6c3a970a939194fb08cfdd148cf65688cf1523f4 Mon Sep 17 00:00:00 2001 From: Dave Snider Date: Mon, 29 Jun 2026 23:38:53 -0400 Subject: [PATCH 4/9] fix standees within box --- scripts/capture-view.ts | 8 +++++++ src/lib/models/standeeTray.ts | 39 +++++++++++++++++++++++------------ 2 files changed, 34 insertions(+), 13 deletions(-) diff --git a/scripts/capture-view.ts b/scripts/capture-view.ts index 46915e8..2750eb6 100644 --- a/scripts/capture-view.ts +++ b/scripts/capture-view.ts @@ -29,6 +29,7 @@ function parseArgs() { debugExport?: boolean; view?: string; trayId?: string; + boxId?: string; counters?: boolean; } = {}; @@ -73,6 +74,10 @@ function parseArgs() { result.trayId = next; i++; break; + case '--boxId': + result.boxId = next; + i++; + break; case '--counters': result.counters = true; break; @@ -198,6 +203,9 @@ async function captureView() { if (args.trayId) { params.set('trayId', args.trayId); } + if (args.boxId) { + params.set('boxId', args.boxId); + } if (args.counters) { params.set('counters', '1'); } diff --git a/src/lib/models/standeeTray.ts b/src/lib/models/standeeTray.ts index f735e76..b9c830e 100644 --- a/src/lib/models/standeeTray.ts +++ b/src/lib/models/standeeTray.ts @@ -75,6 +75,7 @@ interface StandeeLayout { trayWidth: number; trayDepth: number; trayHeight: number; + innerWallTopZ: number; // top of the inner walls/slots — stays at the natural height when the box stretches the tray taller // X positions (front face of each inner wall) leftWallX: number; rightWallX: number; @@ -120,20 +121,28 @@ function computeLayout( const spacerHeight = floorSpacerHeight ?? 0; const axisZ = floorThickness + baseRadius; const contentTopZ = Math.max(floorThickness + baseDiameter, axisZ + standeeWidth / 2); - let trayHeight = contentTopZ + rimHeight + spacerHeight; - if (targetHeight && targetHeight > trayHeight) { - trayHeight = targetHeight; - } + // Natural height the standee + rim need on their own. The inner walls, slots and the standee + // spacing below are all sized from this, so they never change when the box stretches the tray + // taller to match the other trays in a box/layer — otherwise the slot sweep (and therefore the + // tray depth) would grow with the box height and overflow the footprint the packer reserved. + const naturalTrayHeight = contentTopZ + rimHeight; + // Outer tray height: raise the rim to the box/layer height (targetHeight), then add any floor + // spacer — same order as the counter tray so the box's height normalisation lines up. + const trayHeightWithoutSpacer = targetHeight && targetHeight > naturalTrayHeight ? targetHeight : naturalTrayHeight; + const trayHeight = trayHeightWithoutSpacer + spacerHeight; + // The inner walls (and their slots) stop at the natural height; only the outer walls grow. + const innerWallTopZ = naturalTrayHeight; // --- Slot vertical extent (Z) --- - // The slot holds the figure (centred on the axis) and stays open at the top so the standee drops - // in. It plunges only as deep as the figure reaches: if the figure bottom is at or below the - // floor (standee as wide as / wider than the base) it cuts all the way through — extended a few - // mm below the floor so the angled cut leaves no sliver; otherwise it stops at the figure bottom. + // The slot holds the figure (centred on the axis) and stays open at the top of the inner wall so + // the standee drops in. It plunges only as deep as the figure reaches: if the figure bottom is at + // or below the floor (standee as wide as / wider than the base) it cuts all the way through — + // extended a few mm below the floor so the angled cut leaves no sliver; otherwise it stops at the + // figure bottom. const figureBottomZ = axisZ - standeeWidth / 2; const cutsThrough = figureBottomZ <= floorThickness; const slotBottomZ = cutsThrough ? floorThickness - 4 : figureBottomZ; - const slotTopZ = trayHeight + 1; + const slotTopZ = innerWallTopZ + 1; // --- Depth (Y): one slot per standee --- // Spacing must clear both the base discs (baseDiameter + 1) and the angled slots. A slot tilted @@ -142,7 +151,7 @@ function computeLayout( // next slot on the other wall on whichever side sweeps farther. The half-pitch must therefore // exceed that larger one-sided sweep plus the slot width and a clearance gap, otherwise the // staggered slots — and the standees in them — would touch. - const topSweep = (Math.min(slotTopZ, trayHeight) - axisZ) * Math.tan(SLOT_ANGLE); + const topSweep = (innerWallTopZ - axisZ) * Math.tan(SLOT_ANGLE); const botSweep = (axisZ - floorThickness) * Math.tan(SLOT_ANGLE); const requiredStagger = 2 * Math.max(topSweep, botSweep) + slotWidth + STANDEE_GAP; const slotPitch = Math.max(baseDiameter + 1, 2 * requiredStagger); @@ -174,6 +183,7 @@ function computeLayout( trayWidth, trayDepth, trayHeight, + innerWallTopZ, leftWallX, rightWallX, innerWallThickness, @@ -280,8 +290,11 @@ export function createStandeeTray( const standee = getStandee(params.standeeId, standees); const layout = computeLayout(params, standee, targetHeight, floorSpacerHeight); - const { trayWidth, trayDepth, trayHeight, leftWallX, rightWallX } = layout; + const { trayWidth, trayDepth, trayHeight, innerWallTopZ, leftWallX, rightWallX } = layout; const wallHeight = trayHeight - floorThickness; + // Inner walls stop at the natural height (the slots/figures live there); the outer walls grow + // with the box, so a stretched-tall tray keeps the same inner-wall slot geometry. + const innerWallHeight = innerWallTopZ - floorThickness; // === OPEN-TOP BOX (floor + 4 outer walls) === const outerBox = translate( @@ -298,8 +311,8 @@ export function createStandeeTray( const innerCavityDepth = trayDepth - wallThickness * 2; const makeInnerWall = (frontFaceX: number): Geom3 => translate( - [frontFaceX + innerWallThickness / 2, trayDepth / 2, floorThickness + wallHeight / 2], - cuboid({ size: [innerWallThickness, innerCavityDepth, wallHeight] }) + [frontFaceX + innerWallThickness / 2, trayDepth / 2, floorThickness + innerWallHeight / 2], + cuboid({ size: [innerWallThickness, innerCavityDepth, innerWallHeight] }) ); // === ANGLED SLOTS === From 282311249017b453604079d9265a026be1e3f278 Mon Sep 17 00:00:00 2001 From: Dave Snider Date: Mon, 29 Jun 2026 23:52:07 -0400 Subject: [PATCH 5/9] standee trays raise floor in box --- src/lib/models/standeeTray.ts | 59 ++++++++++++++++++++--------------- 1 file changed, 34 insertions(+), 25 deletions(-) diff --git a/src/lib/models/standeeTray.ts b/src/lib/models/standeeTray.ts index b9c830e..11aa448 100644 --- a/src/lib/models/standeeTray.ts +++ b/src/lib/models/standeeTray.ts @@ -75,7 +75,8 @@ interface StandeeLayout { trayWidth: number; trayDepth: number; trayHeight: number; - innerWallTopZ: number; // top of the inner walls/slots — stays at the natural height when the box stretches the tray taller + floorRefZ: number; // top of the (raised) solid floor the standees rest on + innerWallTopZ: number; // top of the inner walls/slots (the rim); the walls run up from floorRefZ // X positions (front face of each inner wall) leftWallX: number; rightWallX: number; @@ -117,31 +118,38 @@ function computeLayout( // --- Height (Z) --- // The base lies on its side as a vertical disc (baseDiameter tall) and the figure (standeeWidth - // tall) is centred on the disc centre. The figure axis is at floor + baseRadius. + // tall) is centred on the disc centre. The figure axis sits a base-radius above whatever floor the + // standees rest on. const spacerHeight = floorSpacerHeight ?? 0; - const axisZ = floorThickness + baseRadius; - const contentTopZ = Math.max(floorThickness + baseDiameter, axisZ + standeeWidth / 2); - // Natural height the standee + rim need on their own. The inner walls, slots and the standee - // spacing below are all sized from this, so they never change when the box stretches the tray - // taller to match the other trays in a box/layer — otherwise the slot sweep (and therefore the - // tray depth) would grow with the box height and overflow the footprint the packer reserved. + // Natural height the standee + rim need on their own (measured from a floor at floorThickness). The + // inner walls, slots and the standee spacing below are all sized from this so they never change + // when the box stretches the tray taller — otherwise the slot sweep (and therefore the tray depth) + // would grow with the box height and overflow the footprint the packer reserved. + const naturalAxisZ = floorThickness + baseRadius; + const contentTopZ = Math.max(floorThickness + baseDiameter, naturalAxisZ + standeeWidth / 2); const naturalTrayHeight = contentTopZ + rimHeight; // Outer tray height: raise the rim to the box/layer height (targetHeight), then add any floor // spacer — same order as the counter tray so the box's height normalisation lines up. const trayHeightWithoutSpacer = targetHeight && targetHeight > naturalTrayHeight ? targetHeight : naturalTrayHeight; const trayHeight = trayHeightWithoutSpacer + spacerHeight; - // The inner walls (and their slots) stop at the natural height; only the outer walls grow. - const innerWallTopZ = naturalTrayHeight; + // When the tray is stretched taller, raise the floor with a solid spacer (like the counter tray) + // so the standees sit up near the rim rather than buried at the bottom. The whole standee layout — + // floor, inner walls, slots and figures — shifts up by this lift; the cavity above stays the + // natural size and the slot spacing is unchanged (it uses differences that cancel the lift). + const lift = trayHeight - naturalTrayHeight; + const floorRefZ = floorThickness + lift; // top of the raised solid floor the standees rest on + const axisZ = naturalAxisZ + lift; + const innerWallTopZ = naturalTrayHeight + lift; // inner walls run from the raised floor to the rim // --- Slot vertical extent (Z) --- // The slot holds the figure (centred on the axis) and stays open at the top of the inner wall so // the standee drops in. It plunges only as deep as the figure reaches: if the figure bottom is at - // or below the floor (standee as wide as / wider than the base) it cuts all the way through — - // extended a few mm below the floor so the angled cut leaves no sliver; otherwise it stops at the - // figure bottom. + // or below the (raised) floor the standee is as wide as / wider than the base, so it cuts all the + // way through — extended a few mm below the floor so the angled cut leaves no sliver; otherwise it + // stops at the figure bottom. const figureBottomZ = axisZ - standeeWidth / 2; - const cutsThrough = figureBottomZ <= floorThickness; - const slotBottomZ = cutsThrough ? floorThickness - 4 : figureBottomZ; + const cutsThrough = figureBottomZ <= floorRefZ; + const slotBottomZ = cutsThrough ? floorRefZ - 4 : figureBottomZ; const slotTopZ = innerWallTopZ + 1; // --- Depth (Y): one slot per standee --- @@ -152,7 +160,7 @@ function computeLayout( // exceed that larger one-sided sweep plus the slot width and a clearance gap, otherwise the // staggered slots — and the standees in them — would touch. const topSweep = (innerWallTopZ - axisZ) * Math.tan(SLOT_ANGLE); - const botSweep = (axisZ - floorThickness) * Math.tan(SLOT_ANGLE); + const botSweep = (axisZ - floorRefZ) * Math.tan(SLOT_ANGLE); const requiredStagger = 2 * Math.max(topSweep, botSweep) + slotWidth + STANDEE_GAP; const slotPitch = Math.max(baseDiameter + 1, 2 * requiredStagger); const staggerY = slotPitch / 2; // right wall offset so figures interleave @@ -183,6 +191,7 @@ function computeLayout( trayWidth, trayDepth, trayHeight, + floorRefZ, innerWallTopZ, leftWallX, rightWallX, @@ -286,15 +295,15 @@ export function createStandeeTray( floorSpacerHeight?: number, showEmboss: boolean = true ): Geom3 { - const { wallThickness, innerWallThickness, floorThickness } = params; + const { wallThickness, innerWallThickness } = params; const standee = getStandee(params.standeeId, standees); const layout = computeLayout(params, standee, targetHeight, floorSpacerHeight); - const { trayWidth, trayDepth, trayHeight, innerWallTopZ, leftWallX, rightWallX } = layout; - const wallHeight = trayHeight - floorThickness; - // Inner walls stop at the natural height (the slots/figures live there); the outer walls grow - // with the box, so a stretched-tall tray keeps the same inner-wall slot geometry. - const innerWallHeight = innerWallTopZ - floorThickness; + const { trayWidth, trayDepth, trayHeight, floorRefZ, innerWallTopZ, leftWallX, rightWallX } = layout; + // The cavity (and the inner walls) start at floorRefZ, which is raised above the real floor when + // the tray is stretched taller — leaving a solid spacer below so the standees sit near the rim. + const cavityHeight = trayHeight - floorRefZ; + const innerWallHeight = innerWallTopZ - floorRefZ; // === OPEN-TOP BOX (floor + 4 outer walls) === const outerBox = translate( @@ -302,8 +311,8 @@ export function createStandeeTray( cuboid({ size: [trayWidth, trayDepth, trayHeight] }) ); const innerCavity = translate( - [trayWidth / 2, trayDepth / 2, floorThickness + wallHeight / 2 + 0.1], - cuboid({ size: [trayWidth - wallThickness * 2, trayDepth - wallThickness * 2, wallHeight + 0.2] }) + [trayWidth / 2, trayDepth / 2, floorRefZ + cavityHeight / 2 + 0.1], + cuboid({ size: [trayWidth - wallThickness * 2, trayDepth - wallThickness * 2, cavityHeight + 0.2] }) ); let tray = subtract(outerBox, innerCavity); @@ -311,7 +320,7 @@ export function createStandeeTray( const innerCavityDepth = trayDepth - wallThickness * 2; const makeInnerWall = (frontFaceX: number): Geom3 => translate( - [frontFaceX + innerWallThickness / 2, trayDepth / 2, floorThickness + innerWallHeight / 2], + [frontFaceX + innerWallThickness / 2, trayDepth / 2, floorRefZ + innerWallHeight / 2], cuboid({ size: [innerWallThickness, innerCavityDepth, innerWallHeight] }) ); From a1d64b0677ecf179b82939dfbf75eb829a0b209e Mon Sep 17 00:00:00 2001 From: Dave Snider Date: Tue, 30 Jun 2026 07:30:13 -0400 Subject: [PATCH 6/9] standees no longer use herringbone pattern, added to default --- src/lib/data/defaultProject.json | 44 +++++++++++++++++++++++++------- src/lib/models/standeeTray.ts | 26 +++++++++++-------- 2 files changed, 50 insertions(+), 20 deletions(-) diff --git a/src/lib/data/defaultProject.json b/src/lib/data/defaultProject.json index e63bed3..a9c447a 100644 --- a/src/lib/data/defaultProject.json +++ b/src/lib/data/defaultProject.json @@ -269,7 +269,7 @@ "rotationOverride": "auto", "params": { "layout": { - "columns": [["lalhjbh", "l2mnoqz"], ["3k64by4"]] + "columns": [["l2mnoqz"], ["3k64by4"]] }, "stacks": [ { @@ -279,13 +279,6 @@ "count": 30, "rotation": 0 }, - { - "id": "75stmty", - "cellId": "lalhjbh", - "cardSizeId": "card-standard", - "count": 30, - "rotation": 0 - }, { "id": "dhiocl2", "cellId": "l2mnoqz", @@ -301,8 +294,41 @@ "clearance": 1, "rimHeight": 3 } + }, + { + "id": "rg4klk5", + "type": "standee", + "name": "Standee Tray", + "color": "#3d7a6a", + "rotationOverride": "auto", + "params": { + "standeeId": "standee-standard", + "count": 10, + "wallThickness": 2, + "innerWallThickness": 2, + "floorThickness": 2, + "clearance": 0.5, + "rimHeight": 2 + } } - ] + ], + "manualLayout": { + "boxes": [], + "looseTrays": [ + { + "trayId": "nrme206", + "x": 0, + "y": 0, + "rotation": 0 + }, + { + "trayId": "rg4klk5", + "x": 0, + "y": 97, + "rotation": 90 + } + ] + } } ], "counterShapes": [ diff --git a/src/lib/models/standeeTray.ts b/src/lib/models/standeeTray.ts index 11aa448..4110c96 100644 --- a/src/lib/models/standeeTray.ts +++ b/src/lib/models/standeeTray.ts @@ -153,17 +153,19 @@ function computeLayout( const slotTopZ = innerWallTopZ + 1; // --- Depth (Y): one slot per standee --- - // Spacing must clear both the base discs (baseDiameter + 1) and the angled slots. A slot tilted - // SLOT_ANGLE sweeps sideways (Y) above the figure axis (topSweep) and below it (botSweep). The - // opposing row tilts the other way and is staggered by half a pitch, so each slot approaches the - // next slot on the other wall on whichever side sweeps farther. The half-pitch must therefore - // exceed that larger one-sided sweep plus the slot width and a clearance gap, otherwise the - // staggered slots — and the standees in them — would touch. + // Both inner walls tilt their slots the SAME direction, so the standees on the two walls run + // parallel rather than converging like a herringbone — which lets the row pitch be set by the base + // discs (the widest part) instead of the slot's sideways sweep, packing the tray much shorter than + // opposed slots would. The right row is then offset by HALF a pitch so its standees sit centred + // between the left row's, so each reads clearly from above instead of hiding behind its neighbour. + // The pitch is floored at twice the parallel-figure clearance so that half-pitch offset always + // keeps opposing figures apart. topSweep/botSweep (the slot's sideways sweep above/below the figure + // axis) are still used for the end margins below. const topSweep = (innerWallTopZ - axisZ) * Math.tan(SLOT_ANGLE); const botSweep = (axisZ - floorRefZ) * Math.tan(SLOT_ANGLE); - const requiredStagger = 2 * Math.max(topSweep, botSweep) + slotWidth + STANDEE_GAP; - const slotPitch = Math.max(baseDiameter + 1, 2 * requiredStagger); - const staggerY = slotPitch / 2; // right wall offset so figures interleave + const figureClearance = slotWidth + STANDEE_GAP; // min offset for opposing parallel figures to clear + const slotPitch = Math.max(baseDiameter + 1, 2 * figureClearance); + const staggerY = slotPitch / 2; // half-pitch so the right row centres between the left row's standees // End margin so the end standees still slide in past the end walls. The base disc needs // baseRadius, and because the standee enters from the (tilted) top of the slot its base swings @@ -281,7 +283,7 @@ export function getStandeePositions( x: rightX, y: layout.firstSlotY + layout.staggerY + i * layout.slotPitch, figureDir: -1, - tilt: -SLOT_ANGLE + tilt: SLOT_ANGLE // same slant as the left row (figureDir still points inward from the right wall) }); } return positions; @@ -365,8 +367,10 @@ export function createStandeeTray( return wall; }; + // Both walls tilt the same way (SLOT_ANGLE), so the standees all slant in one direction; the right + // row is just nudged by staggerY so its figures interleave with the left row's in the middle. const leftWall = buildWall(leftWallX, layout.leftRowCount, SLOT_ANGLE, 0); - const rightWall = buildWall(rightWallX, layout.rightRowCount, -SLOT_ANGLE, layout.staggerY); + const rightWall = buildWall(rightWallX, layout.rightRowCount, SLOT_ANGLE, layout.staggerY); tray = union(tray, leftWall, rightWall); // === EMBOSS TRAY NAME ON BOTTOM === From b6276d6f667c07c1ce4639fdc903317fac02a3fb Mon Sep 17 00:00:00 2001 From: Dave Snider Date: Tue, 30 Jun 2026 07:53:01 -0400 Subject: [PATCH 7/9] custom width / heights now allowed --- .../panels/StandeeTrayEditor.svelte | 38 ++++++++++++++++++ src/lib/data/defaultProject.json | 4 +- src/lib/models/standeeTray.ts | 39 ++++++++++++++----- 3 files changed, 71 insertions(+), 10 deletions(-) diff --git a/src/lib/components/panels/StandeeTrayEditor.svelte b/src/lib/components/panels/StandeeTrayEditor.svelte index b46ff76..f6eb282 100644 --- a/src/lib/components/panels/StandeeTrayEditor.svelte +++ b/src/lib/components/panels/StandeeTrayEditor.svelte @@ -29,9 +29,21 @@ }; }); + // Auto dimensions (ignoring overrides) - used as placeholders / minimums for the width & depth inputs + let autoDimensions = $derived( + getStandeeTrayDimensions({ ...tray.params, trayWidthOverride: null, trayDepthOverride: null }, getStandees()) + ); + function updateParam(key: K, value: StandeeTrayParams[K]) { onUpdateParams({ ...tray.params, [key]: value }); } + + // Width/depth overrides: blank = auto, otherwise the value (clamped to the auto minimum at generation) + function updateSizeOverride(key: 'trayWidthOverride' | 'trayDepthOverride', raw: string) { + const trimmed = raw.trim(); + const parsed = parseFloat(trimmed); + updateParam(key, trimmed === '' || Number.isNaN(parsed) ? null : parsed); + }
@@ -159,6 +171,32 @@ {/snippet} {#snippet end()}mm{/snippet} + + {#snippet input({ inputProps })} + updateSizeOverride('trayWidthOverride', e.currentTarget.value)} + /> + {/snippet} + {#snippet end()}mm{/snippet} + + + {#snippet input({ inputProps })} + updateSizeOverride('trayDepthOverride', e.currentTarget.value)} + /> + {/snippet} + {#snippet end()}mm{/snippet} +
diff --git a/src/lib/data/defaultProject.json b/src/lib/data/defaultProject.json index a9c447a..ce16468 100644 --- a/src/lib/data/defaultProject.json +++ b/src/lib/data/defaultProject.json @@ -308,7 +308,9 @@ "innerWallThickness": 2, "floorThickness": 2, "clearance": 0.5, - "rimHeight": 2 + "rimHeight": 2, + "trayDepthOverride": null, + "trayWidthOverride": null } } ], diff --git a/src/lib/models/standeeTray.ts b/src/lib/models/standeeTray.ts index 4110c96..25cfd5d 100644 --- a/src/lib/models/standeeTray.ts +++ b/src/lib/models/standeeTray.ts @@ -30,6 +30,8 @@ export interface StandeeTrayParams { floorThickness: number; clearance: number; // Tolerance around standees rimHeight: number; // Extra height above the tallest content + trayWidthOverride: number | null; // null = auto from standees; acts as a minimum, content is centred + trayDepthOverride: number | null; // null = auto from standees; acts as a minimum, content is centred } export const defaultStandeeTrayParams: StandeeTrayParams = { @@ -39,7 +41,9 @@ export const defaultStandeeTrayParams: StandeeTrayParams = { innerWallThickness: 2.0, floorThickness: 2.0, clearance: 0.5, - rimHeight: 2.0 + rimHeight: 2.0, + trayWidthOverride: null, + trayDepthOverride: null }; // Helper to get a standee from the global standees by ID. @@ -77,7 +81,8 @@ interface StandeeLayout { trayHeight: number; floorRefZ: number; // top of the (raised) solid floor the standees rest on innerWallTopZ: number; // top of the inner walls/slots (the rim); the walls run up from floorRefZ - // X positions (front face of each inner wall) + // X positions (front face of each inner wall). The left wall is anchored to the left outer wall and + // the right wall to the right outer wall, so a custom width grows the middle cavity between them. leftWallX: number; rightWallX: number; innerWallThickness: number; @@ -172,9 +177,9 @@ function computeLayout( // toward the end by the slot's sweep before seating — so add that sweep on top of the radius. const endSweep = Math.max(topSweep, botSweep); const endMargin = wallThickness + clearance + baseRadius + endSweep; - const firstSlotY = endMargin; + const naturalFirstSlotY = endMargin; const lastSlotY = endMargin + (maxRowCount - 1) * slotPitch + staggerY; - const trayDepth = lastSlotY + baseRadius + endSweep + clearance + wallThickness; + const autoDepth = lastSlotY + baseRadius + endSweep + clearance + wallThickness; // --- Width (X) --- // The base is a thin vertical disc against the side wall, so the outer cavity only needs the base @@ -186,8 +191,21 @@ function computeLayout( const middleCavityWidth = Math.max(standeeLength + clearance - outerCavityWidth, outerCavityWidth); const leftWallX = wallThickness + outerCavityWidth; - const rightWallX = leftWallX + innerWallThickness + middleCavityWidth; - const trayWidth = rightWallX + innerWallThickness + outerCavityWidth + wallThickness; + const naturalRightWallX = leftWallX + innerWallThickness + middleCavityWidth; + const autoWidth = naturalRightWallX + innerWallThickness + outerCavityWidth + wallThickness; + + // --- Custom width / length override --- + // Each override is a minimum; values below the auto size are ignored. Widening keeps the outer + // walls and the outer cavities (where the bases sit) exactly where auto fit put them — only the + // MIDDLE cavity grows, so the two opposing rows spread apart. Lengthening centres the slot row + // along the depth. The outer-wall thickness never changes. + const trayWidth = params.trayWidthOverride != null ? Math.max(params.trayWidthOverride, autoWidth) : autoWidth; + const trayDepth = params.trayDepthOverride != null ? Math.max(params.trayDepthOverride, autoDepth) : autoDepth; + // Left inner wall stays put; the right inner wall is anchored to the right outer wall so the middle + // cavity absorbs the extra width. + const rightWallX = trayWidth - wallThickness - outerCavityWidth - innerWallThickness; + // Centre the slot row along the (possibly longer) depth. + const firstSlotY = naturalFirstSlotY + (trayDepth - autoDepth) / 2; return { trayWidth, @@ -254,7 +272,8 @@ export function getStandeePositions( const { wallThickness } = params; const { baseRadius, baseThickness, standeeWidth, standeeHeight, standeeThickness } = standee; - // Base disc sits flush against the side wall in the outer cavity. + // Base disc sits flush against the side outer wall in its outer cavity (unchanged by a custom + // width — only the middle cavity between the rows grows). const leftX = wallThickness + baseThickness / 2; const rightX = layout.trayWidth - wallThickness - baseThickness / 2; @@ -306,6 +325,9 @@ export function createStandeeTray( // the tray is stretched taller — leaving a solid spacer below so the standees sit near the rim. const cavityHeight = trayHeight - floorRefZ; const innerWallHeight = innerWallTopZ - floorRefZ; + // The cavity spans the whole interior (only the outer walls stay solid). A custom width just makes + // the middle cavity wider; a custom length just makes the cavity longer. + const innerCavityDepth = trayDepth - wallThickness * 2; // === OPEN-TOP BOX (floor + 4 outer walls) === const outerBox = translate( @@ -314,12 +336,11 @@ export function createStandeeTray( ); const innerCavity = translate( [trayWidth / 2, trayDepth / 2, floorRefZ + cavityHeight / 2 + 0.1], - cuboid({ size: [trayWidth - wallThickness * 2, trayDepth - wallThickness * 2, cavityHeight + 0.2] }) + cuboid({ size: [trayWidth - wallThickness * 2, innerCavityDepth, cavityHeight + 0.2] }) ); let tray = subtract(outerBox, innerCavity); // === TWO INNER WALLS spanning the depth === - const innerCavityDepth = trayDepth - wallThickness * 2; const makeInnerWall = (frontFaceX: number): Geom3 => translate( [frontFaceX + innerWallThickness / 2, trayDepth / 2, floorRefZ + innerWallHeight / 2], From daf564066c5e04938b3650ed5c2acb82e25b42c3 Mon Sep 17 00:00:00 2001 From: Dave Snider Date: Tue, 30 Jun 2026 07:57:20 -0400 Subject: [PATCH 8/9] cl --- src/lib/changelog/2026-06.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/lib/changelog/2026-06.md b/src/lib/changelog/2026-06.md index 36a11bb..b79f3ba 100644 --- a/src/lib/changelog/2026-06.md +++ b/src/lib/changelog/2026-06.md @@ -1,3 +1,7 @@ +#### Standee trays + +Added a new **standee tray** type for storing standees (cardboard figures on plastic bases) lying on their sides. Each standee drops into an angled slot in one of two opposing slotted walls, with its base resting against the side wall and its figure pointing toward the center. [#74](https://github.com/Siege-Perilous/counterslayer/pull/74) + #### Duplicate items Added the ability to duplicate trays and boxes. Right-click any tray or box in the sidebar or use the action menu to create a copy with all settings preserved. Duplicated items are added to the same layer and can be moved or modified independently. [#66](https://github.com/Siege-Perilous/counterslayer/pull/66) From 5176b1b269e79665b988ebf9e8a9921eebdb8a6e Mon Sep 17 00:00:00 2001 From: Dave Snider Date: Tue, 30 Jun 2026 08:01:12 -0400 Subject: [PATCH 9/9] linting --- src/lib/utils/storage.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/utils/storage.ts b/src/lib/utils/storage.ts index 9ff7c4a..912dcbc 100644 --- a/src/lib/utils/storage.ts +++ b/src/lib/utils/storage.ts @@ -508,7 +508,7 @@ export function migrateProjectData(project: Project | LegacyProject): Project { // Standees are a newer global - older projects won't have them. Start from any // existing array and backfill the defaults. - let standees: Standee[] = Array.isArray((project as { standees?: unknown }).standees) + const standees: Standee[] = Array.isArray((project as { standees?: unknown }).standees) ? (project as { standees: Standee[] }).standees.map((s) => (s.id ? s : { ...s, id: generateId() })) : []; const existingStandeeIds = new Set(standees.map((s) => s.id));