Files
editor/packages/nodes/src/cabinet/geometry.ts
T
6cc10c929e editor: improve cabinet resizing and wall alignment (#503)
* Add roof surface placement support for items

Items (e.g. solar panels) can now be placed on sloped roof surfaces.
The placement system computes euler rotation from the roof surface
normal so items sit flush on the slope instead of going inside.

- Add roofStrategy to placement-strategies with enter/move/click/leave
- Wire roof:enter/move/click/leave events in the placement coordinator
- Add calculateRoofRotation in placement-math using surface normals
- Support full 3D cursor rotation for sloped surfaces
- Items on roofs are parented to the level with world-space rotation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fixed conflict

* fix wall treatment miter geometry

* fix cabinet group resizing and corner alignment

* fix cabinet corner depth resizing

* fix(cabinet): stabilize modular preset changes

* fix(cabinet): stabilize corner resizing and wall alignment

* fix(nodes): stabilize wall cabinet depth resizing

* fix(editor): hide cabinet arrows for module selection

* feat(cabinet): add individual width resize handles

* feat(cabinet): improve wall cabinet editing

* fix(cabinet): harden wall cabinet resizing

* fix(cabinet): correct wall drag and depth handles

* fix(cabinet): refine individual depth resizing

* fix(cabinet): preserve context-aware corner depth behavior

* fix(editor): respect snapping modes for resize handles

* fix(editor): harden cabinet resize interactions

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Aymeric Rabot <aymeric.rabot@gmail.com>
2026-07-19 17:33:57 +02:00

519 lines
15 KiB
TypeScript

import type { CabinetNode, GeometryContext } from '@pascal-app/core'
import type { ColorPreset, RenderShading } from '@pascal-app/viewer'
import { Group } from 'three'
import { addCooktopCompartment } from './geometry/cooktop'
import { addDishwasherCompartment } from './geometry/dishwasher'
import { addFridgeCompartment } from './geometry/fridge'
import {
addDoorFronts,
addDrawerFronts,
addShelfBoards,
addSinkFalseFront,
} from './geometry/fronts'
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 { addSinkCompartment, cutSinkIntoCountertop, sinkBowls } from './geometry/sink'
import {
type CabinetHoodCompartmentType,
compartmentDoorType,
compartmentDrawerCount,
compartmentShelfCount,
compartmentSinkLayout,
isHoodCompartmentType,
normalizeCabinetStack,
stackForCabinet,
} from './stack'
const CORNER_FILLER_TOP_INSET = 0.001
const CORNER_FILLER_SIDE_INSET = 0.001
const WALL_CORNER_FILLER_FRONT_HEIGHT_INSET = 0.001
const SINK_FALSE_FRONT_HEIGHT = 0.22
const MIN_RENDERABLE_BRIDGE_FILLER_WIDTH = 1e-4
export function buildCabinetGeometry(
node: CabinetGeometryNode,
ctx?: GeometryContext,
shading: RenderShading = 'rendered',
textures = true,
colorPreset: ColorPreset = 'clay',
sceneTheme?: string,
): Group {
if (node.type === 'cabinet') {
const run = buildCabinetRunGeometry(node, ctx, shading, textures, colorPreset, sceneTheme)
if (run) return run
return new Group()
}
if (node.name === 'Wall Bridge Filler' && node.width <= MIN_RENDERABLE_BRIDGE_FILLER_WIDTH) {
return new Group()
}
const group = new Group()
const materials = getCabinetSlotMaterials(node, ctx, shading, textures, colorPreset, sceneTheme)
const hoodRows = normalizeCabinetStack(node)
if (hoodRows.length > 0 && hoodRows.every((row) => isHoodCompartmentType(row.compartment.type))) {
const hoodPlinth = node.showPlinth ? node.plinthHeight : 0
hoodRows.forEach((row) => {
addRangeHoodCompartment(
group,
node,
materials,
row.compartment.type as CabinetHoodCompartmentType,
hoodPlinth + row.y0,
row.height,
ctx,
row.index,
)
})
return group
}
const width = node.width
const depth = node.depth
const board = node.boardThickness
const plinth = node.showPlinth ? node.plinthHeight : 0
const toeKickDepth = node.showPlinth ? Math.min(node.toeKickDepth, depth - board * 2) : 0
const carcassHeight = node.carcassHeight
const frontThickness = node.frontThickness
const frontGap = node.frontGap
const countertopThickness = node.withCountertop ? node.countertopThickness : 0
const countertopOverhang = node.withCountertop ? node.countertopOverhang : 0
const bodyCenterY = plinth + carcassHeight / 2
const topY = plinth + carcassHeight
const bottomLift = node.withBottomPanel ? board : 0
const backThickness = Math.min(0.006, board / 2)
const backInset = Math.min(0.012, depth * 0.08)
const frontRecess = 0.0015
const inset = node.frontOverlay === 'inset'
const insetInteriorClearance = inset ? Math.max(0.012, frontThickness + frontRecess + 0.006) : 0
// Overlay fronts sit proud on the carcass face; inset fronts sit flush within the opening.
const frontZ = inset
? depth / 2 - frontThickness / 2 - frontRecess
: depth / 2 + frontThickness / 2 - frontRecess
const openLeft = node.openSide === 'left'
const openRight = node.openSide === 'right'
const innerLeft = -width / 2 + (openLeft ? 0 : board)
const innerRight = width / 2 - (openRight ? 0 : board)
const innerWidth = Math.max(0.01, innerRight - innerLeft)
const innerCenterX = (innerLeft + innerRight) / 2
const openingWidth = innerWidth
const openingDepth = Math.max(0.01, depth - backInset - 0.02 - insetInteriorClearance)
const drawerBoxBackZ = -depth / 2 + backInset + 0.02
const drawerBoxFrontZ = frontZ - frontThickness / 2 - 0.001 - insetInteriorClearance
const drawerBoxDepth = Math.max(0.05, drawerBoxFrontZ - drawerBoxBackZ)
const parentRun = ctx?.parent?.type === 'cabinet' ? (ctx.parent as CabinetNode) : null
const isWallCornerFiller = node.moduleKind === 'corner-filler' && parentRun?.runTier === 'wall'
if (node.moduleKind === 'corner-filler') {
const filler = new Group()
const shelfDepth = Math.max(0.01, depth - backInset - 0.02)
const shelfCount = Math.max(
1,
stackForCabinet(node)
.filter((compartment) => compartment.type === 'door')
.reduce((best, compartment) => Math.max(best, compartmentShelfCount(compartment)), 0),
)
if (!openLeft) {
const leftSideInset = isWallCornerFiller && openRight ? CORNER_FILLER_SIDE_INSET / 2 : 0
addBox(
filler,
[board, carcassHeight, depth],
[-width / 2 + board / 2 + leftSideInset, bodyCenterY, 0],
materials.carcass,
'cabinet-corner-filler-side-left',
'carcass',
)
}
if (!openRight) {
const rightSideInset = isWallCornerFiller && openLeft ? CORNER_FILLER_SIDE_INSET / 2 : 0
addBox(
filler,
[board, carcassHeight, depth],
[width / 2 - board / 2 - rightSideInset, bodyCenterY, 0],
materials.carcass,
'cabinet-corner-filler-side-right',
'carcass',
)
}
if (node.withBottomPanel) {
addBox(
filler,
[innerWidth, board, depth - backInset],
[innerCenterX, plinth + board / 2, backInset / 2],
materials.carcass,
'cabinet-corner-filler-bottom',
'carcass',
)
}
// Keep open-side corner-filler tops just inside the shared boundary so
// neighboring wall-top fillers/cabinets don't land coplanar and shimmer.
const topLeft = innerLeft + (openLeft ? CORNER_FILLER_TOP_INSET : 0)
const topRight = innerRight - (openRight ? CORNER_FILLER_TOP_INSET : 0)
addBox(
filler,
[Math.max(0.01, topRight - topLeft), board, depth],
[(topLeft + topRight) / 2, topY - board / 2, 0],
materials.carcass,
'cabinet-corner-filler-top',
'carcass',
)
addBox(
filler,
[innerWidth, Math.max(0.001, carcassHeight - board), backThickness],
[innerCenterX, plinth + carcassHeight / 2, -depth / 2 + backInset + backThickness / 2],
materials.carcass,
'cabinet-corner-filler-back',
'carcass',
)
const frontExtension = board / 2 + frontGap
const wallFrontSharedInset = isWallCornerFiller ? frontThickness + frontGap : 0
const frontLeft =
-width / 2 -
(openLeft ? frontExtension : 0) +
(isWallCornerFiller && openRight ? wallFrontSharedInset : 0)
const frontRight =
width / 2 +
(openRight ? frontExtension : 0) -
(isWallCornerFiller && openLeft ? wallFrontSharedInset : 0)
const frontWidth = Math.max(0.01, frontRight - frontLeft)
const frontHeight = isWallCornerFiller
? Math.max(0.01, carcassHeight - WALL_CORNER_FILLER_FRONT_HEIGHT_INSET * 2)
: carcassHeight
addBox(
filler,
[frontWidth, frontHeight, frontThickness],
[(frontLeft + frontRight) / 2, bodyCenterY, frontZ],
materials.front,
'cabinet-corner-filler-front',
'front',
)
if (node.cornerShelf) {
addShelfBoards(
filler,
materials,
innerWidth,
shelfDepth,
board,
plinth + board,
Math.max(0.01, carcassHeight - board * 2),
shelfCount,
innerCenterX,
)
}
return filler
}
const rows = normalizeCabinetStack(node)
const sinkRow = rows.find((row) => row.compartment.type === 'sink')
const sinkBowlSpecs = sinkRow
? sinkBowls(compartmentSinkLayout(sinkRow.compartment), innerWidth, depth)
: null
if (!openLeft) {
addBox(
group,
[board, carcassHeight, depth],
[-width / 2 + board / 2, bodyCenterY, 0],
materials.carcass,
'cabinet-side-left',
'carcass',
)
}
if (!openRight) {
addBox(
group,
[board, carcassHeight, depth],
[width / 2 - board / 2, bodyCenterY, 0],
materials.carcass,
'cabinet-side-right',
'carcass',
)
}
if (node.withBottomPanel) {
addBox(
group,
[innerWidth, board, depth - backInset],
[innerCenterX, plinth + board / 2, backInset / 2],
materials.carcass,
'cabinet-bottom',
'carcass',
)
}
// Sink bases skip the top panel — the basin hangs through that plane.
if (!sinkRow) {
addBox(
group,
[innerWidth, board, depth],
[innerCenterX, topY - board / 2, 0],
materials.carcass,
'cabinet-top',
'carcass',
)
}
if (node.showPlinth && plinth > 0) {
addBox(
group,
[width - board * 2, plinth, Math.max(board, depth - toeKickDepth)],
[0, plinth / 2, -(toeKickDepth / 2)],
materials.plinth,
'cabinet-plinth',
'plinth',
)
}
if (node.withCountertop && countertopThickness > 0) {
const countertop = addBox(
group,
[width + countertopOverhang * 2, countertopThickness, depth + countertopOverhang],
[0, topY + countertopThickness / 2, 0.01],
materials.countertop,
'cabinet-countertop',
'countertop',
)
if (sinkBowlSpecs) {
group.remove(countertop)
const cut = cutSinkIntoCountertop(countertop, sinkBowlSpecs, 0, 0, countertopThickness)
countertop.geometry.dispose()
group.add(cut)
}
}
if (sinkBowlSpecs && sinkRow) {
// Modules inside a run don't own a countertop (the run draws and cuts
// it), so the faucet rises above the run's slab thickness instead.
const slabThickness =
countertopThickness > 0
? countertopThickness
: parentRun?.withCountertop
? parentRun.countertopThickness
: 0.02
addSinkCompartment(
group,
sinkBowlSpecs,
0,
0,
topY,
slabThickness,
sinkRow.index,
materials.appliance,
)
const falseFrontHeight = Math.min(
Math.max(0.01, carcassHeight - frontGap * 2),
SINK_FALSE_FRONT_HEIGHT,
)
addSinkFalseFront(
group,
node,
materials,
inset ? openingWidth : Math.max(0.01, width - frontGap),
falseFrontHeight,
topY - falseFrontHeight / 2,
frontZ,
sinkRow.index,
)
}
rows.forEach((row, index) => {
// Sink rows are zero-height; the basin/faucet render against the
// countertop plane above, so the row contributes no carcass geometry.
if (row.compartment.type === 'sink') return
if (row.compartment.type === 'cooktop-gas' || row.compartment.type === 'cooktop-induction') {
const countertopClearance = 0.001
const effectiveCountertopThickness = Math.max(countertopThickness, 0.02)
addCooktopCompartment(
group,
node,
row.compartment,
row.compartment.type,
topY + effectiveCountertopThickness + countertopClearance,
index,
)
return
}
const isFirst = index === 0
const isLast = index === rows.length - 1
const bottomOccupancy = isFirst ? bottomLift : board / 2
const topOccupancy = isLast ? board : board / 2
const subCellBottomY = plinth + row.y0
const openingBottomY = subCellBottomY + bottomOccupancy
const openingHeight = Math.max(0.01, row.height - bottomOccupancy - topOccupancy)
const openingCenterY = openingBottomY + openingHeight / 2
addBox(
group,
[openingWidth, Math.max(0.001, row.height - board), backThickness],
[innerCenterX, subCellBottomY + row.height / 2, -depth / 2 + backInset + backThickness / 2],
materials.carcass,
`cabinet-back-${index}`,
'carcass',
)
// No deck below a sink row — the basin hangs through that plane.
if (index < rows.length - 1 && rows[index + 1]!.compartment.type !== 'sink') {
const deckY = plinth + row.y1
addBox(
group,
[openingWidth, board, openingDepth],
[innerCenterX, deckY, board / 2],
materials.carcass,
`cabinet-deck-${index}`,
'carcass',
)
}
const faceWidth = inset ? openingWidth : Math.max(0.01, width - frontGap)
const faceHeight = inset ? openingHeight : Math.max(0.01, row.height)
const faceCenterY = inset ? openingCenterY : subCellBottomY + row.height / 2
if (row.compartment.type === 'door') {
addDoorFronts(
group,
node,
materials,
faceWidth,
faceHeight,
0,
faceCenterY,
frontZ,
compartmentDoorType(row.compartment, node.width),
)
if ((row.compartment.shelfCount ?? 0) > 0) {
addShelfBoards(
group,
materials,
openingWidth,
openingDepth,
board,
openingBottomY,
openingHeight,
row.compartment.shelfCount ?? 0,
innerCenterX,
)
}
return
}
if (row.compartment.type === 'shelf') {
addShelfBoards(
group,
materials,
openingWidth,
openingDepth,
board,
openingBottomY,
openingHeight,
compartmentShelfCount(row.compartment),
innerCenterX,
)
return
}
if (row.compartment.type === 'drawer') {
addDrawerFronts(
group,
node,
materials,
faceWidth,
faceHeight,
faceCenterY,
inset ? openingBottomY : subCellBottomY,
openingWidth,
frontZ,
compartmentDrawerCount(row.compartment),
drawerBoxBackZ,
drawerBoxDepth,
)
return
}
if (row.compartment.type === 'dishwasher') {
addDishwasherCompartment(
group,
node,
materials,
faceWidth,
faceHeight,
faceCenterY,
openingWidth,
openingDepth,
frontZ,
index,
)
return
}
if (row.compartment.type === 'pull-out-pantry') {
addPullOutPantryCompartment(
group,
node,
materials,
faceWidth,
faceHeight,
faceCenterY,
openingWidth,
openingDepth,
frontZ,
row.compartment,
index,
)
return
}
if (row.compartment.type === 'oven' || row.compartment.type === 'microwave') {
addApplianceCompartment(
group,
node,
materials,
row.compartment.type,
faceWidth,
faceHeight,
faceCenterY,
openingWidth,
openingDepth,
frontZ,
index,
)
return
}
if (
row.compartment.type === 'fridge-single' ||
row.compartment.type === 'fridge-double' ||
row.compartment.type === 'fridge-top-freezer' ||
row.compartment.type === 'fridge-bottom-freezer'
) {
addFridgeCompartment(
group,
node,
materials,
row.compartment.type,
faceWidth,
faceHeight,
faceCenterY,
openingWidth,
openingDepth,
frontZ,
index,
)
return
}
if (isHoodCompartmentType(row.compartment.type)) {
addRangeHoodCompartment(
group,
node,
materials,
row.compartment.type,
plinth + row.y0,
row.height,
ctx,
index,
)
}
})
return group
}