From 1490d280d4bb93c0bed9e048396ac37c0abf934d Mon Sep 17 00:00:00 2001 From: sudhir Date: Tue, 5 May 2026 13:26:39 +0530 Subject: [PATCH 1/6] Refine editor layer boundaries and selection handling --- packages/core/src/schema/nodes/door.ts | 29 + .../editor/first-person-controls.tsx | 33 +- .../src/components/editor/floorplan-panel.tsx | 634 +++++++++-- .../src/components/tools/door/door-tool.tsx | 7 + .../src/components/ui/panels/door-panel.tsx | 445 ++++++-- .../keyboard-shortcuts-dialog.tsx | 4 +- packages/editor/src/hooks/use-keyboard.ts | 43 +- .../viewer/src/systems/door/door-system.tsx | 1009 ++++++++++++++--- 8 files changed, 1873 insertions(+), 331 deletions(-) diff --git a/packages/core/src/schema/nodes/door.ts b/packages/core/src/schema/nodes/door.ts index 8940270a4..b64938c74 100644 --- a/packages/core/src/schema/nodes/door.ts +++ b/packages/core/src/schema/nodes/door.ts @@ -18,6 +18,25 @@ export const DoorSegment = z.object({ export type DoorSegment = z.infer +export const DoorCategory = z.enum(['interior', 'garage']) +export const DoorType = z.enum([ + 'hinged', + 'double', + 'french', + 'folding', + 'pocket', + 'barn', + 'sliding', + 'garage-sectional', + 'garage-rollup', + 'garage-tiltup', +]) +export const DoorTrackStyle = z.enum(['none', 'visible', 'pocket', 'overhead']) + +export type DoorCategory = z.infer +export type DoorType = z.infer +export type DoorTrackStyle = z.infer + export const DoorNode = BaseNode.extend({ id: objectId('door'), type: nodeType('door'), @@ -32,6 +51,15 @@ export const DoorNode = BaseNode.extend({ width: z.number().default(0.9), height: z.number().default(2.1), + // Door family + doorCategory: DoorCategory.default('interior'), + doorType: DoorType.default('hinged'), + leafCount: z.union([z.literal(1), z.literal(2), z.literal(3), z.literal(4)]).default(1), + operationState: z.number().min(0).max(1).default(0), + slideDirection: z.enum(['left', 'right']).default('left'), + trackStyle: DoorTrackStyle.default('none'), + garagePanelCount: z.number().int().min(1).max(12).default(4), + // Opening mode openingKind: z.enum(['door', 'opening']).default('door'), openingShape: z.enum(['rectangle', 'rounded', 'arch']).default('rectangle'), @@ -90,6 +118,7 @@ export const DoorNode = BaseNode.extend({ panicBarHeight: z.number().default(1.0), }).describe(dedent`Door node - a parametric door placed on a wall - position: center of the door in wall-local coordinate system (Y = height/2, always at floor) + - doorCategory/doorType: explicit operation family, defaulting old doors to interior hinged - openingKind/openingShape: hinged door or frameless wall opening shape - segments: rows stacked top to bottom, each defining its own columnRatios - type 'empty' = no leaf fill for that segment, 'panel' = raised/recessed panel, 'glass' = glazed diff --git a/packages/editor/src/components/editor/first-person-controls.tsx b/packages/editor/src/components/editor/first-person-controls.tsx index ee39342ec..35ce42d6e 100644 --- a/packages/editor/src/components/editor/first-person-controls.tsx +++ b/packages/editor/src/components/editor/first-person-controls.tsx @@ -56,6 +56,17 @@ const resolvePlacedSpawnNode = ( return [...candidates].sort((a, b) => a.id.localeCompare(b.id))[0] ?? null } +function updateDoorOpenState( + doorId: AnyNodeId, + data: { operationState?: number; swingAngle?: number }, +) { + const scene = useScene.getState() + const node = scene.nodes[doorId] + scene.updateNode(doorId, data) + scene.dirtyNodes.add(doorId) + if (node?.parentId) scene.dirtyNodes.add(node.parentId as AnyNodeId) +} + export const FirstPersonControls = () => { const { camera, gl } = useThree() const selectedLevelId = useViewer((state) => state.selection.levelId) @@ -151,10 +162,22 @@ export const FirstPersonControls = () => { const node = useScene.getState().nodes[doorId] if (node?.type !== 'door' || node.openingKind === 'opening') return - const currentSwingAngle = node.swingAngle ?? 0 - useScene.getState().updateNode(doorId, { - swingAngle: currentSwingAngle >= DOOR_SWING_OPEN_ANGLE / 2 ? 0 : DOOR_SWING_OPEN_ANGLE, - }) + if ( + node.doorType === 'folding' || + node.doorType === 'pocket' || + node.doorType === 'barn' || + node.doorType === 'sliding' + ) { + const currentOpenAmount = node.operationState ?? 0 + updateDoorOpenState(doorId, { + operationState: currentOpenAmount >= 0.5 ? 0 : 1, + }) + } else { + const currentSwingAngle = node.swingAngle ?? 0 + updateDoorOpenState(doorId, { + swingAngle: currentSwingAngle >= DOOR_SWING_OPEN_ANGLE / 2 ? 0 : DOOR_SWING_OPEN_ANGLE, + }) + } requestAnimationFrame(rebuildColliderWorld) }, [rebuildColliderWorld, resolveInteractableDoorId]) @@ -260,7 +283,7 @@ export const FirstPersonControls = () => { document.exitPointerLock() } useEditor.getState().setFirstPersonMode(false) - } else if (event.code === 'KeyE') { + } else if (event.code === 'KeyE' || event.code === 'KeyR') { event.preventDefault() event.stopPropagation() toggleInteractableDoor() diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index 55a32e7d5..df6f4a1e3 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -4679,6 +4679,275 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({ x: (svgP2.x + svgP3.x) / 2, y: (svgP2.y + svgP3.y) / 2, } + const isFoldingDoor = opening.doorType === 'folding' + const foldingPanelCount = opening.leafCount === 2 ? 2 : 4 + const foldingAmount = Math.max(0, Math.min(1, opening.operationState ?? 0)) + const foldingSpan = Math.max(1e-6, Math.hypot(svgP2.x - svgP1.x, svgP2.y - svgP1.y)) + const foldingPanelLength = foldingSpan / foldingPanelCount + const foldingAngle = Math.PI * 0.44 * foldingAmount + const foldingPoints = isFoldingDoor + ? Array.from({ length: foldingPanelCount + 1 }).reduce( + (points, _, index) => { + if (index === 0) return [{ x: svgP1.x, y: svgP1.y }] + + const previous = points[index - 1]! + const direction = (index - 1) % 2 === 0 ? -1 : 1 + const angle = direction * foldingAngle + const along = Math.cos(angle) * foldingPanelLength + const out = Math.sin(angle) * foldingPanelLength * swingSign + points.push({ + x: previous.x + nx * along + px * out, + y: previous.y + ny * along + py * out, + }) + return points + }, + [], + ) + : [] + const foldingPath = + foldingPoints.length > 0 + ? foldingPoints + .map((point, index) => `${index === 0 ? 'M' : 'L'} ${point.x} ${point.y}`) + .join(' ') + : null + const isPocketDoor = opening.doorType === 'pocket' + const pocketAmount = Math.max(0, Math.min(1, opening.operationState ?? 0)) + const pocketSign = opening.slideDirection === 'right' ? 1 : -1 + const pocketShift = pocketSign * foldingSpan * pocketAmount + const pocketTrackStart = + pocketSign > 0 + ? svgP1 + : { x: svgP1.x - nx * foldingSpan, y: svgP1.y - ny * foldingSpan } + const pocketTrackEnd = + pocketSign > 0 + ? { x: svgP2.x + nx * foldingSpan, y: svgP2.y + ny * foldingSpan } + : svgP2 + const pocketLeafStart = { + x: svgP1.x + nx * pocketShift + px * swingSign * doorCubeSize * 0.5, + y: svgP1.y + ny * pocketShift + py * swingSign * doorCubeSize * 0.5, + } + const pocketLeafEnd = { + x: svgP2.x + nx * pocketShift + px * swingSign * doorCubeSize * 0.5, + y: svgP2.y + ny * pocketShift + py * swingSign * doorCubeSize * 0.5, + } + const pocketLeafPoints = [ + { + x: pocketLeafStart.x - px * leafHalfThickness, + y: pocketLeafStart.y - py * leafHalfThickness, + }, + { + x: pocketLeafEnd.x - px * leafHalfThickness, + y: pocketLeafEnd.y - py * leafHalfThickness, + }, + { + x: pocketLeafEnd.x + px * leafHalfThickness, + y: pocketLeafEnd.y + py * leafHalfThickness, + }, + { + x: pocketLeafStart.x + px * leafHalfThickness, + y: pocketLeafStart.y + py * leafHalfThickness, + }, + ] + .map((point) => `${point.x},${point.y}`) + .join(' ') + const isBarnDoor = opening.doorType === 'barn' + const barnLeafStart = { + x: pocketLeafStart.x + px * swingSign * doorCubeSize * 0.75, + y: pocketLeafStart.y + py * swingSign * doorCubeSize * 0.75, + } + const barnLeafEnd = { + x: pocketLeafEnd.x + px * swingSign * doorCubeSize * 0.75, + y: pocketLeafEnd.y + py * swingSign * doorCubeSize * 0.75, + } + const barnLeafPoints = [ + { + x: barnLeafStart.x - px * leafHalfThickness, + y: barnLeafStart.y - py * leafHalfThickness, + }, + { + x: barnLeafEnd.x - px * leafHalfThickness, + y: barnLeafEnd.y - py * leafHalfThickness, + }, + { + x: barnLeafEnd.x + px * leafHalfThickness, + y: barnLeafEnd.y + py * leafHalfThickness, + }, + { + x: barnLeafStart.x + px * leafHalfThickness, + y: barnLeafStart.y + py * leafHalfThickness, + }, + ] + .map((point) => `${point.x},${point.y}`) + .join(' ') + const isSlidingDoor = opening.doorType === 'sliding' + const slidingPanelSpan = foldingSpan * 0.54 + const slidingActiveOnRight = opening.slideDirection !== 'right' + const slidingFixedSign = slidingActiveOnRight ? -1 : 1 + const slidingActiveSign = slidingActiveOnRight ? 1 : -1 + const slidingFixedCenter = slidingFixedSign * foldingSpan * 0.23 + const slidingActiveCenter = + slidingActiveSign * foldingSpan * 0.23 - + slidingActiveSign * foldingSpan * 0.44 * pocketAmount + const slidingPanelPoints = (centerOffset: number, faceOffset: number) => { + const start = { + x: + svgP1.x + + nx * (centerOffset + (foldingSpan - slidingPanelSpan) / 2) + + px * swingSign * faceOffset, + y: + svgP1.y + + ny * (centerOffset + (foldingSpan - slidingPanelSpan) / 2) + + py * swingSign * faceOffset, + } + const end = { + x: + svgP1.x + + nx * (centerOffset + (foldingSpan + slidingPanelSpan) / 2) + + px * swingSign * faceOffset, + y: + svgP1.y + + ny * (centerOffset + (foldingSpan + slidingPanelSpan) / 2) + + py * swingSign * faceOffset, + } + return [ + { x: start.x - px * leafHalfThickness, y: start.y - py * leafHalfThickness }, + { x: end.x - px * leafHalfThickness, y: end.y - py * leafHalfThickness }, + { x: end.x + px * leafHalfThickness, y: end.y + py * leafHalfThickness }, + { x: start.x + px * leafHalfThickness, y: start.y + py * leafHalfThickness }, + ] + .map((point) => `${point.x},${point.y}`) + .join(' ') + } + const slidingFixedPoints = slidingPanelPoints(slidingFixedCenter, doorCubeSize * 0.34) + const slidingActivePoints = slidingPanelPoints(slidingActiveCenter, doorCubeSize * 0.68) + const isDoubleSwingDoor = opening.doorType === 'double' || opening.doorType === 'french' + const doubleLeafPlans = isDoubleSwingDoor + ? ( + [ + { + key: 'left', + hingePoint: { x: cx - nx * (width / 2), y: cy - ny * (width / 2) }, + strikePoint: { x: cx, y: cy }, + }, + { + key: 'right', + hingePoint: { x: cx + nx * (width / 2), y: cy + ny * (width / 2) }, + strikePoint: { x: cx, y: cy }, + }, + ] as const + ).map(({ key, hingePoint, strikePoint }) => { + const tangentSign = key === 'left' ? 1 : -1 + const planHingeCubeCenter = { + x: hingePoint.x + nx * tangentSign * doorCubeInset, + y: hingePoint.y + ny * tangentSign * doorCubeInset, + } + const planStrikeCubeCenter = { + x: strikePoint.x - nx * tangentSign * doorCubeInset, + y: strikePoint.y - ny * tangentSign * doorCubeInset, + } + const planLeafStart = { + x: + planHingeCubeCenter.x + + px * swingSign * (doorCubeSize / 2) + + nx * tangentSign * (doorCubeSize / 2 + leafHalfThickness), + y: + planHingeCubeCenter.y + + py * swingSign * (doorCubeSize / 2) + + ny * tangentSign * (doorCubeSize / 2 + leafHalfThickness), + } + const planArcEnd = { + x: + planStrikeCubeCenter.x + + px * swingSign * (doorCubeSize / 2) - + nx * tangentSign * (doorCubeSize / 2), + y: + planStrikeCubeCenter.y + + py * swingSign * (doorCubeSize / 2) - + ny * tangentSign * (doorCubeSize / 2), + } + const planSwingRadius = Math.hypot( + planArcEnd.x - planLeafStart.x, + planArcEnd.y - planLeafStart.y, + ) + const planClosedLeafVector = { + x: planArcEnd.x - planLeafStart.x, + y: planArcEnd.y - planLeafStart.y, + } + const planOpenAngle = swingAngle * swingSign * tangentSign + const planOpenCos = Math.cos(planOpenAngle) + const planOpenSin = Math.sin(planOpenAngle) + const planLeafEnd = { + x: + planLeafStart.x + + planClosedLeafVector.x * planOpenCos - + planClosedLeafVector.y * planOpenSin, + y: + planLeafStart.y + + planClosedLeafVector.x * planOpenSin + + planClosedLeafVector.y * planOpenCos, + } + const planSweepFlag = + key === 'left' + ? swingDirection === 'inward' + ? 0 + : 1 + : swingDirection === 'inward' + ? 1 + : 0 + + return { + key, + hingeCubeCenter: planHingeCubeCenter, + strikeCubeCenter: planStrikeCubeCenter, + hingeMarkerX: planHingeCubeCenter.x, + hingeMarkerY: planHingeCubeCenter.y, + swingRadius: planSwingRadius, + sweepFlag: planSweepFlag, + arcEnd: planArcEnd, + leafEnd: planLeafEnd, + leafPolygonPoints: [ + { + x: planLeafStart.x - nx * leafHalfThickness, + y: planLeafStart.y - ny * leafHalfThickness, + }, + { + x: planLeafEnd.x - nx * leafHalfThickness, + y: planLeafEnd.y - ny * leafHalfThickness, + }, + { + x: planLeafEnd.x + nx * leafHalfThickness, + y: planLeafEnd.y + ny * leafHalfThickness, + }, + { + x: planLeafStart.x + nx * leafHalfThickness, + y: planLeafStart.y + ny * leafHalfThickness, + }, + ] + .map((point) => `${point.x},${point.y}`) + .join(' '), + closedLeafHintPoints: [ + { + x: planLeafStart.x - nx * leafHalfThickness * 0.7, + y: planLeafStart.y - ny * leafHalfThickness * 0.7, + }, + { + x: planArcEnd.x - nx * leafHalfThickness * 0.7, + y: planArcEnd.y - ny * leafHalfThickness * 0.7, + }, + { + x: planArcEnd.x + nx * leafHalfThickness * 0.7, + y: planArcEnd.y + ny * leafHalfThickness * 0.7, + }, + { + x: planLeafStart.x + nx * leafHalfThickness * 0.7, + y: planLeafStart.y + ny * leafHalfThickness * 0.7, + }, + ] + .map((point) => `${point.x},${point.y}`) + .join(' '), + } + }) + : [] return ( - {swingSweepPath && ( - - )} - {swingAngle > 0.03 && ( - + {isFoldingDoor ? ( + <> + + {foldingPath && ( + + )} + {foldingPoints.map((point, index) => ( + + ))} + + ) : isPocketDoor ? ( + <> + + + + + + ) : isBarnDoor ? ( + <> + + + + {[0.28, 0.72].map((ratio) => { + const wheel = { + x: barnLeafStart.x + (barnLeafEnd.x - barnLeafStart.x) * ratio, + y: barnLeafStart.y + (barnLeafEnd.y - barnLeafStart.y) * ratio, + } + return ( + + ) + })} + + ) : isSlidingDoor ? ( + <> + + + + + + ) : isDoubleSwingDoor ? ( + <> + {doubleLeafPlans.map((leaf) => + leaf.swingRadius > 1e-6 ? ( + + ) : null, + )} + {swingAngle > 0.03 && + doubleLeafPlans.map((leaf) => ( + + ))} + {doubleLeafPlans.map((leaf) => ( + + ))} + {doubleLeafPlans.map((leaf) => ( + + ))} + {doubleLeafPlans.map((leaf) => ( + + ))} + + ) : ( + <> + {swingSweepPath && ( + + )} + {swingAngle > 0.03 && ( + + )} + {[hingeCubeCenter, strikeCubeCenter].map((point, index) => ( + + ))} + + + + + )} - {[hingeCubeCenter, strikeCubeCenter].map((point, index) => ( - - ))} - - - - )} {isSelected ? ( diff --git a/packages/editor/src/components/tools/door/door-tool.tsx b/packages/editor/src/components/tools/door/door-tool.tsx index 2f4b6e889..76da29e71 100644 --- a/packages/editor/src/components/tools/door/door-tool.tsx +++ b/packages/editor/src/components/tools/door/door-tool.tsx @@ -248,6 +248,13 @@ export const DoorTool: React.FC = () => { parentId: event.node.id, width: draft.width, height: draft.height, + doorCategory: draft.doorCategory, + doorType: draft.doorType, + leafCount: draft.leafCount, + operationState: draft.operationState, + slideDirection: draft.slideDirection, + trackStyle: draft.trackStyle, + garagePanelCount: draft.garagePanelCount, frameThickness: draft.frameThickness, frameDepth: draft.frameDepth, threshold: draft.threshold, diff --git a/packages/editor/src/components/ui/panels/door-panel.tsx b/packages/editor/src/components/ui/panels/door-panel.tsx index 363a859ce..f0b5c480a 100755 --- a/packages/editor/src/components/ui/panels/door-panel.tsx +++ b/packages/editor/src/components/ui/panels/door-panel.tsx @@ -8,9 +8,10 @@ import { useScene, } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' -import { BookMarked, Copy, FlipHorizontal2, Move, Trash2 } from 'lucide-react' +import { BookMarked, Copy, DoorOpen, FlipHorizontal2, Move, Trash2 } from 'lucide-react' import { useCallback, useRef } from 'react' import { usePresetsAdapter } from '../../../contexts/presets-context' +import { cn } from '../../../lib/utils' import { sfxEmitter } from '../../../lib/sfx-bus' import useEditor from '../../../store/use-editor' import { ActionButton, ActionGroup } from '../controls/action-button' @@ -22,6 +23,63 @@ import { ToggleControl } from '../controls/toggle-control' import { PanelWrapper } from './panel-wrapper' import { PresetsPopover } from './presets/presets-popover' +const doorTypeOptions = [ + { label: 'Hinged', value: 'hinged', available: true }, + { label: 'Double', value: 'double', available: true }, + { label: 'French', value: 'french', available: true }, + { label: 'Folding', value: 'folding', available: true }, + { label: 'Pocket', value: 'pocket', available: true }, + { label: 'Barn', value: 'barn', available: true }, + { label: 'Sliding', value: 'sliding', available: true }, +] satisfies { + label: string + value: DoorNode['doorType'] + available: boolean +}[] + +const frenchDoorSegments: DoorNode['segments'] = [ + { + type: 'glass', + heightRatio: 0.76, + columnRatios: [1, 1], + dividerThickness: 0.025, + panelDepth: 0.01, + panelInset: 0.04, + }, + { + type: 'panel', + heightRatio: 0.24, + columnRatios: [1], + dividerThickness: 0.03, + panelDepth: 0.012, + panelInset: 0.035, + }, +] + +const foldingDoorSegments: DoorNode['segments'] = [ + { + type: 'panel', + heightRatio: 1, + columnRatios: [1], + dividerThickness: 0.02, + panelDepth: 0.008, + panelInset: 0.025, + }, +] + +const defaultDoorDimensions: Record = { + hinged: { width: 0.9, height: 2.1 }, + double: { width: 1.5, height: 2.1 }, + french: { width: 1.5, height: 2.1 }, + folding: { width: 1.8, height: 2.1 }, + pocket: { width: 0.9, height: 2.1 }, + barn: { width: 1, height: 2.1 }, + sliding: { width: 1.5, height: 2.1 }, + 'garage-sectional': { width: 2.7, height: 2.4 }, + 'garage-rollup': { width: 2.7, height: 2.4 }, + 'garage-tiltup': { width: 2.7, height: 2.4 }, +} + function isSameDoorValue(current: unknown, next: unknown): boolean { if (typeof current === 'number' && typeof next === 'number') { return Math.abs(current - next) < 1e-6 @@ -195,6 +253,13 @@ export function DoorPanel() { const getDoorPresetData = useCallback(() => { if (!node) return null return { + doorCategory: node.doorCategory, + doorType: node.doorType, + leafCount: node.leafCount, + operationState: node.operationState, + slideDirection: node.slideDirection, + trackStyle: node.trackStyle, + garagePanelCount: node.garagePanelCount, width: node.width, height: node.height, frameThickness: node.frameThickness, @@ -260,6 +325,10 @@ export function DoorPanel() { const archHeight = node.archHeight ?? 0.45 const openingRevealRadius = node.openingRevealRadius ?? 0.025 const maxRoundedRadius = Math.max(0.01, Math.min(node.width / 2, node.height)) + const doorType = node.doorType ?? 'hinged' + const isSwingDoor = doorType === 'hinged' || doorType === 'double' || doorType === 'french' + const isSlidingDoor = doorType === 'pocket' || doorType === 'barn' || doorType === 'sliding' + const supportsHandleSide = isSwingDoor const setOpeningTopRadius = (index: number, value: number, commit = false) => { const next = [...openingTopRadii] as [number, number] @@ -271,6 +340,101 @@ export function DoorPanel() { } } + const getDoorTypeUpdates = (nextDoorType: DoorNode['doorType']): Partial => { + const dimensions = defaultDoorDimensions[nextDoorType] + const dimensionUpdates = { + width: dimensions.width, + height: dimensions.height, + position: [node.position[0], dimensions.height / 2, node.position[2]] as DoorNode['position'], + } + + if (nextDoorType === 'double' || nextDoorType === 'french') { + return { + doorCategory: 'interior', + doorType: nextDoorType, + leafCount: 2, + ...dimensionUpdates, + handleSide: 'right', + ...(nextDoorType === 'french' + ? { + contentPadding: [0.045, 0.055], + segments: frenchDoorSegments, + } + : {}), + } + } + + if (nextDoorType === 'folding') { + return { + doorCategory: 'interior', + doorType: nextDoorType, + leafCount: 4, + ...dimensionUpdates, + handle: true, + handleSide: 'right', + trackStyle: 'visible', + operationState: Math.max(node.operationState ?? 0, 0.65), + contentPadding: [0.03, 0.04], + segments: foldingDoorSegments, + } + } + + if (nextDoorType === 'pocket') { + return { + doorCategory: 'interior', + doorType: nextDoorType, + leafCount: 1, + ...dimensionUpdates, + handle: true, + handleSide: 'right', + trackStyle: 'pocket', + slideDirection: node.slideDirection ?? 'left', + operationState: node.operationState ?? 0, + contentPadding: [0.035, 0.045], + segments: foldingDoorSegments, + } + } + + if (nextDoorType === 'barn') { + return { + doorCategory: 'interior', + doorType: nextDoorType, + leafCount: 1, + ...dimensionUpdates, + handle: true, + handleSide: 'right', + trackStyle: 'visible', + slideDirection: node.slideDirection ?? 'left', + operationState: node.operationState ?? 0, + contentPadding: [0.035, 0.045], + segments: foldingDoorSegments, + } + } + + if (nextDoorType === 'sliding') { + return { + doorCategory: 'interior', + doorType: nextDoorType, + leafCount: 2, + ...dimensionUpdates, + handle: true, + handleSide: 'right', + trackStyle: 'visible', + slideDirection: node.slideDirection ?? 'left', + operationState: node.operationState ?? 0, + contentPadding: [0.03, 0.04], + segments: frenchDoorSegments, + } + } + + return { + doorCategory: 'interior', + doorType: nextDoorType, + leafCount: 1, + ...dimensionUpdates, + } + } + return ( + {!isOpening && ( +
+ {doorTypeOptions.map((option) => { + const isSelected = doorType === option.value + return ( + + ) + })} +
+ )} @@ -353,6 +542,68 @@ export function DoorPanel() { )} + {doorType === 'folding' && !isOpening && ( + +
+
+ + Panels + + handleUpdate({ leafCount: v === '2' ? 2 : 4 })} + options={[ + { label: '2', value: '2' }, + { label: '4', value: '4' }, + ]} + value={node.leafCount === 2 ? '2' : '4'} + /> +
+
+ handleUpdate({ operationState: v / 100 })} + precision={0} + restoreOnCommit={false} + step={5} + unit="%" + value={Math.round((node.operationState ?? 0) * 100)} + /> +
+ )} + + {isSlidingDoor && !isOpening && ( + +
+
+ + {doorType === 'pocket' ? 'Pocket' : doorType === 'barn' ? 'Rail' : 'Panel'} + + handleUpdate({ slideDirection: v })} + options={[ + { label: 'Left', value: 'left' }, + { label: 'Right', value: 'right' }, + ]} + value={node.slideDirection ?? 'left'} + /> +
+
+ handleUpdate({ operationState: v / 100 })} + precision={0} + restoreOnCommit={false} + step={5} + unit="%" + value={Math.round((node.operationState ?? 0) * 100)} + /> +
+ )} + - -
-
- - Hinges Side - - handleUpdate({ hingesSide: v })} - options={[ - { label: 'Left', value: 'left' }, - { label: 'Right', value: 'right' }, - ]} - value={node.hingesSide} - /> -
-
- - Direction - - handleUpdate({ swingDirection: v })} - options={[ - { label: 'Inward', value: 'inward' }, - { label: 'Outward', value: 'outward' }, - ]} - value={node.swingDirection} - /> -
-
-
- - - handleUpdate({ threshold: checked })} - /> - {node.threshold && ( -
- handleUpdate({ thresholdHeight: v })} - precision={3} - step={0.005} - unit="m" - value={Math.round(node.thresholdHeight * 1000) / 1000} - /> -
- )} -
- - - handleUpdate({ handle: checked })} - /> - {node.handle && ( -
- handleUpdate({ handleHeight: v })} - precision={2} - step={0.05} - unit="m" - value={Math.round(node.handleHeight * 100) / 100} - /> + {isSwingDoor && ( + +
- Handle Side + Hinges Side handleUpdate({ handleSide: v })} + onChange={(v) => handleUpdate({ hingesSide: v })} options={[ { label: 'Left', value: 'left' }, { label: 'Right', value: 'right' }, ]} - value={node.handleSide} + value={node.hingesSide} + /> +
+
+ + Direction + + handleUpdate({ swingDirection: v })} + options={[ + { label: 'Inward', value: 'inward' }, + { label: 'Outward', value: 'outward' }, + ]} + value={node.swingDirection} />
- )} -
+ + )} - - handleUpdate({ doorCloser: checked })} - /> - handleUpdate({ panicBar: checked })} - /> - {node.panicBar && ( + {isSwingDoor && ( + + handleUpdate({ threshold: checked })} + /> + {node.threshold && ( +
+ handleUpdate({ thresholdHeight: v })} + precision={3} + step={0.005} + unit="m" + value={Math.round(node.thresholdHeight * 1000) / 1000} + /> +
+ )} +
+ )} + + + {isSwingDoor && ( + handleUpdate({ handle: checked })} + /> + )} + {(node.handle || !isSwingDoor) && (
handleUpdate({ panicBarHeight: v })} + onChange={(v) => handleUpdate({ handleHeight: v })} precision={2} step={0.05} unit="m" - value={Math.round(node.panicBarHeight * 100) / 100} + value={Math.round(node.handleHeight * 100) / 100} /> + {supportsHandleSide && ( +
+ + Handle Side + + handleUpdate({ handleSide: v })} + options={[ + { label: 'Left', value: 'left' }, + { label: 'Right', value: 'right' }, + ]} + value={node.handleSide} + /> +
+ )}
)}
+ {isSwingDoor && ( + + handleUpdate({ doorCloser: checked })} + /> + handleUpdate({ panicBar: checked })} + /> + {node.panicBar && ( +
+ handleUpdate({ panicBarHeight: v })} + precision={2} + step={0.05} + unit="m" + value={Math.round(node.panicBarHeight * 100) / 100} + /> +
+ )} +
+ )} + {node.segments.map((seg, i) => { const numCols = seg.columnRatios.length diff --git a/packages/editor/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx b/packages/editor/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx index 04ea2bb64..6fb4f2e00 100644 --- a/packages/editor/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/settings-panel/keyboard-shortcuts-dialog.tsx @@ -88,8 +88,8 @@ const SHORTCUT_CATEGORIES: ShortcutCategory[] = [ { title: 'Item Placement', shortcuts: [ - { keys: ['R'], action: 'Rotate item clockwise by 90 degrees' }, - { keys: ['T'], action: 'Rotate item counter-clockwise by 90 degrees' }, + { keys: ['R'], action: 'Rotate item clockwise, or toggle selected door open/closed' }, + { keys: ['T'], action: 'Rotate item counter-clockwise, or close selected door' }, { keys: ['Shift'], action: 'Temporarily bypass placement validation constraints', diff --git a/packages/editor/src/hooks/use-keyboard.ts b/packages/editor/src/hooks/use-keyboard.ts index dd7623865..e583fd196 100755 --- a/packages/editor/src/hooks/use-keyboard.ts +++ b/packages/editor/src/hooks/use-keyboard.ts @@ -7,6 +7,17 @@ import useEditor from '../store/use-editor' const DOOR_SWING_OPEN_ANGLE = Math.PI / 2 +function updateDoorOpenState( + doorId: AnyNodeId, + data: { operationState?: number; swingAngle?: number }, +) { + const scene = useScene.getState() + const node = scene.nodes[doorId] + scene.updateNode(doorId, data) + scene.dirtyNodes.add(doorId) + if (node?.parentId) scene.dirtyNodes.add(node.parentId as AnyNodeId) +} + // Tools call this in their onCancel handler when they have an active mid-action to cancel, // so that the global Escape handler knows not to also switch to select mode. let _toolCancelConsumed = false @@ -154,11 +165,23 @@ export const useKeyboard = ({ if (node?.type === 'door') { e.preventDefault() if (node.openingKind !== 'opening') { - const currentSwingAngle = node.swingAngle ?? 0 - useScene.getState().updateNode(node.id, { - swingAngle: - currentSwingAngle >= DOOR_SWING_OPEN_ANGLE / 2 ? 0 : DOOR_SWING_OPEN_ANGLE, - }) + if ( + node.doorType === 'folding' || + node.doorType === 'pocket' || + node.doorType === 'barn' || + node.doorType === 'sliding' + ) { + const currentOpenAmount = node.operationState ?? 0 + updateDoorOpenState(node.id, { + operationState: currentOpenAmount >= 0.5 ? 0 : 1, + }) + } else { + const currentSwingAngle = node.swingAngle ?? 0 + updateDoorOpenState(node.id, { + swingAngle: + currentSwingAngle >= DOOR_SWING_OPEN_ANGLE / 2 ? 0 : DOOR_SWING_OPEN_ANGLE, + }) + } sfxEmitter.emit('sfx:item-rotate') } } else if (node && 'rotation' in node) { @@ -184,7 +207,15 @@ export const useKeyboard = ({ if (node?.type === 'door') { e.preventDefault() if (node.openingKind !== 'opening') { - useScene.getState().updateNode(node.id, { swingAngle: 0 }) + updateDoorOpenState( + node.id, + node.doorType === 'folding' || + node.doorType === 'pocket' || + node.doorType === 'barn' || + node.doorType === 'sliding' + ? { operationState: 0 } + : { swingAngle: 0 }, + ) sfxEmitter.emit('sfx:item-rotate') } } else if (node && 'rotation' in node) { diff --git a/packages/viewer/src/systems/door/door-system.tsx b/packages/viewer/src/systems/door/door-system.tsx index a782ed954..d78d6c167 100644 --- a/packages/viewer/src/systems/door/door-system.tsx +++ b/packages/viewer/src/systems/door/door-system.tsx @@ -1,15 +1,11 @@ import { useFrame } from '@react-three/fiber' -import { - type AnyNodeId, - type DoorNode, - sceneRegistry, - useScene, -} from '@pascal-app/core' +import { type AnyNodeId, type DoorNode, sceneRegistry, useScene } from '@pascal-app/core' import * as THREE from 'three' import { baseMaterial, glassMaterial } from '../../lib/materials' // Invisible material for root mesh — used as selection hitbox only const hitboxMaterial = new THREE.MeshBasicMaterial({ visible: false }) +const revealMaterial = new THREE.MeshBasicMaterial({ color: '#7f766c' }) export const DoorSystem = () => { const dirtyNodes = useScene((state) => state.dirtyNodes) @@ -55,12 +51,730 @@ function addBox( parent.add(m) } +function addRotatedBox( + parent: THREE.Object3D, + material: THREE.Material, + w: number, + h: number, + d: number, + x: number, + y: number, + z: number, + rotationY: number, +) { + const m = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), material) + m.position.set(x, y, z) + m.rotation.y = rotationY + parent.add(m) +} + function disposeObject(object: THREE.Object3D) { object.traverse((child) => { if (child instanceof THREE.Mesh) child.geometry.dispose() }) } +function addLeafSegmentContent({ + addLeafBox, + leafWidth, + leafHeight, + leafCenterX, + leafCenterY, + leafDepth, + segments, + contentPadding, + keepFrameWhenEmpty = false, +}: { + addLeafBox: ( + material: THREE.Material, + w: number, + h: number, + d: number, + x: number, + y: number, + z: number, + ) => void + leafWidth: number + leafHeight: number + leafCenterX: number + leafCenterY: number + leafDepth: number + segments: DoorNode['segments'] + contentPadding: DoorNode['contentPadding'] + keepFrameWhenEmpty?: boolean +}) { + const hasLeafContent = segments.some((seg) => seg.type !== 'empty') + const shouldRenderFrame = hasLeafContent || keepFrameWhenEmpty + const cpX = contentPadding[0] + const cpY = contentPadding[1] + if (shouldRenderFrame && cpY > 0) { + addLeafBox( + baseMaterial, + leafWidth, + cpY, + leafDepth, + leafCenterX, + leafCenterY + leafHeight / 2 - cpY / 2, + 0, + ) + addLeafBox( + baseMaterial, + leafWidth, + cpY, + leafDepth, + leafCenterX, + leafCenterY - leafHeight / 2 + cpY / 2, + 0, + ) + } + if (shouldRenderFrame && cpX > 0) { + const innerH = leafHeight - 2 * cpY + addLeafBox( + baseMaterial, + cpX, + innerH, + leafDepth, + leafCenterX - leafWidth / 2 + cpX / 2, + leafCenterY, + 0, + ) + addLeafBox( + baseMaterial, + cpX, + innerH, + leafDepth, + leafCenterX + leafWidth / 2 - cpX / 2, + leafCenterY, + 0, + ) + } + + const contentW = leafWidth - 2 * cpX + const contentH = leafHeight - 2 * cpY + const totalRatio = segments.reduce((sum, s) => sum + s.heightRatio, 0) + const contentTop = leafCenterY + contentH / 2 + + let segY = contentTop + for (const seg of segments) { + const segH = (seg.heightRatio / totalRatio) * contentH + const segCenterY = segY - segH / 2 + const numCols = seg.columnRatios.length + const colSum = seg.columnRatios.reduce((a, b) => a + b, 0) + const usableW = contentW - (numCols - 1) * seg.dividerThickness + const colWidths = seg.columnRatios.map((r) => (r / colSum) * usableW) + + const colXCenters: number[] = [] + let cx = leafCenterX - contentW / 2 + for (let c = 0; c < numCols; c++) { + colXCenters.push(cx + colWidths[c]! / 2) + cx += colWidths[c]! + if (c < numCols - 1) cx += seg.dividerThickness + } + + if (seg.type !== 'empty') { + cx = leafCenterX - contentW / 2 + for (let c = 0; c < numCols - 1; c++) { + cx += colWidths[c]! + addLeafBox( + baseMaterial, + seg.dividerThickness, + segH, + leafDepth + 0.001, + cx + seg.dividerThickness / 2, + segCenterY, + 0, + ) + cx += seg.dividerThickness + } + } + + for (let c = 0; c < numCols; c++) { + const colW = colWidths[c]! + const colX = colXCenters[c]! + + if (seg.type === 'glass') { + const glassDepth = Math.max(0.004, leafDepth * 0.15) + addLeafBox(glassMaterial, colW, segH, glassDepth, colX, segCenterY, 0) + } else if (seg.type === 'panel') { + addLeafBox(baseMaterial, colW, segH, leafDepth, colX, segCenterY, 0) + const panelW = colW - 2 * seg.panelInset + const panelH = segH - 2 * seg.panelInset + if (panelW > 0.01 && panelH > 0.01) { + const effectiveDepth = Math.abs(seg.panelDepth) < 0.002 ? 0.005 : Math.abs(seg.panelDepth) + const panelZ = leafDepth / 2 + effectiveDepth / 2 + addLeafBox(baseMaterial, panelW, panelH, effectiveDepth, colX, segCenterY, panelZ) + } + } + } + + segY -= segH + } +} + +function addDoorLeaf( + mesh: THREE.Mesh, + { + leafWidth, + leafHeight, + leafCenterX, + leafCenterY, + leafDepth, + hingeX, + hingeSide, + swingRotation, + segments, + contentPadding, + handle, + handleHeight, + handleSide, + doorCloser, + panicBar, + panicBarHeight, + doorHeight, + }: { + leafWidth: number + leafHeight: number + leafCenterX: number + leafCenterY: number + leafDepth: number + hingeX: number + hingeSide: 'left' | 'right' + swingRotation: number + segments: DoorNode['segments'] + contentPadding: DoorNode['contentPadding'] + handle: boolean + handleHeight: number + handleSide: DoorNode['handleSide'] + doorCloser: boolean + panicBar: boolean + panicBarHeight: number + doorHeight: number + }, +) { + const hasLeafContent = segments.some((seg) => seg.type !== 'empty') + const leafGroup = new THREE.Group() + leafGroup.position.set(hingeX, 0, 0) + leafGroup.rotation.y = swingRotation + mesh.add(leafGroup) + + const addLeafBox = ( + material: THREE.Material, + w: number, + h: number, + d: number, + x: number, + y: number, + z: number, + ) => addBox(leafGroup, material, w, h, d, x - hingeX, y, z) + + addLeafSegmentContent({ + addLeafBox, + leafWidth, + leafHeight, + leafCenterX, + leafCenterY, + leafDepth, + segments, + contentPadding, + }) + + if (hasLeafContent && handle) { + const handleY = handleHeight - doorHeight / 2 + const faceZ = leafDepth / 2 + const handleX = + handleSide === 'right' + ? leafCenterX + leafWidth / 2 - 0.045 + : leafCenterX - leafWidth / 2 + 0.045 + + addLeafBox(baseMaterial, 0.028, 0.14, 0.01, handleX, handleY, faceZ + 0.005) + addLeafBox(baseMaterial, 0.022, 0.1, 0.035, handleX, handleY, faceZ + 0.025) + } + + if (hasLeafContent && doorCloser) { + const closerY = leafCenterY + leafHeight / 2 - 0.04 + addLeafBox(baseMaterial, 0.28, 0.055, 0.055, leafCenterX, closerY, leafDepth / 2 + 0.03) + addLeafBox( + baseMaterial, + 0.14, + 0.015, + 0.015, + leafCenterX + leafWidth / 4, + closerY + 0.025, + leafDepth / 2 + 0.015, + ) + } + + if (hasLeafContent && panicBar) { + const barY = panicBarHeight - doorHeight / 2 + addLeafBox(baseMaterial, leafWidth * 0.72, 0.04, 0.055, leafCenterX, barY, leafDepth / 2 + 0.03) + } + + if (hasLeafContent) { + const hingeMarkerX = hingeSide === 'right' ? hingeX - 0.012 : hingeX + 0.012 + const hingeH = 0.1 + const hingeW = 0.024 + const hingeD = leafDepth + 0.016 + const leafBottom = leafCenterY - leafHeight / 2 + const leafTop = leafCenterY + leafHeight / 2 + addBox(mesh, baseMaterial, hingeW, hingeH, hingeD, hingeMarkerX, leafBottom + 0.25, 0) + addBox(mesh, baseMaterial, hingeW, hingeH, hingeD, hingeMarkerX, (leafBottom + leafTop) / 2, 0) + addBox(mesh, baseMaterial, hingeW, hingeH, hingeD, hingeMarkerX, leafTop - 0.25, 0) + } +} + +function addFoldingDoor( + mesh: THREE.Mesh, + { + insideWidth, + leafHeight, + leafCenterY, + leafDepth, + frameThickness, + frameDepth, + operationState, + leafCount, + doorHeight, + handleHeight, + segments, + contentPadding, + }: { + insideWidth: number + leafHeight: number + leafCenterY: number + leafDepth: number + frameThickness: number + frameDepth: number + operationState: number + leafCount: DoorNode['leafCount'] + doorHeight: number + handleHeight: number + segments: DoorNode['segments'] + contentPadding: DoorNode['contentPadding'] + }, +) { + const panelCount = leafCount === 2 ? 2 : 4 + const foldAmount = Math.max(0, Math.min(1, operationState)) + const panelLength = insideWidth / panelCount + const foldAngle = Math.PI * 0.44 * foldAmount + + addBox( + mesh, + baseMaterial, + insideWidth, + Math.min(frameThickness * 0.5, 0.025), + Math.max(frameDepth * 0.45, 0.035), + 0, + leafCenterY + leafHeight / 2 - 0.018, + 0, + ) + + const vertices: Array<{ x: number; z: number }> = [{ x: -insideWidth / 2, z: 0 }] + for (let index = 0; index < panelCount; index++) { + const previous = vertices[index]! + const direction = index % 2 === 0 ? -1 : 1 + const angle = direction * foldAngle + vertices.push({ + x: previous.x + panelLength * Math.cos(angle), + z: previous.z + panelLength * Math.sin(angle), + }) + } + + for (let index = 0; index < panelCount; index++) { + const start = vertices[index]! + const end = vertices[index + 1]! + const dx = end.x - start.x + const dz = end.z - start.z + const centerX = (start.x + end.x) / 2 + const centerZ = (start.z + end.z) / 2 + const rotationY = Math.atan2(-dz, dx) + const localX = { + x: Math.cos(rotationY), + z: -Math.sin(rotationY), + } + + const addFoldingLeafBox = ( + material: THREE.Material, + w: number, + h: number, + d: number, + x: number, + y: number, + z: number, + ) => { + addRotatedBox( + mesh, + material, + w, + h, + d, + centerX + localX.x * x + Math.sin(rotationY) * z, + y, + centerZ + localX.z * x + Math.cos(rotationY) * z, + rotationY, + ) + } + + addLeafSegmentContent({ + addLeafBox: addFoldingLeafBox, + leafWidth: Math.max(0.08, panelLength), + leafHeight, + leafCenterX: 0, + leafCenterY, + leafDepth, + segments, + contentPadding, + keepFrameWhenEmpty: true, + }) + + for (const point of [start, end]) { + addBox( + mesh, + revealMaterial, + 0.018, + leafHeight * 0.92, + leafDepth + 0.016, + point.x, + leafCenterY, + point.z, + ) + } + } + + const handlePoint = vertices[vertices.length - 1]! + const handleY = handleHeight - doorHeight / 2 + addBox( + mesh, + baseMaterial, + 0.035, + 0.16, + leafDepth + 0.035, + handlePoint.x - 0.035, + handleY, + handlePoint.z + 0.045, + ) +} + +function addPocketDoor( + mesh: THREE.Mesh, + { + insideWidth, + leafHeight, + leafCenterY, + leafDepth, + frameThickness, + frameDepth, + operationState, + slideDirection, + doorHeight, + handleHeight, + segments, + contentPadding, + }: { + insideWidth: number + leafHeight: number + leafCenterY: number + leafDepth: number + frameThickness: number + frameDepth: number + operationState: number + slideDirection: DoorNode['slideDirection'] + doorHeight: number + handleHeight: number + segments: DoorNode['segments'] + contentPadding: DoorNode['contentPadding'] + }, +) { + const openAmount = Math.max(0, Math.min(1, operationState)) + const slideSign = slideDirection === 'right' ? 1 : -1 + const leafWidth = insideWidth + const leafCenterX = slideSign * insideWidth * openAmount + const topY = leafCenterY + leafHeight / 2 + const pocketCenterX = slideSign * insideWidth + const handleY = handleHeight - doorHeight / 2 + const handleX = leafCenterX - slideSign * (leafWidth / 2 - 0.055) + + addBox( + mesh, + baseMaterial, + insideWidth * 2, + Math.min(frameThickness * 0.45, 0.024), + Math.max(frameDepth * 0.38, 0.03), + slideSign * (insideWidth / 2), + topY - 0.018, + 0, + ) + addBox( + mesh, + revealMaterial, + insideWidth * 0.9, + 0.018, + Math.max(frameDepth * 0.32, 0.026), + pocketCenterX, + topY - 0.055, + 0, + ) + addBox( + mesh, + revealMaterial, + 0.018, + leafHeight * 0.94, + leafDepth + 0.014, + slideSign * insideWidth * 0.5, + leafCenterY, + 0, + ) + + const addPocketLeafBox = ( + material: THREE.Material, + w: number, + h: number, + d: number, + x: number, + y: number, + z: number, + ) => addBox(mesh, material, w, h, d, x, y, z) + + addLeafSegmentContent({ + addLeafBox: addPocketLeafBox, + leafWidth, + leafHeight, + leafCenterX, + leafCenterY, + leafDepth, + segments, + contentPadding, + }) + addBox(mesh, baseMaterial, 0.03, 0.18, leafDepth + 0.03, handleX, handleY, leafDepth / 2 + 0.02) +} + +function addBarnDoor( + mesh: THREE.Mesh, + { + insideWidth, + leafHeight, + leafCenterY, + leafDepth, + frameThickness, + frameDepth, + operationState, + slideDirection, + doorHeight, + handleHeight, + segments, + contentPadding, + }: { + insideWidth: number + leafHeight: number + leafCenterY: number + leafDepth: number + frameThickness: number + frameDepth: number + operationState: number + slideDirection: DoorNode['slideDirection'] + doorHeight: number + handleHeight: number + segments: DoorNode['segments'] + contentPadding: DoorNode['contentPadding'] + }, +) { + const openAmount = Math.max(0, Math.min(1, operationState)) + const slideSign = slideDirection === 'right' ? 1 : -1 + const leafWidth = insideWidth * 1.06 + const leafCenterX = slideSign * insideWidth * openAmount + const faceZ = frameDepth / 2 + leafDepth / 2 + 0.028 + const trackY = leafCenterY + leafHeight / 2 + Math.max(frameThickness * 0.55, 0.045) + const railLength = insideWidth * 2.25 + const railCenterX = slideSign * (insideWidth * 0.56) + const handleY = handleHeight - doorHeight / 2 + const handleX = leafCenterX - slideSign * (leafWidth / 2 - 0.075) + const wheelY = trackY - 0.075 + + addBox(mesh, revealMaterial, railLength, 0.035, 0.035, railCenterX, trackY, faceZ + 0.01) + addBox(mesh, revealMaterial, 0.05, 0.13, 0.035, -insideWidth / 2, trackY - 0.02, faceZ + 0.01) + addBox(mesh, revealMaterial, 0.05, 0.13, 0.035, insideWidth / 2, trackY - 0.02, faceZ + 0.01) + + const addBarnLeafBox = ( + material: THREE.Material, + w: number, + h: number, + d: number, + x: number, + y: number, + z: number, + ) => addBox(mesh, material, w, h, d, x, y, faceZ + z) + + addLeafSegmentContent({ + addLeafBox: addBarnLeafBox, + leafWidth, + leafHeight, + leafCenterX, + leafCenterY, + leafDepth, + segments, + contentPadding, + keepFrameWhenEmpty: true, + }) + + addRotatedBox( + mesh, + revealMaterial, + 0.018, + leafHeight * 0.86, + 0.012, + leafCenterX, + leafCenterY, + faceZ + leafDepth / 2 + 0.014, + -0.52, + ) + addRotatedBox( + mesh, + revealMaterial, + 0.018, + leafHeight * 0.86, + 0.012, + leafCenterX, + leafCenterY, + faceZ + leafDepth / 2 + 0.014, + 0.52, + ) + + for (const offset of [-leafWidth * 0.28, leafWidth * 0.28]) { + addBox(mesh, revealMaterial, 0.085, 0.085, 0.035, leafCenterX + offset, wheelY, faceZ + 0.022) + addBox( + mesh, + revealMaterial, + 0.026, + 0.16, + 0.026, + leafCenterX + offset, + wheelY - 0.075, + faceZ + 0.022, + ) + } + + addBox( + mesh, + baseMaterial, + 0.032, + 0.22, + leafDepth + 0.034, + handleX, + handleY, + faceZ + leafDepth / 2 + 0.02, + ) +} + +function addSlidingDoor( + mesh: THREE.Mesh, + { + insideWidth, + leafHeight, + leafCenterY, + leafDepth, + frameThickness, + frameDepth, + operationState, + slideDirection, + doorHeight, + handleHeight, + segments, + contentPadding, + }: { + insideWidth: number + leafHeight: number + leafCenterY: number + leafDepth: number + frameThickness: number + frameDepth: number + operationState: number + slideDirection: DoorNode['slideDirection'] + doorHeight: number + handleHeight: number + segments: DoorNode['segments'] + contentPadding: DoorNode['contentPadding'] + }, +) { + const openAmount = Math.max(0, Math.min(1, operationState)) + const activeOnRight = slideDirection === 'left' + const fixedSign = activeOnRight ? -1 : 1 + const activeSign = activeOnRight ? 1 : -1 + const panelWidth = insideWidth * 0.54 + const panelHeight = leafHeight + const closedActiveX = activeSign * insideWidth * 0.23 + const fixedX = fixedSign * insideWidth * 0.23 + const activeX = closedActiveX - activeSign * insideWidth * 0.44 * openAmount + const frontZ = leafDepth / 2 + 0.016 + const backZ = -leafDepth / 2 - 0.006 + const railY = leafCenterY + panelHeight / 2 - Math.min(frameThickness * 0.35, 0.02) + const handleY = handleHeight - doorHeight / 2 + const handleX = activeX - activeSign * (panelWidth / 2 - 0.06) + + addBox(mesh, revealMaterial, insideWidth, 0.024, Math.max(frameDepth * 0.32, 0.026), 0, railY, 0) + addBox( + mesh, + revealMaterial, + insideWidth, + 0.018, + Math.max(frameDepth * 0.28, 0.022), + 0, + -leafHeight / 2 + 0.04, + 0, + ) + + const addFixedPanelBox = ( + material: THREE.Material, + w: number, + h: number, + d: number, + x: number, + y: number, + z: number, + ) => addBox(mesh, material, w, h, d, x + fixedX, y, z + backZ) + + const addActivePanelBox = ( + material: THREE.Material, + w: number, + h: number, + d: number, + x: number, + y: number, + z: number, + ) => addBox(mesh, material, w, h, d, x + activeX, y, z + frontZ) + + addLeafSegmentContent({ + addLeafBox: addFixedPanelBox, + leafWidth: panelWidth, + leafHeight: panelHeight, + leafCenterX: 0, + leafCenterY, + leafDepth, + segments, + contentPadding, + keepFrameWhenEmpty: true, + }) + addLeafSegmentContent({ + addLeafBox: addActivePanelBox, + leafWidth: panelWidth, + leafHeight: panelHeight, + leafCenterX: 0, + leafCenterY, + leafDepth, + segments, + contentPadding, + keepFrameWhenEmpty: true, + }) + addBox( + mesh, + baseMaterial, + 0.028, + 0.18, + leafDepth + 0.03, + handleX, + handleY, + frontZ + leafDepth / 2 + 0.018, + ) +} + function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) { // Root mesh is an invisible hitbox; all visuals live in child meshes mesh.geometry.dispose() @@ -97,8 +811,11 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) { hingesSide, swingDirection, swingAngle = 0, + doorType = 'hinged', + operationState = 0, + leafCount = 1, + slideDirection = 'left', } = node - const hasLeafContent = segments.some((seg) => seg.type !== 'empty') const clampedSwingAngle = Math.max(0, Math.min(Math.PI / 2, swingAngle)) if (openingKind === 'opening') { @@ -106,29 +823,11 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) { return } - // Leaf occupies the full opening (no bottom frame bar — door opens to floor) - const leafW = width - 2 * frameThickness + const insideWidth = width - 2 * frameThickness const leafH = height - frameThickness // only top frame const leafDepth = 0.04 - // Leaf center is shifted down from door center by half the top frame const leafCenterY = -frameThickness / 2 - const hingeX = hingesSide === 'right' ? leafW / 2 : -leafW / 2 const swingDirectionSign = swingDirection === 'inward' ? 1 : -1 - const hingeDirectionSign = hingesSide === 'right' ? 1 : -1 - const leafSwingRotation = clampedSwingAngle * swingDirectionSign * hingeDirectionSign - const leafGroup = new THREE.Group() - leafGroup.position.set(hingeX, 0, 0) - leafGroup.rotation.y = leafSwingRotation - mesh.add(leafGroup) - const addLeafBox = ( - material: THREE.Material, - w: number, - h: number, - d: number, - x: number, - y: number, - z: number, - ) => addBox(leafGroup, material, w, h, d, x - hingeX, y, z) // ── Frame members ── // Left post — full height @@ -170,7 +869,7 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) { addBox( mesh, baseMaterial, - leafW, + insideWidth, thresholdHeight, frameDepth, 0, @@ -179,140 +878,128 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) { ) } - // ── Leaf — contentPadding border strips (no full backing; glass areas are open) ── - const cpX = contentPadding[0] - const cpY = contentPadding[1] - if (hasLeafContent && cpY > 0) { - // Top strip - addLeafBox(baseMaterial, leafW, cpY, leafDepth, 0, leafCenterY + leafH / 2 - cpY / 2, 0) - // Bottom strip - addLeafBox(baseMaterial, leafW, cpY, leafDepth, 0, leafCenterY - leafH / 2 + cpY / 2, 0) - } - if (hasLeafContent && cpX > 0) { - const innerH = leafH - 2 * cpY - // Left strip - addLeafBox(baseMaterial, cpX, innerH, leafDepth, -leafW / 2 + cpX / 2, leafCenterY, 0) - // Right strip - addLeafBox(baseMaterial, cpX, innerH, leafDepth, leafW / 2 - cpX / 2, leafCenterY, 0) - } - - // Content area inside padding - const contentW = leafW - 2 * cpX - const contentH = leafH - 2 * cpY - - // ── Segments (stacked top to bottom within content area) ── - const totalRatio = segments.reduce((sum, s) => sum + s.heightRatio, 0) - const contentTop = leafCenterY + contentH / 2 - - let segY = contentTop - for (const seg of segments) { - const segH = (seg.heightRatio / totalRatio) * contentH - const segCenterY = segY - segH / 2 - - const numCols = seg.columnRatios.length - const colSum = seg.columnRatios.reduce((a, b) => a + b, 0) - const usableW = contentW - (numCols - 1) * seg.dividerThickness - const colWidths = seg.columnRatios.map((r) => (r / colSum) * usableW) - - // Column x-centers (relative to mesh center) - const colXCenters: number[] = [] - let cx = -contentW / 2 - for (let c = 0; c < numCols; c++) { - colXCenters.push(cx + colWidths[c]! / 2) - cx += colWidths[c]! - if (c < numCols - 1) cx += seg.dividerThickness - } - - // Column dividers within this segment - if (seg.type !== 'empty') { - cx = -contentW / 2 - for (let c = 0; c < numCols - 1; c++) { - cx += colWidths[c]! - addLeafBox( - baseMaterial, - seg.dividerThickness, - segH, - leafDepth + 0.001, - cx + seg.dividerThickness / 2, - segCenterY, - 0, - ) - cx += seg.dividerThickness - } - } - - // Segment content per column - for (let c = 0; c < numCols; c++) { - const colW = colWidths[c]! - const colX = colXCenters[c]! - - if (seg.type === 'glass') { - // Glass only — no opaque backing so it's truly transparent - const glassDepth = Math.max(0.004, leafDepth * 0.15) - addLeafBox(glassMaterial, colW, segH, glassDepth, colX, segCenterY, 0) - } else if (seg.type === 'panel') { - // Opaque leaf backing for this column - addLeafBox(baseMaterial, colW, segH, leafDepth, colX, segCenterY, 0) - // Raised panel detail - const panelW = colW - 2 * seg.panelInset - const panelH = segH - 2 * seg.panelInset - if (panelW > 0.01 && panelH > 0.01) { - const effectiveDepth = Math.abs(seg.panelDepth) < 0.002 ? 0.005 : Math.abs(seg.panelDepth) - const panelZ = leafDepth / 2 + effectiveDepth / 2 - addLeafBox(baseMaterial, panelW, panelH, effectiveDepth, colX, segCenterY, panelZ) - } - } else { - // 'empty' leaves the opening unfilled - } - } - - segY -= segH - } - - // ── Handle ── - if (hasLeafContent && handle) { - // Convert from floor-based height to mesh-center-based Y - const handleY = handleHeight - height / 2 - // Handle grip sits on the front face (+Z) of the leaf - const faceZ = leafDepth / 2 - - // X position: handleSide refers to which side the grip is on - const handleX = handleSide === 'right' ? leafW / 2 - 0.045 : -leafW / 2 + 0.045 - - // Backplate - addLeafBox(baseMaterial, 0.028, 0.14, 0.01, handleX, handleY, faceZ + 0.005) - // Grip lever - addLeafBox(baseMaterial, 0.022, 0.1, 0.035, handleX, handleY, faceZ + 0.025) - } - - // ── Door closer (commercial hardware at top) ── - if (hasLeafContent && doorCloser) { - const closerY = leafCenterY + leafH / 2 - 0.04 - // Body - addLeafBox(baseMaterial, 0.28, 0.055, 0.055, 0, closerY, leafDepth / 2 + 0.03) - // Arm (simplified as thin bar to frame side) - addLeafBox(baseMaterial, 0.14, 0.015, 0.015, leafW / 4, closerY + 0.025, leafDepth / 2 + 0.015) - } - - // ── Panic bar ── - if (hasLeafContent && panicBar) { - const barY = panicBarHeight - height / 2 - addLeafBox(baseMaterial, leafW * 0.72, 0.04, 0.055, 0, barY, leafDepth / 2 + 0.03) - } - - // ── Hinges (3 knuckle-style hinges on the hinge side) ── - if (hasLeafContent) { - const hingeX = hingesSide === 'right' ? leafW / 2 - 0.012 : -leafW / 2 + 0.012 - const hingeZ = 0 // centered in leaf depth - const hingeH = 0.1 - const hingeW = 0.024 - const hingeD = leafDepth + 0.016 - // Bottom hinge ~0.25m from floor, middle hinge, top hinge ~0.25m from top - const leafBottom = leafCenterY - leafH / 2 - const leafTop = leafCenterY + leafH / 2 - addBox(mesh, baseMaterial, hingeW, hingeH, hingeD, hingeX, leafBottom + 0.25, hingeZ) - addBox(mesh, baseMaterial, hingeW, hingeH, hingeD, hingeX, (leafBottom + leafTop) / 2, hingeZ) - addBox(mesh, baseMaterial, hingeW, hingeH, hingeD, hingeX, leafTop - 0.25, hingeZ) + if (doorType === 'folding') { + addFoldingDoor(mesh, { + insideWidth, + leafHeight: leafH, + leafCenterY, + leafDepth, + frameThickness, + frameDepth, + operationState, + leafCount, + doorHeight: height, + handleHeight, + segments, + contentPadding, + }) + } else if (doorType === 'pocket') { + addPocketDoor(mesh, { + insideWidth, + leafHeight: leafH, + leafCenterY, + leafDepth, + frameThickness, + frameDepth, + operationState, + slideDirection, + doorHeight: height, + handleHeight, + segments, + contentPadding, + }) + } else if (doorType === 'barn') { + addBarnDoor(mesh, { + insideWidth, + leafHeight: leafH, + leafCenterY, + leafDepth, + frameThickness, + frameDepth, + operationState, + slideDirection, + doorHeight: height, + handleHeight, + segments, + contentPadding, + }) + } else if (doorType === 'sliding') { + addSlidingDoor(mesh, { + insideWidth, + leafHeight: leafH, + leafCenterY, + leafDepth, + frameThickness, + frameDepth, + operationState, + slideDirection, + doorHeight: height, + handleHeight, + segments, + contentPadding, + }) + } else if (doorType === 'double' || doorType === 'french') { + const doubleLeafW = insideWidth / 2 + addDoorLeaf(mesh, { + leafWidth: doubleLeafW, + leafHeight: leafH, + leafCenterX: -insideWidth / 4, + leafCenterY, + leafDepth, + hingeX: -insideWidth / 2, + hingeSide: 'left', + swingRotation: -clampedSwingAngle * swingDirectionSign, + segments, + contentPadding, + handle, + handleHeight, + handleSide: 'right', + doorCloser, + panicBar, + panicBarHeight, + doorHeight: height, + }) + addDoorLeaf(mesh, { + leafWidth: doubleLeafW, + leafHeight: leafH, + leafCenterX: insideWidth / 4, + leafCenterY, + leafDepth, + hingeX: insideWidth / 2, + hingeSide: 'right', + swingRotation: clampedSwingAngle * swingDirectionSign, + segments, + contentPadding, + handle, + handleHeight, + handleSide: 'left', + doorCloser: false, + panicBar, + panicBarHeight, + doorHeight: height, + }) + } else { + const hingeX = hingesSide === 'right' ? insideWidth / 2 : -insideWidth / 2 + const hingeDirectionSign = hingesSide === 'right' ? 1 : -1 + addDoorLeaf(mesh, { + leafWidth: insideWidth, + leafHeight: leafH, + leafCenterX: 0, + leafCenterY, + leafDepth, + hingeX, + hingeSide: hingesSide, + swingRotation: clampedSwingAngle * swingDirectionSign * hingeDirectionSign, + segments, + contentPadding, + handle, + handleHeight, + handleSide, + doorCloser, + panicBar, + panicBarHeight, + doorHeight: height, + }) } syncDoorCutout(node, mesh) From 6df4b4895c14479e61a460fe4c3dc26623812398 Mon Sep 17 00:00:00 2001 From: sudhir Date: Tue, 5 May 2026 14:20:41 +0530 Subject: [PATCH 2/6] Add garage door types and animated door controls --- .../editor/first-person-controls.tsx | 5 +- .../src/components/editor/floorplan-panel.tsx | 77 +++ .../src/components/ui/panels/door-panel.tsx | 558 +++++++++++------- packages/editor/src/hooks/use-keyboard.ts | 61 +- .../viewer/src/systems/door/door-system.tsx | 340 ++++++++++- 5 files changed, 805 insertions(+), 236 deletions(-) diff --git a/packages/editor/src/components/editor/first-person-controls.tsx b/packages/editor/src/components/editor/first-person-controls.tsx index 35ce42d6e..b146d533a 100644 --- a/packages/editor/src/components/editor/first-person-controls.tsx +++ b/packages/editor/src/components/editor/first-person-controls.tsx @@ -166,7 +166,10 @@ export const FirstPersonControls = () => { node.doorType === 'folding' || node.doorType === 'pocket' || node.doorType === 'barn' || - node.doorType === 'sliding' + node.doorType === 'sliding' || + node.doorType === 'garage-sectional' || + node.doorType === 'garage-rollup' || + node.doorType === 'garage-tiltup' ) { const currentOpenAmount = node.operationState ?? 0 updateDoorOpenState(doorId, { diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index df6f4a1e3..0cb751688 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -4820,6 +4820,23 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({ } const slidingFixedPoints = slidingPanelPoints(slidingFixedCenter, doorCubeSize * 0.34) const slidingActivePoints = slidingPanelPoints(slidingActiveCenter, doorCubeSize * 0.68) + const isGarageSectionalDoor = opening.doorType === 'garage-sectional' + const isGarageRollupDoor = opening.doorType === 'garage-rollup' + const isGarageTiltupDoor = opening.doorType === 'garage-tiltup' + const garagePanelCount = Math.max(3, Math.min(12, opening.garagePanelCount ?? 4)) + const garagePanelLines = Array.from({ length: garagePanelCount - 1 }, (_, index) => { + const t = (index + 1) / garagePanelCount + return { + start: { + x: svgP1.x + (svgP2.x - svgP1.x) * t, + y: svgP1.y + (svgP2.y - svgP1.y) * t, + }, + end: { + x: svgP1.x + (svgP2.x - svgP1.x) * t + px * swingSign * doorCubeSize * 0.78, + y: svgP1.y + (svgP2.y - svgP1.y) * t + py * swingSign * doorCubeSize * 0.78, + }, + } + }) const isDoubleSwingDoor = opening.doorType === 'double' || opening.doorType === 'french' const doubleLeafPlans = isDoubleSwingDoor ? ( @@ -5215,6 +5232,66 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({ vectorEffect="non-scaling-stroke" /> + ) : isGarageSectionalDoor || isGarageRollupDoor || isGarageTiltupDoor ? ( + <> + + + {isGarageRollupDoor ? ( + + ) : isGarageTiltupDoor ? ( + + ) : ( + garagePanelLines.map((line, index) => ( + + )) + )} + ) : isDoubleSwingDoor ? ( <> {doubleLeafPlans.map((leaf) => diff --git a/packages/editor/src/components/ui/panels/door-panel.tsx b/packages/editor/src/components/ui/panels/door-panel.tsx index f0b5c480a..77b009d78 100755 --- a/packages/editor/src/components/ui/panels/door-panel.tsx +++ b/packages/editor/src/components/ui/panels/door-panel.tsx @@ -37,6 +37,16 @@ const doorTypeOptions = [ available: boolean }[] +const garageDoorTypeOptions = [ + { label: 'Sectional', value: 'garage-sectional', available: true }, + { label: 'Roll-up', value: 'garage-rollup', available: true }, + { label: 'Tilt-up', value: 'garage-tiltup', available: true }, +] satisfies { + label: string + value: DoorNode['doorType'] + available: boolean +}[] + const frenchDoorSegments: DoorNode['segments'] = [ { type: 'glass', @@ -328,7 +338,13 @@ export function DoorPanel() { const doorType = node.doorType ?? 'hinged' const isSwingDoor = doorType === 'hinged' || doorType === 'double' || doorType === 'french' const isSlidingDoor = doorType === 'pocket' || doorType === 'barn' || doorType === 'sliding' + const isGarageDoor = node.doorCategory === 'garage' || doorType.startsWith('garage-') + const isSectionalGarageDoor = doorType === 'garage-sectional' + const isRollupGarageDoor = doorType === 'garage-rollup' + const isTiltupGarageDoor = doorType === 'garage-tiltup' + const typeMode = isOpening ? 'opening' : isGarageDoor ? 'garage' : 'door' const supportsHandleSide = isSwingDoor + const maxDoorWidth = isGarageDoor ? 6 : 3 const setOpeningTopRadius = (index: number, value: number, commit = false) => { const next = [...openingTopRadii] as [number, number] @@ -427,11 +443,60 @@ export function DoorPanel() { } } + if (nextDoorType === 'garage-sectional') { + return { + doorCategory: 'garage', + doorType: nextDoorType, + leafCount: 1, + ...dimensionUpdates, + handle: false, + threshold: false, + trackStyle: 'overhead', + operationState: 0, + garagePanelCount: Math.max(3, Math.min(8, node.garagePanelCount ?? 4)), + contentPadding: [0.04, 0.04], + segments: foldingDoorSegments, + } + } + + if (nextDoorType === 'garage-rollup') { + return { + doorCategory: 'garage', + doorType: nextDoorType, + leafCount: 1, + ...dimensionUpdates, + handle: false, + threshold: false, + trackStyle: 'overhead', + operationState: 0, + garagePanelCount: 4, + contentPadding: [0.04, 0.04], + segments: foldingDoorSegments, + } + } + + if (nextDoorType === 'garage-tiltup') { + return { + doorCategory: 'garage', + doorType: nextDoorType, + leafCount: 1, + ...dimensionUpdates, + handle: false, + threshold: false, + trackStyle: 'overhead', + operationState: 0, + garagePanelCount: 4, + contentPadding: [0.04, 0.04], + segments: foldingDoorSegments, + } + } + return { doorCategory: 'interior', doorType: nextDoorType, leafCount: 1, ...dimensionUpdates, + threshold: true, } } @@ -478,19 +543,28 @@ export function DoorPanel() { archHeight, openingRevealRadius, } - : { openingKind: v }, + : v === 'garage' + ? { + openingKind: 'door', + ...getDoorTypeUpdates(isGarageDoor ? doorType : 'garage-sectional'), + } + : { + openingKind: 'door', + ...(isGarageDoor ? getDoorTypeUpdates('hinged') : {}), + }, ) } options={[ { label: 'Door', value: 'door' }, { label: 'Opening', value: 'opening' }, + { label: 'Garage', value: 'garage' }, ]} - value={node.openingKind} + value={typeMode} />
{!isOpening && (
- {doorTypeOptions.map((option) => { + {(isGarageDoor ? garageDoorTypeOptions : doorTypeOptions).map((option) => { const isSelected = doorType === option.value return (