Add frameless door openings and first-person collider support

This commit is contained in:
sudhir
2026-05-01 11:35:26 +05:30
parent 915393d649
commit 09bfb0b484
8 changed files with 379 additions and 63 deletions
@@ -45,6 +45,12 @@ function isMesh(object: THREE.Object3D): object is THREE.Mesh {
return 'isMesh' in object && (object as THREE.Mesh).isMesh
}
function isColliderMaterialVisible(material: THREE.Material | THREE.Material[]) {
return Array.isArray(material)
? material.some((entry) => entry.visible)
: material.visible
}
function cloneWorldGeometry(mesh: THREE.Mesh) {
const sourceGeometry = mesh.geometry
const position = sourceGeometry.getAttribute('position')
@@ -79,6 +85,8 @@ function shouldSkipColliderNode(nodeId: string, type: (typeof COLLIDER_NODE_TYPE
const node = useScene.getState().nodes[nodeId]
if (!node || node.type !== 'door') return false
if (node.openingKind === 'opening') return true
if (!node.segments.length) return true
return node.segments.every((segment) => segment.type === 'empty')
@@ -109,7 +117,12 @@ function collectColliderGeometriesFromNode(
if (visitedMeshes.has(object)) return
visitedMeshes.add(object)
if (isMesh(object) && object.visible && !SKIPPED_MESH_NAMES.has(object.name)) {
if (
isMesh(object) &&
object.visible &&
isColliderMaterialVisible(object.material) &&
!SKIPPED_MESH_NAMES.has(object.name)
) {
const geometry = cloneWorldGeometry(object)
if (geometry) {
geometries.push(geometry)
@@ -4346,9 +4346,15 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
const doorCubeSize = Math.min(Math.max(width * 0.08, 0.06), 0.12)
const doorCubeInset = doorCubeSize * 0.5
const doorCubeStroke = palette.openingStroke
const hingeCubeCenter = { x: hx + nx * doorCubeInset, y: hy + ny * doorCubeInset }
const strikeCubeCenter = { x: ox2 - nx * doorCubeInset, y: oy2 - ny * doorCubeInset }
const hingeTangentSign = hingesSide === 'left' ? 1 : -1
const hingeCubeCenter = {
x: hx + nx * hingeTangentSign * doorCubeInset,
y: hy + ny * hingeTangentSign * doorCubeInset,
}
const strikeCubeCenter = {
x: ox2 - nx * hingeTangentSign * doorCubeInset,
y: oy2 - ny * hingeTangentSign * doorCubeInset,
}
const leafHalfThickness = doorCubeSize * 0.18
const leafSideOffset = hingeTangentSign * (doorCubeSize / 2 + leafHalfThickness)
const leafStart = {
@@ -4377,7 +4383,7 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
x: leafStart.x + closedLeafVector.x * openCos - closedLeafVector.y * openSin,
y: leafStart.y + closedLeafVector.x * openSin + closedLeafVector.y * openCos,
}
const doorBackgroundPoints = [
const doorBackgroundPointList = [
{
x: svgP1.x - px * depthDirectionSign * depthExtraOffset,
y: svgP1.y - py * depthDirectionSign * depthExtraOffset,
@@ -4395,8 +4401,76 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
y: svgP4.y + py * depthDirectionSign * depthExtraOffset,
},
]
const doorBackgroundPoints = doorBackgroundPointList
.map((point) => `${point.x},${point.y}`)
.join(' ')
const openingPlanPath =
opening.openingKind === 'opening' && opening.openingShape === 'rounded'
? (() => {
const [a, b, c, d] = doorBackgroundPointList
if (!(a && b && c && d)) return null
const tangentRadius = Math.min(width * 0.14, doorCubeSize * 1.6)
const depthRadius = Math.min(
Math.hypot(svgP4.x - svgP1.x, svgP4.y - svgP1.y) * 0.42,
doorCubeSize,
)
const radius = Math.min(tangentRadius, depthRadius)
const offset = (from: Point2D, to: Point2D, distance: number) => {
const dx = to.x - from.x
const dy = to.y - from.y
const length = Math.hypot(dx, dy)
if (length < 1e-6) return from
return {
x: from.x + (dx / length) * Math.min(distance, length / 2),
y: from.y + (dy / length) * Math.min(distance, length / 2),
}
}
const aToB = offset(a, b, radius)
const bToA = offset(b, a, radius)
const bToC = offset(b, c, radius)
const cToB = offset(c, b, radius)
const cToD = offset(c, d, radius)
const dToC = offset(d, c, radius)
const dToA = offset(d, a, radius)
const aToD = offset(a, d, radius)
return [
`M ${aToB.x} ${aToB.y}`,
`L ${bToA.x} ${bToA.y}`,
`Q ${b.x} ${b.y} ${bToC.x} ${bToC.y}`,
`L ${cToB.x} ${cToB.y}`,
`Q ${c.x} ${c.y} ${cToD.x} ${cToD.y}`,
`L ${dToC.x} ${dToC.y}`,
`Q ${d.x} ${d.y} ${dToA.x} ${dToA.y}`,
`L ${aToD.x} ${aToD.y}`,
`Q ${a.x} ${a.y} ${aToB.x} ${aToB.y}`,
'Z',
].join(' ')
})()
: null
const archPlanPath =
opening.openingKind === 'opening' && opening.openingShape === 'arch'
? (() => {
const centerStart = {
x: (svgP1.x + svgP4.x) / 2,
y: (svgP1.y + svgP4.y) / 2,
}
const centerEnd = {
x: (svgP2.x + svgP3.x) / 2,
y: (svgP2.y + svgP3.y) / 2,
}
const midpoint = {
x: (centerStart.x + centerEnd.x) / 2,
y: (centerStart.y + centerEnd.y) / 2,
}
const bow = Math.min(width * 0.18, doorCubeSize * 1.8)
return `M ${centerStart.x} ${centerStart.y} Q ${midpoint.x + px * bow} ${
midpoint.y + py * bow
} ${centerEnd.x} ${centerEnd.y}`
})()
: null
const leafPolygonPoints = [
{
x: leafStart.x - nx * leafHalfThickness,
@@ -4467,34 +4541,67 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
vectorEffect="non-scaling-stroke"
/>
)}
<polygon fill="#ffffff" points={doorBackgroundPoints} stroke="none" />
{[hingeCubeCenter, strikeCubeCenter].map((point, index) => (
<rect
fill="#ffffff"
height={doorCubeSize}
key={`${opening.id}:door-cube:${index}`}
stroke={doorCubeStroke}
strokeWidth="1.25"
vectorEffect="non-scaling-stroke"
width={doorCubeSize}
x={point.x - doorCubeSize / 2}
y={point.y - doorCubeSize / 2}
/>
))}
<polygon
fill="#ffffff"
points={leafPolygonPoints}
stroke={isDeleteHovered ? palette.deleteStroke : doorCubeStroke}
strokeWidth="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={isDeleteHovered ? palette.deleteStroke : doorCubeStroke}
strokeWidth={arcStrokeWidth}
vectorEffect="non-scaling-stroke"
/>
{opening.openingKind === 'opening' ? (
<>
{openingPlanPath ? (
<path
d={openingPlanPath}
fill="#ffffff"
stroke={isDeleteHovered ? palette.deleteStroke : doorCubeStroke}
strokeWidth="1.25"
vectorEffect="non-scaling-stroke"
/>
) : (
<polygon
fill="#ffffff"
points={doorBackgroundPoints}
stroke={isDeleteHovered ? palette.deleteStroke : doorCubeStroke}
strokeWidth="1.25"
vectorEffect="non-scaling-stroke"
/>
)}
{archPlanPath && (
<path
d={archPlanPath}
fill="none"
stroke={isDeleteHovered ? palette.deleteStroke : doorCubeStroke}
strokeWidth={arcStrokeWidth}
vectorEffect="non-scaling-stroke"
/>
)}
</>
) : (
<>
<polygon fill="#ffffff" points={doorBackgroundPoints} stroke="none" />
{[hingeCubeCenter, strikeCubeCenter].map((point, index) => (
<rect
fill="#ffffff"
height={doorCubeSize}
key={`${opening.id}:door-cube:${index}`}
stroke={doorCubeStroke}
strokeWidth="1.25"
vectorEffect="non-scaling-stroke"
width={doorCubeSize}
x={point.x - doorCubeSize / 2}
y={point.y - doorCubeSize / 2}
/>
))}
<polygon
fill="#ffffff"
points={leafPolygonPoints}
stroke={isDeleteHovered ? palette.deleteStroke : doorCubeStroke}
strokeWidth="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={isDeleteHovered ? palette.deleteStroke : doorCubeStroke}
strokeWidth={arcStrokeWidth}
vectorEffect="non-scaling-stroke"
/>
</>
)}
</g>
)
}
@@ -85,9 +85,10 @@ export function DoorPanel() {
}, [node, setMovingNode, setSelection])
const setSegmentHeightRatio = (segIdx: number, newVal: number) => {
const numSegs = node?.segments.length
const totalH = node?.segments.reduce((sum, s) => sum + s.heightRatio, 0)
const normH = node?.segments.map((s) => s.heightRatio / totalH)
if (!node) return
const numSegs = node.segments.length
const totalH = node.segments.reduce((sum, s) => sum + s.heightRatio, 0)
const normH = node.segments.map((s) => s.heightRatio / totalH)
const clamped = Math.max(0.05, Math.min(0.95, newVal))
const neighborIdx = segIdx < numSegs - 1 ? segIdx + 1 : segIdx - 1
const delta = clamped - normH[segIdx]!
@@ -97,7 +98,7 @@ export function DoorPanel() {
if (i === neighborIdx) return neighborVal
return v
})
const updated = node?.segments.map((s, idx) => ({ ...s, heightRatio: newRatios[idx]! }))
const updated = node.segments.map((s, idx) => ({ ...s, heightRatio: newRatios[idx]! }))
handleUpdate({ segments: updated })
}
@@ -131,6 +132,11 @@ export function DoorPanel() {
height: node.height,
frameThickness: node.frameThickness,
frameDepth: node.frameDepth,
openingKind: node.openingKind,
openingShape: node.openingShape,
cornerRadius: node.cornerRadius,
archHeight: node.archHeight,
openingRevealRadius: node.openingRevealRadius,
contentPadding: node.contentPadding,
hingesSide: node.hingesSide,
swingDirection: node.swingDirection,
@@ -177,6 +183,11 @@ export function DoorPanel() {
const hSum = node.segments.reduce((s, seg) => s + seg.heightRatio, 0)
const normHeights = node.segments.map((seg) => seg.heightRatio / hSum)
const isOpening = node.openingKind === 'opening'
const openingShape = node.openingShape ?? 'rectangle'
const cornerRadius = node.cornerRadius ?? 0.15
const archHeight = node.archHeight ?? 0.45
const openingRevealRadius = node.openingRevealRadius ?? 0.025
return (
<PanelWrapper
@@ -206,6 +217,31 @@ export function DoorPanel() {
</PresetsPopover>
</div>
<PanelSection title="Type">
<div className="flex flex-col gap-2 px-1 pb-1">
<SegmentedControl
onChange={(v) =>
handleUpdate(
v === 'opening'
? {
openingKind: v,
openingShape,
cornerRadius,
archHeight,
openingRevealRadius,
}
: { openingKind: v },
)
}
options={[
{ label: 'Door', value: 'door' },
{ label: 'Opening', value: 'opening' },
]}
value={node.openingKind}
/>
</div>
</PanelSection>
<PanelSection title="Position">
<SliderControl
label={
@@ -221,14 +257,16 @@ export function DoorPanel() {
unit="m"
value={Math.round(node.position[0] * 100) / 100}
/>
<div className="px-1 pt-2 pb-1">
<ActionButton
className="w-full"
icon={<FlipHorizontal2 className="h-4 w-4" />}
label="Flip Side"
onClick={handleFlip}
/>
</div>
{!isOpening && (
<div className="px-1 pt-2 pb-1">
<ActionButton
className="w-full"
icon={<FlipHorizontal2 className="h-4 w-4" />}
label="Flip Side"
onClick={handleFlip}
/>
</div>
)}
</PanelSection>
<PanelSection title="Dimensions">
@@ -256,6 +294,66 @@ export function DoorPanel() {
/>
</PanelSection>
{isOpening && (
<PanelSection title="Opening Shape">
<div className="flex flex-col gap-2 px-1 pb-1">
<SegmentedControl
onChange={(v) =>
handleUpdate({
openingShape: v,
...(v === 'rounded' ? { cornerRadius, openingRevealRadius } : {}),
...(v === 'arch' ? { archHeight } : {}),
})
}
options={[
{ label: 'Rect', value: 'rectangle' },
{ label: 'Rounded', value: 'rounded' },
{ label: 'Arch', value: 'arch' },
]}
value={openingShape}
/>
</div>
{openingShape === 'rounded' && (
<>
<SliderControl
label="Corner Radius"
max={Math.min(node.width / 2, node.height)}
min={0}
onChange={(v) => handleUpdate({ cornerRadius: v })}
precision={2}
step={0.05}
unit="m"
value={Math.round(cornerRadius * 100) / 100}
/>
<SliderControl
label="Reveal Radius"
max={0.08}
min={0}
onChange={(v) => handleUpdate({ openingRevealRadius: v })}
precision={3}
step={0.005}
unit="m"
value={Math.round(openingRevealRadius * 1000) / 1000}
/>
</>
)}
{openingShape === 'arch' && (
<SliderControl
label="Arch Height"
max={node.height}
min={0.05}
onChange={(v) => handleUpdate({ archHeight: v })}
precision={2}
step={0.05}
unit="m"
value={Math.round(archHeight * 100) / 100}
/>
)}
</PanelSection>
)}
{!isOpening && (
<>
<PanelSection title="Frame">
<SliderControl
label="Thickness"
@@ -567,6 +665,9 @@ export function DoorPanel() {
</div>
</PanelSection>
</>
)}
<PanelSection title="Actions">
<ActionGroup>
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
+12 -8
View File
@@ -153,12 +153,14 @@ export const useKeyboard = ({
const node = useScene.getState().nodes[selectedNodeIds[0]!]
if (node?.type === 'door') {
e.preventDefault()
const currentSwingAngle = node.swingAngle ?? 0
useScene.getState().updateNode(node.id, {
swingAngle:
currentSwingAngle >= DOOR_SWING_OPEN_ANGLE / 2 ? 0 : DOOR_SWING_OPEN_ANGLE,
})
sfxEmitter.emit('sfx:item-rotate')
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,
})
sfxEmitter.emit('sfx:item-rotate')
}
} else if (node && 'rotation' in node) {
e.preventDefault()
const ROTATION_STEP = Math.PI / 4
@@ -181,8 +183,10 @@ export const useKeyboard = ({
const node = useScene.getState().nodes[selectedNodeIds[0]!]
if (node?.type === 'door') {
e.preventDefault()
useScene.getState().updateNode(node.id, { swingAngle: 0 })
sfxEmitter.emit('sfx:item-rotate')
if (node.openingKind !== 'opening') {
useScene.getState().updateNode(node.id, { swingAngle: 0 })
sfxEmitter.emit('sfx:item-rotate')
}
} else if (node && 'rotation' in node) {
e.preventDefault()
const ROTATION_STEP = Math.PI / 4