Extract floorplan placement and hit-testing helpers
This commit is contained in:
@@ -0,0 +1,92 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { memo, useEffect } from 'react'
|
||||||
|
import useEditor from '../../store/use-editor'
|
||||||
|
|
||||||
|
type FloorplanSiteKeyHandlerProps = {
|
||||||
|
onRestoreGroundLevel: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FloorplanSiteKeyHandler = memo(function FloorplanSiteKeyHandler({
|
||||||
|
onRestoreGroundLevel,
|
||||||
|
}: FloorplanSiteKeyHandlerProps) {
|
||||||
|
const isFloorplanHovered = useEditor((state) => state.isFloorplanHovered)
|
||||||
|
const phase = useEditor((state) => state.phase)
|
||||||
|
const setFloorplanSelectionTool = useEditor((state) => state.setFloorplanSelectionTool)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
const target = event.target as HTMLElement | null
|
||||||
|
const isEditableTarget =
|
||||||
|
target instanceof HTMLInputElement ||
|
||||||
|
target instanceof HTMLTextAreaElement ||
|
||||||
|
Boolean(target?.isContentEditable)
|
||||||
|
|
||||||
|
if (
|
||||||
|
isEditableTarget ||
|
||||||
|
!isFloorplanHovered ||
|
||||||
|
phase !== 'site' ||
|
||||||
|
event.metaKey ||
|
||||||
|
event.ctrlKey ||
|
||||||
|
event.altKey ||
|
||||||
|
event.key.toLowerCase() !== 'v'
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setFloorplanSelectionTool('click')
|
||||||
|
onRestoreGroundLevel()
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown, true)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', handleKeyDown, true)
|
||||||
|
}
|
||||||
|
}, [isFloorplanHovered, onRestoreGroundLevel, phase, setFloorplanSelectionTool])
|
||||||
|
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
|
||||||
|
type FloorplanDuplicateHotkeyProps = {
|
||||||
|
hasDuplicatable: boolean
|
||||||
|
onDuplicateSelected: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FloorplanDuplicateHotkey = memo(function FloorplanDuplicateHotkey({
|
||||||
|
hasDuplicatable,
|
||||||
|
onDuplicateSelected,
|
||||||
|
}: FloorplanDuplicateHotkeyProps) {
|
||||||
|
const isFloorplanHovered = useEditor((state) => state.isFloorplanHovered)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== 'c') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(isFloorplanHovered && hasDuplicatable)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = event.target as HTMLElement | null
|
||||||
|
const isEditableTarget =
|
||||||
|
target instanceof HTMLInputElement ||
|
||||||
|
target instanceof HTMLTextAreaElement ||
|
||||||
|
Boolean(target?.isContentEditable)
|
||||||
|
|
||||||
|
if (isEditableTarget) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
onDuplicateSelected()
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown, true)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', handleKeyDown, true)
|
||||||
|
}
|
||||||
|
}, [hasDuplicatable, isFloorplanHovered, onDuplicateSelected])
|
||||||
|
|
||||||
|
return null
|
||||||
|
})
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import type { Point2D, ZoneNode as ZoneNodeType } from '@pascal-app/core'
|
||||||
|
import { isPointInsidePolygon } from '../../lib/floorplan'
|
||||||
|
import type { WallPlanPoint } from '../tools/wall/wall-drafting'
|
||||||
|
|
||||||
|
type ModifierKeys = {
|
||||||
|
meta: boolean
|
||||||
|
ctrl: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type ZoneHitEntry = {
|
||||||
|
zone: {
|
||||||
|
id: ZoneNodeType['id']
|
||||||
|
}
|
||||||
|
polygon: Point2D[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type ResolveFloorplanBackgroundSelectionArgs = {
|
||||||
|
canSelectElementFloorplanGeometry: boolean
|
||||||
|
canSelectFloorplanZones: boolean
|
||||||
|
currentSelectedIds: string[]
|
||||||
|
getFloorplanHitIdAtPoint: (planPoint: WallPlanPoint) => string | null
|
||||||
|
isWallBuildActive: boolean
|
||||||
|
modifierKeys: ModifierKeys
|
||||||
|
planPoint: WallPlanPoint
|
||||||
|
structureLayer: string
|
||||||
|
toPoint2D: (point: WallPlanPoint) => Point2D
|
||||||
|
visibleZonePolygons: ZoneHitEntry[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FloorplanBackgroundSelectionResult =
|
||||||
|
| {
|
||||||
|
handled: true
|
||||||
|
kind: 'select-zone'
|
||||||
|
zoneId: ZoneNodeType['id']
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
handled: true
|
||||||
|
kind: 'select-elements'
|
||||||
|
selectedIds: string[]
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
handled: true
|
||||||
|
kind: 'clear-zones'
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
handled: true
|
||||||
|
kind: 'clear-elements'
|
||||||
|
preserveSelection: boolean
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
handled: false
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveFloorplanBackgroundSelection({
|
||||||
|
canSelectElementFloorplanGeometry,
|
||||||
|
canSelectFloorplanZones,
|
||||||
|
currentSelectedIds,
|
||||||
|
getFloorplanHitIdAtPoint,
|
||||||
|
isWallBuildActive,
|
||||||
|
modifierKeys,
|
||||||
|
planPoint,
|
||||||
|
structureLayer,
|
||||||
|
toPoint2D,
|
||||||
|
visibleZonePolygons,
|
||||||
|
}: ResolveFloorplanBackgroundSelectionArgs): FloorplanBackgroundSelectionResult {
|
||||||
|
if (canSelectFloorplanZones) {
|
||||||
|
const zoneHit = visibleZonePolygons.find(({ polygon }) =>
|
||||||
|
isPointInsidePolygon(toPoint2D(planPoint), polygon),
|
||||||
|
)
|
||||||
|
if (zoneHit) {
|
||||||
|
return {
|
||||||
|
handled: true,
|
||||||
|
kind: 'select-zone',
|
||||||
|
zoneId: zoneHit.zone.id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (canSelectElementFloorplanGeometry) {
|
||||||
|
const hitId = getFloorplanHitIdAtPoint(planPoint)
|
||||||
|
if (hitId) {
|
||||||
|
return {
|
||||||
|
handled: true,
|
||||||
|
kind: 'select-elements',
|
||||||
|
selectedIds:
|
||||||
|
modifierKeys.meta || modifierKeys.ctrl
|
||||||
|
? currentSelectedIds.includes(hitId)
|
||||||
|
? currentSelectedIds.filter((selectedId) => selectedId !== hitId)
|
||||||
|
: [...currentSelectedIds, hitId]
|
||||||
|
: [hitId],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isWallBuildActive) {
|
||||||
|
if (structureLayer === 'zones') {
|
||||||
|
return {
|
||||||
|
handled: true,
|
||||||
|
kind: 'clear-zones',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
handled: true,
|
||||||
|
kind: 'clear-elements',
|
||||||
|
preserveSelection: modifierKeys.meta || modifierKeys.ctrl,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { handled: false }
|
||||||
|
}
|
||||||
@@ -54,9 +54,12 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
} from 'react'
|
} from 'react'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
import { useShallow } from 'zustand/react/shallow'
|
|
||||||
import { FloorplanActionMenuLayer as Editor2dFloorplanActionMenuLayer } from '../editor-2d/floorplan-action-menu-layer'
|
import { FloorplanActionMenuLayer as Editor2dFloorplanActionMenuLayer } from '../editor-2d/floorplan-action-menu-layer'
|
||||||
import { FloorplanCursorIndicatorOverlay as Editor2dFloorplanCursorIndicatorOverlay } from '../editor-2d/floorplan-cursor-indicator-overlay'
|
import { FloorplanCursorIndicatorOverlay as Editor2dFloorplanCursorIndicatorOverlay } from '../editor-2d/floorplan-cursor-indicator-overlay'
|
||||||
|
import {
|
||||||
|
FloorplanDuplicateHotkey,
|
||||||
|
FloorplanSiteKeyHandler,
|
||||||
|
} from '../editor-2d/floorplan-hotkey-handlers'
|
||||||
import { FloorplanDraftLayer } from '../editor-2d/renderers/floorplan-draft-layer'
|
import { FloorplanDraftLayer } from '../editor-2d/renderers/floorplan-draft-layer'
|
||||||
import {
|
import {
|
||||||
FloorplanMeasurementsLayer,
|
FloorplanMeasurementsLayer,
|
||||||
@@ -73,18 +76,13 @@ import {
|
|||||||
import {
|
import {
|
||||||
buildFloorplanItemEntry,
|
buildFloorplanItemEntry,
|
||||||
buildFloorplanStairEntry as buildSharedFloorplanStairEntry,
|
buildFloorplanStairEntry as buildSharedFloorplanStairEntry,
|
||||||
collectLevelDescendants as collectSharedLevelDescendants,
|
|
||||||
getFloorplanWall as getSharedFloorplanWall,
|
getFloorplanWall as getSharedFloorplanWall,
|
||||||
type FloorplanNodeTransform as SharedFloorplanNodeTransform,
|
type FloorplanNodeTransform as SharedFloorplanNodeTransform,
|
||||||
rotatePlanVector as rotateSharedPlanVector,
|
rotatePlanVector as rotateSharedPlanVector,
|
||||||
} from '../../lib/floorplan'
|
} from '../../lib/floorplan'
|
||||||
import { sfxEmitter } from '../../lib/sfx-bus'
|
import { sfxEmitter } from '../../lib/sfx-bus'
|
||||||
import { cn } from '../../lib/utils'
|
import { cn } from '../../lib/utils'
|
||||||
import useEditor, { type FloorplanSelectionTool } from '../../store/use-editor'
|
import useEditor from '../../store/use-editor'
|
||||||
import {
|
|
||||||
getFloorplanHitNodeId,
|
|
||||||
getFloorplanSelectionIdsInBounds as getSelectionIdsInBoundsFromTool,
|
|
||||||
} from '../../lib/floorplan/selection-tool'
|
|
||||||
import { snapToHalf } from '../tools/item/placement-math'
|
import { snapToHalf } from '../tools/item/placement-math'
|
||||||
import {
|
import {
|
||||||
DEFAULT_STAIR_ATTACHMENT_SIDE,
|
DEFAULT_STAIR_ATTACHMENT_SIDE,
|
||||||
@@ -103,13 +101,12 @@ import {
|
|||||||
WALL_GRID_STEP,
|
WALL_GRID_STEP,
|
||||||
type WallPlanPoint,
|
type WallPlanPoint,
|
||||||
} from '../tools/wall/wall-drafting'
|
} from '../tools/wall/wall-drafting'
|
||||||
import { furnishTools } from '../ui/action-menu/furnish-tools'
|
|
||||||
import { tools as structureTools } from '../ui/action-menu/structure-tools'
|
|
||||||
|
|
||||||
import { PALETTE_COLORS } from '../ui/primitives/color-dot'
|
import { PALETTE_COLORS } from '../ui/primitives/color-dot'
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '../ui/primitives/popover'
|
import { resolveFloorplanBackgroundSelection } from './floorplan-background-selection'
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/primitives/tooltip'
|
import { useFloorplanBackgroundPlacement } from './use-floorplan-background-placement'
|
||||||
import { NodeActionMenu } from './node-action-menu'
|
import { useFloorplanHitTesting } from './use-floorplan-hit-testing'
|
||||||
|
import { useFloorplanSceneData } from './use-floorplan-scene-data'
|
||||||
|
|
||||||
const FALLBACK_VIEW_SIZE = 12
|
const FALLBACK_VIEW_SIZE = 12
|
||||||
const FLOORPLAN_PADDING = 2
|
const FLOORPLAN_PADDING = 2
|
||||||
@@ -129,7 +126,6 @@ const FLOORPLAN_MIN_VISIBLE_WALL_THICKNESS = 0.13
|
|||||||
const FLOORPLAN_MAX_EXTRA_THICKNESS = 0.035
|
const FLOORPLAN_MAX_EXTRA_THICKNESS = 0.035
|
||||||
const FLOORPLAN_PANEL_LAYOUT_STORAGE_KEY = 'pascal-editor-floorplan-panel-layout'
|
const FLOORPLAN_PANEL_LAYOUT_STORAGE_KEY = 'pascal-editor-floorplan-panel-layout'
|
||||||
const EMPTY_WALL_MITER_DATA = calculateLevelMiters([])
|
const EMPTY_WALL_MITER_DATA = calculateLevelMiters([])
|
||||||
const DEFAULT_BUILDING_POSITION = [0, 0, 0] as const satisfies [number, number, number]
|
|
||||||
const EDITOR_CURSOR = "url('/cursor.svg') 4 2, default"
|
const EDITOR_CURSOR = "url('/cursor.svg') 4 2, default"
|
||||||
const FLOORPLAN_CURSOR_INDICATOR_LINE_HEIGHT = 18
|
const FLOORPLAN_CURSOR_INDICATOR_LINE_HEIGHT = 18
|
||||||
const FLOORPLAN_CURSOR_BADGE_OFFSET_X = 14
|
const FLOORPLAN_CURSOR_BADGE_OFFSET_X = 14
|
||||||
@@ -5249,304 +5245,6 @@ const FloorplanPolygonHandleLayer = memo(function FloorplanPolygonHandleLayer({
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
type FloorplanSiteKeyHandlerProps = {
|
|
||||||
onRestoreGroundLevel: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const FloorplanSiteKeyHandler = memo(function FloorplanSiteKeyHandler({
|
|
||||||
onRestoreGroundLevel,
|
|
||||||
}: FloorplanSiteKeyHandlerProps) {
|
|
||||||
const isFloorplanHovered = useEditor((state) => state.isFloorplanHovered)
|
|
||||||
const phase = useEditor((state) => state.phase)
|
|
||||||
const setFloorplanSelectionTool = useEditor((state) => state.setFloorplanSelectionTool)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
|
||||||
const target = event.target as HTMLElement | null
|
|
||||||
const isEditableTarget =
|
|
||||||
target instanceof HTMLInputElement ||
|
|
||||||
target instanceof HTMLTextAreaElement ||
|
|
||||||
Boolean(target?.isContentEditable)
|
|
||||||
|
|
||||||
if (
|
|
||||||
isEditableTarget ||
|
|
||||||
!isFloorplanHovered ||
|
|
||||||
phase !== 'site' ||
|
|
||||||
event.metaKey ||
|
|
||||||
event.ctrlKey ||
|
|
||||||
event.altKey ||
|
|
||||||
event.key.toLowerCase() !== 'v'
|
|
||||||
) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setFloorplanSelectionTool('click')
|
|
||||||
onRestoreGroundLevel()
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener('keydown', handleKeyDown, true)
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('keydown', handleKeyDown, true)
|
|
||||||
}
|
|
||||||
}, [isFloorplanHovered, onRestoreGroundLevel, phase, setFloorplanSelectionTool])
|
|
||||||
|
|
||||||
return null
|
|
||||||
})
|
|
||||||
|
|
||||||
type FloorplanDuplicateHotkeyProps = {
|
|
||||||
hasDuplicatable: boolean
|
|
||||||
onDuplicateSelected: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const FloorplanDuplicateHotkey = memo(function FloorplanDuplicateHotkey({
|
|
||||||
hasDuplicatable,
|
|
||||||
onDuplicateSelected,
|
|
||||||
}: FloorplanDuplicateHotkeyProps) {
|
|
||||||
const isFloorplanHovered = useEditor((state) => state.isFloorplanHovered)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
|
||||||
if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== 'c') {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!(isFloorplanHovered && hasDuplicatable)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const target = event.target as HTMLElement | null
|
|
||||||
const isEditableTarget =
|
|
||||||
target instanceof HTMLInputElement ||
|
|
||||||
target instanceof HTMLTextAreaElement ||
|
|
||||||
Boolean(target?.isContentEditable)
|
|
||||||
|
|
||||||
if (isEditableTarget) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
event.preventDefault()
|
|
||||||
onDuplicateSelected()
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener('keydown', handleKeyDown, true)
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('keydown', handleKeyDown, true)
|
|
||||||
}
|
|
||||||
}, [hasDuplicatable, isFloorplanHovered, onDuplicateSelected])
|
|
||||||
|
|
||||||
return null
|
|
||||||
})
|
|
||||||
|
|
||||||
type FloorplanActionMenuHandler = (event: ReactMouseEvent<HTMLButtonElement>) => void
|
|
||||||
|
|
||||||
type FloorplanActionMenuEntry = {
|
|
||||||
position: SvgPoint | null
|
|
||||||
onDelete: FloorplanActionMenuHandler
|
|
||||||
onMove: FloorplanActionMenuHandler
|
|
||||||
onDuplicate?: FloorplanActionMenuHandler
|
|
||||||
}
|
|
||||||
|
|
||||||
type FloorplanActionMenuLayerProps = {
|
|
||||||
item: FloorplanActionMenuEntry
|
|
||||||
wall: FloorplanActionMenuEntry
|
|
||||||
fence: FloorplanActionMenuEntry
|
|
||||||
slab: FloorplanActionMenuEntry
|
|
||||||
ceiling: FloorplanActionMenuEntry
|
|
||||||
opening: FloorplanActionMenuEntry
|
|
||||||
stair: FloorplanActionMenuEntry
|
|
||||||
roof: FloorplanActionMenuEntry
|
|
||||||
}
|
|
||||||
|
|
||||||
const FloorplanActionMenuLayer = memo(function FloorplanActionMenuLayer({
|
|
||||||
item,
|
|
||||||
wall,
|
|
||||||
fence,
|
|
||||||
slab,
|
|
||||||
ceiling,
|
|
||||||
opening,
|
|
||||||
stair,
|
|
||||||
roof,
|
|
||||||
}: FloorplanActionMenuLayerProps) {
|
|
||||||
const isFloorplanHovered = useEditor((state) => state.isFloorplanHovered)
|
|
||||||
const movingNode = useEditor((state) => state.movingNode)
|
|
||||||
const movingFenceEndpoint = useEditor((state) => state.movingFenceEndpoint)
|
|
||||||
const curvingWall = useEditor((state) => state.curvingWall)
|
|
||||||
const curvingFence = useEditor((state) => state.curvingFence)
|
|
||||||
|
|
||||||
if (!isFloorplanHovered || movingNode || movingFenceEndpoint || curvingWall || curvingFence) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const entries: FloorplanActionMenuEntry[] = [
|
|
||||||
item,
|
|
||||||
wall,
|
|
||||||
fence,
|
|
||||||
slab,
|
|
||||||
ceiling,
|
|
||||||
opening,
|
|
||||||
stair,
|
|
||||||
roof,
|
|
||||||
]
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{entries.map((entry, index) =>
|
|
||||||
entry.position ? (
|
|
||||||
<div
|
|
||||||
className="absolute z-30"
|
|
||||||
key={index}
|
|
||||||
style={{
|
|
||||||
left: entry.position.x,
|
|
||||||
top: entry.position.y,
|
|
||||||
transform: `translate(-50%, calc(-100% - ${FLOORPLAN_ACTION_MENU_OFFSET_Y}px))`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<NodeActionMenu
|
|
||||||
onDelete={entry.onDelete}
|
|
||||||
onDuplicate={entry.onDuplicate}
|
|
||||||
onMove={entry.onMove}
|
|
||||||
onPointerDown={(event) => event.stopPropagation()}
|
|
||||||
onPointerUp={(event) => event.stopPropagation()}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null,
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
type FloorplanCursorIndicatorOverlayProps = {
|
|
||||||
cursorPosition: SvgPoint | null
|
|
||||||
cursorAnchorPosition: SvgPoint | null
|
|
||||||
floorplanSelectionTool: FloorplanSelectionTool
|
|
||||||
movingOpeningType: 'door' | 'window' | null
|
|
||||||
isPanning: boolean
|
|
||||||
cursorColor: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const FloorplanCursorIndicatorOverlay = memo(function FloorplanCursorIndicatorOverlay({
|
|
||||||
cursorPosition,
|
|
||||||
cursorAnchorPosition,
|
|
||||||
floorplanSelectionTool,
|
|
||||||
movingOpeningType,
|
|
||||||
isPanning,
|
|
||||||
cursorColor,
|
|
||||||
}: FloorplanCursorIndicatorOverlayProps) {
|
|
||||||
const mode = useEditor((state) => state.mode)
|
|
||||||
const tool = useEditor((state) => state.tool)
|
|
||||||
const structureLayer = useEditor((state) => state.structureLayer)
|
|
||||||
const catalogCategory = useEditor((state) => state.catalogCategory)
|
|
||||||
|
|
||||||
const activeFloorplanToolConfig = useMemo(() => {
|
|
||||||
if (movingOpeningType) {
|
|
||||||
return structureTools.find((entry) => entry.id === movingOpeningType) ?? null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mode !== 'build' || !tool) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tool === 'item' && catalogCategory) {
|
|
||||||
return furnishTools.find((entry) => entry.catalogCategory === catalogCategory) ?? null
|
|
||||||
}
|
|
||||||
|
|
||||||
return structureTools.find((entry) => entry.id === tool) ?? null
|
|
||||||
}, [catalogCategory, mode, movingOpeningType, tool])
|
|
||||||
|
|
||||||
const indicator = useMemo<FloorplanCursorIndicator | null>(() => {
|
|
||||||
if (activeFloorplanToolConfig) {
|
|
||||||
return { kind: 'asset', iconSrc: activeFloorplanToolConfig.iconSrc }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mode === 'select' && floorplanSelectionTool === 'marquee' && structureLayer !== 'zones') {
|
|
||||||
return { kind: 'icon', icon: 'mdi:select-drag' }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mode === 'delete') {
|
|
||||||
return { kind: 'icon', icon: 'mdi:trash-can-outline' }
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
}, [activeFloorplanToolConfig, floorplanSelectionTool, mode, structureLayer])
|
|
||||||
|
|
||||||
const position = mode === 'delete' ? cursorPosition : cursorAnchorPosition
|
|
||||||
|
|
||||||
if (!(indicator && position) || isPanning) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
aria-hidden="true"
|
|
||||||
className="pointer-events-none absolute z-20"
|
|
||||||
style={{ left: position.x, top: position.y }}
|
|
||||||
>
|
|
||||||
{mode === 'delete' ? (
|
|
||||||
<div
|
|
||||||
className="flex h-8 w-8 items-center justify-center rounded-xl border border-white/5 bg-zinc-900/95 shadow-[0_8px_16px_-4px_rgba(0,0,0,0.3),0_4px_8px_-4px_rgba(0,0,0,0.2)]"
|
|
||||||
style={{
|
|
||||||
boxShadow: `0 8px 16px -4px rgba(0,0,0,0.3), 0 4px 8px -4px rgba(0,0,0,0.2), 0 0 18px ${cursorColor}22`,
|
|
||||||
transform: `translate(${FLOORPLAN_CURSOR_BADGE_OFFSET_X}px, ${FLOORPLAN_CURSOR_BADGE_OFFSET_Y}px)`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{indicator.kind === 'asset' ? (
|
|
||||||
<img
|
|
||||||
alt=""
|
|
||||||
aria-hidden="true"
|
|
||||||
className="h-5 w-5 object-contain drop-shadow-[0_2px_4px_rgba(0,0,0,0.5)]"
|
|
||||||
src={indicator.iconSrc}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Icon
|
|
||||||
aria-hidden="true"
|
|
||||||
className="drop-shadow-[0_2px_4px_rgba(0,0,0,0.5)]"
|
|
||||||
color={cursorColor}
|
|
||||||
height={18}
|
|
||||||
icon={indicator.icon}
|
|
||||||
width={18}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
className="absolute top-0 left-1/2 w-px -translate-x-1/2 -translate-y-full"
|
|
||||||
style={{
|
|
||||||
backgroundColor: cursorColor,
|
|
||||||
boxShadow: `0 0 12px ${cursorColor}55`,
|
|
||||||
height: FLOORPLAN_CURSOR_INDICATOR_LINE_HEIGHT,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className="absolute top-0 left-1/2 flex h-8 w-8 items-center justify-center rounded-xl border border-white/5 bg-zinc-900/95 shadow-[0_8px_16px_-4px_rgba(0,0,0,0.3),0_4px_8px_-4px_rgba(0,0,0,0.2)]"
|
|
||||||
style={{
|
|
||||||
transform: `translate(-50%, calc(-100% - ${FLOORPLAN_CURSOR_INDICATOR_LINE_HEIGHT}px))`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{indicator.kind === 'asset' ? (
|
|
||||||
<img
|
|
||||||
alt=""
|
|
||||||
aria-hidden="true"
|
|
||||||
className="h-5 w-5 object-contain drop-shadow-[0_2px_4px_rgba(0,0,0,0.5)]"
|
|
||||||
src={indicator.iconSrc}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Icon
|
|
||||||
aria-hidden="true"
|
|
||||||
className="drop-shadow-[0_2px_4px_rgba(0,0,0,0.5)]"
|
|
||||||
color="white"
|
|
||||||
height={18}
|
|
||||||
icon={indicator.icon}
|
|
||||||
width={18}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
export function FloorplanPanel() {
|
export function FloorplanPanel() {
|
||||||
const viewportHostRef = useRef<HTMLDivElement>(null)
|
const viewportHostRef = useRef<HTMLDivElement>(null)
|
||||||
const svgRef = useRef<SVGSVGElement>(null)
|
const svgRef = useRef<SVGSVGElement>(null)
|
||||||
@@ -5600,204 +5298,24 @@ export function FloorplanPanel() {
|
|||||||
const tool = useEditor((state) => state.tool)
|
const tool = useEditor((state) => state.tool)
|
||||||
const deleteNode = useScene((state) => state.deleteNode)
|
const deleteNode = useScene((state) => state.deleteNode)
|
||||||
const updateNode = useScene((state) => state.updateNode)
|
const updateNode = useScene((state) => state.updateNode)
|
||||||
const levelNode = useScene((state) =>
|
const {
|
||||||
levelId ? (state.nodes[levelId] as LevelNode | undefined) : undefined,
|
buildingPosition,
|
||||||
)
|
buildingRotationY,
|
||||||
const currentBuildingId =
|
ceilings,
|
||||||
levelNode?.type === 'level' && levelNode.parentId
|
currentBuildingId,
|
||||||
? (levelNode.parentId as BuildingNode['id'])
|
fences,
|
||||||
: (buildingId as BuildingNode['id'] | null)
|
floorplanLevels,
|
||||||
const buildingRotationY = useScene((state) => {
|
levelDescendantNodes,
|
||||||
if (!currentBuildingId) return 0
|
levelGuides,
|
||||||
const node = state.nodes[currentBuildingId]
|
levelNode,
|
||||||
return node?.type === 'building' ? (node.rotation[1] ?? 0) : 0
|
openings,
|
||||||
})
|
roofs,
|
||||||
const buildingPosition = useScene((state) => {
|
site,
|
||||||
if (!currentBuildingId) {
|
slabs,
|
||||||
return DEFAULT_BUILDING_POSITION
|
walls,
|
||||||
}
|
zones,
|
||||||
|
} = useFloorplanSceneData({ buildingId, levelId })
|
||||||
const node = state.nodes[currentBuildingId]
|
|
||||||
return node?.type === 'building'
|
|
||||||
? (node.position as [number, number, number])
|
|
||||||
: DEFAULT_BUILDING_POSITION
|
|
||||||
})
|
|
||||||
const buildingRotationDeg = (buildingRotationY * 180) / Math.PI
|
const buildingRotationDeg = (buildingRotationY * 180) / Math.PI
|
||||||
const site = useScene((state) => {
|
|
||||||
for (const rootNodeId of state.rootNodeIds) {
|
|
||||||
const node = state.nodes[rootNodeId]
|
|
||||||
if (node?.type === 'site') {
|
|
||||||
return node as SiteNode
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
})
|
|
||||||
const floorplanLevels = useScene(
|
|
||||||
useShallow((state) => {
|
|
||||||
if (!currentBuildingId) {
|
|
||||||
return [] as LevelNode[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const buildingNode = state.nodes[currentBuildingId]
|
|
||||||
if (!buildingNode || buildingNode.type !== 'building') {
|
|
||||||
return [] as LevelNode[]
|
|
||||||
}
|
|
||||||
|
|
||||||
return buildingNode.children
|
|
||||||
.map((childId) => state.nodes[childId])
|
|
||||||
.filter((node): node is LevelNode => node?.type === 'level')
|
|
||||||
.sort((a, b) => a.level - b.level)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
const walls = useScene(
|
|
||||||
useShallow((state) => {
|
|
||||||
if (!levelId) {
|
|
||||||
return [] as WallNode[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextLevelNode = state.nodes[levelId]
|
|
||||||
if (!nextLevelNode || nextLevelNode.type !== 'level') {
|
|
||||||
return [] as WallNode[]
|
|
||||||
}
|
|
||||||
|
|
||||||
return nextLevelNode.children
|
|
||||||
.map((childId) => state.nodes[childId])
|
|
||||||
.filter((node): node is WallNode => node?.type === 'wall')
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
const openings = useScene(
|
|
||||||
useShallow((state) => {
|
|
||||||
if (!levelId) {
|
|
||||||
return [] as OpeningNode[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextLevelNode = state.nodes[levelId]
|
|
||||||
if (!nextLevelNode || nextLevelNode.type !== 'level') {
|
|
||||||
return [] as OpeningNode[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextWalls = nextLevelNode.children
|
|
||||||
.map((childId) => state.nodes[childId])
|
|
||||||
.filter((node): node is WallNode => node?.type === 'wall')
|
|
||||||
|
|
||||||
return nextWalls.flatMap((wall) =>
|
|
||||||
wall.children
|
|
||||||
.map((childId) => state.nodes[childId])
|
|
||||||
.filter((node): node is OpeningNode => node?.type === 'window' || node?.type === 'door'),
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
const fences = useScene(
|
|
||||||
useShallow((state) => {
|
|
||||||
if (!levelId) {
|
|
||||||
return [] as FenceNode[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextLevelNode = state.nodes[levelId]
|
|
||||||
if (!nextLevelNode || nextLevelNode.type !== 'level') {
|
|
||||||
return [] as FenceNode[]
|
|
||||||
}
|
|
||||||
|
|
||||||
return nextLevelNode.children
|
|
||||||
.map((childId) => state.nodes[childId])
|
|
||||||
.filter((node): node is FenceNode => node?.type === 'fence')
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
const roofs = useScene(
|
|
||||||
useShallow((state) => {
|
|
||||||
if (!levelId) {
|
|
||||||
return [] as RoofNode[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextLevelNode = state.nodes[levelId]
|
|
||||||
if (!nextLevelNode || nextLevelNode.type !== 'level') {
|
|
||||||
return [] as RoofNode[]
|
|
||||||
}
|
|
||||||
|
|
||||||
return nextLevelNode.children
|
|
||||||
.map((childId) => state.nodes[childId])
|
|
||||||
.filter((node): node is RoofNode => node?.type === 'roof' && node.visible !== false)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
const slabs = useScene(
|
|
||||||
useShallow((state) => {
|
|
||||||
if (!levelId) {
|
|
||||||
return [] as SlabNode[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextLevelNode = state.nodes[levelId]
|
|
||||||
if (!nextLevelNode || nextLevelNode.type !== 'level') {
|
|
||||||
return [] as SlabNode[]
|
|
||||||
}
|
|
||||||
|
|
||||||
return nextLevelNode.children
|
|
||||||
.map((childId) => state.nodes[childId])
|
|
||||||
.filter((node): node is SlabNode => node?.type === 'slab')
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
const ceilings = useScene(
|
|
||||||
useShallow((state) => {
|
|
||||||
if (!levelId) {
|
|
||||||
return [] as CeilingNode[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextLevelNode = state.nodes[levelId]
|
|
||||||
if (!nextLevelNode || nextLevelNode.type !== 'level') {
|
|
||||||
return [] as CeilingNode[]
|
|
||||||
}
|
|
||||||
|
|
||||||
return nextLevelNode.children
|
|
||||||
.map((childId) => state.nodes[childId])
|
|
||||||
.filter((node): node is CeilingNode => node?.type === 'ceiling')
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
const levelGuides = useScene(
|
|
||||||
useShallow((state) => {
|
|
||||||
if (!levelId) {
|
|
||||||
return [] as GuideNode[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextLevelNode = state.nodes[levelId]
|
|
||||||
if (!nextLevelNode || nextLevelNode.type !== 'level') {
|
|
||||||
return [] as GuideNode[]
|
|
||||||
}
|
|
||||||
|
|
||||||
return nextLevelNode.children
|
|
||||||
.map((childId) => state.nodes[childId])
|
|
||||||
.filter((node): node is GuideNode => node?.type === 'guide')
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
const zones = useScene(
|
|
||||||
useShallow((state) => {
|
|
||||||
if (!levelId) {
|
|
||||||
return [] as ZoneNodeType[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextLevelNode = state.nodes[levelId]
|
|
||||||
if (!nextLevelNode || nextLevelNode.type !== 'level') {
|
|
||||||
return [] as ZoneNodeType[]
|
|
||||||
}
|
|
||||||
|
|
||||||
return nextLevelNode.children
|
|
||||||
.map((childId) => state.nodes[childId])
|
|
||||||
.filter((node): node is ZoneNodeType => node?.type === 'zone')
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
const levelDescendantNodes = useScene(
|
|
||||||
useShallow((state) => {
|
|
||||||
if (!levelId) {
|
|
||||||
return [] as AnyNode[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextLevelNode = state.nodes[levelId]
|
|
||||||
if (!nextLevelNode || nextLevelNode.type !== 'level') {
|
|
||||||
return [] as AnyNode[]
|
|
||||||
}
|
|
||||||
|
|
||||||
return collectSharedLevelDescendants(nextLevelNode, state.nodes as Record<string, AnyNode>)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const [draftStart, setDraftStart] = useState<WallPlanPoint | null>(null)
|
const [draftStart, setDraftStart] = useState<WallPlanPoint | null>(null)
|
||||||
const [draftEnd, setDraftEnd] = useState<WallPlanPoint | null>(null)
|
const [draftEnd, setDraftEnd] = useState<WallPlanPoint | null>(null)
|
||||||
@@ -5862,7 +5380,7 @@ export function FloorplanPanel() {
|
|||||||
if (structureLayer === 'zones' && floorplanSelectionTool === 'marquee') {
|
if (structureLayer === 'zones' && floorplanSelectionTool === 'marquee') {
|
||||||
setFloorplanSelectionTool('click')
|
setFloorplanSelectionTool('click')
|
||||||
}
|
}
|
||||||
}, [floorplanSelectionTool, structureLayer])
|
}, [floorplanSelectionTool, setFloorplanSelectionTool, structureLayer])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsMacPlatform(navigator.platform.toUpperCase().includes('MAC'))
|
setIsMacPlatform(navigator.platform.toUpperCase().includes('MAC'))
|
||||||
@@ -9480,6 +8998,56 @@ export function FloorplanPanel() {
|
|||||||
},
|
},
|
||||||
[clearDraft, draftStart],
|
[clearDraft, draftStart],
|
||||||
)
|
)
|
||||||
|
const { getFloorplanHitIdAtPoint, getFloorplanSelectionIdsInBounds } = useFloorplanHitTesting({
|
||||||
|
ceilingPolygons,
|
||||||
|
displaySlabPolygons,
|
||||||
|
displayWallPolygons,
|
||||||
|
floorplanItemEntries,
|
||||||
|
floorplanOpeningHitTolerance,
|
||||||
|
floorplanRoofEntries,
|
||||||
|
floorplanStairEntries,
|
||||||
|
floorplanWallHitTolerance,
|
||||||
|
getOpeningCenterLine,
|
||||||
|
isFloorplanItemContextActive,
|
||||||
|
openingsPolygons,
|
||||||
|
phase,
|
||||||
|
toPoint2D,
|
||||||
|
})
|
||||||
|
const { handleBackgroundPlacementClick } = useFloorplanBackgroundPlacement({
|
||||||
|
activePolygonDraftPoints,
|
||||||
|
ceilingDraftPoints,
|
||||||
|
clearFencePlacementDraft,
|
||||||
|
clearRoofPlacementDraft,
|
||||||
|
emitFloorplanGridEvent,
|
||||||
|
fenceDraftStart,
|
||||||
|
fences,
|
||||||
|
findClosestWallPoint,
|
||||||
|
floorplanOpeningLocalY,
|
||||||
|
getSnappedFloorplanPoint,
|
||||||
|
handleCeilingPlacementPoint,
|
||||||
|
handleSlabPlacementPoint,
|
||||||
|
handleWallPlacementPoint,
|
||||||
|
handleZonePlacementPoint,
|
||||||
|
isCeilingBuildActive,
|
||||||
|
isFenceBuildActive,
|
||||||
|
isFloorplanGridInteractionActive,
|
||||||
|
isOpeningPlacementActive,
|
||||||
|
isPolygonBuildActive,
|
||||||
|
isRoofBuildActive,
|
||||||
|
isWallBuildActive,
|
||||||
|
isZoneBuildActive,
|
||||||
|
roofDraftStart,
|
||||||
|
setCursorPoint,
|
||||||
|
setFenceDraftEnd,
|
||||||
|
setFenceDraftStart,
|
||||||
|
setRoofDraftEnd,
|
||||||
|
setRoofDraftStart,
|
||||||
|
shiftPressed,
|
||||||
|
snapPolygonDraftPoint,
|
||||||
|
snapWallDraftPoint,
|
||||||
|
toPoint2D,
|
||||||
|
walls,
|
||||||
|
})
|
||||||
|
|
||||||
const handleBackgroundClick = useCallback(
|
const handleBackgroundClick = useCallback(
|
||||||
(event: ReactMouseEvent<SVGSVGElement>) => {
|
(event: ReactMouseEvent<SVGSVGElement>) => {
|
||||||
@@ -9492,145 +9060,41 @@ export function FloorplanPanel() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isOpeningPlacementActive) {
|
if (handleBackgroundPlacementClick(planPoint, event, draftStart)) {
|
||||||
const closest = findClosestWallPoint(planPoint, walls, {
|
|
||||||
canUseWall: (wall) => !isCurvedWall(wall),
|
|
||||||
})
|
|
||||||
if (closest) {
|
|
||||||
const dx = closest.wall.end[0] - closest.wall.start[0]
|
|
||||||
const dz = closest.wall.end[1] - closest.wall.start[1]
|
|
||||||
const length = Math.sqrt(dx * dx + dz * dz)
|
|
||||||
const distance = closest.t * length
|
|
||||||
|
|
||||||
emitter.emit('wall:click', {
|
|
||||||
node: closest.wall,
|
|
||||||
point: { x: closest.point[0], y: 0, z: closest.point[1] },
|
|
||||||
localPosition: [distance, floorplanOpeningLocalY, 0],
|
|
||||||
normal: closest.normal,
|
|
||||||
stopPropagation: () => {},
|
|
||||||
} as any)
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isCeilingBuildActive) {
|
|
||||||
emitFloorplanGridEvent('click', planPoint, event)
|
|
||||||
|
|
||||||
const snappedPoint = snapPolygonDraftPoint({
|
|
||||||
point: planPoint,
|
|
||||||
start: ceilingDraftPoints[ceilingDraftPoints.length - 1],
|
|
||||||
angleSnap: ceilingDraftPoints.length > 0 && !shiftPressed,
|
|
||||||
})
|
|
||||||
|
|
||||||
handleCeilingPlacementPoint(snappedPoint)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isRoofBuildActive) {
|
|
||||||
const snappedPoint = getSnappedFloorplanPoint(planPoint)
|
|
||||||
emitFloorplanGridEvent('click', snappedPoint, event)
|
|
||||||
setCursorPoint(snappedPoint)
|
|
||||||
|
|
||||||
if (!roofDraftStart) {
|
|
||||||
setRoofDraftStart(snappedPoint)
|
|
||||||
setRoofDraftEnd(snappedPoint)
|
|
||||||
} else {
|
|
||||||
clearRoofPlacementDraft()
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isFenceBuildActive) {
|
|
||||||
emitFloorplanGridEvent('click', planPoint, event)
|
|
||||||
|
|
||||||
const snappedPoint = snapFenceDraftPoint({
|
|
||||||
point: planPoint,
|
|
||||||
walls,
|
|
||||||
fences,
|
|
||||||
start: fenceDraftStart ?? undefined,
|
|
||||||
angleSnap: Boolean(fenceDraftStart) && !shiftPressed,
|
|
||||||
})
|
|
||||||
|
|
||||||
setCursorPoint(snappedPoint)
|
|
||||||
|
|
||||||
if (!fenceDraftStart) {
|
|
||||||
setFenceDraftStart(snappedPoint)
|
|
||||||
setFenceDraftEnd(snappedPoint)
|
|
||||||
} else if (getPlanPointDistance(toPoint2D(fenceDraftStart), toPoint2D(snappedPoint)) >= 0.01) {
|
|
||||||
clearFencePlacementDraft()
|
|
||||||
} else {
|
|
||||||
setFenceDraftEnd(snappedPoint)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isFloorplanGridInteractionActive) {
|
|
||||||
const snappedPoint = emitFloorplanGridEvent('click', planPoint, event)
|
|
||||||
setCursorPoint(snappedPoint)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isPolygonBuildActive) {
|
|
||||||
const snappedPoint = snapPolygonDraftPoint({
|
|
||||||
point: planPoint,
|
|
||||||
start: activePolygonDraftPoints[activePolygonDraftPoints.length - 1],
|
|
||||||
angleSnap: activePolygonDraftPoints.length > 0 && !shiftPressed,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (isZoneBuildActive) {
|
|
||||||
handleZonePlacementPoint(snappedPoint)
|
|
||||||
} else {
|
|
||||||
handleSlabPlacementPoint(snappedPoint)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (canSelectFloorplanZones) {
|
|
||||||
const zoneHit = visibleZonePolygons.find(({ polygon }) =>
|
|
||||||
isPointInsidePolygon(toPoint2D(planPoint), polygon),
|
|
||||||
)
|
|
||||||
if (zoneHit) {
|
|
||||||
setSelectedReferenceId(null)
|
|
||||||
setSelection({ zoneId: zoneHit.zone.id })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const modifierKeys = getSelectionModifierKeys(event)
|
const modifierKeys = getSelectionModifierKeys(event)
|
||||||
|
|
||||||
if (canSelectElementFloorplanGeometry) {
|
const backgroundSelection = resolveFloorplanBackgroundSelection({
|
||||||
const hitId = getFloorplanHitNodeId({
|
canSelectElementFloorplanGeometry,
|
||||||
point: toPoint2D(planPoint),
|
canSelectFloorplanZones,
|
||||||
ceilings: ceilingPolygons,
|
currentSelectedIds: useViewer.getState().selection.selectedIds,
|
||||||
phase,
|
getFloorplanHitIdAtPoint,
|
||||||
isItemContextActive: isFloorplanItemContextActive,
|
isWallBuildActive,
|
||||||
items: floorplanItemEntries,
|
modifierKeys,
|
||||||
openings: openingsPolygons,
|
planPoint,
|
||||||
roofs: floorplanRoofEntries,
|
structureLayer,
|
||||||
stairs: floorplanStairEntries,
|
toPoint2D,
|
||||||
walls: displayWallPolygons,
|
visibleZonePolygons,
|
||||||
slabs: displaySlabPolygons,
|
})
|
||||||
openingHitTolerance: floorplanOpeningHitTolerance,
|
|
||||||
wallHitTolerance: floorplanWallHitTolerance,
|
|
||||||
getOpeningCenterLine,
|
|
||||||
})
|
|
||||||
if (hitId) {
|
|
||||||
const currentSelectedIds = useViewer.getState().selection.selectedIds
|
|
||||||
const nextSelectedIds =
|
|
||||||
modifierKeys.meta || modifierKeys.ctrl
|
|
||||||
? currentSelectedIds.includes(hitId)
|
|
||||||
? currentSelectedIds.filter((selectedId) => selectedId !== hitId)
|
|
||||||
: [...currentSelectedIds, hitId]
|
|
||||||
: [hitId]
|
|
||||||
|
|
||||||
|
if (backgroundSelection.handled) {
|
||||||
|
setSelectedReferenceId(null)
|
||||||
|
|
||||||
|
if (backgroundSelection.kind === 'select-zone') {
|
||||||
|
setSelection({ zoneId: backgroundSelection.zoneId })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (backgroundSelection.kind === 'select-elements') {
|
||||||
if (!(levelId && levelNode) || levelNode.type !== 'level') {
|
if (!(levelId && levelNode) || levelNode.type !== 'level') {
|
||||||
setSelectedReferenceId(null)
|
setSelection({ selectedIds: backgroundSelection.selectedIds })
|
||||||
setSelection({ selectedIds: nextSelectedIds })
|
|
||||||
} else {
|
} else {
|
||||||
const { selection } = useViewer.getState()
|
const { selection } = useViewer.getState()
|
||||||
const nodes = useScene.getState().nodes
|
const nodes = useScene.getState().nodes
|
||||||
const updates: Parameters<typeof setSelection>[0] = {
|
const updates: Parameters<typeof setSelection>[0] = {
|
||||||
selectedIds: nextSelectedIds,
|
selectedIds: backgroundSelection.selectedIds,
|
||||||
}
|
}
|
||||||
|
|
||||||
if (levelId !== selection.levelId) {
|
if (levelId !== selection.levelId) {
|
||||||
@@ -9642,86 +9106,42 @@ export function FloorplanPanel() {
|
|||||||
updates.buildingId = parentNode.id
|
updates.buildingId = parentNode.id
|
||||||
}
|
}
|
||||||
|
|
||||||
setSelectedReferenceId(null)
|
|
||||||
setSelection(updates)
|
setSelection(updates)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (!isWallBuildActive) {
|
if (backgroundSelection.kind === 'clear-zones') {
|
||||||
if (structureLayer === 'zones') {
|
|
||||||
setSelectedReferenceId(null)
|
|
||||||
setSelection({ zoneId: null })
|
setSelection({ zoneId: null })
|
||||||
// Return to structure select (same as 3D grid click)
|
// Return to structure select (same as 3D grid click)
|
||||||
useEditor.getState().setStructureLayer('elements')
|
useEditor.getState().setStructureLayer('elements')
|
||||||
useEditor.getState().setMode('select')
|
useEditor.getState().setMode('select')
|
||||||
} else {
|
return
|
||||||
setSelectedReferenceId(null)
|
}
|
||||||
if (!(modifierKeys.meta || modifierKeys.ctrl)) {
|
|
||||||
setSelection({ selectedIds: [] })
|
if (!backgroundSelection.preserveSelection) {
|
||||||
}
|
setSelection({ selectedIds: [] })
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const snappedPoint = snapWallDraftPoint({
|
|
||||||
point: planPoint,
|
|
||||||
walls,
|
|
||||||
start: draftStart ?? undefined,
|
|
||||||
angleSnap: Boolean(draftStart) && !shiftPressed,
|
|
||||||
})
|
|
||||||
|
|
||||||
handleWallPlacementPoint(snappedPoint)
|
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
draftStart,
|
draftStart,
|
||||||
ceilingDraftPoints,
|
|
||||||
emitFloorplanGridEvent,
|
|
||||||
fences,
|
|
||||||
fenceDraftStart,
|
|
||||||
floorplanOpeningLocalY,
|
|
||||||
getPlanPointFromClientPoint,
|
getPlanPointFromClientPoint,
|
||||||
activePolygonDraftPoints,
|
handleBackgroundPlacementClick,
|
||||||
canSelectElementFloorplanGeometry,
|
canSelectElementFloorplanGeometry,
|
||||||
canSelectFloorplanZones,
|
canSelectFloorplanZones,
|
||||||
ceilingPolygons,
|
|
||||||
displaySlabPolygons,
|
|
||||||
displayWallPolygons,
|
|
||||||
floorplanItemEntries,
|
|
||||||
floorplanOpeningHitTolerance,
|
|
||||||
floorplanRoofEntries,
|
|
||||||
floorplanStairEntries,
|
|
||||||
floorplanWallHitTolerance,
|
|
||||||
getOpeningCenterLine,
|
|
||||||
handleCeilingPlacementPoint,
|
|
||||||
handleSlabPlacementPoint,
|
|
||||||
handleZonePlacementPoint,
|
|
||||||
handleWallPlacementPoint,
|
|
||||||
clearFencePlacementDraft,
|
|
||||||
clearRoofPlacementDraft,
|
|
||||||
isCeilingBuildActive,
|
|
||||||
isFenceBuildActive,
|
|
||||||
isFloorplanGridInteractionActive,
|
|
||||||
isFloorplanItemContextActive,
|
|
||||||
isOpeningPlacementActive,
|
|
||||||
isPolygonBuildActive,
|
isPolygonBuildActive,
|
||||||
isRoofBuildActive,
|
|
||||||
isWallBuildActive,
|
isWallBuildActive,
|
||||||
isWindowBuildActive,
|
|
||||||
isZoneBuildActive,
|
|
||||||
levelId,
|
levelId,
|
||||||
levelNode,
|
levelNode,
|
||||||
movingOpeningType,
|
|
||||||
openingsPolygons,
|
|
||||||
phase,
|
|
||||||
roofDraftStart,
|
|
||||||
setSelectedReferenceId,
|
setSelectedReferenceId,
|
||||||
setSelection,
|
setSelection,
|
||||||
shiftPressed,
|
|
||||||
structureLayer,
|
structureLayer,
|
||||||
|
getFloorplanHitIdAtPoint,
|
||||||
|
toPoint2D,
|
||||||
visibleZonePolygons,
|
visibleZonePolygons,
|
||||||
walls,
|
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
const handleBackgroundDoubleClick = useCallback(
|
const handleBackgroundDoubleClick = useCallback(
|
||||||
@@ -9835,68 +9255,6 @@ export function FloorplanPanel() {
|
|||||||
[commitFloorplanSelection],
|
[commitFloorplanSelection],
|
||||||
)
|
)
|
||||||
|
|
||||||
const getFloorplanHitIdAtPoint = useCallback(
|
|
||||||
(planPoint: WallPlanPoint) => {
|
|
||||||
const point = toPoint2D(planPoint)
|
|
||||||
return getFloorplanHitNodeId({
|
|
||||||
point,
|
|
||||||
ceilings: ceilingPolygons,
|
|
||||||
phase,
|
|
||||||
isItemContextActive: isFloorplanItemContextActive,
|
|
||||||
items: floorplanItemEntries,
|
|
||||||
openings: openingsPolygons,
|
|
||||||
roofs: floorplanRoofEntries,
|
|
||||||
stairs: floorplanStairEntries,
|
|
||||||
walls: displayWallPolygons,
|
|
||||||
slabs: displaySlabPolygons,
|
|
||||||
openingHitTolerance: floorplanOpeningHitTolerance,
|
|
||||||
wallHitTolerance: floorplanWallHitTolerance,
|
|
||||||
getOpeningCenterLine,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
[
|
|
||||||
ceilingPolygons,
|
|
||||||
displaySlabPolygons,
|
|
||||||
displayWallPolygons,
|
|
||||||
floorplanItemEntries,
|
|
||||||
floorplanOpeningHitTolerance,
|
|
||||||
floorplanRoofEntries,
|
|
||||||
floorplanStairEntries,
|
|
||||||
floorplanWallHitTolerance,
|
|
||||||
getOpeningCenterLine,
|
|
||||||
openingsPolygons,
|
|
||||||
phase,
|
|
||||||
isFloorplanItemContextActive,
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
const getFloorplanSelectionIdsInBounds = useCallback(
|
|
||||||
(bounds: FloorplanSelectionBounds) =>
|
|
||||||
getSelectionIdsInBoundsFromTool({
|
|
||||||
bounds,
|
|
||||||
ceilings: ceilingPolygons,
|
|
||||||
phase,
|
|
||||||
isItemContextActive: isFloorplanItemContextActive,
|
|
||||||
items: floorplanItemEntries,
|
|
||||||
walls: displayWallPolygons,
|
|
||||||
openings: openingsPolygons,
|
|
||||||
roofs: floorplanRoofEntries,
|
|
||||||
slabs: displaySlabPolygons,
|
|
||||||
stairs: floorplanStairEntries,
|
|
||||||
}),
|
|
||||||
[
|
|
||||||
ceilingPolygons,
|
|
||||||
displaySlabPolygons,
|
|
||||||
displayWallPolygons,
|
|
||||||
floorplanItemEntries,
|
|
||||||
floorplanRoofEntries,
|
|
||||||
floorplanStairEntries,
|
|
||||||
isFloorplanItemContextActive,
|
|
||||||
openingsPolygons,
|
|
||||||
phase,
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
const syncPreviewSelectedIds = useCallback(
|
const syncPreviewSelectedIds = useCallback(
|
||||||
(nextSelectedIds: string[]) => {
|
(nextSelectedIds: string[]) => {
|
||||||
const currentPreviewSelectedIds = useViewer.getState().previewSelectedIds
|
const currentPreviewSelectedIds = useViewer.getState().previewSelectedIds
|
||||||
|
|||||||
@@ -0,0 +1,257 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { emitter, type FenceNode, isCurvedWall, type WallNode } from '@pascal-app/core'
|
||||||
|
import { type MouseEvent as ReactMouseEvent, useCallback } from 'react'
|
||||||
|
import { getPlanPointDistance } from '../../lib/floorplan'
|
||||||
|
import { snapFenceDraftPoint } from '../tools/fence/fence-drafting'
|
||||||
|
import type { WallPlanPoint } from '../tools/wall/wall-drafting'
|
||||||
|
|
||||||
|
type UseFloorplanBackgroundPlacementArgs = {
|
||||||
|
activePolygonDraftPoints: WallPlanPoint[]
|
||||||
|
ceilingDraftPoints: WallPlanPoint[]
|
||||||
|
clearFencePlacementDraft: () => void
|
||||||
|
clearRoofPlacementDraft: () => void
|
||||||
|
emitFloorplanGridEvent: (
|
||||||
|
type: 'click' | 'double-click' | 'move',
|
||||||
|
planPoint: WallPlanPoint,
|
||||||
|
event: ReactMouseEvent<SVGSVGElement>,
|
||||||
|
) => WallPlanPoint
|
||||||
|
fenceDraftStart: WallPlanPoint | null
|
||||||
|
fences: FenceNode[]
|
||||||
|
findClosestWallPoint: (
|
||||||
|
point: WallPlanPoint,
|
||||||
|
walls: WallNode[],
|
||||||
|
options?: { canUseWall?: (wall: WallNode) => boolean },
|
||||||
|
) => {
|
||||||
|
normal: [number, number, number]
|
||||||
|
point: WallPlanPoint
|
||||||
|
t: number
|
||||||
|
wall: WallNode
|
||||||
|
} | null
|
||||||
|
floorplanOpeningLocalY: number
|
||||||
|
getSnappedFloorplanPoint: (point: WallPlanPoint) => WallPlanPoint
|
||||||
|
handleCeilingPlacementPoint: (point: WallPlanPoint) => void
|
||||||
|
handleSlabPlacementPoint: (point: WallPlanPoint) => void
|
||||||
|
handleWallPlacementPoint: (point: WallPlanPoint) => void
|
||||||
|
handleZonePlacementPoint: (point: WallPlanPoint) => void
|
||||||
|
isCeilingBuildActive: boolean
|
||||||
|
isFenceBuildActive: boolean
|
||||||
|
isFloorplanGridInteractionActive: boolean
|
||||||
|
isOpeningPlacementActive: boolean
|
||||||
|
isPolygonBuildActive: boolean
|
||||||
|
isRoofBuildActive: boolean
|
||||||
|
isWallBuildActive: boolean
|
||||||
|
isZoneBuildActive: boolean
|
||||||
|
roofDraftStart: WallPlanPoint | null
|
||||||
|
setCursorPoint: React.Dispatch<React.SetStateAction<WallPlanPoint | null>>
|
||||||
|
setFenceDraftEnd: React.Dispatch<React.SetStateAction<WallPlanPoint | null>>
|
||||||
|
setFenceDraftStart: React.Dispatch<React.SetStateAction<WallPlanPoint | null>>
|
||||||
|
setRoofDraftEnd: React.Dispatch<React.SetStateAction<WallPlanPoint | null>>
|
||||||
|
setRoofDraftStart: React.Dispatch<React.SetStateAction<WallPlanPoint | null>>
|
||||||
|
shiftPressed: boolean
|
||||||
|
snapWallDraftPoint: (args: {
|
||||||
|
point: WallPlanPoint
|
||||||
|
walls: WallNode[]
|
||||||
|
start?: WallPlanPoint
|
||||||
|
angleSnap: boolean
|
||||||
|
}) => WallPlanPoint
|
||||||
|
snapPolygonDraftPoint: (args: {
|
||||||
|
point: WallPlanPoint
|
||||||
|
start?: WallPlanPoint
|
||||||
|
angleSnap: boolean
|
||||||
|
}) => WallPlanPoint
|
||||||
|
toPoint2D: (point: WallPlanPoint) => { x: number; y: number }
|
||||||
|
walls: WallNode[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFloorplanBackgroundPlacement({
|
||||||
|
activePolygonDraftPoints,
|
||||||
|
ceilingDraftPoints,
|
||||||
|
clearFencePlacementDraft,
|
||||||
|
clearRoofPlacementDraft,
|
||||||
|
emitFloorplanGridEvent,
|
||||||
|
fenceDraftStart,
|
||||||
|
fences,
|
||||||
|
findClosestWallPoint,
|
||||||
|
floorplanOpeningLocalY,
|
||||||
|
getSnappedFloorplanPoint,
|
||||||
|
handleCeilingPlacementPoint,
|
||||||
|
handleSlabPlacementPoint,
|
||||||
|
handleWallPlacementPoint,
|
||||||
|
handleZonePlacementPoint,
|
||||||
|
isCeilingBuildActive,
|
||||||
|
isFenceBuildActive,
|
||||||
|
isFloorplanGridInteractionActive,
|
||||||
|
isOpeningPlacementActive,
|
||||||
|
isPolygonBuildActive,
|
||||||
|
isRoofBuildActive,
|
||||||
|
isWallBuildActive,
|
||||||
|
isZoneBuildActive,
|
||||||
|
roofDraftStart,
|
||||||
|
setCursorPoint,
|
||||||
|
setFenceDraftEnd,
|
||||||
|
setFenceDraftStart,
|
||||||
|
setRoofDraftEnd,
|
||||||
|
setRoofDraftStart,
|
||||||
|
shiftPressed,
|
||||||
|
snapWallDraftPoint,
|
||||||
|
snapPolygonDraftPoint,
|
||||||
|
toPoint2D,
|
||||||
|
walls,
|
||||||
|
}: UseFloorplanBackgroundPlacementArgs) {
|
||||||
|
const handleBackgroundPlacementClick = useCallback(
|
||||||
|
(
|
||||||
|
planPoint: WallPlanPoint,
|
||||||
|
event: ReactMouseEvent<SVGSVGElement>,
|
||||||
|
draftStart: WallPlanPoint | null,
|
||||||
|
) => {
|
||||||
|
if (isOpeningPlacementActive) {
|
||||||
|
const closest = findClosestWallPoint(planPoint, walls, {
|
||||||
|
canUseWall: (wall) => !isCurvedWall(wall),
|
||||||
|
})
|
||||||
|
if (closest) {
|
||||||
|
const dx = closest.wall.end[0] - closest.wall.start[0]
|
||||||
|
const dz = closest.wall.end[1] - closest.wall.start[1]
|
||||||
|
const length = Math.sqrt(dx * dx + dz * dz)
|
||||||
|
const distance = closest.t * length
|
||||||
|
|
||||||
|
emitter.emit('wall:click', {
|
||||||
|
node: closest.wall,
|
||||||
|
point: { x: closest.point[0], y: 0, z: closest.point[1] },
|
||||||
|
localPosition: [distance, floorplanOpeningLocalY, 0],
|
||||||
|
normal: closest.normal,
|
||||||
|
stopPropagation: () => {},
|
||||||
|
} as any)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCeilingBuildActive) {
|
||||||
|
emitFloorplanGridEvent('click', planPoint, event)
|
||||||
|
|
||||||
|
const snappedPoint = snapPolygonDraftPoint({
|
||||||
|
point: planPoint,
|
||||||
|
start: ceilingDraftPoints[ceilingDraftPoints.length - 1],
|
||||||
|
angleSnap: ceilingDraftPoints.length > 0 && !shiftPressed,
|
||||||
|
})
|
||||||
|
|
||||||
|
handleCeilingPlacementPoint(snappedPoint)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRoofBuildActive) {
|
||||||
|
const snappedPoint = getSnappedFloorplanPoint(planPoint)
|
||||||
|
emitFloorplanGridEvent('click', snappedPoint, event)
|
||||||
|
setCursorPoint(snappedPoint)
|
||||||
|
|
||||||
|
if (!roofDraftStart) {
|
||||||
|
setRoofDraftStart(snappedPoint)
|
||||||
|
setRoofDraftEnd(snappedPoint)
|
||||||
|
} else {
|
||||||
|
clearRoofPlacementDraft()
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isFenceBuildActive) {
|
||||||
|
emitFloorplanGridEvent('click', planPoint, event)
|
||||||
|
|
||||||
|
const snappedPoint = snapFenceDraftPoint({
|
||||||
|
point: planPoint,
|
||||||
|
walls,
|
||||||
|
fences,
|
||||||
|
start: fenceDraftStart ?? undefined,
|
||||||
|
angleSnap: Boolean(fenceDraftStart) && !shiftPressed,
|
||||||
|
})
|
||||||
|
|
||||||
|
setCursorPoint(snappedPoint)
|
||||||
|
|
||||||
|
if (!fenceDraftStart) {
|
||||||
|
setFenceDraftStart(snappedPoint)
|
||||||
|
setFenceDraftEnd(snappedPoint)
|
||||||
|
} else if (
|
||||||
|
getPlanPointDistance(toPoint2D(fenceDraftStart), toPoint2D(snappedPoint)) >= 0.01
|
||||||
|
) {
|
||||||
|
clearFencePlacementDraft()
|
||||||
|
} else {
|
||||||
|
setFenceDraftEnd(snappedPoint)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isFloorplanGridInteractionActive) {
|
||||||
|
const snappedPoint = emitFloorplanGridEvent('click', planPoint, event)
|
||||||
|
setCursorPoint(snappedPoint)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPolygonBuildActive) {
|
||||||
|
const snappedPoint = snapPolygonDraftPoint({
|
||||||
|
point: planPoint,
|
||||||
|
start: activePolygonDraftPoints[activePolygonDraftPoints.length - 1],
|
||||||
|
angleSnap: activePolygonDraftPoints.length > 0 && !shiftPressed,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (isZoneBuildActive) {
|
||||||
|
handleZonePlacementPoint(snappedPoint)
|
||||||
|
} else {
|
||||||
|
handleSlabPlacementPoint(snappedPoint)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isWallBuildActive) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const snappedPoint = snapWallDraftPoint({
|
||||||
|
point: planPoint,
|
||||||
|
walls,
|
||||||
|
start: draftStart ?? undefined,
|
||||||
|
angleSnap: Boolean(draftStart) && !shiftPressed,
|
||||||
|
})
|
||||||
|
|
||||||
|
handleWallPlacementPoint(snappedPoint)
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
[
|
||||||
|
activePolygonDraftPoints,
|
||||||
|
ceilingDraftPoints,
|
||||||
|
clearFencePlacementDraft,
|
||||||
|
clearRoofPlacementDraft,
|
||||||
|
emitFloorplanGridEvent,
|
||||||
|
fenceDraftStart,
|
||||||
|
fences,
|
||||||
|
findClosestWallPoint,
|
||||||
|
floorplanOpeningLocalY,
|
||||||
|
getSnappedFloorplanPoint,
|
||||||
|
handleCeilingPlacementPoint,
|
||||||
|
handleSlabPlacementPoint,
|
||||||
|
handleZonePlacementPoint,
|
||||||
|
isCeilingBuildActive,
|
||||||
|
isFenceBuildActive,
|
||||||
|
isFloorplanGridInteractionActive,
|
||||||
|
isOpeningPlacementActive,
|
||||||
|
isPolygonBuildActive,
|
||||||
|
isRoofBuildActive,
|
||||||
|
isWallBuildActive,
|
||||||
|
isZoneBuildActive,
|
||||||
|
roofDraftStart,
|
||||||
|
setCursorPoint,
|
||||||
|
setFenceDraftEnd,
|
||||||
|
setFenceDraftStart,
|
||||||
|
setRoofDraftEnd,
|
||||||
|
setRoofDraftStart,
|
||||||
|
shiftPressed,
|
||||||
|
snapWallDraftPoint,
|
||||||
|
snapPolygonDraftPoint,
|
||||||
|
toPoint2D,
|
||||||
|
walls,
|
||||||
|
handleWallPlacementPoint,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
handleBackgroundPlacementClick,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import type {
|
||||||
|
AnyNode,
|
||||||
|
CeilingNode,
|
||||||
|
DoorNode,
|
||||||
|
ItemNode,
|
||||||
|
Point2D,
|
||||||
|
RoofNode,
|
||||||
|
RoofSegmentNode,
|
||||||
|
SlabNode,
|
||||||
|
StairNode,
|
||||||
|
StairSegmentNode,
|
||||||
|
WallNode,
|
||||||
|
WindowNode,
|
||||||
|
} from '@pascal-app/core'
|
||||||
|
import { useCallback } from 'react'
|
||||||
|
import {
|
||||||
|
getFloorplanHitNodeId,
|
||||||
|
getFloorplanSelectionIdsInBounds,
|
||||||
|
} from '../../lib/floorplan/selection-tool'
|
||||||
|
import type { FloorplanSelectionBounds } from '../../lib/floorplan/types'
|
||||||
|
import type { WallPlanPoint } from '../tools/wall/wall-drafting'
|
||||||
|
|
||||||
|
type OpeningNode = WindowNode | DoorNode
|
||||||
|
|
||||||
|
type WallPolygonEntry = {
|
||||||
|
wall: WallNode
|
||||||
|
polygon: Point2D[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type OpeningPolygonEntry = {
|
||||||
|
opening: OpeningNode
|
||||||
|
polygon: Point2D[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type SlabPolygonEntry = {
|
||||||
|
slab: SlabNode
|
||||||
|
polygon: Point2D[]
|
||||||
|
holes: Point2D[][]
|
||||||
|
}
|
||||||
|
|
||||||
|
type CeilingPolygonEntry = {
|
||||||
|
ceiling: CeilingNode
|
||||||
|
polygon: Point2D[]
|
||||||
|
holes: Point2D[][]
|
||||||
|
}
|
||||||
|
|
||||||
|
type FloorplanRoofEntry = {
|
||||||
|
roof: RoofNode
|
||||||
|
segments: Array<{
|
||||||
|
polygon: Point2D[]
|
||||||
|
segment: RoofSegmentNode
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
type FloorplanItemEntry = {
|
||||||
|
item: ItemNode
|
||||||
|
polygon: Point2D[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type FloorplanStairSegmentEntry = {
|
||||||
|
polygon: Point2D[]
|
||||||
|
segment: StairSegmentNode | AnyNode
|
||||||
|
}
|
||||||
|
|
||||||
|
type FloorplanStairEntry = {
|
||||||
|
hitPolygons: Point2D[][]
|
||||||
|
stair: StairNode
|
||||||
|
segments: FloorplanStairSegmentEntry[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type UseFloorplanHitTestingArgs = {
|
||||||
|
ceilingPolygons: CeilingPolygonEntry[]
|
||||||
|
displaySlabPolygons: SlabPolygonEntry[]
|
||||||
|
displayWallPolygons: WallPolygonEntry[]
|
||||||
|
floorplanItemEntries: FloorplanItemEntry[]
|
||||||
|
floorplanOpeningHitTolerance: number
|
||||||
|
floorplanRoofEntries: FloorplanRoofEntry[]
|
||||||
|
floorplanStairEntries: FloorplanStairEntry[]
|
||||||
|
floorplanWallHitTolerance: number
|
||||||
|
getOpeningCenterLine: (polygon: Point2D[]) => { start: Point2D; end: Point2D } | null
|
||||||
|
isFloorplanItemContextActive: boolean
|
||||||
|
openingsPolygons: OpeningPolygonEntry[]
|
||||||
|
phase: 'site' | 'structure' | 'furnish'
|
||||||
|
toPoint2D: (point: WallPlanPoint) => Point2D
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFloorplanHitTesting({
|
||||||
|
ceilingPolygons,
|
||||||
|
displaySlabPolygons,
|
||||||
|
displayWallPolygons,
|
||||||
|
floorplanItemEntries,
|
||||||
|
floorplanOpeningHitTolerance,
|
||||||
|
floorplanRoofEntries,
|
||||||
|
floorplanStairEntries,
|
||||||
|
floorplanWallHitTolerance,
|
||||||
|
getOpeningCenterLine,
|
||||||
|
isFloorplanItemContextActive,
|
||||||
|
openingsPolygons,
|
||||||
|
phase,
|
||||||
|
toPoint2D,
|
||||||
|
}: UseFloorplanHitTestingArgs) {
|
||||||
|
const getFloorplanHitIdAtPoint = useCallback(
|
||||||
|
(planPoint: WallPlanPoint) => {
|
||||||
|
const point = toPoint2D(planPoint)
|
||||||
|
return getFloorplanHitNodeId({
|
||||||
|
point,
|
||||||
|
ceilings: ceilingPolygons,
|
||||||
|
phase,
|
||||||
|
isItemContextActive: isFloorplanItemContextActive,
|
||||||
|
items: floorplanItemEntries,
|
||||||
|
openings: openingsPolygons,
|
||||||
|
roofs: floorplanRoofEntries,
|
||||||
|
stairs: floorplanStairEntries,
|
||||||
|
walls: displayWallPolygons,
|
||||||
|
slabs: displaySlabPolygons,
|
||||||
|
openingHitTolerance: floorplanOpeningHitTolerance,
|
||||||
|
wallHitTolerance: floorplanWallHitTolerance,
|
||||||
|
getOpeningCenterLine,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[
|
||||||
|
ceilingPolygons,
|
||||||
|
displaySlabPolygons,
|
||||||
|
displayWallPolygons,
|
||||||
|
floorplanItemEntries,
|
||||||
|
floorplanOpeningHitTolerance,
|
||||||
|
floorplanRoofEntries,
|
||||||
|
floorplanStairEntries,
|
||||||
|
floorplanWallHitTolerance,
|
||||||
|
getOpeningCenterLine,
|
||||||
|
isFloorplanItemContextActive,
|
||||||
|
openingsPolygons,
|
||||||
|
phase,
|
||||||
|
toPoint2D,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
const getFloorplanSelectionIdsInBoundsForArea = useCallback(
|
||||||
|
(bounds: FloorplanSelectionBounds) =>
|
||||||
|
getFloorplanSelectionIdsInBounds({
|
||||||
|
bounds,
|
||||||
|
ceilings: ceilingPolygons,
|
||||||
|
phase,
|
||||||
|
isItemContextActive: isFloorplanItemContextActive,
|
||||||
|
items: floorplanItemEntries,
|
||||||
|
walls: displayWallPolygons,
|
||||||
|
openings: openingsPolygons,
|
||||||
|
roofs: floorplanRoofEntries,
|
||||||
|
slabs: displaySlabPolygons,
|
||||||
|
stairs: floorplanStairEntries,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
ceilingPolygons,
|
||||||
|
displaySlabPolygons,
|
||||||
|
displayWallPolygons,
|
||||||
|
floorplanItemEntries,
|
||||||
|
floorplanRoofEntries,
|
||||||
|
floorplanStairEntries,
|
||||||
|
isFloorplanItemContextActive,
|
||||||
|
openingsPolygons,
|
||||||
|
phase,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
getFloorplanHitIdAtPoint,
|
||||||
|
getFloorplanSelectionIdsInBounds: getFloorplanSelectionIdsInBoundsForArea,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import {
|
||||||
|
type AnyNode,
|
||||||
|
type BuildingNode,
|
||||||
|
type CeilingNode,
|
||||||
|
type DoorNode,
|
||||||
|
type FenceNode,
|
||||||
|
type GuideNode,
|
||||||
|
type LevelNode,
|
||||||
|
type RoofNode,
|
||||||
|
type SiteNode,
|
||||||
|
type SlabNode,
|
||||||
|
useScene,
|
||||||
|
type WallNode,
|
||||||
|
type WindowNode,
|
||||||
|
type ZoneNode as ZoneNodeType,
|
||||||
|
} from '@pascal-app/core'
|
||||||
|
import { useShallow } from 'zustand/react/shallow'
|
||||||
|
import { collectLevelDescendants } from '../../lib/floorplan'
|
||||||
|
|
||||||
|
type OpeningNode = WindowNode | DoorNode
|
||||||
|
|
||||||
|
const DEFAULT_BUILDING_POSITION = [0, 0, 0] as const satisfies [number, number, number]
|
||||||
|
|
||||||
|
function useLevelChildren<TNode extends AnyNode>(
|
||||||
|
levelId: LevelNode['id'] | null,
|
||||||
|
typeGuard: (node: AnyNode | undefined) => node is TNode,
|
||||||
|
) {
|
||||||
|
return useScene(
|
||||||
|
useShallow((state) => {
|
||||||
|
if (!levelId) {
|
||||||
|
return [] as TNode[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const levelNode = state.nodes[levelId]
|
||||||
|
if (!levelNode || levelNode.type !== 'level') {
|
||||||
|
return [] as TNode[]
|
||||||
|
}
|
||||||
|
|
||||||
|
return levelNode.children.map((childId) => state.nodes[childId]).filter(typeGuard)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFloorplanSceneData({
|
||||||
|
buildingId,
|
||||||
|
levelId,
|
||||||
|
}: {
|
||||||
|
buildingId: BuildingNode['id'] | null
|
||||||
|
levelId: LevelNode['id'] | null
|
||||||
|
}) {
|
||||||
|
const levelNode = useScene((state) =>
|
||||||
|
levelId ? (state.nodes[levelId] as LevelNode | undefined) : undefined,
|
||||||
|
)
|
||||||
|
const currentBuildingId =
|
||||||
|
levelNode?.type === 'level' && levelNode.parentId
|
||||||
|
? (levelNode.parentId as BuildingNode['id'])
|
||||||
|
: buildingId
|
||||||
|
|
||||||
|
const buildingRotationY = useScene((state) => {
|
||||||
|
if (!currentBuildingId) return 0
|
||||||
|
const node = state.nodes[currentBuildingId]
|
||||||
|
return node?.type === 'building' ? (node.rotation[1] ?? 0) : 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const buildingPosition = useScene((state) => {
|
||||||
|
if (!currentBuildingId) {
|
||||||
|
return DEFAULT_BUILDING_POSITION
|
||||||
|
}
|
||||||
|
|
||||||
|
const node = state.nodes[currentBuildingId]
|
||||||
|
return node?.type === 'building'
|
||||||
|
? (node.position as [number, number, number])
|
||||||
|
: DEFAULT_BUILDING_POSITION
|
||||||
|
})
|
||||||
|
|
||||||
|
const site = useScene((state) => {
|
||||||
|
for (const rootNodeId of state.rootNodeIds) {
|
||||||
|
const node = state.nodes[rootNodeId]
|
||||||
|
if (node?.type === 'site') {
|
||||||
|
return node as SiteNode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
|
||||||
|
const floorplanLevels = useScene(
|
||||||
|
useShallow((state) => {
|
||||||
|
if (!currentBuildingId) {
|
||||||
|
return [] as LevelNode[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildingNode = state.nodes[currentBuildingId]
|
||||||
|
if (!buildingNode || buildingNode.type !== 'building') {
|
||||||
|
return [] as LevelNode[]
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildingNode.children
|
||||||
|
.map((childId) => state.nodes[childId])
|
||||||
|
.filter((node): node is LevelNode => node?.type === 'level')
|
||||||
|
.sort((a, b) => a.level - b.level)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const walls = useLevelChildren(levelId, (node): node is WallNode => node?.type === 'wall')
|
||||||
|
const fences = useLevelChildren(levelId, (node): node is FenceNode => node?.type === 'fence')
|
||||||
|
const slabs = useLevelChildren(levelId, (node): node is SlabNode => node?.type === 'slab')
|
||||||
|
const ceilings = useLevelChildren(
|
||||||
|
levelId,
|
||||||
|
(node): node is CeilingNode => node?.type === 'ceiling',
|
||||||
|
)
|
||||||
|
const levelGuides = useLevelChildren(levelId, (node): node is GuideNode => node?.type === 'guide')
|
||||||
|
const zones = useLevelChildren(levelId, (node): node is ZoneNodeType => node?.type === 'zone')
|
||||||
|
const roofs = useScene(
|
||||||
|
useShallow((state) => {
|
||||||
|
if (!levelId) {
|
||||||
|
return [] as RoofNode[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextLevelNode = state.nodes[levelId]
|
||||||
|
if (!nextLevelNode || nextLevelNode.type !== 'level') {
|
||||||
|
return [] as RoofNode[]
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextLevelNode.children
|
||||||
|
.map((childId) => state.nodes[childId])
|
||||||
|
.filter((node): node is RoofNode => node?.type === 'roof' && node.visible !== false)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const openings = useScene(
|
||||||
|
useShallow((state) => {
|
||||||
|
if (!levelId) {
|
||||||
|
return [] as OpeningNode[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextLevelNode = state.nodes[levelId]
|
||||||
|
if (!nextLevelNode || nextLevelNode.type !== 'level') {
|
||||||
|
return [] as OpeningNode[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextWalls = nextLevelNode.children
|
||||||
|
.map((childId) => state.nodes[childId])
|
||||||
|
.filter((node): node is WallNode => node?.type === 'wall')
|
||||||
|
|
||||||
|
return nextWalls.flatMap((wall) =>
|
||||||
|
wall.children
|
||||||
|
.map((childId) => state.nodes[childId])
|
||||||
|
.filter((node): node is OpeningNode => node?.type === 'window' || node?.type === 'door'),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const levelDescendantNodes = useScene(
|
||||||
|
useShallow((state) => {
|
||||||
|
if (!levelId) {
|
||||||
|
return [] as AnyNode[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextLevelNode = state.nodes[levelId]
|
||||||
|
if (!nextLevelNode || nextLevelNode.type !== 'level') {
|
||||||
|
return [] as AnyNode[]
|
||||||
|
}
|
||||||
|
|
||||||
|
return collectLevelDescendants(nextLevelNode, state.nodes as Record<string, AnyNode>)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
buildingPosition,
|
||||||
|
buildingRotationY,
|
||||||
|
currentBuildingId,
|
||||||
|
ceilings,
|
||||||
|
fences,
|
||||||
|
floorplanLevels,
|
||||||
|
levelDescendantNodes,
|
||||||
|
levelGuides,
|
||||||
|
levelNode,
|
||||||
|
openings,
|
||||||
|
roofs,
|
||||||
|
site,
|
||||||
|
slabs,
|
||||||
|
walls,
|
||||||
|
zones,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -360,7 +360,7 @@ const ModelRenderer = ({ node }: { node: ItemNode }) => {
|
|||||||
point[1] === nextPolygon[index]?.[1],
|
point[1] === nextPolygon[index]?.[1],
|
||||||
))) &&
|
))) &&
|
||||||
((nextBounds === null &&
|
((nextBounds === null &&
|
||||||
(currentBounds === undefined || currentBounds === null || currentBounds === false)) ||
|
(currentBounds === undefined || currentBounds === null)) ||
|
||||||
(nextBounds !== null &&
|
(nextBounds !== null &&
|
||||||
Array.isArray(currentBounds?.min) &&
|
Array.isArray(currentBounds?.min) &&
|
||||||
Array.isArray(currentBounds?.max) &&
|
Array.isArray(currentBounds?.max) &&
|
||||||
|
|||||||
Reference in New Issue
Block a user