Add garage door types and animated door controls

This commit is contained in:
sudhir
2026-05-05 14:20:41 +05:30
parent 1490d280d4
commit 6df4b4895c
5 changed files with 805 additions and 236 deletions
@@ -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, {
@@ -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 ? (
<>
<line
stroke={doorStroke}
strokeLinecap="round"
strokeWidth={isSelected || isSelectionHighlighted ? '1.8' : '1.25'}
vectorEffect="non-scaling-stroke"
x1={svgP1.x}
x2={svgP2.x}
y1={svgP1.y}
y2={svgP2.y}
/>
<line
stroke={doorSoftStroke}
strokeDasharray="0.12 0.08"
strokeLinecap="round"
strokeWidth="1"
vectorEffect="non-scaling-stroke"
x1={svgP1.x + px * swingSign * doorCubeSize * 0.78}
x2={svgP2.x + px * swingSign * doorCubeSize * 0.78}
y1={svgP1.y + py * swingSign * doorCubeSize * 0.78}
y2={svgP2.y + py * swingSign * doorCubeSize * 0.78}
/>
{isGarageRollupDoor ? (
<circle
cx={(svgP1.x + svgP2.x) / 2 + px * swingSign * doorCubeSize * 0.78}
cy={(svgP1.y + svgP2.y) / 2 + py * swingSign * doorCubeSize * 0.78}
fill="none"
r={doorCubeSize * 0.22}
stroke={doorSoftStroke}
strokeWidth="0.9"
vectorEffect="non-scaling-stroke"
/>
) : isGarageTiltupDoor ? (
<line
stroke={doorSoftStroke}
strokeLinecap="round"
strokeWidth="1"
vectorEffect="non-scaling-stroke"
x1={svgP1.x + px * swingSign * doorCubeSize * 0.18}
x2={svgP2.x + px * swingSign * doorCubeSize * 0.78}
y1={svgP1.y + py * swingSign * doorCubeSize * 0.18}
y2={svgP2.y + py * swingSign * doorCubeSize * 0.78}
/>
) : (
garagePanelLines.map((line, index) => (
<line
key={`${opening.id}:garage-section:${index}`}
stroke={doorSoftStroke}
strokeLinecap="round"
strokeWidth="0.8"
vectorEffect="non-scaling-stroke"
x1={line.start.x}
x2={line.end.x}
y1={line.start.y}
y2={line.end.y}
/>
))
)}
</>
) : isDoubleSwingDoor ? (
<>
{doubleLeafPlans.map((leaf) =>
@@ -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}
/>
</div>
{!isOpening && (
<div className="grid grid-cols-2 gap-1.5 px-1 pt-1">
{doorTypeOptions.map((option) => {
{(isGarageDoor ? garageDoorTypeOptions : doorTypeOptions).map((option) => {
const isSelected = doorType === option.value
return (
<button
@@ -604,10 +678,38 @@ export function DoorPanel() {
</PanelSection>
)}
{(isSectionalGarageDoor || isRollupGarageDoor || isTiltupGarageDoor) && !isOpening && (
<PanelSection title="Garage">
<SliderControl
label="Open"
max={100}
min={0}
onChange={(v) => handleUpdate({ operationState: v / 100 })}
precision={0}
restoreOnCommit={false}
step={5}
unit="%"
value={Math.round((node.operationState ?? 0) * 100)}
/>
{isSectionalGarageDoor && (
<SliderControl
label="Panels"
max={8}
min={3}
onChange={(v) => handleUpdate({ garagePanelCount: Math.round(v) })}
precision={0}
restoreOnCommit={false}
step={1}
value={node.garagePanelCount ?? 4}
/>
)}
</PanelSection>
)}
<PanelSection title="Dimensions">
<SliderControl
label="Width"
max={3}
max={maxDoorWidth}
min={0.5}
onChange={(v) => handleUpdate({ width: v })}
precision={2}
@@ -729,51 +831,53 @@ export function DoorPanel() {
{!isOpening && (
<>
<PanelSection title="Frame">
<SliderControl
label="Thickness"
max={0.2}
min={0.01}
onChange={(v) => handleUpdate({ frameThickness: v })}
precision={3}
step={0.01}
unit="m"
value={Math.round(node.frameThickness * 1000) / 1000}
/>
<SliderControl
label="Depth"
max={0.3}
min={0.01}
onChange={(v) => handleUpdate({ frameDepth: v })}
precision={3}
step={0.01}
unit="m"
value={Math.round(node.frameDepth * 1000) / 1000}
/>
</PanelSection>
<PanelSection title="Frame">
<SliderControl
label="Thickness"
max={0.2}
min={0.01}
onChange={(v) => handleUpdate({ frameThickness: v })}
precision={3}
step={0.01}
unit="m"
value={Math.round(node.frameThickness * 1000) / 1000}
/>
<SliderControl
label="Depth"
max={0.3}
min={0.01}
onChange={(v) => handleUpdate({ frameDepth: v })}
precision={3}
step={0.01}
unit="m"
value={Math.round(node.frameDepth * 1000) / 1000}
/>
</PanelSection>
<PanelSection title="Content Padding">
<SliderControl
label="Horizontal"
max={0.2}
min={0}
onChange={(v) => handleUpdate({ contentPadding: [v, node.contentPadding[1]] })}
precision={3}
step={0.005}
unit="m"
value={Math.round(node.contentPadding[0] * 1000) / 1000}
/>
<SliderControl
label="Vertical"
max={0.2}
min={0}
onChange={(v) => handleUpdate({ contentPadding: [node.contentPadding[0], v] })}
precision={3}
step={0.005}
unit="m"
value={Math.round(node.contentPadding[1] * 1000) / 1000}
/>
</PanelSection>
{!isGarageDoor && (
<PanelSection title="Content Padding">
<SliderControl
label="Horizontal"
max={0.2}
min={0}
onChange={(v) => handleUpdate({ contentPadding: [v, node.contentPadding[1]] })}
precision={3}
step={0.005}
unit="m"
value={Math.round(node.contentPadding[0] * 1000) / 1000}
/>
<SliderControl
label="Vertical"
max={0.2}
min={0}
onChange={(v) => handleUpdate({ contentPadding: [node.contentPadding[0], v] })}
precision={3}
step={0.005}
unit="m"
value={Math.round(node.contentPadding[1] * 1000) / 1000}
/>
</PanelSection>
)}
{isSwingDoor && (
<PanelSection title="Swing">
@@ -832,44 +936,46 @@ export function DoorPanel() {
</PanelSection>
)}
<PanelSection title="Handle">
{isSwingDoor && (
<ToggleControl
checked={node.handle}
label="Enable Handle"
onChange={(checked) => handleUpdate({ handle: checked })}
/>
)}
{(node.handle || !isSwingDoor) && (
<div className="mt-1 flex flex-col gap-1">
<SliderControl
label="Height"
max={node.height - 0.1}
min={0.5}
onChange={(v) => handleUpdate({ handleHeight: v })}
precision={2}
step={0.05}
unit="m"
value={Math.round(node.handleHeight * 100) / 100}
{!isGarageDoor && (
<PanelSection title="Handle">
{isSwingDoor && (
<ToggleControl
checked={node.handle}
label="Enable Handle"
onChange={(checked) => handleUpdate({ handle: checked })}
/>
{supportsHandleSide && (
<div className="space-y-1">
<span className="font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
Handle Side
</span>
<SegmentedControl
onChange={(v) => handleUpdate({ handleSide: v })}
options={[
{ label: 'Left', value: 'left' },
{ label: 'Right', value: 'right' },
]}
value={node.handleSide}
/>
</div>
)}
</div>
)}
</PanelSection>
)}
{(node.handle || !isSwingDoor) && (
<div className="mt-1 flex flex-col gap-1">
<SliderControl
label="Height"
max={node.height - 0.1}
min={0.5}
onChange={(v) => handleUpdate({ handleHeight: v })}
precision={2}
step={0.05}
unit="m"
value={Math.round(node.handleHeight * 100) / 100}
/>
{supportsHandleSide && (
<div className="space-y-1">
<span className="font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
Handle Side
</span>
<SegmentedControl
onChange={(v) => handleUpdate({ handleSide: v })}
options={[
{ label: 'Left', value: 'left' },
{ label: 'Right', value: 'right' },
]}
value={node.handleSide}
/>
</div>
)}
</div>
)}
</PanelSection>
)}
{isSwingDoor && (
<PanelSection title="Hardware">
@@ -900,155 +1006,159 @@ export function DoorPanel() {
</PanelSection>
)}
<PanelSection title="Segments">
{node.segments.map((seg, i) => {
const numCols = seg.columnRatios.length
const colSum = seg.columnRatios.reduce((a, b) => a + b, 0)
const normCols = seg.columnRatios.map((r) => r / colSum)
return (
<div className="mb-2 flex flex-col gap-1" key={i}>
<div className="flex items-center justify-between pb-1">
<span className="font-medium text-white/80 text-xs">Segment {i + 1}</span>
</div>
{!isGarageDoor && (
<PanelSection title="Segments">
{node.segments.map((seg, i) => {
const numCols = seg.columnRatios.length
const colSum = seg.columnRatios.reduce((a, b) => a + b, 0)
const normCols = seg.columnRatios.map((r) => r / colSum)
return (
<div className="mb-2 flex flex-col gap-1" key={i}>
<div className="flex items-center justify-between pb-1">
<span className="font-medium text-white/80 text-xs">Segment {i + 1}</span>
</div>
<SegmentedControl
onChange={(t) => {
const updated = node.segments.map((s, idx) => (idx === i ? { ...s, type: t } : s))
handleUpdate({ segments: updated })
}}
options={[
{ label: 'Panel', value: 'panel' },
{ label: 'Glass', value: 'glass' },
{ label: 'Empty', value: 'empty' },
]}
value={seg.type}
/>
<SegmentedControl
onChange={(t) => {
const updated = node.segments.map((s, idx) =>
idx === i ? { ...s, type: t } : s,
)
handleUpdate({ segments: updated })
}}
options={[
{ label: 'Panel', value: 'panel' },
{ label: 'Glass', value: 'glass' },
{ label: 'Empty', value: 'empty' },
]}
value={seg.type}
/>
<SliderControl
label="Height"
max={95}
min={5}
onChange={(v) => setSegmentHeightRatio(i, v / 100)}
precision={1}
step={1}
unit="%"
value={Math.round(normHeights[i]! * 100 * 10) / 10}
/>
<SliderControl
label="Height"
max={95}
min={5}
onChange={(v) => setSegmentHeightRatio(i, v / 100)}
precision={1}
step={1}
unit="%"
value={Math.round(normHeights[i]! * 100 * 10) / 10}
/>
<SliderControl
label="Columns"
max={8}
min={1}
onChange={(v) => {
const n = Math.max(1, Math.min(8, Math.round(v)))
const updated = node.segments.map((s, idx) =>
idx === i ? { ...s, columnRatios: Array(n).fill(1 / n) } : s,
)
handleUpdate({ segments: updated })
}}
precision={0}
step={1}
value={numCols}
/>
<SliderControl
label="Columns"
max={8}
min={1}
onChange={(v) => {
const n = Math.max(1, Math.min(8, Math.round(v)))
const updated = node.segments.map((s, idx) =>
idx === i ? { ...s, columnRatios: Array(n).fill(1 / n) } : s,
)
handleUpdate({ segments: updated })
}}
precision={0}
step={1}
value={numCols}
/>
{numCols > 1 && (
<div className="mt-1 border-border/50 border-t pt-1">
{normCols.map((ratio, ci) => (
{numCols > 1 && (
<div className="mt-1 border-border/50 border-t pt-1">
{normCols.map((ratio, ci) => (
<SliderControl
key={`c-${ci}`}
label={`C${ci + 1}`}
max={95}
min={5}
onChange={(v) => setSegmentColumnRatio(i, ci, v / 100)}
precision={1}
step={1}
unit="%"
value={Math.round(ratio * 100 * 10) / 10}
/>
))}
<SliderControl
key={`c-${ci}`}
label={`C${ci + 1}`}
max={95}
min={5}
onChange={(v) => setSegmentColumnRatio(i, ci, v / 100)}
precision={1}
step={1}
unit="%"
value={Math.round(ratio * 100 * 10) / 10}
label="Divider"
max={0.1}
min={0.005}
onChange={(v) => {
const updated = node.segments.map((s, idx) =>
idx === i ? { ...s, dividerThickness: v } : s,
)
handleUpdate({ segments: updated })
}}
precision={3}
step={0.005}
unit="m"
value={Math.round(seg.dividerThickness * 1000) / 1000}
/>
))}
<SliderControl
label="Divider"
max={0.1}
min={0.005}
onChange={(v) => {
const updated = node.segments.map((s, idx) =>
idx === i ? { ...s, dividerThickness: v } : s,
)
handleUpdate({ segments: updated })
}}
precision={3}
step={0.005}
unit="m"
value={Math.round(seg.dividerThickness * 1000) / 1000}
/>
</div>
)}
</div>
)}
{seg.type === 'panel' && (
<div className="mt-1 border-border/50 border-t pt-1">
<SliderControl
label="Inset"
max={0.1}
min={0}
onChange={(v) => {
const updated = node.segments.map((s, idx) =>
idx === i ? { ...s, panelInset: v } : s,
)
handleUpdate({ segments: updated })
}}
precision={3}
step={0.005}
unit="m"
value={Math.round(seg.panelInset * 1000) / 1000}
/>
<SliderControl
label="Depth"
max={0.1}
min={0}
onChange={(v) => {
const updated = node.segments.map((s, idx) =>
idx === i ? { ...s, panelDepth: v } : s,
)
handleUpdate({ segments: updated })
}}
precision={3}
step={0.005}
unit="m"
value={Math.round(seg.panelDepth * 1000) / 1000}
/>
</div>
)}
</div>
)
})}
{seg.type === 'panel' && (
<div className="mt-1 border-border/50 border-t pt-1">
<SliderControl
label="Inset"
max={0.1}
min={0}
onChange={(v) => {
const updated = node.segments.map((s, idx) =>
idx === i ? { ...s, panelInset: v } : s,
)
handleUpdate({ segments: updated })
}}
precision={3}
step={0.005}
unit="m"
value={Math.round(seg.panelInset * 1000) / 1000}
/>
<SliderControl
label="Depth"
max={0.1}
min={0}
onChange={(v) => {
const updated = node.segments.map((s, idx) =>
idx === i ? { ...s, panelDepth: v } : s,
)
handleUpdate({ segments: updated })
}}
precision={3}
step={0.005}
unit="m"
value={Math.round(seg.panelDepth * 1000) / 1000}
/>
</div>
)}
</div>
)
})}
<div className="flex gap-1.5 px-1 pt-1">
<ActionButton
label="+ Add Segment"
onClick={() => {
const updated = [
...node.segments,
{
type: 'panel' as const,
heightRatio: 1,
columnRatios: [1],
dividerThickness: 0.03,
panelDepth: 0.01,
panelInset: 0.04,
},
]
handleUpdate({ segments: updated })
}}
/>
{node.segments.length > 1 && (
<div className="flex gap-1.5 px-1 pt-1">
<ActionButton
className="text-white/60 hover:text-white"
label="- Remove"
onClick={() => handleUpdate({ segments: node.segments.slice(0, -1) })}
label="+ Add Segment"
onClick={() => {
const updated = [
...node.segments,
{
type: 'panel' as const,
heightRatio: 1,
columnRatios: [1],
dividerThickness: 0.03,
panelDepth: 0.01,
panelInset: 0.04,
},
]
handleUpdate({ segments: updated })
}}
/>
)}
</div>
</PanelSection>
{node.segments.length > 1 && (
<ActionButton
className="text-white/60 hover:text-white"
label="- Remove"
onClick={() => handleUpdate({ segments: node.segments.slice(0, -1) })}
/>
)}
</div>
</PanelSection>
)}
</>
)}
+52 -9
View File
@@ -6,6 +6,8 @@ import { sfxEmitter } from '../lib/sfx-bus'
import useEditor from '../store/use-editor'
const DOOR_SWING_OPEN_ANGLE = Math.PI / 2
const DOOR_TOGGLE_ANIMATION_MS = 520
const activeDoorAnimations = new Map<AnyNodeId, number>()
function updateDoorOpenState(
doorId: AnyNodeId,
@@ -18,6 +20,36 @@ function updateDoorOpenState(
if (node?.parentId) scene.dirtyNodes.add(node.parentId as AnyNodeId)
}
function animateDoorOpenState(
doorId: AnyNodeId,
field: 'operationState' | 'swingAngle',
from: number,
to: number,
) {
const existingFrame = activeDoorAnimations.get(doorId)
if (existingFrame !== undefined) {
window.cancelAnimationFrame(existingFrame)
}
const startedAt = performance.now()
const ease = (value: number) => value * value * (3 - 2 * value)
const tick = (now: number) => {
const progress = Math.min(1, (now - startedAt) / DOOR_TOGGLE_ANIMATION_MS)
const value = from + (to - from) * ease(progress)
updateDoorOpenState(doorId, { [field]: value })
if (progress < 1) {
activeDoorAnimations.set(doorId, window.requestAnimationFrame(tick))
} else {
activeDoorAnimations.delete(doorId)
updateDoorOpenState(doorId, { [field]: to })
}
}
activeDoorAnimations.set(doorId, window.requestAnimationFrame(tick))
}
// 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
@@ -169,18 +201,26 @@ export const useKeyboard = ({
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(node.id, {
operationState: currentOpenAmount >= 0.5 ? 0 : 1,
})
animateDoorOpenState(
node.id,
'operationState',
currentOpenAmount,
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,
})
animateDoorOpenState(
node.id,
'swingAngle',
currentSwingAngle,
currentSwingAngle >= DOOR_SWING_OPEN_ANGLE / 2 ? 0 : DOOR_SWING_OPEN_ANGLE,
)
}
sfxEmitter.emit('sfx:item-rotate')
}
@@ -212,7 +252,10 @@ export const useKeyboard = ({
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'
? { operationState: 0 }
: { swingAngle: 0 },
)