diff --git a/docs/research/cabinet-ceiling-gap.md b/docs/research/cabinet-ceiling-gap.md new file mode 100644 index 0000000000..4607109572 --- /dev/null +++ b/docs/research/cabinet-ceiling-gap.md @@ -0,0 +1,146 @@ +# Closing the space above wall and tall kitchen cabinets + +Research completed 2026-08-24. This note focuses on the common gap between the top of a wall/tall cabinet and the ceiling: what cabinet manufacturers actually offer, when designers use storage versus trim, and what a modular cabinet planner should model. + +## Short answer + +The normal solution is **not to stretch the existing wall cabinet or tall cabinet indiscriminately**. Designers choose one of four intentional terminations: + +1. **A taller primary cabinet** when the cabinet system offers a height that matches the room. +2. **A separate top/stacked cabinet** when the gap is large enough to provide useful, coordinated storage. +3. **A cover panel, filler, crown/deco molding, or closed soffit** when the gap is too small, the contents would be hard to reach, or the goal is a clean architectural line rather than more storage. +4. **A deliberately open gap** when it is being used for display or lighting and the design accepts the dust/maintenance trade-off. + +Manufacturers treat these as different products. IKEA recommends cover panels for a ceiling connection, rather than adapting a floor plinth; KraftMaid sells wall-cabinet heights that either reach an 8-ft ceiling or intentionally leave room for crown/lighting; and Medallion offers stacked wall-cabinet SKUs with a fixed intermediate rail and upper section. [IKEA: ceiling connection](https://www.ikea.com/se/en/customer-service/knowledge/articles/09b3fb5c-bgdf-4156-b0b3-48b48fc0933g.html), [KraftMaid: cabinet sizes](https://www.kraftmaid.com/kraftmaid/kitchen-cabinet-sizes), [Medallion: stacked wall cabinet](https://medallioncabinetry.com/quick-convenient-one-click-skus/) + +## The four practical approaches + +### 1. Use a taller wall or tall cabinet + +This is the cleanest option when the room and cabinet family have a compatible height. KraftMaid describes 42-in wall cabinets as full-height cabinets that can reach an 8-ft ceiling, while 36- and 39-in wall cabinets leave space for crown molding and above-cabinet accent lighting. It also describes tall-cabinet heights that are selected in relation to ceiling/soffit height, rather than simply scaling every cabinet. [KraftMaid cabinet sizes](https://www.kraftmaid.com/kraftmaid/kitchen-cabinet-sizes) + +For tall units, some manufacturers now provide unusually high products. Medallion advertises tall cabinets up to 120 in with height/depth/end modifications for floor-to-ceiling storage. That is a product-family choice, not a reason to make every tall cabinet an arbitrary height. [Medallion 120-in tall cabinets](https://medallioncabinetry.com/new-arrivals/120-tall-cabinets/) + +Use this approach when: + +- the upper reach remains reasonable for the intended storage; +- the resulting door/front proportions still look intentional; +- the cabinet family has a real manufactured height, finished end, and installation method; +- appliance ventilation and service requirements are unaffected. + +Do not use it merely to absorb a few millimetres or centimetres of site tolerance. That is what fillers and trim are for. + +### 2. Add a separate top/stacked cabinet + +Stacking creates useful storage while preserving the main wall cabinet's height, counter relationship, and front proportions. Medallion's stacked wall cabinet is specifically marketed for taller ceilings. Its combined cabinet heights are 48, 51, and 54 in with a 15-in upper section, and 57 and 60 in with an 18-in upper section. It has a fixed floor and intermediate rail; the upper section can be used for display, glass inserts, mullions, or lighting. [Medallion stacked wall cabinet](https://medallioncabinetry.com/quick-convenient-one-click-skus/) + +KraftMaid likewise says that wall cabinets shorter than 36 in can be combined and stacked for customized storage. Its planning material also lists “wall top hinge cabinets,” stacked crown molding, stacked glass-door cabinets, and tall stacked cabinetry as distinct design choices. [KraftMaid cabinet sizes](https://www.kraftmaid.com/kraftmaid/kitchen-cabinet-sizes), [KraftMaid decorative details](https://homedepot.kraftmaid.com/project-planning-and-budgeting/step-4-creating-your-budget/how-decisions-impact-budget/how-cabinet-choices-impact-budget/storage-solutions-decorative-details%E2%80%8B/) + +IKEA's METOD installation guidance also treats a wall cabinet on top of a high cabinet as an additional cabinet requiring an additional suspension rail. That is evidence for modeling it as a separate mounted module, not as an invisible height mutation of the tall cabinet. [IKEA: suspension rail heights](https://www.ikea.com/nl/en/customer-service/knowledge/articles/0d5d657f-6d3g-4bd0-g7db-997c72590e3b.html) + +Use stacking when: + +- the gap is large enough for a useful, repeatable upper module; +- the upper module can inherit the parent width and align its front/side panel; +- the user wants closed storage, display doors, or integrated lighting; +- the run benefits from a deliberate horizontal break between the main and upper doors. + +The upper module should usually be shallower or flush with the selected wall-cabinet depth, depending on the system. It should not silently inherit the tall cabinet's full base depth. + +### 3. Close the gap with panel, filler, crown, or soffit + +This is the appropriate answer when the remaining gap is not useful storage. IKEA explicitly recommends cover panels for a “ceiling connection” and says its plastic plinth is designed for cabinet legs/floor closure, not for adapting to a ceiling. IKEA also notes that the ceiling connection can match the cabinets or use a contrasting color; cover panels are available in widths and lengths that can be cut to fit. [IKEA: ceiling connection](https://www.ikea.com/se/en/customer-service/knowledge/articles/09b3fb5c-bgdf-4156-b0b3-48b48fc0933g.html) + +KraftMaid defines crown molding as a transition from cabinet to ceiling and as a way to hide accent lighting. It describes soffits as the architectural space commonly found above wall cabinets in older homes. [KraftMaid cabinet terminology](https://www.kraftmaid.com/getting-ready/cabinets-101/glossary/) + +Wood-Mode documents a modern example where floor-to-ceiling cabinets are capped with matching recessed trim to close the ceiling gap. Medallion also offers taller crown profiles for higher ceilings. [Wood-Mode: Savannah Trails Modern](https://www.wood-mode.com/get-inspired/kitchens/savannah-trails-modern/), [Medallion: Quiet Contemporary](https://medallioncabinetry.com/product/quiet-contemporary/) + +Use trim/soffit/panel when: + +- the gap is too short for a practical door and shelf; +- the top should be dust-free and visually quiet; +- a traditional room calls for crown, or a modern room calls for a square recessed cap; +- the gap contains ducting, wiring, or a lighting void that needs concealment; +- the designer wants one continuous ceiling datum across cabinets of different heights. + +Do not model a decorative cap as storage. It has a different depth, front, material, and installation behavior, and may need an intentional ventilation opening. + +### 4. Keep an intentional open gap + +An open shelf/gap can be used for display or uplighting, but it should be an explicit design mode rather than the accidental result of an unfilled ceiling. The manufacturer examples above distinguish crown/trim and closed storage from display sections with glass and lighting. If the user chooses this mode, the planner should make the dusting, lighting, and reach implications visible. + +## Wall cabinets versus tall cabinets + +### Wall cabinets + +Keep the normal wall cabinet height and counter relationship fixed. If the ceiling is higher, offer a stacked/top cabinet whose width comes from the wall cabinet's parent/base alignment. The top module can use a shorter door, glass door, open shelf, or a closed front. For a small residual gap, use a top filler or crown rather than creating a very short cabinet. + +KraftMaid's examples make the intent clear: a 42-in wall cabinet can reach an 8-ft ceiling, while 36/39-in cabinets leave space for crown and lighting. Thus the planner should expose a choice of system height, stacked upper, or decorative termination—not continuously scale the wall cabinet. [KraftMaid cabinet sizes](https://www.kraftmaid.com/kraftmaid/kitchen-cabinet-sizes) + +### Tall cabinets + +Tall cabinets are usually composed as a floor-to-ceiling mass around pantry/appliance functions. The choices are a system tall height, a separate top cabinet above the tall cabinet, or a matching cap/trim. IKEA specifically provides a rule for adding a wall cabinet on top of a high cabinet: it needs an additional rail above the existing rail. When a wall unit is next to a high cabinet, IKEA recommends using the same rail height so the tops are flush. [IKEA suspension rail heights](https://www.ikea.com/nl/en/customer-service/knowledge/articles/0d5d657f-6d3g-4bd0-g7db-997c72590e3b.html) + +For a run containing both tall and wall cabinets, the top of the tall composition and the top of the wall composition should be designed as one elevation. Either align their top datums, or use a clearly intentional step. Avoid a one-off tall cabinet ending slightly above or below an adjacent stack without a trim/end-panel explanation. + +## What an architect or kitchen designer checks + +The ceiling closure is an elevation/composition decision as much as a storage decision: + +1. **Measure the actual room.** Use finished floor-to-ceiling heights at several points; ceilings and floors may not be level. The top closure needs a scribe/tolerance strategy. +2. **Choose the datum.** Set the counter, wall-cabinet bottom, cabinet top, crown/cap line, and ceiling relationship before selecting individual modules. +3. **Group masses.** Tall pantry/oven/refrigerator units read as one vertical mass. A top box or cap should continue that mass across the full group, with finished end panels where visible. +4. **Keep proportions legible.** A very small upper cabinet can look like leftover space; a stacked module with a consistent height or a continuous trim band looks deliberate. +5. **Respect use and access.** Upper storage is only useful if the intended occupants can reach it. Place infrequently used items higher and offer display or trim options where upper storage would be impractical. +6. **Coordinate services.** Hood ducts, refrigerator ventilation, lighting, and electrical routes can occupy the upper zone. The Medallion designer example describes modifying cabinet heights for exhaust piping and using crown molding to conceal it. [Medallion designer profile](https://medallioncabinetry.com/evamarie-sibilia/) +7. **Resolve installation tolerances.** Use fillers/scribes at walls and a ceiling scribe or adjustable cap at the top. Do not rely on a rigid cabinet box being an exact fit to a variable ceiling. + +## Recommended Pascal model + +The cleanest product model is a separate **ceiling termination** attached to a wall or tall cabinet/run: + +```text +parent cabinet/run + └── ceiling termination + ├── none / open gap + ├── top cabinet (storage) + ├── stacked display cabinet (glass/open + lighting) + ├── soffit / closed box + └── crown / recessed cap / cover panel +``` + +Suggested behavior: + +- The termination inherits **width and horizontal placement** from its parent cabinet or aligned run. This is the same visual rule the user described for wall cabinets matching their base parent. +- Its **height** is computed from the measured ceiling datum minus the parent top, with a configurable installation tolerance/scribe. The user may choose a fixed upper-module height, leaving the remainder for trim, or let the termination fill the available zone. +- Its **depth is independent**. A top cabinet may be shallower than the parent, while a cap/soffit may be flush, recessed, or project as a trim profile. +- A stacked top cabinet gets its own front, hinge, shelf, and lighting properties. It must not mutate the base/wall/tall carcass height or counter alignment. +- A soffit/cap is non-storage and should render as a solid/trim volume, not as a cabinet with a fake door. +- If the calculated gap is below the minimum practical top-cabinet height, automatically recommend trim/soffit and keep the storage option disabled or marked impractical. +- For a tall cabinet with a top module, preserve an explicit second mounting/attachment relationship. For an adjacent wall/tall group, offer “align top datums” as a run-level action. +- Validate appliance ventilation and service zones before allowing a closed cap. IKEA explicitly calls out ventilation grilles at ceiling connections for appliances that require them. [IKEA ceiling connection](https://www.ikea.com/se/en/customer-service/knowledge/articles/09b3fb5c-bgdf-4156-b0b3-48b48fc0933g.html) + +### Sensible first implementation sequence + +1. Add a run/module side-panel section called **Top / ceiling finish**. +2. Start with modes `None`, `Top cabinet`, and `Trim/soffit`; keep all controls in the existing side panel. +3. For `Top cabinet`, inherit parent width, expose independent height/depth, and place it directly above the parent. +4. Add a ceiling target only when the scene has a ceiling/room datum; otherwise provide an explicit manual top height rather than pretending the gap is known. +5. Add `Crown` and `Recessed cap` profiles after the basic top-box geometry is stable. +6. Add alignment rules and tests for wall-over-base, top-over-tall, mixed wall/tall runs, width changes, and non-level/unknown ceiling conditions. + +## Sources + +- [IKEA — Can I use a plinth as a ceiling connection in my kitchen?](https://www.ikea.com/se/en/customer-service/knowledge/articles/09b3fb5c-bgdf-4156-b0b3-48b48fc0933g.html) +- [IKEA — How high do I mount the METOD suspension rail?](https://www.ikea.com/nl/en/customer-service/knowledge/articles/0d5d657f-6d3g-4bd0-g7db-997c72590e3b.html) +- [IKEA — METOD installation guide](https://www.ikea.com/th/en/files/pdf/31/1d/311d88a8/th22-kitchen_installation_guide.pdf) +- [KraftMaid — Kitchen cabinet sizes](https://www.kraftmaid.com/kraftmaid/kitchen-cabinet-sizes) +- [KraftMaid — Cabinet terminology](https://www.kraftmaid.com/getting-ready/cabinets-101/glossary/) +- [KraftMaid — Storage solutions and decorative details](https://homedepot.kraftmaid.com/project-planning-and-budgeting/step-4-creating-your-budget/how-decisions-impact-budget/how-cabinet-choices-impact-budget/storage-solutions-decorative-details%E2%80%8B/) +- [Medallion — Stacked wall cabinet](https://medallioncabinetry.com/quick-convenient-one-click-skus/) +- [Medallion — 120-in tall cabinets](https://medallioncabinetry.com/new-arrivals/120-tall-cabinets/) +- [Medallion — Designer profile: Evamarie Sibilia](https://medallioncabinetry.com/evamarie-sibilia/) +- [Medallion — Quiet Contemporary](https://medallioncabinetry.com/product/quiet-contemporary/) +- [Wood-Mode — Savannah Trails Modern](https://www.wood-mode.com/get-inspired/kitchens/savannah-trails-modern/) + +These are manufacturer/product sources and design examples, not a substitute for local building code, appliance installation instructions, structural fastening requirements, or a site measurement. diff --git a/docs/research/modular-kitchen-cabinets.md b/docs/research/modular-kitchen-cabinets.md new file mode 100644 index 0000000000..2a11c48764 --- /dev/null +++ b/docs/research/modular-kitchen-cabinets.md @@ -0,0 +1,138 @@ +# Modular kitchen cabinets: construction and planning research + +Research completed 2026-08-24. This note turns first-party cabinet-system, kitchen-planning, accessibility, quality, and material sources into design constraints for Pascal's modular kitchen cabinets. Dimensions are given in both imperial and metric where the source provides them. + +## Executive conclusion + +There is no single worldwide “standard cabinet.” A modular system is a *dimension family*: a small set of repeatable widths, depths, heights, fronts, fillers, end panels, plinths, and hardware boring patterns. A good planner keeps the family consistent, but allows height/depth adjustments for the room, user, appliances, and local construction practice. + +The most important product rule is to separate three layers: + +1. **Module geometry** — carcass, shelves, drawers, doors, appliance openings, toe kick/plinth, and finished ends. +2. **Run geometry** — alignment, fillers, corners, shared countertop, backsplash, plinth, island overhang, and tall-unit composition. +3. **Planning constraints** — circulation, landing areas, work zones, appliance manufacturer clearances, accessibility, services, and finish coordination. + +The “looks good” result comes primarily from consistent datums and reveals: cabinet fronts should align into intentional horizontal and vertical lines; fillers should absorb irregular room dimensions; tall units and appliances should be composed as architectural masses; and the material palette should be limited and repeated deliberately. Appearance cannot compensate for a door collision, missing landing area, unusable corner, or unventilated appliance. + +## What the sources actually standardize + +### Common dimension families + +| System / convention | Common dimensions | How to use it | +| --- | --- | --- | +| US-style framed/frameless catalog example | KraftMaid describes a 34.5 in (876 mm) base cabinet, usually paired with a 1.5 in (38 mm) countertop for a 36 in (914 mm) finished worktop. Base depth is commonly 24 in (610 mm); widths start at 6 in and commonly increase in 3 in increments to 48 in. Tall pantry depths are 12 or 24 in and heights start at 84 in, increasing in 3 in increments to 96 in. | Treat these as a North American preset family, not universal truths. [KraftMaid cabinet sizes](https://www.kraftmaid.com/kraftmaid/kitchen-cabinet-sizes) | +| IKEA METOD metric family | Base frame H80 cm; base depths D37 or D60 cm; widths W20, 30, 40, 60, 80 cm. Wall units use D37 cm and H40/60/80/100 cm options. Tall units include H140/200/220 cm and D60 cm options. METOD's published frame overview explicitly lists these combinations. | A clean metric preset for the planner. Keep the system's width/depth/height combinations explicit instead of allowing arbitrary values by default. [IKEA METOD cabinet guide](https://www.ikea.com/gb/en/files/pdf/f8/4f/f84f4466/metod-cabinets-guide.pdf) | +| METOD finished installation | IKEA publishes roughly 91–92 cm finished base height with an 80.2 cm frame, 8 cm legs, and a 2.8 or 3.8 cm worktop; it also describes a typical 36.6 cm wall frame depth and roughly 38–40 cm complete wall-unit depth. | Useful for metric ergonomics, but make legs, plinth, and worktop thickness independently configurable. [IKEA base height and wall depth](https://www.ikea.com/pl/pl/customer-service/knowledge/articles/17f088fe-4246-43g5-8831-7ggf7d55dgg5.html) | +| METOD internal capacity | Published internal widths are 16.4/26.4/36.4/56.4/76.4 cm for 20/30/40/60/80 cm frames; internal depths are 35 cm for D37 and 58 cm for D60. | Geometry and storage calculations must distinguish nominal outside size from clear internal size. [IKEA internal frame dimensions](https://www.ikea.com/se/en/customer-service/knowledge/articles/5g03d6cb-4ed3-4g4c-bd6d-52033730g335.html) | + +Do not silently convert between families. A 600 mm module, a 24 in module, and an appliance advertised as 60 cm may differ after side panels, fillers, ventilation gaps, and door overlays. Appliance installation documentation wins over a generic cabinet preset. + +### Construction and durability + +A useful construction abstraction is a rigid box plus replaceable/adjustable components: two sides, bottom, back, top rails or top, shelves, front(s), hardware, and a mounting system. Frameless and face-frame cabinetry are both valid; they change opening dimensions, overlay/reveal behavior, and hardware placement. The box must remain square and rigid even when a sink, oven, refrigerator, or removable back interrupts the normal carcass. + +For North American quality benchmarking, KCMA's current A161.1-2022 document covers general construction, shelf/bottom loading, mounted wall-cabinet loading, door and hinge operation, drawer operation, finish appearance, and finish resistance to heat, chemicals, detergent, water, and related stresses. [KCMA A161.1-2022 standard](https://kcma.org/sites/default/files/2024-08/KCMA%20A161.1%202022%20High%20Res.pdf) KCMA describes certification as third-party testing of structure, doors, drawers, and finish; examples include 600 lb wall-cabinet loading and 25,000 door/drawer cycles. [KCMA certification overview](https://kcma.org/certifications/kcma-quality-cabinet-certification2) + +Implementation implications: + +- Keep carcass dimensions and front dimensions separate. A change in overlay, reveal, or front thickness must not mutate the structural module. +- Model clear opening and service voids explicitly for appliances, plumbing, electrical, ventilation, and sink bowls. +- Make shelves adjustable by default where the module allows it; use pull-outs or drawers where deep shelves would hide contents. +- Provide a mounting datum/rail and leveling state for wall units. IKEA specifically recommends suspension rails because they make alignment easier. [METOD cabinet guide](https://www.ikea.com/gb/en/files/pdf/f8/4f/f84f4466/metod-cabinets-guide.pdf) +- Use tolerance-aware alignment: runs need a small, consistent reveal/gap and a way to absorb wall error with fillers and finished ends. +- Treat hardware as a first-class constraint. Blum's configurator performs collision checking, computes front weight, supports standard dimensions, and produces cutting/manufacturing data; this is a strong model for a parametric planner. [Blum Cabinet Configurator](https://www.blum.com/in/en/services/planning-construction-product-selection/cabinet-configurator/) + +## Placement rules that make a kitchen work + +NKBA's guideline documents are recommendations, not a substitute for local building code or appliance instructions. They are especially useful as planner warnings and defaults. + +### Circulation and work aisles + +- A general walkway should be at least 36 in (914 mm). When two walkways intersect perpendicularly, NKBA's access recommendation is at least 42 in (1067 mm) for one walkway. [NKBA Guideline 7](https://kb.nkba.org/uploads/2022/05/Kitchen-Planning-Guidelines.pdf) +- The NKBA scoring material identifies 42 in as the minimum work aisle for a one-cook kitchen and 48 in for more than one cook; it also checks that the work triangle is at most 26 ft total, with each leg 4–9 ft, and that traffic does not cross the triangle. [NKBA CKBD score sheet](https://kb.nkba.org/wp-content/uploads/2018/05/Grand-Kitchen-Scoresheet.pdf) +- A work aisle is different from a circulation walkway: measure from the furthest projecting cabinet, counter, or appliance face, and account for open doors/drawers. A planner should preview open-door states, not only closed cabinet rectangles. +- In seating areas, allow at least 32 in from counter/table edge to the obstruction behind a seated diner when no traffic passes behind; increase clearance when the area is also a passage. [NKBA Guideline 8](https://kb.nkba.org/uploads/2022/05/Kitchen-Planning-Guidelines.pdf) + +### Activity zones and landing areas + +Use zones (arrival/storage, refrigeration, preparation, cooking, cleanup, serving) as the primary mental model. The work triangle remains a useful collision/efficiency check, but a long kitchen or multi-cook kitchen should not be forced into one literal triangle. NKBA's own material describes both the triangle and activity-center guidance, and its examples emphasize clear traffic and landing spaces. [NKBA planning overview](https://kb.nkba.org/kitchen-bath-planning-guidelines/), [NKBA design examples](https://kb.nkba.org/2013/04/2012-nkba-ge-charette/) + +Useful defaults: + +- Provide a continuous preparation surface of at least 36 in W × 24 in D (914 × 610 mm) immediately next to the primary sink. [NKBA Guideline 12](https://kb.nkba.org/uploads/2022/05/Kitchen-Planning-Guidelines.pdf) +- Put the nearest edge of the dishwasher within 36 in (914 mm) of the nearest edge of the cleanup/prep sink, and reserve at least 21 in (533 mm) of standing space at the dishwasher. [NKBA Guideline 13](https://kb.nkba.org/uploads/2022/05/Kitchen-Planning-Guidelines.pdf) +- Give a cooking surface at least 12 in (305 mm) of landing on one side and 15 in (381 mm) on the other. In an island/peninsula with the same-height counter behind the cooktop, NKBA recommends at least 9 in (229 mm) of rear counter overhang. [NKBA Guideline 17](https://kb.nkba.org/uploads/2022/05/Kitchen-Planning-Guidelines.pdf) +- Give the refrigerator at least 15 in (381 mm) of landing on its handle side, on either side of a side-by-side refrigerator, or across from/above an undercounter unit; NKBA limits the across-the-way landing distance to 48 in (1219 mm). [NKBA Guideline 16](https://kb.nkba.org/uploads/2022/05/Kitchen-Planning-Guidelines.pdf) +- Check that appliance and cabinet doors do not collide with each other, walls, entries, or the work aisle. This is a core design validation, not a cosmetic detail. + +### Seating and islands + +For each diner, NKBA recommends approximately 24 in (610 mm) of width. Knee-space depth depends on counter height: 18 in at a 30 in table, 15 in at a 36 in counter, and 12 in at a 42 in counter. [NKBA Guideline 9](https://kb.nkba.org/uploads/2022/05/Kitchen-Planning-Guidelines.pdf) + +In the data model, an island should therefore expose counter height, seating side, seat count, per-seat width, knee clearance, and traffic clearance. Do not treat a rear overhang as an arbitrary visual extrusion: it changes the required aisle and collision envelope. + +## Accessibility and universal design + +ADA requirements apply to covered facilities and particular residential situations; they are not a universal residential cabinet-size rule. They are nevertheless excellent guardrails for inclusive defaults. + +The US Access Board's 2010 standards specify: pass-through kitchen clearance of at least 40 in (1015 mm), U-shaped kitchen clearance of at least 60 in (1525 mm), at least one 30 in (760 mm) wide work surface in covered dwelling units, a maximum work-surface height of 34 in (865 mm), and at least 50% of storage shelf space within the applicable reach range. Appliance clear floor spaces must be provided; an open dishwasher door must not block the sink or dishwasher clear space; cooktop controls must not require reaching across burners. [US Access Board, Kitchens and Kitchenettes](https://www.access-board.gov/ada/chapter/ch08/), [ADA.gov 2010 Standards](https://www.ada.gov/law-and-regs/design-standards/2010-stds/) + +Universal-design defaults worth supporting as options: + +- One lower or adjustable prep surface, rather than forcing the entire kitchen to one height. +- Drawers and full-extension pull-outs for heavy or frequently used items. +- A removable sink base and finished floor beneath an adaptable work surface. +- Reach-range warnings for wall cabinets and high pantry shelves. +- Controls and handles that can be operated without a tight pinch or a long reach. + +The NKBA's accessible-storage example recommends keeping commonly used storage roughly 18–48 in (457–1219 mm) above the floor and using pull-outs/pull-downs to bring contents into reach. [NKBA accessible storage example](https://kb.nkba.org/2019/07/pull-out-pull-down-go-deep/) + +## Appearance, color, and material defaults + +There is no authoritative “correct kitchen color.” Color should follow the room, light, architecture, client preference, and the selected material's maintenance behavior. A modular product should therefore ship with a restrained palette and allow variation without breaking alignment. + +Recommended product defaults (design heuristics, not standards): + +- **Base palette:** warm white, soft neutral gray, and a natural light/mid wood; keep carcass interiors light enough to see contents. +- **Contrast:** use one primary front color/material, one countertop/backsplash family, and one hardware/accent finish. A second front color should be an explicit two-tone option, not an accidental per-module choice. +- **Small kitchens:** favor lighter fronts, glass/open display only where appropriate, and avoid covering every wall with dark material. NKBA notes that light-colored cabinets, open shelving, and glass fronts can make a small room feel larger, while too many dark cabinets can make it feel smaller. [NKBA small-kitchen guidance](https://kb.nkba.org/2012/10/making-small-kitchen-space/) +- **Visual rhythm:** align drawer rails and door reveals across adjacent modules; repeat the same front style, edge profile, handle family, and reveal width across a run; use fillers at walls and corners rather than shrinking every module to fit. +- **Composition:** group tall cabinets/appliance towers; center the hood/cooktop or make an intentional offset; balance asymmetry with a deliberate open shelf, finished panel, or accent block. Avoid isolated 100–150 mm sliver cabinets unless they have a real function (tray, spice, filler, or pull-out). +- **Lighting:** model under-cabinet/task lighting as part of the kitchen composition. NKBA safety guidance recommends general lighting supplemented by focused task lighting without glare or shadows on work surfaces. [NKBA kitchen safety guidance](https://kb.nkba.org/2012/10/steps-safe-kitchen/) + +Material defaults should expose performance metadata, not only a swatch. For US-sold products containing MDF, particleboard, or hardwood plywood, EPA TSCA Title VI requires regulated composite wood and finished goods containing it to be certified/labeled; the rule includes third-party certification and recordkeeping. [EPA composite-wood formaldehyde requirements](https://www.epa.gov/formaldehyde/formaldehyde-emission-standards-composite-wood-products), [EPA consumer FAQ](https://www.epa.gov/formaldehyde/frequent-questions-consumers-about-formaldehyde-standards-composite-wood-products-act) + +For material selection, expose at least: front finish (paint, wood veneer, laminate, glass), carcass finish, countertop material, edge band, hardware finish, water/heat/chemical resistance, maintenance notes, and regional compliance labels. KCMA's ESP is a cabinet-specific environmental certification covering documented manufacturing and resource practices; it can be used as a sustainability signal but is not a substitute for local requirements. [KCMA ESP](https://kcma.org/environmental-standard-cabinetry) + +## How an architect or kitchen designer typically reasons + +The professional workflow is constraint-first and iterative: + +1. **Brief the household.** Who cooks, how many cooks, handedness, height, accessibility needs, meal patterns, entertaining, children, cleaning/recycling, small appliances, and desired visual character. +2. **Survey the room.** Record finished dimensions, out-of-square walls, windows/doors and swings, ceiling height, floor level, structure, plumbing, electrical, gas, ventilation route, radiators, and required clearances. IKEA's measuring guidance explicitly calls out room dimensions, corners, doors, plumbing, and related site details before planning. [IKEA measuring service](https://www.ikea.com/gb/en/customer-service/services/kitchen-measuring/) +3. **Lock the appliance set early.** Record exact model installation sheets, opening dimensions, ventilation, power, water, drainage, and door-swing requirements. Place the refrigerator, sink, dishwasher, and cooktop as activity centers, then add landing/work surfaces. +4. **Choose a cabinet family.** Select metric or imperial module widths, base/wall/tall heights, depth families, plinth/leg height, front/overlay style, and hardware system before filling the run. +5. **Draft plan and elevations.** Validate walkways, work aisles, triangles/zones, seating, landing areas, corners, open-door collisions, and service access. Then inspect elevations for consistent datums, reveals, tall-unit groupings, appliance alignment, and end treatments. +6. **Develop materials and lighting.** Limit the palette, test it against daylight and artificial light, specify durable cleanable surfaces, coordinate backsplash, countertop, handles, task lighting, and appliance finishes. +7. **Coordinate and document.** Produce plan, elevations, sections, module schedule, cut/filler list, appliance schedule, service plan, material schedule, and installation tolerances. A 3D preview is useful, but the dimensional schedule is the source of truth. +8. **Install and verify.** Level the run, anchor wall units to structure/rail, scribe fillers to walls, check reveals and door swings, seal wet areas, and verify appliance clearances and ventilation. + +## Recommended Pascal cabinet rules + +These are implementation recommendations derived from the sources above: + +- Make the cabinet family explicit: nominal width, nominal depth, carcass height, plinth/leg height, worktop thickness, front thickness, overlay/reveal, and system unit (metric or imperial). +- Keep `module`, `run`, and `room-planning-constraint` separate. A run owns shared countertop, plinth, finished backs, fillers, and end panels; a module owns carcass/front/storage/appliance content. +- Store nominal outside dimensions and computed clear inside dimensions separately. Include side-panel, front, back, shelf, hardware, and service-void thicknesses in the calculation. +- Add semantic warnings for walkway/work-aisle width, sink prep area, dishwasher distance/standing space, refrigerator/cooktop landing, seating knee space, door collisions, corner reachability, appliance requirements, and accessibility options. +- Give each module a role (`base`, `wall`, `tall`, `corner`, `filler`, `open`, `sink`, `cooktop`, `oven`, `dishwasher`, `refrigerator`, `pantry`) and role-specific constraints rather than a single generic box with ad hoc flags. +- Make fillers and finished ends real modules in layout and rendering. They are what make a standardized catalog fit imperfect walls and look intentional. +- Use one shared front/reveal/handle/material definition at run or kitchen scope with per-module overrides only when intentional. This avoids accidental checkerboard colors and inconsistent gaps. +- Validate both closed and open states. A kitchen that looks correct in a top-down closed-box view can fail when a dishwasher, drawer, oven, refrigerator, or corner mechanism opens. +- Keep color and material presets replaceable and regional. Aesthetic defaults should be easy to change; safety, accessibility, structural, and appliance constraints should be hard to bypass without an explicit override. + +## Source notes + +NKBA's fourth-edition overview says its guidelines cover activity centers, seating, cabinetry/casework, finishes/materials, storage, lighting, systems coordination, code compliance, universal design, and sustainability; it is a professional planning reference, not a building code. [NKBA overview](https://kb.nkba.org/kitchen-bath-planning-guidelines/) + +Always check the jurisdiction, appliance installation manual, countertop fabricator requirements, electrical/plumbing/ventilation code, and the actual cabinet manufacturer's construction instructions before treating any number above as buildable project documentation. diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index bf08161e06..2212040bcb 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -56,7 +56,13 @@ export { } from './nodes/block' export { BoxVentMaterialRole, BoxVentNode } from './nodes/box-vent' export { BuildingNode } from './nodes/building' -export { CabinetModuleNode, CabinetNode } from './nodes/cabinet' +export { + CABINET_METRIC_DEFAULTS, + CabinetFrontStyleSchema, + CabinetModuleNode, + CabinetNode, + CabinetTopFinishSchema, +} from './nodes/cabinet' export { CeilingNode } from './nodes/ceiling' export { ChimneyMaterialRole, ChimneyNode } from './nodes/chimney' export { diff --git a/packages/core/src/schema/nodes/cabinet.ts b/packages/core/src/schema/nodes/cabinet.ts index a0949a4967..c645154870 100644 --- a/packages/core/src/schema/nodes/cabinet.ts +++ b/packages/core/src/schema/nodes/cabinet.ts @@ -15,6 +15,15 @@ const cooktopFields = { } export const CabinetFrontStyleSchema = z.enum(['slab', 'shaker', 'raised-arch']) +export const CabinetTopFinishSchema = z.enum(['none', 'top-cabinet', 'trim']) + +/** Canonical metric cabinet family used when no regional profile is selected. */ +export const CABINET_METRIC_DEFAULTS = { + depth: 0.6, + carcassHeight: 0.8, + plinthHeight: 0.1, + countertopThickness: 0.02, +} as const // Discriminated on `type` so invalid field combinations (a drawer with a // pantry rack style, a fridge with burner state) are unrepresentable. New @@ -83,13 +92,17 @@ const cabinetBoxFields = { // Persisted slab-support host — see ItemNode.supportSlabId for the rules. supportSlabId: z.string().optional(), width: z.number().min(0.05).max(3).default(0.5), - depth: z.number().min(0.3).max(1.2).default(0.5), - carcassHeight: z.number().min(0.4).max(2.4).default(0.72), + depth: z.number().min(0.3).max(1.2).default(CABINET_METRIC_DEFAULTS.depth), + carcassHeight: z.number().min(0.4).max(2.4).default(CABINET_METRIC_DEFAULTS.carcassHeight), operationState: z.number().min(0).max(1).default(0), - plinthHeight: z.number().min(0).max(0.3).default(0.1), + plinthHeight: z.number().min(0).max(0.3).default(CABINET_METRIC_DEFAULTS.plinthHeight), toeKickDepth: z.number().min(0).max(0.2).default(0.075), boardThickness: z.number().min(0.01).max(0.08).default(0.018), - countertopThickness: z.number().min(0).max(0.08).default(0.02), + countertopThickness: z + .number() + .min(0) + .max(0.08) + .default(CABINET_METRIC_DEFAULTS.countertopThickness), countertopOverhang: z.number().min(0).max(0.12).default(0.02), // Extra slab reach off the back edge (island seating side) — up to a // 45 cm knee-space overhang, unlike the small uniform front/side overhang. @@ -144,6 +157,11 @@ export const CabinetModuleNode = BaseNode.extend({ // Corner-pocket fillers carry a small internal shelf so the dead corner reads // as reachable storage instead of an empty boxed void. cornerShelf: z.boolean().optional(), + // Optional upper termination for wall/tall compositions. It is deliberately + // separate from carcassHeight so the main cabinet proportions stay stable. + topFinish: CabinetTopFinishSchema.default('none'), + topFinishHeight: z.number().min(0.05).max(1.2).default(0.33), + topFinishDepth: z.number().min(0.15).max(1.2).default(0.32), ...cabinetBoxFields, }).describe('Parametric module inside a modular cabinet run') diff --git a/packages/nodes/src/cabinet/__tests__/ceiling-gap.test.ts b/packages/nodes/src/cabinet/__tests__/ceiling-gap.test.ts new file mode 100644 index 0000000000..9fd8cf0d33 --- /dev/null +++ b/packages/nodes/src/cabinet/__tests__/ceiling-gap.test.ts @@ -0,0 +1,28 @@ +import { expect, test } from 'bun:test' +import { type AnyNode, CabinetModuleNode, CabinetNode, LevelNode } from '@pascal-app/core' +import { cabinetCeilingGap } from '../run-ops' + +test('ceiling gap resolves the remaining space above a nested tall module', () => { + const level = LevelNode.parse({ id: 'level_ceiling-gap', height: 2.5 }) + const run = CabinetNode.parse({ + id: 'cabinet_ceiling-gap-run', + parentId: level.id, + children: ['cabinet-module_ceiling-gap-module'], + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_ceiling-gap-module', + parentId: run.id, + position: [0, 0.1, 0], + carcassHeight: 2.07, + showPlinth: false, + withCountertop: false, + }) + + expect( + cabinetCeilingGap(module, { + [level.id]: level, + [run.id]: run, + [module.id]: module, + } as Record), + ).toBeCloseTo(0.33) +}) diff --git a/packages/nodes/src/cabinet/__tests__/context-aware-depth.test.ts b/packages/nodes/src/cabinet/__tests__/context-aware-depth.test.ts index 01a35baf7d..746621bf35 100644 --- a/packages/nodes/src/cabinet/__tests__/context-aware-depth.test.ts +++ b/packages/nodes/src/cabinet/__tests__/context-aware-depth.test.ts @@ -107,12 +107,12 @@ describe('context-aware cabinet depth', () => { ) expect(baseLeg?.type).toBe('cabinet') if (baseLeg?.type !== 'cabinet') return - expect(baseLeg.depth).toBeCloseTo(0.5) + expect(baseLeg.depth).toBeCloseTo(0.6) const legModules = (baseLeg.children ?? []) .map((id) => sceneApi.get(id as AnyNodeId)) .filter((node) => node?.type === 'cabinet-module') - expect(legModules.every((module) => module.depth === 0.5)).toBe(true) + expect(legModules.every((module) => module.depth === 0.6)).toBe(true) expect(legModules.find((module) => module.name === 'Corner Filler')?.width).toBeCloseTo( source.depth, ) @@ -123,7 +123,7 @@ describe('context-aware cabinet depth', () => { run: sceneApi.get(run.id as AnyNodeId) as typeof run, sceneApi, }) - expect(sceneApi.get(baseLeg.id as AnyNodeId)?.depth).toBeCloseTo(0.5) + expect(sceneApi.get(baseLeg.id as AnyNodeId)?.depth).toBeCloseTo(0.6) expect( (sceneApi.get(baseLeg.id as AnyNodeId) as typeof baseLeg).children .map((id) => sceneApi.get(id as AnyNodeId)) @@ -163,7 +163,7 @@ describe('context-aware cabinet depth', () => { ) expect(bridge?.type).toBe('cabinet-module') if (bridge?.type !== 'cabinet-module') return - expect(bridge.width).toBeCloseTo(0.5 - 0.32) + expect(bridge.width).toBeCloseTo(0.6 - 0.32) expect(bridge.depth).toBeCloseTo(sourceWall.depth) const cornerWallFiller = Object.values(sceneApi.nodes()).find( @@ -233,7 +233,7 @@ describe('context-aware cabinet depth', () => { ) expect(bridge?.type).toBe('cabinet-module') if (bridge?.type !== 'cabinet-module') return - expect(bridge.width).toBeCloseTo(0.5 - 0.32) + expect(bridge.width).toBeCloseTo(0.6 - 0.32) expect(bridge.depth).toBeCloseTo(sourceWall.depth) const cornerWallFiller = Object.values(sceneApi.nodes()).find( diff --git a/packages/nodes/src/cabinet/__tests__/defaults.test.ts b/packages/nodes/src/cabinet/__tests__/defaults.test.ts index 9df50b6beb..6e8fb7908f 100644 --- a/packages/nodes/src/cabinet/__tests__/defaults.test.ts +++ b/packages/nodes/src/cabinet/__tests__/defaults.test.ts @@ -1,8 +1,15 @@ import { expect, test } from 'bun:test' -import type { AnyNode, AnyNodeId, SceneApi } from '@pascal-app/core' -import { cabinetPresetById } from '../presets' +import { + type AnyNode, + type AnyNodeId, + CABINET_METRIC_DEFAULTS, + CabinetModuleNode, + CabinetNode, + type SceneApi, +} from '@pascal-app/core' +import { cabinetDefinition, cabinetModuleDefinition } from '../definition' +import { CABINET_PRESETS, cabinetPresetById } from '../presets' import { addWallChildAbove } from '../run-ops' -import { CabinetModuleNode, CabinetNode } from '../schema' function sceneApiFixture(seed: AnyNode[]): SceneApi { const nodes = Object.fromEntries(seed.map((node) => [node.id, node])) as Record< @@ -45,6 +52,58 @@ test('the default base cabinet preset uses overlay fronts', () => { expect(cabinetPresetById('base-door').createPatch().frontOverlay).toBe('full') }) +test('cabinet creation defaults use the metric 600 mm family', () => { + const run = CabinetNode.parse({}) + const module = CabinetModuleNode.parse({}) + + expect(run).toMatchObject({ + depth: CABINET_METRIC_DEFAULTS.depth, + carcassHeight: CABINET_METRIC_DEFAULTS.carcassHeight, + plinthHeight: CABINET_METRIC_DEFAULTS.plinthHeight, + countertopThickness: CABINET_METRIC_DEFAULTS.countertopThickness, + }) + expect(module).toMatchObject({ + depth: CABINET_METRIC_DEFAULTS.depth, + carcassHeight: CABINET_METRIC_DEFAULTS.carcassHeight, + topFinish: 'none', + topFinishHeight: 0.33, + }) + expect(cabinetDefinition.defaults()).toMatchObject({ + depth: CABINET_METRIC_DEFAULTS.depth, + carcassHeight: CABINET_METRIC_DEFAULTS.carcassHeight, + }) + expect(cabinetModuleDefinition.defaults()).toMatchObject({ + depth: CABINET_METRIC_DEFAULTS.depth, + carcassHeight: CABINET_METRIC_DEFAULTS.carcassHeight, + }) + for (const preset of CABINET_PRESETS) { + expect(preset.createPatch().depth).toBeCloseTo(CABINET_METRIC_DEFAULTS.depth) + } +}) + +test('tall modules expose a visible height resize handle', () => { + const run = CabinetNode.parse({ + id: 'cabinet_height-handle-run', + children: ['cabinet-module_height-handle-module'], + }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_height-handle-module', + parentId: run.id, + cabinetType: 'tall', + }) + const sceneApi = sceneApiFixture([run as AnyNode, module as AnyNode]) + const handles = + typeof cabinetModuleDefinition.handles === 'function' + ? cabinetModuleDefinition.handles(module, sceneApi) + : cabinetModuleDefinition.handles + const heightHandle = handles?.find( + (handle) => handle.kind === 'linear-resize' && handle.axis === 'y', + ) + + expect(heightHandle).toBeDefined() + expect(heightHandle?.visible?.(module, sceneApi)).not.toBe(false) +}) + test('a wall cabinet added from an inset base starts with overlay fronts', () => { const run = CabinetNode.parse({ id: 'cabinet_default-front-run', diff --git a/packages/nodes/src/cabinet/__tests__/front-family.test.ts b/packages/nodes/src/cabinet/__tests__/front-family.test.ts new file mode 100644 index 0000000000..559f4d60a8 --- /dev/null +++ b/packages/nodes/src/cabinet/__tests__/front-family.test.ts @@ -0,0 +1,55 @@ +import { expect, test } from 'bun:test' +import type { AnyNode, AnyNodeId, SceneApi } from '@pascal-app/core' +import { applyCabinetModuleFrontPatch } from '../run-ops' +import { CabinetModuleNode, CabinetNode } from '../schema' + +test('module front settings propagate to its nested wall and top cabinet', () => { + const run = CabinetNode.parse({ + id: 'cabinet_front-family-run', + children: ['cabinet-module_front-family-base'], + }) + const base = CabinetModuleNode.parse({ + id: 'cabinet-module_front-family-base', + parentId: run.id, + children: ['cabinet-module_front-family-wall'], + frontOverlay: 'full', + frontStyle: 'slab', + }) + const wall = CabinetModuleNode.parse({ + id: 'cabinet-module_front-family-wall', + parentId: base.id, + frontOverlay: 'full', + frontStyle: 'slab', + topFinish: 'top-cabinet', + }) + const nodes = Object.fromEntries( + [run, base, wall].map((node) => [node.id as AnyNodeId, node as AnyNode]), + ) as Record + const sceneApi = { + get: (id: AnyNodeId) => nodes[id] as N | undefined, + nodes: () => nodes, + update: (id: AnyNodeId, patch: Partial) => { + nodes[id] = { ...nodes[id], ...patch } as AnyNode + }, + markDirty: () => {}, + } as SceneApi + + applyCabinetModuleFrontPatch({ + module: base, + patch: { frontOverlay: 'inset', frontStyle: 'raised-arch' }, + sceneApi, + }) + + expect(sceneApi.get(base.id)?.frontOverlay).toBe('inset') + expect(sceneApi.get(wall.id)?.frontOverlay).toBe('inset') + expect(sceneApi.get(wall.id)?.frontStyle).toBe('raised-arch') + + applyCabinetModuleFrontPatch({ + module: sceneApi.get(base.id)!, + patch: { frontOverlay: 'full', frontStyle: 'slab' }, + sceneApi, + }) + + expect(sceneApi.get(wall.id)?.frontOverlay).toBe('full') + expect(sceneApi.get(wall.id)?.frontStyle).toBe('slab') +}) diff --git a/packages/nodes/src/cabinet/__tests__/geometry.test.ts b/packages/nodes/src/cabinet/__tests__/geometry.test.ts index 3411d9e143..8122087b6b 100644 --- a/packages/nodes/src/cabinet/__tests__/geometry.test.ts +++ b/packages/nodes/src/cabinet/__tests__/geometry.test.ts @@ -1123,26 +1123,21 @@ describe('buildCabinetGeometry — appliance compartments', () => { expect(hinge.rotation.y).toBeGreaterThan(1.9) }) - test('fridge cabinet fills tall-carcass remainder with a drawer front above the fridge', () => { + test('fridge cabinet carcass ends at the appliance without a top filler', () => { const node = CabinetModuleNode.parse({ cabinetType: 'tall', width: FRIDGE_COLUMN_WIDTH, depth: FRIDGE_STANDARD_DEPTH, - carcassHeight: TALL_CABINET_CARCASS_HEIGHT, + carcassHeight: FRIDGE_COLUMN_HEIGHT, showPlinth: false, stack: fridgeCabinetStack('fridge-single'), }) const group = buildCabinetGeometry(node, undefined, 'rendered', false) - const fridgePanel = worldBounds( - findMeshByName(group, 'cabinet-fridge-single-0-door-single-panel'), - ) - const drawerFront = worldBounds(findMeshByNamePrefix(group, 'cabinet-drawer-front-')) const cabinetTop = worldBounds(findMeshByName(group, 'cabinet-top')) - expect(cabinetTop.max.y).toBeCloseTo(TALL_CABINET_CARCASS_HEIGHT) - expect(fridgePanel.max.y).toBeLessThan(drawerFront.min.y) - expect(drawerFront.max.y).toBeCloseTo(TALL_CABINET_CARCASS_HEIGHT) + expect(cabinetTop.max.y).toBeCloseTo(FRIDGE_COLUMN_HEIGHT) + expect(() => findMeshByNamePrefix(group, 'cabinet-drawer-front-')).toThrow() }) test('double refrigerator opens opposing side-by-side leaves', () => { @@ -2343,7 +2338,7 @@ describe('cabinet handles', () => { const leftHandle = widthHandles.find((handle) => handle.anchor === 'max') const rightHandle = widthHandles.find((handle) => handle.anchor === 'min') - expect(handles).toHaveLength(3) + expect(handles).toHaveLength(4) expect(leftHandle).toBeDefined() expect(rightHandle).toBeDefined() expect(leftHandle!.apply(node, 0.8, null as never).position?.[0]).toBeCloseTo(-0.1) diff --git a/packages/nodes/src/cabinet/__tests__/panel-visibility.test.ts b/packages/nodes/src/cabinet/__tests__/panel-visibility.test.ts new file mode 100644 index 0000000000..0a6a4a04e4 --- /dev/null +++ b/packages/nodes/src/cabinet/__tests__/panel-visibility.test.ts @@ -0,0 +1,27 @@ +import { expect, test } from 'bun:test' +import { CabinetModuleNode } from '@pascal-app/core' +import { cabinetModuleSupportsPresets, cabinetModuleSupportsTopFinish } from '../panel-visibility' + +test.each([ + 'Corner Filler', + 'Wall Bridge Filler', + 'Corner Wall Filler', +])('%s supports a top or ceiling finish without relying on its parent run', (name) => { + const module = CabinetModuleNode.parse({ moduleKind: 'corner-filler', name }) + + expect(cabinetModuleSupportsTopFinish({ module, parentIsModule: false })).toBe(true) +}) + +test('an ordinary base module still omits the top or ceiling finish controls', () => { + const module = CabinetModuleNode.parse({ cabinetType: 'base' }) + + expect(cabinetModuleSupportsTopFinish({ module, parentIsModule: false })).toBe(false) +}) + +test('structural corner fillers cannot be converted with cabinet presets', () => { + const filler = CabinetModuleNode.parse({ moduleKind: 'corner-filler', name: 'Corner Filler' }) + const cabinet = CabinetModuleNode.parse({ moduleKind: 'standard', name: 'Base Cabinet' }) + + expect(cabinetModuleSupportsPresets(filler)).toBe(false) + expect(cabinetModuleSupportsPresets(cabinet)).toBe(true) +}) diff --git a/packages/nodes/src/cabinet/__tests__/profiles.test.ts b/packages/nodes/src/cabinet/__tests__/profiles.test.ts new file mode 100644 index 0000000000..9d3661b03f --- /dev/null +++ b/packages/nodes/src/cabinet/__tests__/profiles.test.ts @@ -0,0 +1,46 @@ +import { expect, test } from 'bun:test' +import { cabinetDimensionProfileById, cabinetDimensionProfileId } from '../profiles' + +test('recognizes the metric base profile', () => { + expect( + cabinetDimensionProfileId({ + depth: 0.6, + carcassHeight: 0.8, + plinthHeight: 0.1, + countertopThickness: 0.02, + }), + ).toBe('metric-base') +}) + +test('recognizes the US base profile with small measurement noise', () => { + expect( + cabinetDimensionProfileId({ + depth: 0.60960001, + carcassHeight: 0.762, + plinthHeight: 0.1016, + countertopThickness: 0.0381, + }), + ).toBe('us-base') +}) + +test('keeps custom dimensions distinguishable from standard profiles', () => { + expect( + cabinetDimensionProfileId({ + depth: 0.58, + carcassHeight: 0.8, + plinthHeight: 0.1, + countertopThickness: 0.02, + }), + ).toBe('custom') +}) + +test('returns the complete profile used by the side-panel action', () => { + expect(cabinetDimensionProfileById('metric-base')).toEqual({ + id: 'metric-base', + label: 'Metric · 600 mm', + depth: 0.6, + carcassHeight: 0.8, + plinthHeight: 0.1, + countertopThickness: 0.02, + }) +}) diff --git a/packages/nodes/src/cabinet/__tests__/reveals.test.ts b/packages/nodes/src/cabinet/__tests__/reveals.test.ts new file mode 100644 index 0000000000..3ec8d09cb8 --- /dev/null +++ b/packages/nodes/src/cabinet/__tests__/reveals.test.ts @@ -0,0 +1,18 @@ +import { expect, test } from 'bun:test' +import { CabinetModuleNode, CabinetNode } from '@pascal-app/core' +import { CABINET_REVEAL_GAPS, cabinetRevealGapById, cabinetRevealGapId } from '../reveals' + +test('standard reveal presets use millimetre values', () => { + expect(CABINET_REVEAL_GAPS.map((gap) => gap.value)).toEqual([0.002, 0.003, 0.004, 0.006]) + expect(cabinetRevealGapById('3')).toMatchObject({ label: '3 mm', value: 0.003 }) +}) + +test('custom reveal values stay visible as custom', () => { + expect(cabinetRevealGapId(0.003)).toBe('3') + expect(cabinetRevealGapId(0.005)).toBe('custom') +}) + +test('cabinet defaults keep the architectural 3 mm reveal', () => { + expect(CabinetNode.parse({}).frontGap).toBe(0.003) + expect(CabinetModuleNode.parse({}).frontGap).toBe(0.003) +}) diff --git a/packages/nodes/src/cabinet/__tests__/run-ops.test.ts b/packages/nodes/src/cabinet/__tests__/run-ops.test.ts index 4777e96e58..bdaffd487a 100644 --- a/packages/nodes/src/cabinet/__tests__/run-ops.test.ts +++ b/packages/nodes/src/cabinet/__tests__/run-ops.test.ts @@ -141,7 +141,7 @@ describe('addCabinetModuleSide', () => { } }) - test('adds a default base cabinet at 0.5m wide and 0.5m deep', () => { + test('adds a default base cabinet at 0.5m wide and 0.6m deep', () => { const levelId = 'level_add-side-default-size' as AnyNodeId const run = CabinetNode.parse({ id: 'cabinet_run-add-side-default-size', @@ -161,7 +161,7 @@ describe('addCabinetModuleSide', () => { expect(id).toBeTruthy() const added = sceneApi.get(id!) expect(added?.width).toBeCloseTo(0.5) - expect(added?.depth).toBeCloseTo(0.5) + expect(added?.depth).toBeCloseTo(0.6) }) test('shrinks a newly added corner-end base cabinet to the remaining wall clearance', () => { @@ -942,7 +942,7 @@ describe('addCornerRun', () => { ) const connectedBase = derivedModules.find((module) => module.name === 'Base Cabinet')! expect(wallChildOf(connectedBase, sceneApi.nodes())?.width).toBeCloseTo(0.6) - expect(derivedRun.depth).toBeCloseTo(0.5) + expect(derivedRun.depth).toBeCloseTo(0.6) } for (const filler of cornerWallFillers) { expect(sceneApi.get(filler.id as AnyNodeId)?.width).toBeCloseTo(0.32) @@ -1965,7 +1965,7 @@ describe('addCornerRun', () => { ) const bridgeFillers = modulesOut.filter((node) => node.name === 'Wall Bridge Filler') expect(bridgeFillers).toHaveLength(1) - expect(bridgeFillers[0]?.width).toBeCloseTo(0.5 - 0.32) + expect(bridgeFillers[0]?.width).toBeCloseTo(0.6 - 0.32) const linkedBase = modulesOut.find( (node) => node.id !== module.id && node.name === 'Base Cabinet', diff --git a/packages/nodes/src/cabinet/__tests__/run-reflow.test.ts b/packages/nodes/src/cabinet/__tests__/run-reflow.test.ts new file mode 100644 index 0000000000..f28451fc47 --- /dev/null +++ b/packages/nodes/src/cabinet/__tests__/run-reflow.test.ts @@ -0,0 +1,1345 @@ +import { afterEach, beforeAll, describe, expect, test } from 'bun:test' +import { + type AnyNode, + type AnyNodeId, + createSceneApi, + LevelNode, + SiteNode, + useScene, + WallNode, +} from '@pascal-app/core' +import { cabinetPresetById } from '../presets' +import { runMaxX, runMinX, runWallConstraints } from '../run-layout' +import { addCornerRun } from '../run-ops' +import { reflowRunModules } from '../run-panel' +import { CabinetModuleNode, CabinetNode } from '../schema' + +function worldTransform( + node: ReturnType | ReturnType, + nodes: Record, +): { position: [number, number, number]; rotation: number } { + const parent = node.parentId ? nodes[node.parentId as AnyNodeId] : null + if (parent?.type !== 'cabinet' && parent?.type !== 'cabinet-module') { + return { position: [...node.position], rotation: node.rotation } + } + + const parentTransform = worldTransform(parent, nodes) + const cos = Math.cos(parentTransform.rotation) + const sin = Math.sin(parentTransform.rotation) + return { + position: [ + parentTransform.position[0] + node.position[0] * cos + node.position[2] * sin, + parentTransform.position[1] + node.position[1], + parentTransform.position[2] - node.position[0] * sin + node.position[2] * cos, + ], + rotation: parentTransform.rotation + node.rotation, + } +} + +function worldPosition( + node: ReturnType | ReturnType, + nodes: Record, +): [number, number, number] { + return worldTransform(node, nodes).position +} + +function moduleWorldBounds( + modules: ReturnType[], + nodes: Record, +) { + const points = modules.flatMap((module) => { + const { position, rotation } = worldTransform(module, nodes) + const cos = Math.cos(rotation) + const sin = Math.sin(rotation) + return [-1, 1].flatMap((xSign) => + [-1, 1].map((zSign) => { + const x = (xSign * module.width) / 2 + const z = (zSign * module.depth) / 2 + return [position[0] + x * cos + z * sin, position[2] - x * sin + z * cos] + }), + ) + }) + + return { + minX: Math.min(...points.map(([x]) => x)), + maxX: Math.max(...points.map(([x]) => x)), + minZ: Math.min(...points.map(([, z]) => z)), + maxZ: Math.max(...points.map(([, z]) => z)), + } +} + +function runModuleBounds(runId: AnyNodeId, nodes: Record) { + const run = nodes[runId] + const modules = + run?.type === 'cabinet' + ? run.children + .map((id) => nodes[id]) + .filter((node): node is ReturnType => + Boolean(node?.type === 'cabinet-module'), + ) + : [] + return moduleWorldBounds(modules, nodes) +} + +function moduleSubtreeBounds(rootId: AnyNodeId, nodes: Record) { + const pending = [rootId] + const modules: ReturnType[] = [] + + while (pending.length > 0) { + const id = pending.pop()! + const node = nodes[id] + if (!node) continue + if (node.type === 'cabinet-module') modules.push(node) + if ('children' in node && Array.isArray(node.children)) { + pending.push(...(node.children as AnyNodeId[])) + } + } + + return moduleWorldBounds(modules, nodes) +} + +function derivedBaseRunForSource( + sourceId: AnyNodeId, + nodes: Record, +): ReturnType { + return Object.values(nodes).find((node): node is ReturnType => { + if (node.type !== 'cabinet' || node.runTier !== 'base') return false + const link = (node.metadata as Record | null)?.cabinetCornerDerivedRun + return ( + Boolean(link && typeof link === 'object' && !Array.isArray(link)) && + (link as { sourceModuleId?: unknown }).sourceModuleId === sourceId + ) + })! +} + +function seedScene(nodes: AnyNode[], levelId: AnyNodeId) { + useScene.setState({ + nodes: Object.fromEntries(nodes.map((node) => [node.id, node])), + rootNodeIds: [levelId], + } as never) +} + +function wallConstraintFlags(constraints: ReturnType) { + return { + left: constraints.left.constrained, + right: constraints.right.constrained, + } +} + +beforeAll(() => { + globalThis.requestAnimationFrame = ((callback: FrameRequestCallback) => { + callback(0) + return 0 + }) as typeof requestAnimationFrame + globalThis.cancelAnimationFrame = (() => {}) as typeof cancelAnimationFrame +}) + +afterEach(() => { + useScene.setState({ nodes: {}, rootNodeIds: [] } as never) +}) + +describe('cabinet preset run reflow', () => { + test('reanchors an existing right L inside a newly recognized perpendicular wall', () => { + const level = LevelNode.parse({ id: 'level_reflow-room-bound-right-l' }) + const run = CabinetNode.parse({ + id: 'cabinet_reflow-room-bound-right-l', + parentId: level.id, + position: [1.75, 0, -4.65], + children: [ + 'cabinet-module_reflow-room-bound-right-l-left', + 'cabinet-module_reflow-room-bound-right-l-selected', + 'cabinet-module_reflow-room-bound-right-l-neighbor', + 'cabinet-module_reflow-room-bound-right-l-source', + ], + }) + const modules = [ + CabinetModuleNode.parse({ + id: 'cabinet-module_reflow-room-bound-right-l-left', + parentId: run.id, + position: [-1.06, 0.1, 0], + width: 0.5, + }), + CabinetModuleNode.parse({ + id: 'cabinet-module_reflow-room-bound-right-l-selected', + parentId: run.id, + position: [-0.56, 0.1, 0], + width: 0.5, + }), + CabinetModuleNode.parse({ + id: 'cabinet-module_reflow-room-bound-right-l-neighbor', + parentId: run.id, + position: [0.07, 0.1, 0], + width: 0.76, + }), + CabinetModuleNode.parse({ + id: 'cabinet-module_reflow-room-bound-right-l-source', + parentId: run.id, + position: [0.655, 0.1, 0], + width: 0.41, + }), + ] + const walls = [ + WallNode.parse({ + id: 'wall_reflow-room-bound-right-l-left', + parentId: level.id, + start: [0, -1], + end: [0, -5], + thickness: 0.2, + }), + WallNode.parse({ + id: 'wall_reflow-room-bound-right-l-back', + parentId: level.id, + start: [0, -5], + end: [3, -5], + thickness: 0.2, + }), + WallNode.parse({ + id: 'wall_reflow-room-bound-right-l-right', + parentId: level.id, + start: [3, -5], + end: [3, -3.78], + thickness: 0.2, + }), + ] + seedScene([level, run, ...modules] as AnyNode[], level.id as AnyNodeId) + const sceneApi = createSceneApi(useScene) + expect(addCornerRun({ module: modules[3]!, run, sceneApi, side: 'right' })).toBeTruthy() + for (const wall of walls) sceneApi.upsert(wall as AnyNode, level.id as AnyNodeId) + + const nodesBefore = useScene.getState().nodes + const liveRun = nodesBefore[run.id] as ReturnType + const liveModules = liveRun.children + .map((id) => nodesBefore[id]) + .filter((node): node is ReturnType => + Boolean(node?.type === 'cabinet-module'), + ) + const derivedRun = derivedBaseRunForSource(modules[3]!.id, nodesBefore) + const footprintBefore = runModuleBounds(derivedRun.id, nodesBefore) + const rightWallInnerFace = 3 - walls[2]!.thickness / 2 + expect(wallConstraintFlags(runWallConstraints(liveRun, liveModules, nodesBefore))).toEqual({ + left: true, + right: true, + }) + expect(footprintBefore.maxX).toBeGreaterThan(rightWallInnerFace) + expect( + reflowRunModules({ + modules: liveModules, + parentRun: liveRun, + patch: cabinetPresetById('fridge-single').createPatch(liveRun), + scene: useScene.getState(), + selected: nodesBefore[modules[1]!.id] as ReturnType, + }), + ).toBe(true) + + const footprintAfter = runModuleBounds(derivedRun.id, useScene.getState().nodes) + expect(footprintAfter.maxX).toBeLessThanOrEqual(rightWallInnerFace + 1e-4) + }) + + test.each([ + { cornerSide: 'left', openDirection: -1, wallX: 0.5 }, + { cornerSide: 'right', openDirection: 1, wallX: -0.5 }, + ] as const)('moves a linked $cornerSide L layout toward its unconstrained side', ({ + cornerSide, + openDirection, + wallX, + }) => { + const level = LevelNode.parse({ id: `level_reflow-l-${cornerSide}` }) + const run = CabinetNode.parse({ + id: `cabinet_reflow-l-${cornerSide}`, + parentId: level.id, + children: [ + `cabinet-module_reflow-l-${cornerSide}-left`, + `cabinet-module_reflow-l-${cornerSide}-right`, + ], + }) + const sourceIsLeft = cornerSide === 'left' + const left = CabinetModuleNode.parse({ + id: `cabinet-module_reflow-l-${cornerSide}-left`, + parentId: run.id, + position: sourceIsLeft ? [-0.4, 0.1, 0] : [-0.25, 0.1, 0], + width: sourceIsLeft ? 0.8 : 0.5, + }) + const right = CabinetModuleNode.parse({ + id: `cabinet-module_reflow-l-${cornerSide}-right`, + parentId: run.id, + position: sourceIsLeft ? [0.25, 0.1, 0] : [0.4, 0.1, 0], + width: sourceIsLeft ? 0.5 : 0.8, + }) + const source = sourceIsLeft ? left : right + const selected = sourceIsLeft ? right : left + const wall = WallNode.parse({ + id: `wall_reflow-l-${cornerSide}`, + parentId: level.id, + start: [wallX, -1], + end: [wallX, 1], + }) + seedScene([level, run, left, right, wall] as AnyNode[], level.id as AnyNodeId) + const sceneApi = createSceneApi(useScene) + expect(addCornerRun({ module: source, run, sceneApi, side: cornerSide })).toBeTruthy() + + const nodesBefore = useScene.getState().nodes + const liveRun = nodesBefore[run.id] as ReturnType + const liveModules = liveRun.children + .map((id) => nodesBefore[id]) + .filter((node): node is ReturnType => + Boolean(node?.type === 'cabinet-module'), + ) + const liveSelected = nodesBefore[selected.id] as ReturnType + const derivedBaseRun = Object.values(nodesBefore).find( + (node): node is ReturnType => + node.type === 'cabinet' && node.id !== run.id && node.runTier === 'base', + )! + const before = worldPosition(derivedBaseRun, nodesBefore) + const sourceXBefore = (nodesBefore[source.id] as ReturnType) + .position[0] + const constraints = runWallConstraints(liveRun, liveModules, nodesBefore) + + expect(wallConstraintFlags(constraints)).toEqual( + cornerSide === 'left' ? { left: false, right: true } : { left: true, right: false }, + ) + expect( + reflowRunModules({ + modules: liveModules, + parentRun: liveRun, + patch: cabinetPresetById('fridge-single').createPatch(liveRun), + scene: useScene.getState(), + selected: liveSelected, + }), + ).toBe(true) + + const nodesAfter = useScene.getState().nodes + const after = worldPosition( + nodesAfter[derivedBaseRun.id] as ReturnType, + nodesAfter, + ) + const sourceAfter = nodesAfter[source.id] as ReturnType + expect(sourceAfter.position[0] - sourceXBefore).toBeCloseTo(openDirection * 0.26) + expect((sourceAfter.metadata as Record).cabinetCornerSourceLink).toBeDefined() + expect(after[0] - before[0]).toBeCloseTo(openDirection * 0.26) + expect(after[2]).toBeCloseTo(before[2]) + expect(sourceAfter.width).toBeCloseTo(0.8) + }) + + test.each([ + { cornerSide: 'left', openDirection: -1, turnSide: 'left', wallX: 0.8 }, + { cornerSide: 'left', openDirection: -1, turnSide: 'right', wallX: 0.8 }, + { cornerSide: 'right', openDirection: 1, turnSide: 'left', wallX: -0.8 }, + { cornerSide: 'right', openDirection: 1, turnSide: 'right', wallX: -0.8 }, + ] as const)('moves a linked $cornerSide L layout turning $turnSide when its source grows', ({ + cornerSide, + openDirection, + turnSide, + wallX, + }) => { + const level = LevelNode.parse({ id: `level_reflow-l-source-${cornerSide}-${turnSide}` }) + const run = CabinetNode.parse({ + id: `cabinet_reflow-l-source-${cornerSide}-${turnSide}`, + parentId: level.id, + children: [ + `cabinet-module_reflow-l-source-${cornerSide}-${turnSide}-left`, + `cabinet-module_reflow-l-source-${cornerSide}-${turnSide}-right`, + ], + }) + const sourceIsLeft = cornerSide === 'left' + const left = CabinetModuleNode.parse({ + id: `cabinet-module_reflow-l-source-${cornerSide}-${turnSide}-left`, + parentId: run.id, + position: sourceIsLeft ? [-0.25, 0.1, 0] : [-0.4, 0.1, 0], + width: sourceIsLeft ? 0.5 : 0.8, + }) + const right = CabinetModuleNode.parse({ + id: `cabinet-module_reflow-l-source-${cornerSide}-${turnSide}-right`, + parentId: run.id, + position: sourceIsLeft ? [0.4, 0.1, 0] : [0.25, 0.1, 0], + width: sourceIsLeft ? 0.8 : 0.5, + }) + const source = sourceIsLeft ? left : right + const wall = WallNode.parse({ + id: `wall_reflow-l-source-${cornerSide}-${turnSide}`, + parentId: level.id, + start: [wallX, -1], + end: [wallX, 1], + }) + seedScene([level, run, left, right, wall] as AnyNode[], level.id as AnyNodeId) + const sceneApi = createSceneApi(useScene) + expect(addCornerRun({ module: source, run, sceneApi, side: turnSide })).toBeTruthy() + + const nodesBefore = useScene.getState().nodes + const liveRun = nodesBefore[run.id] as ReturnType + const liveModules = liveRun.children + .map((id) => nodesBefore[id]) + .filter((node): node is ReturnType => + Boolean(node?.type === 'cabinet-module'), + ) + const liveSource = nodesBefore[source.id] as ReturnType + const derivedBaseRun = Object.values(nodesBefore).find( + (node): node is ReturnType => + node.type === 'cabinet' && node.id !== run.id && node.runTier === 'base', + )! + const derivedPositionBefore = worldPosition(derivedBaseRun, nodesBefore) + const constraints = runWallConstraints(liveRun, liveModules, nodesBefore) + + expect(wallConstraintFlags(constraints)).toEqual( + cornerSide === 'left' ? { left: false, right: true } : { left: true, right: false }, + ) + expect( + reflowRunModules({ + modules: liveModules, + parentRun: liveRun, + patch: { cabinetType: 'tall', width: 0.76 }, + scene: useScene.getState(), + selected: liveSource, + }), + ).toBe(true) + + const nodesAfter = useScene.getState().nodes + const derivedPositionAfter = worldPosition( + nodesAfter[derivedBaseRun.id] as ReturnType, + nodesAfter, + ) + expect(derivedPositionAfter[0] - derivedPositionBefore[0]).toBeCloseTo(openDirection * 0.26) + expect(derivedPositionAfter[2]).toBeCloseTo(derivedPositionBefore[2]) + }) + + test.each([ + { cornerSide: 'left', turnSide: 'left' }, + { cornerSide: 'left', turnSide: 'right' }, + { cornerSide: 'right', turnSide: 'left' }, + { cornerSide: 'right', turnSide: 'right' }, + ] as const)('respects source-wall anchoring for a constrained $cornerSide-end/$turnSide-turn L', ({ + cornerSide, + turnSide, + }) => { + const level = LevelNode.parse({ id: `level_reflow-l-constrained-${cornerSide}` }) + const room = SiteNode.parse({ + id: `site_reflow-l-constrained-${cornerSide}`, + parentId: level.id, + }) + const run = CabinetNode.parse({ + id: `cabinet_reflow-l-constrained-${cornerSide}`, + parentId: level.id, + children: [ + `cabinet-module_reflow-l-constrained-${cornerSide}-left`, + `cabinet-module_reflow-l-constrained-${cornerSide}-right`, + ], + }) + const sourceIsLeft = cornerSide === 'left' + const left = CabinetModuleNode.parse({ + id: `cabinet-module_reflow-l-constrained-${cornerSide}-left`, + parentId: run.id, + position: sourceIsLeft ? [-0.4, 0.1, 0] : [-0.25, 0.1, 0], + width: sourceIsLeft ? 0.8 : 0.5, + }) + const right = CabinetModuleNode.parse({ + id: `cabinet-module_reflow-l-constrained-${cornerSide}-right`, + parentId: run.id, + position: sourceIsLeft ? [0.25, 0.1, 0] : [0.4, 0.1, 0], + width: sourceIsLeft ? 0.5 : 0.8, + }) + const source = sourceIsLeft ? left : right + const selected = sourceIsLeft ? right : left + const outerEdges = sourceIsLeft ? [-0.8, 0.5] : [-0.5, 0.8] + const walls = outerEdges.map((edge, index) => { + const x = edge + (index === 0 ? -0.1 : 0.1) + return WallNode.parse({ + id: `wall_reflow-l-constrained-${cornerSide}-${index}`, + parentId: room.id, + start: [x, -1], + end: [x, 1], + }) + }) + seedScene([level, room, run, left, right] as AnyNode[], level.id as AnyNodeId) + const sceneApi = createSceneApi(useScene) + expect(addCornerRun({ module: source, run, sceneApi, side: turnSide })).toBeTruthy() + for (const wall of walls) sceneApi.upsert(wall as AnyNode, level.id as AnyNodeId) + + const nodesBefore = useScene.getState().nodes + const liveRun = nodesBefore[run.id] as ReturnType + const liveModules = liveRun.children + .map((id) => nodesBefore[id]) + .filter((node): node is ReturnType => + Boolean(node?.type === 'cabinet-module'), + ) + const derivedBaseRun = Object.values(nodesBefore).find( + (node): node is ReturnType => + node.type === 'cabinet' && node.id !== run.id && node.runTier === 'base', + )! + const footprintBefore = runModuleBounds(derivedBaseRun.id, nodesBefore) + const derivedPositionBefore = worldPosition(derivedBaseRun, nodesBefore) + const constraints = runWallConstraints(liveRun, liveModules, nodesBefore) + const extentBefore = { minX: runMinX(liveModules), maxX: runMaxX(liveModules) } + + expect(wallConstraintFlags(constraints)).toEqual({ left: true, right: true }) + expect( + reflowRunModules({ + modules: liveModules, + parentRun: liveRun, + patch: { cabinetType: 'tall', width: 0.76 }, + scene: useScene.getState(), + selected: nodesBefore[selected.id] as ReturnType, + }), + ).toBe(true) + + const nodesAfter = useScene.getState().nodes + const sourceAfter = nodesAfter[source.id] as ReturnType + const liveModulesAfter = liveRun.children + .map((id) => nodesAfter[id]) + .filter((node): node is ReturnType => + Boolean(node?.type === 'cabinet-module'), + ) + const footprintAfter = runModuleBounds(derivedBaseRun.id, nodesAfter) + const derivedPositionAfter = worldPosition( + nodesAfter[derivedBaseRun.id] as ReturnType, + nodesAfter, + ) + expect(sourceAfter.width).toBeCloseTo(0.54) + expect( + (nodesAfter[selected.id] as ReturnType).width, + ).toBeCloseTo(0.76) + expect(runMinX(liveModulesAfter)).toBeCloseTo(extentBefore.minX) + expect(runMaxX(liveModulesAfter)).toBeCloseTo(extentBefore.maxX) + const sideWallInnerFace = + cornerSide === 'left' + ? walls[0]!.start[0] + (walls[0]!.thickness ?? 0.2) / 2 + : walls[1]!.start[0] - (walls[1]!.thickness ?? 0.2) / 2 + if (turnSide === cornerSide) { + if (cornerSide === 'left') { + expect(footprintBefore.minX).toBeLessThan(sideWallInnerFace) + expect(footprintAfter.minX).toBeGreaterThanOrEqual(sideWallInnerFace - 1e-4) + } else { + expect(footprintBefore.maxX).toBeGreaterThan(sideWallInnerFace) + expect(footprintAfter.maxX).toBeLessThanOrEqual(sideWallInnerFace + 1e-4) + } + } else { + expect(derivedPositionAfter[0]).toBeCloseTo(derivedPositionBefore[0]) + expect(derivedPositionAfter[2]).toBeCloseTo(derivedPositionBefore[2]) + } + expect(footprintAfter.minZ).toBeCloseTo(footprintBefore.minZ) + expect(footprintAfter.maxZ).toBeCloseTo(footprintBefore.maxZ) + }) + + test.each([ + 'left', + 'right', + ] as const)('reanchors a two-wall %s L when its corner source donates', (cornerSide) => { + const level = LevelNode.parse({ id: `level_reflow-l-slack-${cornerSide}` }) + const run = CabinetNode.parse({ + id: `cabinet_reflow-l-slack-${cornerSide}`, + parentId: level.id, + children: [ + `cabinet-module_reflow-l-slack-${cornerSide}-left`, + `cabinet-module_reflow-l-slack-${cornerSide}-right`, + ], + }) + const sourceIsLeft = cornerSide === 'left' + const left = CabinetModuleNode.parse({ + id: `cabinet-module_reflow-l-slack-${cornerSide}-left`, + parentId: run.id, + position: sourceIsLeft ? [-0.4, 0.1, 0] : [-0.25, 0.1, 0], + width: sourceIsLeft ? 0.8 : 0.5, + }) + const right = CabinetModuleNode.parse({ + id: `cabinet-module_reflow-l-slack-${cornerSide}-right`, + parentId: run.id, + position: sourceIsLeft ? [0.25, 0.1, 0] : [0.4, 0.1, 0], + width: sourceIsLeft ? 0.5 : 0.8, + }) + const source = sourceIsLeft ? left : right + const selected = sourceIsLeft ? right : left + const outerEdges = sourceIsLeft ? [-0.8, 0.5] : [-0.5, 0.8] + const walls = outerEdges.map((edge, index) => { + const side = index === 0 ? -1 : 1 + const x = edge + side * 0.23 + return WallNode.parse({ + id: `wall_reflow-l-slack-${cornerSide}-${index}`, + parentId: level.id, + start: [x, -1], + end: [x, 1], + thickness: 0.2, + }) + }) + seedScene([level, run, left, right] as AnyNode[], level.id as AnyNodeId) + const sceneApi = createSceneApi(useScene) + expect(addCornerRun({ module: source, run, sceneApi, side: cornerSide })).toBeTruthy() + for (const wall of walls) sceneApi.upsert(wall as AnyNode, level.id as AnyNodeId) + + const nodesBefore = useScene.getState().nodes + const liveRun = nodesBefore[run.id] as ReturnType + const liveModules = liveRun.children + .map((id) => nodesBefore[id]) + .filter((node): node is ReturnType => + Boolean(node?.type === 'cabinet-module'), + ) + const liveSelected = nodesBefore[selected.id] as ReturnType + const derivedBaseRun = Object.values(nodesBefore).find( + (node): node is ReturnType => + node.type === 'cabinet' && node.id !== run.id && node.runTier === 'base', + )! + const footprintBefore = runModuleBounds(derivedBaseRun.id, nodesBefore) + const constraints = runWallConstraints(liveRun, liveModules, nodesBefore) + const extentBefore = { minX: runMinX(liveModules), maxX: runMaxX(liveModules) } + + expect(constraints.left.slack).toBeCloseTo(0.13) + expect(constraints.right.slack).toBeCloseTo(0.13) + expect( + reflowRunModules({ + modules: liveModules, + parentRun: liveRun, + patch: cabinetPresetById('fridge-single').createPatch(liveRun), + scene: useScene.getState(), + selected: liveSelected, + }), + ).toBe(true) + + const nodesAfter = useScene.getState().nodes + const sourceAfter = nodesAfter[source.id] as ReturnType + const liveModulesAfter = liveRun.children + .map((id) => nodesAfter[id]) + .filter((node): node is ReturnType => + Boolean(node?.type === 'cabinet-module'), + ) + const footprintAfter = runModuleBounds(derivedBaseRun.id, nodesAfter) + expect(sourceAfter.width).toBeCloseTo(0.54) + expect( + (nodesAfter[selected.id] as ReturnType).width, + ).toBeCloseTo(0.76) + expect(runMinX(liveModulesAfter)).toBeCloseTo(extentBefore.minX) + expect(runMaxX(liveModulesAfter)).toBeCloseTo(extentBefore.maxX) + const sideWall = cornerSide === 'left' ? walls[0]! : walls[1]! + const sideWallInnerFace = + sideWall.start[0] + ((cornerSide === 'left' ? 1 : -1) * (sideWall.thickness ?? 0.2)) / 2 + if (cornerSide === 'left') { + expect(footprintBefore.minX).toBeLessThan(sideWallInnerFace) + expect(footprintAfter.minX).toBeGreaterThanOrEqual(sideWallInnerFace - 1e-4) + } else { + expect(footprintBefore.maxX).toBeGreaterThan(sideWallInnerFace) + expect(footprintAfter.maxX).toBeLessThanOrEqual(sideWallInnerFace + 1e-4) + } + expect(footprintAfter.minZ).toBeCloseTo(footprintBefore.minZ) + expect(footprintAfter.maxZ).toBeCloseTo(footprintBefore.maxZ) + }) + + test('keeps the native L footprint fixed when its corner source wins donor selection', () => { + const level = LevelNode.parse({ id: 'level_reflow-native-l' }) + const run = CabinetNode.parse({ + id: 'cabinet_reflow-native-l', + parentId: level.id, + children: [ + 'cabinet-module_reflow-native-l-source', + 'cabinet-module_reflow-native-l-selected', + 'cabinet-module_reflow-native-l-donor', + ], + }) + const source = CabinetModuleNode.parse({ + id: 'cabinet-module_reflow-native-l-source', + parentId: run.id, + position: [-0.65, 0.1, 0], + width: 0.8, + }) + const selected = CabinetModuleNode.parse({ + id: 'cabinet-module_reflow-native-l-selected', + parentId: run.id, + position: [0, 0.1, 0], + width: 0.5, + }) + const donor = CabinetModuleNode.parse({ + id: 'cabinet-module_reflow-native-l-donor', + parentId: run.id, + position: [0.55, 0.1, 0], + width: 0.6, + }) + seedScene([level, run, source, selected, donor] as AnyNode[], level.id as AnyNodeId) + const sceneApi = createSceneApi(useScene) + expect(addCornerRun({ module: source, run, sceneApi, side: 'left' })).toBeTruthy() + const nodesAfterCorner = useScene.getState().nodes + const liveRunAfterCorner = nodesAfterCorner[run.id] as ReturnType + const modulesAfterCorner = liveRunAfterCorner.children + .map((id) => nodesAfterCorner[id]) + .filter((node): node is ReturnType => + Boolean(node?.type === 'cabinet-module'), + ) + const walls = [runMinX(modulesAfterCorner) - 0.1, runMaxX(modulesAfterCorner) + 0.1].map( + (x, index) => + WallNode.parse({ + id: `wall_reflow-native-l-${index}`, + parentId: level.id, + start: [x, -1], + end: [x, 1], + thickness: 0.2, + }), + ) + for (const wall of walls) sceneApi.upsert(wall as AnyNode, level.id as AnyNodeId) + + const nodesBefore = useScene.getState().nodes + const liveRun = nodesBefore[run.id] as ReturnType + const liveModules = liveRun.children + .map((id) => nodesBefore[id]) + .filter((node): node is ReturnType => + Boolean(node?.type === 'cabinet-module'), + ) + const derivedBaseRun = Object.values(nodesBefore).find( + (node): node is ReturnType => + node.type === 'cabinet' && node.id !== run.id && node.runTier === 'base', + )! + const footprintBefore = moduleSubtreeBounds(derivedBaseRun.id, nodesBefore) + const extentBefore = { minX: runMinX(liveModules), maxX: runMaxX(liveModules) } + + expect(wallConstraintFlags(runWallConstraints(liveRun, liveModules, nodesBefore))).toEqual({ + left: true, + right: true, + }) + expect( + reflowRunModules({ + modules: liveModules, + parentRun: liveRun, + patch: cabinetPresetById('fridge-single').createPatch(liveRun), + scene: useScene.getState(), + selected: nodesBefore[selected.id] as ReturnType, + }), + ).toBe(true) + + const nodesAfter = useScene.getState().nodes + const liveModulesAfter = liveRun.children + .map((id) => nodesAfter[id]) + .filter((node): node is ReturnType => + Boolean(node?.type === 'cabinet-module'), + ) + const footprintAfter = moduleSubtreeBounds(derivedBaseRun.id, nodesAfter) + + expect((nodesAfter[source.id] as ReturnType).width).toBeCloseTo( + 0.54, + ) + expect((nodesAfter[donor.id] as ReturnType).width).toBeCloseTo( + 0.6, + ) + expect(runMinX(liveModulesAfter)).toBeCloseTo(extentBefore.minX) + expect(runMaxX(liveModulesAfter)).toBeCloseTo(extentBefore.maxX) + expect(footprintBefore.minX).toBeLessThan(extentBefore.minX) + expect(footprintAfter.minX).toBeGreaterThanOrEqual(extentBefore.minX - 1e-4) + expect(footprintAfter.minZ).toBeCloseTo(footprintBefore.minZ) + expect(footprintAfter.maxZ).toBeCloseTo(footprintBefore.maxZ) + }) + + test('uses the original straight run constraints when editing the nested L leg', () => { + const level = LevelNode.parse({ id: 'level_reflow-nested-l-leg' }) + const run = CabinetNode.parse({ + id: 'cabinet_reflow-nested-l-leg', + parentId: level.id, + position: [2, 0, 3], + rotation: Math.PI / 2, + children: [ + 'cabinet-module_reflow-nested-l-leg-source', + 'cabinet-module_reflow-nested-l-leg-neighbor', + ], + }) + const source = CabinetModuleNode.parse({ + id: 'cabinet-module_reflow-nested-l-leg-source', + parentId: run.id, + position: [-0.4, 0.1, 0], + width: 0.8, + }) + const neighbor = CabinetModuleNode.parse({ + id: 'cabinet-module_reflow-nested-l-leg-neighbor', + parentId: run.id, + position: [0.3, 0.1, 0], + width: 0.6, + }) + seedScene([level, run, source, neighbor] as AnyNode[], level.id as AnyNodeId) + const sceneApi = createSceneApi(useScene) + expect(addCornerRun({ module: source, run, sceneApi, side: 'left' })).toBeTruthy() + + const nodesBeforeWalls = useScene.getState().nodes + const liveRun = nodesBeforeWalls[run.id] as ReturnType + const liveModules = liveRun.children + .map((id) => nodesBeforeWalls[id]) + .filter((node): node is ReturnType => + Boolean(node?.type === 'cabinet-module'), + ) + const nestedRun = Object.values(nodesBeforeWalls).find( + (node): node is ReturnType => + node.type === 'cabinet' && node.id !== run.id && node.runTier === 'base', + )! + const nestedModules = nestedRun.children + .map((id) => nodesBeforeWalls[id]) + .filter((node): node is ReturnType => + Boolean(node?.type === 'cabinet-module'), + ) + const sourceTransform = worldTransform(liveRun, nodesBeforeWalls) + const cos = Math.cos(sourceTransform.rotation) + const sin = Math.sin(sourceTransform.rotation) + const wallAxis: [number, number] = [sin, cos] + const worldEnd = (localX: number): [number, number] => [ + sourceTransform.position[0] + localX * cos, + sourceTransform.position[2] - localX * sin, + ] + const walls = [runMinX(liveModules), runMaxX(liveModules)].map((localX, index) => { + const [x, z] = worldEnd(localX) + return WallNode.parse({ + id: `wall_reflow-nested-l-leg-${index}`, + parentId: level.id, + start: [x - wallAxis[0], z - wallAxis[1]], + end: [x + wallAxis[0], z + wallAxis[1]], + }) + }) + for (const wall of walls) sceneApi.upsert(wall as AnyNode, level.id as AnyNodeId) + + const nodesBefore = useScene.getState().nodes + const footprintBefore = moduleSubtreeBounds(nestedRun.id, nodesBefore) + expect(wallConstraintFlags(runWallConstraints(liveRun, liveModules, nodesBefore))).toEqual({ + left: true, + right: true, + }) + expect(wallConstraintFlags(runWallConstraints(nestedRun, nestedModules, nodesBefore))).toEqual({ + left: false, + right: false, + }) + expect( + reflowRunModules({ + modules: nestedModules, + parentRun: nestedRun, + patch: cabinetPresetById('fridge-single').createPatch(nestedRun), + scene: useScene.getState(), + selected: nestedModules.at(-1)!, + }), + ).toBe(true) + + const nodesAfter = useScene.getState().nodes + const footprintAfter = moduleSubtreeBounds(nestedRun.id, nodesAfter) + expect(footprintAfter.minX).toBeCloseTo(footprintBefore.minX) + expect(footprintAfter.maxX).toBeCloseTo(footprintBefore.maxX) + expect(footprintAfter.minZ).toBeCloseTo(footprintBefore.minZ) + expect(footprintAfter.maxZ).toBeCloseTo(footprintBefore.maxZ) + }) + + test.each([ + { endSide: 'left', turnSide: 'left' }, + { endSide: 'left', turnSide: 'right' }, + { endSide: 'right', turnSide: 'left' }, + { endSide: 'right', turnSide: 'right' }, + ] as const)('ignores derived-leg walls for an open $endSide-end/$turnSide-turn source run', ({ + endSide, + turnSide, + }) => { + const suffix = `${endSide}-${turnSide}` + const level = LevelNode.parse({ id: `level_reflow-l-leg-base-${suffix}` }) + const run = CabinetNode.parse({ + id: `cabinet_reflow-l-leg-base-${suffix}`, + parentId: level.id, + children: + endSide === 'left' + ? [ + `cabinet-module_reflow-l-leg-base-source-${suffix}`, + `cabinet-module_reflow-l-leg-base-donor-${suffix}`, + ] + : [ + `cabinet-module_reflow-l-leg-base-donor-${suffix}`, + `cabinet-module_reflow-l-leg-base-source-${suffix}`, + ], + }) + const source = CabinetModuleNode.parse({ + id: `cabinet-module_reflow-l-leg-base-source-${suffix}`, + parentId: run.id, + position: [endSide === 'left' ? -0.4 : 0.4, 0.1, 0], + width: 0.5, + }) + const donor = CabinetModuleNode.parse({ + id: `cabinet-module_reflow-l-leg-base-donor-${suffix}`, + parentId: run.id, + position: [endSide === 'left' ? 0.25 : -0.25, 0.1, 0], + width: 0.8, + }) + seedScene([level, run, source, donor] as AnyNode[], level.id as AnyNodeId) + const sceneApi = createSceneApi(useScene) + expect(addCornerRun({ module: source, run, sceneApi, side: turnSide })).toBeTruthy() + + const nodesBeforeWalls = useScene.getState().nodes + const legRun = derivedBaseRunForSource(source.id, nodesBeforeWalls) + const legModules = legRun.children + .map((id) => nodesBeforeWalls[id]) + .filter((node): node is ReturnType => + Boolean(node?.type === 'cabinet-module'), + ) + const selected = legModules.find((module) => module.name === 'Base Cabinet')! + const transform = worldTransform(legRun, nodesBeforeWalls) + const cos = Math.cos(transform.rotation) + const sin = Math.sin(transform.rotation) + const wallAxis: [number, number] = [sin, cos] + for (const [index, localX] of [runMinX(legModules), runMaxX(legModules)].entries()) { + const x = transform.position[0] + localX * cos + const z = transform.position[2] - localX * sin + sceneApi.upsert( + WallNode.parse({ + id: `wall_reflow-l-leg-base-${suffix}-${index}`, + parentId: level.id, + start: [x - wallAxis[0], z - wallAxis[1]], + end: [x + wallAxis[0], z + wallAxis[1]], + }) as AnyNode, + level.id as AnyNodeId, + ) + } + + const nodesBefore = useScene.getState().nodes + const footprintBefore = moduleSubtreeBounds(legRun.id, nodesBefore) + expect(wallConstraintFlags(runWallConstraints(run, [source, donor], nodesBefore))).toEqual({ + left: false, + right: false, + }) + expect(wallConstraintFlags(runWallConstraints(legRun, legModules, nodesBefore))).toEqual({ + left: true, + right: true, + }) + expect( + reflowRunModules({ + modules: legModules, + parentRun: legRun, + patch: cabinetPresetById('fridge-single').createPatch(legRun), + scene: useScene.getState(), + selected, + }), + ).toBe(true) + + const nodesAfter = useScene.getState().nodes + expect( + (nodesAfter[selected.id] as ReturnType).width, + ).toBeCloseTo(0.76) + const footprintAfter = moduleSubtreeBounds(legRun.id, nodesAfter) + const footprintLength = (bounds: ReturnType) => + bounds.maxX - bounds.minX + (bounds.maxZ - bounds.minZ) + expect(footprintLength(footprintAfter) - footprintLength(footprintBefore)).toBeCloseTo(0.26) + }) + + test('uses only the real source wall when both source ends have L returns', () => { + const level = LevelNode.parse({ id: 'level_reflow-two-corners' }) + const run = CabinetNode.parse({ + id: 'cabinet_reflow-two-corners', + parentId: level.id, + position: [2, 0, 3], + rotation: Math.PI / 2, + children: [ + 'cabinet-module_reflow-two-corners-left', + 'cabinet-module_reflow-two-corners-selected', + 'cabinet-module_reflow-two-corners-neighbor', + 'cabinet-module_reflow-two-corners-right', + ], + }) + const left = CabinetModuleNode.parse({ + id: 'cabinet-module_reflow-two-corners-left', + parentId: run.id, + position: [-0.675, 0.1, 0], + width: 0.35, + }) + const selected = CabinetModuleNode.parse({ + id: 'cabinet-module_reflow-two-corners-selected', + parentId: run.id, + position: [-0.25, 0.1, 0], + width: 0.5, + }) + const neighbor = CabinetModuleNode.parse({ + id: 'cabinet-module_reflow-two-corners-neighbor', + parentId: run.id, + position: [0.25, 0.1, 0], + width: 0.5, + }) + const right = CabinetModuleNode.parse({ + id: 'cabinet-module_reflow-two-corners-right', + parentId: run.id, + position: [0.675, 0.1, 0], + width: 0.35, + }) + seedScene([level, run, left, selected, neighbor, right] as AnyNode[], level.id as AnyNodeId) + const sceneApi = createSceneApi(useScene) + expect(addCornerRun({ module: left, run, sceneApi, side: 'left' })).toBeTruthy() + expect(addCornerRun({ module: right, run, sceneApi, side: 'right' })).toBeTruthy() + + const nodesBeforeWalls = useScene.getState().nodes + const liveRun = nodesBeforeWalls[run.id] as ReturnType + const liveModules = liveRun.children + .map((id) => nodesBeforeWalls[id]) + .filter((node): node is ReturnType => + Boolean(node?.type === 'cabinet-module'), + ) + const runTransform = worldTransform(liveRun, nodesBeforeWalls) + const cos = Math.cos(runTransform.rotation) + const sin = Math.sin(runTransform.rotation) + const wallAxis: [number, number] = [sin, cos] + const rightX = runTransform.position[0] + runMaxX(liveModules) * cos + const rightZ = runTransform.position[2] - runMaxX(liveModules) * sin + const wallOffset = 0.39 + const wall = WallNode.parse({ + id: 'wall_reflow-two-corners-right', + parentId: level.id, + start: [rightX + cos * wallOffset - wallAxis[0], rightZ - sin * wallOffset - wallAxis[1]], + end: [rightX + cos * wallOffset + wallAxis[0], rightZ - sin * wallOffset + wallAxis[1]], + }) + sceneApi.upsert(wall as AnyNode, level.id as AnyNodeId) + + const nodesBefore = useScene.getState().nodes + const leftRun = derivedBaseRunForSource(left.id, nodesBefore) + const rightRun = derivedBaseRunForSource(right.id, nodesBefore) + const leftBefore = moduleSubtreeBounds(leftRun.id, nodesBefore) + const rightBefore = moduleSubtreeBounds(rightRun.id, nodesBefore) + const extentBefore = { minX: runMinX(liveModules), maxX: runMaxX(liveModules) } + const constraints = runWallConstraints(liveRun, liveModules, nodesBefore) + expect(wallConstraintFlags(constraints)).toEqual({ + left: false, + right: true, + }) + expect(constraints.right.slack).toBeCloseTo(0.29) + expect( + reflowRunModules({ + modules: liveModules, + parentRun: liveRun, + patch: cabinetPresetById('fridge-single').createPatch(liveRun), + scene: useScene.getState(), + selected: nodesBefore[selected.id] as ReturnType, + }), + ).toBe(true) + + const nodesAfter = useScene.getState().nodes + const leftAfter = moduleSubtreeBounds(leftRun.id, nodesAfter) + const rightAfter = moduleSubtreeBounds(rightRun.id, nodesAfter) + const liveModulesAfter = liveRun.children + .map((id) => nodesAfter[id]) + .filter((node): node is ReturnType => + Boolean(node?.type === 'cabinet-module'), + ) + expect( + (nodesAfter[selected.id] as ReturnType).width, + ).toBeCloseTo(0.76) + expect(liveModulesAfter.every((module) => module.width >= 0.3)).toBe(true) + expect( + liveModulesAfter.reduce((sum, module) => sum + module.width, 0) - + liveModules.reduce((sum, module) => sum + module.width, 0), + ).toBeCloseTo(0.26) + expect(runMinX(liveModulesAfter)).toBeCloseTo(extentBefore.minX - 0.26) + expect(runMaxX(liveModulesAfter)).toBeCloseTo(extentBefore.maxX) + expect((leftAfter.minX + leftAfter.maxX) / 2).toBeCloseTo( + (leftBefore.minX + leftBefore.maxX) / 2, + ) + expect( + Math.abs((leftAfter.minZ + leftAfter.maxZ - leftBefore.minZ - leftBefore.maxZ) / 2), + ).toBeCloseTo(0.26) + const rightWallInset = liveRun.depth - constraints.right.slack + expect(rightAfter.minX).toBeCloseTo(rightBefore.minX) + expect(rightAfter.maxX).toBeCloseTo(rightBefore.maxX) + expect(rightAfter.minZ).toBeCloseTo(rightBefore.minZ + rightWallInset) + expect(rightAfter.maxZ).toBeCloseTo(rightBefore.maxZ + rightWallInset) + }) + + test('does not turn two linked L returns into wall constraints', () => { + const level = LevelNode.parse({ id: 'level_reflow-corner-trim' }) + const run = CabinetNode.parse({ + id: 'cabinet_reflow-corner-trim', + parentId: level.id, + children: [ + 'cabinet-module_reflow-corner-trim-donor', + 'cabinet-module_reflow-corner-trim-selected', + ], + }) + const donor = CabinetModuleNode.parse({ + id: 'cabinet-module_reflow-corner-trim-donor', + parentId: run.id, + position: [-0.25, 0.1, 0], + width: 0.35, + }) + const selected = CabinetModuleNode.parse({ + id: 'cabinet-module_reflow-corner-trim-selected', + parentId: run.id, + position: [0.175, 0.1, 0], + width: 0.5, + }) + seedScene([level, run, donor, selected] as AnyNode[], level.id as AnyNodeId) + const sceneApi = createSceneApi(useScene) + expect(addCornerRun({ module: donor, run, sceneApi, side: 'left' })).toBeTruthy() + expect(addCornerRun({ module: selected, run, sceneApi, side: 'right' })).toBeTruthy() + + const nodesBefore = useScene.getState().nodes + const liveRun = nodesBefore[run.id] as ReturnType + const liveModules = liveRun.children + .map((id) => nodesBefore[id]) + .filter((node): node is ReturnType => + Boolean(node?.type === 'cabinet-module'), + ) + const donorRun = derivedBaseRunForSource(donor.id, nodesBefore) + const selectedRun = derivedBaseRunForSource(selected.id, nodesBefore) + const donorFootprintBefore = moduleSubtreeBounds(donorRun.id, nodesBefore) + const selectedFootprintBefore = moduleSubtreeBounds(selectedRun.id, nodesBefore) + const extentBefore = { minX: runMinX(liveModules), maxX: runMaxX(liveModules) } + expect(wallConstraintFlags(runWallConstraints(liveRun, liveModules, nodesBefore))).toEqual({ + left: false, + right: false, + }) + + expect( + reflowRunModules({ + modules: liveModules, + parentRun: liveRun, + patch: cabinetPresetById('fridge-single').createPatch(liveRun), + scene: useScene.getState(), + selected: nodesBefore[selected.id] as ReturnType, + }), + ).toBe(true) + + const nodesAfter = useScene.getState().nodes + const liveModulesAfter = liveRun.children + .map((id) => nodesAfter[id]) + .filter((node): node is ReturnType => + Boolean(node?.type === 'cabinet-module'), + ) + expect( + (nodesAfter[selected.id] as ReturnType).width, + ).toBeCloseTo(0.76) + expect((nodesAfter[donor.id] as ReturnType).width).toBeCloseTo( + 0.35, + ) + expect(runMinX(liveModulesAfter)).toBeCloseTo(extentBefore.minX) + expect(runMaxX(liveModulesAfter)).toBeCloseTo(extentBefore.maxX + 0.26) + expect(moduleSubtreeBounds(donorRun.id, nodesAfter)).toEqual(donorFootprintBefore) + expect(moduleSubtreeBounds(selectedRun.id, nodesAfter)).toEqual({ + minX: expect.closeTo(selectedFootprintBefore.minX + 0.26), + maxX: expect.closeTo(selectedFootprintBefore.maxX + 0.26), + minZ: expect.closeTo(selectedFootprintBefore.minZ), + maxZ: expect.closeTo(selectedFootprintBefore.maxZ), + }) + }) + + test('restores exact widths after alternating preset changes in a constrained two-L run', () => { + const level = LevelNode.parse({ id: 'level_reflow-alternating-two-l' }) + const run = CabinetNode.parse({ + id: 'cabinet_reflow-alternating-two-l', + parentId: level.id, + children: [ + 'cabinet-module_reflow-alternating-two-l-left', + 'cabinet-module_reflow-alternating-two-l-a', + 'cabinet-module_reflow-alternating-two-l-b', + 'cabinet-module_reflow-alternating-two-l-right', + ], + }) + const left = CabinetModuleNode.parse({ + id: 'cabinet-module_reflow-alternating-two-l-left', + parentId: run.id, + position: [-0.675, 0.1, 0], + width: 0.35, + }) + const a = CabinetModuleNode.parse({ + id: 'cabinet-module_reflow-alternating-two-l-a', + parentId: run.id, + position: [-0.25, 0.1, 0], + width: 0.5, + }) + const b = CabinetModuleNode.parse({ + id: 'cabinet-module_reflow-alternating-two-l-b', + parentId: run.id, + position: [0.25, 0.1, 0], + width: 0.5, + }) + const right = CabinetModuleNode.parse({ + id: 'cabinet-module_reflow-alternating-two-l-right', + parentId: run.id, + position: [0.675, 0.1, 0], + width: 0.35, + }) + seedScene([level, run, left, a, b, right] as AnyNode[], level.id as AnyNodeId) + const sceneApi = createSceneApi(useScene) + expect(addCornerRun({ module: left, run, sceneApi, side: 'left' })).toBeTruthy() + expect(addCornerRun({ module: right, run, sceneApi, side: 'right' })).toBeTruthy() + + const nodesAfterCorners = useScene.getState().nodes + const liveRun = nodesAfterCorners[run.id] as ReturnType + const initialModules = liveRun.children + .map((id) => nodesAfterCorners[id]) + .filter((node): node is ReturnType => + Boolean(node?.type === 'cabinet-module'), + ) + for (const [index, x] of [ + runMinX(initialModules) - 0.1, + runMaxX(initialModules) + 0.1, + ].entries()) { + sceneApi.upsert( + WallNode.parse({ + id: `wall_reflow-alternating-two-l-${index}`, + parentId: level.id, + start: [x, -1], + end: [x, 1], + thickness: 0.2, + }) as AnyNode, + level.id as AnyNodeId, + ) + } + const initialWidths = initialModules.map((module) => module.width) + const initialExtent = { minX: runMinX(initialModules), maxX: runMaxX(initialModules) } + const apply = (moduleId: AnyNodeId, presetId: 'base-door' | 'fridge-single') => { + const scene = useScene.getState() + const liveParent = scene.nodes[run.id] as ReturnType + const liveModules = liveParent.children + .map((id) => scene.nodes[id]) + .filter((node): node is ReturnType => + Boolean(node?.type === 'cabinet-module'), + ) + return reflowRunModules({ + modules: liveModules, + parentRun: liveParent, + patch: cabinetPresetById(presetId).createPatch(liveParent), + scene, + selected: scene.nodes[moduleId] as ReturnType, + }) + } + expect(apply(a.id as AnyNodeId, 'fridge-single')).toBe(true) + expect( + (useScene.getState().nodes[b.id]?.metadata as Record | null) + ?.cabinetPresetWidthDebtBySource, + ).toBeDefined() + expect(apply(b.id as AnyNodeId, 'fridge-single')).toBe(true) + expect( + (useScene.getState().nodes[b.id]?.metadata as Record | null) + ?.cabinetPresetWidthDebtBySource, + ).toBeUndefined() + expect(apply(b.id as AnyNodeId, 'base-door')).toBe(true) + expect(apply(a.id as AnyNodeId, 'base-door')).toBe(true) + + const nodesAfter = useScene.getState().nodes + const modulesAfter = liveRun.children + .map((id) => nodesAfter[id]) + .filter((node): node is ReturnType => + Boolean(node?.type === 'cabinet-module'), + ) + expect(modulesAfter).toHaveLength(initialWidths.length) + modulesAfter.forEach((module, index) => { + expect(module.width).toBeCloseTo(initialWidths[index]!) + }) + expect(runMinX(modulesAfter)).toBeCloseTo(initialExtent.minX) + expect(runMaxX(modulesAfter)).toBeCloseTo(initialExtent.maxX) + }) + + test('resizes the closest eligible cabinet when both run ends are constrained', () => { + const level = LevelNode.parse({ id: 'level_reflow-constrained' }) + const run = CabinetNode.parse({ + id: 'cabinet_reflow-constrained', + parentId: level.id, + children: [ + 'cabinet-module_reflow-constrained-left', + 'cabinet-module_reflow-constrained-selected', + 'cabinet-module_reflow-constrained-right', + ], + }) + const left = CabinetModuleNode.parse({ + id: 'cabinet-module_reflow-constrained-left', + parentId: run.id, + position: [-0.9, 0.1, 0], + width: 0.8, + }) + const selected = CabinetModuleNode.parse({ + id: 'cabinet-module_reflow-constrained-selected', + parentId: run.id, + position: [-0.25, 0.1, 0], + width: 0.5, + }) + const right = CabinetModuleNode.parse({ + id: 'cabinet-module_reflow-constrained-right', + parentId: run.id, + position: [0.5, 0.1, 0], + width: 1, + }) + const walls = [-1.3, 1].map((x, index) => + WallNode.parse({ + id: `wall_reflow-constrained-${index}`, + parentId: level.id, + start: [x, -1], + end: [x, 1], + }), + ) + seedScene([level, run, left, selected, right, ...walls] as AnyNode[], level.id as AnyNodeId) + const constraints = runWallConstraints(run, [left, selected, right], useScene.getState().nodes) + + expect(wallConstraintFlags(constraints)).toEqual({ left: true, right: true }) + expect( + reflowRunModules({ + modules: [left, selected, right], + parentRun: run, + patch: { cabinetType: 'tall', width: 0.76 }, + scene: useScene.getState(), + selected, + }), + ).toBe(true) + + const nodes = useScene.getState().nodes + expect((nodes[right.id] as ReturnType).width).toBeCloseTo(0.74) + expect((nodes[left.id] as ReturnType).width).toBeCloseTo(0.8) + const liveModules = [left.id, selected.id, right.id].map( + (id) => nodes[id] as ReturnType, + ) + expect( + Math.min(...liveModules.map((module) => module.position[0] - module.width / 2)), + ).toBeCloseTo(-1.3) + expect( + Math.max(...liveModules.map((module) => module.position[0] + module.width / 2)), + ).toBeCloseTo(1) + }) + + test('combines eligible cabinets when the closest cannot absorb the fridge width', () => { + const level = LevelNode.parse({ id: 'level_reflow-capable-donor' }) + const run = CabinetNode.parse({ + id: 'cabinet_reflow-capable-donor', + parentId: level.id, + children: [ + 'cabinet-module_reflow-capable-donor-tall', + 'cabinet-module_reflow-capable-donor-selected', + 'cabinet-module_reflow-capable-donor-near', + 'cabinet-module_reflow-capable-donor-far', + ], + }) + const tall = CabinetModuleNode.parse({ + id: 'cabinet-module_reflow-capable-donor-tall', + parentId: run.id, + cabinetType: 'tall', + position: [-0.9, 0.1, 0], + width: 0.76, + }) + const selected = CabinetModuleNode.parse({ + id: 'cabinet-module_reflow-capable-donor-selected', + parentId: run.id, + position: [-0.27, 0.1, 0], + width: 0.5, + }) + const near = CabinetModuleNode.parse({ + id: 'cabinet-module_reflow-capable-donor-near', + parentId: run.id, + position: [0.23, 0.1, 0], + width: 0.5, + }) + const far = CabinetModuleNode.parse({ + id: 'cabinet-module_reflow-capable-donor-far', + parentId: run.id, + position: [0.88, 0.1, 0], + width: 0.8, + }) + const walls = [-1.28, 1.28].map((x, index) => + WallNode.parse({ + id: `wall_reflow-capable-donor-${index}`, + parentId: level.id, + start: [x, -1], + end: [x, 1], + }), + ) + seedScene([level, run, tall, selected, near, far, ...walls] as AnyNode[], level.id as AnyNodeId) + expect( + wallConstraintFlags( + runWallConstraints(run, [tall, selected, near, far], useScene.getState().nodes), + ), + ).toEqual({ left: true, right: true }) + + expect( + reflowRunModules({ + modules: [tall, selected, near, far], + parentRun: run, + patch: { cabinetType: 'tall', width: 0.76 }, + scene: useScene.getState(), + selected, + }), + ).toBe(true) + + const nodes = useScene.getState().nodes + expect((nodes[near.id] as ReturnType).width).toBeCloseTo(0.3) + expect((nodes[far.id] as ReturnType).width).toBeCloseTo(0.74) + }) +}) diff --git a/packages/nodes/src/cabinet/__tests__/stack.test.ts b/packages/nodes/src/cabinet/__tests__/stack.test.ts index 80f54accd4..21094a73c5 100644 --- a/packages/nodes/src/cabinet/__tests__/stack.test.ts +++ b/packages/nodes/src/cabinet/__tests__/stack.test.ts @@ -1,6 +1,8 @@ import { describe, expect, test } from 'bun:test' +import { type AnyNodeId, LevelNode, SiteNode, WallNode } from '@pascal-app/core' import { cabinetPresetById } from '../presets' -import { CabinetNode } from '../schema' +import { runWallConstraints } from '../run-layout' +import { CabinetModuleNode, CabinetNode } from '../schema' import { backAnchoredModuleZ, type CabinetCompartment, @@ -30,10 +32,12 @@ import { PULL_OUT_PANTRY_DEFAULT_SHELF_COUNT, PULL_OUT_PANTRY_STANDARD_WIDTH, reflowCabinetRunModules, + removeCabinetCompartmentStack, replaceCabinetCompartmentStack, resizeCabinetCompartmentStack, TALL_CABINET_CARCASS_HEIGHT, } from '../stack' +import { resolveCompartmentTransition } from '../stack-transitions' const stack: CabinetCompartment[] = [ { id: 'drawer', type: 'drawer', height: 0.44, drawerCount: 3 }, @@ -87,6 +91,16 @@ describe('resizeCabinetCompartmentStack', () => { expect(rows[1]!.height).toBeCloseTo(OVEN_DEFAULT_HEIGHT) expect(rows[0]!.height + rows[1]!.height + rows[2]!.height).toBeCloseTo(1.2) }) + + test('uses the requested height for a single flexible compartment', () => { + const resized = resizeCabinetCompartmentStack( + { width: 0.6, carcassHeight: 0.72, stack: [{ id: 'top', type: 'shelf' }] }, + 0, + 0.42, + ) + + expect(resized[0]!.height).toBeCloseTo(0.42) + }) }) describe('appliance compartments', () => { @@ -150,21 +164,37 @@ describe('appliance compartments', () => { expect(FRIDGE_COLUMN_HEIGHT).toBeCloseTo(1.78) }) - test('fridgeCabinetStack fills the tall-cabinet remainder with a drawer front', () => { + test('fridgeCabinetStack creates only the refrigerator compartment', () => { const stack = fridgeCabinetStack('fridge-single') const rows = normalizeCabinetStack({ width: FRIDGE_COLUMN_WIDTH, - carcassHeight: TALL_CABINET_CARCASS_HEIGHT, + carcassHeight: FRIDGE_COLUMN_HEIGHT, stack, }) - expect(stack).toHaveLength(2) + expect(stack).toHaveLength(1) expect(stack[0]!.type).toBe('fridge-single') expect(stack[0]!.height).toBeCloseTo(FRIDGE_COLUMN_HEIGHT) - expect(stack[1]!.type).toBe('drawer') - expect(stack[1]!.drawerCount).toBe(1) expect(rows[0]!.height).toBeCloseTo(FRIDGE_COLUMN_HEIGHT) - expect(rows[1]!.height).toBeCloseTo(TALL_CABINET_CARCASS_HEIGHT - FRIDGE_COLUMN_HEIGHT) + }) + + test('removing the top fridge filler compacts the carcass to the fridge height', () => { + const stack: CabinetCompartment[] = [ + newCabinetCompartment('fridge-single'), + { ...newCabinetCompartment('drawer'), drawerCount: 1 }, + ] + const result = removeCabinetCompartmentStack( + { + width: FRIDGE_COLUMN_WIDTH, + carcassHeight: TALL_CABINET_CARCASS_HEIGHT, + stack, + }, + 1, + ) + + expect(result.stack).toHaveLength(1) + expect(result.stack[0]!.type).toBe('fridge-single') + expect(result.carcassHeight).toBeCloseTo(FRIDGE_COLUMN_HEIGHT) }) test('fridge preset inherits the run depth instead of using appliance depth', () => { @@ -172,11 +202,9 @@ describe('appliance compartments', () => { const patch = cabinetPresetById('fridge-single').createPatch(run) expect(patch.depth).toBeCloseTo(run.depth) - expect(patch.carcassHeight).toBeCloseTo(TALL_CABINET_CARCASS_HEIGHT) - expect(patch.stack).toHaveLength(2) + expect(patch.carcassHeight).toBeCloseTo(FRIDGE_COLUMN_HEIGHT) + expect(patch.stack).toHaveLength(1) expect(patch.stack?.[0]?.type).toBe('fridge-single') - expect(patch.stack?.[1]?.type).toBe('drawer') - expect(patch.stack?.[1]?.drawerCount).toBe(1) }) test('cooktop stack keeps storage below a countertop-mounted overlay', () => { @@ -372,6 +400,69 @@ describe('appliance compartments', () => { expect(replaced[1]!.type).toBe('microwave') }) + test('changing a configured flexible row type keeps its explicit height', () => { + const replaced = replaceCabinetCompartmentStack( + { + width: 0.6, + carcassHeight: 1.2, + stack: [ + { id: 'drawer', type: 'drawer', height: 0.44, drawerCount: 2 }, + { id: 'door', type: 'door', height: 0.76, doorType: 'double' }, + ], + }, + 0, + { id: 'drawer', type: 'shelf', shelfCount: 1 }, + ) + + expect(replaced[0]!.type).toBe('shelf') + expect(replaced[0]!.height).toBeCloseTo(0.44) + expect(normalizeCabinetStack({ width: 0.6, carcassHeight: 1.2, stack: replaced })).toEqual( + expect.arrayContaining([ + expect.objectContaining({ index: 0, height: 0.44 }), + expect.objectContaining({ index: 1, height: 0.76 }), + ]), + ) + }) + + test.each([ + 'shelf', + 'drawer', + ] as const)('switching a pull-out pantry to %s restores a default base cabinet', (type) => { + const parentRun = CabinetNode.parse({ + carcassHeight: 0.72, + depth: 0.58, + plinthHeight: 0.1, + toeKickDepth: 0.075, + }) + const node = CabinetModuleNode.parse({ + cabinetType: 'tall', + width: PULL_OUT_PANTRY_STANDARD_WIDTH, + carcassHeight: TALL_CABINET_CARCASS_HEIGHT, + stack: [newCabinetCompartment('pull-out-pantry')], + }) + + const transition = resolveCompartmentTransition({ + node, + parentRun, + index: 0, + next: { ...newCabinetCompartment(type), id: node.stack![0]!.id }, + }) + + expect(transition.stack).toHaveLength(1) + expect(transition.stack[0]!.type).toBe(type) + expect(transition.stack[0]!.height).toBeUndefined() + expect(transition.modulePatch).toEqual( + expect.objectContaining({ + cabinetType: 'base', + width: 0.5, + depth: parentRun.depth, + carcassHeight: parentRun.carcassHeight, + plinthHeight: parentRun.plinthHeight, + toeKickDepth: parentRun.toeKickDepth, + }), + ) + }) + test('replacing a single compartment with a refrigerator does not add a filler row', () => { const replaced = replaceCabinetCompartmentStack( { @@ -388,7 +479,26 @@ describe('appliance compartments', () => { expect(replaced[0]!.type).toBe('fridge-single') }) - test('replacing a tall cabinet compartment with a refrigerator adds a drawer filler', () => { + test('switching a tall cabinet compartment to a refrigerator removes the top filler and compacts the carcass', () => { + const node = CabinetNode.parse({ + width: 0.6, + carcassHeight: TALL_CABINET_CARCASS_HEIGHT, + stack: [{ id: 'door', type: 'door', doorType: 'double' }], + }) + + const transition = resolveCompartmentTransition({ + node, + parentRun: undefined, + index: 0, + next: { id: 'door', type: 'fridge-single', height: FRIDGE_COLUMN_HEIGHT }, + }) + + expect(transition.stack).toHaveLength(1) + expect(transition.stack[0]!.type).toBe('fridge-single') + expect(transition.modulePatch.carcassHeight).toBeCloseTo(FRIDGE_COLUMN_HEIGHT) + }) + + test('replacing a tall cabinet compartment with a refrigerator removes all filler rows', () => { const replaced = replaceCabinetCompartmentStack( { width: FRIDGE_COLUMN_WIDTH, @@ -399,17 +509,8 @@ describe('appliance compartments', () => { { id: 'fridge', type: 'fridge-single', height: FRIDGE_COLUMN_HEIGHT }, 'drawer', ) - const rows = normalizeCabinetStack({ - width: FRIDGE_COLUMN_WIDTH, - carcassHeight: TALL_CABINET_CARCASS_HEIGHT, - stack: replaced, - }) - - expect(replaced).toHaveLength(2) + expect(replaced).toHaveLength(1) expect(replaced[0]!.type).toBe('fridge-single') - expect(replaced[1]!.type).toBe('drawer') - expect(rows[0]!.height).toBeCloseTo(FRIDGE_COLUMN_HEIGHT) - expect(rows[1]!.height).toBeCloseTo(TALL_CABINET_CARCASS_HEIGHT - FRIDGE_COLUMN_HEIGHT) }) test('newCabinetCompartment seeds fixed range hood heights', () => { @@ -477,25 +578,378 @@ describe('reflowCabinetRunModules', () => { expect(reflowed[2]!.position[1]).toBeCloseTo(0.1) }) - test('fits a wider preset inside the existing run by reducing adjacent modules', () => { + test('leaves neighboring widths unchanged when an open run grows', () => { const modules = [ { id: 'left', position: [-0.5, 0.1, 0] as [number, number, number], width: 0.5 }, { id: 'middle', position: [0, 0.1, 0] as [number, number, number], width: 0.5 }, { id: 'right', position: [0.5, 0.1, 0] as [number, number, number], width: 0.5 }, ] - const reflowed = reflowCabinetRunModules(modules, 'middle', 0.75, { - preserveExtent: true, + const reflowed = reflowCabinetRunModules(modules, 'middle', 0.75) + + expect(reflowed.map((module) => module.width)).toEqual([0.5, 0.75, 0.5]) + }) + + test('grows an open left-end module outward without moving the opposite end', () => { + const modules = [ + { id: 'left', position: [-0.5, 0.1, 0] as [number, number, number], width: 0.5 }, + { id: 'middle', position: [0, 0.1, 0] as [number, number, number], width: 0.5 }, + { id: 'right', position: [0.5, 0.1, 0] as [number, number, number], width: 0.5 }, + ] + + const reflowed = reflowCabinetRunModules(modules, 'left', 0.76) + + expect(reflowed[0]!.position[0] - reflowed[0]!.width / 2).toBeCloseTo(-1.01) + expect(reflowed[2]!.position[0] + reflowed[2]!.width / 2).toBeCloseTo(0.75) + }) + + test('keeps the constrained right edge fixed and moves the run left', () => { + const modules = [ + { id: 'left', position: [-0.5, 0.1, 0] as [number, number, number], width: 0.5 }, + { id: 'middle', position: [0, 0.1, 0] as [number, number, number], width: 0.5 }, + { id: 'right', position: [0.5, 0.1, 0] as [number, number, number], width: 0.5 }, + ] + + const reflowed = reflowCabinetRunModules(modules, 'right', 0.8, { + wallConstraints: { + left: { constrained: false, slack: 0 }, + right: { constrained: true, slack: 0 }, + }, + }) + + expect(reflowed.map((module) => module.width)).toEqual([0.5, 0.5, 0.8]) + expect(reflowed[0]!.position[0] - reflowed[0]!.width / 2).toBeCloseTo(-1.05) + expect(reflowed[2]!.position[0] + reflowed[2]!.width / 2).toBeCloseTo(0.75) + }) + + test.each([ + 'left', + 'right', + ] as const)('consumes a constrained %s wall gap before growing toward the open end', (side) => { + const modules = [ + { id: 'left', position: [-0.5, 0.1, 0] as [number, number, number], width: 0.5 }, + { id: 'middle', position: [0, 0.1, 0] as [number, number, number], width: 0.5 }, + { id: 'right', position: [0.5, 0.1, 0] as [number, number, number], width: 0.5 }, + ] + + const reflowed = reflowCabinetRunModules(modules, 'middle', 0.7, { + wallConstraints: { + left: { constrained: side === 'left', slack: side === 'left' ? 0.1 : 0 }, + right: { constrained: side === 'right', slack: side === 'right' ? 0.1 : 0 }, + }, }) + expect(reflowed.map((module) => module.width)).toEqual([0.5, 0.7, 0.5]) + expect(reflowed[0]!.position[0] - reflowed[0]!.width / 2).toBeCloseTo(-0.85) + expect(reflowed[2]!.position[0] + reflowed[2]!.width / 2).toBeCloseTo(0.85) + }) + + test('detects perpendicular wall constraints at each run end', () => { + const level = LevelNode.parse({ id: 'level_run-constraints' }) + const run = CabinetNode.parse({ + id: 'cabinet_run-constraints', + parentId: level.id, + position: [0.75, 0, 0], + width: 1.5, + depth: 0.6, + }) + const modules = [ + { id: 'left', position: [-0.5, 0, 0] as [number, number, number], width: 0.5 }, + { id: 'middle', position: [0, 0, 0] as [number, number, number], width: 0.5 }, + { id: 'right', position: [0.5, 0, 0] as [number, number, number], width: 0.5 }, + ] + const leftWall = WallNode.parse({ + id: 'wall_run-constraints-left', + parentId: level.id, + start: [0, -0.5], + end: [0, 0.5], + }) + const rightWall = WallNode.parse({ + id: 'wall_run-constraints-right', + parentId: level.id, + start: [1.5, -0.5], + end: [1.5, 0.5], + }) + const backWall = WallNode.parse({ + id: 'wall_run-constraints-back', + parentId: level.id, + start: [0, -0.3], + end: [1.5, -0.3], + }) + const nodes = { + [level.id as AnyNodeId]: level, + [leftWall.id as AnyNodeId]: leftWall, + [rightWall.id as AnyNodeId]: rightWall, + [backWall.id as AnyNodeId]: backWall, + } + + expect(runWallConstraints(run, modules, nodes)).toEqual({ + left: { constrained: true, slack: 0 }, + right: { constrained: true, slack: 0 }, + }) + expect( + runWallConstraints(run, modules, { + [level.id as AnyNodeId]: level, + [rightWall.id as AnyNodeId]: rightWall, + }), + ).toEqual({ + left: { constrained: false, slack: 0 }, + right: { constrained: true, slack: 0 }, + }) + expect( + runWallConstraints(run, modules, { + [level.id as AnyNodeId]: level, + [leftWall.id as AnyNodeId]: leftWall, + }), + ).toEqual({ + left: { constrained: true, slack: 0 }, + right: { constrained: false, slack: 0 }, + }) + expect( + runWallConstraints(run, modules, { + [level.id as AnyNodeId]: level, + [backWall.id as AnyNodeId]: backWall, + }), + ).toEqual({ + left: { constrained: false, slack: 0 }, + right: { constrained: false, slack: 0 }, + }) + }) + + test('detects perpendicular walls through an intermediate scene parent', () => { + const level = LevelNode.parse({ id: 'level_run-nested-walls' }) + const room = SiteNode.parse({ id: 'site_run-nested-walls', parentId: level.id }) + const run = CabinetNode.parse({ + id: 'cabinet_run-nested-walls', + parentId: level.id, + position: [0.75, 0, 0], + width: 1.5, + depth: 0.6, + }) + const modules = [ + { id: 'left', position: [-0.5, 0, 0] as [number, number, number], width: 0.5 }, + { id: 'middle', position: [0, 0, 0] as [number, number, number], width: 0.5 }, + { id: 'right', position: [0.5, 0, 0] as [number, number, number], width: 0.5 }, + ] + const leftWall = WallNode.parse({ + id: 'wall_run-nested-walls-left', + parentId: room.id, + start: [0, -0.5], + end: [0, 0.5], + }) + const rightWall = WallNode.parse({ + id: 'wall_run-nested-walls-right', + parentId: room.id, + start: [1.5, -0.5], + end: [1.5, 0.5], + }) + + expect( + runWallConstraints(run, modules, { + [level.id as AnyNodeId]: level, + [room.id as AnyNodeId]: room, + [leftWall.id as AnyNodeId]: leftWall, + [rightWall.id as AnyNodeId]: rightWall, + }), + ).toEqual({ + left: { constrained: true, slack: 0 }, + right: { constrained: true, slack: 0 }, + }) + }) + + test('measures clear space from each run end to the perpendicular wall face', () => { + const level = LevelNode.parse({ id: 'level_run-constraint-slack' }) + const run = CabinetNode.parse({ + id: 'cabinet_run-constraint-slack', + parentId: level.id, + depth: 0.6, + }) + const modules = [ + { id: 'left', position: [-0.5, 0, 0] as [number, number, number], width: 0.5 }, + { id: 'middle', position: [0, 0, 0] as [number, number, number], width: 0.5 }, + { id: 'right', position: [0.5, 0, 0] as [number, number, number], width: 0.5 }, + ] + const leftWall = WallNode.parse({ + id: 'wall_run-constraint-slack-left', + parentId: level.id, + start: [-0.95, -0.5], + end: [-0.95, 0.5], + thickness: 0.2, + }) + const rightWall = WallNode.parse({ + id: 'wall_run-constraint-slack-right', + parentId: level.id, + start: [0.95, -0.5], + end: [0.95, 0.5], + thickness: 0.2, + }) + + const constraints = runWallConstraints(run, modules, { + [level.id as AnyNodeId]: level, + [leftWall.id as AnyNodeId]: leftWall, + [rightWall.id as AnyNodeId]: rightWall, + }) + + expect(constraints.left.constrained).toBe(true) + expect(constraints.left.slack).toBeCloseTo(0.1) + expect(constraints.right.constrained).toBe(true) + expect(constraints.right.slack).toBeCloseTo(0.1) + }) + + test('detects a perpendicular wall within the requested width growth', () => { + const level = LevelNode.parse({ id: 'level_run-growth-constraint' }) + const run = CabinetNode.parse({ + id: 'cabinet_run-growth-constraint', + parentId: level.id, + depth: 0.6, + }) + const modules = [ + { id: 'selected', position: [0, 0, 0] as [number, number, number], width: 0.6 }, + ] + const rightWall = WallNode.parse({ + id: 'wall_run-growth-constraint-right', + parentId: level.id, + start: [0.8, -0.5], + end: [0.8, 0.5], + thickness: 0.2, + }) + const nodes = { + [level.id as AnyNodeId]: level, + [rightWall.id as AnyNodeId]: rightWall, + } + + expect(runWallConstraints(run, modules, nodes).right.constrained).toBe(false) + const constraints = runWallConstraints(run, modules, nodes, { widthGrowth: 0.46 }) + expect(constraints.right.constrained).toBe(true) + expect(constraints.right.slack).toBeCloseTo(0.4) + }) + + test('keeps the exact two-wall extent and changes one eligible cabinet width', () => { + const modules = [ + { id: 'left', position: [-0.5, 0.1, 0] as [number, number, number], width: 0.5 }, + { id: 'middle', position: [0, 0.1, 0] as [number, number, number], width: 0.5 }, + { id: 'right', position: [0.5, 0.1, 0] as [number, number, number], width: 0.5 }, + ] + + const reflowed = reflowCabinetRunModules(modules, 'middle', 0.7, { + wallConstraints: { + left: { constrained: true, slack: 0.1 }, + right: { constrained: true, slack: 0.1 }, + }, + eligibleDonorIds: new Set(['left', 'right']), + }) + + expect(reflowed.map((module) => module.width)).toEqual([0.5, 0.7, expect.closeTo(0.3)]) expect(reflowed[0]!.position[0] - reflowed[0]!.width / 2).toBeCloseTo(-0.75) expect(reflowed[2]!.position[0] + reflowed[2]!.width / 2).toBeCloseTo(0.75) - expect(reflowed[0]!.width).toBeCloseTo(0.45) - expect(reflowed[1]!.width).toBeCloseTo(0.75) - expect(reflowed[2]!.width).toBeCloseTo(0.3) }) - test('uses the side with more reducible width before changing the opposite side', () => { + test('rejects two-wall growth when combined eligible capacity is insufficient', () => { + const modules = [ + { id: 'left', position: [-0.55, 0.1, 0] as [number, number, number], width: 0.4 }, + { id: 'middle', position: [-0.1, 0.1, 0] as [number, number, number], width: 0.5 }, + { id: 'right', position: [0.4, 0.1, 0] as [number, number, number], width: 0.5 }, + ] + + const reflowed = reflowCabinetRunModules(modules, 'middle', 0.7, { + wallConstraints: { + left: { constrained: true, slack: 0.05 }, + right: { constrained: true, slack: 0.05 }, + }, + eligibleDonorIds: new Set(['left']), + }) + + expect(reflowed).toEqual([]) + }) + + test('rejects two-wall growth when donor capacity is short by a fraction of a millimetre', () => { + const modules = [ + { id: 'donor', position: [-0.530025, 0.1, 0] as [number, number, number], width: 0.55995 }, + { id: 'selected', position: [0, 0.1, 0] as [number, number, number], width: 0.5 }, + ] + + const reflowed = reflowCabinetRunModules(modules, 'selected', 0.76, { + wallConstraints: { + left: { constrained: true, slack: 0 }, + right: { constrained: true, slack: 0 }, + }, + eligibleDonorIds: new Set(['donor']), + }) + + expect(reflowed).toEqual([]) + }) + + test('accepts exact capacity when the final donor contributes a fraction of a millimetre', () => { + const modules = [ + { + id: 'large-donor', + position: [0.279975, 0.1, 0] as [number, number, number], + width: 0.55995, + }, + { + id: 'small-donor', + position: [0.709975, 0.1, 0] as [number, number, number], + width: 0.30005, + }, + { id: 'selected', position: [1.11, 0.1, 0] as [number, number, number], width: 0.5 }, + ] + + const reflowed = reflowCabinetRunModules(modules, 'selected', 0.76, { + wallConstraints: { + left: { constrained: true, slack: 0 }, + right: { constrained: true, slack: 0 }, + }, + eligibleDonorIds: new Set(['large-donor', 'small-donor']), + }) + + expect(reflowed).toHaveLength(3) + expect(reflowed[0]!.width).toBeCloseTo(0.3, 5) + expect(reflowed[1]!.width).toBeCloseTo(0.3, 5) + }) + + test('uses the closest eligible base cabinet when both ends are constrained', () => { + const modules = [ + { id: 'base', position: [-0.8, 0.1, 0] as [number, number, number], width: 0.8 }, + { id: 'appliance', position: [0, 0.1, 0] as [number, number, number], width: 0.8 }, + { id: 'selected', position: [0.65, 0.1, 0] as [number, number, number], width: 0.5 }, + ] + + const reflowed = reflowCabinetRunModules(modules, 'selected', 0.7, { + wallConstraints: { + left: { constrained: true, slack: 0 }, + right: { constrained: true, slack: 0 }, + }, + eligibleDonorIds: new Set(['base']), + }) + + expect(reflowed[0]!.width).toBeCloseTo(0.6) + expect(reflowed[1]!.width).toBeCloseTo(0.8) + expect(reflowed[2]!.width).toBeCloseTo(0.7) + expect(reflowed[0]!.position[0] - reflowed[0]!.width / 2).toBeCloseTo(-1.2) + expect(reflowed[2]!.position[0] + reflowed[2]!.width / 2).toBeCloseTo(0.9) + }) + + test('combines the closest eligible cabinets to absorb width growth', () => { + const modules = [ + { id: 'far', position: [-0.625, 0.1, 0] as [number, number, number], width: 0.9 }, + { id: 'closest', position: [0, 0.1, 0] as [number, number, number], width: 0.35 }, + { id: 'selected', position: [0.425, 0.1, 0] as [number, number, number], width: 0.5 }, + ] + + const reflowed = reflowCabinetRunModules(modules, 'selected', 0.7, { + wallConstraints: { + left: { constrained: true, slack: 0 }, + right: { constrained: true, slack: 0 }, + }, + eligibleDonorIds: new Set(['far', 'closest']), + }) + + expect(reflowed[0]!.width).toBeCloseTo(0.75) + expect(reflowed[1]!.width).toBeCloseTo(0.3) + expect(reflowed[2]!.width).toBeCloseTo(0.7) + }) + + test('uses the larger donor when two equally close cabinets are eligible', () => { const modules = [ { id: 'left', position: [-0.6, 0.1, 0] as [number, number, number], width: 0.7 }, { id: 'middle', position: [0, 0.1, 0] as [number, number, number], width: 0.5 }, @@ -503,7 +957,10 @@ describe('reflowCabinetRunModules', () => { ] const reflowed = reflowCabinetRunModules(modules, 'middle', 0.75, { - preserveExtent: true, + wallConstraints: { + left: { constrained: true, slack: 0 }, + right: { constrained: true, slack: 0 }, + }, }) expect(reflowed[0]!.width).toBeCloseTo(0.45) @@ -518,14 +975,20 @@ describe('reflowCabinetRunModules', () => { { id: 'right', position: [0.45, 0.1, 0] as [number, number, number], width: 0.4 }, ] const widened = reflowCabinetRunModules(modules, 'middle', 0.75, { - preserveExtent: true, + wallConstraints: { + left: { constrained: true, slack: 0 }, + right: { constrained: true, slack: 0 }, + }, }) const restorableWidthById = new Map( modules.map((module, index) => [module.id, module.width - widened[index]!.width]), ) const restored = reflowCabinetRunModules(widened, 'middle', 0.5, { - preserveExtent: true, + wallConstraints: { + left: { constrained: true, slack: 0 }, + right: { constrained: true, slack: 0 }, + }, restorableWidthById, }) @@ -533,6 +996,25 @@ describe('reflowCabinetRunModules', () => { expect(restored[0]!.position[0] - restored[0]!.width / 2).toBeCloseTo(-0.95) expect(restored[2]!.position[0] + restored[2]!.width / 2).toBeCloseTo(0.65) }) + + test('keeps a two-wall extent when shrinking without recorded donor debt', () => { + const modules = [ + { id: 'donor', position: [-0.38, 0.1, 0] as [number, number, number], width: 0.5 }, + { id: 'selected', position: [0.25, 0.1, 0] as [number, number, number], width: 0.76 }, + ] + + const reflowed = reflowCabinetRunModules(modules, 'selected', 0.5, { + wallConstraints: { + left: { constrained: true, slack: 0 }, + right: { constrained: true, slack: 0 }, + }, + eligibleDonorIds: new Set(['donor']), + }) + + expect(reflowed.map((module) => module.width)).toEqual([0.76, 0.5]) + expect(reflowed[0]!.position[0] - reflowed[0]!.width / 2).toBeCloseTo(-0.63) + expect(reflowed[1]!.position[0] + reflowed[1]!.width / 2).toBeCloseTo(0.63) + }) }) describe('backAnchoredModuleZ', () => { diff --git a/packages/nodes/src/cabinet/__tests__/top-finish.test.ts b/packages/nodes/src/cabinet/__tests__/top-finish.test.ts new file mode 100644 index 0000000000..1f8a7cf41d --- /dev/null +++ b/packages/nodes/src/cabinet/__tests__/top-finish.test.ts @@ -0,0 +1,202 @@ +import { expect, test } from 'bun:test' +import { CabinetModuleNode } from '@pascal-app/core' +import type { Mesh } from 'three' +import { Vector3 } from 'three' +import { buildCabinetGeometry } from '../geometry' + +function cabinetDoorLeaf( + geometry: ReturnType, + side: 'left' | 'right', + row: 'bottom' | 'top', +): Mesh { + const matches: Mesh[] = [] + geometry.updateMatrixWorld(true) + geometry.traverse((object) => { + if (object.isMesh && new RegExp(`^cabinet-door-${side}-[\\d.]+$`).test(object.name)) { + matches.push(object as Mesh) + } + }) + matches.sort((a, b) => a.getWorldPosition(new Vector3()).y - b.getWorldPosition(new Vector3()).y) + const result = row === 'top' ? matches.at(-1) : matches[0] + if (!result) throw new Error(`${row} ${side} door was not generated`) + return result +} + +function doorLeafWidth(mesh: Mesh) { + mesh.geometry.computeBoundingBox() + const bounds = mesh.geometry.boundingBox + if (!bounds) throw new Error('Door leaf has no bounds') + return bounds.max.x - bounds.min.x +} + +test('cabinet modules do not add a ceiling finish by default', () => { + const geometry = buildCabinetGeometry(CabinetModuleNode.parse({})) + expect(geometry.getObjectByName('cabinet-top-cabinet-top')).toBeUndefined() + geometry.clear() +}) + +test('top cabinet finish adds a framed storage box above the module', () => { + const geometry = buildCabinetGeometry( + CabinetModuleNode.parse({ + topFinish: 'top-cabinet', + topFinishHeight: 0.36, + topFinishDepth: 0.32, + }), + ) + expect(geometry.getObjectByName('cabinet-top-cabinet-top')).not.toBeNull() + expect(geometry.getObjectByName('cabinet-top-cabinet-back')).not.toBeNull() + geometry.clear() +}) + +test('trim finish adds a solid ceiling closure', () => { + const geometry = buildCabinetGeometry( + CabinetModuleNode.parse({ topFinish: 'trim', topFinishHeight: 0.12 }), + ) + expect(geometry.getObjectByName('cabinet-top-trim')).not.toBeNull() + geometry.clear() +}) + +test.each([ + 'Corner Filler', + 'Wall Bridge Filler', + 'Corner Wall Filler', +])('%s renders its selected top cabinet finish', (name) => { + const geometry = buildCabinetGeometry( + CabinetModuleNode.parse({ + moduleKind: 'corner-filler', + name, + topFinish: 'top-cabinet', + }), + ) + + expect(geometry.getObjectByName('cabinet-top-cabinet-top')).toBeDefined() + geometry.clear() +}) + +test.each([ + ['Corner Filler', 'left'], + ['Wall Bridge Filler', 'right'], + ['Corner Wall Filler', 'left'], +] as const)('%s top cabinet stays doorless and accessible from the %s side', (name, openSide) => { + const module = CabinetModuleNode.parse({ + moduleKind: 'corner-filler', + name, + openSide, + topFinish: 'top-cabinet', + }) + const geometry = buildCabinetGeometry(module) + const closedSide = openSide === 'left' ? 'right' : 'left' + const expectedInteriorCenterX = + openSide === 'left' ? -module.boardThickness / 2 : module.boardThickness / 2 + const doorFronts: Mesh[] = [] + geometry.traverse((object) => { + if (object.isMesh && object.name.startsWith('cabinet-door-')) { + doorFronts.push(object as Mesh) + } + }) + + expect(geometry.getObjectByName(`cabinet-top-cabinet-side-${openSide}`)).toBeUndefined() + expect(geometry.getObjectByName(`cabinet-top-cabinet-side-${closedSide}`)).toBeDefined() + expect(geometry.getObjectByName('cabinet-top-corner-filler-front')).toBeDefined() + expect(geometry.getObjectByName('cabinet-top-cabinet-bottom')?.position.x).toBeCloseTo( + expectedInteriorCenterX, + ) + expect(doorFronts).toHaveLength(0) + geometry.clear() +}) + +test.each([ + 'left', + 'right', +] as const)('top cabinet mirrors the parent cabinet open %s side', (openSide) => { + const module = CabinetModuleNode.parse({ + openSide, + topFinish: 'top-cabinet', + }) + const geometry = buildCabinetGeometry(module) + const closedSide = openSide === 'left' ? 'right' : 'left' + const expectedInteriorCenterX = + openSide === 'left' ? -module.boardThickness / 2 : module.boardThickness / 2 + + expect(geometry.getObjectByName(`cabinet-side-${openSide}`)).toBeUndefined() + expect(geometry.getObjectByName(`cabinet-top-cabinet-side-${openSide}`)).toBeUndefined() + expect(geometry.getObjectByName(`cabinet-top-cabinet-side-${closedSide}`)).toBeDefined() + expect(geometry.getObjectByName('cabinet-top-cabinet-bottom')?.position.x).toBeCloseTo( + expectedInteriorCenterX, + ) + geometry.clear() +}) + +test('top cabinet doors reuse the parent overlay and inset reveal rules', () => { + const overlayNode = CabinetModuleNode.parse({ + width: 0.6, + topFinish: 'top-cabinet', + topFinishHeight: 0.36, + topFinishDepth: 0.32, + frontOverlay: 'full', + }) + const overlayGeometry = buildCabinetGeometry(overlayNode) + const insetGeometry = buildCabinetGeometry({ ...overlayNode, frontOverlay: 'inset' }) + + const overlayLeafWidth = doorLeafWidth(cabinetDoorLeaf(overlayGeometry, 'left', 'top')) + const insetLeafWidth = doorLeafWidth(cabinetDoorLeaf(insetGeometry, 'left', 'top')) + const overlayOpening = overlayNode.width - overlayNode.frontGap + const insetOpening = overlayNode.width - overlayNode.boardThickness * 2 + + expect(overlayLeafWidth).toBeCloseTo( + doorLeafWidth(cabinetDoorLeaf(overlayGeometry, 'left', 'bottom')), + 5, + ) + expect(overlayLeafWidth).toBeCloseTo((overlayOpening - 3 * overlayNode.frontGap) / 2, 5) + expect(insetLeafWidth).toBeCloseTo((insetOpening - 3 * overlayNode.frontGap) / 2, 5) + expect(insetLeafWidth).toBeLessThan(overlayLeafWidth) + overlayGeometry.clear() + insetGeometry.clear() +}) + +test('top cabinet doors reuse the parent door type and front style', () => { + const slabGeometry = buildCabinetGeometry( + CabinetModuleNode.parse({ + width: 0.5, + topFinish: 'top-cabinet', + topFinishHeight: 0.36, + topFinishDepth: 0.32, + stack: [{ id: 'top-door', type: 'door', doorType: 'double', shelfCount: 1 }], + }), + ) + const raisedArchGeometry = buildCabinetGeometry( + CabinetModuleNode.parse({ + width: 0.5, + topFinish: 'top-cabinet', + topFinishHeight: 0.36, + topFinishDepth: 0.32, + frontStyle: 'raised-arch', + stack: [{ id: 'top-door', type: 'door', doorType: 'double', shelfCount: 1 }], + }), + ) + + const slabDoor = cabinetDoorLeaf(slabGeometry, 'left', 'top') + const raisedArchDoor = cabinetDoorLeaf(raisedArchGeometry, 'left', 'top') + expect(cabinetDoorLeaf(slabGeometry, 'right', 'top')).toBeDefined() + expect(raisedArchDoor.geometry.getAttribute('position').count).toBeGreaterThan( + slabDoor.geometry.getAttribute('position').count, + ) + slabGeometry.clear() + raisedArchGeometry.clear() +}) + +test('top cabinet doors retain the normal open animation pose', () => { + const geometry = buildCabinetGeometry( + CabinetModuleNode.parse({ + width: 0.6, + topFinish: 'top-cabinet', + topFinishHeight: 0.36, + topFinishDepth: 0.32, + operationState: 1, + }), + ) + const hingeRotation = cabinetDoorLeaf(geometry, 'left', 'top').parent?.rotation.y + + expect(hingeRotation).toBeCloseTo(-Math.PI / 2) + geometry.clear() +}) diff --git a/packages/nodes/src/cabinet/__tests__/wall-depth-handles.test.ts b/packages/nodes/src/cabinet/__tests__/wall-depth-handles.test.ts index 09bac41158..0680ce718b 100644 --- a/packages/nodes/src/cabinet/__tests__/wall-depth-handles.test.ts +++ b/packages/nodes/src/cabinet/__tests__/wall-depth-handles.test.ts @@ -167,13 +167,14 @@ describe('wall cabinet depth handles', () => { for (const cabinet of [baseA, wallA]) { const handles = buildModuleHandles(cabinet, sceneApi) - expect(handles).toHaveLength(3) + expect(handles).toHaveLength(4) expect(handles.map((handle) => handle.kind)).toEqual([ 'linear-resize', 'linear-resize', 'linear-resize', + 'linear-resize', ]) - expect(handles.map((handle) => handle.axis)).toEqual(['x', 'x', 'z']) + expect(handles.map((handle) => handle.axis)).toEqual(['x', 'x', 'z', 'y']) const widthHandles = handles.filter( (handle): handle is LinearResizeHandle => diff --git a/packages/nodes/src/cabinet/__tests__/widths.test.ts b/packages/nodes/src/cabinet/__tests__/widths.test.ts new file mode 100644 index 0000000000..69a84d2e7b --- /dev/null +++ b/packages/nodes/src/cabinet/__tests__/widths.test.ts @@ -0,0 +1,21 @@ +import { expect, test } from 'bun:test' +import { + CABINET_STANDARD_WIDTHS, + cabinetStandardWidthById, + cabinetStandardWidthId, +} from '../widths' + +test('recognizes standard metric module widths', () => { + expect(cabinetStandardWidthId(0.6)).toBe('600') + expect(cabinetStandardWidthId(0.80000001)).toBe('800') +}) + +test('keeps non-catalog widths custom', () => { + expect(cabinetStandardWidthId(0.55)).toBe('custom') +}) + +test('returns the selected standard width value', () => { + expect(cabinetStandardWidthById('600')).toEqual( + CABINET_STANDARD_WIDTHS.find((option) => option.id === '600'), + ) +}) diff --git a/packages/nodes/src/cabinet/definition.ts b/packages/nodes/src/cabinet/definition.ts index a2aac0063e..8dba83a3f3 100644 --- a/packages/nodes/src/cabinet/definition.ts +++ b/packages/nodes/src/cabinet/definition.ts @@ -11,7 +11,11 @@ import type { NodeDefinition, SceneApi, } from '@pascal-app/core' -import { findLevelAncestorId, selectionProxyIdFromMetadata } from '@pascal-app/core' +import { + CABINET_METRIC_DEFAULTS, + findLevelAncestorId, + selectionProxyIdFromMetadata, +} from '@pascal-app/core' import { bakeCabinetAnimationClip } from './animation' import { buildCabinetFloorplan, buildCabinetModuleFloorplan } from './floorplan' import { cabinetModuleFloorplanMoveTarget } from './floorplan-move' @@ -443,7 +447,12 @@ function includeCabinetModuleBounds( bounds.minY = Math.min(bounds.minY, y - (module.showPlinth ? module.plinthHeight : 0)) bounds.maxY = Math.max( bounds.maxY, - y + module.carcassHeight + (module.withCountertop ? module.countertopThickness : 0), + y + + module.carcassHeight + + (module.withCountertop ? module.countertopThickness : 0) + + (module.topFinish === 'top-cabinet' || module.topFinish === 'trim' + ? (module.topFinishHeight ?? 0.33) + : 0), ) bounds.minZ = Math.min(bounds.minZ, z - module.depth / 2) bounds.maxZ = Math.max(bounds.maxZ, z + module.depth / 2) @@ -492,7 +501,12 @@ function cabinetLocalBounds( minX: -node.width / 2, maxX: node.width / 2, minY: 0, - maxY: cabinetTotalHeight(node), + maxY: + cabinetTotalHeight(node) + + (node.type === 'cabinet-module' && + (node.topFinish === 'top-cabinet' || node.topFinish === 'trim') + ? (node.topFinishHeight ?? 0.33) + : 0), minZ: -node.depth / 2, maxZ: node.depth / 2, } @@ -1814,6 +1828,17 @@ function isHoodOnlyCabinet(node: CabinetEditableNode): boolean { return stack.length > 0 && stack.every((compartment) => isHoodCompartmentType(compartment.type)) } +function cabinetModuleHeightHandleVisible( + node: CabinetModuleNodeType, + sceneApi: SceneApi, +): boolean { + const parent = node.parentId ? sceneApi.get(node.parentId as AnyNodeId) : undefined + if (isCabinetRun(parent)) { + return parent.runTier === 'wall' || resolveCabinetType(node, parent) === 'tall' + } + return isCabinetModule(parent) && wallChildOf(parent, sceneApi.nodes())?.id === node.id +} + function cabinetModuleHandles(): HandleDescriptor[] { return [ { @@ -1830,6 +1855,10 @@ function cabinetModuleHandles(): HandleDescriptor[] { ...cabinetDepthHandle(), visible: (node) => !isCabinetWidthFiller(node), } as HandleDescriptor, + { + ...cabinetHeightHandle(), + visible: cabinetModuleHeightHandleVisible, + } as HandleDescriptor, ] } @@ -1852,13 +1881,13 @@ export const cabinetDefinition: NodeDefinition = { runTier: 'base', children: [], width: 0.5, - depth: 0.5, - carcassHeight: 0.72, + depth: CABINET_METRIC_DEFAULTS.depth, + carcassHeight: CABINET_METRIC_DEFAULTS.carcassHeight, operationState: 0, - plinthHeight: 0.1, + plinthHeight: CABINET_METRIC_DEFAULTS.plinthHeight, toeKickDepth: 0.075, boardThickness: 0.018, - countertopThickness: 0.02, + countertopThickness: CABINET_METRIC_DEFAULTS.countertopThickness, countertopOverhang: 0.02, countertopBackOverhang: 0, withFinishedBack: false, @@ -2025,7 +2054,7 @@ export const cabinetDefinition: NodeDefinition = { export const cabinetModuleDefinition: NodeDefinition = { kind: 'cabinet-module', - schemaVersion: 4, + schemaVersion: 5, schema: CabinetModuleNode, category: 'furnish', surfaceRole: 'joinery', @@ -2042,8 +2071,8 @@ export const cabinetModuleDefinition: NodeDefinition = children: [], cabinetType: 'base', width: 0.5, - depth: 0.5, - carcassHeight: 0.72, + depth: CABINET_METRIC_DEFAULTS.depth, + carcassHeight: CABINET_METRIC_DEFAULTS.carcassHeight, operationState: 0, plinthHeight: 0, toeKickDepth: 0.075, @@ -2057,6 +2086,9 @@ export const cabinetModuleDefinition: NodeDefinition = moduleKind: 'standard' as const, openSide: undefined, cornerShelf: false, + topFinish: 'none' as const, + topFinishHeight: 0.33, + topFinishDepth: 0.32, frontStyle: 'slab', handleStyle: 'bar', handlePosition: 'auto', @@ -2136,6 +2168,9 @@ export const cabinetModuleDefinition: NodeDefinition = n.withCountertop, n.openSide ?? null, n.cornerShelf ?? false, + n.topFinish, + n.topFinishHeight, + n.topFinishDepth, JSON.stringify(n.material ?? null), JSON.stringify(n.materialPreset ?? null), JSON.stringify(n.slots ?? null), diff --git a/packages/nodes/src/cabinet/geometry.ts b/packages/nodes/src/cabinet/geometry.ts index 9aa4c4a045..0186230cdb 100644 --- a/packages/nodes/src/cabinet/geometry.ts +++ b/packages/nodes/src/cabinet/geometry.ts @@ -1,4 +1,4 @@ -import type { CabinetNode, GeometryContext } from '@pascal-app/core' +import type { CabinetModuleNode, CabinetNode, GeometryContext } from '@pascal-app/core' import type { ColorPreset, RenderShading } from '@pascal-app/viewer' import { Group } from 'three' import { addCooktopCompartment } from './geometry/cooktop' @@ -14,7 +14,12 @@ import { addRangeHoodCompartment } from './geometry/hood' import { addApplianceCompartment } from './geometry/oven-microwave' import { addPullOutPantryCompartment } from './geometry/pantry' import { buildCabinetRunGeometry } from './geometry/run' -import { addBox, type CabinetGeometryNode, getCabinetSlotMaterials } from './geometry/shared' +import { + addBox, + type CabinetGeometryNode, + type CabinetSlotMaterials, + getCabinetSlotMaterials, +} from './geometry/shared' import { addSinkCompartment, cutSinkIntoCountertop, sinkBowls } from './geometry/sink' import { type CabinetHoodCompartmentType, @@ -33,6 +38,138 @@ const WALL_CORNER_FILLER_FRONT_HEIGHT_INSET = 0.001 const SINK_FALSE_FRONT_HEIGHT = 0.22 const MIN_RENDERABLE_BRIDGE_FILLER_WIDTH = 1e-4 +function addTopFinishGeometry( + group: Group, + node: CabinetModuleNode, + materials: CabinetSlotMaterials, + topY: number, + isWallCornerFiller = false, +) { + if (!node.topFinish || node.topFinish === 'none') return + + const height = Math.max(0.05, node.topFinishHeight ?? 0.33) + const board = node.boardThickness + const depth = Math.min(node.depth, Math.max(0.15, node.topFinishDepth ?? node.depth)) + const backInset = Math.min(0.012, depth * 0.08) + const backThickness = Math.min(0.006, board / 2) + const centerZ = (node.depth - depth) / 2 + const inset = node.frontOverlay === 'inset' + const isCornerFiller = node.moduleKind === 'corner-filler' + const openLeft = node.openSide === 'left' + const openRight = node.openSide === 'right' + const topFrontZ = inset + ? centerZ + depth / 2 - node.frontThickness / 2 - 0.0015 + : centerZ + depth / 2 + node.frontThickness / 2 - 0.0015 + + if (node.topFinish === 'trim') { + addBox( + group, + [node.width, height, depth], + [0, topY + height / 2, centerZ], + materials.carcass, + 'cabinet-top-trim', + 'carcass', + ) + return + } + + const innerLeft = -node.width / 2 + (node.openSide === 'left' ? 0 : board) + const innerRight = node.width / 2 - (node.openSide === 'right' ? 0 : board) + const innerWidth = Math.max(0.01, innerRight - innerLeft) + const innerCenterX = (innerLeft + innerRight) / 2 + if (!openLeft) { + addBox( + group, + [board, height, depth], + [-node.width / 2 + board / 2, topY + height / 2, centerZ], + materials.carcass, + 'cabinet-top-cabinet-side-left', + 'carcass', + ) + } + if (!openRight) { + addBox( + group, + [board, height, depth], + [node.width / 2 - board / 2, topY + height / 2, centerZ], + materials.carcass, + 'cabinet-top-cabinet-side-right', + 'carcass', + ) + } + addBox( + group, + [innerWidth, board, depth], + [innerCenterX, topY + board / 2, centerZ], + materials.carcass, + 'cabinet-top-cabinet-bottom', + 'carcass', + ) + addBox( + group, + [innerWidth, board, depth], + [innerCenterX, topY + height - board / 2, centerZ], + materials.carcass, + 'cabinet-top-cabinet-top', + 'carcass', + ) + addBox( + group, + [innerWidth, Math.max(0.001, height - board * 2), backThickness], + [innerCenterX, topY + height / 2, centerZ - depth / 2 + backInset + backThickness / 2], + materials.carcass, + 'cabinet-top-cabinet-back', + 'carcass', + ) + if (isCornerFiller) { + const frontExtension = board / 2 + node.frontGap + const wallFrontSharedInset = isWallCornerFiller ? node.frontThickness + node.frontGap : 0 + const frontLeft = + -node.width / 2 - + (openLeft ? frontExtension : 0) + + (isWallCornerFiller && openRight ? wallFrontSharedInset : 0) + const frontRight = + node.width / 2 + + (openRight ? frontExtension : 0) - + (isWallCornerFiller && openLeft ? wallFrontSharedInset : 0) + const frontHeight = isWallCornerFiller + ? Math.max(0.01, height - WALL_CORNER_FILLER_FRONT_HEIGHT_INSET * 2) + : height + addBox( + group, + [Math.max(0.01, frontRight - frontLeft), frontHeight, node.frontThickness], + [(frontLeft + frontRight) / 2, topY + height / 2, topFrontZ], + materials.front, + 'cabinet-top-corner-filler-front', + 'front', + ) + return + } + // Keep the upper front's reveal contract identical to the parent cabinet. + // Overlay fronts reserve one extra front gap at the opening edge; addDoorFronts + // applies the remaining leaf-to-leaf gaps. Inset fronts use the carcass opening. + const faceWidth = inset ? innerWidth : Math.max(0.01, node.width - node.frontGap) + const topDoorCompartment = stackForCabinet(node).find( + (compartment) => compartment.type === 'door', + ) + const topDoorType = topDoorCompartment + ? compartmentDoorType(topDoorCompartment, node.width) + : node.width > 0.5 + ? 'double' + : 'single-left' + addDoorFronts( + group, + node, + materials, + faceWidth, + inset ? Math.max(0.01, height - board * 2) : height, + 0, + topY + height / 2, + topFrontZ, + topDoorType, + ) +} + export function buildCabinetGeometry( node: CabinetGeometryNode, ctx?: GeometryContext, @@ -203,6 +340,7 @@ export function buildCabinetGeometry( innerCenterX, ) } + addTopFinishGeometry(filler, node, materials, topY, isWallCornerFiller) return filler } @@ -514,5 +652,7 @@ export function buildCabinetGeometry( } }) + addTopFinishGeometry(group, node, materials, topY) + return group } diff --git a/packages/nodes/src/cabinet/index.ts b/packages/nodes/src/cabinet/index.ts index cc69a75bec..00196bd931 100644 --- a/packages/nodes/src/cabinet/index.ts +++ b/packages/nodes/src/cabinet/index.ts @@ -5,3 +5,12 @@ export { type CabinetPlacementType, default as useCabinetPlacementType, } from './placement-type' +export { + CABINET_PLANNING_TOLERANCE, + type CabinetPlanningIssue, + type CabinetPlanningIssueCode, + type CabinetPlanningOptions, + type CabinetPlanningReport, + MIN_PRACTICAL_TOP_CABINET_HEIGHT, + validateCabinetRun, +} from './validation' diff --git a/packages/nodes/src/cabinet/panel-visibility.ts b/packages/nodes/src/cabinet/panel-visibility.ts new file mode 100644 index 0000000000..686173b2cf --- /dev/null +++ b/packages/nodes/src/cabinet/panel-visibility.ts @@ -0,0 +1,23 @@ +import type { CabinetModuleNode, CabinetNode } from '@pascal-app/core' +import { resolveCabinetType } from './run-ops' + +export function cabinetModuleSupportsPresets(module: CabinetModuleNode) { + return module.moduleKind !== 'corner-filler' +} + +export function cabinetModuleSupportsTopFinish({ + module, + parentIsModule, + parentRun, +}: { + module: CabinetModuleNode + parentIsModule: boolean + parentRun?: CabinetNode +}) { + return ( + module.moduleKind === 'corner-filler' || + parentIsModule || + resolveCabinetType(module, parentRun) === 'tall' || + parentRun?.runTier === 'wall' + ) +} diff --git a/packages/nodes/src/cabinet/panel.tsx b/packages/nodes/src/cabinet/panel.tsx index f6e2cddd3b..c0a1c47bcb 100644 --- a/packages/nodes/src/cabinet/panel.tsx +++ b/packages/nodes/src/cabinet/panel.tsx @@ -1,6 +1,7 @@ 'use client' import type { + AnyNode, AnyNodeId, CabinetModuleNode as CabinetModuleNodeType, CabinetNode as CabinetNodeType, @@ -14,7 +15,7 @@ import { SliderControl, } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' -import { Pause, Play, Plus } from 'lucide-react' +import { AlertTriangle, Pause, Play, Plus } from 'lucide-react' import { useCallback, useEffect, useState } from 'react' import { useShallow } from 'zustand/react/shallow' import { CompartmentCard } from './compartment-card' @@ -24,15 +25,26 @@ import { onCabinetAnimationChange, stopCabinetAnimation, } from './interaction' +import { cabinetModuleSupportsPresets, cabinetModuleSupportsTopFinish } from './panel-visibility' import { CABINET_PRESETS, type CabinetPresetId } from './presets' +import { + CABINET_REVEAL_GAPS, + type CabinetRevealGapId, + cabinetRevealGapById, + cabinetRevealGapId, +} from './reveals' import { addWallChildAbove, + applyCabinetModuleFrontPatch, backAlignZ, + type CabinetRunStylePatch, + cabinetCeilingGap, resolveCabinetType, runModuleBaseY, switchCabinetToBase, switchCabinetToTall, syncCornerRunsFromSourceModule, + syncCornerStyleGroupFromRun, wallChildOf, } from './run-ops' import { @@ -48,10 +60,18 @@ import { minCabinetCarcassHeightForStack, newCabinetCompartment, normalizeCabinetStack, + removeCabinetCompartmentStack, resizeCabinetCompartmentStack, stackForCabinet, } from './stack' import { resolveCompartmentTransition } from './stack-transitions' +import { validateCabinetRun } from './validation' +import { + CABINET_STANDARD_WIDTHS, + type CabinetStandardWidthId, + cabinetStandardWidthById, + cabinetStandardWidthId, +} from './widths' const HANDLE_STYLE_OPTIONS = [ { value: 'bar', label: 'Bar' }, @@ -83,16 +103,25 @@ const CABINET_TIER_OPTIONS = [ { value: 'tall', label: 'Tall Cabinet' }, ] as const +const TOP_FINISH_OPTIONS = [ + { value: 'none', label: 'None' }, + { value: 'top-cabinet', label: 'Top Cabinet' }, + { value: 'trim', label: 'Trim / Soffit' }, +] as const + const EMPTY_MODULES: CabinetModuleNodeType[] = [] const EMPTY_MODULE_IDS: AnyNodeId[] = [] const PRESET_BUTTON_CLASS = 'flex h-9 items-center justify-center rounded-md border border-border/40 bg-[#252527] px-3 py-2 text-center text-xs font-medium text-foreground transition-colors hover:border-border/70 hover:bg-[#303033]' +const REFLOW_REJECTED_MESSAGE = + 'No space in this run. No base cabinet can shrink enough to fit this item.' export default function CabinetPanel() { const selectedId = useViewer((s) => s.selection.selectedIds[0]) const setSelection = useViewer((s) => s.setSelection) const [isAnimating, setIsAnimating] = useState(false) + const [reflowNotice, setReflowNotice] = useState<{ message: string } | null>(null) const node = useScene((s) => selectedId ? (s.nodes[selectedId as AnyNodeId] as CabinetEditableNode | undefined) : undefined, ) @@ -141,6 +170,20 @@ export default function CabinetPanel() { ) }) + const showReflowRejected = useCallback(() => { + setReflowNotice({ message: REFLOW_REJECTED_MESSAGE }) + }, []) + + useEffect(() => { + if (selectedId) setReflowNotice(null) + }, [selectedId]) + + useEffect(() => { + if (!reflowNotice) return + const timeout = window.setTimeout(() => setReflowNotice(null), 4000) + return () => window.clearTimeout(timeout) + }, [reflowNotice]) + const updateNode = useCallback( (patch: Partial) => { if (!selectedId) return @@ -158,6 +201,44 @@ export default function CabinetPanel() { minCabinetCarcassHeightForStack(liveBeforeUpdate), ) } + if (liveBeforeUpdate?.type === 'cabinet-module') { + const frontPatch: CabinetRunStylePatch = {} + if ('frontStyle' in nextPatch) frontPatch.frontStyle = nextPatch.frontStyle + if ('frontOverlay' in nextPatch) frontPatch.frontOverlay = nextPatch.frontOverlay + if ('handleStyle' in nextPatch) frontPatch.handleStyle = nextPatch.handleStyle + if ('handlePosition' in nextPatch) frontPatch.handlePosition = nextPatch.handlePosition + if (Object.keys(frontPatch).length > 0) { + applyCabinetModuleFrontPatch({ + module: liveBeforeUpdate, + patch: frontPatch, + sceneApi: createSceneApi(useScene), + }) + } + } + if ( + liveBeforeUpdate?.type === 'cabinet-module' && + liveBeforeUpdate.parentId && + parentRun?.type === 'cabinet' && + typeof nextPatch.frontGap === 'number' + ) { + const frontGap = nextPatch.frontGap + scene.updateNode(parentRun.id as AnyNodeId, { frontGap }) + for (const module of modules) { + scene.updateNode(module.id as AnyNodeId, { frontGap }) + const wallChild = wallChildOf( + module, + scene.nodes as Record, + ) + if (wallChild) scene.updateNode(wallChild.id as AnyNodeId, { frontGap }) + } + bumpRunLayoutRevisionViaStore(scene, parentRun) + syncCornerStyleGroupFromRun({ + run: parentRun, + patch: { frontGap }, + sceneApi: createSceneApi(useScene), + }) + return + } if ( liveBeforeUpdate?.type === 'cabinet-module' && liveBeforeUpdate.parentId && @@ -165,13 +246,15 @@ export default function CabinetPanel() { 'width' in nextPatch && typeof nextPatch.width === 'number' ) { - reflowRunModules({ + const applied = reflowRunModules({ modules, parentRun, patch: nextPatch as Partial, scene, selected: liveBeforeUpdate, }) + if (applied) setReflowNotice(null) + else showReflowRejected() return } if ( @@ -234,7 +317,7 @@ export default function CabinetPanel() { } } }, - [modules, parentRun, selectedId], + [modules, parentRun, selectedId, showReflowRejected], ) const close = useCallback(() => { @@ -273,12 +356,23 @@ export default function CabinetPanel() { if (!node || (node.type !== 'cabinet' && node.type !== 'cabinet-module')) return null const stack = stackForCabinet(node) + const planningRun = node.type === 'cabinet' ? node : parentRun + const planningReport = planningRun ? validateCabinetRun(planningRun, modules) : null const isHoodOnlyNode = stack.length > 0 && stack.every((compartment) => isHoodCompartmentType(compartment.type)) const normalized = normalizeCabinetStack(node) const rowHeights = new Map(normalized.map((row) => [row.index, row.height])) const rows = stack.map((compartment, index) => ({ compartment, index })).reverse() + const removeWallChildForTallPatch = ( + patch: Partial, + scene: ReturnType, + ) => { + if (node.type !== 'cabinet-module' || patch.cabinetType !== 'tall') return + const child = wallChildOf(node, scene.nodes as Record) + if (child) scene.deleteNode(child.id as AnyNodeId) + } + const commitStack = ( next: CabinetCompartment[], extraPatch: Partial = {}, @@ -287,25 +381,42 @@ export default function CabinetPanel() { const minCarcassHeight = minCabinetCarcassHeightForStack({ ...node, stack: next }) const targetCarcassHeight = patch.carcassHeight ?? node.carcassHeight if (targetCarcassHeight < minCarcassHeight) patch.carcassHeight = minCarcassHeight + const scene = useScene.getState() if (node.type === 'cabinet-module' && parentRun?.type === 'cabinet' && patch.width) { - reflowRunModules({ + const applied = reflowRunModules({ modules, parentRun, patch, - scene: useScene.getState(), + scene, selected: node, }) + if (!applied) { + showReflowRejected() + return + } + setReflowNotice(null) + removeWallChildForTallPatch(patch, scene) return } + removeWallChildForTallPatch(patch, scene) updateNode(patch) } const replaceAt = (index: number, next: CabinetCompartment) => { const transition = resolveCompartmentTransition({ node, parentRun, index, next }) commitStack(transition.stack, transition.modulePatch) } - const resizeAt = (index: number, height: number) => - commitStack(resizeCabinetCompartmentStack(node, index, height)) - const removeAt = (index: number) => commitStack(stack.filter((_, i) => i !== index)) + const resizeAt = (index: number, height: number) => { + const resized = resizeCabinetCompartmentStack(node, index, height) + const extraPatch: Partial = + stack.length === 1 && resized[0] + ? { carcassHeight: resized[0].height ?? node.carcassHeight } + : {} + commitStack(resized, extraPatch) + } + const removeAt = (index: number) => { + const result = removeCabinetCompartmentStack(node, index) + commitStack(result.stack, result.carcassHeight == null ? {} : result) + } const addCompartment = () => commitStack([...stack, newCabinetCompartment('shelf')]) const moveCompartment = (index: number, delta: -1 | 1) => { const target = index + delta @@ -355,21 +466,22 @@ export default function CabinetPanel() { const hasWallCabinet = node?.type === 'cabinet-module' ? Boolean(wallChild) : false const isWallChildModule = node?.type === 'cabinet-module' && parentIsModule + const canAddTopFinish = + node.type === 'cabinet-module' && + !isHoodOnlyNode && + cabinetModuleSupportsTopFinish({ + module: node, + parentIsModule, + parentRun, + }) const applyPreset = (presetId: CabinetPresetId) => { - if (node?.type !== 'cabinet-module') return + if (node?.type !== 'cabinet-module' || !cabinetModuleSupportsPresets(node)) return const scene = useScene.getState() const preset = CABINET_PRESETS.find((entry) => entry.id === presetId) if (!preset) return const patch = preset.createPatch(parentRun) - const wallChild = wallChildOf( - node, - scene.nodes as Record, - ) - if (wallChild && patch.cabinetType === 'tall') { - scene.deleteNode(wallChild.id as AnyNodeId) - } const nextPatch: Partial = { ...patch, @@ -383,20 +495,29 @@ export default function CabinetPanel() { } if (parentRun?.type === 'cabinet') { - reflowRunModules({ + const applied = reflowRunModules({ modules, parentRun, patch: nextPatch, - preserveExtent: true, scene, selected: node, }) + if (!applied) { + showReflowRejected() + return + } + setReflowNotice(null) + removeWallChildForTallPatch(patch, scene) } else { + removeWallChildForTallPatch(patch, scene) scene.updateNode(node.id as AnyNodeId, nextPatch) } setSelection({ selectedIds: [node.id] }) } + const standardWidth = + node.type === 'cabinet-module' ? cabinetStandardWidthId(node.width) : 'custom' + if (node.type === 'cabinet' && modules.length > 0) { return } @@ -409,24 +530,46 @@ export default function CabinetPanel() { title={node.name || 'Modular Cabinet'} width={320} > - {node.type === 'cabinet-module' && parentRun?.type === 'cabinet' && ( - -
- {CABINET_PRESETS.map((preset) => ( - - ))} -
-
- )} + {node.type === 'cabinet-module' && + parentRun?.type === 'cabinet' && + cabinetModuleSupportsPresets(node) && ( + +
+ {CABINET_PRESETS.map((preset) => ( + + ))} +
+
+ )} + {node.type === 'cabinet-module' && !isHoodOnlyNode && ( +
+
+ Standard width +
+ + updateNode({ + width: cabinetStandardWidthById(value as CabinetStandardWidthId).value, + }) + } + options={CABINET_STANDARD_WIDTHS.map((option) => ({ + label: option.label, + value: option.id, + }))} + value={standardWidth === 'custom' ? '600' : standardWidth} + /> +
+ )} )} + {canAddTopFinish && ( + +
+
+
+ Finish +
+ + updateNode({ + topFinish: value as CabinetModuleNodeType['topFinish'], + ...(value !== 'none' && node.topFinish === 'none' + ? { topFinishDepth: node.depth } + : {}), + }) + } + options={TOP_FINISH_OPTIONS.map((option) => ({ + label: option.label, + value: option.value, + }))} + value={node.topFinish ?? 'none'} + /> +
+ {node.topFinish !== 'none' && ( + <> + + updateNode({ + topFinishHeight: cabinetCeilingGap( + node, + useScene.getState().nodes as Record, + ), + }) + } + /> + updateNode({ topFinishHeight: value })} + precision={2} + step={0.01} + unit="m" + value={node.topFinishHeight} + /> + updateNode({ topFinishDepth: value })} + precision={2} + step={0.01} + unit="m" + value={node.topFinishDepth} + /> + + )} +
+
+ )} + + {planningReport && + (planningReport.errors.length > 0 || planningReport.warnings.length > 0) && ( + +
+ {planningReport.errors.map((planningIssue) => ( +
+ + {planningIssue.message} +
+ ))} + {planningReport.warnings.map((planningIssue) => ( +
+ + {planningIssue.message} +
+ ))} +
+
+ )} + {!isHoodOnlyNode && (
@@ -553,6 +784,15 @@ export default function CabinetPanel() { )} + {reflowNotice ? ( +

+ {reflowNotice.message} +

+ ) : null}
{rows.map(({ compartment, index }, displayIndex) => (
+
+
+ Reveal gap +
+ + updateNode({ + frontGap: cabinetRevealGapById(value as CabinetRevealGapId).value, + }) + } + options={CABINET_REVEAL_GAPS.map((gap) => ({ + value: gap.id, + label: gap.label, + }))} + value={ + cabinetRevealGapId(node.frontGap) === 'custom' + ? '3' + : cabinetRevealGapId(node.frontGap) + } + /> +
diff --git a/packages/nodes/src/cabinet/presets.ts b/packages/nodes/src/cabinet/presets.ts index 186cc169b8..ad36e9a2ff 100644 --- a/packages/nodes/src/cabinet/presets.ts +++ b/packages/nodes/src/cabinet/presets.ts @@ -1,16 +1,17 @@ import type { CabinetModuleNode, CabinetNode } from '@pascal-app/core' +import { CABINET_METRIC_DEFAULTS } from '@pascal-app/core' import { COOKTOP_STANDARD_WIDTH, cooktopCabinetStack, DISHWASHER_STANDARD_HEIGHT, DISHWASHER_STANDARD_WIDTH, + FRIDGE_COLUMN_HEIGHT, FRIDGE_COLUMN_WIDTH, fridgeCabinetStack, MICROWAVE_STANDARD_WIDTH, newCabinetCompartment, SINK_STANDARD_WIDTH, sinkCabinetStack, - TALL_CABINET_CARCASS_HEIGHT, } from './stack' export type CabinetPresetId = @@ -32,9 +33,9 @@ export type CabinetPreset = { const baseShared = (run?: CabinetNode): Partial => ({ cabinetType: 'base', - depth: run?.depth ?? 0.5, - carcassHeight: run?.carcassHeight ?? 0.72, - plinthHeight: run?.plinthHeight ?? 0.1, + depth: run?.depth ?? CABINET_METRIC_DEFAULTS.depth, + carcassHeight: run?.carcassHeight ?? CABINET_METRIC_DEFAULTS.carcassHeight, + plinthHeight: run?.plinthHeight ?? CABINET_METRIC_DEFAULTS.plinthHeight, toeKickDepth: run?.toeKickDepth ?? 0.075, countertopThickness: 0, countertopOverhang: run?.countertopOverhang ?? 0.02, @@ -42,7 +43,7 @@ const baseShared = (run?: CabinetNode): Partial => ({ withCountertop: false, }) -const runDepth = (run?: CabinetNode) => run?.depth ?? 0.5 +const runDepth = (run?: CabinetNode) => run?.depth ?? CABINET_METRIC_DEFAULTS.depth export const CABINET_PRESETS: CabinetPreset[] = [ { @@ -134,9 +135,9 @@ export const CABINET_PRESETS: CabinetPreset[] = [ cabinetType: 'tall', name: 'Tall Pantry', width: 0.5, - depth: run?.depth ?? 0.5, + depth: run?.depth ?? CABINET_METRIC_DEFAULTS.depth, carcassHeight: 2.07, - plinthHeight: 0.1, + plinthHeight: CABINET_METRIC_DEFAULTS.plinthHeight, toeKickDepth: 0.075, countertopThickness: 0, countertopOverhang: run?.countertopOverhang ?? 0.02, @@ -155,9 +156,9 @@ export const CABINET_PRESETS: CabinetPreset[] = [ cabinetType: 'tall', name: 'Oven Tower', width: MICROWAVE_STANDARD_WIDTH, - depth: run?.depth ?? 0.5, + depth: run?.depth ?? CABINET_METRIC_DEFAULTS.depth, carcassHeight: 2.07, - plinthHeight: 0.1, + plinthHeight: CABINET_METRIC_DEFAULTS.plinthHeight, toeKickDepth: 0.075, countertopThickness: 0, countertopOverhang: run?.countertopOverhang ?? 0.02, @@ -182,8 +183,8 @@ export const CABINET_PRESETS: CabinetPreset[] = [ name: 'Single Door Refrigerator', width: FRIDGE_COLUMN_WIDTH, depth: runDepth(run), - carcassHeight: TALL_CABINET_CARCASS_HEIGHT, - plinthHeight: 0.1, + carcassHeight: FRIDGE_COLUMN_HEIGHT, + plinthHeight: CABINET_METRIC_DEFAULTS.plinthHeight, toeKickDepth: 0.075, countertopThickness: 0, countertopOverhang: run?.countertopOverhang ?? 0.02, diff --git a/packages/nodes/src/cabinet/profiles.ts b/packages/nodes/src/cabinet/profiles.ts new file mode 100644 index 0000000000..5cc7e46466 --- /dev/null +++ b/packages/nodes/src/cabinet/profiles.ts @@ -0,0 +1,51 @@ +import type { CabinetNode } from '@pascal-app/core' +import { CABINET_METRIC_DEFAULTS } from '@pascal-app/core' + +export type CabinetDimensionProfileId = 'metric-base' | 'us-base' + +export type CabinetDimensionProfile = { + id: CabinetDimensionProfileId + label: string + depth: number + carcassHeight: number + plinthHeight: number + countertopThickness: number +} + +export const CABINET_DIMENSION_PROFILES: CabinetDimensionProfile[] = [ + { + id: 'metric-base', + label: 'Metric · 600 mm', + depth: CABINET_METRIC_DEFAULTS.depth, + carcassHeight: CABINET_METRIC_DEFAULTS.carcassHeight, + plinthHeight: CABINET_METRIC_DEFAULTS.plinthHeight, + countertopThickness: CABINET_METRIC_DEFAULTS.countertopThickness, + }, + { + id: 'us-base', + label: 'US · 24 in', + depth: 0.6096, + carcassHeight: 0.762, + plinthHeight: 0.1016, + countertopThickness: 0.0381, + }, +] + +const PROFILE_MATCH_TOLERANCE = 1e-4 + +export function cabinetDimensionProfileId( + node: Pick, +): CabinetDimensionProfileId | 'custom' { + const profile = CABINET_DIMENSION_PROFILES.find( + (candidate) => + Math.abs(candidate.depth - node.depth) <= PROFILE_MATCH_TOLERANCE && + Math.abs(candidate.carcassHeight - node.carcassHeight) <= PROFILE_MATCH_TOLERANCE && + Math.abs(candidate.plinthHeight - node.plinthHeight) <= PROFILE_MATCH_TOLERANCE && + Math.abs(candidate.countertopThickness - node.countertopThickness) <= PROFILE_MATCH_TOLERANCE, + ) + return profile?.id ?? 'custom' +} + +export function cabinetDimensionProfileById(id: CabinetDimensionProfileId) { + return CABINET_DIMENSION_PROFILES.find((profile) => profile.id === id)! +} diff --git a/packages/nodes/src/cabinet/reveals.ts b/packages/nodes/src/cabinet/reveals.ts new file mode 100644 index 0000000000..32ad4c7aa4 --- /dev/null +++ b/packages/nodes/src/cabinet/reveals.ts @@ -0,0 +1,21 @@ +export type CabinetRevealGapId = '2' | '3' | '4' | '6' + +export const CABINET_REVEAL_GAPS = [ + { id: '2', label: '2 mm', value: 0.002 }, + { id: '3', label: '3 mm', value: 0.003 }, + { id: '4', label: '4 mm', value: 0.004 }, + { id: '6', label: '6 mm', value: 0.006 }, +] as const satisfies ReadonlyArray<{ + id: CabinetRevealGapId + label: string + value: number +}> + +export function cabinetRevealGapId(value: number): CabinetRevealGapId | 'custom' { + const match = CABINET_REVEAL_GAPS.find((gap) => Math.abs(gap.value - value) < 1e-4) + return match?.id ?? 'custom' +} + +export function cabinetRevealGapById(id: CabinetRevealGapId) { + return CABINET_REVEAL_GAPS.find((gap) => gap.id === id) ?? CABINET_REVEAL_GAPS[1] +} diff --git a/packages/nodes/src/cabinet/run-layout.ts b/packages/nodes/src/cabinet/run-layout.ts index bdaa68d3d4..89a11ec967 100644 --- a/packages/nodes/src/cabinet/run-layout.ts +++ b/packages/nodes/src/cabinet/run-layout.ts @@ -1,4 +1,12 @@ -import type { AnyNode, CabinetModuleNode, CabinetNode, GeometryContext } from '@pascal-app/core' +import type { + AnyNode, + AnyNodeId, + CabinetModuleNode, + CabinetNode, + GeometryContext, + WallNode, +} from '@pascal-app/core' +import { resolveLevelId } from '@pascal-app/core' /** * Straight-line run layout math — the single home for the "modules sit on the @@ -11,15 +19,36 @@ export const RUN_ADJACENCY_EPSILON = 1e-4 const ADJACENT_RUN_EPSILON = 1e-4 const ADJACENT_RUN_Z_TOLERANCE = 0.03 +const REFLOW_CAPACITY_EPSILON = 1e-9 type ModuleLike = Pick type ReflowRunModulesOptions = { + wallConstraints?: RunWallConstraints + eligibleDonorIds?: ReadonlySet + maximumWidth?: number + maximumWidthById?: ReadonlyMap minimumWidth?: number - preserveExtent?: boolean + minimumWidthById?: ReadonlyMap restorableWidthById?: ReadonlyMap } +export type RunWallEndConstraint = { + constrained: boolean + slack: number +} + +export type RunWallConstraints = { + left: RunWallEndConstraint + right: RunWallEndConstraint +} + +type RunWallConstraintOptions = { + widthGrowth?: number +} + +const OPEN_RUN_END: RunWallEndConstraint = { constrained: false, slack: 0 } + export function sortRunModules(modules: readonly T[]): T[] { return [...modules].sort((a, b) => a.position[0] - b.position[0]) } @@ -32,6 +61,130 @@ export function moduleMaxX(module: Pick return module.position[0] + module.width / 2 } +function levelIdForRun( + run: Pick, + nodes: Readonly>>, +): AnyNodeId | null { + let parentId = run.parentId as AnyNodeId | null + const visited = new Set() + while (parentId && !visited.has(parentId)) { + visited.add(parentId) + const parent = nodes[parentId] + if (!parent) return null + if (parent.type === 'level') return parent.id as AnyNodeId + parentId = parent.parentId as AnyNodeId | null + } + return null +} + +function runInLevelFrame( + run: Pick, + nodes: Readonly>>, +): Pick { + let position: CabinetNode['position'] = [...run.position] + let rotation = run.rotation + let parentId = run.parentId as AnyNodeId | null + const visited = new Set() + + while (parentId && !visited.has(parentId)) { + visited.add(parentId) + const parent = nodes[parentId] + if (parent?.type !== 'cabinet' && parent?.type !== 'cabinet-module') break + position = runLocalToPlan(parent, position) + rotation += parent.rotation + parentId = parent.parentId as AnyNodeId | null + } + + return { depth: run.depth, position, rotation } +} + +function closestPointOnSegment( + point: readonly [number, number], + start: readonly [number, number], + end: readonly [number, number], +): readonly [number, number] { + const dx = end[0] - start[0] + const dz = end[1] - start[1] + const lengthSquared = dx * dx + dz * dz + if (lengthSquared <= 1e-8) return start + const t = Math.max( + 0, + Math.min(1, ((point[0] - start[0]) * dx + (point[1] - start[1]) * dz) / lengthSquared), + ) + return [start[0] + t * dx, start[1] + t * dz] +} + +function wallConstraintAtRunEnd({ + endX, + run, + side, + walls, + widthGrowth, +}: { + endX: number + run: Pick + side: 'left' | 'right' + walls: readonly WallNode[] + widthGrowth: number +}): RunWallEndConstraint { + const worldPoint = runLocalToPlan(run, [endX, 0, 0]) + const point: readonly [number, number] = [worldPoint[0], worldPoint[2]] + const runAxis: readonly [number, number] = [Math.cos(run.rotation), -Math.sin(run.rotation)] + const maxDistance = Math.max(run.depth / 2 + 0.08, widthGrowth) + const direction = side === 'left' ? -1 : 1 + let closestSlack = Number.POSITIVE_INFINITY + + for (const wall of walls) { + const dx = wall.end[0] - wall.start[0] + const dz = wall.end[1] - wall.start[1] + const length = Math.hypot(dx, dz) + if (length <= 1e-6) continue + const wallAxis: readonly [number, number] = [dx / length, dz / length] + const axisDot = runAxis[0] * wallAxis[0] + runAxis[1] * wallAxis[1] + if (Math.abs(axisDot) > 0.2) continue + const closest = closestPointOnSegment(point, wall.start, wall.end) + const offsetX = (closest[0] - point[0]) * runAxis[0] + (closest[1] - point[1]) * runAxis[1] + const halfThickness = ((wall.thickness ?? 0.2) / 2) * Math.sqrt(1 - axisDot * axisDot) + const distance = Math.hypot(point[0] - closest[0], point[1] - closest[1]) + if (distance > maxDistance + (wall.thickness ?? 0.2) / 2 + RUN_ADJACENCY_EPSILON) continue + if (direction * offsetX < -halfThickness - RUN_ADJACENCY_EPSILON) continue + const slack = Math.max(0, direction * offsetX - halfThickness) + closestSlack = Math.min(closestSlack, slack) + } + + return Number.isFinite(closestSlack) ? { constrained: true, slack: closestSlack } : OPEN_RUN_END +} + +export function runWallConstraints( + run: Pick, + modules: readonly ModuleLike[], + nodes: Readonly>>, + options: RunWallConstraintOptions = {}, +): RunWallConstraints { + const levelId = levelIdForRun(run, nodes) + if (!levelId) return { left: OPEN_RUN_END, right: OPEN_RUN_END } + const walls = Object.values(nodes).filter( + (node): node is WallNode => + node?.type === 'wall' && + resolveLevelId(node, nodes as Record) === levelId, + ) + if (walls.length === 0) return { left: OPEN_RUN_END, right: OPEN_RUN_END } + const minX = modules.length > 0 ? runMinX(modules) : -run.width / 2 + const maxX = modules.length > 0 ? runMaxX(modules) : run.width / 2 + const levelRun = runInLevelFrame(run, nodes) + const widthGrowth = Math.max(0, options.widthGrowth ?? 0) + return { + left: wallConstraintAtRunEnd({ endX: minX, run: levelRun, side: 'left', walls, widthGrowth }), + right: wallConstraintAtRunEnd({ + endX: maxX, + run: levelRun, + side: 'right', + walls, + widthGrowth, + }), + } +} + export function runMinX(modules: readonly ModuleLike[]): number { return Math.min(...modules.map(moduleMinX)) } @@ -390,8 +543,10 @@ export function sideInsertX({ } /** - * Re-pack the run left-to-right after one module's width changes, keeping - * every module flush with its left neighbor. Returns per-module patches. + * Re-pack the run after one module's width changes. A single constrained end + * may consume its wall gap. When both ends are constrained, the run extent is + * fixed and eligible donors absorb the growth, nearest first. The change is + * rejected only when their combined capacity is insufficient. */ export function reflowRunModules( modules: readonly T[], @@ -407,30 +562,66 @@ export function reflowRunModules( widths.set(selectedId, selectedWidth) const selected = sorted[selectedIndex]! - let remainingGrowth = selectedWidth - selected.width - if (options.preserveExtent && remainingGrowth > RUN_ADJACENCY_EPSILON) { - const minimumWidth = options.minimumWidth ?? 0.3 - const left = sorted.slice(0, selectedIndex).reverse() - const right = sorted.slice(selectedIndex + 1) - const capacity = (candidates: readonly T[]) => - candidates.reduce((total, module) => total + Math.max(0, module.width - minimumWidth), 0) - const candidates = capacity(left) > capacity(right) ? [...left, ...right] : [...right, ...left] - - for (const module of candidates) { - if (remainingGrowth <= RUN_ADJACENCY_EPSILON) break - const available = Math.max(0, module.width - minimumWidth) - const reduction = Math.min(available, remainingGrowth) - widths.set(module.id, module.width - reduction) - remainingGrowth -= reduction + const wallConstraints = options.wallConstraints + const leftConstrained = wallConstraints?.left.constrained ?? false + const rightConstrained = wallConstraints?.right.constrained ?? false + const preserveExtent = leftConstrained && rightConstrained + const widthGrowth = selectedWidth - selected.width + let remainingGrowth = Math.max(0, widthGrowth) + const consumedRightSlack = + rightConstrained && !preserveExtent + ? Math.min(remainingGrowth, Math.max(0, wallConstraints?.right.slack ?? 0)) + : 0 + remainingGrowth -= consumedRightSlack + const consumedLeftSlack = + leftConstrained && !preserveExtent + ? Math.min(remainingGrowth, Math.max(0, wallConstraints?.left.slack ?? 0)) + : 0 + remainingGrowth -= consumedLeftSlack + + if (preserveExtent && remainingGrowth > REFLOW_CAPACITY_EPSILON) { + const defaultMinimumWidth = options.minimumWidth ?? 0.3 + const minimumWidth = (module: T) => + options.minimumWidthById?.get(module.id) ?? defaultMinimumWidth + const donors = sorted + .map((module, index) => ({ index, module })) + .filter( + ({ module }) => + module.id !== selectedId && + (!options.eligibleDonorIds || options.eligibleDonorIds.has(module.id)) && + module.width - minimumWidth(module) > REFLOW_CAPACITY_EPSILON, + ) + .sort((a, b) => { + const distance = Math.abs(a.index - selectedIndex) - Math.abs(b.index - selectedIndex) + if (distance !== 0) return distance + const capacity = + Math.max(0, b.module.width - Math.max(defaultMinimumWidth, minimumWidth(b.module))) - + Math.max(0, a.module.width - Math.max(defaultMinimumWidth, minimumWidth(a.module))) + if (capacity !== 0) return capacity + return b.index - a.index + }) + const available = donors.reduce( + (total, { module }) => total + Math.max(0, module.width - minimumWidth(module)), + 0, + ) + if (available + REFLOW_CAPACITY_EPSILON < remainingGrowth) return [] + + for (const useTrimCapacity of [false, true]) { + for (const { module } of donors) { + if (remainingGrowth <= REFLOW_CAPACITY_EPSILON) break + const currentWidth = widths.get(module.id) ?? module.width + const floor = useTrimCapacity + ? minimumWidth(module) + : Math.max(defaultMinimumWidth, minimumWidth(module)) + const donation = Math.min(Math.max(0, currentWidth - floor), remainingGrowth) + widths.set(module.id, Math.max(floor, currentWidth - donation)) + remainingGrowth -= donation + } } } let remainingFreedWidth = selected.width - selectedWidth - if ( - options.preserveExtent && - remainingFreedWidth > RUN_ADJACENCY_EPSILON && - options.restorableWidthById - ) { + if (preserveExtent && remainingFreedWidth > REFLOW_CAPACITY_EPSILON) { const left = sorted.slice(0, selectedIndex).reverse() const right = sorted.slice(selectedIndex + 1) const restorable = (candidates: readonly T[]) => @@ -442,15 +633,53 @@ export function reflowRunModules( restorable(left) > restorable(right) ? [...left, ...right] : [...right, ...left] for (const module of candidates) { - if (remainingFreedWidth <= RUN_ADJACENCY_EPSILON) break - const available = Math.max(0, options.restorableWidthById.get(module.id) ?? 0) + if (remainingFreedWidth <= REFLOW_CAPACITY_EPSILON) break + const available = Math.max(0, options.restorableWidthById?.get(module.id) ?? 0) const restoration = Math.min(available, remainingFreedWidth) widths.set(module.id, module.width + restoration) remainingFreedWidth -= restoration } + + if (remainingFreedWidth > REFLOW_CAPACITY_EPSILON) { + const maximumWidth = options.maximumWidth ?? 1.2 + const fallbackCandidates = sorted + .map((module, index) => ({ index, module })) + .filter( + ({ module }) => + module.id !== selectedId && + (!options.eligibleDonorIds || options.eligibleDonorIds.has(module.id)), + ) + .sort((a, b) => { + const distance = Math.abs(a.index - selectedIndex) - Math.abs(b.index - selectedIndex) + if (distance !== 0) return distance + return b.index - a.index + }) + const available = fallbackCandidates.reduce((total, { module }) => { + const currentWidth = widths.get(module.id) ?? module.width + const moduleMaximum = options.maximumWidthById?.get(module.id) ?? maximumWidth + return total + Math.max(0, moduleMaximum - currentWidth) + }, 0) + if (available + REFLOW_CAPACITY_EPSILON < remainingFreedWidth) return [] + + for (const { module } of fallbackCandidates) { + if (remainingFreedWidth <= REFLOW_CAPACITY_EPSILON) break + const currentWidth = widths.get(module.id) ?? module.width + const moduleMaximum = options.maximumWidthById?.get(module.id) ?? maximumWidth + const restoration = Math.min(Math.max(0, moduleMaximum - currentWidth), remainingFreedWidth) + widths.set(module.id, currentWidth + restoration) + remainingFreedWidth -= restoration + } + } } - let nextLeft = runMinX(sorted) + const totalWidth = sorted.reduce((total, module) => total + (widths.get(module.id) ?? 0), 0) + let nextLeft = runMinX(sorted) - consumedLeftSlack + if ( + (!leftConstrained && !rightConstrained && selectedIndex === 0) || + (rightConstrained && !leftConstrained) + ) { + nextLeft = runMaxX(sorted) + consumedRightSlack - totalWidth + } return sorted.map((module) => { const width = widths.get(module.id) ?? module.width const position: T['position'] = [ diff --git a/packages/nodes/src/cabinet/run-ops.ts b/packages/nodes/src/cabinet/run-ops.ts index e86f1ce382..4e1c12a46e 100644 --- a/packages/nodes/src/cabinet/run-ops.ts +++ b/packages/nodes/src/cabinet/run-ops.ts @@ -1,6 +1,7 @@ import { type AnyNode, type AnyNodeId, + CABINET_METRIC_DEFAULTS, type CabinetModuleNode, type CabinetNode, calculateLevelMiters, @@ -17,6 +18,7 @@ import { planToRunLocal, runLocalToPlan, runLocalXExtent, + runWallConstraints, sideInsertX, sortRunModules, } from './run-layout' @@ -26,6 +28,7 @@ import { } from './schema' import { backAnchoredModuleZ, + DEFAULT_CEILING_HEIGHT, hoodCompartmentHeight, newCabinetCompartment, stackForCabinet, @@ -42,10 +45,10 @@ import { export const CABINET_BASE_WIDTH = 0.5 export const CABINET_WALL_DEPTH = 0.32 -export const CABINET_BASE_DEPTH = 0.5 -export const CABINET_WALL_CARCASS_HEIGHT = 0.72 -export const CABINET_TALL_DEPTH = 0.58 -export const CABINET_TALL_PLINTH_HEIGHT = 0.1 +export const CABINET_BASE_DEPTH = CABINET_METRIC_DEFAULTS.depth +export const CABINET_WALL_CARCASS_HEIGHT = CABINET_METRIC_DEFAULTS.carcassHeight +export const CABINET_TALL_DEPTH = CABINET_METRIC_DEFAULTS.depth +export const CABINET_TALL_PLINTH_HEIGHT = CABINET_METRIC_DEFAULTS.plinthHeight export const CABINET_TALL_CARCASS_HEIGHT = 2.07 export const CABINET_EDGE_EPSILON = 1e-4 const MIN_CORNER_CONNECTED_WIDTH = 0.3 @@ -81,9 +84,9 @@ export type WallCornerDepthIndex = ReadonlyArray<{ wallLegRunId: AnyNodeId }> -type CabinetRunStylePatch = Pick< +export type CabinetRunStylePatch = Pick< Partial, - 'frontStyle' | 'frontOverlay' | 'handleStyle' | 'handlePosition' + 'frontStyle' | 'frontOverlay' | 'handleStyle' | 'handlePosition' | 'frontGap' > export function cabinetMetadataRecord( @@ -319,6 +322,43 @@ export function wallBottomHeightForTallAlignment() { ) } +/** Resolve the remaining vertical space above a wall/tall module. */ +export function cabinetCeilingGap( + node: CabinetModuleNode, + nodes: Readonly>>, +): number { + let worldY = node.position[1] + let current: AnyNode = node + const visited = new Set() + let level: AnyNode | undefined + + while (current.parentId) { + const currentId = current.id as AnyNodeId + if (visited.has(currentId)) break + visited.add(currentId) + const parent: AnyNode | undefined = nodes[current.parentId as AnyNodeId] + if (!parent) break + if (parent.type === 'level') { + level = parent + break + } + if (parent.type !== 'cabinet' && parent.type !== 'cabinet-module') break + worldY += parent.position[1] + current = parent + } + + const ceilingHeight = + level?.type === 'level' && typeof level.height === 'number' + ? level.height + : DEFAULT_CEILING_HEIGHT + const currentTop = + worldY + + (node.showPlinth ? node.plinthHeight : 0) + + node.carcassHeight + + (node.withCountertop ? node.countertopThickness : 0) + return Math.max(0.05, ceilingHeight - currentTop) +} + /** Local Z offset that makes a shallower wall cabinet's back flush with its deeper base. */ export function backAlignZ(baseDepth: number, wallDepth: number) { return -(baseDepth - wallDepth) / 2 @@ -335,6 +375,22 @@ export function wallChildOf( return null } +export function applyCabinetModuleFrontPatch({ + module, + patch, + sceneApi, +}: { + module: CabinetModuleNode + patch: CabinetRunStylePatch + sceneApi: SceneApi +}) { + sceneApi.update(module.id as AnyNodeId, patch as Partial) + const wallChild = wallChildOf(module, sceneApi.nodes()) + if (wallChild) { + sceneApi.update(wallChild.id as AnyNodeId, patch as Partial) + } +} + export function resolveCabinetType(module: CabinetModuleNode, run?: CabinetNode): 'base' | 'tall' { if (module.cabinetType) return module.cabinetType return run?.runTier === 'tall' ? 'tall' : 'base' @@ -1050,10 +1106,17 @@ function resolveWallLimitedWidth({ position: [backLeft[0], 0, backLeft[1]] as [number, number, number], rotation, } + const runAxis: readonly [number, number] = [Math.cos(rotation), -Math.sin(rotation)] const miterData = calculateLevelMiters(walls) let blockingDistance = Number.POSITIVE_INFINITY for (const wall of walls) { + const wallDx = wall.end[0] - wall.start[0] + const wallDz = wall.end[1] - wall.start[1] + const wallLength = Math.hypot(wallDx, wallDz) + if (wallLength <= WALL_CLEARANCE_EPSILON) continue + const axisDot = (wallDx * runAxis[0] + wallDz * runAxis[1]) / wallLength + if (Math.abs(axisDot) > 0.2) continue const footprint = getWallPlanFootprint(wall, miterData) if (footprint.length < 3) continue @@ -1251,9 +1314,16 @@ function computeCornerRunLayout({ const corner = runLocalToPlan(runWorld, [cornerX, 0, backZ]) const sourceAxis: [number, number] = [Math.cos(runWorld.rotation), -Math.sin(runWorld.rotation)] const sign = side === 'right' ? 1 : -1 + const sourceWallConstraint = runWallConstraints(run, modules, nodes, { + widthGrowth: baseLegDepth, + })[side] + const sideWallInset = + turnSide === side && sourceWallConstraint.constrained + ? Math.max(0, baseLegDepth - sourceWallConstraint.slack) + : 0 const shiftedCorner: [number, number] = [ - corner[0] + sign * baseLegDepth * sourceAxis[0], - corner[2] + sign * baseLegDepth * sourceAxis[1], + corner[0] + sign * (baseLegDepth - sideWallInset) * sourceAxis[0], + corner[2] + sign * (baseLegDepth - sideWallInset) * sourceAxis[1], ] const legRotation = turnSide === 'right' ? runWorld.rotation - Math.PI / 2 : runWorld.rotation + Math.PI / 2 @@ -1783,11 +1853,53 @@ function syncDerivedCornerRun({ ? Math.min(...modules.map((entry) => entry.position[0] - entry.width / 2)) : Math.max(...modules.map((entry) => entry.position[0] + entry.width / 2)) - nextTotalWidth let cursor = fixedEdge + const nextPositions = currentWidths.map((width) => { + const positionX = cursor + width / 2 + cursor += width + return positionX + }) + const fillerName = role === 'base-leg' ? 'Corner Filler' : 'Corner Wall Filler' + const anchorModuleIndex = modules.findIndex((entry) => entry.name === fillerName) + const anchorModule = modules[anchorModuleIndex] + const canonicalAnchorIndex = anchorModule ? fullNames.indexOf(anchorModule.name) : -1 + if (anchorModule && canonicalAnchorIndex >= 0) { + const rotation = layout.legRotation + const layoutRunPosition = + role === 'base-leg' ? layout.baseRunPosition : layout.wallRunPosition + const anchorWorldPosition = runLocalToPlan({ position: layoutRunPosition, rotation }, [ + fullCenters[canonicalAnchorIndex] ?? 0, + 0, + 0, + ]) + const runWorldPosition = runLocalToPlan({ position: anchorWorldPosition, rotation }, [ + -(nextPositions[anchorModuleIndex] ?? 0), + 0, + -anchorModule.position[2], + ]) + const frameParent = cabinetFrameParent(run, sceneApi.nodes()) ?? sourceRun + const runPosition = worldToCabinetLocalPosition( + frameParent, + sceneApi.nodes(), + runWorldPosition, + ) + const localRotation = worldToCabinetLocalRotation(frameParent, sceneApi.nodes(), rotation) + const positionChanged = runPosition.some( + (value, index) => Math.abs(value - run.position[index]!) > CABINET_EDGE_EPSILON, + ) + if ( + positionChanged || + Math.abs(angleDelta(localRotation, run.rotation)) > CABINET_EDGE_EPSILON + ) { + sceneApi.update( + run.id as AnyNodeId, + { position: runPosition, rotation: localRotation } as Partial, + ) + } + } modules.forEach((entry, index) => { const spec = currentSpecs[index] if (!spec) return - const positionX = cursor + spec.width / 2 - cursor += spec.width + const positionX = nextPositions[index] ?? entry.position[0] sceneApi.update( entry.id as AnyNodeId, { @@ -2022,10 +2134,12 @@ export function syncCornerRunsFromSourceModule({ export function syncCornerRunsFromRunSources({ baseLayout = 'full', + previousModules = [], run, sceneApi, }: { baseLayout?: CornerBaseLayout + previousModules?: readonly CabinetModuleNode[] run: CabinetNode sceneApi: SceneApi }) { @@ -2033,7 +2147,35 @@ export function syncCornerRunsFromRunSources({ baseLayout === 'width-only' && !cornerDerivedRunLink(run.metadata) ? 'preserve-connected-widths' : baseLayout + const previousModulesById = new Map(previousModules.map((module) => [module.id, module])) for (const sourceModule of cornerSourceModulesForRun(run, sceneApi.nodes())) { + const previousModule = previousModulesById.get(sourceModule.id) + const sourceLink = previousModule ? cornerSourceLink(sourceModule.metadata) : null + if (previousModule && sourceLink) { + const previousEdge = + sourceLink.side === 'left' ? moduleMinX(previousModule) : moduleMaxX(previousModule) + const nextEdge = + sourceLink.side === 'left' ? moduleMinX(sourceModule) : moduleMaxX(sourceModule) + const edgeShift = nextEdge - previousEdge + if (Math.abs(edgeShift) > CABINET_EDGE_EPSILON) { + // Move the direct leg first so it stays attached even when a wall makes + // the canonical corner re-layout reject the otherwise valid live shape. + for (const linkedRunId of sourceLink.linkedRunIds) { + const linkedRun = sceneApi.get(linkedRunId) + if (linkedRun?.type !== 'cabinet' || linkedRun.parentId !== run.id) continue + sceneApi.update( + linkedRun.id as AnyNodeId, + { + position: [ + linkedRun.position[0] + edgeShift, + linkedRun.position[1], + linkedRun.position[2], + ], + } as Partial, + ) + } + } + } syncCornerRunsFromSourceModule({ baseLayout: effectiveBaseLayout, module: sourceModule, diff --git a/packages/nodes/src/cabinet/run-panel.tsx b/packages/nodes/src/cabinet/run-panel.tsx index 282348fd4a..4c361ad37f 100644 --- a/packages/nodes/src/cabinet/run-panel.tsx +++ b/packages/nodes/src/cabinet/run-panel.tsx @@ -1,6 +1,7 @@ 'use client' import type { + AnyNode, AnyNodeId, CabinetModuleNode as CabinetModuleNodeType, CabinetNode as CabinetNodeType, @@ -17,13 +18,29 @@ import { import { useViewer } from '@pascal-app/viewer' import { Plus, Trash } from 'lucide-react' import { useCallback, useMemo } from 'react' +import { + CABINET_DIMENSION_PROFILES, + type CabinetDimensionProfileId, + cabinetDimensionProfileById, + cabinetDimensionProfileId, +} from './profiles' +import { MAX_CABINET_WIDTH } from './resize-limits' +import { + CABINET_REVEAL_GAPS, + type CabinetRevealGapId, + cabinetRevealGapById, + cabinetRevealGapId, +} from './reveals' +import { runWallConstraints } from './run-layout' import { addCabinetModuleSide, backAlignZ, bumpCabinetRunLayoutRevision, cabinetMetadataRecord, cornerLinkedSourceModuleForRun, + resolveCabinetType, runModuleBaseY, + syncCornerRunsFromRunSources, syncCornerRunsFromSourceModule, syncCornerStyleGroupFromRun, wallChildOf, @@ -42,9 +59,12 @@ const RUN_MODULE_SYNC_PATCH_KEYS = new Set([ 'frontOverlay', 'handleStyle', 'handlePosition', + 'frontGap', ]) const RUN_DEPTH_PATCH_KEY = 'depth' const PRESET_WIDTH_DEBT_KEY = 'cabinetPresetWidthDebtBySource' +const PRESET_NOMINAL_WIDTH_KEY = 'cabinetPresetNominalWidth' +const MIN_TRIMMED_CORNER_PRESET_WIDTH = 0.05 const FRONT_STYLE_OPTIONS = [ { value: 'slab', label: 'Slab' }, @@ -111,36 +131,162 @@ function metadataWithPresetWidthDebt( const nextDebt = Math.max(0, presetWidthDebt(module, sourceId) - widthDelta) if (nextDebt > 1e-4) debts[sourceId] = nextDebt else delete debts[sourceId] - - if (Object.keys(debts).length > 0) { - return { ...metadata, [PRESET_WIDTH_DEBT_KEY]: debts } as CabinetModuleNodeType['metadata'] + const nextMetadata = { ...metadata } + if (widthDelta < -1e-4 && typeof nextMetadata[PRESET_NOMINAL_WIDTH_KEY] !== 'number') { + nextMetadata[PRESET_NOMINAL_WIDTH_KEY] = module.width } + if (Object.keys(debts).length > 0) nextMetadata[PRESET_WIDTH_DEBT_KEY] = debts + else delete nextMetadata[PRESET_WIDTH_DEBT_KEY] + return nextMetadata as CabinetModuleNodeType['metadata'] +} + +function metadataForSelectedWidth( + module: CabinetModuleNodeType, + width: number, + patchMetadata?: CabinetModuleNodeType['metadata'], +): CabinetModuleNodeType['metadata'] { + const metadata = cabinetMetadataRecord(patchMetadata ?? module.metadata) const { [PRESET_WIDTH_DEBT_KEY]: _removed, ...rest } = metadata - return rest as CabinetModuleNodeType['metadata'] + return { ...rest, [PRESET_NOMINAL_WIDTH_KEY]: width } as CabinetModuleNodeType['metadata'] +} + +function presetNominalWidth(module: CabinetModuleNodeType): number { + const value = cabinetMetadataRecord(module.metadata)[PRESET_NOMINAL_WIDTH_KEY] + return typeof value === 'number' && value >= module.width ? value : MAX_CABINET_WIDTH +} + +function canDonatePresetWidth(module: CabinetModuleNodeType, run: CabinetNodeType): boolean { + return ( + resolveCabinetType(module, run) === 'base' && + stackForCabinet(module).every( + (compartment) => + compartment.type === 'door' || + compartment.type === 'drawer' || + compartment.type === 'shelf', + ) + ) +} + +function hasLinkedCornerRun(module: CabinetModuleNodeType): boolean { + const value = cabinetMetadataRecord(module.metadata).cabinetCornerSourceLink + return ( + Boolean(value && typeof value === 'object' && !Array.isArray(value)) && + Array.isArray((value as { linkedRunIds?: unknown }).linkedRunIds) && + (value as { linkedRunIds: unknown[] }).linkedRunIds.length > 0 + ) +} + +function governingConstraintRun( + run: CabinetNodeType, + runModules: CabinetModuleNodeType[], + nodes: Readonly>>, +): { modules: CabinetModuleNodeType[]; run: CabinetNodeType } { + const value = cabinetMetadataRecord(run.metadata).cabinetCornerDerivedRun + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return { modules: runModules, run } + } + + const sourceRunId = (value as { sourceRunId?: unknown }).sourceRunId + if (typeof sourceRunId !== 'string') return { modules: runModules, run } + const sourceRun = nodes[sourceRunId as AnyNodeId] + if (sourceRun?.type !== 'cabinet') return { modules: runModules, run } + return { + modules: (sourceRun.children ?? []) + .map((id) => nodes[id as AnyNodeId]) + .filter((node): node is CabinetModuleNodeType => node?.type === 'cabinet-module'), + run: sourceRun, + } +} + +function nestedCornerRuns( + module: CabinetModuleNodeType, + nodes: Readonly>>, +): CabinetNodeType[] { + return Object.values(nodes).filter((node): node is CabinetNodeType => { + if (node?.type !== 'cabinet' || node.parentId !== module.id) return false + const value = cabinetMetadataRecord(node.metadata).cabinetCornerDerivedRun + if (!value || typeof value !== 'object' || Array.isArray(value)) return false + const role = (value as { role?: unknown }).role + return role === 'bridge' || role === 'wall-leg' + }) +} + +function positionKeepingWorldTransform( + child: CabinetNodeType, + parent: CabinetModuleNodeType, + nextParentPosition: CabinetModuleNodeType['position'], +): CabinetNodeType['position'] { + const dx = nextParentPosition[0] - parent.position[0] + const dy = nextParentPosition[1] - parent.position[1] + const dz = nextParentPosition[2] - parent.position[2] + const cos = Math.cos(parent.rotation) + const sin = Math.sin(parent.rotation) + return [ + child.position[0] - (dx * cos - dz * sin), + child.position[1] - dy, + child.position[2] - (dx * sin + dz * cos), + ] } export function reflowRunModules({ modules, parentRun, patch, - preserveExtent = false, scene, selected, }: { modules: CabinetModuleNodeType[] parentRun: CabinetNodeType patch: Partial - preserveExtent?: boolean scene: ReturnType selected: CabinetModuleNodeType -}) { +}): boolean { + const constraintOwner = governingConstraintRun( + parentRun, + modules, + scene.nodes as Readonly>>, + ) + const wallConstraints = runWallConstraints( + constraintOwner.run, + constraintOwner.modules, + scene.nodes as Record, + { widthGrowth: Math.max(0, (patch.width ?? selected.width) - selected.width) }, + ) + const sortedModules = [...constraintOwner.modules].sort((a, b) => a.position[0] - b.position[0]) + const leftCornerAnchored = Boolean(sortedModules[0] && hasLinkedCornerRun(sortedModules[0])) + const rightCornerAnchored = Boolean( + sortedModules.at(-1) && hasLinkedCornerRun(sortedModules.at(-1)!), + ) + const effectiveWallConstraints = { + left: + leftCornerAnchored && wallConstraints.left.constrained + ? { constrained: true, slack: 0 } + : wallConstraints.left, + right: + rightCornerAnchored && wallConstraints.right.constrained + ? { constrained: true, slack: 0 } + : wallConstraints.right, + } + const eligibleDonorIds = new Set( + modules.filter((module) => canDonatePresetWidth(module, parentRun)).map((module) => module.id), + ) + const preserveExtent = + effectiveWallConstraints.left.constrained && effectiveWallConstraints.right.constrained const reflowed = reflowCabinetRunModules(modules, selected.id, patch.width ?? selected.width, { - preserveExtent, + wallConstraints: effectiveWallConstraints, + eligibleDonorIds, + minimumWidthById: new Map( + modules + .filter(hasLinkedCornerRun) + .map((module) => [module.id, MIN_TRIMMED_CORNER_PRESET_WIDTH]), + ), + maximumWidth: MAX_CABINET_WIDTH, + maximumWidthById: new Map(modules.map((module) => [module.id, presetNominalWidth(module)])), restorableWidthById: new Map( modules.map((module) => [module.id, presetWidthDebt(module, selected.id)]), ), }) - if (reflowed.length === 0) return + if (reflowed.length === 0) return false const reflowById = new Map(reflowed.map((entry) => [entry.id, entry])) for (const module of [...modules].sort((a, b) => a.position[0] - b.position[0])) { @@ -151,6 +297,9 @@ export function reflowRunModules({ ? { ...patch, width: reflow.width } : { width: reflow.width } const widthDelta = reflow.width - module.width + if (isSelected && Math.abs(widthDelta) > 1e-4) { + nextPatch.metadata = metadataForSelectedWidth(module, reflow.width, nextPatch.metadata) + } if (!isSelected && preserveExtent && Math.abs(widthDelta) > 1e-4) { nextPatch.metadata = metadataWithPresetWidthDebt(module, selected.id, widthDelta) } @@ -175,7 +324,16 @@ export function reflowRunModules({ } nextPatch.position = nextPosition + const cornerRuns = nestedCornerRuns( + module, + scene.nodes as Readonly>>, + ) scene.updateNode(module.id as AnyNodeId, nextPatch) + for (const cornerRun of cornerRuns) { + scene.updateNode(cornerRun.id as AnyNodeId, { + position: positionKeepingWorldTransform(cornerRun, module, nextPosition), + }) + } const wallChild = wallChildOf( module, @@ -194,7 +352,14 @@ export function reflowRunModules({ } } + syncCornerRunsFromRunSources({ + baseLayout: 'width-only', + previousModules: modules, + run: (useScene.getState().nodes[parentRun.id] as CabinetNodeType | undefined) ?? parentRun, + sceneApi: createSceneApi(useScene), + }) bumpRunLayoutRevisionViaStore(scene, parentRun) + return true } export function CabinetRunPanel({ @@ -242,6 +407,7 @@ export function CabinetRunPanel({ if ('frontOverlay' in nextPatch) stylePatch.frontOverlay = nextNode.frontOverlay if ('handleStyle' in nextPatch) stylePatch.handleStyle = nextNode.handleStyle if ('handlePosition' in nextPatch) stylePatch.handlePosition = nextNode.handlePosition + if ('frontGap' in nextPatch) stylePatch.frontGap = nextNode.frontGap for (const module of modules) { const modulePatch: Partial = {} @@ -262,6 +428,7 @@ export function CabinetRunPanel({ if ('frontOverlay' in nextPatch) modulePatch.frontOverlay = nextNode.frontOverlay if ('handleStyle' in nextPatch) modulePatch.handleStyle = nextNode.handleStyle if ('handlePosition' in nextPatch) modulePatch.handlePosition = nextNode.handlePosition + if ('frontGap' in nextPatch) modulePatch.frontGap = nextNode.frontGap } scene.updateNode(module.id, modulePatch) @@ -276,6 +443,7 @@ export function CabinetRunPanel({ frontOverlay: nextNode.frontOverlay, handleStyle: nextNode.handleStyle, handlePosition: nextNode.handlePosition, + ...('frontGap' in nextPatch ? { frontGap: nextNode.frontGap } : {}), }) } } @@ -312,6 +480,20 @@ export function CabinetRunPanel({ [node, setSelection], ) + const dimensionProfile = cabinetDimensionProfileId(node) + const applyDimensionProfile = useCallback( + (profileId: CabinetDimensionProfileId) => { + const profile = cabinetDimensionProfileById(profileId) + updateRun({ + carcassHeight: profile.carcassHeight, + countertopThickness: profile.countertopThickness, + depth: profile.depth, + plinthHeight: profile.plinthHeight, + }) + }, + [updateRun], + ) + const deleteModule = useCallback( (module: CabinetModuleNodeType) => { useScene.getState().deleteNode(module.id as AnyNodeId) @@ -382,6 +564,25 @@ export function CabinetRunPanel({
+ {node.runTier === 'base' && ( +
+
+ Standard dimensions +
+ applyDimensionProfile(value as CabinetDimensionProfileId)} + options={CABINET_DIMENSION_PROFILES.map((profile) => ({ + label: profile.label, + value: profile.id, + }))} + value={dimensionProfile === 'us-base' ? 'us-base' : 'metric-base'} + /> +

+ Applies depth, carcass, plinth, and countertop thickness to this run. +

+
+ )}
+
+
+ Reveal gap +
+ + updateRun({ + frontGap: cabinetRevealGapById(value as CabinetRevealGapId).value, + }) + } + options={CABINET_REVEAL_GAPS.map((gap) => ({ + value: gap.id, + label: gap.label, + }))} + value={ + cabinetRevealGapId(node.frontGap) === 'custom' + ? '3' + : cabinetRevealGapId(node.frontGap) + } + /> +
diff --git a/packages/nodes/src/cabinet/stack-transitions.ts b/packages/nodes/src/cabinet/stack-transitions.ts index a81bcd264c..c9d39484a7 100644 --- a/packages/nodes/src/cabinet/stack-transitions.ts +++ b/packages/nodes/src/cabinet/stack-transitions.ts @@ -2,6 +2,7 @@ import type { CabinetModuleNode as CabinetModuleNodeType, CabinetNode as CabinetNodeType, } from '@pascal-app/core' +import { CABINET_METRIC_DEFAULTS } from '@pascal-app/core' import { resolveCabinetType } from './run-ops' import { type CabinetCompartment, @@ -12,6 +13,7 @@ import { cooktopCabinetStack, DISHWASHER_STANDARD_HEIGHT, DISHWASHER_STANDARD_WIDTH, + FRIDGE_COLUMN_HEIGHT, FRIDGE_COLUMN_WIDTH, FRIDGE_WIDE_WIDTH, fridgeCabinetStack, @@ -29,8 +31,8 @@ import { } from './stack' const BASE_MODULE_WIDTH = 0.5 -const BASE_CARCASS_HEIGHT = 0.72 -const WALL_CARCASS_HEIGHT = 0.72 +const BASE_CARCASS_HEIGHT = CABINET_METRIC_DEFAULTS.carcassHeight +const WALL_CARCASS_HEIGHT = CABINET_METRIC_DEFAULTS.carcassHeight const TALL_CARCASS_HEIGHT = TALL_CABINET_CARCASS_HEIGHT export function resolveCompartmentTransition({ @@ -52,6 +54,9 @@ export function resolveCompartmentTransition({ const enteringSink = next.type === 'sink' const leavingPullOutPantry = current?.type === 'pull-out-pantry' const enteringPullOutPantry = next.type === 'pull-out-pantry' + const leavingPullOutForStandardStorage = + leavingPullOutPantry && + (next.type === 'shelf' || next.type === 'drawer' || next.type === 'door') const leavingHood = current ? isHoodCompartmentType(current.type) : false const enteringHood = isHoodCompartmentType(next.type) const enteringSingleDishwasher = next.type === 'dishwasher' && stack.length === 1 @@ -78,9 +83,9 @@ export function resolveCompartmentTransition({ : next.type === 'fridge-double' ? FRIDGE_WIDE_WIDTH : FRIDGE_COLUMN_WIDTH, - depth: parentRun?.depth ?? 0.5, - carcassHeight: TALL_CARCASS_HEIGHT, - plinthHeight: 0.1, + depth: parentRun?.depth ?? CABINET_METRIC_DEFAULTS.depth, + carcassHeight: enteringFridge ? FRIDGE_COLUMN_HEIGHT : TALL_CARCASS_HEIGHT, + plinthHeight: CABINET_METRIC_DEFAULTS.plinthHeight, toeKickDepth: 0.075, countertopThickness: 0, countertopOverhang: parentRun?.countertopOverhang ?? 0.02, @@ -100,9 +105,9 @@ export function resolveCompartmentTransition({ : enteringCooktop ? COOKTOP_STANDARD_WIDTH : BASE_MODULE_WIDTH, - depth: parentRun?.depth ?? 0.5, + depth: parentRun?.depth ?? CABINET_METRIC_DEFAULTS.depth, carcassHeight: parentRun?.carcassHeight ?? BASE_CARCASS_HEIGHT, - plinthHeight: parentRun?.plinthHeight ?? 0.1, + plinthHeight: parentRun?.plinthHeight ?? CABINET_METRIC_DEFAULTS.plinthHeight, toeKickDepth: parentRun?.toeKickDepth ?? 0.075, countertopThickness: 0, countertopOverhang: parentRun?.countertopOverhang ?? 0.02, @@ -114,9 +119,9 @@ export function resolveCompartmentTransition({ ? { cabinetType: 'base', width: DISHWASHER_STANDARD_WIDTH, - depth: parentRun?.depth ?? 0.5, + depth: parentRun?.depth ?? CABINET_METRIC_DEFAULTS.depth, carcassHeight: DISHWASHER_STANDARD_HEIGHT, - plinthHeight: parentRun?.plinthHeight ?? 0.1, + plinthHeight: parentRun?.plinthHeight ?? CABINET_METRIC_DEFAULTS.plinthHeight, toeKickDepth: parentRun?.toeKickDepth ?? 0.075, countertopThickness: 0, countertopOverhang: parentRun?.countertopOverhang ?? 0.02, @@ -134,16 +139,18 @@ export function resolveCompartmentTransition({ ? sinkCabinetStack() : enteringPullOutPantry ? [{ ...next, height: TALL_CARCASS_HEIGHT }] - : enteringHood + : leavingPullOutForStandardStorage ? [next] - : replaceCabinetCompartmentStack( - node, - index, - next, - node.type === 'cabinet-module' && resolveCabinetType(node, parentRun) === 'base' - ? 'drawer' - : 'door', - ), + : enteringHood + ? [next] + : replaceCabinetCompartmentStack( + node, + index, + next, + node.type === 'cabinet-module' && resolveCabinetType(node, parentRun) === 'base' + ? 'drawer' + : 'door', + ), modulePatch: { ...tallApplianceModulePatch, ...standardModulePatch, diff --git a/packages/nodes/src/cabinet/stack.ts b/packages/nodes/src/cabinet/stack.ts index 978a78d26b..9517eee0a3 100644 --- a/packages/nodes/src/cabinet/stack.ts +++ b/packages/nodes/src/cabinet/stack.ts @@ -183,7 +183,7 @@ export function newCabinetCompartment( } export function fridgeCabinetStack(type: CabinetFridgeCompartmentType): CabinetCompartment[] { - return [newCabinetCompartment(type), { ...newCabinetCompartment('drawer'), drawerCount: 1 }] + return [newCabinetCompartment(type)] } export function cooktopCabinetStack(type: CabinetCooktopCompartmentType): CabinetCompartment[] { @@ -401,6 +401,31 @@ export function minCabinetCarcassHeightForStack( ) } +export function removeCabinetCompartmentStack( + node: Pick, + index: number, +): { stack: CabinetCompartment[]; carcassHeight?: number } { + const stack = stackForCabinet(node) + if (index < 0 || index >= stack.length || stack.length <= 1) return { stack } + + const next = stack.filter((_, compartmentIndex) => compartmentIndex !== index) + if (index !== stack.length - 1) return { stack: next } + + const hasFlexibleCompartment = next.some( + (compartment) => explicitCompartmentHeight(compartment) == null, + ) + if (hasFlexibleCompartment) return { stack: next } + + const occupiedHeight = next.reduce( + (sum, compartment) => sum + (explicitCompartmentHeight(compartment) ?? 0), + 0, + ) + return { + stack: next, + carcassHeight: Math.max(0.4, occupiedHeight), + } +} + export function replaceCabinetCompartmentStack( node: Pick, index: number, @@ -411,9 +436,18 @@ export function replaceCabinetCompartmentStack( const stack = stackForCabinet(node) if (index < 0 || index >= stack.length) return stack + const current = stack[index] + const replacement = + current && + typeof current.height === 'number' && + current.height > 0 && + explicitCompartmentHeight(next) == null + ? { ...next, height: current.height } + : next const replaced = stack.map((compartment, compartmentIndex) => - compartmentIndex === index ? next : compartment, + compartmentIndex === index ? replacement : compartment, ) + if (isFridgeCompartmentType(next.type)) return [replacement] if (lockedApplianceHeight(next) == null) return replaced if (isHoodCompartmentType(next.type)) return replaced if (next.type === 'dishwasher') return replaced @@ -432,9 +466,6 @@ export function replaceCabinetCompartmentStack( if (node.carcassHeight - lockedHeight < minHeight) return replaced const filler = newCabinetCompartment(fillerType) - if (isFridgeCompartmentType(next.type)) { - return [...replaced.slice(0, index + 1), filler, ...replaced.slice(index + 1)] - } return [...replaced.slice(0, index), filler, ...replaced.slice(index)] } @@ -474,13 +505,11 @@ export function resizeCabinetCompartmentStack( if (stack.length === 0 || index < 0 || index >= stack.length) return stack if (stack.length === 1) { const compartment = stack[0]! + const height = Math.max(minHeight, Math.min(targetHeight, node.carcassHeight)) return [ { ...compartment, - height: - lockedApplianceHeight(compartment) != null - ? Math.max(minHeight, Math.min(targetHeight, node.carcassHeight)) - : node.carcassHeight, + height, }, ] } diff --git a/packages/nodes/src/cabinet/validation.test.ts b/packages/nodes/src/cabinet/validation.test.ts new file mode 100644 index 0000000000..f544ce94cd --- /dev/null +++ b/packages/nodes/src/cabinet/validation.test.ts @@ -0,0 +1,117 @@ +import { expect, test } from 'bun:test' +import { CabinetModuleNode, CabinetNode } from '@pascal-app/core' +import { validateCabinetRun } from './validation' + +test('validateCabinetRun accepts a flush modular base run', () => { + const run = CabinetNode.parse({ id: 'cabinet_validation-run' }) + const left = CabinetModuleNode.parse({ + id: 'cabinet-module_validation-left', + parentId: run.id, + position: [-0.3, 0.1, 0], + width: 0.6, + }) + const right = CabinetModuleNode.parse({ + id: 'cabinet-module_validation-right', + parentId: run.id, + position: [0.3, 0.1, 0], + width: 0.6, + }) + + expect(validateCabinetRun(run, [left, right])).toMatchObject({ + valid: true, + errors: [], + warnings: [], + }) +}) + +test('validateCabinetRun reports overlapping modules as an error', () => { + const run = CabinetNode.parse({ id: 'cabinet_validation-overlap-run' }) + const left = CabinetModuleNode.parse({ + id: 'cabinet-module_validation-overlap-left', + parentId: run.id, + position: [-0.1, 0.1, 0], + width: 0.6, + }) + const right = CabinetModuleNode.parse({ + id: 'cabinet-module_validation-overlap-right', + parentId: run.id, + position: [0.1, 0.1, 0], + width: 0.6, + }) + + const report = validateCabinetRun(run, [left, right]) + + expect(report.valid).toBe(false) + expect(report.errors).toContainEqual( + expect.objectContaining({ + code: 'module-overlap', + nodeIds: [left.id, right.id], + }), + ) +}) + +test('validateCabinetRun warns about an unfilled gap without rejecting the run', () => { + const run = CabinetNode.parse({ id: 'cabinet_validation-gap-run' }) + const left = CabinetModuleNode.parse({ + id: 'cabinet-module_validation-gap-left', + parentId: run.id, + position: [-0.35, 0.1, 0], + width: 0.5, + }) + const right = CabinetModuleNode.parse({ + id: 'cabinet-module_validation-gap-right', + parentId: run.id, + position: [0.35, 0.1, 0], + width: 0.5, + }) + + const report = validateCabinetRun(run, [left, right]) + + expect(report.valid).toBe(true) + expect(report.warnings).toContainEqual( + expect.objectContaining({ + code: 'module-gap', + nodeIds: [left.id, right.id], + }), + ) +}) + +test('validateCabinetRun rejects a stack that cannot fit its carcass', () => { + const run = CabinetNode.parse({ id: 'cabinet_validation-stack-run' }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_validation-stack', + parentId: run.id, + carcassHeight: 0.4, + stack: [{ id: 'compartment-oven', type: 'oven', height: 0.595 }], + }) + + const report = validateCabinetRun(run, [module]) + + expect(report.valid).toBe(false) + expect(report.errors).toContainEqual( + expect.objectContaining({ + code: 'stack-too-short', + nodeIds: [module.id], + }), + ) +}) + +test('validateCabinetRun warns when a top cabinet is too short to be practical storage', () => { + const run = CabinetNode.parse({ id: 'cabinet_validation-top-run' }) + const module = CabinetModuleNode.parse({ + id: 'cabinet-module_validation-top', + parentId: run.id, + topFinish: 'top-cabinet', + topFinishHeight: 0.1, + }) + + const report = validateCabinetRun(run, [module]) + + expect(report.valid).toBe(true) + expect(report.warnings).toContainEqual( + expect.objectContaining({ + code: 'top-cabinet-too-short', + nodeIds: [module.id], + }), + ) +}) diff --git a/packages/nodes/src/cabinet/validation.ts b/packages/nodes/src/cabinet/validation.ts new file mode 100644 index 0000000000..af1be54529 --- /dev/null +++ b/packages/nodes/src/cabinet/validation.ts @@ -0,0 +1,134 @@ +import type { CabinetModuleNode, CabinetNode } from '@pascal-app/core' +import { moduleMaxX, moduleMinX, sortRunModules } from './run-layout' +import { minCabinetCarcassHeightForStack } from './stack' + +export const CABINET_PLANNING_TOLERANCE = 1e-4 +export const MIN_PRACTICAL_TOP_CABINET_HEIGHT = 0.15 + +export type CabinetPlanningIssueCode = + | 'module-overlap' + | 'module-gap' + | 'tier-mismatch' + | 'stack-too-short' + | 'top-cabinet-too-short' + +export type CabinetPlanningIssue = { + code: CabinetPlanningIssueCode + severity: 'error' | 'warning' + message: string + nodeIds: string[] +} + +export type CabinetPlanningReport = { + valid: boolean + errors: CabinetPlanningIssue[] + warnings: CabinetPlanningIssue[] +} + +export type CabinetPlanningOptions = { + tolerance?: number + minimumTopCabinetHeight?: number +} + +function issue( + code: CabinetPlanningIssueCode, + severity: CabinetPlanningIssue['severity'], + message: string, + nodeIds: string[], +): CabinetPlanningIssue { + return { code, severity, message, nodeIds } +} + +function isFiller(module: CabinetModuleNode): boolean { + return module.moduleKind === 'corner-filler' +} + +/** + * Validate the structural rules shared by cabinet-run editing, previews, and + * export. This is intentionally scene-independent: callers resolve a run's + * module children and pass the same values used to build the run geometry. + */ +export function validateCabinetRun( + run: CabinetNode, + modules: readonly CabinetModuleNode[], + options: CabinetPlanningOptions = {}, +): CabinetPlanningReport { + const tolerance = options.tolerance ?? CABINET_PLANNING_TOLERANCE + const minimumTopCabinetHeight = + options.minimumTopCabinetHeight ?? MIN_PRACTICAL_TOP_CABINET_HEIGHT + const errors: CabinetPlanningIssue[] = [] + const warnings: CabinetPlanningIssue[] = [] + const sorted = sortRunModules(modules) + + for (let index = 0; index < sorted.length; index += 1) { + const module = sorted[index]! + const next = sorted[index + 1] + + const minimumStackHeight = minCabinetCarcassHeightForStack(module) + if (module.carcassHeight + tolerance < minimumStackHeight) { + errors.push( + issue( + 'stack-too-short', + 'error', + `${module.name || 'Cabinet module'} is shorter than its fixed compartment stack.`, + [module.id], + ), + ) + } + + if (run.runTier === 'tall' && module.cabinetType !== 'tall') { + errors.push( + issue( + 'tier-mismatch', + 'error', + `${module.name || 'Cabinet module'} must be a tall module in a tall run.`, + [run.id, module.id], + ), + ) + } else if (run.runTier === 'wall' && module.cabinetType === 'tall') { + errors.push( + issue( + 'tier-mismatch', + 'error', + `${module.name || 'Cabinet module'} cannot be a tall module in a wall run.`, + [run.id, module.id], + ), + ) + } + + if (module.topFinish === 'top-cabinet' && module.topFinishHeight < minimumTopCabinetHeight) { + warnings.push( + issue( + 'top-cabinet-too-short', + 'warning', + `${module.name || 'Top cabinet'} is too short to be practical storage; use trim instead.`, + [module.id], + ), + ) + } + + if (!next) continue + const gap = moduleMinX(next) - moduleMaxX(module) + if (gap < -tolerance) { + errors.push( + issue( + 'module-overlap', + 'error', + `${module.name || 'Cabinet module'} overlaps ${next.name || 'the next cabinet module'}.`, + [module.id, next.id], + ), + ) + } else if (gap > tolerance && !isFiller(module) && !isFiller(next)) { + warnings.push( + issue( + 'module-gap', + 'warning', + `There is an unfilled ${(gap * 1000).toFixed(0)} mm gap between cabinet modules.`, + [module.id, next.id], + ), + ) + } + } + + return { valid: errors.length === 0, errors, warnings } +} diff --git a/packages/nodes/src/cabinet/widths.ts b/packages/nodes/src/cabinet/widths.ts new file mode 100644 index 0000000000..f509a981bf --- /dev/null +++ b/packages/nodes/src/cabinet/widths.ts @@ -0,0 +1,28 @@ +export type CabinetStandardWidthId = '300' | '400' | '600' | '800' + +export type CabinetStandardWidth = { + id: CabinetStandardWidthId + label: string + value: number +} + +export const CABINET_STANDARD_WIDTHS: CabinetStandardWidth[] = [ + { id: '300', label: '300 mm', value: 0.3 }, + { id: '400', label: '400 mm', value: 0.4 }, + { id: '600', label: '600 mm', value: 0.6 }, + { id: '800', label: '800 mm', value: 0.8 }, +] + +const WIDTH_MATCH_TOLERANCE = 1e-4 + +export function cabinetStandardWidthId(width: number): CabinetStandardWidthId | 'custom' { + return ( + CABINET_STANDARD_WIDTHS.find( + (candidate) => Math.abs(candidate.value - width) <= WIDTH_MATCH_TOLERANCE, + )?.id ?? 'custom' + ) +} + +export function cabinetStandardWidthById(id: CabinetStandardWidthId): CabinetStandardWidth { + return CABINET_STANDARD_WIDTHS.find((candidate) => candidate.id === id)! +} diff --git a/packages/nodes/src/index.ts b/packages/nodes/src/index.ts index 44ab8f2406..373b198034 100644 --- a/packages/nodes/src/index.ts +++ b/packages/nodes/src/index.ts @@ -137,12 +137,19 @@ export { boxVentDefinition } from './box-vent' export { buildingDefinition } from './building' export { bakeCabinetAnimationClip, + CABINET_PLANNING_TOLERANCE, type CabinetPlacementType, + type CabinetPlanningIssue, + type CabinetPlanningIssueCode, + type CabinetPlanningOptions, + type CabinetPlanningReport, cabinetDefinition, cabinetModuleDefinition, + MIN_PRACTICAL_TOP_CABINET_HEIGHT, poseCabinetMovingParts, useCabinetPlacementStatus, useCabinetPlacementType, + validateCabinetRun, } from './cabinet' export { ceilingDefinition } from './ceiling' export { chimneyDefinition } from './chimney'