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>
This commit is contained in:
Sudhir Yadav
2026-07-19 17:33:57 +02:00
committed by GitHub
co-authored by Claude Opus 4.6 Aymeric Rabot
parent c0a5db935e
commit 6cc10c929e
64 changed files with 6481 additions and 402 deletions
@@ -0,0 +1,261 @@
import { describe, expect, test } from 'bun:test'
import type { AnyNode, AnyNodeId, SceneApi } from '@pascal-app/core'
import { addCabinetModuleSide, addCornerRun, syncCornerRunsFromSourceModule } 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<
AnyNodeId,
AnyNode
>
return {
get: (id) => nodes[id],
nodes: () => nodes,
update: (id, patch) => {
const current = nodes[id]
if (current) nodes[id] = { ...current, ...patch } as AnyNode
},
upsert: (node, parentId) => {
nodes[node.id as AnyNodeId] = node
const parent = parentId ? nodes[parentId] : undefined
if (parent && Array.isArray((parent as { children?: unknown }).children)) {
nodes[parentId!] = {
...parent,
children: [...new Set([...(parent.children ?? []), node.id])],
} as AnyNode
}
return node.id as AnyNodeId
},
delete: () => {},
restore: () => {},
restoreAll: () => {},
markDirty: () => {},
pauseHistory: () => {},
resumeHistory: () => {},
getSubtree: () => null,
cloneNodesInto: () => null,
}
}
describe('context-aware cabinet depth', () => {
test('side additions inherit the connected edge cabinet depth', () => {
const run = CabinetNode.parse({
id: 'cabinet_context-depth-side-run',
depth: 0.5,
children: ['cabinet-module_context-depth-left', 'cabinet-module_context-depth-right'],
})
const left = CabinetModuleNode.parse({
id: 'cabinet-module_context-depth-left',
parentId: run.id,
position: [-0.25, 0.1, 0.2],
depth: 0.4,
})
const right = CabinetModuleNode.parse({
id: 'cabinet-module_context-depth-right',
parentId: run.id,
position: [0.25, 0.1, 0.35],
depth: 0.7,
})
const sceneApi = sceneApiFixture([run as AnyNode, left as AnyNode, right as AnyNode])
const addedLeftId = addCabinetModuleSide({
anchorModule: null,
run,
sceneApi,
side: 'left',
})
const addedLeft = sceneApi.get(addedLeftId!)
expect(addedLeft?.type).toBe('cabinet-module')
if (addedLeft?.type !== 'cabinet-module') return
expect(addedLeft.depth).toBeCloseTo(left.depth)
expect(addedLeft.position[2]).toBeCloseTo(left.position[2])
const addedRightId = addCabinetModuleSide({
anchorModule: null,
run: sceneApi.get(run.id as AnyNodeId) as typeof run,
sceneApi,
side: 'right',
})
const addedRight = sceneApi.get(addedRightId!)
expect(addedRight?.type).toBe('cabinet-module')
if (addedRight?.type !== 'cabinet-module') return
expect(addedRight.depth).toBeCloseTo(right.depth)
expect(addedRight.position[2]).toBeCloseTo(right.position[2])
})
test('L additions use source depth for corner width and default depth for the new leg', () => {
const run = CabinetNode.parse({
id: 'cabinet_context-depth-corner-run',
depth: 0.5,
children: ['cabinet-module_context-depth-corner-source'],
})
const source = CabinetModuleNode.parse({
id: 'cabinet-module_context-depth-corner-source',
parentId: run.id,
position: [0, 0.1, 0.325],
width: 0.9,
depth: 0.65,
})
const sceneApi = sceneApiFixture([run as AnyNode, source as AnyNode])
expect(addCornerRun({ module: source, run, sceneApi, side: 'right' })).toBeTruthy()
const baseLeg = Object.values(sceneApi.nodes()).find(
(node) => node.type === 'cabinet' && node.name === 'Corner Base Run',
)
expect(baseLeg?.type).toBe('cabinet')
if (baseLeg?.type !== 'cabinet') return
expect(baseLeg.depth).toBeCloseTo(0.5)
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.find((module) => module.name === 'Corner Filler')?.width).toBeCloseTo(
source.depth,
)
sceneApi.update(source.id as AnyNodeId, { depth: 0.75 })
syncCornerRunsFromSourceModule({
module: sceneApi.get(source.id as AnyNodeId) as typeof source,
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) as typeof baseLeg).children
.map((id) => sceneApi.get(id as AnyNodeId))
.find((node) => node?.type === 'cabinet-module' && node.name === 'Corner Filler')?.width,
).toBeCloseTo(0.75)
})
test('L additions use source wall depth for corner width and default depth for the wall leg', () => {
const run = CabinetNode.parse({
id: 'cabinet_context-depth-wall-corner-run',
depth: 0.5,
children: ['cabinet-module_context-depth-wall-corner-source'],
})
const source = CabinetModuleNode.parse({
id: 'cabinet-module_context-depth-wall-corner-source',
parentId: run.id,
children: ['cabinet-module_context-depth-wall-corner-top'],
position: [0, 0.1, 0.21],
width: 0.9,
depth: 0.42,
})
const sourceWall = CabinetModuleNode.parse({
id: 'cabinet-module_context-depth-wall-corner-top',
parentId: source.id,
name: 'Wall Cabinet',
position: [0, 1.4, -0.045],
width: 0.9,
depth: 0.33,
carcassHeight: 0.72,
})
const sceneApi = sceneApiFixture([run as AnyNode, source as AnyNode, sourceWall as AnyNode])
expect(addCornerRun({ module: source, run, sceneApi, side: 'right' })).toBeTruthy()
const bridge = Object.values(sceneApi.nodes()).find(
(node) => node.type === 'cabinet-module' && node.name === 'Wall Bridge Filler',
)
expect(bridge?.type).toBe('cabinet-module')
if (bridge?.type !== 'cabinet-module') return
expect(bridge.width).toBeCloseTo(0.5 - 0.32)
expect(bridge.depth).toBeCloseTo(sourceWall.depth)
const cornerWallFiller = Object.values(sceneApi.nodes()).find(
(node) => node.type === 'cabinet-module' && node.name === 'Corner Wall Filler',
)
expect(cornerWallFiller?.type).toBe('cabinet-module')
if (cornerWallFiller?.type !== 'cabinet-module') return
expect(cornerWallFiller.width).toBeCloseTo(sourceWall.depth)
expect(cornerWallFiller.depth).toBeCloseTo(0.32)
const connectedBase = Object.values(sceneApi.nodes()).find(
(node) =>
node.type === 'cabinet-module' && node.name === 'Base Cabinet' && node.id !== source.id,
)
expect(connectedBase?.type).toBe('cabinet-module')
if (connectedBase?.type !== 'cabinet-module') return
const connectedWall = (connectedBase.children ?? [])
.map((id) => sceneApi.get(id as AnyNodeId))
.find((node) => node?.type === 'cabinet-module' && node.name === 'Wall Cabinet')
expect(connectedWall?.type).toBe('cabinet-module')
if (connectedWall?.type !== 'cabinet-module') return
expect(connectedWall.depth).toBeCloseTo(0.32)
expect(connectedWall.position[0]).toBeCloseTo(sourceWall.depth - source.depth)
sceneApi.update(sourceWall.id as AnyNodeId, { depth: 0.46 })
syncCornerRunsFromSourceModule({
module: sceneApi.get(source.id as AnyNodeId) as typeof source,
run: sceneApi.get(run.id as AnyNodeId) as typeof run,
sceneApi,
})
expect(sceneApi.get<CabinetModuleNode>(bridge.id as AnyNodeId)?.depth).toBeCloseTo(0.46)
expect(sceneApi.get<CabinetModuleNode>(cornerWallFiller.id as AnyNodeId)?.width).toBeCloseTo(
0.46,
)
expect(sceneApi.get<CabinetModuleNode>(connectedWall.id as AnyNodeId)?.position[0]).toBeCloseTo(
0.46 - source.depth,
)
})
test('L additions clear a wall cabinet that is deeper than its base cabinet', () => {
const run = CabinetNode.parse({
id: 'cabinet_context-depth-shallow-corner-run',
children: ['cabinet-module_context-depth-shallow-corner-source'],
})
const source = CabinetModuleNode.parse({
id: 'cabinet-module_context-depth-shallow-corner-source',
parentId: run.id,
children: ['cabinet-module_context-depth-shallow-corner-wall'],
position: [0, 0.1, 0.15],
width: 0.9,
depth: 0.3,
})
const sourceWall = CabinetModuleNode.parse({
id: 'cabinet-module_context-depth-shallow-corner-wall',
parentId: source.id,
name: 'Wall Cabinet',
position: [0, 1.4, 0.14],
width: 0.9,
depth: 0.58,
carcassHeight: 0.72,
})
const sceneApi = sceneApiFixture([run as AnyNode, source as AnyNode, sourceWall as AnyNode])
expect(addCornerRun({ module: source, run, sceneApi, side: 'left' })).toBeTruthy()
const bridge = Object.values(sceneApi.nodes()).find(
(node) => node.type === 'cabinet-module' && node.name === 'Wall Bridge Filler',
)
expect(bridge?.type).toBe('cabinet-module')
if (bridge?.type !== 'cabinet-module') return
expect(bridge.width).toBeCloseTo(0.5 - 0.32)
expect(bridge.depth).toBeCloseTo(sourceWall.depth)
const cornerWallFiller = Object.values(sceneApi.nodes()).find(
(node) => node.type === 'cabinet-module' && node.name === 'Corner Wall Filler',
)
expect(cornerWallFiller?.type).toBe('cabinet-module')
if (cornerWallFiller?.type !== 'cabinet-module') return
expect(cornerWallFiller.width).toBeCloseTo(sourceWall.depth)
expect(cornerWallFiller.depth).toBeCloseTo(0.32)
const connectedBase = Object.values(sceneApi.nodes()).find(
(node) =>
node.type === 'cabinet-module' && node.name === 'Base Cabinet' && node.id !== source.id,
)
expect(connectedBase?.type).toBe('cabinet-module')
if (connectedBase?.type !== 'cabinet-module') return
const connectedWall = (connectedBase.children ?? [])
.map((id) => sceneApi.get(id as AnyNodeId))
.find((node) => node?.type === 'cabinet-module' && node.name === 'Wall Cabinet')
expect(connectedWall?.type).toBe('cabinet-module')
if (connectedWall?.type !== 'cabinet-module') return
expect(connectedWall.depth).toBeCloseTo(0.32)
expect(connectedWall.position[0]).toBeCloseTo(-(sourceWall.depth - source.depth))
})
})
@@ -19,15 +19,15 @@ const ANCHOR: StretchAnchor = {
describe('cabinet continuous placement', () => {
test('fills a stretch with full modules plus a partial end module when needed', () => {
const widths = fillCabinetContinuousSpan(1.35)
const widths = fillCabinetContinuousSpan(1.15)
expect(widths).toHaveLength(3)
expect(widths[0]).toBeCloseTo(0.6)
expect(widths[1]).toBeCloseTo(0.6)
expect(widths[0]).toBeCloseTo(0.5)
expect(widths[1]).toBeCloseTo(0.5)
expect(widths[2]).toBeCloseTo(0.15)
})
test('drops a tiny remainder below the minimum end-module width', () => {
expect(fillCabinetContinuousSpan(1.27)).toEqual([0.6, 0.6])
expect(fillCabinetContinuousSpan(1.07)).toEqual([0.5, 0.5])
})
test('plans module offsets to the right of the anchored cabinet', () => {
@@ -40,15 +40,15 @@ describe('cabinet continuous placement', () => {
expect(stretch.modules).toHaveLength(3)
expect(stretch.modules[0]?.x).toBeCloseTo(0)
expect(stretch.modules[0]?.width).toBeCloseTo(0.6)
expect(stretch.modules[1]?.x).toBeCloseTo(0.6)
expect(stretch.modules[1]?.width).toBeCloseTo(0.6)
expect(stretch.modules[2]?.x).toBeCloseTo(1.125)
expect(stretch.modules[2]?.width).toBeCloseTo(0.45)
expect(stretch.length).toBeCloseTo(1.65)
expect(stretch.centerLocalX).toBeCloseTo(0.525)
expect(stretch.modules[1]?.x).toBeCloseTo(0.55)
expect(stretch.modules[1]?.width).toBeCloseTo(0.5)
expect(stretch.modules[2]?.x).toBeCloseTo(1.05)
expect(stretch.modules[2]?.width).toBeCloseTo(0.5)
expect(stretch.length).toBeCloseTo(1.6)
expect(stretch.centerLocalX).toBeCloseTo(0.5)
expect(stretch.direction).toBe(1)
expect(cabinetStretchExitSide(stretch)).toBe('right')
expect(cabinetStretchEndLocalX(stretch, 0.6)).toBeCloseTo(1.35)
expect(cabinetStretchEndLocalX(stretch, 0.6)).toBeCloseTo(1.3)
})
test('mirrors module offsets when the stretch grows left of the anchor', () => {
@@ -61,14 +61,14 @@ describe('cabinet continuous placement', () => {
expect(stretch.modules).toHaveLength(3)
expect(stretch.modules[0]?.x).toBeCloseTo(0)
expect(stretch.modules[0]?.width).toBeCloseTo(0.6)
expect(stretch.modules[1]?.x).toBeCloseTo(-0.6)
expect(stretch.modules[1]?.width).toBeCloseTo(0.6)
expect(stretch.modules[2]?.x).toBeCloseTo(-1.125)
expect(stretch.modules[2]?.width).toBeCloseTo(0.45)
expect(stretch.centerLocalX).toBeCloseTo(-0.525)
expect(stretch.modules[1]?.x).toBeCloseTo(-0.55)
expect(stretch.modules[1]?.width).toBeCloseTo(0.5)
expect(stretch.modules[2]?.x).toBeCloseTo(-1.05)
expect(stretch.modules[2]?.width).toBeCloseTo(0.5)
expect(stretch.centerLocalX).toBeCloseTo(-0.5)
expect(stretch.direction).toBe(-1)
expect(cabinetStretchExitSide(stretch)).toBe('left')
expect(cabinetStretchEndLocalX(stretch, 0.6)).toBeCloseTo(-1.35)
expect(cabinetStretchEndLocalX(stretch, 0.6)).toBeCloseTo(-1.3)
})
test('forced-direction anchors keep orthogonal follow-on legs growing outward', () => {
@@ -91,7 +91,7 @@ describe('cabinet continuous placement', () => {
expect(stretch.modules[0]?.width).toBeCloseTo(0.58)
expect(stretch.modules[0]?.x).toBeCloseTo(0)
expect(stretch.modules[1]?.width).toBeCloseTo(0.6)
expect(stretch.modules[1]?.width).toBeCloseTo(0.5)
expect(stretch.modules[1]?.x).toBeGreaterThan(0.58 / 2)
})
@@ -102,8 +102,8 @@ describe('cabinet continuous placement', () => {
rawPlanPosition: [0.05, 0, 0],
})
expect(stretch.modules.map((module) => module.width)).toEqual([0.58, 0.6])
expect(stretch.length).toBeCloseTo(1.18)
expect(stretch.modules.map((module) => module.width)).toEqual([0.58, 0.5])
expect(stretch.length).toBeCloseTo(1.08)
})
test('prefers continuing straight when the cursor moves forward from the committed end', () => {
@@ -0,0 +1,64 @@
import { expect, test } from 'bun:test'
import type { AnyNode, AnyNodeId, SceneApi } from '@pascal-app/core'
import { 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<
AnyNodeId,
AnyNode
>
return {
get: (id) => nodes[id],
nodes: () => nodes,
update: (id, patch) => {
const current = nodes[id]
if (current) nodes[id] = { ...current, ...patch } as AnyNode
},
upsert: (node, parentId) => {
nodes[node.id as AnyNodeId] = node
if (parentId) {
const parent = nodes[parentId]
if (parent) {
nodes[parentId] = {
...parent,
children: [...new Set([...(parent.children ?? []), node.id as AnyNodeId])],
} as AnyNode
}
}
return node.id as AnyNodeId
},
delete: () => {},
restore: () => {},
restoreAll: () => {},
markDirty: () => {},
pauseHistory: () => {},
resumeHistory: () => {},
getSubtree: () => null,
cloneNodesInto: () => null,
}
}
test('the default base cabinet preset uses overlay fronts', () => {
expect(cabinetPresetById('base-door').createPatch().frontOverlay).toBe('full')
})
test('a wall cabinet added from an inset base starts with overlay fronts', () => {
const run = CabinetNode.parse({
id: 'cabinet_default-front-run',
children: ['cabinet-module_default-front-base'],
})
const module = CabinetModuleNode.parse({
id: 'cabinet-module_default-front-base',
parentId: run.id,
frontOverlay: 'inset',
})
const sceneApi = sceneApiFixture([run as AnyNode, module as AnyNode])
const wallId = addWallChildAbove({ kind: 'cabinet', module, run, sceneApi })
expect(wallId).not.toBeNull()
expect(sceneApi.get<CabinetModuleNode>(wallId!)?.frontOverlay).toBe('full')
})
@@ -1,6 +1,6 @@
import { describe, expect, test } from 'bun:test'
import { cabinetModuleDefinition } from '../definition'
import { CabinetModuleNode } from '../schema'
import { CabinetModuleNode, CabinetNode } from '../schema'
describe('cabinet module drag bounds', () => {
test('uses schema dimensions instead of measured render geometry', () => {
@@ -19,4 +19,45 @@ describe('cabinet module drag bounds', () => {
expect(bounds?.size).toEqual([0.82, 0.88, 0.64])
expect(bounds?.center).toEqual([0, 0.44, 0])
})
test('moves an attached wall cabinet with its host module and bounds the full stack', () => {
const run = CabinetNode.parse({
id: 'cabinet_wall-drag-run',
children: ['cabinet-module_wall-drag-base'],
})
const base = CabinetModuleNode.parse({
id: 'cabinet-module_wall-drag-base',
parentId: run.id,
children: ['cabinet-module_wall-drag-upper'],
position: [0, 0.1, 0],
width: 0.6,
depth: 0.58,
})
const wall = CabinetModuleNode.parse({
id: 'cabinet-module_wall-drag-upper',
parentId: base.id,
position: [0, 1.25, -0.13],
width: 0.6,
depth: 0.32,
carcassHeight: 0.72,
plinthHeight: 0,
showPlinth: false,
withCountertop: false,
})
const nodes = { [run.id]: run, [base.id]: base, [wall.id]: wall }
const parent = cabinetModuleDefinition.capabilities.movable?.parentFrame?.resolveParent(
wall,
nodes,
)
const bounds = cabinetModuleDefinition.capabilities.dragBounds?.(base, nodes)
expect(parent?.id).toBe(base.id)
expect(bounds?.size[0]).toBeCloseTo(0.6)
expect(bounds?.size[1]).toBeCloseTo(1.97)
expect(bounds?.size[2]).toBeCloseTo(0.58)
expect(bounds?.center[0]).toBeCloseTo(0)
expect(bounds?.center[1]).toBeCloseTo(0.985)
expect(bounds?.center[2]).toBeCloseTo(0)
})
})
@@ -1,5 +1,11 @@
import { describe, expect, test } from 'bun:test'
import type { AnyNode, AnyNodeId, GeometryContext, LinearResizeHandle } from '@pascal-app/core'
import type {
AnyNode,
AnyNodeId,
GeometryContext,
HandleDescriptor,
LinearResizeHandle,
} from '@pascal-app/core'
import type { BufferAttribute, Mesh, Object3D } from 'three'
import { Box3 } from 'three'
import { bakeCabinetAnimationClip } from '../animation'
@@ -1316,10 +1322,15 @@ describe('buildCabinetGeometry — run countertops', () => {
'rendered',
false,
)
const plinth = worldBounds(findMeshByName(group, 'cabinet-run-plinth'))
const plinths = findMeshesBySlot(group, 'plinth')
.map(worldBounds)
.sort((a, b) => a.min.x - b.min.x)
expect(plinth.min.z).toBeCloseTo(-standardDepth / 2)
expect(plinth.max.z).toBeCloseTo(fridgeZ + FRIDGE_STANDARD_DEPTH / 2 - run.toeKickDepth)
expect(plinths).toHaveLength(2)
expect(plinths[0]!.min.z).toBeCloseTo(-standardDepth / 2)
expect(plinths[0]!.max.z).toBeCloseTo(standardDepth / 2 - run.toeKickDepth)
expect(plinths[1]!.min.z).toBeCloseTo(-standardDepth / 2)
expect(plinths[1]!.max.z).toBeCloseTo(fridgeZ + FRIDGE_STANDARD_DEPTH / 2 - run.toeKickDepth)
})
test('run countertop follows shifted module depth extents instead of staying centered', () => {
@@ -1355,6 +1366,54 @@ describe('buildCabinetGeometry — run countertops', () => {
expect(countertop!.maxZ).toBeCloseTo(shiftedZ + nextDepth / 2 + run.countertopOverhang)
})
test('run countertop and plinth split at cabinet depth changes', () => {
const run = CabinetNode.parse({
id: 'cabinet_individual-depth-surfaces',
showPlinth: true,
withCountertop: true,
})
const modules = [
CabinetModuleNode.parse({
id: 'cabinet-module_shallow-surface',
parentId: run.id,
cabinetType: 'base',
position: [-0.3, run.plinthHeight, 0.25],
width: 0.6,
depth: 0.5,
}),
CabinetModuleNode.parse({
id: 'cabinet-module_deep-surface',
parentId: run.id,
cabinetType: 'base',
position: [0.3, run.plinthHeight, 0.35],
width: 0.6,
depth: 0.7,
}),
]
const group = buildCabinetGeometry(
run,
geometryContext({ children: modules }),
'rendered',
false,
)
const countertops = countertopBounds(group)
const plinths = findMeshesBySlot(group, 'plinth')
.map(worldBounds)
.sort((a, b) => a.min.x - b.min.x)
expect(countertops).toHaveLength(2)
expect(countertops[0]!.minZ).toBeCloseTo(0)
expect(countertops[0]!.maxZ).toBeCloseTo(0.5 + run.countertopOverhang)
expect(countertops[1]!.minZ).toBeCloseTo(0)
expect(countertops[1]!.maxZ).toBeCloseTo(0.7 + run.countertopOverhang)
expect(plinths).toHaveLength(2)
expect(plinths[0]!.min.z).toBeCloseTo(0)
expect(plinths[0]!.max.z).toBeCloseTo(0.5 - run.toeKickDepth)
expect(plinths[1]!.min.z).toBeCloseTo(0)
expect(plinths[1]!.max.z).toBeCloseTo(0.7 - run.toeKickDepth)
})
test('island back overhang extends the slab backward and adds a finished back panel', () => {
const run = CabinetNode.parse({
id: 'cabinet_island-run',
@@ -2187,7 +2246,7 @@ describe('cabinet handles', () => {
] as const
}
function linearHandles() {
function moduleHandles() {
const node = CabinetModuleNode.parse({
position: [0, 0.1, 0],
width: 0.6,
@@ -2197,32 +2256,335 @@ describe('cabinet handles', () => {
typeof cabinetModuleDefinition.handles === 'function'
? cabinetModuleDefinition.handles(node)
: (cabinetModuleDefinition.handles ?? [])
return { handles, node }
}
function generatedL(side: 'left' | 'right') {
const run = CabinetNode.parse({
id: `cabinet_handle-source-${side}`,
parentId: `level_handle-source-${side}`,
position: [0, 0, 0],
depth: 0.58,
children: [`cabinet-module_handle-source-${side}`],
})
const sourceModule = CabinetModuleNode.parse({
id: `cabinet-module_handle-source-${side}`,
parentId: run.id,
position: [0, 0.1, 0],
width: 0.9,
depth: 0.58,
carcassHeight: 0.72,
})
const sceneApi = sceneApiFixture([run as AnyNode, sourceModule as AnyNode])
const selectedId = addCornerRun({ module: sourceModule, run, sceneApi, side })!
const selectedModule = sceneApi.get(selectedId) as CabinetModuleNode
const leg = sceneApi.get(selectedModule.parentId as AnyNodeId) as CabinetNode
const source = sceneApi.get(run.id as AnyNodeId) as CabinetNode
const liveSourceModule = sceneApi.get(sourceModule.id as AnyNodeId) as CabinetModuleNode
const legModule = leg.children
.map((id) => sceneApi.get(id as AnyNodeId))
.find((node): node is CabinetModuleNode => node?.type === 'cabinet-module')!
const handles =
typeof cabinetDefinition.handles === 'function'
? cabinetDefinition.handles(source, sceneApi as never)
: (cabinetDefinition.handles ?? [])
const depthHandles = handles.filter(
(handle): handle is LinearResizeHandle<typeof source> =>
handle.kind === 'linear-resize' && handle.visible?.(source, sceneApi as never) !== false,
)
return {
node,
handles: handles.filter(
(handle): handle is LinearResizeHandle<typeof node> => handle.kind === 'linear-resize',
),
depthHandles,
leg,
legModule,
sceneApi,
selectedModule,
source,
sourceModule: liveSourceModule,
}
}
test('width arrows resize from the chosen side instead of around center', () => {
const { node, handles } = linearHandles()
const leftHandle = handles.find((handle) => handle.axis === 'x' && handle.anchor === 'max')
const rightHandle = handles.find((handle) => handle.axis === 'x' && handle.anchor === 'min')
function generatedU(side: 'left' | 'right') {
const fixture = generatedL(side)
const thirdSelectedId = addCornerRun({
module: fixture.selectedModule,
run: fixture.leg,
sceneApi: fixture.sceneApi,
side,
})!
const thirdSelectedModule = fixture.sceneApi.get(thirdSelectedId) as CabinetModuleNode
const thirdRun = fixture.sceneApi.get(thirdSelectedModule.parentId as AnyNodeId) as CabinetNode
const source = fixture.sceneApi.get(fixture.source.id as AnyNodeId) as CabinetNode
const buildHandles = cabinetDefinition.handles as (
node: CabinetNode,
sceneApi: ReturnType<typeof sceneApiFixture>,
) => HandleDescriptor<CabinetNode>[]
const depthHandles = buildHandles(source, fixture.sceneApi).filter(
(handle): handle is LinearResizeHandle<CabinetNode> =>
handle.kind === 'linear-resize' &&
handle.visible?.(source, fixture.sceneApi as never) !== false,
)
return { ...fixture, depthHandles, source, thirdRun }
}
test('single cabinet side arrows resize from the dragged side', () => {
const { handles, node } = moduleHandles()
const widthHandles = handles.filter(
(handle): handle is LinearResizeHandle<typeof node> =>
handle.kind === 'linear-resize' && handle.axis === 'x',
)
const leftHandle = widthHandles.find((handle) => handle.anchor === 'max')
const rightHandle = widthHandles.find((handle) => handle.anchor === 'min')
expect(handles).toHaveLength(3)
expect(leftHandle).toBeDefined()
expect(rightHandle).toBeDefined()
expect(leftHandle!.apply(node, 0.8, null as never).position?.[0]).toBeCloseTo(-0.1)
expect(rightHandle!.apply(node, 0.8, null as never).position?.[0]).toBeCloseTo(0.1)
})
test('depth arrow keeps the back aligned and grows toward the front', () => {
const { node, handles } = linearHandles()
const depthHandle = handles.find((handle) => handle.axis === 'z')
test.each([
['left', -Math.PI / 2],
['right', Math.PI / 2],
] as const)('L %s groups expose a depth arrow on both inside fronts', (_side, legRotation) => {
const sourceModule = CabinetModuleNode.parse({
id: `cabinet-module_source-${_side}`,
parentId: `cabinet_source-${_side}`,
position: [0, 0.1, 0],
depth: 0.58,
})
const legModule = CabinetModuleNode.parse({
id: `cabinet-module_leg-${_side}`,
parentId: `cabinet_leg-${_side}`,
position: [0, 0.1, 0],
depth: 0.58,
})
const leg = CabinetNode.parse({
id: `cabinet_leg-${_side}`,
parentId: `cabinet_source-${_side}`,
position: [legRotation < 0 ? -0.6 : 0.6, 0, 0.3],
rotation: legRotation,
depth: 0.58,
children: [legModule.id],
metadata: {
cabinetCornerDerivedRun: {
role: 'base-leg',
side: _side,
turnSide: _side,
sourceModuleId: sourceModule.id,
sourceRunId: `cabinet_source-${_side}`,
},
},
})
const run = {
...CabinetNode.parse({
id: `cabinet_source-${_side}`,
position: [0, 0, 0],
depth: 0.58,
children: [sourceModule.id],
}),
children: [sourceModule.id, leg.id],
} as CabinetNode
const nodes = Object.fromEntries(
[run, sourceModule, leg, legModule].map((node) => [node.id as AnyNodeId, node as AnyNode]),
) as Record<AnyNodeId, AnyNode>
const sceneApi = {
get: (id: AnyNodeId) => nodes[id],
nodes: () => nodes,
}
const handles =
typeof cabinetDefinition.handles === 'function'
? cabinetDefinition.handles(run, sceneApi as never)
: (cabinetDefinition.handles ?? [])
const depthHandles = handles.filter(
(handle): handle is LinearResizeHandle<typeof run> =>
handle.kind === 'linear-resize' && handle.visible?.(run, sceneApi as never) !== false,
)
expect(depthHandles.map((handle) => handle.axis).sort()).toEqual(['x', 'z'])
const legHandle = depthHandles.find((handle) => handle.axis === 'x')!
const frontOffset = leg.depth / 2 + 0.18
expect(legHandle.overrideTarget?.(run, sceneApi as never)).toBe(leg.id)
expect(legHandle.placement.position(run, sceneApi as never)[0]).toBeCloseTo(
leg.position[0] + Math.sin(legRotation) * frontOffset,
)
expect(legHandle.placement.position(run, sceneApi as never)[2]).toBeCloseTo(
leg.position[2] + Math.cos(legRotation) * frontOffset,
)
const patch = legHandle.apply(run, 0.78, sceneApi as never)
expect(patch.depth).toBeCloseTo(0.78)
expect(patch.position).toBeUndefined()
const originalBack = legModule.position[2] - legModule.depth / 2
const preview = legHandle.previewOverrides?.(run, 0.78, sceneApi as never) ?? []
const modulePreview = preview.find(([id]) => id === legModule.id)?.[1]
expect(modulePreview?.depth).toBeCloseTo(0.78)
expect(modulePreview?.position?.[2] - modulePreview?.depth / 2).toBeCloseTo(originalBack)
expect(nodes[legModule.id]?.depth).toBeCloseTo(0.58)
expect(nodes[legModule.id]?.position[2]).toBeCloseTo(0)
})
test('plain grouped runs expose bottom depth and rotate affordances', () => {
const module = CabinetModuleNode.parse({
id: 'cabinet-module_plain-group',
parentId: 'cabinet_plain-group',
})
const run = CabinetNode.parse({
id: 'cabinet_plain-group',
children: [module.id],
})
const nodes = { [run.id]: run, [module.id]: module } as Record<AnyNodeId, AnyNode>
const sceneApi = {
get: (id: AnyNodeId) => nodes[id],
nodes: () => nodes,
}
const handles =
typeof cabinetDefinition.handles === 'function'
? cabinetDefinition.handles(run, sceneApi as never)
: (cabinetDefinition.handles ?? [])
const visibleHandles = handles.filter(
(handle) =>
handle.kind !== 'linear-resize' || handle.visible?.(run, sceneApi as never) !== false,
)
expect(visibleHandles).toHaveLength(2)
const depthHandle = visibleHandles.find(
(handle): handle is LinearResizeHandle<typeof run> =>
handle.kind === 'linear-resize' && handle.axis === 'z',
)
expect(depthHandle).toBeDefined()
expect(depthHandle!.anchor).toBe('min')
expect(depthHandle!.apply(node, 0.78, null as never).position?.[2]).toBeCloseTo(0.1)
expect(depthHandle?.overrideTarget?.(run, sceneApi as never)).toBe(run.id)
expect(visibleHandles.some((handle) => handle.kind === 'arc-resize')).toBe(true)
})
test.each([
'left',
'right',
] as const)('source depth on an L %s changes only the source leg', (side) => {
const { depthHandles, leg, sceneApi, source, sourceModule } = generatedL(side)
const handle = depthHandles.find((candidate) => candidate.axis === 'z')!
const initialSourcePosition = [...source.position]
const initialLegPosition = [...leg.position]
const initialSourceBack = sourceModule.position[2] - sourceModule.depth / 2
const patch = handle.apply(source, 0.78, sceneApi as never)
expect(patch.position).toBeUndefined()
handle.commit?.(source, patch, sceneApi as never)
expect(sceneApi.get<CabinetNode>(source.id)?.depth).toBeCloseTo(0.78)
expect(sceneApi.get<CabinetNode>(source.id)?.position).toEqual(initialSourcePosition)
const resizedSourceModule = sceneApi.get<CabinetModuleNode>(sourceModule.id)!
expect(resizedSourceModule.position[2] - resizedSourceModule.depth / 2).toBeCloseTo(
initialSourceBack,
)
expect(sceneApi.get<CabinetNode>(leg.id)?.depth).toBeCloseTo(leg.depth)
expect(sceneApi.get<CabinetNode>(leg.id)?.position).toEqual(initialLegPosition)
})
test.each([
'left',
'right',
] as const)('perpendicular depth on an L %s changes only the derived leg', (side) => {
const { depthHandles, leg, legModule, sceneApi, source } = generatedL(side)
const handle = depthHandles.find((candidate) => candidate.axis === 'x')!
const initialSourcePosition = [...source.position]
const initialLegPosition = [...leg.position]
const initialLegBack = legModule.position[2] - legModule.depth / 2
const cornerWallFiller = Object.values(sceneApi.nodes()).find(
(node): node is CabinetModuleNode =>
node.type === 'cabinet-module' && node.name === 'Corner Wall Filler',
)!
const initialCornerWallWorld = resolveCabinetWorldTransform(
cornerWallFiller,
sceneApi.nodes() as Record<AnyNodeId, AnyNode>,
)
const patch = handle.apply(source, 0.48, sceneApi as never)
handle.commit?.(source, patch, sceneApi as never)
expect(sceneApi.get<CabinetNode>(leg.id)?.depth).toBeCloseTo(0.48)
expect(sceneApi.get<CabinetNode>(leg.id)?.position).toEqual(initialLegPosition)
const resizedLegModule = sceneApi.get<CabinetModuleNode>(legModule.id)!
expect(resizedLegModule.position[2] - resizedLegModule.depth / 2).toBeCloseTo(initialLegBack)
expect(sceneApi.get<CabinetNode>(source.id)?.depth).toBeCloseTo(source.depth)
expect(sceneApi.get<CabinetNode>(source.id)?.position).toEqual(initialSourcePosition)
const resizedCornerWallWorld = resolveCabinetWorldTransform(
sceneApi.get<CabinetModuleNode>(cornerWallFiller.id)!,
sceneApi.nodes() as Record<AnyNodeId, AnyNode>,
)
expect(resizedCornerWallWorld.position[0]).toBeCloseTo(initialCornerWallWorld.position[0])
expect(resizedCornerWallWorld.position[2]).toBeCloseTo(initialCornerWallWorld.position[2])
})
test.each([
'left',
'right',
] as const)('chained L %s groups expose one centered depth arrow per run', (side) => {
const { depthHandles, leg, sceneApi, source, thirdRun } = generatedU(side)
const runs = [source, leg, thirdRun]
const sourceWorld = resolveCabinetWorldTransform(
source,
sceneApi.nodes() as Record<AnyNodeId, AnyNode>,
)
const sourceCos = Math.cos(sourceWorld.rotation)
const sourceSin = Math.sin(sourceWorld.rotation)
const targetIds = depthHandles.map(
(handle) => handle.overrideTarget?.(source, sceneApi as never) ?? source.id,
)
expect(new Set(targetIds)).toEqual(new Set(runs.map((run) => run.id)))
expect(depthHandles).toHaveLength(3)
for (const run of runs) {
const modules = run.children
.map((id) => sceneApi.get(id as AnyNodeId))
.filter((node): node is CabinetModuleNode => node?.type === 'cabinet-module')
const centerX =
(Math.min(...modules.map((module) => module.position[0] - module.width / 2)) +
Math.max(...modules.map((module) => module.position[0] + module.width / 2))) /
2
const frontZ = Math.max(...modules.map((module) => module.position[2] + module.depth / 2))
const runWorld = resolveCabinetWorldTransform(
run,
sceneApi.nodes() as Record<AnyNodeId, AnyNode>,
)
const frontWorld = localPointToWorld(runWorld, [centerX, 0, frontZ + 0.18])
const dx = frontWorld[0] - sourceWorld.position[0]
const dz = frontWorld[2] - sourceWorld.position[2]
const expectedX = sourceCos * dx - sourceSin * dz
const expectedZ = sourceSin * dx + sourceCos * dz
const handle = depthHandles.find(
(candidate) =>
(candidate.overrideTarget?.(source, sceneApi as never) ?? source.id) === run.id,
)!
const position = handle.placement.position(source, sceneApi as never)
expect(position[0]).toBeCloseTo(expectedX)
expect(position[2]).toBeCloseTo(expectedZ)
}
})
test.each([
'left',
'right',
] as const)('depth resize on a chained L %s updates the connected corner width', (side) => {
const { depthHandles, leg, sceneApi, source, thirdRun } = generatedU(side)
const handle = depthHandles.find(
(candidate) =>
(candidate.overrideTarget?.(source, sceneApi as never) ?? source.id) === leg.id,
)!
const patch = handle.apply(source, 0.78, sceneApi as never)
handle.commit?.(source, patch, sceneApi as never)
const connectedFiller = thirdRun.children
.map((id) => sceneApi.get(id as AnyNodeId))
.find(
(node): node is CabinetModuleNode =>
node?.type === 'cabinet-module' && node.name === 'Corner Filler',
)!
expect(connectedFiller.width).toBeCloseTo(0.78)
})
test('run rotation keeps the cabinet bounding-box center fixed', () => {
@@ -2255,7 +2617,7 @@ describe('cabinet handles', () => {
}
const rotateHandle = (
typeof cabinetDefinition.handles === 'function'
? cabinetDefinition.handles(run)
? cabinetDefinition.handles(run, sceneApi as never)
: (cabinetDefinition.handles ?? [])
).find((handle) => handle.kind === 'arc-resize' && handle.shape === 'rotate')
@@ -190,11 +190,15 @@ describe('cabinetModuleParentFrame.magneticSnapMatches', () => {
id: 'cabinet-module_moving',
parentId: nestedRun.id,
position: [0.65, 0.1, 0],
width: 0.6,
depth: 0.58,
})
const sibling = CabinetModuleNode.parse({
id: 'cabinet-module_sibling',
parentId: nestedRun.id,
position: [0, 0.1, 0],
width: 0.6,
depth: 0.58,
})
const nodes = Object.fromEntries(
[rootRun, parentModule, nestedRun, moving, sibling].map((node) => [node.id, node as AnyNode]),
@@ -0,0 +1,45 @@
import { describe, expect, test } from 'bun:test'
import { resolveCabinetGridPosition } from '../placement-snap'
const DIMENSIONS: [number, number, number] = [0.6, 0.84, 0.58]
describe('cabinet placement grid snap', () => {
test('aligns the footprint edges to grid lines', () => {
const position = resolveCabinetGridPosition({
raw: [0.12, 0, 0.17],
dimensions: DIMENSIONS,
yaw: 0,
step: 0.5,
})
expect(position[0]).toBeCloseTo(0.3)
expect(position[1]).toBe(0)
expect(position[2]).toBeCloseTo(0.29)
expect(position[0] - DIMENSIONS[0] / 2).toBeCloseTo(0)
expect(position[2] - DIMENSIONS[2] / 2).toBeCloseTo(0)
})
test('swaps footprint axes after a quarter turn', () => {
const position = resolveCabinetGridPosition({
raw: [0.12, 0, 0.17],
dimensions: DIMENSIONS,
yaw: Math.PI / 2,
step: 0.5,
})
expect(position[0]).toBeCloseTo(0.29)
expect(position[1]).toBe(0)
expect(position[2]).toBeCloseTo(0.3)
})
test('preserves free placement when grid snap is disabled', () => {
expect(
resolveCabinetGridPosition({
raw: [0.12, 0, 0.17],
dimensions: DIMENSIONS,
yaw: 0,
step: 0,
}),
).toEqual([0.12, 0, 0.17])
})
})
@@ -42,6 +42,39 @@ function sceneApiFixture(seed: AnyNode[]): SceneApi {
}
describe('cabinet quick actions', () => {
test.each([
'left',
'right',
] as const)('selects the outer base cabinet after an L %s action', (side) => {
const levelId = `level_quick-actions-select-outer-${side}` as AnyNodeId
const run = CabinetNode.parse({
id: `cabinet_run-quick-actions-select-outer-${side}`,
parentId: levelId,
position: [0, 0, 0],
rotation: 0,
children: [`cabinet-module_source-quick-actions-select-outer-${side}`],
})
const source = CabinetModuleNode.parse({
id: `cabinet-module_source-quick-actions-select-outer-${side}`,
parentId: run.id,
position: [0, 0.1, 0],
width: 0.9,
depth: 0.58,
carcassHeight: 0.72,
})
const sceneApi = sceneApiFixture([run as AnyNode, source as AnyNode])
const action = cabinetQuickActions({ node: source, nodes: sceneApi.nodes() }).find(
(candidate) => candidate.id === `cabinet:add-corner-${side}`,
)
expect(action?.disabled).toBeFalsy()
const selectedId = action?.run({ sceneApi })?.selectedIds?.[0]
const selected = selectedId ? sceneApi.get<CabinetModuleNode>(selectedId) : null
expect(selected?.name).toBe('Base Cabinet')
expect(selected?.moduleKind).toBe('standard')
})
test('offers and runs an L-corner action from run selection using the end module', () => {
const levelId = 'level_quick_actions_corner' as AnyNodeId
const run = CabinetNode.parse({
@@ -335,6 +368,104 @@ describe('cabinet quick actions', () => {
expect(cornerRightAction?.disabled).toBeFalsy()
})
test('disables wall addition when an expanded wall cabinet occupies the proposed space', () => {
const levelId = 'level_quick-actions-wall-overlap' as AnyNodeId
const run = CabinetNode.parse({
id: 'cabinet_run-quick-actions-wall-overlap',
parentId: levelId,
children: [
'cabinet-module_left-quick-actions-wall-overlap',
'cabinet-module_selected-quick-actions-wall-overlap',
],
})
const leftBase = CabinetModuleNode.parse({
id: 'cabinet-module_left-quick-actions-wall-overlap',
parentId: run.id,
children: ['cabinet-module_expanded-wall-quick-actions-wall-overlap'],
position: [-0.25, 0.1, 0],
})
const selectedBase = CabinetModuleNode.parse({
id: 'cabinet-module_selected-quick-actions-wall-overlap',
parentId: run.id,
position: [0.25, 0.1, 0],
})
const expandedWall = CabinetModuleNode.parse({
id: 'cabinet-module_expanded-wall-quick-actions-wall-overlap',
parentId: leftBase.id,
name: 'Wall Cabinet',
position: [0.15, 1.35, -0.13],
width: 0.8,
depth: 0.32,
carcassHeight: 0.72,
})
const sceneApi = sceneApiFixture([
run as AnyNode,
leftBase as AnyNode,
selectedBase as AnyNode,
expandedWall as AnyNode,
])
const wallAction = cabinetQuickActions({
node: selectedBase,
nodes: sceneApi.nodes(),
}).find((action) => action.id === 'cabinet:add-wall')
const moduleCount = Object.values(sceneApi.nodes()).filter(
(node) => node?.type === 'cabinet-module',
).length
expect(wallAction?.disabled).toBe(true)
expect(wallAction?.blockedFeedback).toBe(true)
expect(wallAction?.title).toBe('No space above—overlaps an existing wall cabinet')
expect(wallAction?.run({ sceneApi })).toBeUndefined()
expect(
Object.values(sceneApi.nodes()).filter((node) => node?.type === 'cabinet-module'),
).toHaveLength(moduleCount)
})
test('allows wall addition when an existing wall cabinet only touches the proposed edge', () => {
const levelId = 'level_quick-actions-wall-touching' as AnyNodeId
const run = CabinetNode.parse({
id: 'cabinet_run-quick-actions-wall-touching',
parentId: levelId,
children: [
'cabinet-module_left-quick-actions-wall-touching',
'cabinet-module_selected-quick-actions-wall-touching',
],
})
const leftBase = CabinetModuleNode.parse({
id: 'cabinet-module_left-quick-actions-wall-touching',
parentId: run.id,
children: ['cabinet-module_wall-quick-actions-wall-touching'],
position: [-0.25, 0.1, 0],
})
const selectedBase = CabinetModuleNode.parse({
id: 'cabinet-module_selected-quick-actions-wall-touching',
parentId: run.id,
position: [0.25, 0.1, 0],
})
const existingWall = CabinetModuleNode.parse({
id: 'cabinet-module_wall-quick-actions-wall-touching',
parentId: leftBase.id,
name: 'Wall Cabinet',
position: [0, 1.35, -0.13],
depth: 0.32,
carcassHeight: 0.72,
})
const sceneApi = sceneApiFixture([
run as AnyNode,
leftBase as AnyNode,
selectedBase as AnyNode,
existingWall as AnyNode,
])
const wallAction = cabinetQuickActions({
node: selectedBase,
nodes: sceneApi.nodes(),
}).find((action) => action.id === 'cabinet:add-wall')
expect(wallAction?.disabled).toBeFalsy()
expect(wallAction?.blockedFeedback).toBeUndefined()
expect(wallAction?.run({ sceneApi })?.selectedIds).toHaveLength(1)
})
test('disables L action when the corner preview has no usable width', () => {
const levelId = 'level_quick_actions_disabled-corner-wall' as AnyNodeId
const run = CabinetNode.parse({
@@ -358,8 +489,8 @@ describe('cabinet quick actions', () => {
const blockingWall = WallNode.parse({
id: 'wall_quick-actions-disabled-corner-wall',
parentId: levelId,
start: [-1, 0.65],
end: [2, 0.65],
start: [-1, 0.55],
end: [2, 0.55],
thickness: 0.2,
})
const sceneApi = sceneApiFixture([run as AnyNode, source as AnyNode, blockingWall as AnyNode])
@@ -0,0 +1,35 @@
import { describe, expect, test } from 'bun:test'
import {
cabinetConnectedDepthBounds,
cabinetResizeUpperBound,
connectedCabinetDepthUpperBound,
MAX_CABINET_DEPTH,
MAX_CABINET_WIDTH,
} from '../resize-limits'
describe('cabinet resize limits', () => {
test('caps new cabinet width and depth at usable maximums', () => {
expect(MAX_CABINET_WIDTH).toBe(1.2)
expect(MAX_CABINET_DEPTH).toBe(0.8)
})
test('does not force an oversized legacy cabinet smaller when dragging begins', () => {
expect(cabinetResizeUpperBound(1.4, MAX_CABINET_WIDTH)).toBe(1.4)
expect(cabinetResizeUpperBound(0.95, MAX_CABINET_DEPTH)).toBe(0.95)
})
test('stops a connected depth resize before its source cabinet becomes too narrow', () => {
expect(connectedCabinetDepthUpperBound(0.5, 0.4)).toBeCloseTo(0.6)
expect(connectedCabinetDepthUpperBound(0.5, 0.3)).toBeCloseTo(0.5)
expect(connectedCabinetDepthUpperBound(0.5)).toBeCloseTo(MAX_CABINET_DEPTH)
})
test('keeps every compensating cabinet within the width limits in both directions', () => {
const oneSide = cabinetConnectedDepthBounds(0.8, [0.9])
expect(oneSide.min).toBeCloseTo(0.5)
expect(oneSide.max).toBeCloseTo(0.8)
const bothSides = cabinetConnectedDepthBounds(0.5, [0.4, 0.6])
expect(bothSides.min).toBeCloseTo(0.3)
expect(bothSides.max).toBeCloseTo(0.6)
})
})
@@ -4,10 +4,17 @@ import { runLocalToPlan } from '../run-layout'
import {
addCabinetModuleSide,
addCornerRun,
backAlignedRunDepthOverrides,
backAlignZ,
cabinetModulesForRun,
cornerSourceWidthOverridesForDerivedDepth,
previewCornerAdditionLayout,
previewCornerRunsFromRunSources,
syncCornerRunsFromRunSources,
syncCornerRunsFromSourceModule,
syncCornerStyleGroupFromRun,
wallBottomHeightForTallAlignment,
wallChildOf,
} from '../run-ops'
import { CabinetModuleNode, CabinetNode } from '../schema'
@@ -74,6 +81,89 @@ function resolveCabinetWorldTransform(
}
describe('addCabinetModuleSide', () => {
test('group depth resize keeps one stable back plane through grow and shrink cycles', () => {
const run = CabinetNode.parse({
id: 'cabinet_back-aligned-depth-run',
depth: 0.58,
children: ['cabinet-module_back-left', 'cabinet-module_back-right'],
})
const modules = [
CabinetModuleNode.parse({
id: 'cabinet-module_back-left',
parentId: run.id,
position: [-0.3, 0.1, 0],
width: 0.6,
depth: 0.58,
children: ['cabinet-module_back-left-wall'],
}),
CabinetModuleNode.parse({
id: 'cabinet-module_back-right',
parentId: run.id,
position: [0.3, 0.1, 0.02],
width: 0.6,
depth: 0.58,
}),
]
const wall = CabinetModuleNode.parse({
id: 'cabinet-module_back-left-wall',
parentId: modules[0]!.id,
name: 'Wall Cabinet',
position: [0, 1.35, backAlignZ(0.58, 0.32)],
width: 0.6,
depth: 0.32,
})
const sceneApi = sceneApiFixture([
run as AnyNode,
...modules.map((module) => module as AnyNode),
wall as AnyNode,
])
const originalBack = -0.29
for (const depth of [0.82, 0.42, 0.68]) {
const liveRun = { ...sceneApi.get<CabinetNode>(run.id)!, depth }
for (const [id, override] of backAlignedRunDepthOverrides(liveRun, sceneApi.nodes(), depth)) {
sceneApi.update(id, override)
}
sceneApi.update(run.id as AnyNodeId, { depth })
const backs = liveRun.children.map((id) => {
const module = sceneApi.get<CabinetModuleNode>(id as AnyNodeId)!
return module.position[2] - module.depth / 2
})
expect(backs[0]).toBeCloseTo(originalBack)
expect(backs[1]).toBeCloseTo(originalBack)
const liveBase = sceneApi.get<CabinetModuleNode>(modules[0]!.id)!
const liveWall = sceneApi.get<CabinetModuleNode>(wall.id)!
expect(liveBase.position[2] + liveWall.position[2] - liveWall.depth / 2).toBeCloseTo(
originalBack,
)
expect(liveWall.width).toBeCloseTo(0.6)
}
})
test('adds a default base cabinet at 0.5m wide and 0.5m deep', () => {
const levelId = 'level_add-side-default-size' as AnyNodeId
const run = CabinetNode.parse({
id: 'cabinet_run-add-side-default-size',
parentId: levelId,
position: [0, 0, 0],
rotation: 0,
})
const sceneApi = sceneApiFixture([run as AnyNode])
const id = addCabinetModuleSide({
anchorModule: null,
run,
sceneApi,
side: 'right',
})
expect(id).toBeTruthy()
const added = sceneApi.get<CabinetModuleNode>(id!)
expect(added?.width).toBeCloseTo(0.5)
expect(added?.depth).toBeCloseTo(0.5)
})
test('shrinks a newly added corner-end base cabinet to the remaining wall clearance', () => {
const levelId = 'level_add-side-wall-clearance' as AnyNodeId
const run = CabinetNode.parse({
@@ -109,8 +199,8 @@ describe('addCabinetModuleSide', () => {
expect(id).toBeTruthy()
const added = sceneApi.get<CabinetModuleNode>(id!)
expect(added?.width).toBeCloseTo(0.55)
expect(added?.position[0]).toBeCloseTo(0.725)
expect(added?.width).toBeCloseTo(0.5)
expect(added?.position[0]).toBeCloseTo(0.7)
expect(sceneApi.get<CabinetModuleNode>(anchor.id)?.width).toBeCloseTo(0.9)
})
@@ -205,8 +295,8 @@ describe('addCabinetModuleSide', () => {
expect(id).toBeTruthy()
const added = sceneApi.get<CabinetModuleNode>(id!)
expect(added?.width).toBeCloseTo(0.55)
expect(added?.position[0]).toBeCloseTo(0.725)
expect(added?.width).toBeCloseTo(0.5)
expect(added?.position[0]).toBeCloseTo(0.7)
})
})
@@ -692,6 +782,610 @@ describe('addCornerRun', () => {
expect(allCabinets.every((node) => node.handlePosition === 'center')).toBe(true)
})
test('keeps both corner fillers consistent when a two-ended source run changes depth', () => {
const run = CabinetNode.parse({
id: 'cabinet_source-run-both-sides-depth',
depth: 0.58,
children: [
'cabinet-module_left-both-sides-depth',
'cabinet-module_center-both-sides-depth',
'cabinet-module_right-both-sides-depth',
],
})
const modules = [
CabinetModuleNode.parse({
id: 'cabinet-module_left-both-sides-depth',
parentId: run.id,
position: [-0.75, 0.1, 0],
width: 0.6,
depth: 0.58,
}),
CabinetModuleNode.parse({
id: 'cabinet-module_center-both-sides-depth',
parentId: run.id,
position: [0, 0.1, 0],
width: 0.9,
depth: 0.58,
}),
CabinetModuleNode.parse({
id: 'cabinet-module_right-both-sides-depth',
parentId: run.id,
position: [0.75, 0.1, 0],
width: 0.6,
depth: 0.58,
}),
]
const sceneApi = sceneApiFixture([
run as AnyNode,
...modules.map((module) => module as AnyNode),
])
addCornerRun({ module: modules[0]!, run, sceneApi, side: 'left' })
addCornerRun({ module: modules[2]!, run, sceneApi, side: 'right' })
const resizedRun = { ...sceneApi.get<CabinetNode>(run.id)!, depth: 0.78 }
const depthOverrides = backAlignedRunDepthOverrides(
sceneApi.get<CabinetNode>(run.id)!,
sceneApi.nodes(),
resizedRun.depth,
)
const previewOverrides = new Map(
previewCornerRunsFromRunSources({
baseLayout: 'width-only',
initialOverrides: depthOverrides,
run: resizedRun,
sceneApi,
}),
)
const previewFillers = Object.values(sceneApi.nodes()).filter(
(node): node is CabinetModuleNode =>
node.type === 'cabinet-module' && node.name === 'Corner Filler',
)
expect(previewFillers).toHaveLength(2)
for (const filler of previewFillers) {
expect(previewOverrides.get(filler.id as AnyNodeId)?.width).toBeCloseTo(0.78)
expect(filler.width).toBeCloseTo(0.58)
}
const previewConnectedCabinets = Object.values(sceneApi.nodes()).filter(
(node): node is CabinetModuleNode =>
node.type === 'cabinet-module' && node.name === 'Base Cabinet',
)
expect(previewConnectedCabinets).toHaveLength(2)
for (const cabinet of previewConnectedCabinets) {
expect(previewOverrides.get(cabinet.id as AnyNodeId)?.width).toBeCloseTo(0.6)
expect(cabinet.width).toBeCloseTo(0.6)
}
const connectedWallCabinets = previewConnectedCabinets
.map((cabinet) => wallChildOf(cabinet, sceneApi.nodes()))
.filter((cabinet): cabinet is CabinetModuleNode => cabinet != null)
expect(connectedWallCabinets).toHaveLength(2)
for (const wallCabinet of connectedWallCabinets) {
expect(previewOverrides.get(wallCabinet.id as AnyNodeId)?.width).toBeCloseTo(0.6)
expect(wallCabinet.width).toBeCloseTo(0.6)
}
const cornerWallFillers = Object.values(sceneApi.nodes()).filter(
(node): node is CabinetModuleNode =>
node.type === 'cabinet-module' && node.name === 'Corner Wall Filler',
)
const bridgeWallFillers = Object.values(sceneApi.nodes()).filter(
(node): node is CabinetModuleNode =>
node.type === 'cabinet-module' && node.name === 'Wall Bridge Filler',
)
expect(cornerWallFillers).toHaveLength(2)
expect(bridgeWallFillers).toHaveLength(2)
const bridgeWidths = new Map(bridgeWallFillers.map((filler) => [filler.id, filler.width]))
for (const filler of cornerWallFillers) {
const preview = previewOverrides.get(filler.id as AnyNodeId)!
expect(preview.width).toBeCloseTo(0.32)
const parentRun = sceneApi.get<CabinetNode>(filler.parentId as AnyNodeId)!
const side = (parentRun.metadata as Record<string, { side?: 'left' | 'right' }> | null)
?.cabinetCornerDerivedRun?.side
const previewX = preview.position?.[0] ?? filler.position[0]
if (side === 'left') {
expect(previewX + preview.width! / 2).toBeCloseTo(filler.position[0] + filler.width / 2)
} else {
expect(previewX - preview.width! / 2).toBeCloseTo(filler.position[0] - filler.width / 2)
}
}
for (const filler of bridgeWallFillers) {
expect(previewOverrides.get(filler.id as AnyNodeId)?.width).toBeUndefined()
}
const wallRuns = Object.values(sceneApi.nodes()).filter(
(node): node is CabinetNode => node.type === 'cabinet' && node.runTier === 'wall',
)
expect(wallRuns).toHaveLength(4)
const wallRunWorldPositions = new Map(
wallRuns.map((wallRun) => [
wallRun.id,
resolveCabinetWorldTransform(wallRun, sceneApi.nodes() as Record<AnyNodeId, AnyNode>)
.position,
]),
)
const previewNodes = { ...sceneApi.nodes() } as Record<AnyNodeId, AnyNode>
for (const [id, override] of previewOverrides) {
if (previewNodes[id]) previewNodes[id] = { ...previewNodes[id], ...override } as AnyNode
}
for (const wallRun of wallRuns) {
const previewWorld = resolveCabinetWorldTransform(
previewNodes[wallRun.id] as CabinetNode,
previewNodes,
)
const originalWorld = wallRunWorldPositions.get(wallRun.id)!
expect(previewWorld.position[0]).toBeCloseTo(originalWorld[0])
expect(previewWorld.position[2]).toBeCloseTo(originalWorld[2])
}
for (const [id, override] of depthOverrides) sceneApi.update(id, override)
sceneApi.update(run.id as AnyNodeId, { depth: resizedRun.depth })
syncCornerRunsFromRunSources({
baseLayout: 'width-only',
run: resizedRun,
sceneApi,
})
const derivedBaseRuns = Object.values(sceneApi.nodes()).filter(
(node): node is CabinetNode =>
node.type === 'cabinet' &&
(node.metadata as Record<string, { role?: string }> | null)?.cabinetCornerDerivedRun
?.role === 'base-leg',
)
expect(derivedBaseRuns).toHaveLength(2)
for (const derivedRun of derivedBaseRuns) {
const derivedModules = derivedRun.children
.map((id) => sceneApi.get<CabinetModuleNode>(id as AnyNodeId))
.filter((module): module is CabinetModuleNode => module?.type === 'cabinet-module')
expect(derivedModules.find((module) => module.name === 'Corner Filler')?.width).toBeCloseTo(
0.78,
)
expect(derivedModules.find((module) => module.name === 'Base Cabinet')?.width).toBeCloseTo(
0.6,
)
const connectedBase = derivedModules.find((module) => module.name === 'Base Cabinet')!
expect(wallChildOf(connectedBase, sceneApi.nodes())?.width).toBeCloseTo(0.6)
expect(derivedRun.depth).toBeCloseTo(0.5)
}
for (const filler of cornerWallFillers) {
expect(sceneApi.get<CabinetModuleNode>(filler.id as AnyNodeId)?.width).toBeCloseTo(0.32)
}
for (const filler of bridgeWallFillers) {
expect(sceneApi.get<CabinetModuleNode>(filler.id as AnyNodeId)?.width).toBeCloseTo(
bridgeWidths.get(filler.id)!,
)
}
for (const wallRun of wallRuns) {
const committedWorld = resolveCabinetWorldTransform(
sceneApi.get<CabinetNode>(wallRun.id)!,
sceneApi.nodes() as Record<AnyNodeId, AnyNode>,
)
const originalWorld = wallRunWorldPositions.get(wallRun.id)!
expect(committedWorld.position[0]).toBeCloseTo(originalWorld[0])
expect(committedWorld.position[2]).toBeCloseTo(originalWorld[2])
}
for (const wallCabinet of connectedWallCabinets) {
const liveWall = sceneApi.get<CabinetModuleNode>(wallCabinet.id as AnyNodeId)!
sceneApi.update(liveWall.id as AnyNodeId, {
position: [0.05, liveWall.position[1], liveWall.position[2]],
})
}
const shrunkRun = { ...sceneApi.get<CabinetNode>(run.id)!, depth: 0.48 }
for (const [id, override] of backAlignedRunDepthOverrides(
sceneApi.get<CabinetNode>(run.id)!,
sceneApi.nodes(),
shrunkRun.depth,
)) {
sceneApi.update(id, override)
}
sceneApi.update(run.id as AnyNodeId, { depth: shrunkRun.depth })
syncCornerRunsFromRunSources({ baseLayout: 'width-only', run: shrunkRun, sceneApi })
for (const derivedRun of derivedBaseRuns) {
const derivedModules = derivedRun.children
.map((id) => sceneApi.get<CabinetModuleNode>(id as AnyNodeId))
.filter((module): module is CabinetModuleNode => module?.type === 'cabinet-module')
expect(derivedModules.find((module) => module.name === 'Corner Filler')?.width).toBeCloseTo(
0.48,
)
expect(derivedModules.find((module) => module.name === 'Base Cabinet')?.width).toBeCloseTo(
0.6,
)
const connectedBase = derivedModules.find((module) => module.name === 'Base Cabinet')!
expect(wallChildOf(connectedBase, sceneApi.nodes())?.width).toBeCloseTo(0.6)
}
for (const filler of cornerWallFillers) {
expect(sceneApi.get<CabinetModuleNode>(filler.id as AnyNodeId)?.width).toBeCloseTo(0.32)
}
for (const derivedRun of derivedBaseRuns) {
const side = (derivedRun.metadata as Record<string, { side?: 'left' | 'right' }> | null)
?.cabinetCornerDerivedRun?.side
const connectedBase = derivedRun.children
.map((id) => sceneApi.get<CabinetModuleNode>(id as AnyNodeId))
.find((module) => module?.type === 'cabinet-module' && module.name === 'Base Cabinet')!
const connectedWall = wallChildOf(connectedBase, sceneApi.nodes())!
const cornerWallId = cornerWallFillers.find((filler) => {
const parentRun = sceneApi.get<CabinetNode>(filler.parentId as AnyNodeId)
return (
(parentRun?.metadata as Record<string, { side?: 'left' | 'right' }> | null)
?.cabinetCornerDerivedRun?.side === side
)
})!.id
const liveConnectedWall = sceneApi.get<CabinetModuleNode>(connectedWall.id as AnyNodeId)!
const liveCornerWall = sceneApi.get<CabinetModuleNode>(cornerWallId as AnyNodeId)!
expect(liveConnectedWall.position[0]).toBeCloseTo(
(side === 'right' ? 1 : -1) * (liveCornerWall.width - 0.48),
)
const runWorld = resolveCabinetWorldTransform(
derivedRun,
sceneApi.nodes() as Record<AnyNodeId, AnyNode>,
)
const wallWorld = resolveCabinetWorldTransform(
liveConnectedWall,
sceneApi.nodes() as Record<AnyNodeId, AnyNode>,
)
const cornerWorld = resolveCabinetWorldTransform(
liveCornerWall,
sceneApi.nodes() as Record<AnyNodeId, AnyNode>,
)
const localX = (position: [number, number, number]) => {
const dx = position[0] - runWorld.position[0]
const dz = position[2] - runWorld.position[2]
return Math.cos(runWorld.rotation) * dx - Math.sin(runWorld.rotation) * dz
}
if (side === 'right') {
expect(localX(cornerWorld.position) + liveCornerWall.width / 2).toBeCloseTo(
localX(wallWorld.position) - liveConnectedWall.width / 2,
)
} else {
expect(localX(wallWorld.position) + liveConnectedWall.width / 2).toBeCloseTo(
localX(cornerWorld.position) - liveCornerWall.width / 2,
)
}
}
})
test('keeps both corner fillers linked when left and right start from one center module', () => {
const run = CabinetNode.parse({
id: 'cabinet_source-run-shared-corner-source',
depth: 0.58,
children: ['cabinet-module_shared-corner-source'],
})
const module = CabinetModuleNode.parse({
id: 'cabinet-module_shared-corner-source',
parentId: run.id,
position: [0, 0.1, 0],
width: 0.9,
depth: 0.58,
})
const sceneApi = sceneApiFixture([run as AnyNode, module as AnyNode])
addCornerRun({ module, run, sceneApi, side: 'left' })
addCornerRun({
module: sceneApi.get<CabinetModuleNode>(module.id)!,
run: sceneApi.get<CabinetNode>(run.id)!,
sceneApi,
side: 'right',
})
const nodesAfterAddition = sceneApi.nodes() as Record<AnyNodeId, AnyNode>
const liveSource = sceneApi.get<CabinetModuleNode>(module.id)!
const sourceWall = wallChildOf(liveSource, nodesAfterAddition)!
const sourceWallWorld = resolveCabinetWorldTransform(sourceWall, nodesAfterAddition)
const baseLegs = Object.values(nodesAfterAddition).filter(
(node): node is CabinetNode =>
node.type === 'cabinet' &&
(node.metadata as Record<string, { role?: string }> | null)?.cabinetCornerDerivedRun
?.role === 'base-leg',
)
expect(baseLegs).toHaveLength(2)
for (const baseLeg of baseLegs) {
const metadata = (baseLeg.metadata as Record<string, { side?: 'left' | 'right' }> | null)
?.cabinetCornerDerivedRun
const side = metadata?.side
expect(side).toBeDefined()
const baseLegWorld = resolveCabinetWorldTransform(baseLeg, nodesAfterAddition)
const sourceEdge = module.position[0] + (side === 'right' ? 1 : -1) * (module.width / 2)
const baseLegFrontEdge =
baseLegWorld.position[0] + (side === 'right' ? -1 : 1) * (baseLeg.depth / 2)
expect(baseLegFrontEdge).toBeCloseTo(sourceEdge)
const cornerWallFiller = Object.values(nodesAfterAddition).find(
(node): node is CabinetModuleNode => {
if (node.type !== 'cabinet-module' || node.name !== 'Corner Wall Filler') return false
const parentRun = nodesAfterAddition[node.parentId as AnyNodeId]
return (
parentRun?.type === 'cabinet' &&
(parentRun.metadata as Record<string, { side?: 'left' | 'right' }> | null)
?.cabinetCornerDerivedRun?.side === side
)
},
)!
const bridgeFiller = Object.values(nodesAfterAddition).find(
(node): node is CabinetModuleNode => {
if (node.type !== 'cabinet-module' || node.name !== 'Wall Bridge Filler') return false
const parentRun = nodesAfterAddition[node.parentId as AnyNodeId]
return (
parentRun?.type === 'cabinet' &&
(parentRun.metadata as Record<string, { side?: 'left' | 'right' }> | null)
?.cabinetCornerDerivedRun?.side === side
)
},
)!
const cornerWallWorld = resolveCabinetWorldTransform(cornerWallFiller, nodesAfterAddition)
const bridgeWorld = resolveCabinetWorldTransform(bridgeFiller, nodesAfterAddition)
const sourceWallEdge =
sourceWallWorld.position[0] + (side === 'right' ? 1 : -1) * (sourceWall.width / 2)
const bridgeSourceEdge =
bridgeWorld.position[0] + (side === 'right' ? -1 : 1) * (bridgeFiller.width / 2)
const bridgeOuterEdge =
bridgeWorld.position[0] + (side === 'right' ? 1 : -1) * (bridgeFiller.width / 2)
const cornerWallFrontEdge =
cornerWallWorld.position[0] + (side === 'right' ? -1 : 1) * (cornerWallFiller.depth / 2)
expect(bridgeSourceEdge).toBeCloseTo(sourceWallEdge)
expect(bridgeOuterEdge).toBeCloseTo(cornerWallFrontEdge)
}
const sourceLink = (
sceneApi.get<CabinetModuleNode>(module.id)?.metadata as Record<string, unknown>
).cabinetCornerSourceLink as { linkedRunIds: AnyNodeId[] }
expect(sourceLink.linkedRunIds).toHaveLength(6)
const resizedRun = { ...sceneApi.get<CabinetNode>(run.id)!, depth: 0.78 }
for (const [id, override] of backAlignedRunDepthOverrides(
sceneApi.get<CabinetNode>(run.id)!,
sceneApi.nodes(),
resizedRun.depth,
)) {
sceneApi.update(id, override)
}
sceneApi.update(run.id as AnyNodeId, { depth: resizedRun.depth })
syncCornerRunsFromRunSources({ baseLayout: 'width-only', run: resizedRun, sceneApi })
const baseFillers = Object.values(sceneApi.nodes()).filter(
(node): node is CabinetModuleNode =>
node.type === 'cabinet-module' && node.name === 'Corner Filler',
)
expect(baseFillers).toHaveLength(2)
expect(baseFillers.every((filler) => Math.abs(filler.width - 0.78) < 1e-6)).toBe(true)
})
test('keeps a chained right corner attached when the upstream run changes depth', () => {
const run = CabinetNode.parse({
id: 'cabinet_source-run-chained-depth',
depth: 0.58,
children: ['cabinet-module_source-chained-depth'],
})
const module = CabinetModuleNode.parse({
id: 'cabinet-module_source-chained-depth',
parentId: run.id,
position: [0, 0.1, 0],
width: 0.9,
depth: 0.58,
})
const sceneApi = sceneApiFixture([run as AnyNode, module as AnyNode])
const middleModuleId = addCornerRun({ module, run, sceneApi, side: 'right' })!
const middleModule = sceneApi.get<CabinetModuleNode>(middleModuleId)!
const middleRun = sceneApi.get<CabinetNode>(middleModule.parentId as AnyNodeId)!
const thirdModuleId = addCornerRun({
module: middleModule,
run: middleRun,
sceneApi,
side: 'right',
})!
const thirdModule = sceneApi.get<CabinetModuleNode>(thirdModuleId)!
const thirdRun = sceneApi.get<CabinetNode>(thirdModule.parentId as AnyNodeId)!
const initialMiddleX = middleModule.position[0]
const initialMiddleWidth = middleModule.width
const initialMiddleRightEdge = middleModule.position[0] + middleModule.width / 2
const initialThirdRunX = thirdRun.position[0]
const resizedRun = { ...sceneApi.get<CabinetNode>(run.id)!, depth: 0.78 }
for (const [id, override] of backAlignedRunDepthOverrides(
resizedRun,
sceneApi.nodes(),
resizedRun.depth,
)) {
sceneApi.update(id, override)
}
sceneApi.update(run.id as AnyNodeId, { depth: resizedRun.depth })
syncCornerRunsFromRunSources({ baseLayout: 'width-only', run: resizedRun, sceneApi })
const resizedMiddle = sceneApi.get<CabinetModuleNode>(middleModule.id)!
const resizedThirdRun = sceneApi.get<CabinetNode>(thirdRun.id)!
const moduleShift = resizedMiddle.position[0] - initialMiddleX
const runShift = resizedThirdRun.position[0] - initialThirdRunX
expect(resizedMiddle.width).toBeCloseTo(initialMiddleWidth)
expect(resizedMiddle.position[0] + resizedMiddle.width / 2).toBeCloseTo(
initialMiddleRightEdge + 0.2,
)
expect(moduleShift).toBeCloseTo(0.2)
expect(runShift).toBeCloseTo(0.2)
})
test('opposite-turn depth growth resizes the cabinet in front instead of the one behind', () => {
const run = CabinetNode.parse({
id: 'cabinet_source-run-opposite-turn-depth',
depth: 0.58,
children: ['cabinet-module_source-opposite-turn-depth'],
})
const source = CabinetModuleNode.parse({
id: 'cabinet-module_source-opposite-turn-depth',
parentId: run.id,
position: [0, 0.1, 0],
width: 0.9,
depth: 0.58,
})
const sceneApi = sceneApiFixture([run as AnyNode, source as AnyNode])
const firstSelectedId = addCornerRun({ module: source, run, sceneApi, side: 'right' })!
const firstSelected = sceneApi.get<CabinetModuleNode>(firstSelectedId)!
const firstRun = sceneApi.get<CabinetNode>(firstSelected.parentId as AnyNodeId)!
const extendedId = addCabinetModuleSide({
anchorModule: firstSelected,
run: firstRun,
sceneApi,
side: 'right',
})!
const behind = sceneApi.get<CabinetModuleNode>(extendedId)!
const targetSelectedId = addCornerRun({
module: behind,
run: firstRun,
sceneApi,
side: 'left',
})!
const targetSelected = sceneApi.get<CabinetModuleNode>(targetSelectedId)!
const targetRun = sceneApi.get<CabinetNode>(targetSelected.parentId as AnyNodeId)!
const frontSelectedId = addCornerRun({
module: targetSelected,
run: targetRun,
sceneApi,
side: 'right',
})!
const front = sceneApi.get<CabinetModuleNode>(frontSelectedId)!
const initialBehindWidth = behind.width
const initialFrontWidth = front.width
const initialTargetDepth = targetRun.depth
const initialBack = Math.min(
...targetRun.children
.map((id) => sceneApi.get<CabinetModuleNode>(id as AnyNodeId))
.filter((module): module is CabinetModuleNode => module?.type === 'cabinet-module')
.map((module) => module.position[2] - module.depth / 2),
)
const depth = 0.68
for (const [id, override] of cornerSourceWidthOverridesForDerivedDepth(
targetRun,
sceneApi.nodes(),
depth,
)) {
sceneApi.update(id, override)
}
for (const [id, override] of backAlignedRunDepthOverrides(targetRun, sceneApi.nodes(), depth)) {
sceneApi.update(id, override)
}
sceneApi.update(targetRun.id as AnyNodeId, { depth })
syncCornerRunsFromRunSources({
baseLayout: 'width-only',
run: { ...targetRun, depth },
sceneApi,
})
expect(sceneApi.get<CabinetModuleNode>(behind.id)?.width).toBeCloseTo(initialBehindWidth)
expect(sceneApi.get<CabinetModuleNode>(front.id)?.width).toBeCloseTo(
initialFrontWidth - (depth - initialTargetDepth),
)
const resizedBack = Math.min(
...targetRun.children
.map((id) => sceneApi.get<CabinetModuleNode>(id as AnyNodeId))
.filter((module): module is CabinetModuleNode => module?.type === 'cabinet-module')
.map((module) => module.position[2] - module.depth / 2),
)
expect(resizedBack).toBeCloseTo(initialBack)
})
test.each([
'left',
'right',
] as const)('%s leg depth resizes its center-run source cabinet from the outer edge', (side) => {
const run = CabinetNode.parse({
id: `cabinet_source-run-upstream-${side}`,
depth: 0.58,
children: [`cabinet-module_source-upstream-${side}`],
})
const module = CabinetModuleNode.parse({
id: `cabinet-module_source-upstream-${side}`,
parentId: run.id,
position: [0, 0.1, 0],
width: 0.9,
depth: 0.58,
})
const sceneApi = sceneApiFixture([run as AnyNode, module as AnyNode])
const selectedId = addCornerRun({ module, run, sceneApi, side })!
const selectedModule = sceneApi.get<CabinetModuleNode>(selectedId)!
let leg = sceneApi.get<CabinetNode>(selectedModule.parentId as AnyNodeId)!
const initialLegDepth = leg.depth
const originalInnerEdge =
side === 'left'
? module.position[0] + module.width / 2
: module.position[0] - module.width / 2
const initialSource = sceneApi.get<CabinetModuleNode>(module.id)!
const initialWall = wallChildOf(initialSource, sceneApi.nodes())!
const initialBridge = Object.values(sceneApi.nodes()).find(
(node): node is CabinetModuleNode =>
node.type === 'cabinet-module' && node.name === 'Wall Bridge Filler',
)!
const initialCornerWallFiller = Object.values(sceneApi.nodes()).find(
(node): node is CabinetModuleNode =>
node.type === 'cabinet-module' && node.name === 'Corner Wall Filler',
)!
const originalCornerWallPosition = resolveCabinetWorldTransform(
initialCornerWallFiller,
sceneApi.nodes() as Record<AnyNodeId, AnyNode>,
).position
const initialBridgeWorld = resolveCabinetWorldTransform(
initialBridge,
sceneApi.nodes() as Record<AnyNodeId, AnyNode>,
)
const bridgeOuterDirection = side === 'right' ? 1 : -1
const originalBridgeOuterEdge = [
initialBridgeWorld.position[0] +
bridgeOuterDirection * Math.cos(initialBridgeWorld.rotation) * (initialBridge.width / 2),
initialBridgeWorld.position[2] -
bridgeOuterDirection * Math.sin(initialBridgeWorld.rotation) * (initialBridge.width / 2),
]
sceneApi.update(initialWall.id as AnyNodeId, {
position: [initialWall.position[0], initialWall.position[1], initialWall.position[2] + 0.04],
})
for (const depth of [0.78, 0.48]) {
const overrides = previewCornerRunsFromRunSources({
baseLayout: 'width-only',
initialOverrides: [
...backAlignedRunDepthOverrides(leg, sceneApi.nodes(), depth),
...cornerSourceWidthOverridesForDerivedDepth(leg, sceneApi.nodes(), depth),
],
run: { ...leg, depth },
sceneApi,
})
for (const [id, override] of overrides) sceneApi.update(id, override)
sceneApi.update(leg.id as AnyNodeId, { depth })
leg = sceneApi.get<CabinetNode>(leg.id)!
const source = sceneApi.get<CabinetModuleNode>(module.id)!
const expectedWidth = 0.9 - (depth - initialLegDepth)
const innerEdge =
side === 'left'
? source.position[0] + source.width / 2
: source.position[0] - source.width / 2
expect(source.width).toBeCloseTo(expectedWidth)
expect(innerEdge).toBeCloseTo(originalInnerEdge)
const wall = wallChildOf(source, sceneApi.nodes())!
expect(wall.width).toBeCloseTo(expectedWidth)
expect(source.position[2] + wall.position[2] - wall.depth / 2).toBeCloseTo(
source.position[2] - source.depth / 2,
)
const bridge = sceneApi.get<CabinetModuleNode>(initialBridge.id)!
expect(bridge.width).toBeCloseTo(initialBridge.width + (depth - initialLegDepth))
const bridgeWorld = resolveCabinetWorldTransform(
bridge,
sceneApi.nodes() as Record<AnyNodeId, AnyNode>,
)
const bridgeOuterEdge = [
bridgeWorld.position[0] +
bridgeOuterDirection * Math.cos(bridgeWorld.rotation) * (bridge.width / 2),
bridgeWorld.position[2] -
bridgeOuterDirection * Math.sin(bridgeWorld.rotation) * (bridge.width / 2),
]
expect(bridgeOuterEdge[0]).toBeCloseTo(originalBridgeOuterEdge[0]!)
expect(bridgeOuterEdge[1]).toBeCloseTo(originalBridgeOuterEdge[1]!)
const cornerWallPosition = resolveCabinetWorldTransform(
sceneApi.get<CabinetModuleNode>(initialCornerWallFiller.id)!,
sceneApi.nodes() as Record<AnyNodeId, AnyNode>,
).position
expect(cornerWallPosition[0]).toBeCloseTo(originalCornerWallPosition[0])
expect(cornerWallPosition[2]).toBeCloseTo(originalCornerWallPosition[2])
}
})
test('propagates front styling into linked runs even when the corner re-layout bails', () => {
const levelId = 'level_corner-style-layout-bail' as AnyNodeId
const run = CabinetNode.parse({
@@ -786,6 +1480,72 @@ describe('addCornerRun', () => {
expect(allCabinets.every((node) => node.frontStyle === 'raised-arch')).toBe(true)
})
test.each([
'left',
'right',
] as const)('%s corner filler resizes without changing connected cabinet widths', (side) => {
const run = CabinetNode.parse({
id: `cabinet_source-run-extended-depth-${side}`,
depth: 0.58,
children: [`cabinet-module_source-extended-depth-${side}`],
})
const module = CabinetModuleNode.parse({
id: `cabinet-module_source-extended-depth-${side}`,
parentId: run.id,
position: [0, 0.1, 0],
width: 0.9,
depth: 0.58,
})
const sceneApi = sceneApiFixture([run as AnyNode, module as AnyNode])
const connectedId = addCornerRun({ module, run, sceneApi, side })!
const connected = sceneApi.get<CabinetModuleNode>(connectedId)!
const leg = sceneApi.get<CabinetNode>(connected.parentId as AnyNodeId)!
const extraId = addCabinetModuleSide({
anchorModule: connected,
run: leg,
sceneApi,
side,
})!
const initialExtra = sceneApi.get<CabinetModuleNode>(extraId)!
const initialLegModules = cabinetModulesForRun(leg, sceneApi.nodes())
const initialFiller = initialLegModules.find((entry) => entry.name === 'Corner Filler')!
const initialConnected = initialLegModules.find((entry) => entry.name === 'Base Cabinet')!
const initialConnectedWidth = initialConnected.width
for (const depth of [0.48, 0.68]) {
const resizedRun = { ...sceneApi.get<CabinetNode>(run.id)!, depth }
for (const [id, override] of backAlignedRunDepthOverrides(
sceneApi.get<CabinetNode>(run.id)!,
sceneApi.nodes(),
depth,
)) {
sceneApi.update(id, override)
}
sceneApi.update(run.id as AnyNodeId, { depth })
syncCornerRunsFromRunSources({ baseLayout: 'width-only', run: resizedRun, sceneApi })
const liveLeg = sceneApi.get<CabinetNode>(leg.id)!
const liveModules = cabinetModulesForRun(liveLeg, sceneApi.nodes()).sort(
(a, b) => a.position[0] - b.position[0],
)
const filler = liveModules.find((entry) => entry.name === 'Corner Filler')!
const liveConnected = liveModules.find((entry) => entry.name === 'Base Cabinet')!
const liveExtra = sceneApi.get<CabinetModuleNode>(extraId)!
expect(filler.width).toBeCloseTo(depth)
expect(liveConnected.width).toBeCloseTo(initialConnectedWidth)
expect(wallChildOf(liveConnected, sceneApi.nodes())?.width).toBeCloseTo(initialConnectedWidth)
expect(liveExtra.width).toBeCloseTo(initialExtra.width)
for (let index = 1; index < liveModules.length; index++) {
const previous = liveModules[index - 1]!
const current = liveModules[index]!
expect(previous.position[0] + previous.width / 2).toBeCloseTo(
current.position[0] - current.width / 2,
)
}
}
})
test('anchors the right bridge filler to the live source wall cabinet edge', () => {
const levelId = 'level_corner-bridge-anchor-right' as AnyNodeId
const run = CabinetNode.parse({
@@ -1205,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.26)
expect(bridgeFillers[0]?.width).toBeCloseTo(0.5 - 0.32)
const linkedBase = modulesOut.find(
(node) => node.id !== module.id && node.name === 'Base Cabinet',
@@ -1695,8 +2455,8 @@ describe('addCornerRun', () => {
const blockingWall = WallNode.parse({
id: 'wall_corner-too-close',
parentId: levelId,
start: [-1, 0.65],
end: [2, 0.65],
start: [-1, 0.55],
end: [2, 0.55],
thickness: 0.2,
})
const sceneApi = sceneApiFixture([run as AnyNode, module as AnyNode, blockingWall as AnyNode])
@@ -0,0 +1,61 @@
import { expect, test } from 'bun:test'
import type { AnyNode, GeometryContext } from '@pascal-app/core'
import { getRunSpanEnds, getRunSpans } from '../run-layout'
import { CabinetModuleNode, CabinetNode } from '../schema'
test('run surface spans follow each cabinet module depth independently', () => {
const run = CabinetNode.parse({
id: 'cabinet_individual-surfaces',
children: ['cabinet-module_shallow', 'cabinet-module_deep'],
showPlinth: true,
withCountertop: true,
})
const shallow = CabinetModuleNode.parse({
id: 'cabinet-module_shallow',
parentId: run.id,
cabinetType: 'base',
position: [-0.3, run.plinthHeight, 0.25],
width: 0.6,
depth: 0.5,
})
const deep = CabinetModuleNode.parse({
id: 'cabinet-module_deep',
parentId: run.id,
cabinetType: 'base',
position: [0.3, run.plinthHeight, 0.35],
width: 0.6,
depth: 0.7,
})
const spans = getRunSpans([shallow, deep], { runTier: run.runTier })
const children = [shallow, deep] as AnyNode[]
const context: GeometryContext = {
children,
parent: null,
resolve: (id) => children.find((node) => node.id === id) as never,
siblings: [],
}
const ends = getRunSpanEnds(run, context, spans)
expect(spans).toHaveLength(2)
expect(spans[0]!.minZ).toBeCloseTo(0)
expect(spans[0]!.maxZ).toBeCloseTo(0.5)
expect(spans[1]!.minZ).toBeCloseTo(0)
expect(spans[1]!.maxZ).toBeCloseTo(0.7)
expect(ends[0]!.rightOverhang).toBe(0)
expect(ends[1]!.leftOverhang).toBe(0)
})
test('equal-depth adjacent cabinets keep one continuous surface span', () => {
const left = CabinetModuleNode.parse({
position: [-0.3, 0.1, 0],
width: 0.6,
depth: 0.58,
})
const right = CabinetModuleNode.parse({
position: [0.3, 0.1, 0],
width: 0.6,
depth: 0.58,
})
expect(getRunSpans([left, right])).toHaveLength(1)
})
@@ -476,6 +476,63 @@ describe('reflowCabinetRunModules', () => {
expect(reflowed[0]!.position[1]).toBeCloseTo(0.1)
expect(reflowed[2]!.position[1]).toBeCloseTo(0.1)
})
test('fits a wider preset inside the existing run by reducing adjacent modules', () => {
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,
})
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', () => {
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 },
{ id: 'right', position: [0.45, 0.1, 0] as [number, number, number], width: 0.4 },
]
const reflowed = reflowCabinetRunModules(modules, 'middle', 0.75, {
preserveExtent: true,
})
expect(reflowed[0]!.width).toBeCloseTo(0.45)
expect(reflowed[1]!.width).toBeCloseTo(0.75)
expect(reflowed[2]!.width).toBeCloseTo(0.4)
})
test('restores the exact donor widths when a wider preset switches back', () => {
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 },
{ id: 'right', position: [0.45, 0.1, 0] as [number, number, number], width: 0.4 },
]
const widened = reflowCabinetRunModules(modules, 'middle', 0.75, {
preserveExtent: true,
})
const restorableWidthById = new Map(
modules.map((module, index) => [module.id, module.width - widened[index]!.width]),
)
const restored = reflowCabinetRunModules(widened, 'middle', 0.5, {
preserveExtent: true,
restorableWidthById,
})
expect(restored.map((module) => module.width)).toEqual([0.7, 0.5, 0.4])
expect(restored[0]!.position[0] - restored[0]!.width / 2).toBeCloseTo(-0.95)
expect(restored[2]!.position[0] + restored[2]!.width / 2).toBeCloseTo(0.65)
})
})
describe('backAnchoredModuleZ', () => {
@@ -0,0 +1,216 @@
import { describe, expect, test } from 'bun:test'
import type {
AnyNode,
AnyNodeId,
CabinetModuleNode as CabinetModuleNodeType,
} from '@pascal-app/core'
import { buildWallCornerDepthIndex, wallCornerWidthOverridesForDepthTargets } from '../run-ops'
import { CabinetModuleNode, CabinetNode } from '../schema'
function derivedMetadata(
role: 'base-leg' | 'wall-leg' | 'bridge',
side: 'left' | 'right',
sourceModuleId: AnyNodeId,
sourceRunId: AnyNodeId,
) {
return {
cabinetCornerDerivedRun: { role, side, turnSide: side, sourceModuleId, sourceRunId },
}
}
describe('wall depth corner companions', () => {
test('resizes bridge fillers without exchanging corner wall widths', () => {
const sourceRunA = CabinetNode.parse({ id: 'cabinet_wall-depth-source-a', depth: 0.58 })
const sourceA = CabinetModuleNode.parse({
id: 'cabinet-module_wall-depth-source-a',
parentId: sourceRunA.id,
children: ['cabinet-module_wall-depth-a'],
})
const wallA = CabinetModuleNode.parse({
id: 'cabinet-module_wall-depth-a',
parentId: sourceA.id,
name: 'Wall Cabinet',
width: 0.5,
depth: 0.32,
})
const baseLegB = CabinetNode.parse({
id: 'cabinet_wall-depth-base-leg-b',
depth: 0.68,
metadata: derivedMetadata('base-leg', 'right', sourceA.id, sourceRunA.id),
children: ['cabinet-module_wall-depth-source-b'],
})
const sourceB = CabinetModuleNode.parse({
id: 'cabinet-module_wall-depth-source-b',
parentId: baseLegB.id,
name: 'Base Cabinet',
children: ['cabinet-module_wall-depth-b'],
})
const wallB = CabinetModuleNode.parse({
id: 'cabinet-module_wall-depth-b',
parentId: sourceB.id,
name: 'Wall Cabinet',
width: 0.5,
depth: 0.32,
})
const bridgeA = CabinetNode.parse({
id: 'cabinet_wall-depth-bridge-a',
runTier: 'wall',
metadata: derivedMetadata('bridge', 'right', sourceA.id, sourceRunA.id),
children: ['cabinet-module_wall-depth-bridge-filler-a'],
})
const bridgeFillerA = CabinetModuleNode.parse({
id: 'cabinet-module_wall-depth-bridge-filler-a',
parentId: bridgeA.id,
name: 'Wall Bridge Filler',
width: 0.36,
openSide: 'left',
})
const wallLegB = CabinetNode.parse({
id: 'cabinet_wall-depth-wall-leg-b',
runTier: 'wall',
metadata: derivedMetadata('wall-leg', 'right', sourceA.id, sourceRunA.id),
children: ['cabinet-module_wall-depth-corner-filler-b'],
})
const cornerFillerB = CabinetModuleNode.parse({
id: 'cabinet-module_wall-depth-corner-filler-b',
parentId: wallLegB.id,
name: 'Corner Wall Filler',
width: 0.58,
})
const baseLegC = CabinetNode.parse({
id: 'cabinet_wall-depth-base-leg-c',
metadata: derivedMetadata('base-leg', 'left', sourceB.id, baseLegB.id),
children: ['cabinet-module_wall-depth-source-c'],
})
const sourceC = CabinetModuleNode.parse({
id: 'cabinet-module_wall-depth-source-c',
parentId: baseLegC.id,
name: 'Base Cabinet',
children: ['cabinet-module_wall-depth-c'],
})
const wallC = CabinetModuleNode.parse({
id: 'cabinet-module_wall-depth-c',
parentId: sourceC.id,
name: 'Wall Cabinet',
width: 0.5,
depth: 0.32,
})
const bridgeB = CabinetNode.parse({
id: 'cabinet_wall-depth-bridge-b',
runTier: 'wall',
metadata: derivedMetadata('bridge', 'right', sourceB.id, baseLegB.id),
children: ['cabinet-module_wall-depth-bridge-filler-b'],
})
const bridgeFillerB = CabinetModuleNode.parse({
id: 'cabinet-module_wall-depth-bridge-filler-b',
parentId: bridgeB.id,
name: 'Wall Bridge Filler',
width: 0.36,
openSide: 'right',
})
const wallLegC = CabinetNode.parse({
id: 'cabinet_wall-depth-wall-leg-c',
runTier: 'wall',
metadata: derivedMetadata('wall-leg', 'right', sourceB.id, baseLegB.id),
children: ['cabinet-module_wall-depth-corner-filler-c'],
})
const cornerFillerC = CabinetModuleNode.parse({
id: 'cabinet-module_wall-depth-corner-filler-c',
parentId: wallLegC.id,
name: 'Corner Wall Filler',
width: 0.58,
})
const allNodes = [
sourceRunA,
sourceA,
wallA,
baseLegB,
sourceB,
wallB,
bridgeA,
bridgeFillerA,
wallLegB,
cornerFillerB,
baseLegC,
sourceC,
wallC,
bridgeB,
bridgeFillerB,
wallLegC,
cornerFillerC,
]
const nodes = Object.fromEntries(
allNodes.map((node) => [node.id as AnyNodeId, node as AnyNode]),
) as Record<AnyNodeId, AnyNode>
const overrides = new Map(
wallCornerWidthOverridesForDepthTargets({
depth: 0.42,
nodes,
targets: [wallB, wallLegB, bridgeB],
}),
)
const patch = (node: CabinetModuleNodeType) => overrides.get(node.id as AnyNodeId)
const runPatch = (node: AnyNode) => overrides.get(node.id as AnyNodeId)
expect(patch(bridgeFillerA)?.width).toBeCloseTo(0.26)
expect(patch(wallA)).toBeUndefined()
expect(patch(cornerFillerC)).toBeUndefined()
expect(patch(wallC)).toBeUndefined()
expect(patch(cornerFillerB)).toBeUndefined()
expect(patch(bridgeFillerB)?.width).toBeCloseTo(0.26)
expect(patch(wallB)).toBeUndefined()
expect(patch(bridgeFillerA)?.position?.[0]).toBeCloseTo(0)
expect(patch(bridgeFillerB)?.position?.[0]).toBeCloseTo(0)
expect(runPatch(bridgeA)?.position?.[0]).toBeCloseTo(0.38)
expect(runPatch(bridgeB)?.position?.[0]).toBeCloseTo(-0.38)
const cornerIndex = buildWallCornerDepthIndex(nodes)
const indexedNodes = new Proxy(nodes, {
ownKeys: () => {
throw new Error('live depth preview must not rescan the cabinet graph')
},
})
const indexedOverrides = new Map(
wallCornerWidthOverridesForDepthTargets({
cornerIndex,
depth: 0.42,
nodes: indexedNodes,
targets: [wallB, wallLegB, bridgeB],
}),
)
expect(indexedOverrides.get(bridgeFillerA.id as AnyNodeId)?.width).toBeCloseTo(0.26)
expect(indexedOverrides.get(bridgeFillerB.id as AnyNodeId)?.width).toBeCloseTo(0.26)
expect(indexedOverrides.get(cornerFillerB.id as AnyNodeId)).toBeUndefined()
expect(indexedOverrides.get(wallB.id as AnyNodeId)).toBeUndefined()
const rightSideOverrides = new Map(
wallCornerWidthOverridesForDepthTargets({
depth: 0.42,
nodes,
targets: [wallA, bridgeA],
}),
)
expect(
(rightSideOverrides.get(bridgeFillerA.id as AnyNodeId) as Partial<CabinetModuleNodeType>)
?.width,
).toBeCloseTo(0.16)
expect(rightSideOverrides.get(wallA.id as AnyNodeId)).toBeUndefined()
const endpointOverrides = new Map(
wallCornerWidthOverridesForDepthTargets({
depth: 0.72,
nodes,
targets: [wallB, wallLegB, bridgeB],
}),
)
const endpointPatch = (node: CabinetModuleNodeType) =>
endpointOverrides.get(node.id as AnyNodeId) as Partial<CabinetModuleNodeType> | undefined
expect(endpointPatch(bridgeFillerA)?.width).toBe(0)
expect(endpointPatch(bridgeFillerB)?.width).toBe(0)
expect(endpointPatch(bridgeFillerA)!.position![0]).toBeCloseTo(0)
expect(endpointPatch(bridgeFillerB)!.position![0]).toBeCloseTo(0)
expect(endpointOverrides.get(bridgeA.id as AnyNodeId)?.position?.[0]).toBeCloseTo(0.25)
expect(endpointOverrides.get(bridgeB.id as AnyNodeId)?.position?.[0]).toBeCloseTo(-0.25)
})
})
File diff suppressed because it is too large Load Diff
@@ -44,7 +44,7 @@ describe('resolveCabinetWallSnapPlacement', () => {
expect(placement!.yaw).toBeCloseTo(0)
})
test('snaps along the wall axis when grid snap is active', () => {
test('snaps a footprint edge along the wall axis when grid snap is active', () => {
const placement = resolveCabinetWallSnapPlacement({
depth: 0.58,
gridStep: 0.5,
@@ -53,8 +53,9 @@ describe('resolveCabinetWallSnapPlacement', () => {
})
expect(placement).not.toBeNull()
expect(placement!.localX).toBeCloseTo(0.5)
expect(placement!.position[0]).toBeCloseTo(0.5)
expect(placement!.localX).toBeCloseTo(0.8)
expect(placement!.position[0]).toBeCloseTo(0.8)
expect(placement!.localX - 0.6 / 2).toBeCloseTo(0.5)
})
test('clamps the cabinet center so its edges stay inside the wall span', () => {
@@ -466,7 +467,8 @@ describe('resolveCabinetRunWallSnap', () => {
})
expect(snapped).not.toBeNull()
expect(snapped![0]).toBeCloseTo(1)
expect(snapped![0]).toBeCloseTo(1.45)
expect(snapped![0] - movingModule.width / 2).toBeCloseTo(1)
expect(snapped![2]).toBeCloseTo(0.39)
})
File diff suppressed because it is too large Load Diff
+4
View File
@@ -31,6 +31,7 @@ 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,
@@ -45,6 +46,9 @@ export function buildCabinetGeometry(
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)
+4 -4
View File
@@ -15,13 +15,13 @@ const GUIDE_EPSILON_M = 1e-4
type PlanTransform = { position: [number, number, number]; rotation: number }
type PlanPoint = { x: number; z: number }
function runParent(
function frameParent(
node: AnyNode,
nodes: Readonly<Record<string, AnyNode>>,
): CabinetNodeType | null {
): CabinetNodeType | CabinetModuleNodeType | null {
if (node.type !== 'cabinet-module' || !node.parentId) return null
const parent = nodes[node.parentId]
return parent?.type === 'cabinet' ? (parent as CabinetNodeType) : null
return isCabinetFrameNode(parent) ? parent : null
}
function isCabinetFrameNode(
@@ -295,7 +295,7 @@ function magneticSnapMatches(
}
export const cabinetModuleParentFrame: MovableParentFrame = {
resolveParent: runParent,
resolveParent: frameParent,
parentRotationY: (parent, nodes) =>
frameWorldTransform(parent as CabinetNodeType, nodes).rotation,
localToPlan,
+1
View File
@@ -387,6 +387,7 @@ export default function CabinetPanel() {
modules,
parentRun,
patch: nextPatch,
preserveExtent: true,
scene,
selected: node,
})
@@ -0,0 +1,29 @@
export function snapCabinetFootprintCenter(value: number, extent: number, step: number): number {
if (step <= 0) return value
const halfExtent = extent / 2
const offset = ((halfExtent % step) + step) % step
return Math.round((value - offset) / step) * step + offset
}
export function resolveCabinetGridPosition({
raw,
dimensions,
yaw,
step,
}: {
raw: [number, number, number]
dimensions: [number, number, number]
yaw: number
step: number
}): [number, number, number] {
if (step <= 0) return [raw[0], 0, raw[2]]
const swapAxes = Math.abs(Math.sin(yaw)) > 0.9
const extentX = swapAxes ? dimensions[2] : dimensions[0]
const extentZ = swapAxes ? dimensions[0] : dimensions[2]
return [
snapCabinetFootprintCenter(raw[0], extentX, step),
0,
snapCabinetFootprintCenter(raw[2], extentZ, step),
]
}
+8 -8
View File
@@ -32,7 +32,7 @@ export type CabinetPreset = {
const baseShared = (run?: CabinetNode): Partial<CabinetModuleNode> => ({
cabinetType: 'base',
depth: run?.depth ?? 0.58,
depth: run?.depth ?? 0.5,
carcassHeight: run?.carcassHeight ?? 0.72,
plinthHeight: run?.plinthHeight ?? 0.1,
toeKickDepth: run?.toeKickDepth ?? 0.075,
@@ -42,7 +42,7 @@ const baseShared = (run?: CabinetNode): Partial<CabinetModuleNode> => ({
withCountertop: false,
})
const runDepth = (run?: CabinetNode) => run?.depth ?? 0.58
const runDepth = (run?: CabinetNode) => run?.depth ?? 0.5
export const CABINET_PRESETS: CabinetPreset[] = [
{
@@ -51,10 +51,10 @@ export const CABINET_PRESETS: CabinetPreset[] = [
createPatch: (run) => ({
...baseShared(run),
name: 'Base Cabinet',
width: 0.6,
width: 0.5,
handleStyle: 'bar',
handlePosition: 'auto',
frontOverlay: 'inset',
frontOverlay: 'full',
stack: [
{ ...newCabinetCompartment('drawer'), height: 0.44, drawerCount: 3 },
{ ...newCabinetCompartment('door'), doorType: 'double', shelfCount: 2 },
@@ -67,7 +67,7 @@ export const CABINET_PRESETS: CabinetPreset[] = [
createPatch: (run) => ({
...baseShared(run),
name: 'Drawer Base',
width: 0.6,
width: 0.5,
handleStyle: 'bar',
handlePosition: 'top',
frontOverlay: 'full',
@@ -133,8 +133,8 @@ export const CABINET_PRESETS: CabinetPreset[] = [
createPatch: (run) => ({
cabinetType: 'tall',
name: 'Tall Pantry',
width: 0.6,
depth: run?.depth ?? 0.58,
width: 0.5,
depth: run?.depth ?? 0.5,
carcassHeight: 2.07,
plinthHeight: 0.1,
toeKickDepth: 0.075,
@@ -155,7 +155,7 @@ export const CABINET_PRESETS: CabinetPreset[] = [
cabinetType: 'tall',
name: 'Oven Tower',
width: MICROWAVE_STANDARD_WIDTH,
depth: run?.depth ?? 0.58,
depth: run?.depth ?? 0.5,
carcassHeight: 2.07,
plinthHeight: 0.1,
toeKickDepth: 0.075,
+10 -2
View File
@@ -19,6 +19,7 @@ import {
resolveCabinetType,
switchCabinetToBase,
switchCabinetToTall,
wallChildAdditionOverlaps,
wallChildOf,
} from './run-ops'
@@ -91,6 +92,10 @@ export function cabinetQuickActions({
context.module && standardModule && selectedCabinetType === 'base'
? Boolean(wallChildOf(context.module, nodes))
: false
const wallAdditionBlocked =
context.module && standardModule && selectedCabinetType === 'base'
? wallChildAdditionOverlaps(context.module, context.run, nodes)
: false
const runModules = cabinetModulesForRun(context.run, nodes)
const leftCornerModule =
context.module && standardModule && selectedCabinetType === 'base'
@@ -210,9 +215,12 @@ export function cabinetQuickActions({
label: 'Wall',
title: hasWallCabinet
? 'A wall cabinet already exists above this cabinet'
: 'Add wall cabinet above',
: wallAdditionBlocked
? 'No space above—overlaps an existing wall cabinet'
: 'Add wall cabinet above',
icon: cabinetWallIcon,
disabled: hasWallCabinet,
disabled: hasWallCabinet || wallAdditionBlocked,
blockedFeedback: !hasWallCabinet && wallAdditionBlocked ? true : undefined,
run: ({ sceneApi }) => {
const id = addWallChildAbove({
kind: 'cabinet',
@@ -0,0 +1,31 @@
export const MIN_CABINET_WIDTH = 0.3
export const MIN_CABINET_DEPTH = 0.3
export const MAX_CABINET_WIDTH = 1.2
export const MAX_CABINET_DEPTH = 0.8
export function cabinetResizeUpperBound(currentValue: number, limit: number) {
return Math.max(currentValue, limit)
}
export function connectedCabinetDepthUpperBound(currentDepth: number, sourceWidth?: number) {
return cabinetConnectedDepthBounds(
currentDepth,
typeof sourceWidth === 'number' ? [sourceWidth] : [],
).max
}
export function cabinetConnectedDepthBounds(
currentDepth: number,
compensatedWidths: readonly number[],
) {
let min = MIN_CABINET_DEPTH
let max = MAX_CABINET_DEPTH
for (const width of compensatedWidths) {
min = Math.max(min, currentDepth - (MAX_CABINET_WIDTH - width))
max = Math.min(max, currentDepth + width - MIN_CABINET_WIDTH)
}
return {
min: Math.min(currentDepth, min),
max: Math.max(currentDepth, max),
}
}
+78 -6
View File
@@ -14,6 +14,12 @@ const ADJACENT_RUN_Z_TOLERANCE = 0.03
type ModuleLike = Pick<CabinetModuleNode, 'id' | 'position' | 'width'>
type ReflowRunModulesOptions = {
minimumWidth?: number
preserveExtent?: boolean
restorableWidthById?: ReadonlyMap<CabinetModuleNode['id'], number>
}
export function sortRunModules<T extends ModuleLike>(modules: readonly T[]): T[] {
return [...modules].sort((a, b) => a.position[0] - b.position[0])
}
@@ -71,7 +77,8 @@ export type RunSpan = {
/**
* Contiguous same-height module groups along the run — the units the
* countertop, plinth, and appliance-gap logic operate on. A gap, a
* base↔tall transition, or a top-height change starts a new span.
* base↔tall transition, a top-height change, or a depth-footprint change
* starts a new span.
*/
export function getRunSpans(
modules: readonly Pick<
@@ -98,7 +105,9 @@ export function getRunSpans(
!current ||
minX - current.maxX > RUN_ADJACENCY_EPSILON ||
current.hasCountertop !== hasCountertop ||
Math.abs(current.topY - topY) > RUN_ADJACENCY_EPSILON
Math.abs(current.topY - topY) > RUN_ADJACENCY_EPSILON ||
Math.abs(current.minZ - minZ) > RUN_ADJACENCY_EPSILON ||
Math.abs(current.maxZ - maxZ) > RUN_ADJACENCY_EPSILON
) {
spans.push({
minX,
@@ -263,12 +272,26 @@ export function getRunSpanEnds(
return spans.map((span, spanIndex) => {
const previousSpan = spans[spanIndex - 1]
const nextSpan = spans[spanIndex + 1]
const hasFlushCountertopLeftNeighbor =
!!previousSpan &&
previousSpan.hasCountertop &&
span.hasCountertop &&
Math.abs(previousSpan.topY - span.topY) <= RUN_ADJACENCY_EPSILON &&
span.minX - previousSpan.maxX <= RUN_ADJACENCY_EPSILON
const hasFlushCountertopRightNeighbor =
!!nextSpan &&
nextSpan.hasCountertop &&
span.hasCountertop &&
Math.abs(nextSpan.topY - span.topY) <= RUN_ADJACENCY_EPSILON &&
nextSpan.minX - span.maxX <= RUN_ADJACENCY_EPSILON
const hasInternalLeftNeighbor =
!!previousSpan &&
!previousSpan.hasCountertop &&
(!previousSpan.hasCountertop || hasFlushCountertopLeftNeighbor) &&
span.minX - previousSpan.maxX <= RUN_ADJACENCY_EPSILON
const hasInternalRightNeighbor =
!!nextSpan && !nextSpan.hasCountertop && nextSpan.minX - span.maxX <= RUN_ADJACENCY_EPSILON
!!nextSpan &&
(!nextSpan.hasCountertop || hasFlushCountertopRightNeighbor) &&
nextSpan.minX - span.maxX <= RUN_ADJACENCY_EPSILON
const hasExternalLeftNeighbor = hasAdjacentCabinetSpan({
depth: span.depth,
edgeX: span.minX,
@@ -374,13 +397,62 @@ export function reflowRunModules<T extends ModuleLike>(
modules: readonly T[],
selectedId: CabinetModuleNode['id'],
selectedWidth: number,
options: ReflowRunModulesOptions = {},
): Array<{ id: T['id']; position: T['position']; width: number }> {
const sorted = sortRunModules(modules)
if (!sorted.some((module) => module.id === selectedId)) return []
const selectedIndex = sorted.findIndex((module) => module.id === selectedId)
if (selectedIndex < 0) return []
const widths = new Map(sorted.map((module) => [module.id, module.width]))
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
}
}
let remainingFreedWidth = selected.width - selectedWidth
if (
options.preserveExtent &&
remainingFreedWidth > RUN_ADJACENCY_EPSILON &&
options.restorableWidthById
) {
const left = sorted.slice(0, selectedIndex).reverse()
const right = sorted.slice(selectedIndex + 1)
const restorable = (candidates: readonly T[]) =>
candidates.reduce(
(total, module) => total + (options.restorableWidthById?.get(module.id) ?? 0),
0,
)
const candidates =
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)
const restoration = Math.min(available, remainingFreedWidth)
widths.set(module.id, module.width + restoration)
remainingFreedWidth -= restoration
}
}
let nextLeft = runMinX(sorted)
return sorted.map((module) => {
const width = module.id === selectedId ? selectedWidth : module.width
const width = widths.get(module.id) ?? module.width
const position: T['position'] = [
nextLeft + width / 2,
module.position[1],
File diff suppressed because it is too large Load Diff
+49 -2
View File
@@ -21,6 +21,7 @@ import {
addCabinetModuleSide,
backAlignZ,
bumpCabinetRunLayoutRevision,
cabinetMetadataRecord,
cornerLinkedSourceModuleForRun,
runModuleBaseY,
syncCornerRunsFromSourceModule,
@@ -43,6 +44,7 @@ const RUN_MODULE_SYNC_PATCH_KEYS = new Set<keyof CabinetNodeType>([
'handlePosition',
])
const RUN_DEPTH_PATCH_KEY = 'depth'
const PRESET_WIDTH_DEBT_KEY = 'cabinetPresetWidthDebtBySource'
const FRONT_STYLE_OPTIONS = [
{ value: 'slab', label: 'Slab' },
@@ -85,20 +87,59 @@ export function bumpRunLayoutRevisionViaStore(
scene.markDirty(run.id as AnyNodeId)
}
function presetWidthDebt(
module: CabinetModuleNodeType,
sourceId: CabinetModuleNodeType['id'],
): number {
const value = cabinetMetadataRecord(module.metadata)[PRESET_WIDTH_DEBT_KEY]
if (!value || typeof value !== 'object' || Array.isArray(value)) return 0
const debt = (value as Record<string, unknown>)[sourceId]
return typeof debt === 'number' && debt > 0 ? debt : 0
}
function metadataWithPresetWidthDebt(
module: CabinetModuleNodeType,
sourceId: CabinetModuleNodeType['id'],
widthDelta: number,
): CabinetModuleNodeType['metadata'] {
const metadata = cabinetMetadataRecord(module.metadata)
const value = metadata[PRESET_WIDTH_DEBT_KEY]
const debts =
value && typeof value === 'object' && !Array.isArray(value)
? { ...(value as Record<string, unknown>) }
: {}
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 { [PRESET_WIDTH_DEBT_KEY]: _removed, ...rest } = metadata
return rest as CabinetModuleNodeType['metadata']
}
export function reflowRunModules({
modules,
parentRun,
patch,
preserveExtent = false,
scene,
selected,
}: {
modules: CabinetModuleNodeType[]
parentRun: CabinetNodeType
patch: Partial<CabinetModuleNodeType>
preserveExtent?: boolean
scene: ReturnType<typeof useScene.getState>
selected: CabinetModuleNodeType
}) {
const reflowed = reflowCabinetRunModules(modules, selected.id, patch.width ?? selected.width)
const reflowed = reflowCabinetRunModules(modules, selected.id, patch.width ?? selected.width, {
preserveExtent,
restorableWidthById: new Map(
modules.map((module) => [module.id, presetWidthDebt(module, selected.id)]),
),
})
if (reflowed.length === 0) return
const reflowById = new Map(reflowed.map((entry) => [entry.id, entry]))
@@ -106,7 +147,13 @@ export function reflowRunModules({
const reflow = reflowById.get(module.id)
if (!reflow) continue
const isSelected = module.id === selected.id
const nextPatch: Partial<CabinetModuleNodeType> = isSelected ? { ...patch } : {}
const nextPatch: Partial<CabinetModuleNodeType> = isSelected
? { ...patch, width: reflow.width }
: { width: reflow.width }
const widthDelta = reflow.width - module.width
if (!isSelected && preserveExtent && Math.abs(widthDelta) > 1e-4) {
nextPatch.metadata = metadataWithPresetWidthDebt(module, selected.id, widthDelta)
}
const nextPosition: CabinetModuleNodeType['position'] = [
reflow.position[0],
isSelected && patch.position ? patch.position[1] : reflow.position[1],
@@ -28,7 +28,7 @@ import {
TALL_CABINET_CARCASS_HEIGHT,
} from './stack'
const BASE_MODULE_WIDTH = 0.6
const BASE_MODULE_WIDTH = 0.5
const BASE_CARCASS_HEIGHT = 0.72
const WALL_CARCASS_HEIGHT = 0.72
const TALL_CARCASS_HEIGHT = TALL_CABINET_CARCASS_HEIGHT
@@ -78,7 +78,7 @@ export function resolveCompartmentTransition({
: next.type === 'fridge-double'
? FRIDGE_WIDE_WIDTH
: FRIDGE_COLUMN_WIDTH,
depth: parentRun?.depth ?? 0.58,
depth: parentRun?.depth ?? 0.5,
carcassHeight: TALL_CARCASS_HEIGHT,
plinthHeight: 0.1,
toeKickDepth: 0.075,
@@ -100,7 +100,7 @@ export function resolveCompartmentTransition({
: enteringCooktop
? COOKTOP_STANDARD_WIDTH
: BASE_MODULE_WIDTH,
depth: parentRun?.depth ?? 0.58,
depth: parentRun?.depth ?? 0.5,
carcassHeight: parentRun?.carcassHeight ?? BASE_CARCASS_HEIGHT,
plinthHeight: parentRun?.plinthHeight ?? 0.1,
toeKickDepth: parentRun?.toeKickDepth ?? 0.075,
@@ -114,7 +114,7 @@ export function resolveCompartmentTransition({
? {
cabinetType: 'base',
width: DISHWASHER_STANDARD_WIDTH,
depth: parentRun?.depth ?? 0.58,
depth: parentRun?.depth ?? 0.5,
carcassHeight: DISHWASHER_STANDARD_HEIGHT,
plinthHeight: parentRun?.plinthHeight ?? 0.1,
toeKickDepth: parentRun?.toeKickDepth ?? 0.075,
+108 -9
View File
@@ -1,16 +1,20 @@
'use client'
import {
type AnyNode,
type AnyNodeId,
CabinetModuleNode,
CabinetNode,
collectAlignmentAnchors,
createSceneApi,
emitter,
type GridEvent,
getFloorPlacedFootprints,
getWallThickness,
isCurvedWall,
movingFootprintAnchors,
nodeRegistry,
resolveAlignment,
spatialGridManager,
useScene,
type WallEvent,
@@ -20,6 +24,7 @@ import {
clearPlacementSurface,
getFloorStackPreviewPosition,
getSideFromNormal,
isAlignmentGuideActive,
isGridSnapActive,
isMagneticSnapActive,
isValidWallSideFace,
@@ -28,6 +33,7 @@ import {
PlacementBox,
publishPlacementSurface,
triggerSFX,
useAlignmentGuides,
useEditor,
useFacingPose,
usePlacementPreview,
@@ -38,6 +44,7 @@ import { useFrame } from '@react-three/fiber'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { type Group, Mesh, Quaternion, Vector3 } from 'three'
import {
FLOOR_PLACEMENT_ALIGNMENT_THRESHOLD_M,
type FloorPlacementClickTriggerEvent,
getLevelLocalSnappedPosition,
stopPlacementCommitPropagation,
@@ -65,6 +72,7 @@ import {
cabinetRunFootprint,
} from './definition'
import { buildCabinetGeometry } from './geometry'
import { resolveCabinetGridPosition } from './placement-snap'
import useCabinetPlacementStatus from './placement-status'
import useCabinetPlacementType from './placement-type'
import { cabinetPresetById } from './presets'
@@ -169,11 +177,6 @@ function buildCabinetPlacementPreviewNode({
})
}
function snap(value: number, step: number): number {
if (step <= 0) return value
return Math.round(value / step) * step
}
// Cabinet wall attachment is a placement affordance, separate from floor-grid
// quantization. Keep the long-standing behavior in grid and magnetic modes;
// Off remains the explicit way to place without wall attachment.
@@ -269,6 +272,7 @@ const CabinetTool = () => {
const previousWasWallSnapRef = useRef(false)
const previousTickFrameRef = useRef(-1)
const draftAnchorRef = useRef<DraftAnchorState | null>(null)
const lastRawPositionRef = useRef<[number, number, number] | null>(null)
const activeGhostRef = useRef<Group | null>(null)
const surfacePointRef = useRef(new Vector3())
const surfaceNormalRef = useRef(new Vector3(0, 1, 0))
@@ -394,6 +398,11 @@ const CabinetTool = () => {
previousWasWallSnapRef.current = false
previousTickFrameRef.current = -1
draftAnchorRef.current = null
let alignmentCandidates = collectAlignmentAnchors(
useScene.getState().nodes,
previewNode.id,
activeLevelId,
)
let lastWallEventTime = -1
let wallOwnedPointerAt = Number.NEGATIVE_INFINITY
const WALL_OWNS_POINTER_MS = 64
@@ -417,6 +426,7 @@ const CabinetTool = () => {
previousTickFrameRef.current = -1
clearPlacementSurface()
useFacingPose.getState().clear()
useAlignmentGuides.getState().clear()
useCabinetPlacementStatus.getState().setBlocked(false)
}
@@ -461,7 +471,59 @@ const CabinetTool = () => {
bypassGrid = false,
): [number, number, number] => {
const step = !bypassGrid && isGridSnapActive() ? useEditor.getState().gridSnapStep : 0
return [snap(raw[0], step), 0, snap(raw[2], step)]
return resolveCabinetGridPosition({
raw,
dimensions: placementDimensions,
yaw: yawRef.current,
step,
})
}
const resolveAlignedCabinetPosition = ({
applyAlignmentSnap,
position,
width,
yaw,
}: {
applyAlignmentSnap: boolean
position: [number, number, number]
width?: number
yaw: number
}): [number, number, number] => {
if (!isAlignmentGuideActive()) {
useAlignmentGuides.getState().clear()
return position
}
const alignmentNode = buildCabinetPlacementPreviewNode({
island: islandModeRef.current,
position,
previewModule: previewNode,
yaw,
})
const moving = movingFootprintAnchors(
{
...alignmentNode,
...(width != null ? { width } : null),
} as AnyNode,
position[0],
position[2],
yaw,
)
if (moving.length === 0 || alignmentCandidates.length === 0) {
useAlignmentGuides.getState().clear()
return position
}
const result = resolveAlignment({
moving,
candidates: alignmentCandidates,
threshold: FLOOR_PLACEMENT_ALIGNMENT_THRESHOLD_M,
})
useAlignmentGuides.getState().set(result.guides)
if (!applyAlignmentSnap || !result.snap) return position
return [position[0] + result.snap.dx, position[1], position[2] + result.snap.dz]
}
const withPlacementValidity = (
@@ -552,12 +614,30 @@ const CabinetTool = () => {
const resolvePlacement = (event: FloorPlacementClickTriggerEvent): CabinetPlacement => {
const raw = resolveRawPosition(event)
lastRawPositionRef.current = raw
const forcePlacement = isForcePlacementEvent(event)
const wallPlacement = islandModeRef.current ? null : resolveWallPlacement(raw)
if (wallPlacement) return withPlacementValidity(wallPlacement, forcePlacement)
if (wallPlacement) {
return withPlacementValidity(
{
...wallPlacement,
position: resolveAlignedCabinetPosition({
applyAlignmentSnap: false,
position: wallPlacement.position,
yaw: wallPlacement.yaw,
}),
},
forcePlacement,
)
}
const position = resolveAlignedCabinetPosition({
applyAlignmentSnap: isMagneticSnapActive(),
position: resolveGridPosition(raw),
yaw: yawRef.current,
})
return withPlacementValidity(
{
position: resolveGridPosition(raw),
position,
yaw: yawRef.current,
snappedToWall: false,
},
@@ -571,6 +651,7 @@ const CabinetTool = () => {
anchor: StretchAnchor,
event: FloorPlacementClickTriggerEvent,
): CabinetPlacement => {
useAlignmentGuides.getState().clear()
const raw = resolveRawPosition(event)
let stretch = planCabinetContinuousStretch({
anchor,
@@ -923,6 +1004,7 @@ const CabinetTool = () => {
useViewer.getState().setSelection({ selectedIds: [module.id] })
useEditor.getState().setMode('select')
triggerSFX('sfx:item-place')
useAlignmentGuides.getState().clear()
usePlacementPreview.getState().clear()
clearPlacementSurface()
useFacingPose.getState().clear()
@@ -950,7 +1032,23 @@ const CabinetTool = () => {
!placementRef.current.snappedToWall &&
!placementRef.current.stretch
) {
const next = { ...placementRef.current, yaw: yawRef.current }
const current = placementRef.current
const { conflictIds: _conflictIds, valid: _valid, ...placementBase } = current
const raw = lastRawPositionRef.current ?? current.position
const position = resolveAlignedCabinetPosition({
applyAlignmentSnap: isMagneticSnapActive(),
position: resolveCabinetGridPosition({
raw,
dimensions: placementDimensions,
yaw: yawRef.current,
step: isGridSnapActive() ? useEditor.getState().gridSnapStep : 0,
}),
yaw: yawRef.current,
})
const next = withPlacementValidity(
{ ...placementBase, position, yaw: yawRef.current },
false,
)
placementRef.current = next
setPlacement(next)
publishFloorplanPreview(next)
@@ -985,6 +1083,7 @@ const CabinetTool = () => {
usePlacementPreview.getState().clear()
clearPlacementSurface()
useFacingPose.getState().clear()
useAlignmentGuides.getState().clear()
useCabinetPlacementStatus.getState().setBlocked(false)
}
}, [activeLevelId, placementDimensions, previewNode, publishFloorplanPreview])
+2 -6
View File
@@ -9,6 +9,7 @@ import {
} from '@pascal-app/core'
import type { WallHit } from '../shared/wall-attach-target'
import { findClosestWallInPlan, projectWallLocalPointToPlan } from '../shared/wall-attach-target'
import { snapCabinetFootprintCenter } from './placement-snap'
import { planToRunLocal, runLocalToPlan } from './run-layout'
const EDGE_SNAP_THRESHOLD = 0.08
@@ -33,11 +34,6 @@ export type CabinetWallSnapPlacement = {
}
}
function snap(value: number, step: number): number {
if (step <= 0) return value
return Math.round(value / step) * step
}
function angleDelta(a: number, b: number): number {
return Math.atan2(Math.sin(a - b), Math.cos(a - b))
}
@@ -225,7 +221,7 @@ export function resolveCabinetWallSnapPlacement({
if (hit.wallLength <= 1e-6) return null
const halfWidth = width / 2
const snappedLocalX = snap(hit.localX, gridStep)
const snappedLocalX = snapCabinetFootprintCenter(hit.localX, width, gridStep)
const clampedLocalX =
hit.wallLength > width
? Math.min(hit.wallLength - halfWidth, Math.max(halfWidth, snappedLocalX))
+17 -6
View File
@@ -10,7 +10,9 @@ import {
import { getVisibleWallMaterials, NodeRenderer, useNodeEvents, useViewer } from '@pascal-app/viewer'
import { useEffect, useLayoutEffect, useMemo, useRef } from 'react'
import type { Mesh } from 'three'
import { useShallow } from 'zustand/react/shallow'
import { createPlaceholderGeometry } from '../shared/placeholder-geometry'
import { useWallTreatmentLevelData } from './treatment-level-data'
import { createWallExtraSlotMaterials, WallTreatments } from './treatments'
/**
@@ -55,13 +57,15 @@ const WallRenderer = ({ node }: { node: WallNode }) => {
const textures = useViewer((s) => s.textures)
const colorPreset = useViewer((s) => s.colorPreset)
const sceneTheme = useViewer((s) => s.sceneTheme)
const sceneNodes = useScene((state) => state.nodes)
const childNodes = useMemo(
() =>
const childNodes = useScene(
useShallow((state) =>
(node.children ?? [])
.map((childId) => sceneNodes[childId as AnyNodeId])
.map((childId) => state.nodes[childId as AnyNodeId])
.filter((child): child is AnyNode => child !== undefined),
[node.children, sceneNodes],
),
)
const treatmentLevelData = useWallTreatmentLevelData((state) =>
node.parentId ? state.byLevelId.get(node.parentId) : undefined,
)
// Subscribe to the scene-material palette so editing a `scene:` material a
// wall slot references re-renders the wall live (the wall-system geometry
@@ -105,7 +109,14 @@ const WallRenderer = ({ node }: { node: WallNode }) => {
{...handlers}
/>
<WallTreatments childrenNodes={childNodes} materials={extraMaterials} node={node} />
{treatmentLevelData && (
<WallTreatments
childrenNodes={childNodes}
levelData={treatmentLevelData}
materials={extraMaterials}
node={node}
/>
)}
{(node.children ?? []).map((childId) => (
<NodeRenderer key={`${node.id}:${childId}`} nodeId={childId} />
+38
View File
@@ -1,6 +1,43 @@
'use client'
import { type AnyNodeId, useLiveNodeOverrides, useScene, type WallNode } from '@pascal-app/core'
import { WallCutout, WallSystem } from '@pascal-app/viewer'
import { useFrame } from '@react-three/fiber'
import { buildWallTreatmentLevelData, useWallTreatmentLevelData } from './treatment-level-data'
import { wallTreatmentProudOffsets } from './treatments'
function effectiveWall(wall: WallNode): WallNode {
const override = useLiveNodeOverrides.getState().get(wall.id)
return override ? ({ ...wall, ...override } as WallNode) : wall
}
const WallTreatmentMiterSystem = () => {
useFrame(() => {
const { dirtyNodes, nodes } = useScene.getState()
if (dirtyNodes.size === 0) return
const dirtyLevelIds = new Set<string>()
for (const id of dirtyNodes) {
const node = nodes[id]
if (node?.type === 'wall' && node.parentId) dirtyLevelIds.add(node.parentId)
}
for (const levelId of dirtyLevelIds) {
const level = nodes[levelId as AnyNodeId]
if (level?.type !== 'level') continue
const walls = level.children
.map((id) => nodes[id])
.filter((node): node is WallNode => node?.type === 'wall')
.map(effectiveWall)
const proudOffsets = walls.flatMap(wallTreatmentProudOffsets)
useWallTreatmentLevelData
.getState()
.setLevelData(levelId, buildWallTreatmentLevelData(walls, proudOffsets))
}
}, -1)
return null
}
/**
* Registry-driven wall system bundle.
@@ -16,6 +53,7 @@ import { WallCutout, WallSystem } from '@pascal-app/viewer'
const WallSystems = () => {
return (
<>
<WallTreatmentMiterSystem />
<WallSystem />
<WallCutout />
</>
@@ -0,0 +1,61 @@
import {
calculateLevelMiters,
getWallThickness,
type WallMiterData,
type WallNode,
} from '@pascal-app/core'
import { create } from 'zustand'
const PROUD_KEY_PRECISION = 1e6
function proudKey(proud: number) {
return Math.round(proud * PROUD_KEY_PRECISION) / PROUD_KEY_PRECISION
}
export type WallTreatmentLevelData = {
walls: readonly WallNode[]
miterDataByProud: ReadonlyMap<number, WallMiterData>
}
export function buildWallTreatmentLevelData(
walls: readonly WallNode[],
proudOffsets: readonly number[],
): WallTreatmentLevelData {
const uniqueProudOffsets = new Set([0, ...proudOffsets.map(proudKey)])
const miterDataByProud = new Map<number, WallMiterData>()
for (const proud of uniqueProudOffsets) {
const adjustedWalls =
proud === 0
? [...walls]
: walls.map((wall) => ({
...wall,
thickness: getWallThickness(wall) + proud * 2,
}))
miterDataByProud.set(proud, calculateLevelMiters(adjustedWalls))
}
return { walls, miterDataByProud }
}
export function treatmentMiterDataForProud(
levelData: WallTreatmentLevelData,
proud: number,
): WallMiterData | undefined {
return levelData.miterDataByProud.get(proudKey(proud))
}
type WallTreatmentLevelDataState = {
byLevelId: ReadonlyMap<string, WallTreatmentLevelData>
setLevelData: (levelId: string, data: WallTreatmentLevelData) => void
}
export const useWallTreatmentLevelData = create<WallTreatmentLevelDataState>((set) => ({
byLevelId: new Map(),
setLevelData: (levelId, data) =>
set((state) => {
const byLevelId = new Map(state.byLevelId)
byLevelId.set(levelId, data)
return { byLevelId }
}),
}))
+149
View File
@@ -0,0 +1,149 @@
import { describe, expect, mock, test } from 'bun:test'
import type { WallNode, WallTrimConfig } from '@pascal-app/core'
import { buildWallTreatmentLevelData } from './treatment-level-data'
mock.module('@pascal-app/viewer', () => ({
baseMaterial: () => undefined,
createMaterialFromPresetRef: () => undefined,
resolveMaterialRef: () => undefined,
}))
const { buildTrimGeometry, wallTreatmentProudOffsets } = await import('./treatments')
function wall(id: string, start: [number, number], end: [number, number]): WallNode {
return {
id,
type: 'wall',
object: 'node',
visible: true,
parentId: 'level_test',
children: [],
start,
end,
thickness: 0.1,
height: 2.5,
frontSide: 'interior',
backSide: 'exterior',
metadata: {},
} as WallNode
}
const trim: WallTrimConfig = {
enabled: true,
height: 0.1,
proud: 0.02,
profile: 'flat',
sides: 'both',
}
function treatmentLevelData(walls: WallNode[]) {
const treatedWalls = walls.map((entry) => ({
...entry,
skirting: trim,
crown: trim,
chairRail: trim,
}))
return buildWallTreatmentLevelData(treatedWalls, treatedWalls.flatMap(wallTreatmentProudOffsets))
}
function cornerXs(
side: 'interior' | 'exterior',
kind: 'skirting' | 'crown' | 'chairRail',
outerOffset: number,
) {
const walls = [wall('A', [0, 0], [3, 0]), wall('B', [0, 0], [0, 3])]
const geometry = buildTrimGeometry(walls[0]!, side, trim, kind, [], treatmentLevelData(walls))
expect(geometry).not.toBeNull()
if (!geometry) throw new Error('expected trim geometry')
const positions = geometry.getAttribute('position')
const outerZ = side === 'interior' ? outerOffset : -outerOffset
const xs: number[] = []
for (let index = 0; index < positions.count; index += 1) {
if (Math.abs(positions.getZ(index) - outerZ) < 1e-5) xs.push(positions.getX(index))
}
geometry.dispose()
return xs
}
function allPositions(geometry: NonNullable<ReturnType<typeof buildTrimGeometry>>) {
const positions = geometry.getAttribute('position')
return Array.from({ length: positions.count }, (_, index) => ({
x: positions.getX(index),
y: positions.getY(index),
z: positions.getZ(index),
}))
}
describe('wall treatment miters', () => {
test.each([
['skirting', 0.0624],
['crown', 0.0604],
['chairRail', 0.0616],
] as const)('preserves the %s outer miter endpoint on both sides', (kind, outerOffset) => {
const interiorXs = cornerXs('interior', kind, outerOffset)
const exteriorXs = cornerXs('exterior', kind, outerOffset)
expect(interiorXs.length).toBeGreaterThan(0)
expect(exteriorXs.length).toBeGreaterThan(0)
expect(Math.min(...interiorXs)).toBeCloseTo(outerOffset, 5)
expect(Math.min(...exteriorXs)).toBeCloseTo(-outerOffset, 5)
})
test('keeps each treatment on one physical side of an isolated wall', () => {
const node = wall('A', [0, 0], [3, 0])
const levelData = treatmentLevelData([node])
for (const side of ['interior', 'exterior'] as const) {
const geometry = buildTrimGeometry(node, side, trim, 'skirting', [], levelData)
expect(geometry).not.toBeNull()
if (!geometry) throw new Error('expected trim geometry')
const positions = allPositions(geometry)
expect(positions.every((point) => (side === 'interior' ? point.z > 0 : point.z < 0))).toBe(
true,
)
expect(Math.min(...positions.map((point) => point.x))).toBeCloseTo(0, 6)
expect(Math.max(...positions.map((point) => point.x))).toBeCloseTo(3, 6)
geometry.dispose()
}
})
test('joins the outer profile at an end-to-start room corner', () => {
const walls = [wall('A', [0, 0], [3, 0]), wall('B', [3, 0], [3, 3])]
const levelData = treatmentLevelData(walls)
const a = buildTrimGeometry(walls[0]!, 'interior', trim, 'skirting', [], levelData)
const b = buildTrimGeometry(walls[1]!, 'interior', trim, 'skirting', [], levelData)
expect(a).not.toBeNull()
expect(b).not.toBeNull()
if (!(a && b)) throw new Error('expected trim geometry')
const aOuter = allPositions(a).filter((point) => Math.abs(point.z - 0.0624) < 1e-5)
const bOuter = allPositions(b).filter((point) => Math.abs(point.z - 0.0624) < 1e-5)
expect(Math.max(...aOuter.map((point) => point.x))).toBeCloseTo(2.9376, 5)
expect(Math.min(...bOuter.map((point) => point.x))).toBeCloseTo(0.0624, 5)
a.dispose()
b.dispose()
})
test('keeps opening cuts at their local wall positions', () => {
const node = wall('A', [0, 0], [3, 0])
const geometry = buildTrimGeometry(
node,
'interior',
trim,
'skirting',
[{ type: 'door', width: 1, height: 2, position: [1.5, 1, 0] }],
treatmentLevelData([node]),
)
expect(geometry).not.toBeNull()
if (!geometry) throw new Error('expected trim geometry')
const xs = allPositions(geometry).map((point) => point.x)
expect(xs.some((x) => Math.abs(x - 1) < 1e-6)).toBe(true)
expect(xs.some((x) => Math.abs(x - 2) < 1e-6)).toBe(true)
expect(xs.every((x) => x <= 1 + 1e-6 || x >= 2 - 1e-6)).toBe(true)
geometry.dispose()
})
})
+67 -13
View File
@@ -2,6 +2,7 @@
import {
getWallCurveFrameAt,
getWallMiterBoundaryPoints,
getWallThickness,
isCurvedWall,
type SceneMaterial,
@@ -24,6 +25,7 @@ import {
import { memo, useEffect, useMemo } from 'react'
import * as THREE from 'three'
import { mergeGeometries as mergeBufferGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
import { treatmentMiterDataForProud, type WallTreatmentLevelData } from './treatment-level-data'
const CURVE_SEGMENTS = 24
const MIN_SLICE_PROUD = 0.0005
@@ -257,6 +259,28 @@ function resolveTrimProfile(kind: TrimKind, trim: WallTrimConfig) {
)
}
export function wallTreatmentProudOffsets(node: WallNode): number[] {
const offsets = new Set<number>()
const configs: Array<[TrimKind, WallTrimConfig | undefined]> = [
['skirting', node.skirting],
['crown', node.crown],
['chairRail', node.chairRail],
]
for (const [kind, rawConfig] of configs) {
const trim = { ...TRIM_KIND_CONFIG[kind].defaultConfig, ...(rawConfig ?? {}) }
if (!trim.enabled) continue
const profile = resolveTrimProfile(kind, trim)
if (!profile) continue
for (let index = 0; index < profile.samples; index += 1) {
const t = (index + 0.5) / profile.samples
offsets.add(Math.max(MIN_SLICE_PROUD, trim.proud * profile.proudAt(t)))
}
}
return [...offsets]
}
function resolveTreatmentSideSign(node: WallNode, side: WallSide) {
if (side === 'interior') {
if (node.frontSide === 'interior') return 1
@@ -300,8 +324,30 @@ function buildSidePolyline(node: WallNode, side: WallSide, offset: number): Poin
return points
}
function clipPolyline(points: Point2[], x0: number, x1: number): Point2[] {
if (points.length < 2 || x1 - x0 <= EPS) return []
function buildMiteredSidePolyline(
node: WallNode,
levelData: WallTreatmentLevelData,
side: WallSide,
offset: number,
): Point2[] {
if (isCurvedWall(node)) return buildSidePolyline(node, side, offset)
const sideSign = resolveTreatmentSideSign(node, side)
const toLocal = wallToLocalTransform(node)
const proud = offset - getWallThickness(node) / 2
const boundarySource = treatmentMiterDataForProud(levelData, proud)
if (!boundarySource) return buildSidePolyline(node, side, offset)
const boundary = getWallMiterBoundaryPoints({ ...node, thickness: offset * 2 }, boundarySource)
if (!boundary) return buildSidePolyline(node, side, offset)
const start = sideSign > 0 ? boundary.startLeft : boundary.startRight
const end = sideSign > 0 ? boundary.endLeft : boundary.endRight
return [toLocal(start.x, start.y), toLocal(end.x, end.y)]
}
function clipPolyline(points: Point2[], x0?: number, x1?: number): Point2[] {
if (points.length < 2 || (x0 !== undefined && x1 !== undefined && x1 - x0 <= EPS)) return []
const out: Point2[] = []
for (let index = 0; index < points.length - 1; index += 1) {
const a = points[index]
@@ -309,7 +355,9 @@ function clipPolyline(points: Point2[], x0: number, x1: number): Point2[] {
if (!(a && b)) continue
const minX = Math.min(a.x, b.x)
const maxX = Math.max(a.x, b.x)
if (maxX < x0 - EPS || minX > x1 + EPS) continue
if ((x0 !== undefined && maxX < x0 - EPS) || (x1 !== undefined && minX > x1 + EPS)) {
continue
}
const pushPointAt = (x: number) => {
if (Math.abs(b.x - a.x) <= EPS) {
@@ -322,8 +370,8 @@ function clipPolyline(points: Point2[], x0: number, x1: number): Point2[] {
}
}
const start = minX < x0 ? pushPointAt(x0) : a
const end = maxX > x1 ? pushPointAt(x1) : b
const start = x0 !== undefined && minX < x0 ? pushPointAt(x0) : a
const end = x1 !== undefined && maxX > x1 ? pushPointAt(x1) : b
if (
out.length === 0 ||
Math.hypot(out[out.length - 1]!.x - start.x, out[out.length - 1]!.z - start.z) > EPS
@@ -446,12 +494,13 @@ function mergeGeometries(geometries: THREE.BufferGeometry[]) {
return null
}
function buildTrimGeometry(
export function buildTrimGeometry(
node: WallNode,
side: WallSide,
trim: WallTrimConfig,
kind: TrimKind,
childrenNodes: OpeningLike[],
levelData: WallTreatmentLevelData,
) {
const wallHeight = node.height ?? 2.5
const height = trim.height
@@ -466,11 +515,12 @@ function buildTrimGeometry(
: 0
const thickness = getWallThickness(node)
const inner = buildSidePolyline(node, side, thickness / 2)
const inner = buildMiteredSidePolyline(node, levelData, side, thickness / 2)
if (inner.length < 2) return null
const wallLength = Math.hypot(node.end[0] - node.start[0], node.end[1] - node.start[1])
const openingRanges = trimOpeningRanges(node, childrenNodes, yBottom, height)
const fullRanges: Array<[number, number]> = [[inner[0]!.x, inner[inner.length - 1]!.x]]
const fullRanges: Array<[number, number]> = [[0, wallLength]]
const runs = subtractOpeningRanges(fullRanges, openingRanges)
if (runs.length === 0) return null
@@ -480,13 +530,15 @@ function buildTrimGeometry(
const sliceHeight = height / profile.samples
for (const [runStart, runEnd] of runs) {
const innerRun = clipPolyline(inner, runStart, runEnd)
const clipStart = runStart > EPS ? runStart : undefined
const clipEnd = runEnd < wallLength - EPS ? runEnd : undefined
const innerRun = clipPolyline(inner, clipStart, clipEnd)
if (innerRun.length < 2) continue
for (let index = 0; index < profile.samples; index += 1) {
const t = (index + 0.5) / profile.samples
const proud = Math.max(MIN_SLICE_PROUD, trim.proud * profile.proudAt(t))
const outerRun = buildSidePolyline(node, side, thickness / 2 + proud)
const outerClipped = clipPolyline(outerRun, runStart, runEnd)
const outerRun = buildMiteredSidePolyline(node, levelData, side, thickness / 2 + proud)
const outerClipped = clipPolyline(outerRun, clipStart, clipEnd)
if (outerClipped.length < 2) continue
const slice = buildTrimSliceGeometry(
outerClipped,
@@ -538,10 +590,12 @@ export function createWallExtraSlotMaterials(
export const WallTreatments = memo(function WallTreatments({
node,
childrenNodes,
levelData,
materials,
}: {
node: WallNode
childrenNodes: OpeningLike[]
levelData: WallTreatmentLevelData
materials: Record<WallTreatmentSlotId, THREE.Material>
}) {
const fallbackMaterial =
@@ -574,7 +628,7 @@ export const WallTreatments = memo(function WallTreatments({
? (['interior', 'exterior'] as WallSide[])
: ([trim.sides] as WallSide[])
for (const side of sides) {
const geometry = buildTrimGeometry(node, side, trim, kind, childrenNodes)
const geometry = buildTrimGeometry(node, side, trim, kind, childrenNodes, levelData)
if (!geometry) continue
const slotId = TRIM_KIND_CONFIG[kind].slots[side]
out.push({
@@ -587,7 +641,7 @@ export const WallTreatments = memo(function WallTreatments({
}
return out
}, [childrenNodes, fallbackMaterial, materials, node])
}, [childrenNodes, fallbackMaterial, levelData, materials, node])
useEffect(
() => () => {