Add auto ceilings and simplify ceiling selection affordances
This commit is contained in:
+83
-44
@@ -2,30 +2,30 @@
|
||||
|
||||
import {
|
||||
type CeilingNode,
|
||||
emitter,
|
||||
resolveLevelId,
|
||||
sceneRegistry,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { createPortal, type ThreeEvent } from '@react-three/fiber'
|
||||
import { useMemo } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import type { Object3D } from 'three'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
|
||||
const BRACKET_THICKNESS = 0.04
|
||||
const BRACKET_HEIGHT = 0.04
|
||||
const BRACKET_Y_OFFSET = 0.035
|
||||
const CORNER_BLOCK_SIZE = 0.085
|
||||
const HIT_BOX_SIZE: [number, number, number] = [0.24, 0.12, 0.24]
|
||||
const HIT_INSET = 0.16
|
||||
const HIT_BOX_SIZE: [number, number, number] = [0.28, 0.08, 0.28]
|
||||
|
||||
type CornerBracketData = {
|
||||
corner: [number, number]
|
||||
hitCenter: [number, number]
|
||||
incomingDirection: [number, number]
|
||||
outgoingDirection: [number, number]
|
||||
incomingLength: number
|
||||
outgoingLength: number
|
||||
cornerStrength: number
|
||||
}
|
||||
|
||||
export const CeilingSelectionAffordanceSystem = () => {
|
||||
@@ -75,13 +75,37 @@ const CeilingSelectionAffordance = ({
|
||||
ceiling: CeilingNode
|
||||
levelId: string
|
||||
}) => {
|
||||
const selectedIds = useViewer((state) => state.selection.selectedIds)
|
||||
const isSelected = selectedIds.includes(ceiling.id)
|
||||
const levelObject = sceneRegistry.nodes.get(levelId)
|
||||
const [levelObject, setLevelObject] = useState<Object3D | null>(() => sceneRegistry.nodes.get(levelId) ?? null)
|
||||
|
||||
const corners = useMemo(() => buildCornerBrackets(ceiling.polygon), [ceiling.polygon])
|
||||
|
||||
if (!levelObject || corners.length === 0 || isSelected) return null
|
||||
useEffect(() => {
|
||||
let frameId = 0
|
||||
|
||||
const resolveLevelObject = () => {
|
||||
const nextLevelObject = sceneRegistry.nodes.get(levelId) ?? null
|
||||
setLevelObject((currentLevelObject) => {
|
||||
if (currentLevelObject === nextLevelObject) {
|
||||
return currentLevelObject
|
||||
}
|
||||
return nextLevelObject
|
||||
})
|
||||
|
||||
if (!nextLevelObject) {
|
||||
frameId = window.requestAnimationFrame(resolveLevelObject)
|
||||
}
|
||||
}
|
||||
|
||||
resolveLevelObject()
|
||||
|
||||
return () => {
|
||||
if (frameId) {
|
||||
window.cancelAnimationFrame(frameId)
|
||||
}
|
||||
}
|
||||
}, [levelId])
|
||||
|
||||
if (!levelObject || corners.length === 0) return null
|
||||
|
||||
return createPortal(
|
||||
<group position={[0, (ceiling.height ?? 2.5) + BRACKET_Y_OFFSET, 0]}>
|
||||
@@ -104,17 +128,16 @@ const CornerBracket = ({
|
||||
ceiling: CeilingNode
|
||||
corner: CornerBracketData
|
||||
}) => {
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
const color = '#d4d4d4'
|
||||
const opacity = 0.72
|
||||
const cubeColor = isHovered ? '#818cf8' : '#d4d4d4'
|
||||
const cubeOpacity = isHovered ? 0.92 : 0.72
|
||||
|
||||
const handleClick = (e: ThreeEvent<PointerEvent>) => {
|
||||
if (e.button !== 0) return
|
||||
const handleClick = (e: ThreeEvent<MouseEvent>) => {
|
||||
e.stopPropagation()
|
||||
|
||||
const nodes = useScene.getState().nodes
|
||||
const selection = useViewer.getState().selection
|
||||
const levelId = resolveLevelId(ceiling, nodes)
|
||||
const buildingId = findBuildingId(levelId, nodes)
|
||||
|
||||
useEditor.getState().setMovingNode(null)
|
||||
useEditor.getState().setMovingWallEndpoint(null)
|
||||
@@ -122,39 +145,45 @@ const CornerBracket = ({
|
||||
useEditor.getState().setEditingHole(null)
|
||||
useEditor.getState().setMode('select')
|
||||
|
||||
useViewer.getState().setSelection({
|
||||
buildingId: buildingId ?? selection.buildingId,
|
||||
levelId,
|
||||
selectedIds: [ceiling.id],
|
||||
emitter.emit('ceiling:click' as any, {
|
||||
node: ceiling,
|
||||
nativeEvent: e.nativeEvent,
|
||||
localPosition: [0, 0, 0],
|
||||
position: [corner.corner[0], ceiling.height ?? 2.5, corner.corner[1]],
|
||||
stopPropagation: () => e.stopPropagation(),
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<group position={[corner.corner[0], 0, corner.corner[1]]}>
|
||||
<mesh>
|
||||
<boxGeometry args={[CORNER_BLOCK_SIZE, BRACKET_HEIGHT, CORNER_BLOCK_SIZE]} />
|
||||
<meshBasicMaterial color={color} depthWrite={false} opacity={opacity} transparent />
|
||||
</mesh>
|
||||
|
||||
<BracketLeg
|
||||
color={color}
|
||||
direction={corner.incomingDirection}
|
||||
length={corner.incomingLength}
|
||||
onClick={handleClick}
|
||||
opacity={opacity}
|
||||
/>
|
||||
<BracketLeg
|
||||
color={color}
|
||||
direction={corner.outgoingDirection}
|
||||
length={corner.outgoingLength}
|
||||
onClick={handleClick}
|
||||
opacity={opacity}
|
||||
/>
|
||||
|
||||
<mesh
|
||||
onPointerDown={handleClick}
|
||||
position={[corner.hitCenter[0] - corner.corner[0], 0, corner.hitCenter[1] - corner.corner[1]]}
|
||||
onClick={handleClick}
|
||||
onPointerEnter={(e) => {
|
||||
e.stopPropagation()
|
||||
setIsHovered(true)
|
||||
}}
|
||||
onPointerLeave={(e) => {
|
||||
e.stopPropagation()
|
||||
setIsHovered(false)
|
||||
}}
|
||||
>
|
||||
<boxGeometry args={HIT_BOX_SIZE} />
|
||||
<meshBasicMaterial opacity={0} transparent />
|
||||
<meshBasicMaterial color={cubeColor} depthWrite={false} opacity={cubeOpacity} transparent />
|
||||
</mesh>
|
||||
</group>
|
||||
)
|
||||
@@ -164,11 +193,13 @@ const BracketLeg = ({
|
||||
direction,
|
||||
length,
|
||||
color,
|
||||
onClick,
|
||||
opacity,
|
||||
}: {
|
||||
direction: [number, number]
|
||||
length: number
|
||||
color: string
|
||||
onClick: (e: ThreeEvent<MouseEvent>) => void
|
||||
opacity: number
|
||||
}) => {
|
||||
const angle = Math.atan2(direction[1], direction[0])
|
||||
@@ -179,7 +210,11 @@ const BracketLeg = ({
|
||||
]
|
||||
|
||||
return (
|
||||
<mesh position={position} rotation={[0, angle, 0]}>
|
||||
<mesh
|
||||
onClick={onClick}
|
||||
position={position}
|
||||
rotation={[0, angle, 0]}
|
||||
>
|
||||
<boxGeometry args={[length, BRACKET_HEIGHT, BRACKET_THICKNESS]} />
|
||||
<meshBasicMaterial color={color} depthWrite={false} opacity={opacity} transparent />
|
||||
</mesh>
|
||||
@@ -189,31 +224,41 @@ const BracketLeg = ({
|
||||
function buildCornerBrackets(polygon: Array<[number, number]>): CornerBracketData[] {
|
||||
if (polygon.length < 3) return []
|
||||
|
||||
return polygon.map((corner, index) => {
|
||||
const allCorners = polygon.map((corner, index) => {
|
||||
const previous = polygon[(index - 1 + polygon.length) % polygon.length]!
|
||||
const next = polygon[(index + 1) % polygon.length]!
|
||||
const incomingVector = [previous[0] - corner[0], previous[1] - corner[1]] as [number, number]
|
||||
const outgoingVector = [next[0] - corner[0], next[1] - corner[1]] as [number, number]
|
||||
const incomingDirection = normalize2D(incomingVector)
|
||||
const outgoingDirection = normalize2D(outgoingVector)
|
||||
|
||||
const incomingLength = Math.hypot(incomingVector[0], incomingVector[1])
|
||||
const outgoingLength = Math.hypot(outgoingVector[0], outgoingVector[1])
|
||||
const insetDirection = normalize2D([
|
||||
normalize2D(incomingVector)[0] + normalize2D(outgoingVector)[0],
|
||||
normalize2D(incomingVector)[1] + normalize2D(outgoingVector)[1],
|
||||
])
|
||||
const cornerStrength = 1 - Math.abs(incomingDirection[0] * outgoingDirection[0] + incomingDirection[1] * outgoingDirection[1])
|
||||
|
||||
return {
|
||||
corner,
|
||||
hitCenter: [
|
||||
corner[0] + insetDirection[0] * HIT_INSET,
|
||||
corner[1] + insetDirection[1] * HIT_INSET,
|
||||
],
|
||||
incomingDirection: normalize2D(incomingVector),
|
||||
outgoingDirection: normalize2D(outgoingVector),
|
||||
incomingDirection,
|
||||
outgoingDirection,
|
||||
incomingLength: getBracketLength(incomingLength),
|
||||
outgoingLength: getBracketLength(outgoingLength),
|
||||
cornerStrength,
|
||||
}
|
||||
})
|
||||
|
||||
if (allCorners.length <= 4) {
|
||||
return allCorners
|
||||
}
|
||||
|
||||
const selectedIndices = new Set(
|
||||
allCorners
|
||||
.map((corner, index) => ({ index, strength: corner.cornerStrength }))
|
||||
.sort((a, b) => b.strength - a.strength)
|
||||
.slice(0, 4)
|
||||
.map(({ index }) => index),
|
||||
)
|
||||
|
||||
return allCorners.filter((_, index) => selectedIndices.has(index))
|
||||
}
|
||||
|
||||
function normalize2D(vector: [number, number]): [number, number] {
|
||||
@@ -225,9 +270,3 @@ function normalize2D(vector: [number, number]): [number, number] {
|
||||
function getBracketLength(edgeLength: number): number {
|
||||
return Math.max(0.14, Math.min(0.38, edgeLength * 0.22))
|
||||
}
|
||||
|
||||
function findBuildingId(levelId: string | null, nodes: Record<string, { parentId: string | null }>): string | null {
|
||||
if (!levelId) return null
|
||||
const level = nodes[levelId]
|
||||
return level?.parentId ?? null
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { emitter, type GridEvent, sceneRegistry } from '@pascal-app/core'
|
||||
import { createPortal } from '@react-three/fiber'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { BufferGeometry, Float32BufferAttribute, type Line } from 'three'
|
||||
import { BufferGeometry, Float32BufferAttribute, type Line, type Object3D } from 'three'
|
||||
import { EDITOR_LAYER } from '../../../lib/constants'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
|
||||
@@ -44,8 +44,40 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
||||
surfaceHeight = 0,
|
||||
allowPolygonMove = false,
|
||||
}) => {
|
||||
// Get level node from registry if levelId is provided
|
||||
const levelNode = levelId ? sceneRegistry.nodes.get(levelId) : null
|
||||
const [levelNode, setLevelNode] = useState<Object3D | null>(() =>
|
||||
levelId ? (sceneRegistry.nodes.get(levelId) ?? null) : null,
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!levelId) {
|
||||
setLevelNode(null)
|
||||
return
|
||||
}
|
||||
|
||||
let frameId = 0
|
||||
|
||||
const resolveLevelNode = () => {
|
||||
const nextLevelNode = sceneRegistry.nodes.get(levelId) ?? null
|
||||
setLevelNode((currentLevelNode) => {
|
||||
if (currentLevelNode === nextLevelNode) {
|
||||
return currentLevelNode
|
||||
}
|
||||
return nextLevelNode
|
||||
})
|
||||
|
||||
if (!nextLevelNode) {
|
||||
frameId = window.requestAnimationFrame(resolveLevelNode)
|
||||
}
|
||||
}
|
||||
|
||||
resolveLevelNode()
|
||||
|
||||
return () => {
|
||||
if (frameId) {
|
||||
window.cancelAnimationFrame(frameId)
|
||||
}
|
||||
}
|
||||
}, [levelId])
|
||||
|
||||
// When using portal, edit at Y_OFFSET (local to level)
|
||||
// When not using portal, edit at world origin
|
||||
|
||||
@@ -51,6 +51,9 @@ export const CurveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
|
||||
let wasCommitted = false
|
||||
|
||||
const applyPreview = (curveOffset: number) => {
|
||||
if (previewOffsetRef.current === curveOffset) {
|
||||
return
|
||||
}
|
||||
previewOffsetRef.current = curveOffset
|
||||
|
||||
const nextNode = {
|
||||
@@ -64,6 +67,10 @@ export const CurveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
|
||||
}
|
||||
|
||||
const restoreOriginal = () => {
|
||||
if (previewOffsetRef.current === originalCurveOffset) {
|
||||
return
|
||||
}
|
||||
previewOffsetRef.current = originalCurveOffset
|
||||
useScene.getState().updateNode(nodeId, { curveOffset: originalCurveOffset })
|
||||
useScene.getState().markDirty(nodeId as AnyNodeId)
|
||||
}
|
||||
@@ -100,8 +107,10 @@ export const CurveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
|
||||
const curveOffset = previewOffsetRef.current
|
||||
wasCommitted = true
|
||||
useScene.temporal.getState().resume()
|
||||
useScene.getState().updateNode(nodeId, { curveOffset })
|
||||
useScene.getState().markDirty(nodeId as AnyNodeId)
|
||||
if (curveOffset !== getClampedWallCurveOffset(node)) {
|
||||
useScene.getState().updateNode(nodeId, { curveOffset })
|
||||
useScene.getState().markDirty(nodeId as AnyNodeId)
|
||||
}
|
||||
useScene.temporal.getState().pause()
|
||||
|
||||
sfxEmitter.emit('sfx:item-place')
|
||||
|
||||
Reference in New Issue
Block a user