diff --git a/scripts/capture-view.ts b/scripts/capture-view.ts
index 859e7ad..2750eb6 100644
--- a/scripts/capture-view.ts
+++ b/scripts/capture-view.ts
@@ -29,6 +29,8 @@ function parseArgs() {
debugExport?: boolean;
view?: string;
trayId?: string;
+ boxId?: string;
+ counters?: boolean;
} = {};
for (let i = 0; i < args.length; i++) {
@@ -72,6 +74,13 @@ function parseArgs() {
result.trayId = next;
i++;
break;
+ case '--boxId':
+ result.boxId = next;
+ i++;
+ break;
+ case '--counters':
+ result.counters = true;
+ break;
case '--debug-export':
result.debugExport = true;
break;
@@ -194,6 +203,12 @@ 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');
+ }
// Load markers from file if specified
if (args.markers) {
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/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)
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/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/data/defaultProject.json b/src/lib/data/defaultProject.json
index f278d0a..ce16468 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,43 @@
"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,
+ "trayDepthOverride": null,
+ "trayWidthOverride": null
+ }
}
- ]
+ ],
+ "manualLayout": {
+ "boxes": [],
+ "looseTrays": [
+ {
+ "trayId": "nrme206",
+ "x": 0,
+ "y": 0,
+ "rotation": 0
+ },
+ {
+ "trayId": "rg4klk5",
+ "x": 0,
+ "y": 97,
+ "rotation": 90
+ }
+ ]
+ }
}
],
"counterShapes": [
@@ -392,6 +420,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/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/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/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..25cfd5d
--- /dev/null
+++ b/src/lib/models/standeeTray.ts
@@ -0,0 +1,457 @@
+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;
+
+// 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)
+ 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
+ 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 = {
+ 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,
+ trayWidthOverride: null,
+ trayDepthOverride: null
+};
+
+// 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;
+ 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). 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;
+ // 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;
+ // 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;
+ 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;
+ 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);
+
+ // --- 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 sits a base-radius above whatever floor the
+ // standees rest on.
+ const spacerHeight = floorSpacerHeight ?? 0;
+ // 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;
+ // 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 (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 <= floorRefZ;
+ const slotBottomZ = cutsThrough ? floorRefZ - 4 : figureBottomZ;
+ const slotTopZ = innerWallTopZ + 1;
+
+ // --- Depth (Y): one slot per standee ---
+ // 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 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
+ // 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 naturalFirstSlotY = endMargin;
+ const lastSlotY = endMargin + (maxRowCount - 1) * slotPitch + staggerY;
+ 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
+ // 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;
+ 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,
+ trayDepth,
+ trayHeight,
+ floorRefZ,
+ innerWallTopZ,
+ leftWallX,
+ rightWallX,
+ innerWallThickness,
+ leftRowCount,
+ rightRowCount,
+ firstSlotY,
+ slotPitch,
+ staggerY,
+ slotWidth,
+ axisZ,
+ slotBottomZ,
+ slotTopZ,
+ 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 };
+}
+
+// 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 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;
+
+ 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 // same slant as the left row (figureDir still points inward from the right wall)
+ });
+ }
+ return positions;
+}
+
+export function createStandeeTray(
+ params: StandeeTrayParams,
+ standees: Standee[],
+ trayName?: string,
+ targetHeight?: number,
+ floorSpacerHeight?: number,
+ showEmboss: boolean = true
+): Geom3 {
+ const { wallThickness, innerWallThickness } = params;
+ const standee = getStandee(params.standeeId, standees);
+ const layout = computeLayout(params, standee, targetHeight, floorSpacerHeight);
+
+ 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;
+ // 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(
+ [trayWidth / 2, trayDepth / 2, trayHeight / 2],
+ cuboid({ size: [trayWidth, trayDepth, trayHeight] })
+ );
+ const innerCavity = translate(
+ [trayWidth / 2, trayDepth / 2, floorRefZ + cavityHeight / 2 + 0.1],
+ cuboid({ size: [trayWidth - wallThickness * 2, innerCavityDepth, cavityHeight + 0.2] })
+ );
+ let tray = subtract(outerBox, innerCavity);
+
+ // === TWO INNER WALLS spanning the depth ===
+ const makeInnerWall = (frontFaceX: number): Geom3 =>
+ translate(
+ [frontFaceX + innerWallThickness / 2, trayDepth / 2, floorRefZ + innerWallHeight / 2],
+ cuboid({ size: [innerWallThickness, innerCavityDepth, innerWallHeight] })
+ );
+
+ // === 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 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 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;
+ };
+
+ // 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);
+ tray = union(tray, leftWall, rightWall);
+
+ // === 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..912dcbc 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.
+ 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));
+ 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..442bf8a 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, 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';
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);
}
@@ -341,12 +352,36 @@ 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)) {
+ // 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
const wellStacks = getCardWellPositions(tray.params, cardSizes, maxHeight, spacerHeight);
@@ -547,15 +582,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 +614,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 +638,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,9 +652,23 @@ 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);
+ selectedTrayCounters = getTrayPositions(
+ tray,
+ cardSizes,
+ counterShapes,
+ maxHeight,
+ selectedSpacerHeight,
+ standees
+ );
// Generate all trays for selected box
cachedAllTrays = [];
@@ -626,7 +677,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 });
@@ -643,17 +694,17 @@ 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))
};
});
// 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,16 +717,16 @@ 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);
+ selectedTrayCounters = getTrayPositions(tray, cardSizes, counterShapes, maxHeight, spacerHeight, standees);
cachedAllTrays = [{ jscadGeom: cachedSelectedTray, name: tray.name }];
cachedBox = null;
@@ -724,7 +775,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 +792,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 +808,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 +824,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 });
@@ -789,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))
};
});
@@ -833,13 +899,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({
@@ -855,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 0000000..82d4fab
Binary files /dev/null and b/static/stls/standees.stl differ