Fix floorplan labels and door/fence geometry

This commit is contained in:
sudhir
2026-05-20 00:45:33 +00:00
committed by open-pascal
parent cdde88f9c0
commit 4a402756fc
6 changed files with 816 additions and 122 deletions
@@ -128,12 +128,26 @@ type FloorplanMeasurementsLayerProps = {
className: string
measurements: LinearMeasurementOverlay[]
palette: FloorplanMeasurementPalette
sceneRotationDeg?: number
}
function normalizeReadableScreenAngle(angleDeg: number) {
let normalized = ((((angleDeg + 180) % 360) + 360) % 360) - 180
if (normalized > 90) {
normalized -= 180
} else if (normalized <= -90) {
normalized += 180
}
return normalized
}
export const FloorplanMeasurementsLayer = memo(function FloorplanMeasurementsLayer({
className,
measurements,
palette,
sceneRotationDeg = 0,
}: FloorplanMeasurementsLayerProps) {
if (measurements.length === 0) {
return null
@@ -141,81 +155,90 @@ export const FloorplanMeasurementsLayer = memo(function FloorplanMeasurementsLay
return (
<>
{measurements.map((measurement) => (
<g
className={className}
key={measurement.id}
pointerEvents="none"
style={{ userSelect: 'none' }}
>
<FloorplanMeasurementLine
dashed={measurement.dashedExtensions ?? true}
isSelected={measurement.isSelected}
palette={palette}
segment={measurement.extensionStart}
stroke={measurement.extensionStroke}
/>
<FloorplanMeasurementLine
isSelected={measurement.isSelected}
palette={palette}
path={measurement.dimensionPathStart}
segment={measurement.dimensionLineStart}
stroke={measurement.stroke}
/>
<FloorplanMeasurementLine
isSelected={measurement.isSelected}
palette={palette}
path={measurement.dimensionPathEnd}
segment={measurement.dimensionLineEnd}
stroke={measurement.stroke}
/>
<FloorplanMeasurementLine
dashed={measurement.dashedExtensions ?? true}
isSelected={measurement.isSelected}
palette={palette}
segment={measurement.extensionEnd}
stroke={measurement.extensionStroke}
/>
{measurement.showTicks !== false ? (
<>
<FloorplanMeasurementTick
angleDeg={measurement.labelAngleDeg}
{measurements.map((measurement) =>
(() => {
const screenLabelAngleDeg = normalizeReadableScreenAngle(
measurement.labelAngleDeg + sceneRotationDeg,
)
const localLabelAngleDeg = screenLabelAngleDeg - sceneRotationDeg
return (
<g
className={className}
key={measurement.id}
pointerEvents="none"
style={{ userSelect: 'none' }}
>
<FloorplanMeasurementLine
dashed={measurement.dashedExtensions ?? true}
isSelected={measurement.isSelected}
palette={palette}
stroke={measurement.stroke}
x={measurement.dimensionLineStart.x1}
y={measurement.dimensionLineStart.y1}
segment={measurement.extensionStart}
stroke={measurement.extensionStroke}
/>
<FloorplanMeasurementTick
angleDeg={measurement.labelAngleDeg}
<FloorplanMeasurementLine
isSelected={measurement.isSelected}
palette={palette}
path={measurement.dimensionPathStart}
segment={measurement.dimensionLineStart}
stroke={measurement.stroke}
x={measurement.dimensionLineEnd.x2}
y={measurement.dimensionLineEnd.y2}
/>
</>
) : null}
<text
dominantBaseline="central"
fill={measurement.labelFill ?? palette.measurementStroke}
fillOpacity={
measurement.isSelected
? FLOORPLAN_MEASUREMENT_LABEL_OPACITY
: FLOORPLAN_MEASUREMENT_LABEL_OPACITY * 0.4
}
fontFamily="ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace"
fontSize={FLOORPLAN_MEASUREMENT_LABEL_FONT_SIZE}
fontWeight="600"
textAnchor="middle"
transform={`rotate(${measurement.labelAngleDeg} ${measurement.labelX} ${measurement.labelY}) translate(0, -0.04)`}
x={measurement.labelX}
y={measurement.labelY}
>
{measurement.label}
</text>
</g>
))}
<FloorplanMeasurementLine
isSelected={measurement.isSelected}
palette={palette}
path={measurement.dimensionPathEnd}
segment={measurement.dimensionLineEnd}
stroke={measurement.stroke}
/>
<FloorplanMeasurementLine
dashed={measurement.dashedExtensions ?? true}
isSelected={measurement.isSelected}
palette={palette}
segment={measurement.extensionEnd}
stroke={measurement.extensionStroke}
/>
{measurement.showTicks !== false ? (
<>
<FloorplanMeasurementTick
angleDeg={localLabelAngleDeg}
isSelected={measurement.isSelected}
palette={palette}
stroke={measurement.stroke}
x={measurement.dimensionLineStart.x1}
y={measurement.dimensionLineStart.y1}
/>
<FloorplanMeasurementTick
angleDeg={localLabelAngleDeg}
isSelected={measurement.isSelected}
palette={palette}
stroke={measurement.stroke}
x={measurement.dimensionLineEnd.x2}
y={measurement.dimensionLineEnd.y2}
/>
</>
) : null}
<text
dominantBaseline="central"
fill={measurement.labelFill ?? palette.measurementStroke}
fillOpacity={
measurement.isSelected
? FLOORPLAN_MEASUREMENT_LABEL_OPACITY
: FLOORPLAN_MEASUREMENT_LABEL_OPACITY * 0.4
}
fontFamily="ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace"
fontSize={FLOORPLAN_MEASUREMENT_LABEL_FONT_SIZE}
fontWeight="600"
textAnchor="middle"
transform={`rotate(${localLabelAngleDeg} ${measurement.labelX} ${measurement.labelY}) translate(0, -0.04)`}
x={measurement.labelX}
y={measurement.labelY}
>
{measurement.label}
</text>
</g>
)
})(),
)}
</>
)
})
@@ -482,7 +482,10 @@ export function FloatingActionMenu() {
: undefined
}
onMove={
node && node.type !== 'wall' && !DELETE_ONLY_TYPES.includes(node.type)
node &&
node.type !== 'wall' &&
node.type !== 'fence' &&
!DELETE_ONLY_TYPES.includes(node.type)
? handleMove
: undefined
}
@@ -3,6 +3,7 @@
import {
type AnyNodeId,
DEFAULT_WALL_HEIGHT,
type FenceNode,
getWallCurveFrameAt,
getWallThickness,
isCurvedWall,
@@ -11,9 +12,8 @@ import {
type WallNode,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { createPortal, type ThreeEvent } from '@react-three/fiber'
import { createPortal, type ThreeEvent, useThree } from '@react-three/fiber'
import { useEffect, useMemo, useState } from 'react'
import { useThree } from '@react-three/fiber'
import {
BufferGeometry,
ConeGeometry,
@@ -34,7 +34,6 @@ const ARROW_COLOR = '#8381ed'
const ARROW_HOVER_COLOR = '#a5b4fc'
type WallMoveHandle = {
direction: [number, number]
key: string
position: [number, number, number]
rotationY: number
@@ -91,13 +90,13 @@ export function WallMoveSideHandles() {
const curvingFence = useEditor((state) => state.curvingFence)
const selectedId = selectedIds.length === 1 ? selectedIds[0] : null
const wall = useScene((state) => {
const selectedNode = useScene((state) => {
const node = selectedId ? state.nodes[selectedId as AnyNodeId] : null
return node?.type === 'wall' ? node : null
return node?.type === 'wall' || node?.type === 'fence' ? node : null
})
const shouldRender =
Boolean(wall) &&
Boolean(selectedNode) &&
!isFloorplanHovered &&
mode !== 'delete' &&
!movingNode &&
@@ -106,9 +105,13 @@ export function WallMoveSideHandles() {
!curvingWall &&
!curvingFence
if (!shouldRender || !wall) return null
if (!shouldRender || !selectedNode) return null
return <WallMoveSideHandlesForWall wall={wall} />
return selectedNode.type === 'wall' ? (
<WallMoveSideHandlesForWall wall={selectedNode} />
) : (
<WallMoveSideHandlesForFence fence={selectedNode} />
)
}
function WallMoveSideHandlesForWall({ wall }: { wall: WallNode }) {
@@ -163,10 +166,7 @@ function WallMoveArrowHandle({ wall, handle }: { wall: WallNode; handle: WallMov
const arrowGeometry = useMemo(() => createArrowHandleGeometry(), [])
const { camera } = useThree()
const zoom =
camera instanceof OrthographicCamera
? 1 / camera.zoom
: 1
const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1
const scale = (isHovered ? 1.12 : 1) * zoom
@@ -195,11 +195,7 @@ function WallMoveArrowHandle({ wall, handle }: { wall: WallNode; handle: WallMov
}
return (
<group
position={handle.position}
rotation={[0, handle.rotationY, 0]}
scale={scale}
>
<group position={handle.position} rotation={[0, handle.rotationY, 0]} scale={scale}>
<mesh
frustumCulled={false}
onPointerDown={activateWallMove}
@@ -231,6 +227,71 @@ function WallMoveArrowHandle({ wall, handle }: { wall: WallNode; handle: WallMov
)
}
function FenceMoveArrowHandle({ fence, handle }: { fence: FenceNode; handle: WallMoveHandle }) {
const [isHovered, setIsHovered] = useState(false)
const arrowGeometry = useMemo(() => createArrowHandleGeometry(), [])
const { camera } = useThree()
const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1
const scale = (isHovered ? 1.12 : 1) * zoom
useEffect(() => {
return () => {
if (document.body.style.cursor === 'grab' || document.body.style.cursor === 'grabbing') {
document.body.style.cursor = ''
}
}
}, [])
useEffect(() => () => arrowGeometry.dispose(), [arrowGeometry])
const activateFenceMove = (event: ThreeEvent<PointerEvent>) => {
event.stopPropagation()
event.nativeEvent.preventDefault()
document.body.style.cursor = 'grabbing'
sfxEmitter.emit('sfx:item-pick')
useEditor.getState().setMovingNode(fence)
useEditor.getState().setMovingWallEndpoint(null)
useEditor.getState().setMovingFenceEndpoint(null)
useEditor.getState().setCurvingWall(null)
useEditor.getState().setCurvingFence(null)
useViewer.getState().setSelection({ selectedIds: [] })
}
return (
<group position={handle.position} rotation={[0, handle.rotationY, 0]} scale={scale}>
<mesh
frustumCulled={false}
onPointerDown={activateFenceMove}
onPointerEnter={(event) => {
event.stopPropagation()
setIsHovered(true)
document.body.style.cursor = 'grab'
}}
onPointerLeave={(event) => {
event.stopPropagation()
setIsHovered(false)
if (document.body.style.cursor === 'grab') {
document.body.style.cursor = ''
}
}}
renderOrder={1002}
>
<primitive attach="geometry" object={arrowGeometry} />
<meshBasicMaterial
color={isHovered ? ARROW_HOVER_COLOR : ARROW_COLOR}
depthTest
depthWrite
opacity={1}
side={DoubleSide}
transparent={false}
/>
</mesh>
</group>
)
}
function getWallMoveHandles(wall: WallNode): WallMoveHandle[] {
const dx = wall.end[0] - wall.start[0]
const dz = wall.end[1] - wall.start[1]
@@ -257,6 +318,77 @@ function getWallMoveHandles(wall: WallNode): WallMoveHandle[] {
]
}
function WallMoveSideHandlesForFence({ fence }: { fence: FenceNode }) {
const [levelObject, setLevelObject] = useState<Object3D | null>(() =>
fence.parentId ? (sceneRegistry.nodes.get(fence.parentId) ?? null) : null,
)
useEffect(() => {
let frameId = 0
const resolveLevelObject = () => {
const nextLevelObject = fence.parentId
? (sceneRegistry.nodes.get(fence.parentId) ?? null)
: null
setLevelObject((currentLevelObject) => {
if (currentLevelObject === nextLevelObject) {
return currentLevelObject
}
return nextLevelObject
})
if (!nextLevelObject) {
frameId = window.requestAnimationFrame(resolveLevelObject)
}
}
resolveLevelObject()
return () => {
if (frameId) {
window.cancelAnimationFrame(frameId)
}
}
}, [fence.parentId])
const handles = useMemo(() => getFenceMoveHandles(fence), [fence])
if (!levelObject || handles.length === 0) return null
return createPortal(
<group>
{handles.map((handle) => (
<FenceMoveArrowHandle fence={fence} handle={handle} key={handle.key} />
))}
</group>,
levelObject,
)
}
function getFenceMoveHandles(fence: FenceNode): WallMoveHandle[] {
const dx = fence.end[0] - fence.start[0]
const dz = fence.end[1] - fence.start[1]
const length = Math.hypot(dx, dz)
if (length < 1e-6) {
return []
}
const midpoint: [number, number] = [
(fence.start[0] + fence.end[0]) / 2,
(fence.start[1] + fence.end[1]) / 2,
]
const normal: [number, number] = [-dz / length, dx / length]
const fenceHeight = fence.height ?? 1.8
const handleHeight = Math.max(fenceHeight - HANDLE_TOP_INSET, HANDLE_MIN_HEIGHT)
const offset = Math.max((fence.thickness ?? 0.1) / 2 + HANDLE_OFFSET, HANDLE_MIN_OFFSET)
return [
buildWallMoveHandle('front', midpoint, normal, offset, handleHeight),
buildWallMoveHandle('back', midpoint, [-normal[0], -normal[1]], offset, handleHeight),
]
}
function buildWallMoveHandle(
key: string,
midpoint: [number, number],
@@ -265,7 +397,6 @@ function buildWallMoveHandle(
height: number,
): WallMoveHandle {
return {
direction,
key,
position: [midpoint[0] + direction[0] * offset, height, midpoint[1] + direction[1] * offset],
rotationY: Math.atan2(-direction[1], direction[0]),
@@ -20,17 +20,17 @@ export type ToolConfig = {
export const tools: ToolConfig[] = [
{ id: 'wall', iconSrc: '/icons/wall.png', label: 'Wall' },
{ id: 'door', iconSrc: '/icons/door.png', label: 'Door' },
{ id: 'window', iconSrc: '/icons/window.png', label: 'Window' },
{ id: 'stair', iconSrc: '/icons/stairs.png', label: 'Stairs' },
{ id: 'roof', iconSrc: '/icons/roof.png', label: 'Gable Roof' },
{ id: 'fence', iconSrc: '/icons/fence.png', label: 'Fence' },
{ id: 'column', iconSrc: '/icons/column.png', label: 'Column' },
{ id: 'elevator', iconSrc: '/icons/elevator.png', label: 'Elevator' },
// { id: 'room', iconSrc: '/icons/room.png', label: 'Room' },
// { id: 'custom-room', iconSrc: '/icons/custom-room.png', label: 'Custom Room' },
{ id: 'slab', iconSrc: '/icons/floor.png', label: 'Slab' },
{ id: 'ceiling', iconSrc: '/icons/ceiling.png', label: 'Ceiling' },
{ id: 'column', iconSrc: '/icons/column.png', label: 'Column' },
{ id: 'elevator', iconSrc: '/icons/elevator.png', label: 'Elevator' },
{ id: 'roof', iconSrc: '/icons/roof.png', label: 'Gable Roof' },
{ id: 'stair', iconSrc: '/icons/stairs.png', label: 'Stairs' },
{ id: 'door', iconSrc: '/icons/door.png', label: 'Door' },
{ id: 'window', iconSrc: '/icons/window.png', label: 'Window' },
{ id: 'fence', iconSrc: '/icons/fence.png', label: 'Fence' },
{ id: 'zone', iconSrc: '/icons/zone.png', label: 'Zone' },
{ id: 'spawn', iconSrc: '/icons/site.png', label: 'Spawn Point' },
{ id: 'shelf', iconSrc: '/icons/shelf.png', label: 'Shelf' },