Add frameless door openings and first-person collider support
This commit is contained in:
@@ -32,6 +32,13 @@ export const DoorNode = BaseNode.extend({
|
|||||||
width: z.number().default(0.9),
|
width: z.number().default(0.9),
|
||||||
height: z.number().default(2.1),
|
height: z.number().default(2.1),
|
||||||
|
|
||||||
|
// Opening mode
|
||||||
|
openingKind: z.enum(['door', 'opening']).default('door'),
|
||||||
|
openingShape: z.enum(['rectangle', 'rounded', 'arch']).default('rectangle'),
|
||||||
|
cornerRadius: z.number().min(0).default(0.15),
|
||||||
|
archHeight: z.number().min(0).default(0.45),
|
||||||
|
openingRevealRadius: z.number().min(0).default(0.025),
|
||||||
|
|
||||||
// Frame
|
// Frame
|
||||||
frameThickness: z.number().default(0.05),
|
frameThickness: z.number().default(0.05),
|
||||||
frameDepth: z.number().default(0.07),
|
frameDepth: z.number().default(0.07),
|
||||||
@@ -81,6 +88,7 @@ export const DoorNode = BaseNode.extend({
|
|||||||
panicBarHeight: z.number().default(1.0),
|
panicBarHeight: z.number().default(1.0),
|
||||||
}).describe(dedent`Door node - a parametric door placed on a wall
|
}).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)
|
- position: center of the door in wall-local coordinate system (Y = height/2, always at floor)
|
||||||
|
- openingKind/openingShape: hinged door or frameless wall opening shape
|
||||||
- segments: rows stacked top to bottom, each defining its own columnRatios
|
- 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
|
- type 'empty' = no leaf fill for that segment, 'panel' = raised/recessed panel, 'glass' = glazed
|
||||||
- hingesSide/swingDirection/swingAngle: which way the door opens and how far it is currently open
|
- hingesSide/swingDirection/swingAngle: which way the door opens and how far it is currently open
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) {
|
|||||||
const {
|
const {
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
|
openingKind,
|
||||||
frameThickness,
|
frameThickness,
|
||||||
frameDepth,
|
frameDepth,
|
||||||
threshold,
|
threshold,
|
||||||
@@ -97,6 +98,11 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) {
|
|||||||
const hasLeafContent = segments.some((seg) => seg.type !== 'empty')
|
const hasLeafContent = segments.some((seg) => seg.type !== 'empty')
|
||||||
const clampedSwingAngle = Math.max(0, Math.min(Math.PI / 2, swingAngle))
|
const clampedSwingAngle = Math.max(0, Math.min(Math.PI / 2, swingAngle))
|
||||||
|
|
||||||
|
if (openingKind === 'opening') {
|
||||||
|
syncDoorCutout(node, mesh)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Leaf occupies the full opening (no bottom frame bar — door opens to floor)
|
// Leaf occupies the full opening (no bottom frame bar — door opens to floor)
|
||||||
const leafW = width - 2 * frameThickness
|
const leafW = width - 2 * frameThickness
|
||||||
const leafH = height - frameThickness // only top frame
|
const leafH = height - frameThickness // only top frame
|
||||||
@@ -306,6 +312,10 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) {
|
|||||||
addBox(mesh, baseMaterial, hingeW, hingeH, hingeD, hingeX, leafTop - 0.25, hingeZ)
|
addBox(mesh, baseMaterial, hingeW, hingeH, hingeD, hingeX, leafTop - 0.25, hingeZ)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
syncDoorCutout(node, mesh)
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncDoorCutout(node: DoorNode, mesh: THREE.Mesh) {
|
||||||
// ── Cutout (for wall CSG) — always full door dimensions, 1m deep ──
|
// ── Cutout (for wall CSG) — always full door dimensions, 1m deep ──
|
||||||
let cutout = mesh.getObjectByName('cutout') as THREE.Mesh | undefined
|
let cutout = mesh.getObjectByName('cutout') as THREE.Mesh | undefined
|
||||||
if (!cutout) {
|
if (!cutout) {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { computeBoundsTree } from 'three-mesh-bvh'
|
|||||||
import { sceneRegistry } from '../../hooks/scene-registry/scene-registry'
|
import { sceneRegistry } from '../../hooks/scene-registry/scene-registry'
|
||||||
import { spatialGridManager } from '../../hooks/spatial-grid/spatial-grid-manager'
|
import { spatialGridManager } from '../../hooks/spatial-grid/spatial-grid-manager'
|
||||||
import { resolveLevelId } from '../../hooks/spatial-grid/spatial-grid-sync'
|
import { resolveLevelId } from '../../hooks/spatial-grid/spatial-grid-sync'
|
||||||
import type { AnyNode, AnyNodeId, WallNode } from '../../schema'
|
import type { AnyNode, AnyNodeId, DoorNode, WallNode } from '../../schema'
|
||||||
import useScene from '../../store/use-scene'
|
import useScene from '../../store/use-scene'
|
||||||
import { getWallCurveFrameAt, getWallSurfacePolygon, isCurvedWall } from './wall-curve'
|
import { getWallCurveFrameAt, getWallSurfacePolygon, isCurvedWall } from './wall-curve'
|
||||||
import { DEFAULT_WALL_HEIGHT, getWallPlanFootprint, getWallThickness } from './wall-footprint'
|
import { DEFAULT_WALL_HEIGHT, getWallPlanFootprint, getWallThickness } from './wall-footprint'
|
||||||
@@ -546,6 +546,11 @@ function collectCutoutBrushes(
|
|||||||
for (const child of childrenNodes) {
|
for (const child of childrenNodes) {
|
||||||
if (child.type !== 'item' && child.type !== 'window' && child.type !== 'door') continue
|
if (child.type !== 'item' && child.type !== 'window' && child.type !== 'door') continue
|
||||||
|
|
||||||
|
if (child.type === 'door' && child.openingKind === 'opening') {
|
||||||
|
brushes.push(createDoorOpeningCutoutBrush(child, wallThickness))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
const childMesh = sceneRegistry.nodes.get(child.id)
|
const childMesh = sceneRegistry.nodes.get(child.id)
|
||||||
if (!childMesh) continue
|
if (!childMesh) continue
|
||||||
|
|
||||||
@@ -600,3 +605,76 @@ function collectCutoutBrushes(
|
|||||||
|
|
||||||
return brushes
|
return brushes
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createDoorOpeningCutoutBrush(door: DoorNode, wallThickness: number): Brush {
|
||||||
|
const shape = createDoorOpeningCutoutShape(door)
|
||||||
|
const depth = wallThickness * 2
|
||||||
|
const bevelSize =
|
||||||
|
door.openingShape === 'rounded'
|
||||||
|
? Math.min(
|
||||||
|
Math.max(door.openingRevealRadius ?? 0.025, 0),
|
||||||
|
Math.max(wallThickness * 0.45, 0.001),
|
||||||
|
Math.max((door.cornerRadius ?? 0.15) * 0.45, 0.001),
|
||||||
|
)
|
||||||
|
: 0
|
||||||
|
const geometry = new THREE.ExtrudeGeometry(shape, {
|
||||||
|
depth,
|
||||||
|
bevelEnabled: bevelSize > 0,
|
||||||
|
bevelSegments: bevelSize > 0 ? 8 : 0,
|
||||||
|
bevelSize,
|
||||||
|
bevelThickness: bevelSize,
|
||||||
|
curveSegments: 24,
|
||||||
|
})
|
||||||
|
|
||||||
|
geometry.translate(0, 0, -depth / 2)
|
||||||
|
geometry.computeBoundsTree = computeBoundsTree
|
||||||
|
geometry.computeBoundsTree({ maxLeafSize: 10 })
|
||||||
|
|
||||||
|
return new Brush(geometry)
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDoorOpeningCutoutShape(door: DoorNode): THREE.Shape {
|
||||||
|
const halfWidth = door.width / 2
|
||||||
|
const bottom = door.position[1] - door.height / 2
|
||||||
|
const top = door.position[1] + door.height / 2
|
||||||
|
const centerX = door.position[0]
|
||||||
|
const left = centerX - halfWidth
|
||||||
|
const right = centerX + halfWidth
|
||||||
|
const width = Math.max(door.width, 1e-6)
|
||||||
|
const height = Math.max(door.height, 1e-6)
|
||||||
|
const shape = new THREE.Shape()
|
||||||
|
|
||||||
|
if (door.openingShape === 'arch') {
|
||||||
|
const archHeight = Math.min(Math.max(door.archHeight ?? width / 2, 0.01), height)
|
||||||
|
const springY = top - archHeight
|
||||||
|
|
||||||
|
shape.moveTo(left, bottom)
|
||||||
|
shape.lineTo(right, bottom)
|
||||||
|
shape.lineTo(right, springY)
|
||||||
|
shape.quadraticCurveTo(centerX, top, left, springY)
|
||||||
|
shape.lineTo(left, bottom)
|
||||||
|
shape.closePath()
|
||||||
|
return shape
|
||||||
|
}
|
||||||
|
|
||||||
|
if (door.openingShape === 'rounded') {
|
||||||
|
const radius = Math.min(Math.max(door.cornerRadius ?? 0.15, 0), width / 2, height)
|
||||||
|
|
||||||
|
shape.moveTo(left, bottom)
|
||||||
|
shape.lineTo(right, bottom)
|
||||||
|
shape.lineTo(right, top - radius)
|
||||||
|
shape.absarc(right - radius, top - radius, radius, 0, Math.PI / 2, false)
|
||||||
|
shape.lineTo(left + radius, top)
|
||||||
|
shape.absarc(left + radius, top - radius, radius, Math.PI / 2, Math.PI, false)
|
||||||
|
shape.lineTo(left, bottom)
|
||||||
|
shape.closePath()
|
||||||
|
return shape
|
||||||
|
}
|
||||||
|
|
||||||
|
shape.moveTo(left, bottom)
|
||||||
|
shape.lineTo(right, bottom)
|
||||||
|
shape.lineTo(right, top)
|
||||||
|
shape.lineTo(left, top)
|
||||||
|
shape.closePath()
|
||||||
|
return shape
|
||||||
|
}
|
||||||
|
|||||||
@@ -45,6 +45,12 @@ function isMesh(object: THREE.Object3D): object is THREE.Mesh {
|
|||||||
return 'isMesh' in object && (object as THREE.Mesh).isMesh
|
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) {
|
function cloneWorldGeometry(mesh: THREE.Mesh) {
|
||||||
const sourceGeometry = mesh.geometry
|
const sourceGeometry = mesh.geometry
|
||||||
const position = sourceGeometry.getAttribute('position')
|
const position = sourceGeometry.getAttribute('position')
|
||||||
@@ -79,6 +85,8 @@ function shouldSkipColliderNode(nodeId: string, type: (typeof COLLIDER_NODE_TYPE
|
|||||||
const node = useScene.getState().nodes[nodeId]
|
const node = useScene.getState().nodes[nodeId]
|
||||||
if (!node || node.type !== 'door') return false
|
if (!node || node.type !== 'door') return false
|
||||||
|
|
||||||
|
if (node.openingKind === 'opening') return true
|
||||||
|
|
||||||
if (!node.segments.length) return true
|
if (!node.segments.length) return true
|
||||||
|
|
||||||
return node.segments.every((segment) => segment.type === 'empty')
|
return node.segments.every((segment) => segment.type === 'empty')
|
||||||
@@ -109,7 +117,12 @@ function collectColliderGeometriesFromNode(
|
|||||||
if (visitedMeshes.has(object)) return
|
if (visitedMeshes.has(object)) return
|
||||||
visitedMeshes.add(object)
|
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)
|
const geometry = cloneWorldGeometry(object)
|
||||||
if (geometry) {
|
if (geometry) {
|
||||||
geometries.push(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 doorCubeSize = Math.min(Math.max(width * 0.08, 0.06), 0.12)
|
||||||
const doorCubeInset = doorCubeSize * 0.5
|
const doorCubeInset = doorCubeSize * 0.5
|
||||||
const doorCubeStroke = palette.openingStroke
|
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 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 leafHalfThickness = doorCubeSize * 0.18
|
||||||
const leafSideOffset = hingeTangentSign * (doorCubeSize / 2 + leafHalfThickness)
|
const leafSideOffset = hingeTangentSign * (doorCubeSize / 2 + leafHalfThickness)
|
||||||
const leafStart = {
|
const leafStart = {
|
||||||
@@ -4377,7 +4383,7 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
|
|||||||
x: leafStart.x + closedLeafVector.x * openCos - closedLeafVector.y * openSin,
|
x: leafStart.x + closedLeafVector.x * openCos - closedLeafVector.y * openSin,
|
||||||
y: leafStart.y + closedLeafVector.x * openSin + closedLeafVector.y * openCos,
|
y: leafStart.y + closedLeafVector.x * openSin + closedLeafVector.y * openCos,
|
||||||
}
|
}
|
||||||
const doorBackgroundPoints = [
|
const doorBackgroundPointList = [
|
||||||
{
|
{
|
||||||
x: svgP1.x - px * depthDirectionSign * depthExtraOffset,
|
x: svgP1.x - px * depthDirectionSign * depthExtraOffset,
|
||||||
y: svgP1.y - py * depthDirectionSign * depthExtraOffset,
|
y: svgP1.y - py * depthDirectionSign * depthExtraOffset,
|
||||||
@@ -4395,8 +4401,76 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
|
|||||||
y: svgP4.y + py * depthDirectionSign * depthExtraOffset,
|
y: svgP4.y + py * depthDirectionSign * depthExtraOffset,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
const doorBackgroundPoints = doorBackgroundPointList
|
||||||
.map((point) => `${point.x},${point.y}`)
|
.map((point) => `${point.x},${point.y}`)
|
||||||
.join(' ')
|
.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 = [
|
const leafPolygonPoints = [
|
||||||
{
|
{
|
||||||
x: leafStart.x - nx * leafHalfThickness,
|
x: leafStart.x - nx * leafHalfThickness,
|
||||||
@@ -4467,34 +4541,67 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
|
|||||||
vectorEffect="non-scaling-stroke"
|
vectorEffect="non-scaling-stroke"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<polygon fill="#ffffff" points={doorBackgroundPoints} stroke="none" />
|
{opening.openingKind === 'opening' ? (
|
||||||
{[hingeCubeCenter, strikeCubeCenter].map((point, index) => (
|
<>
|
||||||
<rect
|
{openingPlanPath ? (
|
||||||
fill="#ffffff"
|
<path
|
||||||
height={doorCubeSize}
|
d={openingPlanPath}
|
||||||
key={`${opening.id}:door-cube:${index}`}
|
fill="#ffffff"
|
||||||
stroke={doorCubeStroke}
|
stroke={isDeleteHovered ? palette.deleteStroke : doorCubeStroke}
|
||||||
strokeWidth="1.25"
|
strokeWidth="1.25"
|
||||||
vectorEffect="non-scaling-stroke"
|
vectorEffect="non-scaling-stroke"
|
||||||
width={doorCubeSize}
|
/>
|
||||||
x={point.x - doorCubeSize / 2}
|
) : (
|
||||||
y={point.y - doorCubeSize / 2}
|
<polygon
|
||||||
/>
|
fill="#ffffff"
|
||||||
))}
|
points={doorBackgroundPoints}
|
||||||
<polygon
|
stroke={isDeleteHovered ? palette.deleteStroke : doorCubeStroke}
|
||||||
fill="#ffffff"
|
strokeWidth="1.25"
|
||||||
points={leafPolygonPoints}
|
vectorEffect="non-scaling-stroke"
|
||||||
stroke={isDeleteHovered ? palette.deleteStroke : doorCubeStroke}
|
/>
|
||||||
strokeWidth="1.25"
|
)}
|
||||||
vectorEffect="non-scaling-stroke"
|
{archPlanPath && (
|
||||||
/>
|
<path
|
||||||
<path
|
d={archPlanPath}
|
||||||
d={`M ${leafEnd.x} ${leafEnd.y} A ${swingRadius} ${swingRadius} 0 0 ${sweepFlag} ${arcEnd.x} ${arcEnd.y}`}
|
fill="none"
|
||||||
fill="none"
|
stroke={isDeleteHovered ? palette.deleteStroke : doorCubeStroke}
|
||||||
stroke={isDeleteHovered ? palette.deleteStroke : doorCubeStroke}
|
strokeWidth={arcStrokeWidth}
|
||||||
strokeWidth={arcStrokeWidth}
|
vectorEffect="non-scaling-stroke"
|
||||||
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>
|
</g>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,9 +85,10 @@ export function DoorPanel() {
|
|||||||
}, [node, setMovingNode, setSelection])
|
}, [node, setMovingNode, setSelection])
|
||||||
|
|
||||||
const setSegmentHeightRatio = (segIdx: number, newVal: number) => {
|
const setSegmentHeightRatio = (segIdx: number, newVal: number) => {
|
||||||
const numSegs = node?.segments.length
|
if (!node) return
|
||||||
const totalH = node?.segments.reduce((sum, s) => sum + s.heightRatio, 0)
|
const numSegs = node.segments.length
|
||||||
const normH = node?.segments.map((s) => s.heightRatio / totalH)
|
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 clamped = Math.max(0.05, Math.min(0.95, newVal))
|
||||||
const neighborIdx = segIdx < numSegs - 1 ? segIdx + 1 : segIdx - 1
|
const neighborIdx = segIdx < numSegs - 1 ? segIdx + 1 : segIdx - 1
|
||||||
const delta = clamped - normH[segIdx]!
|
const delta = clamped - normH[segIdx]!
|
||||||
@@ -97,7 +98,7 @@ export function DoorPanel() {
|
|||||||
if (i === neighborIdx) return neighborVal
|
if (i === neighborIdx) return neighborVal
|
||||||
return v
|
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 })
|
handleUpdate({ segments: updated })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,6 +132,11 @@ export function DoorPanel() {
|
|||||||
height: node.height,
|
height: node.height,
|
||||||
frameThickness: node.frameThickness,
|
frameThickness: node.frameThickness,
|
||||||
frameDepth: node.frameDepth,
|
frameDepth: node.frameDepth,
|
||||||
|
openingKind: node.openingKind,
|
||||||
|
openingShape: node.openingShape,
|
||||||
|
cornerRadius: node.cornerRadius,
|
||||||
|
archHeight: node.archHeight,
|
||||||
|
openingRevealRadius: node.openingRevealRadius,
|
||||||
contentPadding: node.contentPadding,
|
contentPadding: node.contentPadding,
|
||||||
hingesSide: node.hingesSide,
|
hingesSide: node.hingesSide,
|
||||||
swingDirection: node.swingDirection,
|
swingDirection: node.swingDirection,
|
||||||
@@ -177,6 +183,11 @@ export function DoorPanel() {
|
|||||||
|
|
||||||
const hSum = node.segments.reduce((s, seg) => s + seg.heightRatio, 0)
|
const hSum = node.segments.reduce((s, seg) => s + seg.heightRatio, 0)
|
||||||
const normHeights = node.segments.map((seg) => seg.heightRatio / hSum)
|
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 (
|
return (
|
||||||
<PanelWrapper
|
<PanelWrapper
|
||||||
@@ -206,6 +217,31 @@ export function DoorPanel() {
|
|||||||
</PresetsPopover>
|
</PresetsPopover>
|
||||||
</div>
|
</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">
|
<PanelSection title="Position">
|
||||||
<SliderControl
|
<SliderControl
|
||||||
label={
|
label={
|
||||||
@@ -221,14 +257,16 @@ export function DoorPanel() {
|
|||||||
unit="m"
|
unit="m"
|
||||||
value={Math.round(node.position[0] * 100) / 100}
|
value={Math.round(node.position[0] * 100) / 100}
|
||||||
/>
|
/>
|
||||||
<div className="px-1 pt-2 pb-1">
|
{!isOpening && (
|
||||||
<ActionButton
|
<div className="px-1 pt-2 pb-1">
|
||||||
className="w-full"
|
<ActionButton
|
||||||
icon={<FlipHorizontal2 className="h-4 w-4" />}
|
className="w-full"
|
||||||
label="Flip Side"
|
icon={<FlipHorizontal2 className="h-4 w-4" />}
|
||||||
onClick={handleFlip}
|
label="Flip Side"
|
||||||
/>
|
onClick={handleFlip}
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</PanelSection>
|
</PanelSection>
|
||||||
|
|
||||||
<PanelSection title="Dimensions">
|
<PanelSection title="Dimensions">
|
||||||
@@ -256,6 +294,66 @@ export function DoorPanel() {
|
|||||||
/>
|
/>
|
||||||
</PanelSection>
|
</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">
|
<PanelSection title="Frame">
|
||||||
<SliderControl
|
<SliderControl
|
||||||
label="Thickness"
|
label="Thickness"
|
||||||
@@ -567,6 +665,9 @@ export function DoorPanel() {
|
|||||||
</div>
|
</div>
|
||||||
</PanelSection>
|
</PanelSection>
|
||||||
|
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<PanelSection title="Actions">
|
<PanelSection title="Actions">
|
||||||
<ActionGroup>
|
<ActionGroup>
|
||||||
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
|
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
|
||||||
|
|||||||
@@ -153,12 +153,14 @@ export const useKeyboard = ({
|
|||||||
const node = useScene.getState().nodes[selectedNodeIds[0]!]
|
const node = useScene.getState().nodes[selectedNodeIds[0]!]
|
||||||
if (node?.type === 'door') {
|
if (node?.type === 'door') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const currentSwingAngle = node.swingAngle ?? 0
|
if (node.openingKind !== 'opening') {
|
||||||
useScene.getState().updateNode(node.id, {
|
const currentSwingAngle = node.swingAngle ?? 0
|
||||||
swingAngle:
|
useScene.getState().updateNode(node.id, {
|
||||||
currentSwingAngle >= DOOR_SWING_OPEN_ANGLE / 2 ? 0 : DOOR_SWING_OPEN_ANGLE,
|
swingAngle:
|
||||||
})
|
currentSwingAngle >= DOOR_SWING_OPEN_ANGLE / 2 ? 0 : DOOR_SWING_OPEN_ANGLE,
|
||||||
sfxEmitter.emit('sfx:item-rotate')
|
})
|
||||||
|
sfxEmitter.emit('sfx:item-rotate')
|
||||||
|
}
|
||||||
} else if (node && 'rotation' in node) {
|
} else if (node && 'rotation' in node) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const ROTATION_STEP = Math.PI / 4
|
const ROTATION_STEP = Math.PI / 4
|
||||||
@@ -181,8 +183,10 @@ export const useKeyboard = ({
|
|||||||
const node = useScene.getState().nodes[selectedNodeIds[0]!]
|
const node = useScene.getState().nodes[selectedNodeIds[0]!]
|
||||||
if (node?.type === 'door') {
|
if (node?.type === 'door') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
useScene.getState().updateNode(node.id, { swingAngle: 0 })
|
if (node.openingKind !== 'opening') {
|
||||||
sfxEmitter.emit('sfx:item-rotate')
|
useScene.getState().updateNode(node.id, { swingAngle: 0 })
|
||||||
|
sfxEmitter.emit('sfx:item-rotate')
|
||||||
|
}
|
||||||
} else if (node && 'rotation' in node) {
|
} else if (node && 'rotation' in node) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const ROTATION_STEP = Math.PI / 4
|
const ROTATION_STEP = Math.PI / 4
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { type DoorNode, useRegistry, useScene } from '@pascal-app/core'
|
import { type DoorNode, useRegistry, useScene } from '@pascal-app/core'
|
||||||
import { useLayoutEffect, useMemo, useRef } from 'react'
|
import { useLayoutEffect, useRef } from 'react'
|
||||||
import type { Mesh } from 'three'
|
import { MeshBasicMaterial, type Mesh } from 'three'
|
||||||
import { useNodeEvents } from '../../../hooks/use-node-events'
|
import { useNodeEvents } from '../../../hooks/use-node-events'
|
||||||
import { createMaterial, DEFAULT_DOOR_MATERIAL } from '../../../lib/materials'
|
|
||||||
|
const doorHitboxMaterial = new MeshBasicMaterial({ visible: false })
|
||||||
|
|
||||||
export const DoorRenderer = ({ node }: { node: DoorNode }) => {
|
export const DoorRenderer = ({ node }: { node: DoorNode }) => {
|
||||||
const ref = useRef<Mesh>(null!)
|
const ref = useRef<Mesh>(null!)
|
||||||
@@ -14,16 +15,10 @@ export const DoorRenderer = ({ node }: { node: DoorNode }) => {
|
|||||||
const handlers = useNodeEvents(node, 'door')
|
const handlers = useNodeEvents(node, 'door')
|
||||||
const isTransient = !!(node.metadata as Record<string, unknown> | null)?.isTransient
|
const isTransient = !!(node.metadata as Record<string, unknown> | null)?.isTransient
|
||||||
|
|
||||||
const material = useMemo(() => {
|
|
||||||
const mat = node.material
|
|
||||||
if (!mat) return DEFAULT_DOOR_MATERIAL
|
|
||||||
return createMaterial(mat)
|
|
||||||
}, [node.material, node.material?.preset, node.material?.properties, node.material?.texture])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<mesh
|
<mesh
|
||||||
castShadow
|
castShadow
|
||||||
material={material}
|
material={doorHitboxMaterial}
|
||||||
position={node.position}
|
position={node.position}
|
||||||
receiveShadow
|
receiveShadow
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|||||||
Reference in New Issue
Block a user