Bin designer
Skill andymai/gridfinity-layout-tool/.claude/skills/bin-designer
Custom bin editor (src/features/bin-designer/) — compartment grid model and the normalizeIdsWithRemap parallel-array rule, epoch-based worker regeneration via pushHistoryEntry, label tabs/compartmentTexts, wall cutouts/handles, and the client/server validation mirror in api/lib/designerValidation.ts. Load when adding a BinParams field, editing merge/split/setCompartmentGrid, labels land on the wrong compartment, cloud sync returns 400 INVALID_PARAMS, or the 3D preview stops updating.From its SKILL.md
npx -y skills add andymai/gridfinity-layout-tool --skill bin-designerAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
SKILL.md
14.9 KB, as published. Nobody here has run it
Bin Designer
When to use
- Adding or changing a designer parameter (
BinParams), constraint, or label-tab behavior. - Writing or modifying a compartment-grid mutation (merge/split/regrid, divider overrides).
- Debugging: labels/tilts on wrong compartments, sync 400s, preview not regenerating.
- For 2D layout-canvas interactions see the grid-editor skill; for BREP/worker internals see geometry-generation; for the layout planner's CQRS store see state-and-cqrs.
Mental model
Read src/features/bin-designer/README.md first — it covers the store/worker/persistence topology, mesh cache, ghost overlays, and cellMask. Facts it does not spell out:
- The designer store is not the CQRS store. It has its own history slice (
store/slices/historySlice.ts) with mesh-attached entries. Every geometry-affecting mutation instore/slices/paramSlice/callspushHistoryEntry(state)(store/helpers.ts), which bumpsgeneration.epoch;hooks/useGeneration.tsregenerates only on epoch change. Cosmetic mutations pass{ affectsGeometry: false }(undo entry, no worker run).restoreHistoryEntryrestores a cached mesh WITHOUT bumping the epoch. - Compartment IDs are unstable and everything hangs off them.
compartments.cellsis a cols×rows array of IDs (row 0 = visual bottom, UI flips withflex-col-reverse). IDs are contiguous from 0 and renumbered on every merge/split vianormalizeIdsWithRemap(utils/compartments.ts). Any parallel per-compartment array —compartmentTexts,dividerOverrides— must be reindexed in the sameset()viaremapCompartmentTexts/remapDividerOverrides. Display numbering ("Comp. N") comes fromgetCompartmentReadingOrder(visual top-left first, #2338) and is NOT the ID.compartmentTextsis normalized on write insetCompartmentText— clamped toTEXT_MAX_LENGTH(50,types/text.ts, mirrored inapi/lib/designerCompartmentValidation.ts), trailing empties popped, all-empty collapsed toundefined; route any new text write path through the same normalization. - Divider walls are derived, never stored —
deriveWallSegmentscomputes them from ID boundaries. Cells sharing an ID must form a filled rectangle (isRectangularSelection/validateCompartmentGrid); the server does NOT enforce this, so a hand-crafted share can violate it. - Every client constraint has a hand-maintained server mirror.
constants/gridfinity.tsDESIGNER_CONSTRAINTS↔api/lib/designerValidationConstants.tsCONSTRAINTS; type unions intypes/index.ts↔VALID_BIN_STYLES/VALID_LABEL_TAB_SUPPORTSinapi/lib/designerValidation.ts; top-level param keys ↔ALLOWED_PARAM_KEYSthere. Divergence means 400s on sync or params silently stripped from shares.sanitizeTags(server) must stay behaviorally identical tonormalizeTags(utils/tags.ts). - Wall-feature units are mixed by design.
WallCutoutwidth/depth are percentages (0–100 of wall span / wall height);widthMm: nullmeans "use the % field". Handle width is % of span but handle height is mm. See the JSDoc on each field intypes/index.ts— mixing these passes typecheck and produces silently wrong geometry.
Recipes
Add a field to BinParams
- Add the field to
src/features/bin-designer/types/index.tsand its default toDEFAULT_BIN_PARAMSinsrc/features/bin-designer/constants/defaults.ts. - Backfill legacy saved designs in
migrateParams(src/features/bin-designer/constants/defaults.ts) — saved designs and stored custom defaults both re-complete through it. Wall-specific legacy handling is factored intomigrateWalls(constants/paramMigration.ts). - Add a setter in
src/features/bin-designer/store/slices/paramSlice/that callspushHistoryEntry(state)before mutating ({ affectsGeometry: false }only if the worker never reads it). - Server: add the key to
ALLOWED_PARAM_KEYSinapi/lib/designerValidation.ts(otherwise it round-trips asundefinedthrough shares/sync); for enums or bounded numbers, extend the matchingVALID_*array andapi/lib/designerValidationConstants.ts. - Make the worker read it — see the geometry-generation skill; if it cuts a wall, it needs border clipping in
src/features/generation/worker/generators/wallPatternBuilder.ts(that skill owns the rule).
Add or change a compartment-grid mutation
- Write the pure version in
src/features/bin-designer/utils/compartments.ts: mutate a copy ofcells, then ALWAYS finish withnormalizeIdsWithRemapand thread the remap throughremapCompartmentTextsANDremapDividerOverrides— copy the pattern frommergeCells/splitCompartmentthere. - Mirror it as a store action in
paramSlice/. Note:paramSliceinlines the algorithm rather than calling the pure utils — a behavior change must land in BOTH copies or store and model diverge. - If the mutation changes grid DIMENSIONS there is no valid remap: use
carryCompartmentTextsByPositionand dropdividerOverrides, returning a dropped count for the UI warning — copysetCompartmentGrid(#2337). Beware: it also returns 0 whenvalidateCompartmentSizesrejects the grid (nothing mutated), so 0 does not mean "success". - Add tests in
utils/compartments.test.tsand a scenario instore/designer.scenario.compartments.test.tsasserting texts/overrides land on the right compartments.
Change a designer constraint
- Update
DESIGNER_CONSTRAINTSinsrc/features/bin-designer/constants/gridfinity.ts. - Update the mirrored value in
api/lib/designerValidationConstants.ts— skipping this makes valid client payloads 400 on sync. - Check derived constants noted in comments there (e.g.
MIN_LABEL_TAB_HEIGHT=MIN_LABEL_TAB_DEPTH+ 1;MAX_LABEL_TAB_HEIGHT= MAX_HEIGHT × heightUnitMm).
Change label-tab behavior
- Types:
LabelTabConfigintypes/index.ts; new enum values must also go intoVALID_LABEL_TAB_SUPPORTS/ the edges allowlist insidevalidateLabel(api/lib/designerValidation.ts). - UI:
components/panel/LabelTabsSection/useLabelTabsSection.tsowns stepper bounds and the warning derivation (priority: inset → depth → height → edges). The grid label editor iscomponents/CompartmentEditor/useCompartmentLabeling.ts(gated tostyle === 'standard'with >1 compartment). - Worker:
src/features/generation/worker/generators/labelTabBuilder.ts— indexescompartmentTexts?.[cellId]by compartment ID; inedges: 'both'mode it silently drops colliding front tabs, and tabs are suppressed when the anchor wall is tilted (compartmentHasTiltedBackWall/compartmentHasTiltedFrontWallinutils/compartments.ts). - Update
components/preview/GhostLabelTabs/so the generating-state preview matches.
Verification
pnpm run test:run src/features/bin-designer/store/designer.scenario.compartments src/features/bin-designer/utils/compartments
pnpm run test:run src/features/bin-designer
pnpm run test:run api/lib
pnpm run typecheck
Run the api/lib suite after ANY touch of the validation mirrors — a green client suite says nothing about sync. Failures show the exact validator message (e.g. label.height must be greater than label.depth). Before PR: pnpm run quality and pnpm run test:coverage; pnpm run check:i18n if you added UI strings.
Traps
| Symptom | Cause | Fix |
|---|---|---|
| Labels/divider tilts jump to wrong compartments after merge/split | Remap from normalizeIdsWithRemap not threaded through a parallel array | Remap every array in the same set(); copy paramSlice/ mergeCells |
| Sync/share returns 400 for designs that work locally | Client enum/range added without the server mirror | Grep api/lib for the field; extend VALID_* / CONSTRAINTS |
New param round-trips as undefined through share/sync | pickAllowedParams strips keys not in ALLOWED_PARAM_KEYS | Add the key in api/lib/designerValidation.ts |
| Preview never updates (or regenerates on lock/hide clicks) | Setter skipped pushHistoryEntry, or cosmetic prop defaulted to affectsGeometry: true | Fix the setter in paramSlice/; see store/helpers.ts |
| Error banner right after a compartment edit | useGeneration pre-flight validateCompartmentSizes rejected (cell < MIN_COMPARTMENT_SIZE 5mm) | Expected — surface via helpers in utils/validation.ts, never bypass |
| Engraving lands on the wrong "Comp. N" | Indexed compartmentTexts by display number instead of ID | Map through getCompartmentReadingOrder only at the display layer |
Front tab missing in edges: 'both' with no error | Builder drops it when 2·depth + 2·inset > compartment depth | By design — the useLabelTabsSection warning is the signal |
| One text edit re-runs every engraved-text solid | textSolidCache keys serialize the WHOLE compartmentTexts array | By design — don't assume per-entry caching |
| Duplicate history entries / worker re-runs on identical text | No-op guards removed from setCompartmentText / setCompartmentDividerHeight | Keep the guards; inputs commit on both idle-flush and blur |
Persisted JSON gains dividerHeight: 'auto' noise | 'auto' and undefined are the same state | setCompartmentDividerHeight omits the field for 'auto'; keep it that way |
Nested cutout groups
Cutout.parentGroups holds the enclosing groups outermost-first, excluding the
cutout's own groupId; absent means top level. Three invariants, each of which
changes geometry silently when broken:
- A group id is a boolean group XOR a container. A boolean group is some
cutout's
groupId: it owns thegroupOpand is allcutoutBuildersees. A container appears only inparentGroupsand has no op. Disjointness is what makes nesting invisible to the generator — wrapping a boolean group cannot change which shapes its op fuses. The store refuses to create anything inside a boolean group;paramMigration.normalizeGroupChainsstrips the container reading from any id a file uses both ways. - Every member of a group repeats the same chain, like
groupOp,colorandarrayalready do.normalizeGroupChainssettles a disagreeing file on the first member in array order. unitTag(cutout, context)is the one definition of "one thing" — read by canvas selection,shapeListModelandcutoutGroups' arrange units.contextis the drill-in level instore/cutoutSelection.ts;[]is the top, where a whole assembly is one unit. A loose direct child tags by its own id, never bynull, or one operation on it sweeps in its every sibling.
utils/cutoutHierarchy.ts owns the tree. withGroupChain infers "ends in a
boolean group?" from cutout.groupId, so removeGroup passes that flag
explicitly — removing a cutout's OWN group would otherwise promote its container
into the boolean slot.
Names live in BinParams.cutoutGroupNames, GC'd across BOTH cutout arrays (bin
and lid share one map) like meshAssets, and written with
affectsGeometry: false. Depth caps at MAX_GROUP_DEPTH, mirrored in
api/lib/designerValidationConstants.ts. Ungroup peels ONE level
(peelGroup); plain Group wraps when the selection reaches any group and forms
a boolean group only when it reaches loose shapes alone; an explicit op is
always the Pathfinder path.
For divider-override structure: pairs are canonical (compartmentA < compartmentB), unique, adjacent — enforced in both utils/compartments.ts (validateDividerOverride[s]) and api/lib/designerCompartmentValidation.ts. Structural validity does not guarantee the tilt renders (geometric checks happen at drag-commit and generation). getEligibleDividers row order is deliberately stable so panel rows don't shuffle — don't replace its sort with grid-scan order.
Designer shares have their own limits (MAX_PAYLOAD_BYTES 100KB in api/lib/designerValidationConstants.ts), smaller than the layout-share limits in api/lib/validation.ts — see the share-api-collab skill. Cloud-sync envelope rules live in src/features/bin-designer/sync/designAdapter.ts. For the placed-bin side panel (bin-inspector), read src/features/bin-inspector/README.md; custom-property keys must avoid RESERVED_PROPERTY_KEYS in src/core/constants.ts.
Compartment geometry and the size lock
A dividerOverride moves the compartment wall
The grid line is not the compartment edge. Anything sized or placed against a
compartment's X extent must go through compartmentTabXSpan() (utils/compartments.ts),
never -innerW/2 + col * cellW. Three layers have to agree: the worker shelf
(labelTabBuilder), the socket plate fit (labelSocketPlan) and the ghost overlay
(GhostLabelTabs). Disagreement floats the shelf off its wall, overhanging the
neighbour while its plate is sized for space it does not have.
The eligibility predicates only guard a TILTED anchor wall. A straight shift passes
them untouched, which is how this reaches production.
bin.update is the only thing enforcing the size lock
A bin with locked: true rejects any change to width/depth/height there, and
nothing else in the app is authoritative. Every flow that resizes on the user's behalf
must skip locked bins BEFORE dispatching: the group resize, rotateAll,
updateMultiHeight, the linked-design cascade and resolveExpandToFit each do, because
a batch that stops at the first rejection strands the bins already written.
Hiding the affordance is not enough, and neither is trusting the command. The guard compares against the bin's current values, so re-sending an unchanged dimension is deliberately allowed.