From 3ae8d6d0fa86f3415673e8dad6210960ee8000dc Mon Sep 17 00:00:00 2001 From: sudhir Date: Mon, 27 Apr 2026 12:18:27 +0530 Subject: [PATCH] Extract floorplan placement and hit-testing helpers --- .../editor-2d/floorplan-hotkey-handlers.tsx | 92 ++ .../editor/floorplan-background-selection.ts | 113 +++ .../src/components/editor/floorplan-panel.tsx | 862 +++--------------- .../use-floorplan-background-placement.ts | 257 ++++++ .../editor/use-floorplan-hit-testing.ts | 171 ++++ .../editor/use-floorplan-scene-data.ts | 186 ++++ .../renderers/item/item-renderer.tsx | 2 +- 7 files changed, 930 insertions(+), 753 deletions(-) create mode 100644 packages/editor/src/components/editor-2d/floorplan-hotkey-handlers.tsx create mode 100644 packages/editor/src/components/editor/floorplan-background-selection.ts create mode 100644 packages/editor/src/components/editor/use-floorplan-background-placement.ts create mode 100644 packages/editor/src/components/editor/use-floorplan-hit-testing.ts create mode 100644 packages/editor/src/components/editor/use-floorplan-scene-data.ts diff --git a/packages/editor/src/components/editor-2d/floorplan-hotkey-handlers.tsx b/packages/editor/src/components/editor-2d/floorplan-hotkey-handlers.tsx new file mode 100644 index 00000000..42de33ce --- /dev/null +++ b/packages/editor/src/components/editor-2d/floorplan-hotkey-handlers.tsx @@ -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 +}) diff --git a/packages/editor/src/components/editor/floorplan-background-selection.ts b/packages/editor/src/components/editor/floorplan-background-selection.ts new file mode 100644 index 00000000..ce190f80 --- /dev/null +++ b/packages/editor/src/components/editor/floorplan-background-selection.ts @@ -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 } +} diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index ff326927..a4b1aea2 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -54,9 +54,12 @@ import { useState, } from 'react' import { createPortal } from 'react-dom' -import { useShallow } from 'zustand/react/shallow' import { FloorplanActionMenuLayer as Editor2dFloorplanActionMenuLayer } from '../editor-2d/floorplan-action-menu-layer' 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 { FloorplanMeasurementsLayer, @@ -73,18 +76,13 @@ import { import { buildFloorplanItemEntry, buildFloorplanStairEntry as buildSharedFloorplanStairEntry, - collectLevelDescendants as collectSharedLevelDescendants, getFloorplanWall as getSharedFloorplanWall, type FloorplanNodeTransform as SharedFloorplanNodeTransform, rotatePlanVector as rotateSharedPlanVector, } from '../../lib/floorplan' import { sfxEmitter } from '../../lib/sfx-bus' import { cn } from '../../lib/utils' -import useEditor, { type FloorplanSelectionTool } from '../../store/use-editor' -import { - getFloorplanHitNodeId, - getFloorplanSelectionIdsInBounds as getSelectionIdsInBoundsFromTool, -} from '../../lib/floorplan/selection-tool' +import useEditor from '../../store/use-editor' import { snapToHalf } from '../tools/item/placement-math' import { DEFAULT_STAIR_ATTACHMENT_SIDE, @@ -103,13 +101,12 @@ import { WALL_GRID_STEP, type WallPlanPoint, } 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 { Popover, PopoverContent, PopoverTrigger } from '../ui/primitives/popover' -import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/primitives/tooltip' -import { NodeActionMenu } from './node-action-menu' +import { resolveFloorplanBackgroundSelection } from './floorplan-background-selection' +import { useFloorplanBackgroundPlacement } from './use-floorplan-background-placement' +import { useFloorplanHitTesting } from './use-floorplan-hit-testing' +import { useFloorplanSceneData } from './use-floorplan-scene-data' const FALLBACK_VIEW_SIZE = 12 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_PANEL_LAYOUT_STORAGE_KEY = 'pascal-editor-floorplan-panel-layout' 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 FLOORPLAN_CURSOR_INDICATOR_LINE_HEIGHT = 18 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) => 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 ? ( -
- event.stopPropagation()} - onPointerUp={(event) => event.stopPropagation()} - /> -
- ) : 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(() => { - 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 ( -