diff --git a/packages/editor/src/components/editor/use-floorplan-hit-testing.test.ts b/packages/editor/src/components/editor/use-floorplan-hit-testing.test.ts
new file mode 100644
index 0000000000..031172e97d
--- /dev/null
+++ b/packages/editor/src/components/editor/use-floorplan-hit-testing.test.ts
@@ -0,0 +1,82 @@
+import { describe, expect, test } from 'bun:test'
+import { collectRegistrySelectionIdsInBounds } from './use-floorplan-hit-testing'
+
+const identityMatrix = {
+ inverse() {
+ return this
+ },
+}
+
+function pointFactory() {
+ return {
+ x: 0,
+ y: 0,
+ matrixTransform() {
+ return pointFactoryWithPosition(this.x, this.y)
+ },
+ }
+}
+
+function pointFactoryWithPosition(x: number, y: number) {
+ return {
+ x,
+ y,
+ matrixTransform() {
+ return pointFactoryWithPosition(x, y)
+ },
+ }
+}
+
+function geometry(x: number, y: number, width: number, height: number) {
+ return {
+ getBBox: () => ({ x, y, width, height }),
+ getScreenCTM: () => identityMatrix,
+ } as unknown as SVGGraphicsElement
+}
+
+function entry(nodeId: string, children: SVGGraphicsElement[]) {
+ return {
+ dataset: { nodeId },
+ querySelectorAll: () => children,
+ } as unknown as SVGGElement
+}
+
+function scene(entries: SVGGElement[]) {
+ return {
+ getScreenCTM: () => identityMatrix,
+ ownerSVGElement: {
+ createSVGPoint: pointFactory,
+ },
+ querySelectorAll: () => entries,
+ } as unknown as SVGGElement
+}
+
+describe('collectRegistrySelectionIdsInBounds', () => {
+ test('selects rendered registry geometry and preserves candidate order', () => {
+ const renderedScene = scene([
+ entry('wall_1', [geometry(0, 0, 4, 0.2)]),
+ entry('door_1', [geometry(1, 1, 0.9, 0.9)]),
+ entry('outside_1', [geometry(20, 20, 1, 1)]),
+ ])
+
+ expect(
+ collectRegistrySelectionIdsInBounds(renderedScene, { minX: -1, minY: -1, maxX: 5, maxY: 5 }, [
+ 'door_1',
+ 'outside_1',
+ 'wall_1',
+ ]),
+ ).toEqual(['door_1', 'wall_1'])
+ })
+
+ test('ignores rendered entries outside the selectable registry candidates', () => {
+ const renderedScene = scene([entry('zone_1', [geometry(0, 0, 2, 2)])])
+
+ expect(
+ collectRegistrySelectionIdsInBounds(
+ renderedScene,
+ { minX: -1, minY: -1, maxX: 3, maxY: 3 },
+ [],
+ ),
+ ).toEqual([])
+ })
+})
diff --git a/packages/editor/src/components/editor/use-floorplan-hit-testing.ts b/packages/editor/src/components/editor/use-floorplan-hit-testing.ts
index 4e71512f89..dbd5ad69b7 100644
--- a/packages/editor/src/components/editor/use-floorplan-hit-testing.ts
+++ b/packages/editor/src/components/editor/use-floorplan-hit-testing.ts
@@ -1,195 +1,178 @@
'use client'
-import type {
- AnyNode,
- CeilingNode,
- ColumnNode,
- DoorNode,
- ElevatorNode,
- 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 RefObject, useCallback } from 'react'
import type { FloorplanSelectionBounds } from '../../lib/floorplan/types'
+import {
+ type Point2 as MarqueePoint2,
+ polygonsIntersect,
+ segmentIntersectsPolygon,
+} from '../tools/select/marquee-geometry'
+import { collectSelectableCandidateIds } from '../tools/select/select-candidates'
import type { WallPlanPoint } from '../tools/wall/wall-drafting'
-type OpeningNode = WindowNode | DoorNode
+const REGISTRY_ENTRY_SELECTOR = '.floorplan-registry-base .floorplan-registry-entry[data-node-id]'
+const REGISTRY_GEOMETRY_SELECTOR = 'path, polygon, polyline, rect, circle, line, image'
+const POINT_HIT_EPSILON = 1e-4
-type WallPolygonEntry = {
- wall: WallNode
- polygon: Point2D[]
+type FloorplanHitTestingOptions = {
+ sceneRef: RefObject
}
-type OpeningPolygonEntry = {
- opening: OpeningNode
- polygon: Point2D[]
+function boundsPolygon(bounds: FloorplanSelectionBounds): MarqueePoint2[] {
+ return [
+ [bounds.minX, bounds.minY],
+ [bounds.maxX, bounds.minY],
+ [bounds.maxX, bounds.maxY],
+ [bounds.minX, bounds.maxY],
+ ]
}
-type SlabPolygonEntry = {
- slab: SlabNode
- polygon: Point2D[]
- holes: Point2D[][]
-}
+function transformElementBoxToScene(
+ element: SVGGraphicsElement,
+ scene: SVGGElement,
+): MarqueePoint2[] | null {
+ const elementMatrix = element.getScreenCTM()
+ const sceneMatrix = scene.getScreenCTM()
+ const svg = scene.ownerSVGElement
+ if (!(elementMatrix && sceneMatrix && svg)) {
+ return null
+ }
-type CeilingPolygonEntry = {
- ceiling: CeilingNode
- polygon: Point2D[]
- holes: Point2D[][]
-}
+ let box: DOMRect
+ try {
+ box = element.getBBox()
+ } catch {
+ return null
+ }
-type ColumnPolygonEntry = {
- column: ColumnNode
- polygon: Point2D[]
+ const inverseSceneMatrix = sceneMatrix.inverse()
+ const corners: MarqueePoint2[] = [
+ [box.x, box.y],
+ [box.x + box.width, box.y],
+ [box.x + box.width, box.y + box.height],
+ [box.x, box.y + box.height],
+ ]
+ return corners.map(([x, y]) => {
+ const point = svg.createSVGPoint()
+ point.x = x
+ point.y = y
+ const screenPoint = point.matrixTransform(elementMatrix)
+ const scenePoint = screenPoint.matrixTransform(inverseSceneMatrix)
+ return [scenePoint.x, scenePoint.y] as MarqueePoint2
+ })
}
-type ElevatorPolygonEntry = {
- elevator: ElevatorNode
- polygon: Point2D[]
-}
+function elementIntersectsPlanPolygon(
+ element: SVGGraphicsElement,
+ scene: SVGGElement,
+ selectionPolygon: MarqueePoint2[],
+): boolean {
+ const polygon = transformElementBoxToScene(element, scene)
+ if (!polygon) {
+ return false
+ }
-type FloorplanRoofEntry = {
- roof: RoofNode
- segments: Array<{
- polygon: Point2D[]
- segment: RoofSegmentNode
- }>
-}
+ const [first, second, third] = polygon
+ if (!(first && second && third)) {
+ return false
+ }
-type FloorplanItemEntry = {
- item: ItemNode
- polygon: Point2D[]
-}
+ const width = Math.hypot(second[0] - first[0], second[1] - first[1])
+ const height = Math.hypot(third[0] - second[0], third[1] - second[1])
+ if (width <= POINT_HIT_EPSILON || height <= POINT_HIT_EPSILON) {
+ const end = width >= height ? second : third
+ return segmentIntersectsPolygon(first, end, selectionPolygon)
+ }
-type FloorplanStairSegmentEntry = {
- polygon: Point2D[]
- segment: StairSegmentNode | AnyNode
+ return polygonsIntersect(polygon, selectionPolygon)
}
-type FloorplanStairEntry = {
- hitPolygons: Point2D[][]
- stair: StairNode
- segments: FloorplanStairSegmentEntry[]
+export function collectRegistrySelectionIdsInBounds(
+ scene: SVGGElement,
+ bounds: FloorplanSelectionBounds,
+ candidateIds = collectSelectableCandidateIds(),
+): string[] {
+ const candidateIdSet = new Set(candidateIds)
+ const selectionPolygon = boundsPolygon(bounds)
+ const hitIds = new Set()
+
+ for (const entry of scene.querySelectorAll(REGISTRY_ENTRY_SELECTOR)) {
+ const nodeId = entry.dataset.nodeId
+ if (!nodeId || !candidateIdSet.has(nodeId)) {
+ continue
+ }
+
+ const geometry = entry.querySelectorAll(REGISTRY_GEOMETRY_SELECTOR)
+ const elements = geometry.length > 0 ? Array.from(geometry) : [entry]
+ if (
+ elements.some((element) => elementIntersectsPlanPolygon(element, scene, selectionPolygon))
+ ) {
+ hitIds.add(nodeId)
+ }
+ }
+
+ return candidateIds.filter((id) => hitIds.has(id))
}
-type UseFloorplanHitTestingArgs = {
- ceilingPolygons: CeilingPolygonEntry[]
- columnPolygons: ColumnPolygonEntry[]
- displaySlabPolygons: SlabPolygonEntry[]
- displayWallPolygons: WallPolygonEntry[]
- floorplanElevatorEntries: ElevatorPolygonEntry[]
- floorplanItemEntries: FloorplanItemEntry[]
- getFloorplanOpeningHitTolerance: () => number
- floorplanRoofEntries: FloorplanRoofEntry[]
- floorplanStairEntries: FloorplanStairEntry[]
- getFloorplanWallHitTolerance: () => number
- getOpeningCenterLine: (polygon: Point2D[]) => { start: Point2D; end: Point2D } | null
- isFloorplanItemContextActive: boolean
- openingsPolygons: OpeningPolygonEntry[]
- phase: 'site' | 'structure' | 'furnish'
- toPoint2D: (point: WallPlanPoint) => Point2D
+export function getRegistryHitIdAtPlanPoint(
+ scene: SVGGElement,
+ planPoint: WallPlanPoint,
+ candidateIds = collectSelectableCandidateIds(),
+): string | null {
+ const sceneMatrix = scene.getScreenCTM()
+ const svg = scene.ownerSVGElement
+ if (!(sceneMatrix && svg)) {
+ return null
+ }
+
+ const point = svg.createSVGPoint()
+ point.x = planPoint[0]
+ point.y = planPoint[1]
+ const screenPoint = point.matrixTransform(sceneMatrix)
+ const candidateIdSet = new Set(candidateIds)
+
+ if (typeof document !== 'undefined' && typeof document.elementsFromPoint === 'function') {
+ for (const element of document.elementsFromPoint(screenPoint.x, screenPoint.y)) {
+ const entry = element.closest('.floorplan-registry-entry[data-node-id]')
+ const nodeId = entry?.dataset.nodeId
+ if (entry && nodeId && candidateIdSet.has(nodeId) && scene.contains(entry)) {
+ return nodeId
+ }
+ }
+ }
+
+ const hits = collectRegistrySelectionIdsInBounds(
+ scene,
+ {
+ minX: planPoint[0] - POINT_HIT_EPSILON,
+ maxX: planPoint[0] + POINT_HIT_EPSILON,
+ minY: planPoint[1] - POINT_HIT_EPSILON,
+ maxY: planPoint[1] + POINT_HIT_EPSILON,
+ },
+ candidateIds,
+ )
+ return hits.at(-1) ?? null
}
-export function useFloorplanHitTesting({
- ceilingPolygons,
- columnPolygons,
- displaySlabPolygons,
- displayWallPolygons,
- floorplanElevatorEntries,
- floorplanItemEntries,
- getFloorplanOpeningHitTolerance,
- floorplanRoofEntries,
- floorplanStairEntries,
- getFloorplanWallHitTolerance,
- getOpeningCenterLine,
- isFloorplanItemContextActive,
- openingsPolygons,
- phase,
- toPoint2D,
-}: UseFloorplanHitTestingArgs) {
+export function useFloorplanHitTesting({ sceneRef }: FloorplanHitTestingOptions) {
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,
- elevators: floorplanElevatorEntries,
- walls: displayWallPolygons,
- slabs: displaySlabPolygons,
- openingHitTolerance: getFloorplanOpeningHitTolerance(),
- wallHitTolerance: getFloorplanWallHitTolerance(),
- columns: columnPolygons,
- getOpeningCenterLine,
- })
+ const scene = sceneRef.current
+ return scene ? getRegistryHitIdAtPlanPoint(scene, planPoint) : null
},
- [
- ceilingPolygons,
- columnPolygons,
- displaySlabPolygons,
- displayWallPolygons,
- floorplanItemEntries,
- floorplanElevatorEntries,
- floorplanRoofEntries,
- floorplanStairEntries,
- getFloorplanOpeningHitTolerance,
- getFloorplanWallHitTolerance,
- getOpeningCenterLine,
- isFloorplanItemContextActive,
- openingsPolygons,
- phase,
- toPoint2D,
- ],
+ [sceneRef],
)
- const getFloorplanSelectionIdsInBoundsForArea = useCallback(
- (bounds: FloorplanSelectionBounds) =>
- getFloorplanSelectionIdsInBounds({
- bounds,
- ceilings: ceilingPolygons,
- phase,
- isItemContextActive: isFloorplanItemContextActive,
- items: floorplanItemEntries,
- walls: displayWallPolygons,
- openings: openingsPolygons,
- roofs: floorplanRoofEntries,
- slabs: displaySlabPolygons,
- columns: columnPolygons,
- elevators: floorplanElevatorEntries,
- stairs: floorplanStairEntries,
- }),
- [
- ceilingPolygons,
- columnPolygons,
- displaySlabPolygons,
- displayWallPolygons,
- floorplanItemEntries,
- floorplanElevatorEntries,
- floorplanRoofEntries,
- floorplanStairEntries,
- isFloorplanItemContextActive,
- openingsPolygons,
- phase,
- ],
+ const getFloorplanSelectionIdsInBounds = useCallback(
+ (bounds: FloorplanSelectionBounds) => {
+ const scene = sceneRef.current
+ return scene ? collectRegistrySelectionIdsInBounds(scene, bounds) : []
+ },
+ [sceneRef],
)
return {
getFloorplanHitIdAtPoint,
- getFloorplanSelectionIdsInBounds: getFloorplanSelectionIdsInBoundsForArea,
+ getFloorplanSelectionIdsInBounds,
}
}
diff --git a/packages/editor/src/components/ui/action-menu/measurement-control.tsx b/packages/editor/src/components/ui/action-menu/measurement-control.tsx
index d383868ef1..b98d19a8a6 100644
--- a/packages/editor/src/components/ui/action-menu/measurement-control.tsx
+++ b/packages/editor/src/components/ui/action-menu/measurement-control.tsx
@@ -22,6 +22,7 @@ import { useState } from 'react'
import type { CreatableMeasurementKind } from '../../../lib/measurement-kind'
import { cn } from '../../../lib/utils'
import useEditor from '../../../store/use-editor'
+import useFloorplanMode from '../../../store/use-floorplan-mode'
import { Popover, PopoverContent, PopoverTrigger } from '../primitives/popover'
import { ActionButton } from './action-button'
@@ -63,6 +64,7 @@ export function MeasurementControl() {
const [isOpen, setIsOpen] = useState(false)
const mode = useEditor((state) => state.mode)
const tool = useEditor((state) => state.tool)
+ const floorplanMode = useFloorplanMode((state) => state.mode)
const selectedKind = useEditor((state) => state.lastMeasurementKind)
const activeToolKind = useEditor((state) => state.toolDefaults.measurement?.kind)
const constructionDimensionChainMode = useEditor(
@@ -130,6 +132,10 @@ export function MeasurementControl() {
dimensionMode: ConstructionDimensionMode,
chainMode: ConstructionDimensionChainMode,
) => {
+ if (useFloorplanMode.getState().mode !== 'expert') {
+ useFloorplanMode.getState().showExpertModeNotice('Construction Dimension')
+ return
+ }
setPhase('structure')
setStructureLayer('elements')
setViewMode('2d')
@@ -218,38 +224,42 @@ export function MeasurementControl() {
)
})}
-
-
- Floor plan
-
+ {floorplanMode === 'expert' ? (
+ <>
+
+
+ Floor plan
+
- {constructionDimensionOptions.map((option) => {
- const OptionIcon = option.icon
- const isSelected =
- isConstructionDimensionActive && activeConstructionDimensionOption === option
- return (
-
- )
- })}
+ {constructionDimensionOptions.map((option) => {
+ const OptionIcon = option.icon
+ const isSelected =
+ isConstructionDimensionActive && activeConstructionDimensionOption === option
+ return (
+
+ )
+ })}
+ >
+ ) : null}
diff --git a/packages/editor/src/components/ui/sidebar/panels/settings-panel/index.tsx b/packages/editor/src/components/ui/sidebar/panels/settings-panel/index.tsx
index 1cc4867140..e1ad6a8c63 100644
--- a/packages/editor/src/components/ui/sidebar/panels/settings-panel/index.tsx
+++ b/packages/editor/src/components/ui/sidebar/panels/settings-panel/index.tsx
@@ -25,6 +25,7 @@ import {
} from './../../../../../components/ui/primitives/dialog'
import { Switch } from './../../../../../components/ui/primitives/switch'
import useEditor, { selectDefaultBuildingAndLevel } from './../../../../../store/use-editor'
+import useFloorplanMode from './../../../../../store/use-floorplan-mode'
import { AudioSettingsDialog } from './audio-settings-dialog'
import { KeyboardShortcutsDialog } from './keyboard-shortcuts-dialog'
import { LoadBuildDialog, type PendingImport } from './load-build-dialog'
@@ -192,6 +193,7 @@ export function SettingsPanel({
const exportScene = useViewer((state) => state.exportScene)
const shadows = useViewer((state) => state.shadows)
const setPhase = useEditor((state) => state.setPhase)
+ const floorplanMode = useFloorplanMode((state) => state.mode)
const [isGeneratingThumbnail, setIsGeneratingThumbnail] = useState(false)
const [pendingImport, setPendingImport] = useState