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
+16 -1
View File
@@ -129,6 +129,17 @@ export type LinearResizeHandle<N> = {
anchor: HandleAnchor anchor: HandleAnchor
currentValue: (node: N) => number currentValue: (node: N) => number
apply: (node: N, newValue: number, sceneApi: SceneApi) => Partial<N> apply: (node: N, newValue: number, sceneApi: SceneApi) => Partial<N>
/**
* Additional live-only patches for geometry owned by related nodes. The
* editor publishes these during the drag and clears them on release or
* cancellation; committed scene writes remain the responsibility of
* `commit` (or the generic selected-node update).
*/
previewOverrides?: (
node: N,
newValue: number,
sceneApi: SceneApi,
) => ReadonlyArray<readonly [AnyNodeId, Partial<AnyNode>]>
/** Optional live-scene visibility gate for context-dependent arrows. */ /** Optional live-scene visibility gate for context-dependent arrows. */
visible?: (node: N, sceneApi: SceneApi) => boolean visible?: (node: N, sceneApi: SceneApi) => boolean
/** /**
@@ -163,6 +174,8 @@ export type LinearResizeHandle<N> = {
max?: number | ((node: N, sceneApi: SceneApi) => number) max?: number | ((node: N, sceneApi: SceneApi) => number)
/** Snap the resized scalar to the editor's active grid step before apply. */ /** Snap the resized scalar to the editor's active grid step before apply. */
gridSnap?: boolean gridSnap?: boolean
/** Kind-owned magnetic snap for the resized scalar, gated by the active snapping mode. */
magneticSnap?: (node: N, newValue: number, sceneApi: SceneApi) => number
placement: HandlePlacement<N> placement: HandlePlacement<N>
/** /**
* Dimension this handle steers (e.g. `'height'`). When set, the editor * Dimension this handle steers (e.g. `'height'`). When set, the editor
@@ -444,4 +457,6 @@ export type HandleDescriptor<N = any> =
* Static array, or a function for shape-dependent cases (column * Static array, or a function for shape-dependent cases (column
* crossSection / supportStyle, stair-segment segmentType, etc.). * crossSection / supportStyle, stair-segment segmentType, etc.).
*/ */
export type HandleList<N> = HandleDescriptor<N>[] | ((node: N) => HandleDescriptor<N>[]) export type HandleList<N> =
| HandleDescriptor<N>[]
| ((node: N, sceneApi?: SceneApi) => HandleDescriptor<N>[])
+1
View File
@@ -109,6 +109,7 @@ export type {
NodePort, NodePort,
NodeQuickAction, NodeQuickAction,
NodeQuickActionIcon, NodeQuickActionIcon,
NodeQuickActionNodeScope,
NodeQuickActionProvider, NodeQuickActionProvider,
NodeQuickActionResult, NodeQuickActionResult,
NodeRegistry, NodeRegistry,
+6
View File
@@ -1070,6 +1070,8 @@ export type NodeDefinition<S extends ZodObject<any>> = {
* and runs through `SceneApi`. * and runs through `SceneApi`.
*/ */
quickActions?: NodeQuickActionProvider<z.infer<S>> quickActions?: NodeQuickActionProvider<z.infer<S>>
/** Scene-graph scope the quick-action provider needs for derived availability. */
quickActionNodeScope?: NodeQuickActionNodeScope
/** /**
* Sidebar-tree presentation hooks. Lets a kind reshape how the generic * Sidebar-tree presentation hooks. Lets a kind reshape how the generic
* scene tree walks its subtree — hiding derived/managed nodes and * scene tree walks its subtree — hiding derived/managed nodes and
@@ -1655,6 +1657,8 @@ export type NodeQuickActionResult = {
selectedIds?: AnyNodeId[] selectedIds?: AnyNodeId[]
} }
export type NodeQuickActionNodeScope = 'family' | 'level'
export type NodeQuickAction = { export type NodeQuickAction = {
id: string id: string
label: string label: string
@@ -1667,6 +1671,8 @@ export type NodeQuickAction = {
*/ */
icon?: NodeQuickActionIcon | IconRef icon?: NodeQuickActionIcon | IconRef
disabled?: boolean disabled?: boolean
/** Whether pressing a disabled action should acknowledge its blocked state. */
blockedFeedback?: boolean
history?: 'single' history?: 'single'
run: (args: { node: AnyNode; sceneApi: SceneApi }) => NodeQuickActionResult | undefined run: (args: { node: AnyNode; sceneApi: SceneApi }) => NodeQuickActionResult | undefined
} }
+2 -2
View File
@@ -80,8 +80,8 @@ export type CabinetCompartmentSchema = z.infer<typeof CabinetCompartment>
const cabinetBoxFields = { const cabinetBoxFields = {
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
rotation: z.number().default(0), rotation: z.number().default(0),
width: z.number().min(0.05).max(3).default(0.6), width: z.number().min(0.05).max(3).default(0.5),
depth: z.number().min(0.3).max(1.2).default(0.58), depth: z.number().min(0.3).max(1.2).default(0.5),
carcassHeight: z.number().min(0.4).max(2.4).default(0.72), carcassHeight: z.number().min(0.4).max(2.4).default(0.72),
operationState: z.number().min(0).max(1).default(0), operationState: z.number().min(0).max(1).default(0),
plinthHeight: z.number().min(0).max(0.3).default(0.1), plinthHeight: z.number().min(0).max(0.3).default(0.1),
@@ -64,3 +64,17 @@ describe('wall mitering miter limit', () => {
expect(startSideX).toBeGreaterThan(-0.5) expect(startSideX).toBeGreaterThan(-0.5)
}) })
}) })
describe('wall miter boundary sides', () => {
test('keeps left and right on the same physical face at both free endpoints', () => {
const node = wall('A', [0, 0], [3, 0])
const boundary = getWallMiterBoundaryPoints(node, calculateLevelMiters([node]))
expect(boundary).not.toBeNull()
if (!boundary) throw new Error('expected miter boundary points')
expect(boundary.startLeft.y).toBeCloseTo(0.05)
expect(boundary.endLeft.y).toBeCloseTo(0.05)
expect(boundary.startRight.y).toBeCloseTo(-0.05)
expect(boundary.endRight.y).toBeCloseTo(-0.05)
})
})
@@ -178,11 +178,8 @@ function getWallBoundaryFrame(wall: WallNode, endType: 'start' | 'end') {
endType === 'start' endType === 'start'
? { x: wall.start[0], y: wall.start[1] } ? { x: wall.start[0], y: wall.start[1] }
: { x: wall.end[0], y: wall.end[1] } : { x: wall.end[0], y: wall.end[1] }
const vector = const direction = { x: wall.end[0] - wall.start[0], y: wall.end[1] - wall.start[1] }
endType === 'start' const length = Math.hypot(direction.x, direction.y)
? { x: wall.end[0] - wall.start[0], y: wall.end[1] - wall.start[1] }
: { x: wall.start[0] - wall.end[0], y: wall.start[1] - wall.end[1] }
const length = Math.hypot(vector.x, vector.y)
if (length < 1e-9) { if (length < 1e-9) {
return { return {
@@ -194,8 +191,11 @@ function getWallBoundaryFrame(wall: WallNode, endType: 'start' | 'end') {
return { return {
point, point,
tangent: { x: vector.x / length, y: vector.y / length }, tangent:
normal: { x: -vector.y / length, y: vector.x / length }, endType === 'start'
? { x: direction.x / length, y: direction.y / length }
: { x: -direction.x / length, y: -direction.y / length },
normal: { x: -direction.y / length, y: direction.x / length },
} }
} }
@@ -613,6 +613,7 @@ export const FloorplanGroupSelectionBox = memo(function FloorplanGroupSelectionB
// which lag the `nodes` commit by a frame or two. // which lag the `nodes` commit by a frame or two.
const meshEpoch = useMeshSettleEpoch(nodes) const meshEpoch = useMeshSettleEpoch(nodes)
const box = useMemo(() => { const box = useMemo(() => {
void meshEpoch
if (selectedIds.length < 2 || !levelId) return null if (selectedIds.length < 2 || !levelId) return null
const participantIds = selectedIds.filter( const participantIds = selectedIds.filter(
(id) => classifyParticipant(nodes[id as AnyNodeId], levelId, nodes) !== null, (id) => classifyParticipant(nodes[id as AnyNodeId], levelId, nodes) !== null,
@@ -630,7 +631,6 @@ export const FloorplanGroupSelectionBox = memo(function FloorplanGroupSelectionB
width: Math.abs(max.x - min.x), width: Math.abs(max.x - min.x),
depth: Math.abs(max.z - min.z), depth: Math.abs(max.z - min.z),
} }
// biome-ignore lint/correctness/useExhaustiveDependencies: meshEpoch re-measures settled meshes
}, [selectedIds, levelId, nodes, meshEpoch]) }, [selectedIds, levelId, nodes, meshEpoch])
if (!box || movingNode || mode === 'delete') return null if (!box || movingNode || mode === 'delete') return null
@@ -15,14 +15,19 @@ import {
type WallNode, type WallNode,
} from '@pascal-app/core' } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { useEffect, useState } from 'react' import { type MouseEvent, useEffect, useState } from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { useShallow } from 'zustand/react/shallow' import { useShallow } from 'zustand/react/shallow'
import { useReducedMotion } from '../../hooks/use-reduced-motion'
import { resolveMoveActionNode } from '../../lib/direct-manipulation'
import { import {
createFreshPlacementSubtree, createFreshPlacementSubtree,
duplicatesAsFreshSubtree, duplicatesAsFreshSubtree,
} from '../../lib/fresh-planar-placement' } from '../../lib/fresh-planar-placement'
import { playBlockedQuickActionFeedback } from '../../lib/quick-action-feedback'
import { collectQuickActionNodeScope } from '../../lib/quick-action-nodes'
import { sfxEmitter } from '../../lib/sfx-bus' import { sfxEmitter } from '../../lib/sfx-bus'
import { cn } from '../../lib/utils'
import useEditor from '../../store/use-editor' import useEditor from '../../store/use-editor'
import { useMovingNode } from '../../store/use-interaction-scope' import { useMovingNode } from '../../store/use-interaction-scope'
import { NodeActionMenu } from '../editor/node-action-menu' import { NodeActionMenu } from '../editor/node-action-menu'
@@ -80,26 +85,9 @@ function collectQuickActionNodes(
): Record<AnyNodeId, AnyNode> | null { ): Record<AnyNodeId, AnyNode> | null {
if (!selectedId) return null if (!selectedId) return null
const selected = nodes[selectedId as AnyNodeId] const selected = nodes[selectedId as AnyNodeId]
if (!selected || !nodeRegistry.get(selected.type)?.quickActions) return null const def = selected ? nodeRegistry.get(selected.type) : undefined
if (!def?.quickActions) return null
const collected: Record<AnyNodeId, AnyNode> = { [selected.id as AnyNodeId]: selected } return collectQuickActionNodeScope(nodes, selectedId, def.quickActionNodeScope)
const add = (id: string | null | undefined) => {
if (!id) return
const node = nodes[id as AnyNodeId]
if (node) collected[node.id as AnyNodeId] = node
}
const addChildren = (node: AnyNode | undefined) => {
for (const childId of (node as { children?: readonly string[] } | undefined)?.children ?? []) {
add(childId)
}
}
add(selected.parentId ?? null)
addChildren(selected)
const parent = selected.parentId ? nodes[selected.parentId as AnyNodeId] : undefined
addChildren(parent)
return collected
} }
/** /**
@@ -129,6 +117,7 @@ function collectQuickActionNodes(
* Hidden while in a move state (so we don't show buttons over a ghost). * Hidden while in a move state (so we don't show buttons over a ghost).
*/ */
export function FloorplanRegistryActionMenu() { export function FloorplanRegistryActionMenu() {
const reducedMotion = useReducedMotion()
// Sole selection only — a multi-selection gets the group menu // Sole selection only — a multi-selection gets the group menu
// (`FloorplanGroupActionMenu`), whose actions target the whole selection. // (`FloorplanGroupActionMenu`), whose actions target the whole selection.
const selectedId = useViewer((s) => const selectedId = useViewer((s) =>
@@ -241,7 +230,8 @@ export function FloorplanRegistryActionMenu() {
const handleMove = () => { const handleMove = () => {
sfxEmitter.emit('sfx:item-pick') sfxEmitter.emit('sfx:item-pick')
setMovingNode(node as never) const sceneNodes = useScene.getState().nodes
setMovingNode(resolveMoveActionNode(node, sceneNodes) as never)
// 2D-owned move: `FloorplanRegistryMoveOverlay` runs the whole gesture. // 2D-owned move: `FloorplanRegistryMoveOverlay` runs the whole gesture.
// Mark the origin (after `setMovingNode`, which resets it to null) so // Mark the origin (after `setMovingNode`, which resets it to null) so
// `ToolManager` keeps the 3D affordance mover from also adopting the node // `ToolManager` keeps the 3D affordance mover from also adopting the node
@@ -334,8 +324,13 @@ export function FloorplanRegistryActionMenu() {
useViewer.getState().setSelection({ selectedIds: [] }) useViewer.getState().setSelection({ selectedIds: [] })
} }
const handleQuickAction = (action: NodeQuickAction) => { const handleQuickAction = (action: NodeQuickAction, event: MouseEvent<HTMLButtonElement>) => {
if (action.disabled) return if (action.disabled) {
if (action.blockedFeedback) {
playBlockedQuickActionFeedback(event.currentTarget, reducedMotion)
}
return
}
const run = () => action.run({ node, sceneApi: createSceneApi(useScene) }) const run = () => action.run({ node, sceneApi: createSceneApi(useScene) })
const result = action.history === 'single' ? runAsSingleSceneHistoryStep(useScene, run) : run() const result = action.history === 'single' ? runAsSingleSceneHistoryStep(useScene, run) : run()
if (result?.selectedIds) useViewer.getState().setSelection({ selectedIds: result.selectedIds }) if (result?.selectedIds) useViewer.getState().setSelection({ selectedIds: result.selectedIds })
@@ -370,18 +365,27 @@ export function FloorplanRegistryActionMenu() {
> >
{quickActions.map((action) => ( {quickActions.map((action) => (
<button <button
aria-disabled={action.disabled || undefined}
aria-label={action.title ?? action.label} aria-label={action.title ?? action.label}
className="tooltip-trigger flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-muted-foreground" className={cn(
disabled={action.disabled} 'tooltip-trigger flex items-center rounded-md px-2 py-1.5 text-xs text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground',
action.disabled &&
'cursor-not-allowed opacity-40 hover:bg-transparent hover:text-muted-foreground',
)}
disabled={action.disabled && !action.blockedFeedback}
key={action.id} key={action.id}
onClick={() => handleQuickAction(action)} onClick={(event) => handleQuickAction(action, event)}
title={action.title ?? action.label} title={action.title ?? action.label}
type="button" type="button"
> >
<span className="flex h-3.5 w-3.5 shrink-0 items-center justify-center text-current"> <span className="flex items-center gap-1.5" data-quick-action-feedback>
<QuickActionIcon action={action} /> <span className="flex h-3.5 w-3.5 shrink-0 items-center justify-center text-current">
<QuickActionIcon action={action} />
</span>
<span className="whitespace-nowrap leading-none" data-quick-action-label>
{action.label}
</span>
</span> </span>
<span className="whitespace-nowrap leading-none">{action.label}</span>
</button> </button>
))} ))}
</div> </div>
@@ -42,15 +42,20 @@ import { useFrame } from '@react-three/fiber'
import { useCallback, useMemo, useRef } from 'react' import { useCallback, useMemo, useRef } from 'react'
import * as THREE from 'three' import * as THREE from 'three'
import { useShallow } from 'zustand/react/shallow' import { useShallow } from 'zustand/react/shallow'
import { useReducedMotion } from '../../hooks/use-reduced-motion'
import { resolveMoveActionNode } from '../../lib/direct-manipulation'
import { import {
createFreshPlacementSubtree, createFreshPlacementSubtree,
duplicatesAsFreshSubtree, duplicatesAsFreshSubtree,
} from '../../lib/fresh-planar-placement' } from '../../lib/fresh-planar-placement'
import { resolveOverlayPolicy } from '../../lib/interaction/overlay-policy' import { resolveOverlayPolicy } from '../../lib/interaction/overlay-policy'
import { curveReshapeScope, holeEditScope } from '../../lib/interaction/scope' import { curveReshapeScope, holeEditScope } from '../../lib/interaction/scope'
import { playBlockedQuickActionFeedback } from '../../lib/quick-action-feedback'
import { collectQuickActionNodeScope } from '../../lib/quick-action-nodes'
import { duplicateRoofSubtree } from '../../lib/roof-duplication' import { duplicateRoofSubtree } from '../../lib/roof-duplication'
import { emitDeleteSFX, sfxEmitter } from '../../lib/sfx-bus' import { emitDeleteSFX, sfxEmitter } from '../../lib/sfx-bus'
import { duplicateStairSubtree } from '../../lib/stair-duplication' import { duplicateStairSubtree } from '../../lib/stair-duplication'
import { cn } from '../../lib/utils'
import useEditor from '../../store/use-editor' import useEditor from '../../store/use-editor'
import useInteractionScope, { import useInteractionScope, {
useActiveHandleDrag, useActiveHandleDrag,
@@ -207,26 +212,9 @@ function collectQuickActionNodes(
): Record<AnyNodeId, AnyNode> | null { ): Record<AnyNodeId, AnyNode> | null {
if (!selectedId) return null if (!selectedId) return null
const selected = nodes[selectedId as AnyNodeId] const selected = nodes[selectedId as AnyNodeId]
if (!selected || !nodeRegistry.get(selected.type)?.quickActions) return null const def = selected ? nodeRegistry.get(selected.type) : undefined
if (!def?.quickActions) return null
const collected: Record<AnyNodeId, AnyNode> = { [selected.id as AnyNodeId]: selected } return collectQuickActionNodeScope(nodes, selectedId, def.quickActionNodeScope)
const add = (id: string | null | undefined) => {
if (!id) return
const node = nodes[id as AnyNodeId]
if (node) collected[node.id as AnyNodeId] = node
}
const addChildren = (node: AnyNode | undefined) => {
for (const childId of (node as { children?: readonly string[] } | undefined)?.children ?? []) {
add(childId)
}
}
add(selected.parentId ?? null)
addChildren(selected)
const parent = selected.parentId ? nodes[selected.parentId as AnyNodeId] : undefined
addChildren(parent)
return collected
} }
// Pooled scratch for the per-frame anchor recompute (see useFrame below) so a // Pooled scratch for the per-frame anchor recompute (see useFrame below) so a
@@ -296,6 +284,7 @@ function getHeightPillDimensions(node: WallNode | FenceNode): {
} }
export function FloatingActionMenu() { export function FloatingActionMenu() {
const reducedMotion = useReducedMotion()
const selectedIds = useViewer((s) => s.selection.selectedIds) const selectedIds = useViewer((s) => s.selection.selectedIds)
const updateNode = useScene((s) => s.updateNode) const updateNode = useScene((s) => s.updateNode)
const mode = useEditor((s) => s.mode) const mode = useEditor((s) => s.mode)
@@ -514,7 +503,8 @@ export function FloatingActionMenu() {
e.stopPropagation() e.stopPropagation()
if (!node) return if (!node) return
sfxEmitter.emit('sfx:item-pick') sfxEmitter.emit('sfx:item-pick')
setMovingNode(node as any) const sceneNodes = useScene.getState().nodes
setMovingNode(resolveMoveActionNode(node, sceneNodes) as any)
setSelection({ selectedIds: [] }) setSelection({ selectedIds: [] })
}, },
[node, setMovingNode, setSelection], [node, setMovingNode, setSelection],
@@ -776,9 +766,15 @@ export function FloatingActionMenu() {
) )
const handleQuickAction = useCallback( const handleQuickAction = useCallback(
(action: NodeQuickAction) => (e: React.MouseEvent) => { (action: NodeQuickAction) => (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation() e.stopPropagation()
if (!node || action.disabled) return if (!node) return
if (action.disabled) {
if (action.blockedFeedback) {
playBlockedQuickActionFeedback(e.currentTarget, reducedMotion)
}
return
}
const run = () => action.run({ node, sceneApi: createSceneApi(useScene) }) const run = () => action.run({ node, sceneApi: createSceneApi(useScene) })
const result = const result =
action.history === 'single' ? runAsSingleSceneHistoryStep(useScene, run) : run() action.history === 'single' ? runAsSingleSceneHistoryStep(useScene, run) : run()
@@ -788,7 +784,7 @@ export function FloatingActionMenu() {
sfxEmitter.emit(selectedDifferentNode ? 'sfx:item-place' : 'sfx:item-pick') sfxEmitter.emit(selectedDifferentNode ? 'sfx:item-place' : 'sfx:item-pick')
} }
}, },
[node, setSelection], [node, reducedMotion, setSelection],
) )
if ( if (
@@ -851,18 +847,27 @@ export function FloatingActionMenu() {
> >
{quickActions.map((action) => ( {quickActions.map((action) => (
<button <button
aria-disabled={action.disabled || undefined}
aria-label={action.title ?? action.label} aria-label={action.title ?? action.label}
className="tooltip-trigger flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-muted-foreground" className={cn(
disabled={action.disabled} 'tooltip-trigger flex items-center rounded-md px-2 py-1.5 text-xs text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground',
action.disabled &&
'cursor-not-allowed opacity-40 hover:bg-transparent hover:text-muted-foreground',
)}
disabled={action.disabled && !action.blockedFeedback}
key={action.id} key={action.id}
onClick={handleQuickAction(action)} onClick={handleQuickAction(action)}
title={action.title ?? action.label} title={action.title ?? action.label}
type="button" type="button"
> >
<span className="flex h-3.5 w-3.5 shrink-0 items-center justify-center text-current"> <span className="flex items-center gap-1.5" data-quick-action-feedback>
<QuickActionIcon action={action} /> <span className="flex h-3.5 w-3.5 shrink-0 items-center justify-center text-current">
<QuickActionIcon action={action} />
</span>
<span className="whitespace-nowrap leading-none" data-quick-action-label>
{action.label}
</span>
</span> </span>
<span className="whitespace-nowrap leading-none">{action.label}</span>
</button> </button>
))} ))}
</div> </div>
@@ -60,6 +60,7 @@ export function GroupFloatingActionMenu() {
// memo keyed on selection + nodes is enough, no per-frame box traversal. // memo keyed on selection + nodes is enough, no per-frame box traversal.
const meshEpoch = useMeshSettleEpoch(nodes) const meshEpoch = useMeshSettleEpoch(nodes)
const anchor = useMemo(() => { const anchor = useMemo(() => {
void meshEpoch
if (participantIds.length === 0) return null if (participantIds.length === 0) return null
const fullIds = expandToComponent(participantIds, nodes, levelId) const fullIds = expandToComponent(participantIds, nodes, levelId)
const box = computeGroupBox(fullIds) const box = computeGroupBox(fullIds)
@@ -69,7 +70,6 @@ export function GroupFloatingActionMenu() {
box.max.y + MENU_Y_OFFSET, box.max.y + MENU_Y_OFFSET,
(box.min.z + box.max.z) / 2, (box.min.z + box.max.z) / 2,
) )
// biome-ignore lint/correctness/useExhaustiveDependencies: meshEpoch re-measures settled meshes
}, [participantIds, nodes, levelId, meshEpoch]) }, [participantIds, nodes, levelId, meshEpoch])
useFrame((state) => { useFrame((state) => {
@@ -132,6 +132,7 @@ function GroupRotateHandleInner({ ids, meshEpoch }: { ids: string[]; meshEpoch:
// - `pivot` = bbox center (XZ), Y at the group's base → the rotation origin // - `pivot` = bbox center (XZ), Y at the group's base → the rotation origin
// - `corner` = front-right bbox corner at mid-height → where the gizmo sits // - `corner` = front-right bbox corner at mid-height → where the gizmo sits
const rest = useMemo(() => { const rest = useMemo(() => {
void meshEpoch
const box = computeGroupBox(ids) const box = computeGroupBox(ids)
if (!box) return null if (!box) return null
const pivot = new Vector3((box.min.x + box.max.x) / 2, box.min.y, (box.min.z + box.max.z) / 2) const pivot = new Vector3((box.min.x + box.max.x) / 2, box.min.y, (box.min.z + box.max.z) / 2)
@@ -141,7 +142,6 @@ function GroupRotateHandleInner({ ids, meshEpoch }: { ids: string[]; meshEpoch:
box.max.z + CORNER_OFFSET, box.max.z + CORNER_OFFSET,
) )
return { pivot, corner } return { pivot, corner }
// biome-ignore lint/correctness/useExhaustiveDependencies: meshEpoch re-measures settled meshes
}, [ids, meshEpoch]) }, [ids, meshEpoch])
if (!rest) return null if (!rest) return null
@@ -53,6 +53,7 @@ export function GroupSelectionBox3D() {
// Re-measure once the meshes settle after a scene change (undo included). // Re-measure once the meshes settle after a scene change (undo included).
const meshEpoch = useMeshSettleEpoch(nodes) const meshEpoch = useMeshSettleEpoch(nodes)
const box = useMemo(() => { const box = useMemo(() => {
void meshEpoch
if (participantIds.length === 0) return null if (participantIds.length === 0) return null
const fullIds = expandToComponent(participantIds, nodes, levelId) const fullIds = expandToComponent(participantIds, nodes, levelId)
const world = computeGroupBox(fullIds) const world = computeGroupBox(fullIds)
@@ -69,7 +70,6 @@ export function GroupSelectionBox3D() {
], ],
center, center,
} }
// biome-ignore lint/correctness/useExhaustiveDependencies: meshEpoch re-measures settled meshes
}, [participantIds, nodes, levelId, meshEpoch]) }, [participantIds, nodes, levelId, meshEpoch])
// Dashed wireframe. Built per box size (rare — selection / commit changes) // Dashed wireframe. Built per box size (rare — selection / commit changes)
@@ -0,0 +1,96 @@
import { beforeEach, describe, expect, test } from 'bun:test'
import {
type AnyNode,
type AnyNodeId,
runAsSingleSceneHistoryStep,
useScene,
} from '@pascal-app/core'
import { commitHandleDragPatch } from './handle-drag-history'
type RafFn = (callback: (time: number) => void) => number
;(globalThis as { requestAnimationFrame?: RafFn }).requestAnimationFrame ??= (callback) => {
callback(0)
return 0
}
;(globalThis as { cancelAnimationFrame?: (id: number) => void }).cancelAnimationFrame ??= () => {}
const NODE_ID = 'shelf_handle-drag-history' as AnyNodeId
const COMPANION_NODE_ID = 'shelf_handle-drag-history-companion' as AnyNodeId
function shelf(depth: number, id = NODE_ID): AnyNode {
return {
id,
type: 'shelf',
object: 'node',
parentId: null,
visible: true,
metadata: {},
children: [],
position: [0, 0, 0],
rotation: [0, 0, 0],
width: 1,
depth,
thickness: 0.04,
height: 0.9,
style: 'wall-shelf',
rows: 1,
columns: 1,
withBack: false,
withSides: true,
withBottom: false,
bracketStyle: 'minimal',
} as AnyNode
}
describe('commitHandleDragPatch', () => {
beforeEach(() => {
useScene.setState({ nodes: {}, rootNodeIds: [], dirtyNodes: new Set() } as never)
useScene.temporal.getState().clear()
useScene.temporal.getState().resume()
})
test('records the final patch as one undo step after preview history is resumed', () => {
useScene.getState().createNode(shelf(0.3))
const pastCount = useScene.temporal.getState().pastStates.length
useScene.temporal.getState().pause()
commitHandleDragPatch({
patch: { depth: 0.7 },
resumeHistory: () => useScene.temporal.getState().resume(),
runAsSingleHistoryStep: (run) => runAsSingleSceneHistoryStep(useScene, run),
commit: (patch) => useScene.getState().updateNode(NODE_ID, patch),
})
expect(useScene.temporal.getState().pastStates).toHaveLength(pastCount + 1)
expect((useScene.getState().nodes[NODE_ID] as { depth: number }).depth).toBe(0.7)
useScene.temporal.getState().undo()
expect(useScene.getState().nodes[NODE_ID]).toBeDefined()
expect((useScene.getState().nodes[NODE_ID] as { depth: number }).depth).toBe(0.3)
})
test('records a composite multi-node commit as one undo step', () => {
useScene.getState().createNode(shelf(0.3))
useScene.getState().createNode(shelf(0.4, COMPANION_NODE_ID))
useScene.temporal.getState().clear()
useScene.temporal.getState().pause()
commitHandleDragPatch({
patch: { selectedDepth: 0.7, companionDepth: 0.2 },
resumeHistory: () => useScene.temporal.getState().resume(),
runAsSingleHistoryStep: (run) => runAsSingleSceneHistoryStep(useScene, run),
commit: ({ selectedDepth, companionDepth }) => {
useScene.getState().updateNode(NODE_ID, { depth: selectedDepth })
useScene.getState().updateNode(COMPANION_NODE_ID, { depth: companionDepth })
},
})
expect(useScene.temporal.getState().pastStates).toHaveLength(1)
expect((useScene.getState().nodes[NODE_ID] as { depth: number }).depth).toBe(0.7)
expect((useScene.getState().nodes[COMPANION_NODE_ID] as { depth: number }).depth).toBe(0.2)
useScene.temporal.getState().undo()
expect((useScene.getState().nodes[NODE_ID] as { depth: number }).depth).toBe(0.3)
expect((useScene.getState().nodes[COMPANION_NODE_ID] as { depth: number }).depth).toBe(0.4)
})
})
@@ -0,0 +1,14 @@
export function commitHandleDragPatch<T>({
commit,
patch,
resumeHistory,
runAsSingleHistoryStep,
}: {
commit: (patch: T) => void
patch: T
resumeHistory: () => void
runAsSingleHistoryStep: (run: () => void) => void
}) {
resumeHistory()
runAsSingleHistoryStep(() => commit(patch))
}
@@ -0,0 +1,26 @@
import { describe, expect, mock, test } from 'bun:test'
import type { AnyNodeId } from '@pascal-app/core'
import { replacePreviewOverrideIds } from './preview-overrides'
const FIRST_ID = 'cabinet_first' as AnyNodeId
const SECOND_ID = 'cabinet_second' as AnyNodeId
const THIRD_ID = 'cabinet_third' as AnyNodeId
describe('replacePreviewOverrideIds', () => {
test('clears companion overrides that leave the active preview', () => {
const clear = mock(() => {})
const nextIds = replacePreviewOverrideIds(
new Set([FIRST_ID, SECOND_ID]),
[
[SECOND_ID, { width: 0.7 }],
[THIRD_ID, { width: 0.5 }],
],
clear,
)
expect(clear).toHaveBeenCalledTimes(1)
expect(clear).toHaveBeenCalledWith(FIRST_ID)
expect(nextIds).toEqual(new Set([SECOND_ID, THIRD_ID]))
})
})
@@ -0,0 +1,13 @@
import type { AnyNode, AnyNodeId } from '@pascal-app/core'
export function replacePreviewOverrideIds(
activeIds: ReadonlySet<AnyNodeId>,
entries: ReadonlyArray<readonly [AnyNodeId, Partial<AnyNode>]>,
clear: (id: AnyNodeId) => void,
): Set<AnyNodeId> {
const nextIds = new Set(entries.map(([id]) => id))
for (const id of activeIds) {
if (!nextIds.has(id)) clear(id)
}
return nextIds
}
@@ -0,0 +1,52 @@
import { describe, expect, it, mock } from 'bun:test'
import { resolveResizeSnapValue } from './resize-snap'
describe('resolveResizeSnapValue', () => {
it('applies only magnetic snapping in lines mode', () => {
const magneticSnap = mock(() => 0.6)
expect(
resolveResizeSnapValue({
rawValue: 0.59,
gridSnapEnabled: true,
gridSnapActive: false,
gridSnapStep: 0.1,
magneticSnapActive: true,
magneticSnap,
}),
).toBe(0.6)
expect(magneticSnap).toHaveBeenCalledWith(0.59)
})
it('applies only grid snapping in grid mode', () => {
const magneticSnap = mock(() => 0.6)
expect(
resolveResizeSnapValue({
rawValue: 0.56,
gridSnapEnabled: true,
gridSnapActive: true,
gridSnapStep: 0.1,
magneticSnapActive: false,
magneticSnap,
}),
).toBeCloseTo(0.6)
expect(magneticSnap).not.toHaveBeenCalled()
})
it('keeps the raw value in off mode', () => {
const magneticSnap = mock(() => 0.6)
expect(
resolveResizeSnapValue({
rawValue: 0.56,
gridSnapEnabled: true,
gridSnapActive: false,
gridSnapStep: 0.1,
magneticSnapActive: false,
magneticSnap,
}),
).toBe(0.56)
expect(magneticSnap).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,23 @@
import { snapScalar } from '@pascal-app/core'
export function resolveResizeSnapValue({
rawValue,
gridSnapEnabled,
gridSnapActive,
gridSnapStep,
magneticSnapActive,
magneticSnap,
}: {
rawValue: number
gridSnapEnabled: boolean
gridSnapActive: boolean
gridSnapStep: number
magneticSnapActive: boolean
magneticSnap?: (value: number) => number
}): number {
const gridValue =
gridSnapEnabled && gridSnapActive && gridSnapStep > 0
? snapScalar(rawValue, gridSnapStep)
: rawValue
return magneticSnapActive && magneticSnap ? magneticSnap(gridValue) : gridValue
}
@@ -5,6 +5,7 @@ import {
type AnyNodeId, type AnyNodeId,
type Cursor, type Cursor,
createSceneApi, createSceneApi,
runAsSingleSceneHistoryStep,
useLiveNodeOverrides, useLiveNodeOverrides,
useScene, useScene,
} from '@pascal-app/core' } from '@pascal-app/core'
@@ -15,6 +16,7 @@ import { type Camera, type Object3D, type Plane, type Ray, Vector2, type Vector3
import { isHistoryShortcut } from '../../../lib/history' import { isHistoryShortcut } from '../../../lib/history'
import { sfxEmitter } from '../../../lib/sfx-bus' import { sfxEmitter } from '../../../lib/sfx-bus'
import { suppressBoxSelectForPointer } from '../../tools/select/box-select-state' import { suppressBoxSelectForPointer } from '../../tools/select/box-select-state'
import { commitHandleDragPatch } from './handle-drag-history'
export type HandleDragControls = { export type HandleDragControls = {
onStart: (index: number, snapshot: AnyNode) => void onStart: (index: number, snapshot: AnyNode) => void
@@ -174,6 +176,13 @@ export function useHandleDrag(args: UseHandleDragArgs) {
session.onBegin?.() session.onBegin?.()
let lastPatch: Partial<AnyNode> | null = null let lastPatch: Partial<AnyNode> | null = null
let historyPaused = true
const resumeHistory = () => {
if (!historyPaused) return
historyPaused = false
useScene.temporal.getState().resume()
}
const onMove = (moveEvent: PointerEvent) => { const onMove = (moveEvent: PointerEvent) => {
const patch = session.move({ event: moveEvent, getPointerRay, intersectPlane }) const patch = session.move({ event: moveEvent, getPointerRay, intersectPlane })
@@ -193,7 +202,7 @@ export function useHandleDrag(args: UseHandleDragArgs) {
if (document.body.style.cursor === cursor) { if (document.body.style.cursor === cursor) {
document.body.style.cursor = '' document.body.style.cursor = ''
} }
useScene.temporal.getState().resume() resumeHistory()
useViewer.getState().setInputDragging(false) useViewer.getState().setInputDragging(false)
setIsDragging(false) setIsDragging(false)
session.onEnd?.() session.onEnd?.()
@@ -212,11 +221,12 @@ export function useHandleDrag(args: UseHandleDragArgs) {
swallowNextClick() swallowNextClick()
sfxEmitter.emit('sfx:item-place') sfxEmitter.emit('sfx:item-place')
if (lastPatch) { if (lastPatch) {
if (session.commit) { commitHandleDragPatch({
session.commit(lastPatch) patch: lastPatch,
} else { resumeHistory,
sceneApi.update(overrideId, lastPatch) runAsSingleHistoryStep: (run) => runAsSingleSceneHistoryStep(useScene, run),
} commit: session.commit ?? ((patch) => sceneApi.update(overrideId, patch)),
})
} }
clearOverride() clearOverride()
cleanup() cleanup()
@@ -14,7 +14,6 @@ import {
nodeRegistry, nodeRegistry,
type RadialResizeHandle, type RadialResizeHandle,
sceneRegistry, sceneRegistry,
snapScalar,
type TapActionHandle, type TapActionHandle,
useLiveNodeOverrides, useLiveNodeOverrides,
useScene, useScene,
@@ -44,11 +43,10 @@ import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js
import { MeshBasicNodeMaterial } from 'three/webgpu' import { MeshBasicNodeMaterial } from 'three/webgpu'
import { EDITOR_LAYER } from '../../lib/constants' import { EDITOR_LAYER } from '../../lib/constants'
import { RESIZE_HANDLE_DRAG_LABEL, ROTATE_HANDLE_DRAG_LABEL } from '../../lib/contextual-help' import { RESIZE_HANDLE_DRAG_LABEL, ROTATE_HANDLE_DRAG_LABEL } from '../../lib/contextual-help'
import { resolveDirectManipulationNode } from '../../lib/direct-manipulation'
import { createEditorApi } from '../../lib/editor-api' import { createEditorApi } from '../../lib/editor-api'
import { sfxEmitter } from '../../lib/sfx-bus' import { sfxEmitter } from '../../lib/sfx-bus'
import useDirectManipulationFeedback from '../../store/use-direct-manipulation-feedback' import useDirectManipulationFeedback from '../../store/use-direct-manipulation-feedback'
import useEditor from '../../store/use-editor' import useEditor, { isGridSnapActive, isMagneticSnapActive } from '../../store/use-editor'
import useInteractionScope, { import useInteractionScope, {
useEndpointReshape, useEndpointReshape,
useIsCurveReshape, useIsCurveReshape,
@@ -64,6 +62,8 @@ import {
HandleArrow, HandleArrow,
NO_RAYCAST, NO_RAYCAST,
} from './handles/handle-arrow' } from './handles/handle-arrow'
import { replacePreviewOverrideIds } from './handles/preview-overrides'
import { resolveResizeSnapValue } from './handles/resize-snap'
import { type HandleDragControls, useHandleDrag } from './handles/use-handle-drag' import { type HandleDragControls, useHandleDrag } from './handles/use-handle-drag'
// Pooled scratch for the handle rig's world-relative pose mapping. // Pooled scratch for the handle rig's world-relative pose mapping.
@@ -213,8 +213,7 @@ export function NodeArrowHandles() {
const selectedId = selectedIds.length === 1 ? selectedIds[0] : activeRotateNodeId const selectedId = selectedIds.length === 1 ? selectedIds[0] : activeRotateNodeId
const rawNode = useScene((state) => { const rawNode = useScene((state) => {
if (!selectedId) return null if (!selectedId) return null
const selectedNode = state.nodes[selectedId as AnyNodeId] return state.nodes[selectedId as AnyNodeId] ?? null
return selectedNode ? resolveDirectManipulationNode(selectedNode, state.nodes) : null
}) })
// Merge any live drag override so the arrows themselves (positions, // Merge any live drag override so the arrows themselves (positions,
@@ -234,7 +233,7 @@ export function NodeArrowHandles() {
if (!(node && def?.handles)) return null if (!(node && def?.handles)) return null
const all = const all =
typeof def.handles === 'function' typeof def.handles === 'function'
? def.handles(node as never) ? def.handles(node as never, descriptorSceneApi)
: (def.handles as HandleDescriptor[]) : (def.handles as HandleDescriptor[])
// The whole-node move-cross gizmo is gone: moving is now click-to-move on // The whole-node move-cross gizmo is gone: moving is now click-to-move on
// the selected node body (see selection-manager). Drop both flavours — the // the selected node body (see selection-manager). Drop both flavours — the
@@ -718,10 +717,6 @@ function LinearArrow({
const initialValue = descriptor.currentValue(initialNode) const initialValue = descriptor.currentValue(initialNode)
const minBound = resolveBound(descriptor.min, Number.NEGATIVE_INFINITY, initialNode, sceneApi) const minBound = resolveBound(descriptor.min, Number.NEGATIVE_INFINITY, initialNode, sceneApi)
const maxBound = resolveBound(descriptor.max, Number.POSITIVE_INFINITY, initialNode, sceneApi) const maxBound = resolveBound(descriptor.max, Number.POSITIVE_INFINITY, initialNode, sceneApi)
const gridSnapStep =
descriptor.kind === 'linear-resize' && descriptor.gridSnap
? useEditor.getState().gridSnapStep
: null
const factor = const factor =
descriptor.kind === 'radial-resize' descriptor.kind === 'radial-resize'
? 1 ? 1
@@ -735,6 +730,7 @@ function LinearArrow({
// when the (snapped + clamped) value actually changes, so the cue // when the (snapped + clamped) value actually changes, so the cue
// tracks real size steps instead of every sub-pixel pointer jitter. // tracks real size steps instead of every sub-pixel pointer jitter.
let lastTickValue = initialValue let lastTickValue = initialValue
let previewOverrideIds = new Set<AnyNodeId>()
return { return {
overrideId, overrideId,
@@ -756,6 +752,10 @@ function LinearArrow({
onEnd: () => { onEnd: () => {
useInteractionScope.getState().endIf((sc) => sc.kind === 'handle-drag') useInteractionScope.getState().endIf((sc) => sc.kind === 'handle-drag')
if (onDrag) useOpeningGuides.getState().clear() if (onDrag) useOpeningGuides.getState().clear()
for (const previewId of previewOverrideIds) {
useLiveNodeOverrides.getState().clear(previewId)
useScene.getState().markDirty(previewId)
}
}, },
move: ({ event: moveEvent, getPointerRay: getMovePointerRay }) => { move: ({ event: moveEvent, getPointerRay: getMovePointerRay }) => {
const currentPointer = const currentPointer =
@@ -766,16 +766,46 @@ function LinearArrow({
) / localToWorldScale ) / localToWorldScale
const delta = currentPointer - initialPointer const delta = currentPointer - initialPointer
const rawNext = initialValue + delta * factor const rawNext = initialValue + delta * factor
const snappedNext = const linearDescriptor = descriptor.kind === 'linear-resize' ? descriptor : null
!moveEvent.shiftKey && gridSnapStep && gridSnapStep > 0 const snappedNext = resolveResizeSnapValue({
? snapScalar(rawNext, gridSnapStep) rawValue: rawNext,
: rawNext gridSnapEnabled: linearDescriptor?.gridSnap === true,
gridSnapActive: isGridSnapActive(),
gridSnapStep: useEditor.getState().gridSnapStep,
magneticSnapActive: isMagneticSnapActive(),
magneticSnap: linearDescriptor?.magneticSnap
? (value) => linearDescriptor.magneticSnap?.(initialNode, value, sceneApi) ?? value
: undefined,
})
const next = Math.min(maxBound, Math.max(minBound, snappedNext)) const next = Math.min(maxBound, Math.max(minBound, snappedNext))
if (next !== lastTickValue) { if (next !== lastTickValue) {
lastTickValue = next lastTickValue = next
sfxEmitter.emit('sfx:resize') sfxEmitter.emit('sfx:resize')
} }
const patch = descriptor.apply(initialNode as never, next, sceneApi) as Partial<AnyNode> const patch = descriptor.apply(initialNode as never, next, sceneApi) as Partial<AnyNode>
if (descriptor.kind === 'linear-resize' && descriptor.previewOverrides) {
const previewEntries = descriptor.previewOverrides(initialNode as never, next, sceneApi)
const nextPreviewOverrideIds = replacePreviewOverrideIds(
previewOverrideIds,
previewEntries,
(previewId) => {
useLiveNodeOverrides.getState().clear(previewId)
useScene.getState().markDirty(previewId)
},
)
useLiveNodeOverrides
.getState()
.setMany(
previewEntries.map(([id, previewPatch]) => [
id,
previewPatch as Record<string, unknown>,
]),
)
for (const [previewId] of previewEntries) {
useScene.getState().markDirty(previewId)
}
previewOverrideIds = nextPreviewOverrideIds
}
// Let the kind publish live guides for the edge being resized. // Let the kind publish live guides for the edge being resized.
onDrag?.({ ...(initialNode as object), ...patch } as AnyNode, sceneApi) onDrag?.({ ...(initialNode as object), ...patch } as AnyNode, sceneApi)
return patch return patch
@@ -13,6 +13,7 @@ import { useEffect, useState } from 'react'
export function useMeshSettleEpoch(nodes: unknown): number { export function useMeshSettleEpoch(nodes: unknown): number {
const [epoch, setEpoch] = useState(0) const [epoch, setEpoch] = useState(0)
useEffect(() => { useEffect(() => {
void nodes
let raf2 = 0 let raf2 = 0
const raf1 = requestAnimationFrame(() => { const raf1 = requestAnimationFrame(() => {
raf2 = requestAnimationFrame(() => setEpoch((e) => e + 1)) raf2 = requestAnimationFrame(() => setEpoch((e) => e + 1))
@@ -14,6 +14,7 @@ import { useViewer } from '@pascal-app/viewer'
import { Icon } from '@iconify/react' import { Icon } from '@iconify/react'
import { Move, Trash2 } from 'lucide-react' import { Move, Trash2 } from 'lucide-react'
import { type ComponentType, lazy, Suspense, useCallback } from 'react' import { type ComponentType, lazy, Suspense, useCallback } from 'react'
import { resolveMoveActionNode } from '../../../lib/direct-manipulation'
import { sfxEmitter } from '../../../lib/sfx-bus' import { sfxEmitter } from '../../../lib/sfx-bus'
import { collectZoneContentIds } from '../../../lib/zone-content' import { collectZoneContentIds } from '../../../lib/zone-content'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
@@ -90,10 +91,11 @@ export function ParametricInspector({
const handleMove = useCallback(() => { const handleMove = useCallback(() => {
if (!selectedId) return if (!selectedId) return
const node = useScene.getState().nodes[selectedId] const sceneNodes = useScene.getState().nodes
const node = sceneNodes[selectedId]
if (!node) return if (!node) return
sfxEmitter.emit('sfx:item-pick') sfxEmitter.emit('sfx:item-pick')
useEditor.getState().setMovingNode(node as any) useEditor.getState().setMovingNode(resolveMoveActionNode(node, sceneNodes) as any)
clearSelection() clearSelection()
}, [selectedId, clearSelection]) }, [selectedId, clearSelection])
@@ -11,6 +11,7 @@ import {
canDirectMoveNode, canDirectMoveNode,
resolveDirectManipulationNode, resolveDirectManipulationNode,
resolveDirectRotationDragDelta, resolveDirectRotationDragDelta,
resolveMoveActionNode,
snapDirectRotationDelta, snapDirectRotationDelta,
} from './direct-manipulation' } from './direct-manipulation'
@@ -190,3 +191,85 @@ describe('resolveDirectManipulationNode', () => {
).toBe(parent) ).toBe(parent)
}) })
}) })
describe('resolveMoveActionNode', () => {
test('routes a nested same-kind child move to its host', () => {
const kind = 'move-action-nested-kind-test'
registerTestDefinition(kind, {
capabilities: {
movable: {
axes: ['x', 'z'],
parentFrame: {
resolveParent: (node: AnyNode, nodes: Readonly<Record<string, AnyNode>>) =>
(node.parentId ? nodes[node.parentId] : null) ?? null,
parentRotationY: () => 0,
localToPlan: (_parent: AnyNode, local: readonly [number, number, number]) => [
local[0],
local[1],
local[2],
],
planToLocal: (_parent: AnyNode, planX: number, localY: number, planZ: number) => [
planX,
localY,
planZ,
],
},
},
},
})
const parent = { id: 'move_action_parent', type: kind } as unknown as AnyNode
const child = {
id: 'move_action_child',
type: kind,
parentId: parent.id,
} as unknown as AnyNode
expect(
resolveMoveActionNode(child, {
[parent.id]: parent,
[child.id]: child,
}),
).toBe(parent)
})
test('keeps a child independently movable when its parent is a different kind', () => {
const parentKind = 'move-action-parent-kind-test'
const childKind = 'move-action-child-kind-test'
registerTestDefinition(parentKind, {})
registerTestDefinition(childKind, {
capabilities: {
movable: {
axes: ['x', 'z'],
parentFrame: {
resolveParent: (node: AnyNode, nodes: Readonly<Record<string, AnyNode>>) =>
(node.parentId ? nodes[node.parentId] : null) ?? null,
parentRotationY: () => 0,
localToPlan: (_parent: AnyNode, local: readonly [number, number, number]) => [
local[0],
local[1],
local[2],
],
planToLocal: (_parent: AnyNode, planX: number, localY: number, planZ: number) => [
planX,
localY,
planZ,
],
},
},
},
})
const parent = { id: 'move_action_run', type: parentKind } as unknown as AnyNode
const child = {
id: 'move_action_module',
type: childKind,
parentId: parent.id,
} as unknown as AnyNode
expect(
resolveMoveActionNode(child, {
[parent.id]: parent,
[child.id]: child,
}),
).toBe(child)
})
})
@@ -74,6 +74,15 @@ export function resolveDirectManipulationNode(
return parent && canDirectRotateNode(parent) ? parent : target return parent && canDirectRotateNode(parent) ? parent : target
} }
export function resolveMoveActionNode(
node: AnyNode,
nodes: Readonly<Record<string, AnyNode | undefined>>,
): AnyNode {
const parentFrame = nodeRegistry.get(node.type)?.capabilities?.movable?.parentFrame
const parent = parentFrame?.resolveParent(node, nodes as Readonly<Record<string, AnyNode>>)
return parent?.type === node.type ? parent : node
}
export function snapDirectRotationDelta(delta: number, free: boolean): number { export function snapDirectRotationDelta(delta: number, free: boolean): number {
return free ? delta : Math.round(delta / DEFAULT_ANGLE_STEP) * DEFAULT_ANGLE_STEP return free ? delta : Math.round(delta / DEFAULT_ANGLE_STEP) * DEFAULT_ANGLE_STEP
} }
@@ -0,0 +1,33 @@
const activeAnimations = new WeakMap<HTMLElement, Animation>()
export function playBlockedQuickActionFeedback(button: HTMLButtonElement, reducedMotion: boolean) {
const content = button.querySelector<HTMLElement>('[data-quick-action-feedback]')
if (!content) return
activeAnimations.get(content)?.cancel()
content.style.color = 'var(--destructive)'
const keyframes: Keyframe[] = reducedMotion
? [{ opacity: 1 }, { opacity: 1 }]
: [
{ transform: 'translateX(0)' },
{ transform: 'translateX(-2.5px)', offset: 0.18 },
{ transform: 'translateX(2px)', offset: 0.38 },
{ transform: 'translateX(-1.5px)', offset: 0.58 },
{ transform: 'translateX(1px)', offset: 0.76 },
{ transform: 'translateX(0)' },
]
const animation = content.animate(keyframes, {
duration: reducedMotion ? 240 : 320,
easing: 'cubic-bezier(0.22, 1, 0.36, 1)',
})
activeAnimations.set(content, animation)
void animation.finished
.catch(() => undefined)
.finally(() => {
if (activeAnimations.get(content) !== animation) return
activeAnimations.delete(content)
content.style.removeProperty('color')
})
}
@@ -0,0 +1,94 @@
import { describe, expect, test } from 'bun:test'
import type { AnyNode, AnyNodeId } from '@pascal-app/core'
import { collectQuickActionNodeScope } from './quick-action-nodes'
function fixtureNode({
id,
parentId,
children = [],
type = 'item',
}: {
id: string
parentId?: string
children?: string[]
type?: string
}) {
return {
id,
type,
parentId,
children,
} as unknown as AnyNode
}
describe('collectQuickActionNodeScope', () => {
test('includes nested children of a selected node sibling', () => {
const run = fixtureNode({
id: 'run',
children: ['left-base', 'selected-base'],
})
const leftBase = fixtureNode({
id: 'left-base',
parentId: run.id,
children: ['expanded-wall'],
})
const selectedBase = fixtureNode({ id: 'selected-base', parentId: run.id })
const expandedWall = fixtureNode({ id: 'expanded-wall', parentId: leftBase.id })
const nodes = Object.fromEntries(
[run, leftBase, selectedBase, expandedWall].map((node) => [node.id, node]),
) as Record<AnyNodeId, AnyNode>
const collected = collectQuickActionNodeScope(nodes, selectedBase.id)
expect(collected?.[expandedWall.id as AnyNodeId]).toBe(expandedWall)
})
test('includes other run subtrees when the provider declares level scope', () => {
const level = fixtureNode({
id: 'level',
type: 'level',
children: ['selected-run', 'other-run'],
})
const selectedRun = fixtureNode({
id: 'selected-run',
parentId: level.id,
children: ['selected-base'],
})
const selectedBase = fixtureNode({ id: 'selected-base', parentId: selectedRun.id })
const otherRun = fixtureNode({
id: 'other-run',
parentId: level.id,
children: ['other-base'],
})
const otherBase = fixtureNode({
id: 'other-base',
parentId: otherRun.id,
children: ['expanded-wall'],
})
const expandedWall = fixtureNode({ id: 'expanded-wall', parentId: otherBase.id })
const nodes = Object.fromEntries(
[level, selectedRun, selectedBase, otherRun, otherBase, expandedWall].map((node) => [
node.id,
node,
]),
) as Record<AnyNodeId, AnyNode>
expect(
collectQuickActionNodeScope(nodes, selectedBase.id)?.[expandedWall.id as AnyNodeId],
).toBeUndefined()
expect(
collectQuickActionNodeScope(nodes, selectedBase.id, 'level')?.[expandedWall.id as AnyNodeId],
).toBe(expandedWall)
})
test('fails closed when a level-scoped provider has no level ancestor', () => {
const run = fixtureNode({ id: 'run', children: ['selected-base'] })
const selectedBase = fixtureNode({ id: 'selected-base', parentId: run.id })
const nodes = Object.fromEntries([run, selectedBase].map((node) => [node.id, node])) as Record<
AnyNodeId,
AnyNode
>
expect(collectQuickActionNodeScope(nodes, selectedBase.id, 'level')).toBeNull()
})
})
@@ -0,0 +1,47 @@
import type { AnyNode, AnyNodeId, NodeQuickActionNodeScope } from '@pascal-app/core'
export function collectQuickActionNodeScope(
nodes: Record<AnyNodeId, AnyNode>,
selectedId: string,
scope: NodeQuickActionNodeScope = 'family',
): Record<AnyNodeId, AnyNode> | null {
const selected = nodes[selectedId as AnyNodeId]
if (!selected) return null
const collected: Record<AnyNodeId, AnyNode> = {}
const addSubtree = (rootId: string | null | undefined) => {
if (!rootId) return
const pending = [rootId]
while (pending.length > 0) {
const id = pending.pop()
if (!id || collected[id as AnyNodeId]) continue
const node = nodes[id as AnyNodeId]
if (!node) continue
collected[node.id as AnyNodeId] = node
for (const childId of (node as { children?: readonly string[] }).children ?? []) {
pending.push(childId)
}
}
}
if (scope === 'level') {
const visited = new Set<AnyNodeId>()
let current: AnyNode | undefined = selected
while (current && !visited.has(current.id as AnyNodeId)) {
visited.add(current.id as AnyNodeId)
if (current.type === 'level') {
addSubtree(current.id)
return collected
}
current = current.parentId ? nodes[current.parentId as AnyNodeId] : undefined
}
return null
}
addSubtree(selected.id)
addSubtree(selected.parentId)
return collected
}
+21 -2
View File
@@ -1,4 +1,5 @@
import { describe, expect, it } from 'bun:test' import { describe, expect, it } from 'bun:test'
import { ROTATE_HANDLE_DRAG_LABEL } from './contextual-help'
import { import {
cycleSnappingModeIn, cycleSnappingModeIn,
DEFAULT_SNAPPING_MODE, DEFAULT_SNAPPING_MODE,
@@ -84,11 +85,20 @@ describe('snapContextOf (profile-driven, node-declared)', () => {
zone: 'structural', zone: 'structural',
} }
const profileOf = (t: string) => declared[t] const profileOf = (t: string) => declared[t]
const profileOfNode = (id: string) =>
id === 'cabinet-module_1' ? declared.item : id === 'wall_1' ? declared.wall : undefined
const ctx = ( const ctx = (
scope: { kind: string; nodeType?: string; reshape?: string; tool?: string }, scope: {
kind: string
nodeType?: string
reshape?: string
nodeId?: string
tool?: string
handle?: string
},
mode = 'select', mode = 'select',
tool: string | null = null, tool: string | null = null,
) => snapContextOf({ scope, mode, tool, profileOf }) ) => snapContextOf({ scope, mode, tool, profileOf, profileOfNode })
it('translating a whole structural node has no angle (polygon, not wall)', () => { it('translating a whole structural node has no angle (polygon, not wall)', () => {
expect(ctx({ kind: 'moving', nodeType: 'wall' })).toBe('polygon') expect(ctx({ kind: 'moving', nodeType: 'wall' })).toBe('polygon')
@@ -96,6 +106,15 @@ describe('snapContextOf (profile-driven, node-declared)', () => {
expect(ctx({ kind: 'placing', nodeType: 'item' }, 'build', 'item')).toBe('item') expect(ctx({ kind: 'placing', nodeType: 'item' }, 'build', 'item')).toBe('item')
}) })
it('resolves handle drags from the target node profile', () => {
expect(ctx({ kind: 'handle-drag', nodeId: 'cabinet-module_1' })).toBe('item')
expect(ctx({ kind: 'handle-drag', nodeId: 'wall_1' })).toBe('polygon')
expect(ctx({ kind: 'handle-drag', nodeId: 'unknown_1' })).toBeNull()
expect(
ctx({ kind: 'handle-drag', nodeId: 'cabinet-module_1', handle: ROTATE_HANDLE_DRAG_LABEL }),
).toBeNull()
})
it('endpoint reshape is angle-bearing (wall); curve + polygon vertex edits are not', () => { it('endpoint reshape is angle-bearing (wall); curve + polygon vertex edits are not', () => {
expect(ctx({ kind: 'reshaping', reshape: 'endpoint' })).toBe('wall') expect(ctx({ kind: 'reshaping', reshape: 'endpoint' })).toBe('wall')
expect(ctx({ kind: 'reshaping', reshape: 'curve' })).toBe('polygon') expect(ctx({ kind: 'reshaping', reshape: 'curve' })).toBe('polygon')
+6 -2
View File
@@ -1,5 +1,5 @@
import type { SnapProfile } from '@pascal-app/core' import type { SnapProfile } from '@pascal-app/core'
import { GROUP_MOVE_DRAG_LABEL } from './contextual-help' import { GROUP_MOVE_DRAG_LABEL, ROTATE_HANDLE_DRAG_LABEL } from './contextual-help'
/** /**
* Snapping mode is a single global, user-cyclable control that maps onto the * Snapping mode is a single global, user-cyclable control that maps onto the
@@ -145,12 +145,13 @@ export function snapContextOf(args: {
mode: string mode: string
tool: string | null tool: string | null
profileOf: (typeOrTool: string) => SnapProfile | undefined profileOf: (typeOrTool: string) => SnapProfile | undefined
profileOfNode?: (nodeId: string) => SnapProfile | undefined
// Whether drafting a kind sets a direction (angle-lock meaningful). Injected // Whether drafting a kind sets a direction (angle-lock meaningful). Injected
// like `profileOf` so `snapping-mode` need not import the registry; defaults // like `profileOf` so `snapping-mode` need not import the registry; defaults
// to `true` (the structural draw default) when not supplied. // to `true` (the structural draw default) when not supplied.
draftDirectionalOf?: (typeOrTool: string) => boolean draftDirectionalOf?: (typeOrTool: string) => boolean
}): SnapContext | null { }): SnapContext | null {
const { scope, mode, tool, profileOf, draftDirectionalOf } = args const { scope, mode, tool, profileOf, profileOfNode, draftDirectionalOf } = args
// The group-move gizmo translates the whole selection — same no-angle // The group-move gizmo translates the whole selection — same no-angle
// treatment as a single-node move, so Shift cycles the 'item' modes and the // treatment as a single-node move, so Shift cycles the 'item' modes and the
// HUD shows the item snapping chips for the drag. // HUD shows the item snapping chips for the drag.
@@ -158,6 +159,9 @@ export function snapContextOf(args: {
return 'item' return 'item'
} }
switch (scope.kind) { switch (scope.kind) {
case 'handle-drag':
if (scope.handle === ROTATE_HANDLE_DRAG_LABEL) return null
return scope.nodeId ? contextForProfile(profileOfNode?.(scope.nodeId), false) : null
case 'placing': case 'placing':
case 'moving': case 'moving':
// A whole-node translate never sets direction → no angle. // A whole-node translate never sets direction → no angle.
+4
View File
@@ -1378,6 +1378,10 @@ export function getActiveSnapContext(): SnapContext | null {
mode: editor.mode, mode: editor.mode,
tool: editor.tool, tool: editor.tool,
profileOf: (typeOrTool) => nodeRegistry.get(typeOrTool)?.snapProfile, profileOf: (typeOrTool) => nodeRegistry.get(typeOrTool)?.snapProfile,
profileOfNode: (nodeId) => {
const node = useScene.getState().nodes[nodeId as AnyNodeId]
return node ? nodeRegistry.get(node.type)?.snapProfile : undefined
},
draftDirectionalOf: (typeOrTool) => nodeRegistry.get(typeOrTool)?.snapDraftDirectional ?? true, draftDirectionalOf: (typeOrTool) => nodeRegistry.get(typeOrTool)?.snapDraftDirectional ?? true,
}) })
} }
@@ -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', () => { describe('cabinet continuous placement', () => {
test('fills a stretch with full modules plus a partial end module when needed', () => { 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).toHaveLength(3)
expect(widths[0]).toBeCloseTo(0.6) expect(widths[0]).toBeCloseTo(0.5)
expect(widths[1]).toBeCloseTo(0.6) expect(widths[1]).toBeCloseTo(0.5)
expect(widths[2]).toBeCloseTo(0.15) expect(widths[2]).toBeCloseTo(0.15)
}) })
test('drops a tiny remainder below the minimum end-module width', () => { 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', () => { 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).toHaveLength(3)
expect(stretch.modules[0]?.x).toBeCloseTo(0) expect(stretch.modules[0]?.x).toBeCloseTo(0)
expect(stretch.modules[0]?.width).toBeCloseTo(0.6) expect(stretch.modules[0]?.width).toBeCloseTo(0.6)
expect(stretch.modules[1]?.x).toBeCloseTo(0.6) expect(stretch.modules[1]?.x).toBeCloseTo(0.55)
expect(stretch.modules[1]?.width).toBeCloseTo(0.6) expect(stretch.modules[1]?.width).toBeCloseTo(0.5)
expect(stretch.modules[2]?.x).toBeCloseTo(1.125) expect(stretch.modules[2]?.x).toBeCloseTo(1.05)
expect(stretch.modules[2]?.width).toBeCloseTo(0.45) expect(stretch.modules[2]?.width).toBeCloseTo(0.5)
expect(stretch.length).toBeCloseTo(1.65) expect(stretch.length).toBeCloseTo(1.6)
expect(stretch.centerLocalX).toBeCloseTo(0.525) expect(stretch.centerLocalX).toBeCloseTo(0.5)
expect(stretch.direction).toBe(1) expect(stretch.direction).toBe(1)
expect(cabinetStretchExitSide(stretch)).toBe('right') 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', () => { 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).toHaveLength(3)
expect(stretch.modules[0]?.x).toBeCloseTo(0) expect(stretch.modules[0]?.x).toBeCloseTo(0)
expect(stretch.modules[0]?.width).toBeCloseTo(0.6) expect(stretch.modules[0]?.width).toBeCloseTo(0.6)
expect(stretch.modules[1]?.x).toBeCloseTo(-0.6) expect(stretch.modules[1]?.x).toBeCloseTo(-0.55)
expect(stretch.modules[1]?.width).toBeCloseTo(0.6) expect(stretch.modules[1]?.width).toBeCloseTo(0.5)
expect(stretch.modules[2]?.x).toBeCloseTo(-1.125) expect(stretch.modules[2]?.x).toBeCloseTo(-1.05)
expect(stretch.modules[2]?.width).toBeCloseTo(0.45) expect(stretch.modules[2]?.width).toBeCloseTo(0.5)
expect(stretch.centerLocalX).toBeCloseTo(-0.525) expect(stretch.centerLocalX).toBeCloseTo(-0.5)
expect(stretch.direction).toBe(-1) expect(stretch.direction).toBe(-1)
expect(cabinetStretchExitSide(stretch)).toBe('left') 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', () => { 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]?.width).toBeCloseTo(0.58)
expect(stretch.modules[0]?.x).toBeCloseTo(0) 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) expect(stretch.modules[1]?.x).toBeGreaterThan(0.58 / 2)
}) })
@@ -102,8 +102,8 @@ describe('cabinet continuous placement', () => {
rawPlanPosition: [0.05, 0, 0], rawPlanPosition: [0.05, 0, 0],
}) })
expect(stretch.modules.map((module) => module.width)).toEqual([0.58, 0.6]) expect(stretch.modules.map((module) => module.width)).toEqual([0.58, 0.5])
expect(stretch.length).toBeCloseTo(1.18) expect(stretch.length).toBeCloseTo(1.08)
}) })
test('prefers continuing straight when the cursor moves forward from the committed end', () => { 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 { describe, expect, test } from 'bun:test'
import { cabinetModuleDefinition } from '../definition' import { cabinetModuleDefinition } from '../definition'
import { CabinetModuleNode } from '../schema' import { CabinetModuleNode, CabinetNode } from '../schema'
describe('cabinet module drag bounds', () => { describe('cabinet module drag bounds', () => {
test('uses schema dimensions instead of measured render geometry', () => { 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?.size).toEqual([0.82, 0.88, 0.64])
expect(bounds?.center).toEqual([0, 0.44, 0]) 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 { 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 type { BufferAttribute, Mesh, Object3D } from 'three'
import { Box3 } from 'three' import { Box3 } from 'three'
import { bakeCabinetAnimationClip } from '../animation' import { bakeCabinetAnimationClip } from '../animation'
@@ -1316,10 +1322,15 @@ describe('buildCabinetGeometry — run countertops', () => {
'rendered', 'rendered',
false, 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(plinths).toHaveLength(2)
expect(plinth.max.z).toBeCloseTo(fridgeZ + FRIDGE_STANDARD_DEPTH / 2 - run.toeKickDepth) 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', () => { 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) 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', () => { test('island back overhang extends the slab backward and adds a finished back panel', () => {
const run = CabinetNode.parse({ const run = CabinetNode.parse({
id: 'cabinet_island-run', id: 'cabinet_island-run',
@@ -2187,7 +2246,7 @@ describe('cabinet handles', () => {
] as const ] as const
} }
function linearHandles() { function moduleHandles() {
const node = CabinetModuleNode.parse({ const node = CabinetModuleNode.parse({
position: [0, 0.1, 0], position: [0, 0.1, 0],
width: 0.6, width: 0.6,
@@ -2197,32 +2256,335 @@ describe('cabinet handles', () => {
typeof cabinetModuleDefinition.handles === 'function' typeof cabinetModuleDefinition.handles === 'function'
? cabinetModuleDefinition.handles(node) ? cabinetModuleDefinition.handles(node)
: (cabinetModuleDefinition.handles ?? []) : (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 { return {
node, depthHandles,
handles: handles.filter( leg,
(handle): handle is LinearResizeHandle<typeof node> => handle.kind === 'linear-resize', legModule,
), sceneApi,
selectedModule,
source,
sourceModule: liveSourceModule,
} }
} }
test('width arrows resize from the chosen side instead of around center', () => { function generatedU(side: 'left' | 'right') {
const { node, handles } = linearHandles() const fixture = generatedL(side)
const leftHandle = handles.find((handle) => handle.axis === 'x' && handle.anchor === 'max') const thirdSelectedId = addCornerRun({
const rightHandle = handles.find((handle) => handle.axis === 'x' && handle.anchor === 'min') 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(leftHandle).toBeDefined()
expect(rightHandle).toBeDefined() expect(rightHandle).toBeDefined()
expect(leftHandle!.apply(node, 0.8, null as never).position?.[0]).toBeCloseTo(-0.1) 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) 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', () => { test.each([
const { node, handles } = linearHandles() ['left', -Math.PI / 2],
const depthHandle = handles.find((handle) => handle.axis === 'z') ['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).toBeDefined()
expect(depthHandle!.anchor).toBe('min') expect(depthHandle?.overrideTarget?.(run, sceneApi as never)).toBe(run.id)
expect(depthHandle!.apply(node, 0.78, null as never).position?.[2]).toBeCloseTo(0.1) 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', () => { test('run rotation keeps the cabinet bounding-box center fixed', () => {
@@ -2255,7 +2617,7 @@ describe('cabinet handles', () => {
} }
const rotateHandle = ( const rotateHandle = (
typeof cabinetDefinition.handles === 'function' typeof cabinetDefinition.handles === 'function'
? cabinetDefinition.handles(run) ? cabinetDefinition.handles(run, sceneApi as never)
: (cabinetDefinition.handles ?? []) : (cabinetDefinition.handles ?? [])
).find((handle) => handle.kind === 'arc-resize' && handle.shape === 'rotate') ).find((handle) => handle.kind === 'arc-resize' && handle.shape === 'rotate')
@@ -190,11 +190,15 @@ describe('cabinetModuleParentFrame.magneticSnapMatches', () => {
id: 'cabinet-module_moving', id: 'cabinet-module_moving',
parentId: nestedRun.id, parentId: nestedRun.id,
position: [0.65, 0.1, 0], position: [0.65, 0.1, 0],
width: 0.6,
depth: 0.58,
}) })
const sibling = CabinetModuleNode.parse({ const sibling = CabinetModuleNode.parse({
id: 'cabinet-module_sibling', id: 'cabinet-module_sibling',
parentId: nestedRun.id, parentId: nestedRun.id,
position: [0, 0.1, 0], position: [0, 0.1, 0],
width: 0.6,
depth: 0.58,
}) })
const nodes = Object.fromEntries( const nodes = Object.fromEntries(
[rootRun, parentModule, nestedRun, moving, sibling].map((node) => [node.id, node as AnyNode]), [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', () => { 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', () => { test('offers and runs an L-corner action from run selection using the end module', () => {
const levelId = 'level_quick_actions_corner' as AnyNodeId const levelId = 'level_quick_actions_corner' as AnyNodeId
const run = CabinetNode.parse({ const run = CabinetNode.parse({
@@ -335,6 +368,104 @@ describe('cabinet quick actions', () => {
expect(cornerRightAction?.disabled).toBeFalsy() 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', () => { test('disables L action when the corner preview has no usable width', () => {
const levelId = 'level_quick_actions_disabled-corner-wall' as AnyNodeId const levelId = 'level_quick_actions_disabled-corner-wall' as AnyNodeId
const run = CabinetNode.parse({ const run = CabinetNode.parse({
@@ -358,8 +489,8 @@ describe('cabinet quick actions', () => {
const blockingWall = WallNode.parse({ const blockingWall = WallNode.parse({
id: 'wall_quick-actions-disabled-corner-wall', id: 'wall_quick-actions-disabled-corner-wall',
parentId: levelId, parentId: levelId,
start: [-1, 0.65], start: [-1, 0.55],
end: [2, 0.65], end: [2, 0.55],
thickness: 0.2, thickness: 0.2,
}) })
const sceneApi = sceneApiFixture([run as AnyNode, source as AnyNode, blockingWall as AnyNode]) 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 { import {
addCabinetModuleSide, addCabinetModuleSide,
addCornerRun, addCornerRun,
backAlignedRunDepthOverrides,
backAlignZ,
cabinetModulesForRun,
cornerSourceWidthOverridesForDerivedDepth,
previewCornerAdditionLayout, previewCornerAdditionLayout,
previewCornerRunsFromRunSources,
syncCornerRunsFromRunSources,
syncCornerRunsFromSourceModule, syncCornerRunsFromSourceModule,
syncCornerStyleGroupFromRun, syncCornerStyleGroupFromRun,
wallBottomHeightForTallAlignment, wallBottomHeightForTallAlignment,
wallChildOf,
} from '../run-ops' } from '../run-ops'
import { CabinetModuleNode, CabinetNode } from '../schema' import { CabinetModuleNode, CabinetNode } from '../schema'
@@ -74,6 +81,89 @@ function resolveCabinetWorldTransform(
} }
describe('addCabinetModuleSide', () => { 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', () => { test('shrinks a newly added corner-end base cabinet to the remaining wall clearance', () => {
const levelId = 'level_add-side-wall-clearance' as AnyNodeId const levelId = 'level_add-side-wall-clearance' as AnyNodeId
const run = CabinetNode.parse({ const run = CabinetNode.parse({
@@ -109,8 +199,8 @@ describe('addCabinetModuleSide', () => {
expect(id).toBeTruthy() expect(id).toBeTruthy()
const added = sceneApi.get<CabinetModuleNode>(id!) const added = sceneApi.get<CabinetModuleNode>(id!)
expect(added?.width).toBeCloseTo(0.55) expect(added?.width).toBeCloseTo(0.5)
expect(added?.position[0]).toBeCloseTo(0.725) expect(added?.position[0]).toBeCloseTo(0.7)
expect(sceneApi.get<CabinetModuleNode>(anchor.id)?.width).toBeCloseTo(0.9) expect(sceneApi.get<CabinetModuleNode>(anchor.id)?.width).toBeCloseTo(0.9)
}) })
@@ -205,8 +295,8 @@ describe('addCabinetModuleSide', () => {
expect(id).toBeTruthy() expect(id).toBeTruthy()
const added = sceneApi.get<CabinetModuleNode>(id!) const added = sceneApi.get<CabinetModuleNode>(id!)
expect(added?.width).toBeCloseTo(0.55) expect(added?.width).toBeCloseTo(0.5)
expect(added?.position[0]).toBeCloseTo(0.725) expect(added?.position[0]).toBeCloseTo(0.7)
}) })
}) })
@@ -692,6 +782,610 @@ describe('addCornerRun', () => {
expect(allCabinets.every((node) => node.handlePosition === 'center')).toBe(true) 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', () => { test('propagates front styling into linked runs even when the corner re-layout bails', () => {
const levelId = 'level_corner-style-layout-bail' as AnyNodeId const levelId = 'level_corner-style-layout-bail' as AnyNodeId
const run = CabinetNode.parse({ const run = CabinetNode.parse({
@@ -786,6 +1480,72 @@ describe('addCornerRun', () => {
expect(allCabinets.every((node) => node.frontStyle === 'raised-arch')).toBe(true) 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', () => { test('anchors the right bridge filler to the live source wall cabinet edge', () => {
const levelId = 'level_corner-bridge-anchor-right' as AnyNodeId const levelId = 'level_corner-bridge-anchor-right' as AnyNodeId
const run = CabinetNode.parse({ const run = CabinetNode.parse({
@@ -1205,7 +1965,7 @@ describe('addCornerRun', () => {
) )
const bridgeFillers = modulesOut.filter((node) => node.name === 'Wall Bridge Filler') const bridgeFillers = modulesOut.filter((node) => node.name === 'Wall Bridge Filler')
expect(bridgeFillers).toHaveLength(1) expect(bridgeFillers).toHaveLength(1)
expect(bridgeFillers[0]?.width).toBeCloseTo(0.26) expect(bridgeFillers[0]?.width).toBeCloseTo(0.5 - 0.32)
const linkedBase = modulesOut.find( const linkedBase = modulesOut.find(
(node) => node.id !== module.id && node.name === 'Base Cabinet', (node) => node.id !== module.id && node.name === 'Base Cabinet',
@@ -1695,8 +2455,8 @@ describe('addCornerRun', () => {
const blockingWall = WallNode.parse({ const blockingWall = WallNode.parse({
id: 'wall_corner-too-close', id: 'wall_corner-too-close',
parentId: levelId, parentId: levelId,
start: [-1, 0.65], start: [-1, 0.55],
end: [2, 0.65], end: [2, 0.55],
thickness: 0.2, thickness: 0.2,
}) })
const sceneApi = sceneApiFixture([run as AnyNode, module as AnyNode, blockingWall as AnyNode]) 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[0]!.position[1]).toBeCloseTo(0.1)
expect(reflowed[2]!.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', () => { 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) 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({ const placement = resolveCabinetWallSnapPlacement({
depth: 0.58, depth: 0.58,
gridStep: 0.5, gridStep: 0.5,
@@ -53,8 +53,9 @@ describe('resolveCabinetWallSnapPlacement', () => {
}) })
expect(placement).not.toBeNull() expect(placement).not.toBeNull()
expect(placement!.localX).toBeCloseTo(0.5) expect(placement!.localX).toBeCloseTo(0.8)
expect(placement!.position[0]).toBeCloseTo(0.5) 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', () => { 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).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) 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 CORNER_FILLER_SIDE_INSET = 0.001
const WALL_CORNER_FILLER_FRONT_HEIGHT_INSET = 0.001 const WALL_CORNER_FILLER_FRONT_HEIGHT_INSET = 0.001
const SINK_FALSE_FRONT_HEIGHT = 0.22 const SINK_FALSE_FRONT_HEIGHT = 0.22
const MIN_RENDERABLE_BRIDGE_FILLER_WIDTH = 1e-4
export function buildCabinetGeometry( export function buildCabinetGeometry(
node: CabinetGeometryNode, node: CabinetGeometryNode,
@@ -45,6 +46,9 @@ export function buildCabinetGeometry(
if (run) return run if (run) return run
return new Group() return new Group()
} }
if (node.name === 'Wall Bridge Filler' && node.width <= MIN_RENDERABLE_BRIDGE_FILLER_WIDTH) {
return new Group()
}
const group = new Group() const group = new Group()
const materials = getCabinetSlotMaterials(node, ctx, shading, textures, colorPreset, sceneTheme) 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 PlanTransform = { position: [number, number, number]; rotation: number }
type PlanPoint = { x: number; z: number } type PlanPoint = { x: number; z: number }
function runParent( function frameParent(
node: AnyNode, node: AnyNode,
nodes: Readonly<Record<string, AnyNode>>, nodes: Readonly<Record<string, AnyNode>>,
): CabinetNodeType | null { ): CabinetNodeType | CabinetModuleNodeType | null {
if (node.type !== 'cabinet-module' || !node.parentId) return null if (node.type !== 'cabinet-module' || !node.parentId) return null
const parent = nodes[node.parentId] const parent = nodes[node.parentId]
return parent?.type === 'cabinet' ? (parent as CabinetNodeType) : null return isCabinetFrameNode(parent) ? parent : null
} }
function isCabinetFrameNode( function isCabinetFrameNode(
@@ -295,7 +295,7 @@ function magneticSnapMatches(
} }
export const cabinetModuleParentFrame: MovableParentFrame = { export const cabinetModuleParentFrame: MovableParentFrame = {
resolveParent: runParent, resolveParent: frameParent,
parentRotationY: (parent, nodes) => parentRotationY: (parent, nodes) =>
frameWorldTransform(parent as CabinetNodeType, nodes).rotation, frameWorldTransform(parent as CabinetNodeType, nodes).rotation,
localToPlan, localToPlan,
+1
View File
@@ -387,6 +387,7 @@ export default function CabinetPanel() {
modules, modules,
parentRun, parentRun,
patch: nextPatch, patch: nextPatch,
preserveExtent: true,
scene, scene,
selected: node, 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> => ({ const baseShared = (run?: CabinetNode): Partial<CabinetModuleNode> => ({
cabinetType: 'base', cabinetType: 'base',
depth: run?.depth ?? 0.58, depth: run?.depth ?? 0.5,
carcassHeight: run?.carcassHeight ?? 0.72, carcassHeight: run?.carcassHeight ?? 0.72,
plinthHeight: run?.plinthHeight ?? 0.1, plinthHeight: run?.plinthHeight ?? 0.1,
toeKickDepth: run?.toeKickDepth ?? 0.075, toeKickDepth: run?.toeKickDepth ?? 0.075,
@@ -42,7 +42,7 @@ const baseShared = (run?: CabinetNode): Partial<CabinetModuleNode> => ({
withCountertop: false, withCountertop: false,
}) })
const runDepth = (run?: CabinetNode) => run?.depth ?? 0.58 const runDepth = (run?: CabinetNode) => run?.depth ?? 0.5
export const CABINET_PRESETS: CabinetPreset[] = [ export const CABINET_PRESETS: CabinetPreset[] = [
{ {
@@ -51,10 +51,10 @@ export const CABINET_PRESETS: CabinetPreset[] = [
createPatch: (run) => ({ createPatch: (run) => ({
...baseShared(run), ...baseShared(run),
name: 'Base Cabinet', name: 'Base Cabinet',
width: 0.6, width: 0.5,
handleStyle: 'bar', handleStyle: 'bar',
handlePosition: 'auto', handlePosition: 'auto',
frontOverlay: 'inset', frontOverlay: 'full',
stack: [ stack: [
{ ...newCabinetCompartment('drawer'), height: 0.44, drawerCount: 3 }, { ...newCabinetCompartment('drawer'), height: 0.44, drawerCount: 3 },
{ ...newCabinetCompartment('door'), doorType: 'double', shelfCount: 2 }, { ...newCabinetCompartment('door'), doorType: 'double', shelfCount: 2 },
@@ -67,7 +67,7 @@ export const CABINET_PRESETS: CabinetPreset[] = [
createPatch: (run) => ({ createPatch: (run) => ({
...baseShared(run), ...baseShared(run),
name: 'Drawer Base', name: 'Drawer Base',
width: 0.6, width: 0.5,
handleStyle: 'bar', handleStyle: 'bar',
handlePosition: 'top', handlePosition: 'top',
frontOverlay: 'full', frontOverlay: 'full',
@@ -133,8 +133,8 @@ export const CABINET_PRESETS: CabinetPreset[] = [
createPatch: (run) => ({ createPatch: (run) => ({
cabinetType: 'tall', cabinetType: 'tall',
name: 'Tall Pantry', name: 'Tall Pantry',
width: 0.6, width: 0.5,
depth: run?.depth ?? 0.58, depth: run?.depth ?? 0.5,
carcassHeight: 2.07, carcassHeight: 2.07,
plinthHeight: 0.1, plinthHeight: 0.1,
toeKickDepth: 0.075, toeKickDepth: 0.075,
@@ -155,7 +155,7 @@ export const CABINET_PRESETS: CabinetPreset[] = [
cabinetType: 'tall', cabinetType: 'tall',
name: 'Oven Tower', name: 'Oven Tower',
width: MICROWAVE_STANDARD_WIDTH, width: MICROWAVE_STANDARD_WIDTH,
depth: run?.depth ?? 0.58, depth: run?.depth ?? 0.5,
carcassHeight: 2.07, carcassHeight: 2.07,
plinthHeight: 0.1, plinthHeight: 0.1,
toeKickDepth: 0.075, toeKickDepth: 0.075,
+10 -2
View File
@@ -19,6 +19,7 @@ import {
resolveCabinetType, resolveCabinetType,
switchCabinetToBase, switchCabinetToBase,
switchCabinetToTall, switchCabinetToTall,
wallChildAdditionOverlaps,
wallChildOf, wallChildOf,
} from './run-ops' } from './run-ops'
@@ -91,6 +92,10 @@ export function cabinetQuickActions({
context.module && standardModule && selectedCabinetType === 'base' context.module && standardModule && selectedCabinetType === 'base'
? Boolean(wallChildOf(context.module, nodes)) ? Boolean(wallChildOf(context.module, nodes))
: false : false
const wallAdditionBlocked =
context.module && standardModule && selectedCabinetType === 'base'
? wallChildAdditionOverlaps(context.module, context.run, nodes)
: false
const runModules = cabinetModulesForRun(context.run, nodes) const runModules = cabinetModulesForRun(context.run, nodes)
const leftCornerModule = const leftCornerModule =
context.module && standardModule && selectedCabinetType === 'base' context.module && standardModule && selectedCabinetType === 'base'
@@ -210,9 +215,12 @@ export function cabinetQuickActions({
label: 'Wall', label: 'Wall',
title: hasWallCabinet title: hasWallCabinet
? 'A wall cabinet already exists above this cabinet' ? '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, icon: cabinetWallIcon,
disabled: hasWallCabinet, disabled: hasWallCabinet || wallAdditionBlocked,
blockedFeedback: !hasWallCabinet && wallAdditionBlocked ? true : undefined,
run: ({ sceneApi }) => { run: ({ sceneApi }) => {
const id = addWallChildAbove({ const id = addWallChildAbove({
kind: 'cabinet', 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 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[] { export function sortRunModules<T extends ModuleLike>(modules: readonly T[]): T[] {
return [...modules].sort((a, b) => a.position[0] - b.position[0]) 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 * Contiguous same-height module groups along the run — the units the
* countertop, plinth, and appliance-gap logic operate on. A gap, a * 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( export function getRunSpans(
modules: readonly Pick< modules: readonly Pick<
@@ -98,7 +105,9 @@ export function getRunSpans(
!current || !current ||
minX - current.maxX > RUN_ADJACENCY_EPSILON || minX - current.maxX > RUN_ADJACENCY_EPSILON ||
current.hasCountertop !== hasCountertop || 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({ spans.push({
minX, minX,
@@ -263,12 +272,26 @@ export function getRunSpanEnds(
return spans.map((span, spanIndex) => { return spans.map((span, spanIndex) => {
const previousSpan = spans[spanIndex - 1] const previousSpan = spans[spanIndex - 1]
const nextSpan = 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 = const hasInternalLeftNeighbor =
!!previousSpan && !!previousSpan &&
!previousSpan.hasCountertop && (!previousSpan.hasCountertop || hasFlushCountertopLeftNeighbor) &&
span.minX - previousSpan.maxX <= RUN_ADJACENCY_EPSILON span.minX - previousSpan.maxX <= RUN_ADJACENCY_EPSILON
const hasInternalRightNeighbor = 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({ const hasExternalLeftNeighbor = hasAdjacentCabinetSpan({
depth: span.depth, depth: span.depth,
edgeX: span.minX, edgeX: span.minX,
@@ -374,13 +397,62 @@ export function reflowRunModules<T extends ModuleLike>(
modules: readonly T[], modules: readonly T[],
selectedId: CabinetModuleNode['id'], selectedId: CabinetModuleNode['id'],
selectedWidth: number, selectedWidth: number,
options: ReflowRunModulesOptions = {},
): Array<{ id: T['id']; position: T['position']; width: number }> { ): Array<{ id: T['id']; position: T['position']; width: number }> {
const sorted = sortRunModules(modules) 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) let nextLeft = runMinX(sorted)
return sorted.map((module) => { return sorted.map((module) => {
const width = module.id === selectedId ? selectedWidth : module.width const width = widths.get(module.id) ?? module.width
const position: T['position'] = [ const position: T['position'] = [
nextLeft + width / 2, nextLeft + width / 2,
module.position[1], module.position[1],
File diff suppressed because it is too large Load Diff
+49 -2
View File
@@ -21,6 +21,7 @@ import {
addCabinetModuleSide, addCabinetModuleSide,
backAlignZ, backAlignZ,
bumpCabinetRunLayoutRevision, bumpCabinetRunLayoutRevision,
cabinetMetadataRecord,
cornerLinkedSourceModuleForRun, cornerLinkedSourceModuleForRun,
runModuleBaseY, runModuleBaseY,
syncCornerRunsFromSourceModule, syncCornerRunsFromSourceModule,
@@ -43,6 +44,7 @@ const RUN_MODULE_SYNC_PATCH_KEYS = new Set<keyof CabinetNodeType>([
'handlePosition', 'handlePosition',
]) ])
const RUN_DEPTH_PATCH_KEY = 'depth' const RUN_DEPTH_PATCH_KEY = 'depth'
const PRESET_WIDTH_DEBT_KEY = 'cabinetPresetWidthDebtBySource'
const FRONT_STYLE_OPTIONS = [ const FRONT_STYLE_OPTIONS = [
{ value: 'slab', label: 'Slab' }, { value: 'slab', label: 'Slab' },
@@ -85,20 +87,59 @@ export function bumpRunLayoutRevisionViaStore(
scene.markDirty(run.id as AnyNodeId) 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({ export function reflowRunModules({
modules, modules,
parentRun, parentRun,
patch, patch,
preserveExtent = false,
scene, scene,
selected, selected,
}: { }: {
modules: CabinetModuleNodeType[] modules: CabinetModuleNodeType[]
parentRun: CabinetNodeType parentRun: CabinetNodeType
patch: Partial<CabinetModuleNodeType> patch: Partial<CabinetModuleNodeType>
preserveExtent?: boolean
scene: ReturnType<typeof useScene.getState> scene: ReturnType<typeof useScene.getState>
selected: CabinetModuleNodeType 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 if (reflowed.length === 0) return
const reflowById = new Map(reflowed.map((entry) => [entry.id, entry])) const reflowById = new Map(reflowed.map((entry) => [entry.id, entry]))
@@ -106,7 +147,13 @@ export function reflowRunModules({
const reflow = reflowById.get(module.id) const reflow = reflowById.get(module.id)
if (!reflow) continue if (!reflow) continue
const isSelected = module.id === selected.id 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'] = [ const nextPosition: CabinetModuleNodeType['position'] = [
reflow.position[0], reflow.position[0],
isSelected && patch.position ? patch.position[1] : reflow.position[1], isSelected && patch.position ? patch.position[1] : reflow.position[1],
@@ -28,7 +28,7 @@ import {
TALL_CABINET_CARCASS_HEIGHT, TALL_CABINET_CARCASS_HEIGHT,
} from './stack' } from './stack'
const BASE_MODULE_WIDTH = 0.6 const BASE_MODULE_WIDTH = 0.5
const BASE_CARCASS_HEIGHT = 0.72 const BASE_CARCASS_HEIGHT = 0.72
const WALL_CARCASS_HEIGHT = 0.72 const WALL_CARCASS_HEIGHT = 0.72
const TALL_CARCASS_HEIGHT = TALL_CABINET_CARCASS_HEIGHT const TALL_CARCASS_HEIGHT = TALL_CABINET_CARCASS_HEIGHT
@@ -78,7 +78,7 @@ export function resolveCompartmentTransition({
: next.type === 'fridge-double' : next.type === 'fridge-double'
? FRIDGE_WIDE_WIDTH ? FRIDGE_WIDE_WIDTH
: FRIDGE_COLUMN_WIDTH, : FRIDGE_COLUMN_WIDTH,
depth: parentRun?.depth ?? 0.58, depth: parentRun?.depth ?? 0.5,
carcassHeight: TALL_CARCASS_HEIGHT, carcassHeight: TALL_CARCASS_HEIGHT,
plinthHeight: 0.1, plinthHeight: 0.1,
toeKickDepth: 0.075, toeKickDepth: 0.075,
@@ -100,7 +100,7 @@ export function resolveCompartmentTransition({
: enteringCooktop : enteringCooktop
? COOKTOP_STANDARD_WIDTH ? COOKTOP_STANDARD_WIDTH
: BASE_MODULE_WIDTH, : BASE_MODULE_WIDTH,
depth: parentRun?.depth ?? 0.58, depth: parentRun?.depth ?? 0.5,
carcassHeight: parentRun?.carcassHeight ?? BASE_CARCASS_HEIGHT, carcassHeight: parentRun?.carcassHeight ?? BASE_CARCASS_HEIGHT,
plinthHeight: parentRun?.plinthHeight ?? 0.1, plinthHeight: parentRun?.plinthHeight ?? 0.1,
toeKickDepth: parentRun?.toeKickDepth ?? 0.075, toeKickDepth: parentRun?.toeKickDepth ?? 0.075,
@@ -114,7 +114,7 @@ export function resolveCompartmentTransition({
? { ? {
cabinetType: 'base', cabinetType: 'base',
width: DISHWASHER_STANDARD_WIDTH, width: DISHWASHER_STANDARD_WIDTH,
depth: parentRun?.depth ?? 0.58, depth: parentRun?.depth ?? 0.5,
carcassHeight: DISHWASHER_STANDARD_HEIGHT, carcassHeight: DISHWASHER_STANDARD_HEIGHT,
plinthHeight: parentRun?.plinthHeight ?? 0.1, plinthHeight: parentRun?.plinthHeight ?? 0.1,
toeKickDepth: parentRun?.toeKickDepth ?? 0.075, toeKickDepth: parentRun?.toeKickDepth ?? 0.075,
+108 -9
View File
@@ -1,16 +1,20 @@
'use client' 'use client'
import { import {
type AnyNode,
type AnyNodeId, type AnyNodeId,
CabinetModuleNode, CabinetModuleNode,
CabinetNode, CabinetNode,
collectAlignmentAnchors,
createSceneApi, createSceneApi,
emitter, emitter,
type GridEvent, type GridEvent,
getFloorPlacedFootprints, getFloorPlacedFootprints,
getWallThickness, getWallThickness,
isCurvedWall, isCurvedWall,
movingFootprintAnchors,
nodeRegistry, nodeRegistry,
resolveAlignment,
spatialGridManager, spatialGridManager,
useScene, useScene,
type WallEvent, type WallEvent,
@@ -20,6 +24,7 @@ import {
clearPlacementSurface, clearPlacementSurface,
getFloorStackPreviewPosition, getFloorStackPreviewPosition,
getSideFromNormal, getSideFromNormal,
isAlignmentGuideActive,
isGridSnapActive, isGridSnapActive,
isMagneticSnapActive, isMagneticSnapActive,
isValidWallSideFace, isValidWallSideFace,
@@ -28,6 +33,7 @@ import {
PlacementBox, PlacementBox,
publishPlacementSurface, publishPlacementSurface,
triggerSFX, triggerSFX,
useAlignmentGuides,
useEditor, useEditor,
useFacingPose, useFacingPose,
usePlacementPreview, usePlacementPreview,
@@ -38,6 +44,7 @@ import { useFrame } from '@react-three/fiber'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { type Group, Mesh, Quaternion, Vector3 } from 'three' import { type Group, Mesh, Quaternion, Vector3 } from 'three'
import { import {
FLOOR_PLACEMENT_ALIGNMENT_THRESHOLD_M,
type FloorPlacementClickTriggerEvent, type FloorPlacementClickTriggerEvent,
getLevelLocalSnappedPosition, getLevelLocalSnappedPosition,
stopPlacementCommitPropagation, stopPlacementCommitPropagation,
@@ -65,6 +72,7 @@ import {
cabinetRunFootprint, cabinetRunFootprint,
} from './definition' } from './definition'
import { buildCabinetGeometry } from './geometry' import { buildCabinetGeometry } from './geometry'
import { resolveCabinetGridPosition } from './placement-snap'
import useCabinetPlacementStatus from './placement-status' import useCabinetPlacementStatus from './placement-status'
import useCabinetPlacementType from './placement-type' import useCabinetPlacementType from './placement-type'
import { cabinetPresetById } from './presets' 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 // Cabinet wall attachment is a placement affordance, separate from floor-grid
// quantization. Keep the long-standing behavior in grid and magnetic modes; // quantization. Keep the long-standing behavior in grid and magnetic modes;
// Off remains the explicit way to place without wall attachment. // Off remains the explicit way to place without wall attachment.
@@ -269,6 +272,7 @@ const CabinetTool = () => {
const previousWasWallSnapRef = useRef(false) const previousWasWallSnapRef = useRef(false)
const previousTickFrameRef = useRef(-1) const previousTickFrameRef = useRef(-1)
const draftAnchorRef = useRef<DraftAnchorState | null>(null) const draftAnchorRef = useRef<DraftAnchorState | null>(null)
const lastRawPositionRef = useRef<[number, number, number] | null>(null)
const activeGhostRef = useRef<Group | null>(null) const activeGhostRef = useRef<Group | null>(null)
const surfacePointRef = useRef(new Vector3()) const surfacePointRef = useRef(new Vector3())
const surfaceNormalRef = useRef(new Vector3(0, 1, 0)) const surfaceNormalRef = useRef(new Vector3(0, 1, 0))
@@ -394,6 +398,11 @@ const CabinetTool = () => {
previousWasWallSnapRef.current = false previousWasWallSnapRef.current = false
previousTickFrameRef.current = -1 previousTickFrameRef.current = -1
draftAnchorRef.current = null draftAnchorRef.current = null
let alignmentCandidates = collectAlignmentAnchors(
useScene.getState().nodes,
previewNode.id,
activeLevelId,
)
let lastWallEventTime = -1 let lastWallEventTime = -1
let wallOwnedPointerAt = Number.NEGATIVE_INFINITY let wallOwnedPointerAt = Number.NEGATIVE_INFINITY
const WALL_OWNS_POINTER_MS = 64 const WALL_OWNS_POINTER_MS = 64
@@ -417,6 +426,7 @@ const CabinetTool = () => {
previousTickFrameRef.current = -1 previousTickFrameRef.current = -1
clearPlacementSurface() clearPlacementSurface()
useFacingPose.getState().clear() useFacingPose.getState().clear()
useAlignmentGuides.getState().clear()
useCabinetPlacementStatus.getState().setBlocked(false) useCabinetPlacementStatus.getState().setBlocked(false)
} }
@@ -461,7 +471,59 @@ const CabinetTool = () => {
bypassGrid = false, bypassGrid = false,
): [number, number, number] => { ): [number, number, number] => {
const step = !bypassGrid && isGridSnapActive() ? useEditor.getState().gridSnapStep : 0 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 = ( const withPlacementValidity = (
@@ -552,12 +614,30 @@ const CabinetTool = () => {
const resolvePlacement = (event: FloorPlacementClickTriggerEvent): CabinetPlacement => { const resolvePlacement = (event: FloorPlacementClickTriggerEvent): CabinetPlacement => {
const raw = resolveRawPosition(event) const raw = resolveRawPosition(event)
lastRawPositionRef.current = raw
const forcePlacement = isForcePlacementEvent(event) const forcePlacement = isForcePlacementEvent(event)
const wallPlacement = islandModeRef.current ? null : resolveWallPlacement(raw) 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( return withPlacementValidity(
{ {
position: resolveGridPosition(raw), position,
yaw: yawRef.current, yaw: yawRef.current,
snappedToWall: false, snappedToWall: false,
}, },
@@ -571,6 +651,7 @@ const CabinetTool = () => {
anchor: StretchAnchor, anchor: StretchAnchor,
event: FloorPlacementClickTriggerEvent, event: FloorPlacementClickTriggerEvent,
): CabinetPlacement => { ): CabinetPlacement => {
useAlignmentGuides.getState().clear()
const raw = resolveRawPosition(event) const raw = resolveRawPosition(event)
let stretch = planCabinetContinuousStretch({ let stretch = planCabinetContinuousStretch({
anchor, anchor,
@@ -923,6 +1004,7 @@ const CabinetTool = () => {
useViewer.getState().setSelection({ selectedIds: [module.id] }) useViewer.getState().setSelection({ selectedIds: [module.id] })
useEditor.getState().setMode('select') useEditor.getState().setMode('select')
triggerSFX('sfx:item-place') triggerSFX('sfx:item-place')
useAlignmentGuides.getState().clear()
usePlacementPreview.getState().clear() usePlacementPreview.getState().clear()
clearPlacementSurface() clearPlacementSurface()
useFacingPose.getState().clear() useFacingPose.getState().clear()
@@ -950,7 +1032,23 @@ const CabinetTool = () => {
!placementRef.current.snappedToWall && !placementRef.current.snappedToWall &&
!placementRef.current.stretch !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 placementRef.current = next
setPlacement(next) setPlacement(next)
publishFloorplanPreview(next) publishFloorplanPreview(next)
@@ -985,6 +1083,7 @@ const CabinetTool = () => {
usePlacementPreview.getState().clear() usePlacementPreview.getState().clear()
clearPlacementSurface() clearPlacementSurface()
useFacingPose.getState().clear() useFacingPose.getState().clear()
useAlignmentGuides.getState().clear()
useCabinetPlacementStatus.getState().setBlocked(false) useCabinetPlacementStatus.getState().setBlocked(false)
} }
}, [activeLevelId, placementDimensions, previewNode, publishFloorplanPreview]) }, [activeLevelId, placementDimensions, previewNode, publishFloorplanPreview])
+2 -6
View File
@@ -9,6 +9,7 @@ import {
} from '@pascal-app/core' } from '@pascal-app/core'
import type { WallHit } from '../shared/wall-attach-target' import type { WallHit } from '../shared/wall-attach-target'
import { findClosestWallInPlan, projectWallLocalPointToPlan } from '../shared/wall-attach-target' import { findClosestWallInPlan, projectWallLocalPointToPlan } from '../shared/wall-attach-target'
import { snapCabinetFootprintCenter } from './placement-snap'
import { planToRunLocal, runLocalToPlan } from './run-layout' import { planToRunLocal, runLocalToPlan } from './run-layout'
const EDGE_SNAP_THRESHOLD = 0.08 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 { function angleDelta(a: number, b: number): number {
return Math.atan2(Math.sin(a - b), Math.cos(a - b)) 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 if (hit.wallLength <= 1e-6) return null
const halfWidth = width / 2 const halfWidth = width / 2
const snappedLocalX = snap(hit.localX, gridStep) const snappedLocalX = snapCabinetFootprintCenter(hit.localX, width, gridStep)
const clampedLocalX = const clampedLocalX =
hit.wallLength > width hit.wallLength > width
? Math.min(hit.wallLength - halfWidth, Math.max(halfWidth, snappedLocalX)) ? 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 { getVisibleWallMaterials, NodeRenderer, useNodeEvents, useViewer } from '@pascal-app/viewer'
import { useEffect, useLayoutEffect, useMemo, useRef } from 'react' import { useEffect, useLayoutEffect, useMemo, useRef } from 'react'
import type { Mesh } from 'three' import type { Mesh } from 'three'
import { useShallow } from 'zustand/react/shallow'
import { createPlaceholderGeometry } from '../shared/placeholder-geometry' import { createPlaceholderGeometry } from '../shared/placeholder-geometry'
import { useWallTreatmentLevelData } from './treatment-level-data'
import { createWallExtraSlotMaterials, WallTreatments } from './treatments' import { createWallExtraSlotMaterials, WallTreatments } from './treatments'
/** /**
@@ -55,13 +57,15 @@ const WallRenderer = ({ node }: { node: WallNode }) => {
const textures = useViewer((s) => s.textures) const textures = useViewer((s) => s.textures)
const colorPreset = useViewer((s) => s.colorPreset) const colorPreset = useViewer((s) => s.colorPreset)
const sceneTheme = useViewer((s) => s.sceneTheme) const sceneTheme = useViewer((s) => s.sceneTheme)
const sceneNodes = useScene((state) => state.nodes) const childNodes = useScene(
const childNodes = useMemo( useShallow((state) =>
() =>
(node.children ?? []) (node.children ?? [])
.map((childId) => sceneNodes[childId as AnyNodeId]) .map((childId) => state.nodes[childId as AnyNodeId])
.filter((child): child is AnyNode => child !== undefined), .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 // Subscribe to the scene-material palette so editing a `scene:` material a
// wall slot references re-renders the wall live (the wall-system geometry // wall slot references re-renders the wall live (the wall-system geometry
@@ -105,7 +109,14 @@ const WallRenderer = ({ node }: { node: WallNode }) => {
{...handlers} {...handlers}
/> />
<WallTreatments childrenNodes={childNodes} materials={extraMaterials} node={node} /> {treatmentLevelData && (
<WallTreatments
childrenNodes={childNodes}
levelData={treatmentLevelData}
materials={extraMaterials}
node={node}
/>
)}
{(node.children ?? []).map((childId) => ( {(node.children ?? []).map((childId) => (
<NodeRenderer key={`${node.id}:${childId}`} nodeId={childId} /> <NodeRenderer key={`${node.id}:${childId}`} nodeId={childId} />
+38
View File
@@ -1,6 +1,43 @@
'use client' 'use client'
import { type AnyNodeId, useLiveNodeOverrides, useScene, type WallNode } from '@pascal-app/core'
import { WallCutout, WallSystem } from '@pascal-app/viewer' 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. * Registry-driven wall system bundle.
@@ -16,6 +53,7 @@ import { WallCutout, WallSystem } from '@pascal-app/viewer'
const WallSystems = () => { const WallSystems = () => {
return ( return (
<> <>
<WallTreatmentMiterSystem />
<WallSystem /> <WallSystem />
<WallCutout /> <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 { import {
getWallCurveFrameAt, getWallCurveFrameAt,
getWallMiterBoundaryPoints,
getWallThickness, getWallThickness,
isCurvedWall, isCurvedWall,
type SceneMaterial, type SceneMaterial,
@@ -24,6 +25,7 @@ import {
import { memo, useEffect, useMemo } from 'react' import { memo, useEffect, useMemo } from 'react'
import * as THREE from 'three' import * as THREE from 'three'
import { mergeGeometries as mergeBufferGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' import { mergeGeometries as mergeBufferGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
import { treatmentMiterDataForProud, type WallTreatmentLevelData } from './treatment-level-data'
const CURVE_SEGMENTS = 24 const CURVE_SEGMENTS = 24
const MIN_SLICE_PROUD = 0.0005 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) { function resolveTreatmentSideSign(node: WallNode, side: WallSide) {
if (side === 'interior') { if (side === 'interior') {
if (node.frontSide === 'interior') return 1 if (node.frontSide === 'interior') return 1
@@ -300,8 +324,30 @@ function buildSidePolyline(node: WallNode, side: WallSide, offset: number): Poin
return points return points
} }
function clipPolyline(points: Point2[], x0: number, x1: number): Point2[] { function buildMiteredSidePolyline(
if (points.length < 2 || x1 - x0 <= EPS) return [] 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[] = [] const out: Point2[] = []
for (let index = 0; index < points.length - 1; index += 1) { for (let index = 0; index < points.length - 1; index += 1) {
const a = points[index] const a = points[index]
@@ -309,7 +355,9 @@ function clipPolyline(points: Point2[], x0: number, x1: number): Point2[] {
if (!(a && b)) continue if (!(a && b)) continue
const minX = Math.min(a.x, b.x) const minX = Math.min(a.x, b.x)
const maxX = Math.max(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) => { const pushPointAt = (x: number) => {
if (Math.abs(b.x - a.x) <= EPS) { 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 start = x0 !== undefined && minX < x0 ? pushPointAt(x0) : a
const end = maxX > x1 ? pushPointAt(x1) : b const end = x1 !== undefined && maxX > x1 ? pushPointAt(x1) : b
if ( if (
out.length === 0 || out.length === 0 ||
Math.hypot(out[out.length - 1]!.x - start.x, out[out.length - 1]!.z - start.z) > EPS 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 return null
} }
function buildTrimGeometry( export function buildTrimGeometry(
node: WallNode, node: WallNode,
side: WallSide, side: WallSide,
trim: WallTrimConfig, trim: WallTrimConfig,
kind: TrimKind, kind: TrimKind,
childrenNodes: OpeningLike[], childrenNodes: OpeningLike[],
levelData: WallTreatmentLevelData,
) { ) {
const wallHeight = node.height ?? 2.5 const wallHeight = node.height ?? 2.5
const height = trim.height const height = trim.height
@@ -466,11 +515,12 @@ function buildTrimGeometry(
: 0 : 0
const thickness = getWallThickness(node) 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 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 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) const runs = subtractOpeningRanges(fullRanges, openingRanges)
if (runs.length === 0) return null if (runs.length === 0) return null
@@ -480,13 +530,15 @@ function buildTrimGeometry(
const sliceHeight = height / profile.samples const sliceHeight = height / profile.samples
for (const [runStart, runEnd] of runs) { 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 if (innerRun.length < 2) continue
for (let index = 0; index < profile.samples; index += 1) { for (let index = 0; index < profile.samples; index += 1) {
const t = (index + 0.5) / profile.samples const t = (index + 0.5) / profile.samples
const proud = Math.max(MIN_SLICE_PROUD, trim.proud * profile.proudAt(t)) const proud = Math.max(MIN_SLICE_PROUD, trim.proud * profile.proudAt(t))
const outerRun = buildSidePolyline(node, side, thickness / 2 + proud) const outerRun = buildMiteredSidePolyline(node, levelData, side, thickness / 2 + proud)
const outerClipped = clipPolyline(outerRun, runStart, runEnd) const outerClipped = clipPolyline(outerRun, clipStart, clipEnd)
if (outerClipped.length < 2) continue if (outerClipped.length < 2) continue
const slice = buildTrimSliceGeometry( const slice = buildTrimSliceGeometry(
outerClipped, outerClipped,
@@ -538,10 +590,12 @@ export function createWallExtraSlotMaterials(
export const WallTreatments = memo(function WallTreatments({ export const WallTreatments = memo(function WallTreatments({
node, node,
childrenNodes, childrenNodes,
levelData,
materials, materials,
}: { }: {
node: WallNode node: WallNode
childrenNodes: OpeningLike[] childrenNodes: OpeningLike[]
levelData: WallTreatmentLevelData
materials: Record<WallTreatmentSlotId, THREE.Material> materials: Record<WallTreatmentSlotId, THREE.Material>
}) { }) {
const fallbackMaterial = const fallbackMaterial =
@@ -574,7 +628,7 @@ export const WallTreatments = memo(function WallTreatments({
? (['interior', 'exterior'] as WallSide[]) ? (['interior', 'exterior'] as WallSide[])
: ([trim.sides] as WallSide[]) : ([trim.sides] as WallSide[])
for (const side of sides) { 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 if (!geometry) continue
const slotId = TRIM_KIND_CONFIG[kind].slots[side] const slotId = TRIM_KIND_CONFIG[kind].slots[side]
out.push({ out.push({
@@ -587,7 +641,7 @@ export const WallTreatments = memo(function WallTreatments({
} }
return out return out
}, [childrenNodes, fallbackMaterial, materials, node]) }, [childrenNodes, fallbackMaterial, levelData, materials, node])
useEffect( useEffect(
() => () => { () => () => {