Refine editor layer boundaries and selection handling

This commit is contained in:
sudhir
2026-05-05 13:26:39 +05:30
parent fe34126d00
commit 1490d280d4
8 changed files with 1877 additions and 335 deletions
+29
View File
@@ -18,6 +18,25 @@ export const DoorSegment = z.object({
export type DoorSegment = z.infer<typeof DoorSegment>
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<typeof DoorCategory>
export type DoorType = z.infer<typeof DoorType>
export type DoorTrackStyle = z.infer<typeof DoorTrackStyle>
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
@@ -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()
@@ -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<Point2D[]>(
(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 (
<g
@@ -4779,72 +5048,307 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
points={doorBackgroundPoints}
stroke="none"
/>
{swingSweepPath && (
<path
d={swingSweepPath}
fill={doorSwingFill}
stroke="none"
vectorEffect="non-scaling-stroke"
/>
{isFoldingDoor ? (
<>
<path
d={`M ${svgP1.x} ${svgP1.y} L ${svgP2.x} ${svgP2.y}`}
fill="none"
stroke={doorSoftStroke}
strokeLinecap="round"
strokeWidth="1"
vectorEffect="non-scaling-stroke"
/>
{foldingPath && (
<path
d={foldingPath}
fill="none"
stroke={doorStroke}
strokeLinejoin="round"
strokeLinecap="round"
strokeWidth={isSelected || isSelectionHighlighted ? '1.8' : '1.25'}
vectorEffect="non-scaling-stroke"
/>
)}
{foldingPoints.map((point, index) => (
<circle
cx={point.x}
cy={point.y}
fill={
index === 0 || index === foldingPoints.length - 1
? doorStroke
: doorLeafFill
}
key={`${opening.id}:folding-node:${index}`}
r={
index === 0 || index === foldingPoints.length - 1
? hingeMarkerRadius
: hingeMarkerRadius * 0.72
}
stroke={doorStroke}
strokeWidth="0.8"
vectorEffect="non-scaling-stroke"
/>
))}
</>
) : isPocketDoor ? (
<>
<line
stroke={doorSoftStroke}
strokeDasharray="0.09 0.06"
strokeLinecap="round"
strokeWidth="1"
vectorEffect="non-scaling-stroke"
x1={pocketTrackStart.x}
x2={pocketTrackEnd.x}
y1={pocketTrackStart.y}
y2={pocketTrackEnd.y}
/>
<line
stroke={doorStroke}
strokeLinecap="round"
strokeWidth="1.1"
vectorEffect="non-scaling-stroke"
x1={svgP1.x}
x2={svgP2.x}
y1={svgP1.y}
y2={svgP2.y}
/>
<polygon
fill={doorLeafFill}
points={pocketLeafPoints}
stroke={doorStroke}
strokeLinejoin="round"
strokeWidth={isSelected || isSelectionHighlighted ? '1.7' : '1.25'}
vectorEffect="non-scaling-stroke"
/>
<circle
cx={pocketLeafEnd.x}
cy={pocketLeafEnd.y}
fill={doorStroke}
r={hingeMarkerRadius * 0.72}
vectorEffect="non-scaling-stroke"
/>
</>
) : isBarnDoor ? (
<>
<line
stroke={doorSoftStroke}
strokeLinecap="round"
strokeWidth="1.2"
vectorEffect="non-scaling-stroke"
x1={pocketTrackStart.x}
x2={pocketTrackEnd.x}
y1={pocketTrackStart.y + py * swingSign * doorCubeSize * 1.15}
y2={pocketTrackEnd.y + py * swingSign * doorCubeSize * 1.15}
/>
<line
stroke={doorSoftStroke}
strokeLinecap="round"
strokeWidth="0.9"
vectorEffect="non-scaling-stroke"
x1={svgP1.x}
x2={svgP2.x}
y1={svgP1.y}
y2={svgP2.y}
/>
<polygon
fill={doorLeafFill}
points={barnLeafPoints}
stroke={doorStroke}
strokeLinejoin="round"
strokeWidth={isSelected || isSelectionHighlighted ? '1.7' : '1.25'}
vectorEffect="non-scaling-stroke"
/>
{[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 (
<circle
cx={wheel.x}
cy={wheel.y}
fill={doorStroke}
key={`${opening.id}:barn-wheel:${ratio}`}
r={hingeMarkerRadius * 0.62}
vectorEffect="non-scaling-stroke"
/>
)
})}
</>
) : isSlidingDoor ? (
<>
<line
stroke={doorSoftStroke}
strokeLinecap="round"
strokeWidth="1"
vectorEffect="non-scaling-stroke"
x1={svgP1.x}
x2={svgP2.x}
y1={svgP1.y + py * swingSign * doorCubeSize * 0.34}
y2={svgP2.y + py * swingSign * doorCubeSize * 0.34}
/>
<line
stroke={doorSoftStroke}
strokeLinecap="round"
strokeWidth="1"
vectorEffect="non-scaling-stroke"
x1={svgP1.x}
x2={svgP2.x}
y1={svgP1.y + py * swingSign * doorCubeSize * 0.68}
y2={svgP2.y + py * swingSign * doorCubeSize * 0.68}
/>
<polygon
fill="rgba(224, 242, 254, 0.7)"
points={slidingFixedPoints}
stroke={doorSoftStroke}
strokeLinejoin="round"
strokeWidth="1"
vectorEffect="non-scaling-stroke"
/>
<polygon
fill={doorLeafFill}
points={slidingActivePoints}
stroke={doorStroke}
strokeLinejoin="round"
strokeWidth={isSelected || isSelectionHighlighted ? '1.7' : '1.25'}
vectorEffect="non-scaling-stroke"
/>
</>
) : isDoubleSwingDoor ? (
<>
{doubleLeafPlans.map((leaf) =>
leaf.swingRadius > 1e-6 ? (
<path
d={`M ${leaf.arcEnd.x} ${leaf.arcEnd.y} A ${leaf.swingRadius} ${leaf.swingRadius} 0 0 ${leaf.sweepFlag} ${leaf.leafEnd.x} ${leaf.leafEnd.y}`}
fill="none"
key={`${opening.id}:double-sweep:${leaf.key}`}
stroke={doorSoftStroke}
strokeLinecap="round"
strokeWidth="0.9"
vectorEffect="non-scaling-stroke"
/>
) : null,
)}
{swingAngle > 0.03 &&
doubleLeafPlans.map((leaf) => (
<polygon
fill="none"
key={`${opening.id}:double-hint:${leaf.key}`}
points={leaf.closedLeafHintPoints}
stroke={doorSoftStroke}
strokeDasharray="0.08 0.06"
strokeLinecap="round"
strokeWidth="0.8"
vectorEffect="non-scaling-stroke"
/>
))}
{doubleLeafPlans.map((leaf) => (
<rect
fill={doorLeafFill}
height={doorCubeSize}
key={`${opening.id}:double-hinge:${leaf.key}`}
rx={doorCubeSize * 0.12}
stroke={doorStroke}
strokeWidth="1.35"
vectorEffect="non-scaling-stroke"
width={doorCubeSize}
x={leaf.hingeCubeCenter.x - doorCubeSize / 2}
y={leaf.hingeCubeCenter.y - doorCubeSize / 2}
/>
))}
{doubleLeafPlans.map((leaf) => (
<circle
cx={leaf.hingeMarkerX}
cy={leaf.hingeMarkerY}
fill={doorStroke}
key={`${opening.id}:double-hinge-marker:${leaf.key}`}
r={hingeMarkerRadius}
vectorEffect="non-scaling-stroke"
/>
))}
{doubleLeafPlans.map((leaf) => (
<polygon
fill={doorLeafFill}
key={`${opening.id}:double-leaf:${leaf.key}`}
points={leaf.leafPolygonPoints}
stroke={doorStroke}
strokeLinejoin="round"
strokeWidth={isSelected || isSelectionHighlighted ? '1.7' : '1.25'}
vectorEffect="non-scaling-stroke"
/>
))}
</>
) : (
<>
{swingSweepPath && (
<path
d={swingSweepPath}
fill={doorSwingFill}
stroke="none"
vectorEffect="non-scaling-stroke"
/>
)}
{swingAngle > 0.03 && (
<polygon
fill="none"
points={closedLeafHintPoints}
stroke={doorSoftStroke}
strokeDasharray="0.08 0.06"
strokeLinecap="round"
strokeWidth="0.8"
vectorEffect="non-scaling-stroke"
/>
)}
{[hingeCubeCenter, strikeCubeCenter].map((point, index) => (
<rect
fill={index === 0 ? doorLeafFill : '#ffffff'}
height={doorCubeSize}
key={`${opening.id}:door-cube:${index}`}
rx={doorCubeSize * 0.12}
stroke={index === 0 ? doorStroke : doorSoftStroke}
strokeWidth={index === 0 ? '1.35' : '1'}
vectorEffect="non-scaling-stroke"
width={doorCubeSize}
x={point.x - doorCubeSize / 2}
y={point.y - doorCubeSize / 2}
/>
))}
<circle
cx={hingeCubeCenter.x}
cy={hingeCubeCenter.y}
fill={doorStroke}
r={hingeMarkerRadius}
vectorEffect="non-scaling-stroke"
/>
<line
stroke={doorSoftStroke}
strokeLinecap="round"
strokeWidth="1.1"
vectorEffect="non-scaling-stroke"
x1={strikeTickStart.x}
x2={strikeTickEnd.x}
y1={strikeTickStart.y}
y2={strikeTickEnd.y}
/>
<polygon
fill={doorLeafFill}
points={leafPolygonPoints}
stroke={doorStroke}
strokeLinejoin="round"
strokeWidth={isSelected || isSelectionHighlighted ? '1.7' : '1.25'}
vectorEffect="non-scaling-stroke"
/>
<path
d={`M ${leafEnd.x} ${leafEnd.y} A ${swingRadius} ${swingRadius} 0 0 ${sweepFlag} ${arcEnd.x} ${arcEnd.y}`}
fill="none"
stroke={doorStroke}
strokeLinecap="round"
strokeWidth={arcStrokeWidth}
vectorEffect="non-scaling-stroke"
/>
</>
)}
{swingAngle > 0.03 && (
<polygon
fill="none"
points={closedLeafHintPoints}
stroke={doorSoftStroke}
strokeDasharray="0.08 0.06"
strokeLinecap="round"
strokeWidth="0.8"
vectorEffect="non-scaling-stroke"
/>
)}
{[hingeCubeCenter, strikeCubeCenter].map((point, index) => (
<rect
fill={index === 0 ? doorLeafFill : '#ffffff'}
height={doorCubeSize}
key={`${opening.id}:door-cube:${index}`}
rx={doorCubeSize * 0.12}
stroke={index === 0 ? doorStroke : doorSoftStroke}
strokeWidth={index === 0 ? '1.35' : '1'}
vectorEffect="non-scaling-stroke"
width={doorCubeSize}
x={point.x - doorCubeSize / 2}
y={point.y - doorCubeSize / 2}
/>
))}
<circle
cx={hingeCubeCenter.x}
cy={hingeCubeCenter.y}
fill={doorStroke}
r={hingeMarkerRadius}
vectorEffect="non-scaling-stroke"
/>
<line
stroke={doorSoftStroke}
strokeLinecap="round"
strokeWidth="1.1"
vectorEffect="non-scaling-stroke"
x1={strikeTickStart.x}
x2={strikeTickEnd.x}
y1={strikeTickStart.y}
y2={strikeTickEnd.y}
/>
<polygon
fill={doorLeafFill}
points={leafPolygonPoints}
stroke={doorStroke}
strokeLinejoin="round"
strokeWidth={isSelected || isSelectionHighlighted ? '1.7' : '1.25'}
vectorEffect="non-scaling-stroke"
/>
<path
d={`M ${leafEnd.x} ${leafEnd.y} A ${swingRadius} ${swingRadius} 0 0 ${sweepFlag} ${arcEnd.x} ${arcEnd.y}`}
fill="none"
stroke={doorStroke}
strokeLinecap="round"
strokeWidth={arcStrokeWidth}
vectorEffect="non-scaling-stroke"
/>
</>
)}
{isSelected ? (
@@ -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,
@@ -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<DoorNode['doorType'], { width: number; height: number }> = {
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<DoorNode> => {
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 (
<PanelWrapper
icon="/icons/door.png"
@@ -324,6 +488,31 @@ export function DoorPanel() {
value={node.openingKind}
/>
</div>
{!isOpening && (
<div className="grid grid-cols-2 gap-1.5 px-1 pt-1">
{doorTypeOptions.map((option) => {
const isSelected = doorType === option.value
return (
<button
className={cn(
'flex min-h-12 items-center gap-2 rounded-lg border px-2.5 text-left text-xs transition-colors',
isSelected
? 'border-orange-400/60 bg-orange-400/10 text-foreground'
: 'border-border/50 bg-[#2C2C2E] text-muted-foreground hover:bg-[#3e3e3e] hover:text-foreground',
!option.available && 'cursor-not-allowed opacity-45 hover:bg-[#2C2C2E] hover:text-muted-foreground',
)}
disabled={!option.available}
key={option.value}
onClick={() => handleUpdate(getDoorTypeUpdates(option.value))}
type="button"
>
<DoorOpen className="h-3.5 w-3.5 shrink-0" />
<span className="truncate font-medium">{option.label}</span>
</button>
)
})}
</div>
)}
</PanelSection>
<PanelSection title="Position">
@@ -353,6 +542,68 @@ export function DoorPanel() {
)}
</PanelSection>
{doorType === 'folding' && !isOpening && (
<PanelSection title="Fold">
<div className="flex flex-col gap-2 px-1 pb-1">
<div className="space-y-1">
<span className="font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
Panels
</span>
<SegmentedControl
onChange={(v) => handleUpdate({ leafCount: v === '2' ? 2 : 4 })}
options={[
{ label: '2', value: '2' },
{ label: '4', value: '4' },
]}
value={node.leafCount === 2 ? '2' : '4'}
/>
</div>
</div>
<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)}
/>
</PanelSection>
)}
{isSlidingDoor && !isOpening && (
<PanelSection title="Slide">
<div className="flex flex-col gap-2 px-1 pb-1">
<div className="space-y-1">
<span className="font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
{doorType === 'pocket' ? 'Pocket' : doorType === 'barn' ? 'Rail' : 'Panel'}
</span>
<SegmentedControl
onChange={(v) => handleUpdate({ slideDirection: v })}
options={[
{ label: 'Left', value: 'left' },
{ label: 'Right', value: 'right' },
]}
value={node.slideDirection ?? 'left'}
/>
</div>
</div>
<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)}
/>
</PanelSection>
)}
<PanelSection title="Dimensions">
<SliderControl
label="Width"
@@ -524,66 +775,72 @@ export function DoorPanel() {
/>
</PanelSection>
<PanelSection title="Swing">
<div className="flex flex-col gap-2 px-1 pb-1">
<div className="space-y-1">
<span className="font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
Hinges Side
</span>
<SegmentedControl
onChange={(v) => handleUpdate({ hingesSide: v })}
options={[
{ label: 'Left', value: 'left' },
{ label: 'Right', value: 'right' },
]}
value={node.hingesSide}
/>
{isSwingDoor && (
<PanelSection title="Swing">
<div className="flex flex-col gap-2 px-1 pb-1">
<div className="space-y-1">
<span className="font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
Hinges Side
</span>
<SegmentedControl
onChange={(v) => handleUpdate({ hingesSide: v })}
options={[
{ label: 'Left', value: 'left' },
{ label: 'Right', value: 'right' },
]}
value={node.hingesSide}
/>
</div>
<div className="space-y-1">
<span className="font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
Direction
</span>
<SegmentedControl
onChange={(v) => handleUpdate({ swingDirection: v })}
options={[
{ label: 'Inward', value: 'inward' },
{ label: 'Outward', value: 'outward' },
]}
value={node.swingDirection}
/>
</div>
</div>
<div className="space-y-1">
<span className="font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
Direction
</span>
<SegmentedControl
onChange={(v) => handleUpdate({ swingDirection: v })}
options={[
{ label: 'Inward', value: 'inward' },
{ label: 'Outward', value: 'outward' },
]}
value={node.swingDirection}
/>
</div>
</div>
</PanelSection>
</PanelSection>
)}
<PanelSection title="Threshold">
<ToggleControl
checked={node.threshold}
label="Enable Threshold"
onChange={(checked) => handleUpdate({ threshold: checked })}
/>
{node.threshold && (
<div className="mt-1 flex flex-col gap-1">
<SliderControl
label="Height"
max={0.1}
min={0.005}
onChange={(v) => handleUpdate({ thresholdHeight: v })}
precision={3}
step={0.005}
unit="m"
value={Math.round(node.thresholdHeight * 1000) / 1000}
/>
</div>
)}
</PanelSection>
{isSwingDoor && (
<PanelSection title="Threshold">
<ToggleControl
checked={node.threshold}
label="Enable Threshold"
onChange={(checked) => handleUpdate({ threshold: checked })}
/>
{node.threshold && (
<div className="mt-1 flex flex-col gap-1">
<SliderControl
label="Height"
max={0.1}
min={0.005}
onChange={(v) => handleUpdate({ thresholdHeight: v })}
precision={3}
step={0.005}
unit="m"
value={Math.round(node.thresholdHeight * 1000) / 1000}
/>
</div>
)}
</PanelSection>
)}
<PanelSection title="Handle">
<ToggleControl
checked={node.handle}
label="Enable Handle"
onChange={(checked) => handleUpdate({ handle: checked })}
/>
{node.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"
@@ -595,49 +852,53 @@ export function DoorPanel() {
unit="m"
value={Math.round(node.handleHeight * 100) / 100}
/>
<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>
{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>
<PanelSection title="Hardware">
<ToggleControl
checked={node.doorCloser}
label="Door Closer"
onChange={(checked) => handleUpdate({ doorCloser: checked })}
/>
<ToggleControl
checked={node.panicBar}
label="Panic Bar"
onChange={(checked) => handleUpdate({ panicBar: checked })}
/>
{node.panicBar && (
<div className="mt-1 flex flex-col gap-1">
<SliderControl
label="Bar Height"
max={node.height - 0.1}
min={0.5}
onChange={(v) => handleUpdate({ panicBarHeight: v })}
precision={2}
step={0.05}
unit="m"
value={Math.round(node.panicBarHeight * 100) / 100}
/>
</div>
)}
</PanelSection>
{isSwingDoor && (
<PanelSection title="Hardware">
<ToggleControl
checked={node.doorCloser}
label="Door Closer"
onChange={(checked) => handleUpdate({ doorCloser: checked })}
/>
<ToggleControl
checked={node.panicBar}
label="Panic Bar"
onChange={(checked) => handleUpdate({ panicBar: checked })}
/>
{node.panicBar && (
<div className="mt-1 flex flex-col gap-1">
<SliderControl
label="Bar Height"
max={node.height - 0.1}
min={0.5}
onChange={(v) => handleUpdate({ panicBarHeight: v })}
precision={2}
step={0.05}
unit="m"
value={Math.round(node.panicBarHeight * 100) / 100}
/>
</div>
)}
</PanelSection>
)}
<PanelSection title="Segments">
{node.segments.map((seg, i) => {
@@ -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',
+37 -6
View File
@@ -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) {
File diff suppressed because it is too large Load Diff