Fix floorplan labels and door/fence geometry
This commit is contained in:
+91
-68
@@ -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' },
|
||||
|
||||
Reference in New Issue
Block a user