@pascal-app/editor 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/package.json +8 -7
  2. package/src/components/editor/editor-layout-v2.tsx +9 -0
  3. package/src/components/editor/floating-action-menu.tsx +341 -48
  4. package/src/components/editor/floating-building-action-menu.tsx +70 -0
  5. package/src/components/editor/floorplan-panel.tsx +1350 -722
  6. package/src/components/editor/index.tsx +221 -167
  7. package/src/components/editor/node-action-menu.tsx +40 -11
  8. package/src/components/editor/selection-manager.tsx +238 -10
  9. package/src/components/editor/site-edge-labels.tsx +9 -3
  10. package/src/components/editor/thumbnail-generator.tsx +422 -79
  11. package/src/components/editor/wall-measurement-label.tsx +120 -32
  12. package/src/components/systems/ceiling/ceiling-selection-affordance-system.tsx +272 -0
  13. package/src/components/systems/roof/roof-edit-system.tsx +5 -5
  14. package/src/components/systems/stair/stair-edit-system.tsx +27 -5
  15. package/src/components/tools/building/move-building-tool.tsx +157 -0
  16. package/src/components/tools/ceiling/ceiling-hole-editor.tsx +1 -0
  17. package/src/components/tools/ceiling/move-ceiling-tool.tsx +257 -0
  18. package/src/components/tools/door/door-math.ts +1 -1
  19. package/src/components/tools/door/door-tool.tsx +31 -7
  20. package/src/components/tools/door/move-door-tool.tsx +27 -8
  21. package/src/components/tools/fence/curve-fence-tool.tsx +179 -0
  22. package/src/components/tools/fence/fence-drafting.ts +137 -0
  23. package/src/components/tools/fence/fence-tool.tsx +190 -0
  24. package/src/components/tools/fence/move-fence-endpoint-tool.tsx +327 -0
  25. package/src/components/tools/fence/move-fence-tool.tsx +231 -0
  26. package/src/components/tools/item/item-tool.tsx +3 -3
  27. package/src/components/tools/item/move-tool.tsx +16 -0
  28. package/src/components/tools/item/placement-math.ts +14 -6
  29. package/src/components/tools/item/placement-strategies.ts +17 -9
  30. package/src/components/tools/item/use-placement-coordinator.tsx +123 -16
  31. package/src/components/tools/roof/move-roof-tool.tsx +90 -26
  32. package/src/components/tools/roof/roof-tool.tsx +6 -6
  33. package/src/components/tools/select/box-select-tool.tsx +2 -2
  34. package/src/components/tools/shared/polygon-editor.tsx +98 -8
  35. package/src/components/tools/slab/move-slab-tool.tsx +182 -0
  36. package/src/components/tools/slab/slab-hole-editor.tsx +1 -0
  37. package/src/components/tools/slab/slab-tool.tsx +4 -4
  38. package/src/components/tools/stair/stair-defaults.ts +10 -0
  39. package/src/components/tools/stair/stair-tool.tsx +39 -8
  40. package/src/components/tools/tool-manager.tsx +54 -14
  41. package/src/components/tools/wall/curve-wall-tool.tsx +176 -0
  42. package/src/components/tools/wall/move-wall-endpoint-tool.tsx +322 -0
  43. package/src/components/tools/wall/move-wall-tool.tsx +356 -0
  44. package/src/components/tools/wall/wall-drafting.ts +331 -9
  45. package/src/components/tools/wall/wall-tool.tsx +19 -29
  46. package/src/components/tools/window/move-window-tool.tsx +27 -8
  47. package/src/components/tools/window/window-math.ts +1 -1
  48. package/src/components/tools/window/window-tool.tsx +31 -7
  49. package/src/components/tools/zone/zone-tool.tsx +7 -7
  50. package/src/components/ui/action-menu/control-modes.tsx +9 -4
  51. package/src/components/ui/action-menu/structure-tools.tsx +1 -0
  52. package/src/components/ui/command-palette/editor-commands.tsx +9 -4
  53. package/src/components/ui/command-palette/index.tsx +0 -1
  54. package/src/components/ui/controls/material-picker.tsx +127 -94
  55. package/src/components/ui/controls/slider-control.tsx +28 -14
  56. package/src/components/ui/helpers/building-helper.tsx +32 -0
  57. package/src/components/ui/helpers/helper-manager.tsx +2 -0
  58. package/src/components/ui/item-catalog/catalog-items.tsx +5 -0
  59. package/src/components/ui/panels/ceiling-panel.tsx +61 -17
  60. package/src/components/ui/panels/door-panel.tsx +5 -5
  61. package/src/components/ui/panels/fence-panel.tsx +269 -0
  62. package/src/components/ui/panels/item-panel.tsx +5 -5
  63. package/src/components/ui/panels/panel-manager.tsx +32 -27
  64. package/src/components/ui/panels/reference-panel.tsx +5 -4
  65. package/src/components/ui/panels/roof-panel.tsx +91 -22
  66. package/src/components/ui/panels/roof-segment-panel.tsx +23 -13
  67. package/src/components/ui/panels/slab-panel.tsx +63 -15
  68. package/src/components/ui/panels/stair-panel.tsx +377 -50
  69. package/src/components/ui/panels/stair-segment-panel.tsx +28 -17
  70. package/src/components/ui/panels/wall-panel.tsx +159 -11
  71. package/src/components/ui/panels/window-panel.tsx +5 -7
  72. package/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +28 -17
  73. package/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +65 -53
  74. package/src/components/ui/sidebar/panels/site-panel/door-tree-node.tsx +40 -25
  75. package/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx +69 -0
  76. package/src/components/ui/sidebar/panels/site-panel/index.tsx +88 -72
  77. package/src/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx +14 -13
  78. package/src/components/ui/sidebar/panels/site-panel/item-tree-node.tsx +64 -53
  79. package/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +32 -23
  80. package/src/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx +72 -51
  81. package/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +40 -37
  82. package/src/components/ui/sidebar/panels/site-panel/stair-tree-node.tsx +72 -51
  83. package/src/components/ui/sidebar/panels/site-panel/tree-node-actions.tsx +13 -13
  84. package/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +20 -17
  85. package/src/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx +62 -54
  86. package/src/components/ui/sidebar/panels/site-panel/window-tree-node.tsx +40 -25
  87. package/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +27 -28
  88. package/src/components/ui/viewer-toolbar.tsx +55 -2
  89. package/src/components/viewer-overlay.tsx +26 -19
  90. package/src/hooks/use-auto-save.ts +3 -6
  91. package/src/hooks/use-contextual-tools.ts +25 -16
  92. package/src/hooks/use-grid-events.ts +13 -1
  93. package/src/hooks/use-keyboard.ts +7 -2
  94. package/src/index.tsx +2 -1
  95. package/src/lib/history.ts +20 -0
  96. package/src/lib/sfx-player.ts +96 -13
  97. package/src/store/use-editor.tsx +125 -10
@@ -5,17 +5,23 @@ import {
5
5
  type AnyNode,
6
6
  type AnyNodeId,
7
7
  type BuildingNode,
8
+ type CeilingNode,
8
9
  calculateLevelMiters,
9
10
  DoorNode,
10
11
  emitter,
11
12
  type GridEvent,
12
13
  type GuideNode,
13
14
  getScaledDimensions,
15
+ getWallChordFrame,
16
+ getWallCurveLength,
17
+ getWallMidpointHandlePoint,
14
18
  getWallPlanFootprint,
15
19
  type ItemNode,
16
20
  ItemNode as ItemNodeSchema,
21
+ isCurvedWall,
17
22
  type LevelNode,
18
23
  loadAssetUrl,
24
+ normalizeWallCurveOffset,
19
25
  type Point2D,
20
26
  type SiteNode,
21
27
  SlabNode,
@@ -46,7 +52,7 @@ import { createPortal } from 'react-dom'
46
52
  import { useShallow } from 'zustand/react/shallow'
47
53
  import { sfxEmitter } from '../../lib/sfx-bus'
48
54
  import { cn } from '../../lib/utils'
49
- import useEditor from '../../store/use-editor'
55
+ import useEditor, { type FloorplanSelectionTool } from '../../store/use-editor'
50
56
  import { snapToHalf } from '../tools/item/placement-math'
51
57
  import {
52
58
  DEFAULT_STAIR_ATTACHMENT_SIDE,
@@ -153,6 +159,12 @@ type FloorplanViewport = {
153
159
  width: number
154
160
  }
155
161
 
162
+ function floorplanViewportEquals(a: FloorplanViewport | null, b: FloorplanViewport | null) {
163
+ if (a === b) return true
164
+ if (!(a && b)) return false
165
+ return a.centerX === b.centerX && a.centerY === b.centerY && a.width === b.width
166
+ }
167
+
156
168
  type SvgPoint = {
157
169
  x: number
158
170
  y: number
@@ -235,6 +247,12 @@ type WallEndpointDragState = {
235
247
  currentPoint: WallPlanPoint
236
248
  }
237
249
 
250
+ type WallCurveDragState = {
251
+ pointerId: number
252
+ wallId: WallNode['id']
253
+ currentCurveOffset: number
254
+ }
255
+
238
256
  const GUIDE_CORNERS = ['nw', 'ne', 'se', 'sw'] as const
239
257
 
240
258
  type GuideCorner = (typeof GUIDE_CORNERS)[number]
@@ -276,6 +294,11 @@ type WallEndpointDraft = {
276
294
  end: WallPlanPoint
277
295
  }
278
296
 
297
+ type WallCurveDraft = {
298
+ wallId: WallNode['id']
299
+ curveOffset: number
300
+ }
301
+
279
302
  type SlabBoundaryDraft = {
280
303
  slabId: SlabNode['id']
281
304
  polygon: WallPlanPoint[]
@@ -2176,7 +2199,7 @@ function getWallMeasurementOverlay(
2176
2199
  ): WallMeasurementOverlay | null {
2177
2200
  const dx = wall.end[0] - wall.start[0]
2178
2201
  const dz = wall.end[1] - wall.start[1]
2179
- const length = Math.hypot(dx, dz)
2202
+ const length = getWallCurveLength(wall)
2180
2203
 
2181
2204
  if (length < 0.1) {
2182
2205
  return null
@@ -2425,13 +2448,19 @@ function buildGridPath(
2425
2448
  function findClosestWallPoint(
2426
2449
  point: WallPlanPoint,
2427
2450
  walls: WallNode[],
2428
- maxDistance = 0.5,
2451
+ options?: {
2452
+ maxDistance?: number
2453
+ canUseWall?: (wall: WallNode) => boolean
2454
+ },
2429
2455
  ): {
2430
2456
  wall: WallNode
2431
2457
  point: WallPlanPoint
2432
2458
  t: number
2433
2459
  normal: [number, number, number]
2434
2460
  } | null {
2461
+ const maxDistance = options?.maxDistance ?? 0.5
2462
+ const canUseWall = options?.canUseWall
2463
+
2435
2464
  let best: {
2436
2465
  wall: WallNode
2437
2466
  point: WallPlanPoint
@@ -2441,6 +2470,10 @@ function findClosestWallPoint(
2441
2470
  let bestDistSq = maxDistance * maxDistance
2442
2471
 
2443
2472
  for (const wall of walls) {
2473
+ if (canUseWall && !canUseWall(wall)) {
2474
+ continue
2475
+ }
2476
+
2444
2477
  const [x1, z1] = wall.start
2445
2478
  const [x2, z2] = wall.end
2446
2479
  const dx = x2 - x1
@@ -4351,6 +4384,97 @@ const FloorplanWallEndpointLayer = memo(function FloorplanWallEndpointLayer({
4351
4384
  )
4352
4385
  })
4353
4386
 
4387
+ const FloorplanWallCurveHandleLayer = memo(function FloorplanWallCurveHandleLayer({
4388
+ curveHandles,
4389
+ hoveredHandleId,
4390
+ onHandleHoverChange,
4391
+ onWallCurvePointerDown,
4392
+ palette,
4393
+ }: {
4394
+ curveHandles: Array<{
4395
+ wall: WallNode
4396
+ point: WallPlanPoint
4397
+ isActive: boolean
4398
+ }>
4399
+ hoveredHandleId: string | null
4400
+ onHandleHoverChange: (handleId: string | null) => void
4401
+ onWallCurvePointerDown: (wall: WallNode, event: ReactPointerEvent<SVGCircleElement>) => void
4402
+ palette: FloorplanPalette
4403
+ }) {
4404
+ return (
4405
+ <>
4406
+ {curveHandles.map(({ wall, point, isActive }) => {
4407
+ const handleId = `curve:${wall.id}`
4408
+ const isHovered = hoveredHandleId === handleId
4409
+ const stroke = isActive ? palette.endpointHandleActiveStroke : palette.endpointHandleStroke
4410
+ const hoverStroke = isActive
4411
+ ? palette.endpointHandleActiveStroke
4412
+ : palette.endpointHandleHoverStroke
4413
+ const svgPoint = toSvgPlanPoint(point)
4414
+ const radius = isActive ? 0.16 : 0.14
4415
+
4416
+ return (
4417
+ <g
4418
+ key={handleId}
4419
+ onClick={(event) => {
4420
+ event.stopPropagation()
4421
+ }}
4422
+ onPointerEnter={() => onHandleHoverChange(handleId)}
4423
+ onPointerLeave={() => onHandleHoverChange(null)}
4424
+ >
4425
+ <circle
4426
+ cx={svgPoint.x}
4427
+ cy={svgPoint.y}
4428
+ fill="none"
4429
+ pointerEvents="none"
4430
+ r={radius}
4431
+ stroke={hoverStroke}
4432
+ strokeOpacity={isActive ? 0.24 : 0.16}
4433
+ strokeWidth={FLOORPLAN_ENDPOINT_HOVER_GLOW_STROKE_WIDTH}
4434
+ style={{
4435
+ opacity: isHovered ? 1 : 0,
4436
+ transition: FLOORPLAN_HOVER_TRANSITION,
4437
+ }}
4438
+ vectorEffect="non-scaling-stroke"
4439
+ />
4440
+ <circle
4441
+ cx={svgPoint.x}
4442
+ cy={svgPoint.y}
4443
+ fill={isActive ? palette.endpointHandleActiveFill : palette.endpointHandleFill}
4444
+ fillOpacity={0.96}
4445
+ pointerEvents="none"
4446
+ r={radius}
4447
+ stroke={stroke}
4448
+ strokeWidth="0.05"
4449
+ vectorEffect="non-scaling-stroke"
4450
+ />
4451
+ <circle
4452
+ cx={svgPoint.x}
4453
+ cy={svgPoint.y}
4454
+ fill={stroke}
4455
+ pointerEvents="none"
4456
+ r={0.045}
4457
+ vectorEffect="non-scaling-stroke"
4458
+ />
4459
+ <circle
4460
+ cx={svgPoint.x}
4461
+ cy={svgPoint.y}
4462
+ fill="transparent"
4463
+ onPointerDown={(event) => onWallCurvePointerDown(wall, event)}
4464
+ pointerEvents="all"
4465
+ r={radius}
4466
+ stroke="transparent"
4467
+ strokeWidth={FLOORPLAN_ENDPOINT_HIT_STROKE_WIDTH}
4468
+ style={{ cursor: EDITOR_CURSOR }}
4469
+ vectorEffect="non-scaling-stroke"
4470
+ />
4471
+ </g>
4472
+ )
4473
+ })}
4474
+ </>
4475
+ )
4476
+ })
4477
+
4354
4478
  const FloorplanPolygonHandleLayer = memo(function FloorplanPolygonHandleLayer({
4355
4479
  hoveredHandleId,
4356
4480
  midpointHandles,
@@ -4537,145 +4661,451 @@ const FloorplanPolygonHandleLayer = memo(function FloorplanPolygonHandleLayer({
4537
4661
  )
4538
4662
  })
4539
4663
 
4540
- export function FloorplanPanel() {
4541
- const viewportHostRef = useRef<HTMLDivElement>(null)
4542
- const svgRef = useRef<SVGSVGElement>(null)
4543
- const panStateRef = useRef<PanState | null>(null)
4544
- const guideInteractionRef = useRef<GuideInteractionState | null>(null)
4545
- const guideTransformDraftRef = useRef<GuideTransformDraft | null>(null)
4546
- const wallEndpointDragRef = useRef<WallEndpointDragState | null>(null)
4547
- const siteBoundaryDraftRef = useRef<SiteBoundaryDraft | null>(null)
4548
- const slabBoundaryDraftRef = useRef<SlabBoundaryDraft | null>(null)
4549
- const zoneBoundaryDraftRef = useRef<ZoneBoundaryDraft | null>(null)
4550
- const gestureScaleRef = useRef(1)
4551
- const panelInteractionRef = useRef<PanelInteractionState | null>(null)
4552
- const panelBoundsRef = useRef<ViewportBounds | null>(null)
4553
- const containerRef = useRef<HTMLDivElement>(null)
4554
- const hasUserAdjustedViewportRef = useRef(false)
4555
- const previousLevelIdRef = useRef<string | null>(null)
4556
- const floorplanMarqueeSnapPointRef = useRef<WallPlanPoint | null>(null)
4557
- const levelId = useViewer((state) => state.selection.levelId)
4558
- const buildingId = useViewer((state) => state.selection.buildingId)
4559
- const selectedZoneId = useViewer((state) => state.selection.zoneId)
4560
- const selectedIds = useViewer((state) => state.selection.selectedIds)
4561
- const previewSelectedIds = useViewer((state) => state.previewSelectedIds)
4562
- const setSelection = useViewer((state) => state.setSelection)
4563
- const setPreviewSelectedIds = useViewer((state) => state.setPreviewSelectedIds)
4564
- const theme = useViewer((state) => state.theme)
4565
- const unit = useViewer((state) => state.unit)
4566
- const showGrid = useViewer((state) => state.showGrid)
4567
- const showGuides = useViewer((state) => state.showGuides)
4568
- const setShowGuides = useViewer((state) => state.setShowGuides)
4569
- const catalogCategory = useEditor((state) => state.catalogCategory)
4570
- const setCatalogCategory = useEditor((state) => state.setCatalogCategory)
4571
- const selectedItem = useEditor((state) => state.selectedItem)
4664
+ type FloorplanSiteKeyHandlerProps = {
4665
+ onRestoreGroundLevel: () => void
4666
+ }
4572
4667
 
4668
+ const FloorplanSiteKeyHandler = memo(function FloorplanSiteKeyHandler({
4669
+ onRestoreGroundLevel,
4670
+ }: FloorplanSiteKeyHandlerProps) {
4573
4671
  const isFloorplanHovered = useEditor((state) => state.isFloorplanHovered)
4574
- const setFloorplanHovered = useEditor((state) => state.setFloorplanHovered)
4575
- const selectedReferenceId = useEditor((state) => state.selectedReferenceId)
4576
- const setSelectedReferenceId = useEditor((state) => state.setSelectedReferenceId)
4577
- const setMode = useEditor((state) => state.setMode)
4578
- const movingNode = useEditor((state) => state.movingNode)
4579
4672
  const phase = useEditor((state) => state.phase)
4580
- const mode = useEditor((state) => state.mode)
4581
- const setPhase = useEditor((state) => state.setPhase)
4582
- const setMovingNode = useEditor((state) => state.setMovingNode)
4583
- const structureLayer = useEditor((state) => state.structureLayer)
4584
- const setStructureLayer = useEditor((state) => state.setStructureLayer)
4585
- const setTool = useEditor((state) => state.setTool)
4586
- const tool = useEditor((state) => state.tool)
4587
- const deleteNode = useScene((state) => state.deleteNode)
4588
- const updateNode = useScene((state) => state.updateNode)
4589
- const levelNode = useScene((state) =>
4590
- levelId ? (state.nodes[levelId] as LevelNode | undefined) : undefined,
4591
- )
4592
- const currentBuildingId =
4593
- levelNode?.type === 'level' && levelNode.parentId
4594
- ? (levelNode.parentId as BuildingNode['id'])
4595
- : (buildingId as BuildingNode['id'] | null)
4596
- const site = useScene((state) => {
4597
- for (const rootNodeId of state.rootNodeIds) {
4598
- const node = state.nodes[rootNodeId]
4599
- if (node?.type === 'site') {
4600
- return node as SiteNode
4673
+ const setFloorplanSelectionTool = useEditor((state) => state.setFloorplanSelectionTool)
4674
+
4675
+ useEffect(() => {
4676
+ const handleKeyDown = (event: KeyboardEvent) => {
4677
+ const target = event.target as HTMLElement | null
4678
+ const isEditableTarget =
4679
+ target instanceof HTMLInputElement ||
4680
+ target instanceof HTMLTextAreaElement ||
4681
+ Boolean(target?.isContentEditable)
4682
+
4683
+ if (
4684
+ isEditableTarget ||
4685
+ !isFloorplanHovered ||
4686
+ phase !== 'site' ||
4687
+ event.metaKey ||
4688
+ event.ctrlKey ||
4689
+ event.altKey ||
4690
+ event.key.toLowerCase() !== 'v'
4691
+ ) {
4692
+ return
4601
4693
  }
4694
+
4695
+ setFloorplanSelectionTool('click')
4696
+ onRestoreGroundLevel()
4602
4697
  }
4603
4698
 
4604
- return null
4605
- })
4606
- const floorplanLevels = useScene(
4607
- useShallow((state) => {
4608
- if (!currentBuildingId) {
4609
- return [] as LevelNode[]
4610
- }
4699
+ window.addEventListener('keydown', handleKeyDown, true)
4700
+ return () => {
4701
+ window.removeEventListener('keydown', handleKeyDown, true)
4702
+ }
4703
+ }, [isFloorplanHovered, onRestoreGroundLevel, phase, setFloorplanSelectionTool])
4611
4704
 
4612
- const buildingNode = state.nodes[currentBuildingId]
4613
- if (!buildingNode || buildingNode.type !== 'building') {
4614
- return [] as LevelNode[]
4615
- }
4705
+ return null
4706
+ })
4616
4707
 
4617
- return buildingNode.children
4618
- .map((childId) => state.nodes[childId])
4619
- .filter((node): node is LevelNode => node?.type === 'level')
4620
- .sort((a, b) => a.level - b.level)
4621
- }),
4622
- )
4623
- const walls = useScene(
4624
- useShallow((state) => {
4625
- if (!levelId) {
4626
- return [] as WallNode[]
4627
- }
4708
+ type FloorplanDuplicateHotkeyProps = {
4709
+ hasDuplicatable: boolean
4710
+ onDuplicateSelected: () => void
4711
+ }
4628
4712
 
4629
- const nextLevelNode = state.nodes[levelId]
4630
- if (!nextLevelNode || nextLevelNode.type !== 'level') {
4631
- return [] as WallNode[]
4632
- }
4713
+ const FloorplanDuplicateHotkey = memo(function FloorplanDuplicateHotkey({
4714
+ hasDuplicatable,
4715
+ onDuplicateSelected,
4716
+ }: FloorplanDuplicateHotkeyProps) {
4717
+ const isFloorplanHovered = useEditor((state) => state.isFloorplanHovered)
4633
4718
 
4634
- return nextLevelNode.children
4635
- .map((childId) => state.nodes[childId])
4636
- .filter((node): node is WallNode => node?.type === 'wall')
4637
- }),
4638
- )
4639
- const openings = useScene(
4640
- useShallow((state) => {
4641
- if (!levelId) {
4642
- return [] as OpeningNode[]
4719
+ useEffect(() => {
4720
+ const handleKeyDown = (event: KeyboardEvent) => {
4721
+ if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== 'c') {
4722
+ return
4643
4723
  }
4644
4724
 
4645
- const nextLevelNode = state.nodes[levelId]
4646
- if (!nextLevelNode || nextLevelNode.type !== 'level') {
4647
- return [] as OpeningNode[]
4725
+ if (!(isFloorplanHovered && hasDuplicatable)) {
4726
+ return
4648
4727
  }
4649
4728
 
4650
- const nextWalls = nextLevelNode.children
4651
- .map((childId) => state.nodes[childId])
4652
- .filter((node): node is WallNode => node?.type === 'wall')
4729
+ const target = event.target as HTMLElement | null
4730
+ const isEditableTarget =
4731
+ target instanceof HTMLInputElement ||
4732
+ target instanceof HTMLTextAreaElement ||
4733
+ Boolean(target?.isContentEditable)
4653
4734
 
4654
- return nextWalls.flatMap((wall) =>
4655
- wall.children
4656
- .map((childId) => state.nodes[childId])
4657
- .filter((node): node is OpeningNode => node?.type === 'window' || node?.type === 'door'),
4658
- )
4659
- }),
4660
- )
4661
- const slabs = useScene(
4662
- useShallow((state) => {
4663
- if (!levelId) {
4664
- return [] as SlabNode[]
4735
+ if (isEditableTarget) {
4736
+ return
4665
4737
  }
4666
4738
 
4667
- const nextLevelNode = state.nodes[levelId]
4668
- if (!nextLevelNode || nextLevelNode.type !== 'level') {
4669
- return [] as SlabNode[]
4670
- }
4739
+ event.preventDefault()
4740
+ onDuplicateSelected()
4741
+ }
4671
4742
 
4672
- return nextLevelNode.children
4673
- .map((childId) => state.nodes[childId])
4674
- .filter((node): node is SlabNode => node?.type === 'slab')
4675
- }),
4676
- )
4677
- const levelGuides = useScene(
4678
- useShallow((state) => {
4743
+ window.addEventListener('keydown', handleKeyDown, true)
4744
+ return () => {
4745
+ window.removeEventListener('keydown', handleKeyDown, true)
4746
+ }
4747
+ }, [hasDuplicatable, isFloorplanHovered, onDuplicateSelected])
4748
+
4749
+ return null
4750
+ })
4751
+
4752
+ type FloorplanActionMenuHandler = (event: ReactMouseEvent<HTMLButtonElement>) => void
4753
+
4754
+ type FloorplanActionMenuEntry = {
4755
+ position: SvgPoint | null
4756
+ onDelete: FloorplanActionMenuHandler
4757
+ onMove: FloorplanActionMenuHandler
4758
+ onDuplicate?: FloorplanActionMenuHandler
4759
+ }
4760
+
4761
+ type FloorplanActionMenuLayerProps = {
4762
+ item: FloorplanActionMenuEntry
4763
+ wall: FloorplanActionMenuEntry
4764
+ slab: FloorplanActionMenuEntry
4765
+ ceiling: FloorplanActionMenuEntry
4766
+ opening: FloorplanActionMenuEntry
4767
+ stair: FloorplanActionMenuEntry
4768
+ }
4769
+
4770
+ const FloorplanActionMenuLayer = memo(function FloorplanActionMenuLayer({
4771
+ item,
4772
+ wall,
4773
+ slab,
4774
+ ceiling,
4775
+ opening,
4776
+ stair,
4777
+ }: FloorplanActionMenuLayerProps) {
4778
+ const isFloorplanHovered = useEditor((state) => state.isFloorplanHovered)
4779
+ const movingNode = useEditor((state) => state.movingNode)
4780
+ const curvingWall = useEditor((state) => state.curvingWall)
4781
+ const curvingFence = useEditor((state) => state.curvingFence)
4782
+
4783
+ if (!isFloorplanHovered || movingNode || curvingWall || curvingFence) {
4784
+ return null
4785
+ }
4786
+
4787
+ const entries: FloorplanActionMenuEntry[] = [item, wall, slab, ceiling, opening, stair]
4788
+
4789
+ return (
4790
+ <>
4791
+ {entries.map((entry, index) =>
4792
+ entry.position ? (
4793
+ <div
4794
+ className="absolute z-30"
4795
+ key={index}
4796
+ style={{
4797
+ left: entry.position.x,
4798
+ top: entry.position.y,
4799
+ transform: `translate(-50%, calc(-100% - ${FLOORPLAN_ACTION_MENU_OFFSET_Y}px))`,
4800
+ }}
4801
+ >
4802
+ <NodeActionMenu
4803
+ onDelete={entry.onDelete}
4804
+ onDuplicate={entry.onDuplicate}
4805
+ onMove={entry.onMove}
4806
+ onPointerDown={(event) => event.stopPropagation()}
4807
+ onPointerUp={(event) => event.stopPropagation()}
4808
+ />
4809
+ </div>
4810
+ ) : null,
4811
+ )}
4812
+ </>
4813
+ )
4814
+ })
4815
+
4816
+ type FloorplanCursorIndicatorOverlayProps = {
4817
+ cursorPosition: SvgPoint | null
4818
+ cursorAnchorPosition: SvgPoint | null
4819
+ floorplanSelectionTool: FloorplanSelectionTool
4820
+ movingOpeningType: 'door' | 'window' | null
4821
+ isPanning: boolean
4822
+ cursorColor: string
4823
+ }
4824
+
4825
+ const FloorplanCursorIndicatorOverlay = memo(function FloorplanCursorIndicatorOverlay({
4826
+ cursorPosition,
4827
+ cursorAnchorPosition,
4828
+ floorplanSelectionTool,
4829
+ movingOpeningType,
4830
+ isPanning,
4831
+ cursorColor,
4832
+ }: FloorplanCursorIndicatorOverlayProps) {
4833
+ const mode = useEditor((state) => state.mode)
4834
+ const tool = useEditor((state) => state.tool)
4835
+ const structureLayer = useEditor((state) => state.structureLayer)
4836
+ const catalogCategory = useEditor((state) => state.catalogCategory)
4837
+
4838
+ const activeFloorplanToolConfig = useMemo(() => {
4839
+ if (movingOpeningType) {
4840
+ return structureTools.find((entry) => entry.id === movingOpeningType) ?? null
4841
+ }
4842
+
4843
+ if (mode !== 'build' || !tool) {
4844
+ return null
4845
+ }
4846
+
4847
+ if (tool === 'item' && catalogCategory) {
4848
+ return furnishTools.find((entry) => entry.catalogCategory === catalogCategory) ?? null
4849
+ }
4850
+
4851
+ return structureTools.find((entry) => entry.id === tool) ?? null
4852
+ }, [catalogCategory, mode, movingOpeningType, tool])
4853
+
4854
+ const indicator = useMemo<FloorplanCursorIndicator | null>(() => {
4855
+ if (activeFloorplanToolConfig) {
4856
+ return { kind: 'asset', iconSrc: activeFloorplanToolConfig.iconSrc }
4857
+ }
4858
+
4859
+ if (mode === 'select' && floorplanSelectionTool === 'marquee' && structureLayer !== 'zones') {
4860
+ return { kind: 'icon', icon: 'mdi:select-drag' }
4861
+ }
4862
+
4863
+ if (mode === 'delete') {
4864
+ return { kind: 'icon', icon: 'mdi:trash-can-outline' }
4865
+ }
4866
+
4867
+ return null
4868
+ }, [activeFloorplanToolConfig, floorplanSelectionTool, mode, structureLayer])
4869
+
4870
+ const position = mode === 'delete' ? cursorPosition : cursorAnchorPosition
4871
+
4872
+ if (!(indicator && position) || isPanning) {
4873
+ return null
4874
+ }
4875
+
4876
+ return (
4877
+ <div
4878
+ aria-hidden="true"
4879
+ className="pointer-events-none absolute z-20"
4880
+ style={{ left: position.x, top: position.y }}
4881
+ >
4882
+ {mode === 'delete' ? (
4883
+ <div
4884
+ 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)]"
4885
+ style={{
4886
+ 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`,
4887
+ transform: `translate(${FLOORPLAN_CURSOR_BADGE_OFFSET_X}px, ${FLOORPLAN_CURSOR_BADGE_OFFSET_Y}px)`,
4888
+ }}
4889
+ >
4890
+ {indicator.kind === 'asset' ? (
4891
+ <img
4892
+ alt=""
4893
+ aria-hidden="true"
4894
+ className="h-5 w-5 object-contain drop-shadow-[0_2px_4px_rgba(0,0,0,0.5)]"
4895
+ src={indicator.iconSrc}
4896
+ />
4897
+ ) : (
4898
+ <Icon
4899
+ aria-hidden="true"
4900
+ className="drop-shadow-[0_2px_4px_rgba(0,0,0,0.5)]"
4901
+ color={cursorColor}
4902
+ height={18}
4903
+ icon={indicator.icon}
4904
+ width={18}
4905
+ />
4906
+ )}
4907
+ </div>
4908
+ ) : (
4909
+ <>
4910
+ <div
4911
+ className="absolute top-0 left-1/2 w-px -translate-x-1/2 -translate-y-full"
4912
+ style={{
4913
+ backgroundColor: cursorColor,
4914
+ boxShadow: `0 0 12px ${cursorColor}55`,
4915
+ height: FLOORPLAN_CURSOR_INDICATOR_LINE_HEIGHT,
4916
+ }}
4917
+ />
4918
+ <div
4919
+ 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)]"
4920
+ style={{
4921
+ transform: `translate(-50%, calc(-100% - ${FLOORPLAN_CURSOR_INDICATOR_LINE_HEIGHT}px))`,
4922
+ }}
4923
+ >
4924
+ {indicator.kind === 'asset' ? (
4925
+ <img
4926
+ alt=""
4927
+ aria-hidden="true"
4928
+ className="h-5 w-5 object-contain drop-shadow-[0_2px_4px_rgba(0,0,0,0.5)]"
4929
+ src={indicator.iconSrc}
4930
+ />
4931
+ ) : (
4932
+ <Icon
4933
+ aria-hidden="true"
4934
+ className="drop-shadow-[0_2px_4px_rgba(0,0,0,0.5)]"
4935
+ color="white"
4936
+ height={18}
4937
+ icon={indicator.icon}
4938
+ width={18}
4939
+ />
4940
+ )}
4941
+ </div>
4942
+ </>
4943
+ )}
4944
+ </div>
4945
+ )
4946
+ })
4947
+
4948
+ export function FloorplanPanel() {
4949
+ const viewportHostRef = useRef<HTMLDivElement>(null)
4950
+ const svgRef = useRef<SVGSVGElement>(null)
4951
+ const panStateRef = useRef<PanState | null>(null)
4952
+ const guideInteractionRef = useRef<GuideInteractionState | null>(null)
4953
+ const guideTransformDraftRef = useRef<GuideTransformDraft | null>(null)
4954
+ const wallEndpointDragRef = useRef<WallEndpointDragState | null>(null)
4955
+ const wallCurveDragRef = useRef<WallCurveDragState | null>(null)
4956
+ const siteBoundaryDraftRef = useRef<SiteBoundaryDraft | null>(null)
4957
+ const slabBoundaryDraftRef = useRef<SlabBoundaryDraft | null>(null)
4958
+ const zoneBoundaryDraftRef = useRef<ZoneBoundaryDraft | null>(null)
4959
+ const gestureScaleRef = useRef(1)
4960
+ const panelInteractionRef = useRef<PanelInteractionState | null>(null)
4961
+ const panelBoundsRef = useRef<ViewportBounds | null>(null)
4962
+ const containerRef = useRef<HTMLDivElement>(null)
4963
+ const hasUserAdjustedViewportRef = useRef(false)
4964
+ const previousLevelIdRef = useRef<string | null>(null)
4965
+ const floorplanMarqueeSnapPointRef = useRef<WallPlanPoint | null>(null)
4966
+ const levelId = useViewer((state) => state.selection.levelId)
4967
+ const buildingId = useViewer((state) => state.selection.buildingId)
4968
+ const selectedZoneId = useViewer((state) => state.selection.zoneId)
4969
+ const selectedIds = useViewer((state) => state.selection.selectedIds)
4970
+ const previewSelectedIds = useViewer((state) => state.previewSelectedIds)
4971
+ const setSelection = useViewer((state) => state.setSelection)
4972
+ const setPreviewSelectedIds = useViewer((state) => state.setPreviewSelectedIds)
4973
+ const theme = useViewer((state) => state.theme)
4974
+ const unit = useViewer((state) => state.unit)
4975
+ const showGrid = useViewer((state) => state.showGrid)
4976
+ const showGuides = useViewer((state) => state.showGuides)
4977
+ const setShowGuides = useViewer((state) => state.setShowGuides)
4978
+ const selectedItem = useEditor((state) => state.selectedItem)
4979
+
4980
+ const setFloorplanHovered = useEditor((state) => state.setFloorplanHovered)
4981
+ const selectedReferenceId = useEditor((state) => state.selectedReferenceId)
4982
+ const setSelectedReferenceId = useEditor((state) => state.setSelectedReferenceId)
4983
+ const setMode = useEditor((state) => state.setMode)
4984
+ const movingNode = useEditor((state) => state.movingNode)
4985
+ const curvingWall = useEditor((state) => state.curvingWall)
4986
+ const curvingFence = useEditor((state) => state.curvingFence)
4987
+ const phase = useEditor((state) => state.phase)
4988
+ const mode = useEditor((state) => state.mode)
4989
+ const setPhase = useEditor((state) => state.setPhase)
4990
+ const setMovingNode = useEditor((state) => state.setMovingNode)
4991
+ const structureLayer = useEditor((state) => state.structureLayer)
4992
+ const setStructureLayer = useEditor((state) => state.setStructureLayer)
4993
+ const setTool = useEditor((state) => state.setTool)
4994
+ const tool = useEditor((state) => state.tool)
4995
+ const deleteNode = useScene((state) => state.deleteNode)
4996
+ const updateNode = useScene((state) => state.updateNode)
4997
+ const levelNode = useScene((state) =>
4998
+ levelId ? (state.nodes[levelId] as LevelNode | undefined) : undefined,
4999
+ )
5000
+ const currentBuildingId =
5001
+ levelNode?.type === 'level' && levelNode.parentId
5002
+ ? (levelNode.parentId as BuildingNode['id'])
5003
+ : (buildingId as BuildingNode['id'] | null)
5004
+ const buildingRotationY = useScene((state) => {
5005
+ if (!currentBuildingId) return 0
5006
+ const node = state.nodes[currentBuildingId]
5007
+ return node?.type === 'building' ? (node.rotation[1] ?? 0) : 0
5008
+ })
5009
+ const buildingRotationDeg = (buildingRotationY * 180) / Math.PI
5010
+ const site = useScene((state) => {
5011
+ for (const rootNodeId of state.rootNodeIds) {
5012
+ const node = state.nodes[rootNodeId]
5013
+ if (node?.type === 'site') {
5014
+ return node as SiteNode
5015
+ }
5016
+ }
5017
+
5018
+ return null
5019
+ })
5020
+ const floorplanLevels = useScene(
5021
+ useShallow((state) => {
5022
+ if (!currentBuildingId) {
5023
+ return [] as LevelNode[]
5024
+ }
5025
+
5026
+ const buildingNode = state.nodes[currentBuildingId]
5027
+ if (!buildingNode || buildingNode.type !== 'building') {
5028
+ return [] as LevelNode[]
5029
+ }
5030
+
5031
+ return buildingNode.children
5032
+ .map((childId) => state.nodes[childId])
5033
+ .filter((node): node is LevelNode => node?.type === 'level')
5034
+ .sort((a, b) => a.level - b.level)
5035
+ }),
5036
+ )
5037
+ const walls = useScene(
5038
+ useShallow((state) => {
5039
+ if (!levelId) {
5040
+ return [] as WallNode[]
5041
+ }
5042
+
5043
+ const nextLevelNode = state.nodes[levelId]
5044
+ if (!nextLevelNode || nextLevelNode.type !== 'level') {
5045
+ return [] as WallNode[]
5046
+ }
5047
+
5048
+ return nextLevelNode.children
5049
+ .map((childId) => state.nodes[childId])
5050
+ .filter((node): node is WallNode => node?.type === 'wall')
5051
+ }),
5052
+ )
5053
+ const openings = useScene(
5054
+ useShallow((state) => {
5055
+ if (!levelId) {
5056
+ return [] as OpeningNode[]
5057
+ }
5058
+
5059
+ const nextLevelNode = state.nodes[levelId]
5060
+ if (!nextLevelNode || nextLevelNode.type !== 'level') {
5061
+ return [] as OpeningNode[]
5062
+ }
5063
+
5064
+ const nextWalls = nextLevelNode.children
5065
+ .map((childId) => state.nodes[childId])
5066
+ .filter((node): node is WallNode => node?.type === 'wall')
5067
+
5068
+ return nextWalls.flatMap((wall) =>
5069
+ wall.children
5070
+ .map((childId) => state.nodes[childId])
5071
+ .filter((node): node is OpeningNode => node?.type === 'window' || node?.type === 'door'),
5072
+ )
5073
+ }),
5074
+ )
5075
+ const slabs = useScene(
5076
+ useShallow((state) => {
5077
+ if (!levelId) {
5078
+ return [] as SlabNode[]
5079
+ }
5080
+
5081
+ const nextLevelNode = state.nodes[levelId]
5082
+ if (!nextLevelNode || nextLevelNode.type !== 'level') {
5083
+ return [] as SlabNode[]
5084
+ }
5085
+
5086
+ return nextLevelNode.children
5087
+ .map((childId) => state.nodes[childId])
5088
+ .filter((node): node is SlabNode => node?.type === 'slab')
5089
+ }),
5090
+ )
5091
+ const ceilings = useScene(
5092
+ useShallow((state) => {
5093
+ if (!levelId) {
5094
+ return [] as CeilingNode[]
5095
+ }
5096
+
5097
+ const nextLevelNode = state.nodes[levelId]
5098
+ if (!nextLevelNode || nextLevelNode.type !== 'level') {
5099
+ return [] as CeilingNode[]
5100
+ }
5101
+
5102
+ return nextLevelNode.children
5103
+ .map((childId) => state.nodes[childId])
5104
+ .filter((node): node is CeilingNode => node?.type === 'ceiling')
5105
+ }),
5106
+ )
5107
+ const levelGuides = useScene(
5108
+ useShallow((state) => {
4679
5109
  if (!levelId) {
4680
5110
  return [] as GuideNode[]
4681
5111
  }
@@ -4735,6 +5165,7 @@ export function FloorplanPanel() {
4735
5165
  const [cursorPoint, setCursorPoint] = useState<WallPlanPoint | null>(null)
4736
5166
  const [floorplanCursorPosition, setFloorplanCursorPosition] = useState<SvgPoint | null>(null)
4737
5167
  const [wallEndpointDraft, setWallEndpointDraft] = useState<WallEndpointDraft | null>(null)
5168
+ const [wallCurveDraft, setWallCurveDraft] = useState<WallCurveDraft | null>(null)
4738
5169
  const [hoveredOpeningId, setHoveredOpeningId] = useState<OpeningNode['id'] | null>(null)
4739
5170
  const [hoveredWallId, setHoveredWallId] = useState<WallNode['id'] | null>(null)
4740
5171
  const [hoveredSlabId, setHoveredSlabId] = useState<SlabNode['id'] | null>(null)
@@ -4742,6 +5173,7 @@ export function FloorplanPanel() {
4742
5173
  const [hoveredStairId, setHoveredStairId] = useState<StairNode['id'] | null>(null)
4743
5174
  const [hoveredZoneId, setHoveredZoneId] = useState<ZoneNodeType['id'] | null>(null)
4744
5175
  const [hoveredEndpointId, setHoveredEndpointId] = useState<string | null>(null)
5176
+ const [hoveredWallCurveHandleId, setHoveredWallCurveHandleId] = useState<string | null>(null)
4745
5177
  const [hoveredSiteHandleId, setHoveredSiteHandleId] = useState<string | null>(null)
4746
5178
  const [hoveredSlabHandleId, setHoveredSlabHandleId] = useState<string | null>(null)
4747
5179
  const [hoveredZoneHandleId, setHoveredZoneHandleId] = useState<string | null>(null)
@@ -4810,53 +5242,14 @@ export function FloorplanPanel() {
4810
5242
  const polygon = siteBoundaryDraft.polygon.map(toPoint2D)
4811
5243
 
4812
5244
  return {
4813
- ...sitePolygonEntry,
4814
- polygon,
4815
- points: formatPolygonPoints(polygon),
4816
- }
4817
- }, [siteBoundaryDraft, sitePolygonEntry])
4818
- const movingOpeningType =
4819
- movingNode?.type === 'door' || movingNode?.type === 'window' ? movingNode.type : null
4820
-
4821
- const activeFloorplanToolConfig = useMemo(() => {
4822
- if (movingOpeningType) {
4823
- return structureTools.find((entry) => entry.id === movingOpeningType) ?? null
4824
- }
4825
-
4826
- if (mode !== 'build' || !tool) {
4827
- return null
4828
- }
4829
-
4830
- if (tool === 'item' && catalogCategory) {
4831
- return furnishTools.find((entry) => entry.catalogCategory === catalogCategory) ?? null
4832
- }
4833
-
4834
- return structureTools.find((entry) => entry.id === tool) ?? null
4835
- }, [catalogCategory, mode, movingOpeningType, tool])
4836
- const activeFloorplanCursorIndicator = useMemo<FloorplanCursorIndicator | null>(() => {
4837
- if (activeFloorplanToolConfig) {
4838
- return {
4839
- kind: 'asset',
4840
- iconSrc: activeFloorplanToolConfig.iconSrc,
4841
- }
4842
- }
4843
-
4844
- if (mode === 'select' && floorplanSelectionTool === 'marquee' && structureLayer !== 'zones') {
4845
- return {
4846
- kind: 'icon',
4847
- icon: 'mdi:select-drag',
4848
- }
4849
- }
4850
-
4851
- if (mode === 'delete') {
4852
- return {
4853
- kind: 'icon',
4854
- icon: 'mdi:trash-can-outline',
4855
- }
5245
+ ...sitePolygonEntry,
5246
+ polygon,
5247
+ points: formatPolygonPoints(polygon),
4856
5248
  }
5249
+ }, [siteBoundaryDraft, sitePolygonEntry])
5250
+ const movingOpeningType =
5251
+ movingNode?.type === 'door' || movingNode?.type === 'window' ? movingNode.type : null
4857
5252
 
4858
- return null
4859
- }, [activeFloorplanToolConfig, floorplanSelectionTool, mode, structureLayer])
4860
5253
  const visibleGuides = useMemo<GuideNode[]>(() => {
4861
5254
  if (!showGuides) {
4862
5255
  return []
@@ -4916,29 +5309,42 @@ export function FloorplanPanel() {
4916
5309
  [floorplanWalls],
4917
5310
  )
4918
5311
  const displayWallById = useMemo(() => {
4919
- if (!wallEndpointDraft) {
5312
+ if (!(wallEndpointDraft || wallCurveDraft)) {
4920
5313
  return wallById
4921
5314
  }
4922
5315
 
4923
- const wall = wallById.get(wallEndpointDraft.wallId)
4924
- if (!wall) {
4925
- return wallById
5316
+ const nextWallById = new Map(wallById)
5317
+
5318
+ if (wallEndpointDraft) {
5319
+ const wall = nextWallById.get(wallEndpointDraft.wallId)
5320
+ if (wall) {
5321
+ nextWallById.set(
5322
+ wall.id,
5323
+ buildWallWithUpdatedEndpoints(wall, wallEndpointDraft.start, wallEndpointDraft.end),
5324
+ )
5325
+ }
4926
5326
  }
4927
5327
 
4928
- const nextWallById = new Map(wallById)
4929
- nextWallById.set(
4930
- wall.id,
4931
- buildWallWithUpdatedEndpoints(wall, wallEndpointDraft.start, wallEndpointDraft.end),
4932
- )
5328
+ if (wallCurveDraft) {
5329
+ const wall = nextWallById.get(wallCurveDraft.wallId)
5330
+ if (wall) {
5331
+ nextWallById.set(wall.id, { ...wall, curveOffset: wallCurveDraft.curveOffset })
5332
+ }
5333
+ }
4933
5334
 
4934
5335
  return nextWallById
4935
- }, [wallById, wallEndpointDraft])
5336
+ }, [wallById, wallCurveDraft, wallEndpointDraft])
4936
5337
  const displayFloorplanWallById = useMemo(() => {
4937
- if (!wallEndpointDraft) {
5338
+ if (!(wallEndpointDraft || wallCurveDraft)) {
5339
+ return floorplanWallById
5340
+ }
5341
+
5342
+ const previewWallId = wallEndpointDraft?.wallId ?? wallCurveDraft?.wallId
5343
+ if (!previewWallId) {
4938
5344
  return floorplanWallById
4939
5345
  }
4940
5346
 
4941
- const previewWall = displayWallById.get(wallEndpointDraft.wallId)
5347
+ const previewWall = displayWallById.get(previewWallId)
4942
5348
  if (!previewWall) {
4943
5349
  return floorplanWallById
4944
5350
  }
@@ -4946,7 +5352,7 @@ export function FloorplanPanel() {
4946
5352
  const nextFloorplanWallById = new Map(floorplanWallById)
4947
5353
  nextFloorplanWallById.set(previewWall.id, getFloorplanWall(previewWall))
4948
5354
  return nextFloorplanWallById
4949
- }, [displayWallById, floorplanWallById, wallEndpointDraft])
5355
+ }, [displayWallById, floorplanWallById, wallCurveDraft, wallEndpointDraft])
4950
5356
  const wallPolygons = useMemo(
4951
5357
  () =>
4952
5358
  walls.map((wall) => {
@@ -4961,11 +5367,16 @@ export function FloorplanPanel() {
4961
5367
  [floorplanWallById, wallMiterData, walls],
4962
5368
  )
4963
5369
  const displayWallPolygons = useMemo(() => {
4964
- if (!wallEndpointDraft) {
5370
+ if (!(wallEndpointDraft || wallCurveDraft)) {
5371
+ return wallPolygons
5372
+ }
5373
+
5374
+ const previewWallId = wallEndpointDraft?.wallId ?? wallCurveDraft?.wallId
5375
+ if (!previewWallId) {
4965
5376
  return wallPolygons
4966
5377
  }
4967
5378
 
4968
- const previewWall = displayWallById.get(wallEndpointDraft.wallId)
5379
+ const previewWall = displayWallById.get(previewWallId)
4969
5380
  if (!previewWall) {
4970
5381
  return wallPolygons
4971
5382
  }
@@ -4984,7 +5395,7 @@ export function FloorplanPanel() {
4984
5395
  }
4985
5396
  : entry,
4986
5397
  )
4987
- }, [displayWallById, wallEndpointDraft, wallPolygons])
5398
+ }, [displayWallById, wallCurveDraft, wallEndpointDraft, wallPolygons])
4988
5399
 
4989
5400
  const openingsPolygons = useMemo(
4990
5401
  () =>
@@ -5040,6 +5451,29 @@ export function FloorplanPanel() {
5040
5451
  : entry,
5041
5452
  )
5042
5453
  }, [slabBoundaryDraft, slabPolygons])
5454
+ const ceilingPolygons = useMemo(
5455
+ () =>
5456
+ ceilings.flatMap((ceiling) => {
5457
+ const polygon = toFloorplanPolygon(ceiling.polygon)
5458
+ if (polygon.length < 3) {
5459
+ return []
5460
+ }
5461
+
5462
+ const holes = (ceiling.holes ?? [])
5463
+ .map((hole) => toFloorplanPolygon(hole))
5464
+ .filter((hole) => hole.length >= 3)
5465
+
5466
+ return [
5467
+ {
5468
+ ceiling,
5469
+ polygon,
5470
+ holes,
5471
+ path: formatPolygonPath(polygon, holes),
5472
+ },
5473
+ ]
5474
+ }),
5475
+ [ceilings],
5476
+ )
5043
5477
  const zonePolygons = useMemo(
5044
5478
  () =>
5045
5479
  zones.flatMap((zone) => {
@@ -5170,6 +5604,13 @@ export function FloorplanPanel() {
5170
5604
 
5171
5605
  return floorplanItemEntries.find(({ item }) => item.id === selectedIds[0]) ?? null
5172
5606
  }, [floorplanItemEntries, selectedIds])
5607
+ const selectedWallEntry = useMemo(() => {
5608
+ if (selectedIds.length !== 1) {
5609
+ return null
5610
+ }
5611
+
5612
+ return displayWallPolygons.find(({ wall }) => wall.id === selectedIds[0]) ?? null
5613
+ }, [displayWallPolygons, selectedIds])
5173
5614
  const selectedStairEntry = useMemo(() => {
5174
5615
  if (selectedIds.length !== 1) {
5175
5616
  return null
@@ -5186,6 +5627,13 @@ export function FloorplanPanel() {
5186
5627
 
5187
5628
  return displaySlabPolygons.find(({ slab }) => slab.id === selectedIds[0]) ?? null
5188
5629
  }, [displaySlabPolygons, selectedIds])
5630
+ const selectedCeilingEntry = useMemo(() => {
5631
+ if (selectedIds.length !== 1) {
5632
+ return null
5633
+ }
5634
+
5635
+ return ceilingPolygons.find(({ ceiling }) => ceiling.id === selectedIds[0]) ?? null
5636
+ }, [ceilingPolygons, selectedIds])
5189
5637
  const selectedZoneEntry = useMemo(() => {
5190
5638
  if (!selectedZoneId) {
5191
5639
  return null
@@ -5206,12 +5654,25 @@ export function FloorplanPanel() {
5206
5654
  const isOpeningPlacementActive = isOpeningBuildActive || isOpeningMoveActive
5207
5655
  const isStairBuildActive = phase === 'structure' && mode === 'build' && tool === 'stair'
5208
5656
  const isStairMoveActive = movingNode?.type === 'stair'
5657
+ const isSlabMoveActive = movingNode?.type === 'slab'
5658
+ const isCeilingMoveActive = movingNode?.type === 'ceiling'
5659
+ const isWallMoveActive = movingNode?.type === 'wall'
5660
+ const isWallCurveActive = curvingWall?.type === 'wall'
5661
+ const isFenceCurveActive = curvingFence?.type === 'fence'
5209
5662
  const isItemPlacementPreviewActive =
5210
5663
  (mode === 'build' && tool === 'item') || movingNode?.type === 'item'
5211
5664
  const isFloorItemBuildActive = mode === 'build' && tool === 'item' && !selectedItem?.attachTo
5212
5665
  const isFloorItemMoveActive = movingNode?.type === 'item' && !movingNode.asset.attachTo
5213
5666
  const isFloorplanGridInteractionActive =
5214
- isStairBuildActive || isStairMoveActive || isFloorItemBuildActive || isFloorItemMoveActive
5667
+ isStairBuildActive ||
5668
+ isStairMoveActive ||
5669
+ isSlabMoveActive ||
5670
+ isCeilingMoveActive ||
5671
+ isWallMoveActive ||
5672
+ isWallCurveActive ||
5673
+ isFenceCurveActive ||
5674
+ isFloorItemBuildActive ||
5675
+ isFloorItemMoveActive
5215
5676
  const floorplanPreviewStairSegment = useMemo(
5216
5677
  () =>
5217
5678
  StairSegmentNodeSchema.parse({
@@ -5393,6 +5854,56 @@ export function FloorplanPanel() {
5393
5854
  shouldShowPersistentWallEndpointHandles,
5394
5855
  wallEndpointDraft,
5395
5856
  ])
5857
+ const wallCurveHandles = useMemo(() => {
5858
+ if (
5859
+ isOpeningPlacementActive ||
5860
+ movingNode ||
5861
+ mode !== 'select' ||
5862
+ floorplanSelectionTool !== 'click' ||
5863
+ !selectedWallEntry
5864
+ ) {
5865
+ return []
5866
+ }
5867
+
5868
+ const hasWallChildrenBlockingCurve = (selectedWallEntry.wall.children ?? []).some((childId) => {
5869
+ const childNode = levelDescendantNodeById.get(childId as AnyNodeId)
5870
+ if (!childNode) {
5871
+ return false
5872
+ }
5873
+
5874
+ if (childNode.type === 'door' || childNode.type === 'window') {
5875
+ return true
5876
+ }
5877
+
5878
+ if (childNode.type === 'item') {
5879
+ const attachTo = childNode.asset?.attachTo
5880
+ return attachTo === 'wall' || attachTo === 'wall-side'
5881
+ }
5882
+
5883
+ return false
5884
+ })
5885
+ if (hasWallChildrenBlockingCurve) {
5886
+ return []
5887
+ }
5888
+
5889
+ const centerPoint = getWallMidpointHandlePoint(selectedWallEntry.wall)
5890
+
5891
+ return [
5892
+ {
5893
+ wall: selectedWallEntry.wall,
5894
+ point: [centerPoint.x, centerPoint.y] as WallPlanPoint,
5895
+ isActive: wallCurveDraft?.wallId === selectedWallEntry.wall.id,
5896
+ },
5897
+ ]
5898
+ }, [
5899
+ floorplanSelectionTool,
5900
+ isOpeningPlacementActive,
5901
+ mode,
5902
+ movingNode,
5903
+ levelDescendantNodeById,
5904
+ selectedWallEntry,
5905
+ wallCurveDraft,
5906
+ ])
5396
5907
  const slabVertexHandles = useMemo(() => {
5397
5908
  if (!shouldShowSlabBoundaryHandles) {
5398
5909
  return []
@@ -5654,23 +6165,15 @@ export function FloorplanPanel() {
5654
6165
  if (levelChanged) {
5655
6166
  previousLevelIdRef.current = levelId ?? null
5656
6167
  hasUserAdjustedViewportRef.current = false
5657
- setViewport(fittedViewport)
6168
+ setViewport((current) => (floorplanViewportEquals(current, fittedViewport) ? current : fittedViewport))
5658
6169
  return
5659
6170
  }
5660
6171
 
5661
6172
  if (!hasUserAdjustedViewportRef.current) {
5662
- setViewport(fittedViewport)
6173
+ setViewport((current) => (floorplanViewportEquals(current, fittedViewport) ? current : fittedViewport))
5663
6174
  }
5664
6175
  }, [fittedViewport, levelId])
5665
6176
 
5666
- useEffect(() => {
5667
- if (!(phase === 'site' && levelNode?.type === 'level' && levelNode.level > 0)) {
5668
- return
5669
- }
5670
-
5671
- setPhase('structure')
5672
- }, [levelNode, phase, setPhase])
5673
-
5674
6177
  const viewBox = useMemo(() => {
5675
6178
  const currentViewport = viewport ?? fittedViewport
5676
6179
  const width = currentViewport.width
@@ -5711,6 +6214,27 @@ export function FloorplanPanel() {
5711
6214
  : null,
5712
6215
  [selectedItemEntry, surfaceSize, viewBox],
5713
6216
  )
6217
+ const selectedSlabActionMenuPosition = useMemo(
6218
+ () =>
6219
+ selectedSlabEntry
6220
+ ? getFloorplanActionMenuPosition(selectedSlabEntry.polygon, viewBox, surfaceSize)
6221
+ : null,
6222
+ [selectedSlabEntry, surfaceSize, viewBox],
6223
+ )
6224
+ const selectedCeilingActionMenuPosition = useMemo(
6225
+ () =>
6226
+ selectedCeilingEntry
6227
+ ? getFloorplanActionMenuPosition(selectedCeilingEntry.polygon, viewBox, surfaceSize)
6228
+ : null,
6229
+ [selectedCeilingEntry, surfaceSize, viewBox],
6230
+ )
6231
+ const selectedWallActionMenuPosition = useMemo(
6232
+ () =>
6233
+ selectedWallEntry
6234
+ ? getFloorplanActionMenuPosition(selectedWallEntry.polygon, viewBox, surfaceSize)
6235
+ : null,
6236
+ [selectedWallEntry, surfaceSize, viewBox],
6237
+ )
5714
6238
  const selectedStairActionMenuPosition = useMemo(
5715
6239
  () =>
5716
6240
  selectedStairEntry
@@ -5963,9 +6487,14 @@ export function FloorplanPanel() {
5963
6487
  return null
5964
6488
  }
5965
6489
 
6490
+ if (buildingRotationY !== 0) {
6491
+ const [unrotX, unrotY] = rotatePlanVector(svgPoint.x, svgPoint.y, buildingRotationY)
6492
+ return toPlanPointFromSvgPoint({ x: unrotX, y: unrotY })
6493
+ }
6494
+
5966
6495
  return toPlanPointFromSvgPoint(svgPoint)
5967
6496
  },
5968
- [getSvgPointFromClientPoint],
6497
+ [getSvgPointFromClientPoint, buildingRotationY],
5969
6498
  )
5970
6499
  useEffect(() => {
5971
6500
  siteBoundaryDraftRef.current = siteBoundaryDraft
@@ -6187,6 +6716,11 @@ export function FloorplanPanel() {
6187
6716
  setWallEndpointDraft(null)
6188
6717
  setHoveredEndpointId(null)
6189
6718
  }, [])
6719
+ const clearWallCurveDrag = useCallback(() => {
6720
+ wallCurveDragRef.current = null
6721
+ setWallCurveDraft(null)
6722
+ setHoveredWallCurveHandleId(null)
6723
+ }, [])
6190
6724
  const clearSiteBoundaryInteraction = useCallback(() => {
6191
6725
  setSiteVertexDragState(null)
6192
6726
  setSiteBoundaryDraft(null)
@@ -6208,11 +6742,13 @@ export function FloorplanPanel() {
6208
6742
  clearSlabPlacementDraft()
6209
6743
  clearZonePlacementDraft()
6210
6744
  clearWallEndpointDrag()
6745
+ clearWallCurveDrag()
6211
6746
  clearSiteBoundaryInteraction()
6212
6747
  clearSlabBoundaryInteraction()
6213
6748
  clearZoneBoundaryInteraction()
6214
6749
  setCursorPoint(null)
6215
6750
  }, [
6751
+ clearWallCurveDrag,
6216
6752
  clearSiteBoundaryInteraction,
6217
6753
  clearSlabBoundaryInteraction,
6218
6754
  clearSlabPlacementDraft,
@@ -6419,51 +6955,85 @@ export function FloorplanPanel() {
6419
6955
  }
6420
6956
 
6421
6957
  const dragState = wallEndpointDragRef.current
6422
- if (!dragState || event.pointerId !== dragState.pointerId) {
6958
+ if (dragState && event.pointerId === dragState.pointerId) {
6959
+ event.preventDefault()
6960
+
6961
+ const planPoint = getPlanPointFromClientPoint(event.clientX, event.clientY)
6962
+ if (!planPoint) {
6963
+ return
6964
+ }
6965
+
6966
+ const snappedPoint = snapWallDraftPoint({
6967
+ point: planPoint,
6968
+ walls,
6969
+ start: dragState.fixedPoint,
6970
+ angleSnap: !shiftPressed,
6971
+ ignoreWallIds: [dragState.wallId],
6972
+ })
6973
+
6974
+ if (pointsEqual(dragState.currentPoint, snappedPoint)) {
6975
+ return
6976
+ }
6977
+
6978
+ dragState.currentPoint = snappedPoint
6979
+ setCursorPoint(snappedPoint)
6980
+ setWallEndpointDraft((previousDraft) => {
6981
+ const nextDraft = buildWallEndpointDraft(
6982
+ dragState.wallId,
6983
+ dragState.endpoint,
6984
+ dragState.fixedPoint,
6985
+ snappedPoint,
6986
+ )
6987
+
6988
+ if (
6989
+ !(
6990
+ previousDraft &&
6991
+ pointsEqual(previousDraft.start, nextDraft.start) &&
6992
+ pointsEqual(previousDraft.end, nextDraft.end)
6993
+ )
6994
+ ) {
6995
+ sfxEmitter.emit('sfx:grid-snap')
6996
+ }
6997
+
6998
+ return nextDraft
6999
+ })
7000
+ return
7001
+ }
7002
+
7003
+ const curveDragState = wallCurveDragRef.current
7004
+ if (!curveDragState || event.pointerId !== curveDragState.pointerId) {
6423
7005
  return
6424
7006
  }
6425
7007
 
6426
7008
  event.preventDefault()
6427
7009
 
6428
7010
  const planPoint = getPlanPointFromClientPoint(event.clientX, event.clientY)
6429
- if (!planPoint) {
7011
+ const wall = wallById.get(curveDragState.wallId)
7012
+ if (!(planPoint && wall)) {
6430
7013
  return
6431
7014
  }
6432
7015
 
6433
- const snappedPoint = snapWallDraftPoint({
6434
- point: planPoint,
6435
- walls,
6436
- start: dragState.fixedPoint,
6437
- angleSnap: !shiftPressed,
6438
- ignoreWallIds: [dragState.wallId],
6439
- })
7016
+ const chord = getWallChordFrame(wall)
7017
+ const snappedPoint: WallPlanPoint = shiftPressed
7018
+ ? planPoint
7019
+ : [snapToHalf(planPoint[0]), snapToHalf(planPoint[1])]
7020
+ const rawCurveOffset = -(
7021
+ (snappedPoint[0] - chord.midpoint.x) * chord.normal.x +
7022
+ (snappedPoint[1] - chord.midpoint.y) * chord.normal.y
7023
+ )
7024
+ const nextCurveOffset = normalizeWallCurveOffset(
7025
+ wall,
7026
+ shiftPressed ? rawCurveOffset : snapToHalf(rawCurveOffset),
7027
+ )
6440
7028
 
6441
- if (pointsEqual(dragState.currentPoint, snappedPoint)) {
7029
+ if (curveDragState.currentCurveOffset === nextCurveOffset) {
6442
7030
  return
6443
7031
  }
6444
7032
 
6445
- dragState.currentPoint = snappedPoint
7033
+ curveDragState.currentCurveOffset = nextCurveOffset
7034
+ setWallCurveDraft({ wallId: wall.id, curveOffset: nextCurveOffset })
6446
7035
  setCursorPoint(snappedPoint)
6447
- setWallEndpointDraft((previousDraft) => {
6448
- const nextDraft = buildWallEndpointDraft(
6449
- dragState.wallId,
6450
- dragState.endpoint,
6451
- dragState.fixedPoint,
6452
- snappedPoint,
6453
- )
6454
-
6455
- if (
6456
- !(
6457
- previousDraft &&
6458
- pointsEqual(previousDraft.start, nextDraft.start) &&
6459
- pointsEqual(previousDraft.end, nextDraft.end)
6460
- )
6461
- ) {
6462
- sfxEmitter.emit('sfx:grid-snap')
6463
- }
6464
-
6465
- return nextDraft
6466
- })
7036
+ sfxEmitter.emit('sfx:grid-snap')
6467
7037
  }
6468
7038
 
6469
7039
  const commitGuideInteraction = (event: PointerEvent) => {
@@ -6548,6 +7118,26 @@ export function FloorplanPanel() {
6548
7118
  setCursorPoint(null)
6549
7119
  }
6550
7120
 
7121
+ const commitWallCurveDrag = (event: PointerEvent) => {
7122
+ const dragState = wallCurveDragRef.current
7123
+ if (!dragState || event.pointerId !== dragState.pointerId) {
7124
+ return
7125
+ }
7126
+
7127
+ const wall = wallById.get(dragState.wallId)
7128
+ if (wall) {
7129
+ const nextCurveOffset = normalizeWallCurveOffset(wall, dragState.currentCurveOffset)
7130
+ const currentCurveOffset = normalizeWallCurveOffset(wall, wall.curveOffset ?? 0)
7131
+ if (nextCurveOffset !== currentCurveOffset) {
7132
+ updateNode(wall.id, { curveOffset: nextCurveOffset })
7133
+ sfxEmitter.emit('sfx:structure-build')
7134
+ }
7135
+ }
7136
+
7137
+ clearWallCurveDrag()
7138
+ setCursorPoint(null)
7139
+ }
7140
+
6551
7141
  const cancelWallEndpointDrag = (event: PointerEvent) => {
6552
7142
  const dragState = wallEndpointDragRef.current
6553
7143
  if (!dragState || event.pointerId !== dragState.pointerId) {
@@ -6558,11 +7148,23 @@ export function FloorplanPanel() {
6558
7148
  setCursorPoint(null)
6559
7149
  }
6560
7150
 
7151
+ const cancelWallCurveDrag = (event: PointerEvent) => {
7152
+ const dragState = wallCurveDragRef.current
7153
+ if (!dragState || event.pointerId !== dragState.pointerId) {
7154
+ return
7155
+ }
7156
+
7157
+ clearWallCurveDrag()
7158
+ setCursorPoint(null)
7159
+ }
7160
+
6561
7161
  window.addEventListener('pointermove', handleWindowPointerMove)
6562
7162
  window.addEventListener('pointerup', commitGuideInteraction)
6563
7163
  window.addEventListener('pointercancel', cancelGuideInteraction)
6564
7164
  window.addEventListener('pointerup', commitWallEndpointDrag)
6565
7165
  window.addEventListener('pointercancel', cancelWallEndpointDrag)
7166
+ window.addEventListener('pointerup', commitWallCurveDrag)
7167
+ window.addEventListener('pointercancel', cancelWallCurveDrag)
6566
7168
 
6567
7169
  return () => {
6568
7170
  window.removeEventListener('pointermove', handleWindowPointerMove)
@@ -6570,8 +7172,11 @@ export function FloorplanPanel() {
6570
7172
  window.removeEventListener('pointercancel', cancelGuideInteraction)
6571
7173
  window.removeEventListener('pointerup', commitWallEndpointDrag)
6572
7174
  window.removeEventListener('pointercancel', cancelWallEndpointDrag)
7175
+ window.removeEventListener('pointerup', commitWallCurveDrag)
7176
+ window.removeEventListener('pointercancel', cancelWallCurveDrag)
6573
7177
  }
6574
7178
  }, [
7179
+ clearWallCurveDrag,
6575
7180
  clearGuideInteraction,
6576
7181
  clearWallEndpointDrag,
6577
7182
  getSvgPointFromClientPoint,
@@ -6585,7 +7190,8 @@ export function FloorplanPanel() {
6585
7190
 
6586
7191
  useEffect(() => {
6587
7192
  clearWallEndpointDrag()
6588
- }, [clearWallEndpointDrag, levelId])
7193
+ clearWallCurveDrag()
7194
+ }, [clearWallCurveDrag, clearWallEndpointDrag, levelId])
6589
7195
 
6590
7196
  useEffect(() => {
6591
7197
  if (shouldShowSiteBoundaryHandles) {
@@ -6973,6 +7579,7 @@ export function FloorplanPanel() {
6973
7579
  emitter.emit(`grid:${eventType}` as any, {
6974
7580
  nativeEvent: nativeEvent.nativeEvent as any,
6975
7581
  position: [snappedPoint[0], worldY, snappedPoint[1]],
7582
+ localPosition: [snappedPoint[0], worldY, snappedPoint[1]],
6976
7583
  })
6977
7584
 
6978
7585
  return snappedPoint
@@ -7054,7 +7661,9 @@ export function FloorplanPanel() {
7054
7661
  }
7055
7662
 
7056
7663
  if (isOpeningPlacementActive) {
7057
- const closest = findClosestWallPoint(planPoint, walls)
7664
+ const closest = findClosestWallPoint(planPoint, walls, {
7665
+ canUseWall: (wall) => !isCurvedWall(wall),
7666
+ })
7058
7667
  if (closest) {
7059
7668
  const dx = closest.wall.end[0] - closest.wall.start[0]
7060
7669
  const dz = closest.wall.end[1] - closest.wall.start[1]
@@ -7274,7 +7883,9 @@ export function FloorplanPanel() {
7274
7883
  }
7275
7884
 
7276
7885
  if (isOpeningPlacementActive) {
7277
- const closest = findClosestWallPoint(planPoint, walls)
7886
+ const closest = findClosestWallPoint(planPoint, walls, {
7887
+ canUseWall: (wall) => !isCurvedWall(wall),
7888
+ })
7278
7889
  if (closest) {
7279
7890
  const dx = closest.wall.end[0] - closest.wall.start[0]
7280
7891
  const dz = closest.wall.end[1] - closest.wall.start[1]
@@ -7361,7 +7972,9 @@ export function FloorplanPanel() {
7361
7972
  isOpeningPlacementActive,
7362
7973
  isPolygonBuildActive,
7363
7974
  isWallBuildActive,
7975
+ isWindowBuildActive,
7364
7976
  isZoneBuildActive,
7977
+ movingOpeningType,
7365
7978
  setSelectedReferenceId,
7366
7979
  setSelection,
7367
7980
  shiftPressed,
@@ -8051,6 +8664,7 @@ export function FloorplanPanel() {
8051
8664
  ...(typeof cloned.metadata === 'object' && cloned.metadata !== null ? cloned.metadata : {}),
8052
8665
  isNew: true,
8053
8666
  }
8667
+ cloned.children = []
8054
8668
 
8055
8669
  try {
8056
8670
  const duplicate = ItemNodeSchema.parse(cloned)
@@ -8082,6 +8696,96 @@ export function FloorplanPanel() {
8082
8696
  },
8083
8697
  [deleteNode, selectedItemEntry, setSelection],
8084
8698
  )
8699
+ const handleSelectedWallMove = useCallback(
8700
+ (event: ReactMouseEvent<HTMLButtonElement>) => {
8701
+ event.stopPropagation()
8702
+
8703
+ const wall = selectedWallEntry?.wall
8704
+ if (!wall) {
8705
+ return
8706
+ }
8707
+
8708
+ sfxEmitter.emit('sfx:item-pick')
8709
+ setMovingNode(wall)
8710
+ setSelection({ selectedIds: [] })
8711
+ },
8712
+ [selectedWallEntry, setMovingNode, setSelection],
8713
+ )
8714
+ const handleSelectedWallDelete = useCallback(
8715
+ (event: ReactMouseEvent<HTMLButtonElement>) => {
8716
+ event.stopPropagation()
8717
+
8718
+ const wall = selectedWallEntry?.wall
8719
+ if (!wall) {
8720
+ return
8721
+ }
8722
+
8723
+ sfxEmitter.emit('sfx:item-delete')
8724
+ deleteNode(wall.id as AnyNodeId)
8725
+ setSelection({ selectedIds: [] })
8726
+ },
8727
+ [deleteNode, selectedWallEntry, setSelection],
8728
+ )
8729
+ const handleSelectedSlabMove = useCallback(
8730
+ (event: ReactMouseEvent<HTMLButtonElement>) => {
8731
+ event.stopPropagation()
8732
+
8733
+ const slab = selectedSlabEntry?.slab
8734
+ if (!slab) {
8735
+ return
8736
+ }
8737
+
8738
+ sfxEmitter.emit('sfx:item-pick')
8739
+ setMovingNode(slab)
8740
+ setSelection({ selectedIds: [] })
8741
+ },
8742
+ [selectedSlabEntry, setMovingNode, setSelection],
8743
+ )
8744
+ const handleSelectedSlabDelete = useCallback(
8745
+ (event: ReactMouseEvent<HTMLButtonElement>) => {
8746
+ event.stopPropagation()
8747
+
8748
+ const slab = selectedSlabEntry?.slab
8749
+ if (!slab) {
8750
+ return
8751
+ }
8752
+
8753
+ sfxEmitter.emit('sfx:item-delete')
8754
+ deleteNode(slab.id as AnyNodeId)
8755
+ setSelection({ selectedIds: [] })
8756
+ },
8757
+ [deleteNode, selectedSlabEntry, setSelection],
8758
+ )
8759
+ const handleSelectedCeilingMove = useCallback(
8760
+ (event: ReactMouseEvent<HTMLButtonElement>) => {
8761
+ event.stopPropagation()
8762
+
8763
+ const ceiling = selectedCeilingEntry?.ceiling
8764
+ if (!ceiling) {
8765
+ return
8766
+ }
8767
+
8768
+ sfxEmitter.emit('sfx:item-pick')
8769
+ setMovingNode(ceiling)
8770
+ setSelection({ selectedIds: [] })
8771
+ },
8772
+ [selectedCeilingEntry, setMovingNode, setSelection],
8773
+ )
8774
+ const handleSelectedCeilingDelete = useCallback(
8775
+ (event: ReactMouseEvent<HTMLButtonElement>) => {
8776
+ event.stopPropagation()
8777
+
8778
+ const ceiling = selectedCeilingEntry?.ceiling
8779
+ if (!ceiling) {
8780
+ return
8781
+ }
8782
+
8783
+ sfxEmitter.emit('sfx:item-delete')
8784
+ deleteNode(ceiling.id as AnyNodeId)
8785
+ setSelection({ selectedIds: [] })
8786
+ },
8787
+ [deleteNode, selectedCeilingEntry, setSelection],
8788
+ )
8085
8789
  const handleStairDoubleClick = useCallback(
8086
8790
  (stair: StairNode, event: ReactMouseEvent<SVGElement>) => {
8087
8791
  emitFloorplanNodeClick(stair.id, 'double-click', event)
@@ -8179,8 +8883,8 @@ export function FloorplanPanel() {
8179
8883
  delete cloned.id
8180
8884
  cloned.metadata = {
8181
8885
  ...(typeof cloned.metadata === 'object' && cloned.metadata !== null ? cloned.metadata : {}),
8182
- isNew: true,
8183
8886
  }
8887
+ delete (cloned.metadata as Record<string, unknown>).isNew
8184
8888
 
8185
8889
  const nextPosition =
8186
8890
  Array.isArray(cloned.position) && cloned.position.length >= 3
@@ -8195,9 +8899,11 @@ export function FloorplanPanel() {
8195
8899
 
8196
8900
  try {
8197
8901
  const duplicate = StairNodeSchema.parse(cloned)
8198
- useScene.getState().createNode(duplicate, stair.parentId as AnyNodeId)
8199
-
8200
8902
  const nodesState = useScene.getState().nodes
8903
+ const createOps: { node: AnyNode; parentId?: AnyNodeId }[] = [
8904
+ { node: duplicate, parentId: stair.parentId as AnyNodeId },
8905
+ ]
8906
+
8201
8907
  for (const childId of stair.children ?? []) {
8202
8908
  const childNode = nodesState[childId]
8203
8909
  if (childNode?.type !== 'stair-segment') {
@@ -8210,19 +8916,20 @@ export function FloorplanPanel() {
8210
8916
  ...(typeof childClone.metadata === 'object' && childClone.metadata !== null
8211
8917
  ? childClone.metadata
8212
8918
  : {}),
8213
- isNew: true,
8214
8919
  }
8920
+ delete (childClone.metadata as Record<string, unknown>).isNew
8215
8921
 
8216
8922
  const childDuplicate = StairSegmentNodeSchema.parse(childClone)
8217
- useScene.getState().createNode(childDuplicate, duplicate.id as AnyNodeId)
8923
+ createOps.push({ node: childDuplicate, parentId: duplicate.id as AnyNodeId })
8218
8924
  }
8219
8925
 
8220
- setMovingNode(duplicate)
8221
- setSelection({ selectedIds: [] })
8926
+ useScene.getState().createNodes(createOps)
8927
+
8928
+ setSelection({ selectedIds: [duplicate.id as AnyNodeId] })
8222
8929
  } catch (error) {
8223
8930
  console.error('Failed to duplicate stair', error)
8224
8931
  }
8225
- }, [selectedStairEntry, setMovingNode, setSelection])
8932
+ }, [selectedStairEntry, setSelection])
8226
8933
  const handleSelectedStairDuplicate = useCallback(
8227
8934
  (event: ReactMouseEvent<HTMLButtonElement>) => {
8228
8935
  event.stopPropagation()
@@ -8288,6 +8995,39 @@ export function FloorplanPanel() {
8288
8995
  },
8289
8996
  [clearWallPlacementDraft, handleWallPlacementPoint, handleWallSelect, isWallBuildActive, mode],
8290
8997
  )
8998
+ const handleWallCurvePointerDown = useCallback(
8999
+ (wall: WallNode, event: ReactPointerEvent<SVGCircleElement>) => {
9000
+ if (event.button !== 0) {
9001
+ return
9002
+ }
9003
+
9004
+ event.preventDefault()
9005
+ event.stopPropagation()
9006
+ setHoveredWallCurveHandleId(null)
9007
+
9008
+ if (isWallBuildActive || mode !== 'select') {
9009
+ return
9010
+ }
9011
+
9012
+ clearWallPlacementDraft()
9013
+ handleWallSelect(wall)
9014
+ clearWallEndpointDrag()
9015
+
9016
+ const currentCurveOffset = normalizeWallCurveOffset(wall, wall.curveOffset ?? 0)
9017
+ wallCurveDragRef.current = {
9018
+ pointerId: event.pointerId,
9019
+ wallId: wall.id,
9020
+ currentCurveOffset,
9021
+ }
9022
+ setWallCurveDraft({
9023
+ wallId: wall.id,
9024
+ curveOffset: currentCurveOffset,
9025
+ })
9026
+ const center = getWallMidpointHandlePoint(wall)
9027
+ setCursorPoint([center.x, center.y])
9028
+ },
9029
+ [clearWallEndpointDrag, clearWallPlacementDraft, handleWallSelect, isWallBuildActive, mode],
9030
+ )
8291
9031
  const handleSlabVertexPointerDown = useCallback(
8292
9032
  (slabId: SlabNode['id'], vertexIndex: number, event: ReactPointerEvent<SVGCircleElement>) => {
8293
9033
  if (event.button !== 0) {
@@ -8634,10 +9374,20 @@ export function FloorplanPanel() {
8634
9374
  zoneVertexDragState,
8635
9375
  ])
8636
9376
 
9377
+ // Lightweight flag that mirrors the conditions under which
9378
+ // FloorplanCursorIndicatorOverlay renders — used to gate cursor-position
9379
+ // tracking. Derived locally here (rather than duplicating the overlay's full
9380
+ // useMemos) so this handler doesn't need to know about catalogCategory.
9381
+ const hasFloorplanCursorIndicator =
9382
+ Boolean(movingOpeningType) ||
9383
+ (mode === 'build' && tool !== null) ||
9384
+ (mode === 'select' && floorplanSelectionTool === 'marquee' && structureLayer !== 'zones') ||
9385
+ mode === 'delete'
9386
+
8637
9387
  const handleSvgPointerMove = useCallback(
8638
9388
  (event: ReactPointerEvent<SVGSVGElement>) => {
8639
9389
  if (
8640
- activeFloorplanCursorIndicator &&
9390
+ hasFloorplanCursorIndicator &&
8641
9391
  !panStateRef.current &&
8642
9392
  !guideInteractionRef.current &&
8643
9393
  !wallEndpointDragRef.current &&
@@ -8646,19 +9396,28 @@ export function FloorplanPanel() {
8646
9396
  !zoneVertexDragState
8647
9397
  ) {
8648
9398
  const rect = event.currentTarget.getBoundingClientRect()
8649
- setFloorplanCursorPosition({
9399
+ const nextPosition = {
8650
9400
  x: event.clientX - rect.left,
8651
9401
  y: event.clientY - rect.top,
8652
- })
9402
+ }
9403
+ setFloorplanCursorPosition((currentPosition) =>
9404
+ currentPosition &&
9405
+ currentPosition.x === nextPosition.x &&
9406
+ currentPosition.y === nextPosition.y
9407
+ ? currentPosition
9408
+ : nextPosition,
9409
+ )
8653
9410
  } else {
8654
- setFloorplanCursorPosition(null)
9411
+ setFloorplanCursorPosition((currentPosition) =>
9412
+ currentPosition === null ? currentPosition : null,
9413
+ )
8655
9414
  }
8656
9415
 
8657
9416
  handlePointerMove(event)
8658
9417
  },
8659
9418
  [
8660
- activeFloorplanCursorIndicator,
8661
9419
  handlePointerMove,
9420
+ hasFloorplanCursorIndicator,
8662
9421
  siteVertexDragState,
8663
9422
  slabVertexDragState,
8664
9423
  zoneVertexDragState,
@@ -9028,84 +9787,25 @@ export function FloorplanPanel() {
9028
9787
  setStructureLayer,
9029
9788
  site,
9030
9789
  ])
9031
- useEffect(() => {
9032
- const handleKeyDown = (event: KeyboardEvent) => {
9033
- const target = event.target as HTMLElement | null
9034
- const isEditableTarget =
9035
- target instanceof HTMLInputElement ||
9036
- target instanceof HTMLTextAreaElement ||
9037
- Boolean(target?.isContentEditable)
9038
-
9039
- if (
9040
- isEditableTarget ||
9041
- !isFloorplanHovered ||
9042
- phase !== 'site' ||
9043
- event.metaKey ||
9044
- event.ctrlKey ||
9045
- event.altKey ||
9046
- event.key.toLowerCase() !== 'v'
9047
- ) {
9048
- return
9049
- }
9050
-
9051
- setFloorplanSelectionTool('click')
9052
- restoreGroundLevelStructureSelection()
9053
- }
9054
-
9055
- window.addEventListener('keydown', handleKeyDown, true)
9056
-
9057
- return () => {
9058
- window.removeEventListener('keydown', handleKeyDown, true)
9790
+ const hasDuplicatableFloorplanSelection = Boolean(
9791
+ selectedItemEntry || selectedOpeningEntry || selectedStairEntry,
9792
+ )
9793
+ const handleDuplicateFloorplanSelection = useCallback(() => {
9794
+ if (selectedOpeningEntry) {
9795
+ duplicateSelectedOpening()
9796
+ return
9059
9797
  }
9060
- }, [isFloorplanHovered, phase, restoreGroundLevelStructureSelection])
9061
- useEffect(() => {
9062
- const handleKeyDown = (event: KeyboardEvent) => {
9063
- if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== 'c') {
9064
- return
9065
- }
9066
-
9067
- if (
9068
- !(isFloorplanHovered && (selectedItemEntry || selectedOpeningEntry || selectedStairEntry))
9069
- ) {
9070
- return
9071
- }
9072
-
9073
- const target = event.target as HTMLElement | null
9074
- const isEditableTarget =
9075
- target instanceof HTMLInputElement ||
9076
- target instanceof HTMLTextAreaElement ||
9077
- Boolean(target?.isContentEditable)
9078
-
9079
- if (isEditableTarget) {
9080
- return
9081
- }
9082
-
9083
- event.preventDefault()
9084
- if (selectedOpeningEntry) {
9085
- duplicateSelectedOpening()
9086
- return
9087
- }
9088
-
9089
- if (selectedItemEntry) {
9090
- duplicateSelectedItem()
9091
- return
9092
- }
9093
-
9094
- if (selectedStairEntry) {
9095
- duplicateSelectedStair()
9096
- }
9798
+ if (selectedItemEntry) {
9799
+ duplicateSelectedItem()
9800
+ return
9097
9801
  }
9098
-
9099
- window.addEventListener('keydown', handleKeyDown, true)
9100
-
9101
- return () => {
9102
- window.removeEventListener('keydown', handleKeyDown, true)
9802
+ if (selectedStairEntry) {
9803
+ duplicateSelectedStair()
9103
9804
  }
9104
9805
  }, [
9105
9806
  duplicateSelectedItem,
9106
9807
  duplicateSelectedOpening,
9107
9808
  duplicateSelectedStair,
9108
- isFloorplanHovered,
9109
9809
  selectedItemEntry,
9110
9810
  selectedOpeningEntry,
9111
9811
  selectedStairEntry,
@@ -9119,9 +9819,6 @@ export function FloorplanPanel() {
9119
9819
  : activeDraftAnchorPoint
9120
9820
  ? palette.draftStroke
9121
9821
  : palette.cursor
9122
- const activeCursorIndicatorPosition =
9123
- mode === 'delete' ? floorplanCursorPosition : floorplanCursorAnchorPosition
9124
-
9125
9822
  return (
9126
9823
  <div
9127
9824
  className="pointer-events-auto flex h-full w-full flex-col overflow-hidden bg-background/95"
@@ -9132,80 +9829,20 @@ export function FloorplanPanel() {
9132
9829
  }}
9133
9830
  ref={containerRef}
9134
9831
  >
9832
+ <FloorplanSiteKeyHandler onRestoreGroundLevel={restoreGroundLevelStructureSelection} />
9833
+ <FloorplanDuplicateHotkey
9834
+ hasDuplicatable={hasDuplicatableFloorplanSelection}
9835
+ onDuplicateSelected={handleDuplicateFloorplanSelection}
9836
+ />
9135
9837
  <div className="relative min-h-0 flex-1" ref={viewportHostRef}>
9136
- {activeFloorplanCursorIndicator && activeCursorIndicatorPosition && !isPanning && (
9137
- <div
9138
- aria-hidden="true"
9139
- className="pointer-events-none absolute z-20"
9140
- style={{
9141
- left: activeCursorIndicatorPosition.x,
9142
- top: activeCursorIndicatorPosition.y,
9143
- }}
9144
- >
9145
- {mode === 'delete' ? (
9146
- <div
9147
- 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)]"
9148
- style={{
9149
- boxShadow: `0 8px 16px -4px rgba(0,0,0,0.3), 0 4px 8px -4px rgba(0,0,0,0.2), 0 0 18px ${floorplanCursorColor}22`,
9150
- transform: `translate(${FLOORPLAN_CURSOR_BADGE_OFFSET_X}px, ${FLOORPLAN_CURSOR_BADGE_OFFSET_Y}px)`,
9151
- }}
9152
- >
9153
- {activeFloorplanCursorIndicator.kind === 'asset' ? (
9154
- <img
9155
- alt=""
9156
- aria-hidden="true"
9157
- className="h-5 w-5 object-contain drop-shadow-[0_2px_4px_rgba(0,0,0,0.5)]"
9158
- src={activeFloorplanCursorIndicator.iconSrc}
9159
- />
9160
- ) : (
9161
- <Icon
9162
- aria-hidden="true"
9163
- className="drop-shadow-[0_2px_4px_rgba(0,0,0,0.5)]"
9164
- color={floorplanCursorColor}
9165
- height={18}
9166
- icon={activeFloorplanCursorIndicator.icon}
9167
- width={18}
9168
- />
9169
- )}
9170
- </div>
9171
- ) : (
9172
- <>
9173
- <div
9174
- className="absolute top-0 left-1/2 w-px -translate-x-1/2 -translate-y-full"
9175
- style={{
9176
- backgroundColor: floorplanCursorColor,
9177
- boxShadow: `0 0 12px ${floorplanCursorColor}55`,
9178
- height: FLOORPLAN_CURSOR_INDICATOR_LINE_HEIGHT,
9179
- }}
9180
- />
9181
- <div
9182
- 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)]"
9183
- style={{
9184
- transform: `translate(-50%, calc(-100% - ${FLOORPLAN_CURSOR_INDICATOR_LINE_HEIGHT}px))`,
9185
- }}
9186
- >
9187
- {activeFloorplanCursorIndicator.kind === 'asset' ? (
9188
- <img
9189
- alt=""
9190
- aria-hidden="true"
9191
- className="h-5 w-5 object-contain drop-shadow-[0_2px_4px_rgba(0,0,0,0.5)]"
9192
- src={activeFloorplanCursorIndicator.iconSrc}
9193
- />
9194
- ) : (
9195
- <Icon
9196
- aria-hidden="true"
9197
- className="drop-shadow-[0_2px_4px_rgba(0,0,0,0.5)]"
9198
- color="white"
9199
- height={18}
9200
- icon={activeFloorplanCursorIndicator.icon}
9201
- width={18}
9202
- />
9203
- )}
9204
- </div>
9205
- </>
9206
- )}
9207
- </div>
9208
- )}
9838
+ <FloorplanCursorIndicatorOverlay
9839
+ cursorAnchorPosition={floorplanCursorAnchorPosition}
9840
+ cursorColor={floorplanCursorColor}
9841
+ cursorPosition={floorplanCursorPosition}
9842
+ floorplanSelectionTool={floorplanSelectionTool}
9843
+ isPanning={isPanning}
9844
+ movingOpeningType={movingOpeningType}
9845
+ />
9209
9846
  {showGuides && canInteractWithGuides && selectedGuide && (
9210
9847
  <FloorplanGuideHandleHint
9211
9848
  anchor={guideHandleHintAnchor}
@@ -9214,60 +9851,41 @@ export function FloorplanPanel() {
9214
9851
  rotationModifierPressed={rotationModifierPressed}
9215
9852
  />
9216
9853
  )}
9217
- {selectedItemActionMenuPosition && isFloorplanHovered && !movingNode && (
9218
- <div
9219
- className="absolute z-30"
9220
- style={{
9221
- left: selectedItemActionMenuPosition.x,
9222
- top: selectedItemActionMenuPosition.y,
9223
- transform: `translate(-50%, calc(-100% - ${FLOORPLAN_ACTION_MENU_OFFSET_Y}px))`,
9224
- }}
9225
- >
9226
- <NodeActionMenu
9227
- onDelete={handleSelectedItemDelete}
9228
- onDuplicate={handleSelectedItemDuplicate}
9229
- onMove={handleSelectedItemMove}
9230
- onPointerDown={(event) => event.stopPropagation()}
9231
- onPointerUp={(event) => event.stopPropagation()}
9232
- />
9233
- </div>
9234
- )}
9235
- {selectedOpeningActionMenuPosition && isFloorplanHovered && !movingNode && (
9236
- <div
9237
- className="absolute z-30"
9238
- style={{
9239
- left: selectedOpeningActionMenuPosition.x,
9240
- top: selectedOpeningActionMenuPosition.y,
9241
- transform: `translate(-50%, calc(-100% - ${FLOORPLAN_ACTION_MENU_OFFSET_Y}px))`,
9242
- }}
9243
- >
9244
- <NodeActionMenu
9245
- onDelete={handleSelectedOpeningDelete}
9246
- onDuplicate={handleSelectedOpeningDuplicate}
9247
- onMove={handleSelectedOpeningMove}
9248
- onPointerDown={(event) => event.stopPropagation()}
9249
- onPointerUp={(event) => event.stopPropagation()}
9250
- />
9251
- </div>
9252
- )}
9253
- {selectedStairActionMenuPosition && isFloorplanHovered && !movingNode && (
9254
- <div
9255
- className="absolute z-30"
9256
- style={{
9257
- left: selectedStairActionMenuPosition.x,
9258
- top: selectedStairActionMenuPosition.y,
9259
- transform: `translate(-50%, calc(-100% - ${FLOORPLAN_ACTION_MENU_OFFSET_Y}px))`,
9260
- }}
9261
- >
9262
- <NodeActionMenu
9263
- onDelete={handleSelectedStairDelete}
9264
- onDuplicate={handleSelectedStairDuplicate}
9265
- onMove={handleSelectedStairMove}
9266
- onPointerDown={(event) => event.stopPropagation()}
9267
- onPointerUp={(event) => event.stopPropagation()}
9268
- />
9269
- </div>
9270
- )}
9854
+ <FloorplanActionMenuLayer
9855
+ ceiling={{
9856
+ position: selectedCeilingActionMenuPosition,
9857
+ onDelete: handleSelectedCeilingDelete,
9858
+ onMove: handleSelectedCeilingMove,
9859
+ }}
9860
+ item={{
9861
+ position: selectedItemActionMenuPosition,
9862
+ onDelete: handleSelectedItemDelete,
9863
+ onDuplicate: handleSelectedItemDuplicate,
9864
+ onMove: handleSelectedItemMove,
9865
+ }}
9866
+ opening={{
9867
+ position: selectedOpeningActionMenuPosition,
9868
+ onDelete: handleSelectedOpeningDelete,
9869
+ onDuplicate: handleSelectedOpeningDuplicate,
9870
+ onMove: handleSelectedOpeningMove,
9871
+ }}
9872
+ slab={{
9873
+ position: selectedSlabActionMenuPosition,
9874
+ onDelete: handleSelectedSlabDelete,
9875
+ onMove: handleSelectedSlabMove,
9876
+ }}
9877
+ stair={{
9878
+ position: selectedStairActionMenuPosition,
9879
+ onDelete: handleSelectedStairDelete,
9880
+ onDuplicate: handleSelectedStairDuplicate,
9881
+ onMove: handleSelectedStairMove,
9882
+ }}
9883
+ wall={{
9884
+ position: selectedWallActionMenuPosition,
9885
+ onDelete: handleSelectedWallDelete,
9886
+ onMove: handleSelectedWallMove,
9887
+ }}
9888
+ />
9271
9889
 
9272
9890
  {!levelNode || levelNode.type !== 'level' ? (
9273
9891
  <div className="flex h-full items-center justify-center px-6 text-center text-muted-foreground text-sm">
@@ -9296,311 +9914,321 @@ export function FloorplanPanel() {
9296
9914
  y={viewBox.minY}
9297
9915
  />
9298
9916
 
9299
- <FloorplanGridLayer
9300
- majorGridPath={majorGridPath}
9301
- minorGridPath={minorGridPath}
9302
- palette={palette}
9303
- showGrid={showGrid}
9304
- />
9305
-
9306
- <FloorplanGuideLayer
9307
- activeGuideInteractionGuideId={activeGuideInteractionGuideId}
9308
- activeGuideInteractionMode={activeGuideInteractionMode}
9309
- guides={displayGuides}
9310
- isInteractive={canInteractWithGuides}
9311
- onGuideSelect={handleGuideSelect}
9312
- onGuideTranslateStart={handleGuideTranslateStart}
9313
- selectedGuideId={selectedGuideId}
9314
- />
9917
+ <g transform={buildingRotationDeg !== 0 ? `rotate(${buildingRotationDeg})` : undefined}>
9918
+ <FloorplanGridLayer
9919
+ majorGridPath={majorGridPath}
9920
+ minorGridPath={minorGridPath}
9921
+ palette={palette}
9922
+ showGrid={showGrid}
9923
+ />
9315
9924
 
9316
- <FloorplanSiteLayer isEditing={isSiteEditActive} sitePolygon={visibleSitePolygon} />
9317
-
9318
- <FloorplanGeometryLayer
9319
- canFocusGeometry={canSelectElementFloorplanGeometry}
9320
- canSelectGeometry={canInteractElementFloorplanGeometry}
9321
- canSelectSlabs={canInteractFloorplanSlabs}
9322
- highlightedIdSet={highlightedFloorplanIdSet}
9323
- hoveredOpeningId={hoveredOpeningId}
9324
- hoveredSlabId={hoveredSlabId}
9325
- hoveredWallId={hoveredWallId}
9326
- isDeleteMode={isDeleteMode}
9327
- onOpeningDoubleClick={handleOpeningDoubleClick}
9328
- onOpeningHoverChange={handleOpeningHoverChange}
9329
- onOpeningPointerDown={handleOpeningPointerDown}
9330
- onOpeningSelect={handleOpeningSelect}
9331
- onSlabDoubleClick={handleSlabDoubleClick}
9332
- onSlabHoverChange={handleSlabHoverChange}
9333
- onSlabSelect={handleSlabSelect}
9334
- onWallClick={handleWallClick}
9335
- onWallDoubleClick={handleWallDoubleClick}
9336
- onWallHoverChange={handleWallHoverChange}
9337
- openingsPolygons={openingsPolygons}
9338
- palette={palette}
9339
- selectedIdSet={selectedIdSet}
9340
- slabPolygons={displaySlabPolygons}
9341
- unit={unit}
9342
- wallPolygons={displayWallPolygons}
9343
- />
9925
+ <FloorplanGuideLayer
9926
+ activeGuideInteractionGuideId={activeGuideInteractionGuideId}
9927
+ activeGuideInteractionMode={activeGuideInteractionMode}
9928
+ guides={displayGuides}
9929
+ isInteractive={canInteractWithGuides}
9930
+ onGuideSelect={handleGuideSelect}
9931
+ onGuideTranslateStart={handleGuideTranslateStart}
9932
+ selectedGuideId={selectedGuideId}
9933
+ />
9344
9934
 
9345
- <FloorplanZoneLayer
9346
- canSelectZones={canInteractFloorplanZones}
9347
- hoveredZoneId={hoveredZoneId}
9348
- isDeleteMode={isDeleteMode}
9349
- onZoneHoverChange={handleZoneHoverChange}
9350
- onZoneSelect={handleZoneSelect}
9351
- palette={palette}
9352
- selectedZoneId={selectedZoneId}
9353
- zonePolygons={visibleZonePolygons}
9354
- />
9935
+ <FloorplanSiteLayer isEditing={isSiteEditActive} sitePolygon={visibleSitePolygon} />
9936
+
9937
+ <FloorplanGeometryLayer
9938
+ canFocusGeometry={canSelectElementFloorplanGeometry}
9939
+ canSelectGeometry={canInteractElementFloorplanGeometry}
9940
+ canSelectSlabs={canInteractFloorplanSlabs}
9941
+ highlightedIdSet={highlightedFloorplanIdSet}
9942
+ hoveredOpeningId={hoveredOpeningId}
9943
+ hoveredSlabId={hoveredSlabId}
9944
+ hoveredWallId={hoveredWallId}
9945
+ isDeleteMode={isDeleteMode}
9946
+ onOpeningDoubleClick={handleOpeningDoubleClick}
9947
+ onOpeningHoverChange={handleOpeningHoverChange}
9948
+ onOpeningPointerDown={handleOpeningPointerDown}
9949
+ onOpeningSelect={handleOpeningSelect}
9950
+ onSlabDoubleClick={handleSlabDoubleClick}
9951
+ onSlabHoverChange={handleSlabHoverChange}
9952
+ onSlabSelect={handleSlabSelect}
9953
+ onWallClick={handleWallClick}
9954
+ onWallDoubleClick={handleWallDoubleClick}
9955
+ onWallHoverChange={handleWallHoverChange}
9956
+ openingsPolygons={openingsPolygons}
9957
+ palette={palette}
9958
+ selectedIdSet={selectedIdSet}
9959
+ slabPolygons={displaySlabPolygons}
9960
+ unit={unit}
9961
+ wallPolygons={displayWallPolygons}
9962
+ />
9355
9963
 
9356
- <FloorplanNodeLayer
9357
- canFocusItems={canFocusFloorplanItems}
9358
- canFocusStairs={canFocusFloorplanStairs}
9359
- canSelectItems={canSelectFloorplanItems}
9360
- canSelectStairs={canSelectFloorplanStairs}
9361
- highlightedIdSet={highlightedFloorplanIdSet}
9362
- hoveredItemId={hoveredItemId}
9363
- hoveredStairId={hoveredStairId}
9364
- isDeleteMode={isDeleteMode}
9365
- isFurnishContextActive={isFloorplanFurnishContextActive}
9366
- itemEntries={floorplanItemEntries}
9367
- onItemDoubleClick={handleItemDoubleClick}
9368
- onItemHoverChange={handleItemHoverChange}
9369
- onItemHoverEnter={handleFloorplanItemHoverEnter}
9370
- onItemPointerDown={handleItemPointerDown}
9371
- onItemSelect={handleItemSelect}
9372
- onStairDoubleClick={handleStairDoubleClick}
9373
- onStairHoverChange={handleStairHoverChange}
9374
- onStairHoverEnter={handleFloorplanStairHoverEnter}
9375
- onStairSelect={handleStairSelect}
9376
- palette={palette}
9377
- selectedIdSet={selectedIdSet}
9378
- stairEntries={renderedFloorplanStairEntries}
9379
- />
9964
+ <FloorplanZoneLayer
9965
+ canSelectZones={canInteractFloorplanZones}
9966
+ hoveredZoneId={hoveredZoneId}
9967
+ isDeleteMode={isDeleteMode}
9968
+ onZoneHoverChange={handleZoneHoverChange}
9969
+ onZoneSelect={handleZoneSelect}
9970
+ palette={palette}
9971
+ selectedZoneId={selectedZoneId}
9972
+ zonePolygons={visibleZonePolygons}
9973
+ />
9380
9974
 
9381
- {/* Zone labels: always visible so users can click to select zones from any mode */}
9382
- <FloorplanZoneLabelLayer
9383
- onLabelHoverChange={handleZoneHoverChange}
9384
- onZoneLabelClick={handleZoneLabelClick}
9385
- selectedZoneId={selectedZoneId}
9386
- svgRef={svgRef}
9387
- viewBox={viewBox}
9388
- zonePolygons={displayZonePolygons}
9389
- />
9975
+ <FloorplanNodeLayer
9976
+ canFocusItems={canFocusFloorplanItems}
9977
+ canFocusStairs={canFocusFloorplanStairs}
9978
+ canSelectItems={canSelectFloorplanItems}
9979
+ canSelectStairs={canSelectFloorplanStairs}
9980
+ highlightedIdSet={highlightedFloorplanIdSet}
9981
+ hoveredItemId={hoveredItemId}
9982
+ hoveredStairId={hoveredStairId}
9983
+ isDeleteMode={isDeleteMode}
9984
+ isFurnishContextActive={isFloorplanFurnishContextActive}
9985
+ itemEntries={floorplanItemEntries}
9986
+ onItemDoubleClick={handleItemDoubleClick}
9987
+ onItemHoverChange={handleItemHoverChange}
9988
+ onItemHoverEnter={handleFloorplanItemHoverEnter}
9989
+ onItemPointerDown={handleItemPointerDown}
9990
+ onItemSelect={handleItemSelect}
9991
+ onStairDoubleClick={handleStairDoubleClick}
9992
+ onStairHoverChange={handleStairHoverChange}
9993
+ onStairHoverEnter={handleFloorplanStairHoverEnter}
9994
+ onStairSelect={handleStairSelect}
9995
+ palette={palette}
9996
+ selectedIdSet={selectedIdSet}
9997
+ stairEntries={renderedFloorplanStairEntries}
9998
+ />
9390
9999
 
9391
- <FloorplanPolygonHandleLayer
9392
- hoveredHandleId={hoveredSiteHandleId}
9393
- midpointHandles={siteMidpointHandles}
9394
- onHandleHoverChange={setHoveredSiteHandleId}
9395
- onMidpointPointerDown={(nodeId, edgeIndex, event) =>
9396
- handleSiteMidpointPointerDown(nodeId as SiteNode['id'], edgeIndex, event)
9397
- }
9398
- onVertexDoubleClick={(nodeId, vertexIndex, event) =>
9399
- handleSiteVertexDoubleClick(nodeId as SiteNode['id'], vertexIndex, event)
9400
- }
9401
- onVertexPointerDown={(nodeId, vertexIndex, event) =>
9402
- handleSiteVertexPointerDown(nodeId as SiteNode['id'], vertexIndex, event)
9403
- }
9404
- palette={palette}
9405
- vertexHandles={siteVertexHandles}
9406
- />
10000
+ {/* Zone labels: always visible so users can click to select zones from any mode */}
10001
+ <FloorplanZoneLabelLayer
10002
+ onLabelHoverChange={handleZoneHoverChange}
10003
+ onZoneLabelClick={handleZoneLabelClick}
10004
+ selectedZoneId={selectedZoneId}
10005
+ svgRef={svgRef}
10006
+ viewBox={viewBox}
10007
+ zonePolygons={displayZonePolygons}
10008
+ />
9407
10009
 
9408
- {isMarqueeSelectionToolActive && (
9409
- <rect
9410
- fill="transparent"
9411
- height={viewBox.height}
9412
- onClick={(event) => {
9413
- event.preventDefault()
9414
- event.stopPropagation()
9415
- }}
9416
- onDoubleClick={(event) => {
9417
- event.preventDefault()
9418
- event.stopPropagation()
9419
- }}
9420
- onPointerCancel={handleMarqueePointerCancel}
9421
- onPointerDown={handleMarqueePointerDown}
9422
- onPointerMove={handleMarqueePointerMove}
9423
- onPointerUp={handleMarqueePointerUp}
9424
- style={{ cursor: EDITOR_CURSOR }}
9425
- width={viewBox.width}
9426
- x={viewBox.minX}
9427
- y={viewBox.minY}
10010
+ <FloorplanPolygonHandleLayer
10011
+ hoveredHandleId={hoveredSiteHandleId}
10012
+ midpointHandles={siteMidpointHandles}
10013
+ onHandleHoverChange={setHoveredSiteHandleId}
10014
+ onMidpointPointerDown={(nodeId, edgeIndex, event) =>
10015
+ handleSiteMidpointPointerDown(nodeId as SiteNode['id'], edgeIndex, event)
10016
+ }
10017
+ onVertexDoubleClick={(nodeId, vertexIndex, event) =>
10018
+ handleSiteVertexDoubleClick(nodeId as SiteNode['id'], vertexIndex, event)
10019
+ }
10020
+ onVertexPointerDown={(nodeId, vertexIndex, event) =>
10021
+ handleSiteVertexPointerDown(nodeId as SiteNode['id'], vertexIndex, event)
10022
+ }
10023
+ palette={palette}
10024
+ vertexHandles={siteVertexHandles}
9428
10025
  />
9429
- )}
9430
10026
 
9431
- {visibleSvgMarqueeBounds && (
9432
- <>
10027
+ {isMarqueeSelectionToolActive && (
9433
10028
  <rect
9434
- fill={palette.cursor}
9435
- fillOpacity={0.12}
9436
- height={visibleSvgMarqueeBounds.height}
9437
- pointerEvents="none"
9438
- stroke={palette.cursor}
9439
- strokeOpacity={0.26}
9440
- strokeWidth={FLOORPLAN_MARQUEE_GLOW_WIDTH}
10029
+ fill="transparent"
10030
+ height={viewBox.height}
10031
+ onClick={(event) => {
10032
+ event.preventDefault()
10033
+ event.stopPropagation()
10034
+ }}
10035
+ onDoubleClick={(event) => {
10036
+ event.preventDefault()
10037
+ event.stopPropagation()
10038
+ }}
10039
+ onPointerCancel={handleMarqueePointerCancel}
10040
+ onPointerDown={handleMarqueePointerDown}
10041
+ onPointerMove={handleMarqueePointerMove}
10042
+ onPointerUp={handleMarqueePointerUp}
10043
+ style={{ cursor: EDITOR_CURSOR }}
10044
+ width={viewBox.width}
10045
+ x={viewBox.minX}
10046
+ y={viewBox.minY}
10047
+ />
10048
+ )}
10049
+
10050
+ {visibleSvgMarqueeBounds && (
10051
+ <>
10052
+ <rect
10053
+ fill={palette.cursor}
10054
+ fillOpacity={0.12}
10055
+ height={visibleSvgMarqueeBounds.height}
10056
+ pointerEvents="none"
10057
+ stroke={palette.cursor}
10058
+ strokeOpacity={0.26}
10059
+ strokeWidth={FLOORPLAN_MARQUEE_GLOW_WIDTH}
10060
+ vectorEffect="non-scaling-stroke"
10061
+ width={visibleSvgMarqueeBounds.width}
10062
+ x={visibleSvgMarqueeBounds.x}
10063
+ y={visibleSvgMarqueeBounds.y}
10064
+ />
10065
+ <rect
10066
+ fill="none"
10067
+ height={visibleSvgMarqueeBounds.height}
10068
+ pointerEvents="none"
10069
+ stroke={palette.cursor}
10070
+ strokeOpacity={0.96}
10071
+ strokeWidth={FLOORPLAN_MARQUEE_OUTLINE_WIDTH}
10072
+ vectorEffect="non-scaling-stroke"
10073
+ width={visibleSvgMarqueeBounds.width}
10074
+ x={visibleSvgMarqueeBounds.x}
10075
+ y={visibleSvgMarqueeBounds.y}
10076
+ />
10077
+ </>
10078
+ )}
10079
+
10080
+ {draftPolygon && (
10081
+ <polygon
10082
+ fill={palette.draftFill}
10083
+ fillOpacity={0.35}
10084
+ points={draftPolygonPoints ?? undefined}
10085
+ stroke={palette.draftStroke}
10086
+ strokeDasharray="0.24 0.12"
10087
+ strokeWidth="0.07"
9441
10088
  vectorEffect="non-scaling-stroke"
9442
- width={visibleSvgMarqueeBounds.width}
9443
- x={visibleSvgMarqueeBounds.x}
9444
- y={visibleSvgMarqueeBounds.y}
9445
10089
  />
9446
- <rect
10090
+ )}
10091
+
10092
+ {polygonDraftPolygonPoints && (
10093
+ <polygon
10094
+ fill={palette.draftFill}
10095
+ fillOpacity={0.2}
10096
+ points={polygonDraftPolygonPoints}
10097
+ stroke="none"
10098
+ />
10099
+ )}
10100
+
10101
+ {polygonDraftPolylinePoints && (
10102
+ <polyline
9447
10103
  fill="none"
9448
- height={visibleSvgMarqueeBounds.height}
9449
- pointerEvents="none"
9450
- stroke={palette.cursor}
9451
- strokeOpacity={0.96}
9452
- strokeWidth={FLOORPLAN_MARQUEE_OUTLINE_WIDTH}
10104
+ points={polygonDraftPolylinePoints}
10105
+ stroke={palette.draftStroke}
10106
+ strokeLinecap="round"
10107
+ strokeLinejoin="round"
10108
+ strokeWidth="0.08"
9453
10109
  vectorEffect="non-scaling-stroke"
9454
- width={visibleSvgMarqueeBounds.width}
9455
- x={visibleSvgMarqueeBounds.x}
9456
- y={visibleSvgMarqueeBounds.y}
9457
10110
  />
9458
- </>
9459
- )}
10111
+ )}
9460
10112
 
9461
- {draftPolygon && (
9462
- <polygon
9463
- fill={palette.draftFill}
9464
- fillOpacity={0.35}
9465
- points={draftPolygonPoints ?? undefined}
9466
- stroke={palette.draftStroke}
9467
- strokeDasharray="0.24 0.12"
9468
- strokeWidth="0.07"
9469
- vectorEffect="non-scaling-stroke"
9470
- />
9471
- )}
10113
+ {polygonDraftClosingSegment && (
10114
+ <line
10115
+ stroke={palette.draftStroke}
10116
+ strokeDasharray="0.16 0.1"
10117
+ strokeLinecap="round"
10118
+ strokeOpacity={0.75}
10119
+ strokeWidth="0.05"
10120
+ vectorEffect="non-scaling-stroke"
10121
+ x1={polygonDraftClosingSegment.x1}
10122
+ x2={polygonDraftClosingSegment.x2}
10123
+ y1={polygonDraftClosingSegment.y1}
10124
+ y2={polygonDraftClosingSegment.y2}
10125
+ />
10126
+ )}
9472
10127
 
9473
- {polygonDraftPolygonPoints && (
9474
- <polygon
9475
- fill={palette.draftFill}
9476
- fillOpacity={0.2}
9477
- points={polygonDraftPolygonPoints}
9478
- stroke="none"
9479
- />
9480
- )}
10128
+ {activePolygonDraftPoints.map((point, index) => (
10129
+ <circle
10130
+ cx={toSvgX(point[0])}
10131
+ cy={toSvgY(point[1])}
10132
+ fill={index === 0 ? palette.anchor : palette.draftStroke}
10133
+ fillOpacity={0.95}
10134
+ key={`polygon-draft-${index}`}
10135
+ pointerEvents="none"
10136
+ r={index === 0 ? 0.12 : 0.1}
10137
+ vectorEffect="non-scaling-stroke"
10138
+ />
10139
+ ))}
9481
10140
 
9482
- {polygonDraftPolylinePoints && (
9483
- <polyline
9484
- fill="none"
9485
- points={polygonDraftPolylinePoints}
9486
- stroke={palette.draftStroke}
9487
- strokeLinecap="round"
9488
- strokeLinejoin="round"
9489
- strokeWidth="0.08"
9490
- vectorEffect="non-scaling-stroke"
10141
+ <FloorplanWallEndpointLayer
10142
+ endpointHandles={wallEndpointHandles}
10143
+ hoveredEndpointId={hoveredEndpointId}
10144
+ onEndpointHoverChange={setHoveredEndpointId}
10145
+ onWallEndpointPointerDown={handleWallEndpointPointerDown}
10146
+ palette={palette}
9491
10147
  />
9492
- )}
9493
10148
 
9494
- {polygonDraftClosingSegment && (
9495
- <line
9496
- stroke={palette.draftStroke}
9497
- strokeDasharray="0.16 0.1"
9498
- strokeLinecap="round"
9499
- strokeOpacity={0.75}
9500
- strokeWidth="0.05"
9501
- vectorEffect="non-scaling-stroke"
9502
- x1={polygonDraftClosingSegment.x1}
9503
- x2={polygonDraftClosingSegment.x2}
9504
- y1={polygonDraftClosingSegment.y1}
9505
- y2={polygonDraftClosingSegment.y2}
10149
+ <FloorplanWallCurveHandleLayer
10150
+ curveHandles={wallCurveHandles}
10151
+ hoveredHandleId={hoveredWallCurveHandleId}
10152
+ onHandleHoverChange={setHoveredWallCurveHandleId}
10153
+ onWallCurvePointerDown={handleWallCurvePointerDown}
10154
+ palette={palette}
9506
10155
  />
9507
- )}
9508
10156
 
9509
- {activePolygonDraftPoints.map((point, index) => (
9510
- <circle
9511
- cx={toSvgX(point[0])}
9512
- cy={toSvgY(point[1])}
9513
- fill={index === 0 ? palette.anchor : palette.draftStroke}
9514
- fillOpacity={0.95}
9515
- key={`polygon-draft-${index}`}
9516
- pointerEvents="none"
9517
- r={index === 0 ? 0.12 : 0.1}
9518
- vectorEffect="non-scaling-stroke"
10157
+ <FloorplanPolygonHandleLayer
10158
+ hoveredHandleId={hoveredSlabHandleId}
10159
+ midpointHandles={slabMidpointHandles}
10160
+ onHandleHoverChange={setHoveredSlabHandleId}
10161
+ onMidpointPointerDown={(nodeId, edgeIndex, event) =>
10162
+ handleSlabMidpointPointerDown(nodeId as SlabNode['id'], edgeIndex, event)
10163
+ }
10164
+ onVertexDoubleClick={(nodeId, vertexIndex, event) =>
10165
+ handleSlabVertexDoubleClick(nodeId as SlabNode['id'], vertexIndex, event)
10166
+ }
10167
+ onVertexPointerDown={(nodeId, vertexIndex, event) =>
10168
+ handleSlabVertexPointerDown(nodeId as SlabNode['id'], vertexIndex, event)
10169
+ }
10170
+ palette={palette}
10171
+ vertexHandles={slabVertexHandles}
9519
10172
  />
9520
- ))}
9521
10173
 
9522
- <FloorplanWallEndpointLayer
9523
- endpointHandles={wallEndpointHandles}
9524
- hoveredEndpointId={hoveredEndpointId}
9525
- onEndpointHoverChange={setHoveredEndpointId}
9526
- onWallEndpointPointerDown={handleWallEndpointPointerDown}
9527
- palette={palette}
9528
- />
9529
-
9530
- <FloorplanPolygonHandleLayer
9531
- hoveredHandleId={hoveredSlabHandleId}
9532
- midpointHandles={slabMidpointHandles}
9533
- onHandleHoverChange={setHoveredSlabHandleId}
9534
- onMidpointPointerDown={(nodeId, edgeIndex, event) =>
9535
- handleSlabMidpointPointerDown(nodeId as SlabNode['id'], edgeIndex, event)
9536
- }
9537
- onVertexDoubleClick={(nodeId, vertexIndex, event) =>
9538
- handleSlabVertexDoubleClick(nodeId as SlabNode['id'], vertexIndex, event)
9539
- }
9540
- onVertexPointerDown={(nodeId, vertexIndex, event) =>
9541
- handleSlabVertexPointerDown(nodeId as SlabNode['id'], vertexIndex, event)
9542
- }
9543
- palette={palette}
9544
- vertexHandles={slabVertexHandles}
9545
- />
9546
-
9547
- <FloorplanPolygonHandleLayer
9548
- hoveredHandleId={hoveredZoneHandleId}
9549
- midpointHandles={zoneMidpointHandles}
9550
- onHandleHoverChange={setHoveredZoneHandleId}
9551
- onMidpointPointerDown={(nodeId, edgeIndex, event) =>
9552
- handleZoneMidpointPointerDown(nodeId as ZoneNodeType['id'], edgeIndex, event)
9553
- }
9554
- onVertexDoubleClick={(nodeId, vertexIndex, event) =>
9555
- handleZoneVertexDoubleClick(nodeId as ZoneNodeType['id'], vertexIndex, event)
9556
- }
9557
- onVertexPointerDown={(nodeId, vertexIndex, event) =>
9558
- handleZoneVertexPointerDown(nodeId as ZoneNodeType['id'], vertexIndex, event)
9559
- }
9560
- palette={palette}
9561
- vertexHandles={zoneVertexHandles}
9562
- />
9563
-
9564
- {selectedGuide && showGuides && (
9565
- <FloorplanGuideSelectionOverlay
9566
- guide={selectedGuide}
9567
- isDarkMode={theme === 'dark'}
9568
- onCornerHoverChange={setHoveredGuideCorner}
9569
- onCornerPointerDown={handleGuideCornerPointerDown}
9570
- rotationModifierPressed={rotationModifierPressed}
9571
- showHandles={canInteractWithGuides}
10174
+ <FloorplanPolygonHandleLayer
10175
+ hoveredHandleId={hoveredZoneHandleId}
10176
+ midpointHandles={zoneMidpointHandles}
10177
+ onHandleHoverChange={setHoveredZoneHandleId}
10178
+ onMidpointPointerDown={(nodeId, edgeIndex, event) =>
10179
+ handleZoneMidpointPointerDown(nodeId as ZoneNodeType['id'], edgeIndex, event)
10180
+ }
10181
+ onVertexDoubleClick={(nodeId, vertexIndex, event) =>
10182
+ handleZoneVertexDoubleClick(nodeId as ZoneNodeType['id'], vertexIndex, event)
10183
+ }
10184
+ onVertexPointerDown={(nodeId, vertexIndex, event) =>
10185
+ handleZoneVertexPointerDown(nodeId as ZoneNodeType['id'], vertexIndex, event)
10186
+ }
10187
+ palette={palette}
10188
+ vertexHandles={zoneVertexHandles}
9572
10189
  />
9573
- )}
9574
10190
 
9575
- {cursorPoint && (
9576
- <g>
9577
- <circle
9578
- cx={toSvgX(cursorPoint[0])}
9579
- cy={toSvgY(cursorPoint[1])}
9580
- fill={floorplanCursorColor}
9581
- fillOpacity={0.25}
9582
- r={FLOORPLAN_CURSOR_MARKER_GLOW_RADIUS}
10191
+ {selectedGuide && showGuides && (
10192
+ <FloorplanGuideSelectionOverlay
10193
+ guide={selectedGuide}
10194
+ isDarkMode={theme === 'dark'}
10195
+ onCornerHoverChange={setHoveredGuideCorner}
10196
+ onCornerPointerDown={handleGuideCornerPointerDown}
10197
+ rotationModifierPressed={rotationModifierPressed}
10198
+ showHandles={canInteractWithGuides}
9583
10199
  />
10200
+ )}
10201
+
10202
+ {cursorPoint && (
10203
+ <g>
10204
+ <circle
10205
+ cx={toSvgX(cursorPoint[0])}
10206
+ cy={toSvgY(cursorPoint[1])}
10207
+ fill={floorplanCursorColor}
10208
+ fillOpacity={0.25}
10209
+ r={FLOORPLAN_CURSOR_MARKER_GLOW_RADIUS}
10210
+ />
10211
+ <circle
10212
+ cx={toSvgX(cursorPoint[0])}
10213
+ cy={toSvgY(cursorPoint[1])}
10214
+ fill={floorplanCursorColor}
10215
+ fillOpacity={0.9}
10216
+ r={FLOORPLAN_CURSOR_MARKER_CORE_RADIUS}
10217
+ />
10218
+ </g>
10219
+ )}
10220
+
10221
+ {activeDraftAnchorPoint && (
9584
10222
  <circle
9585
- cx={toSvgX(cursorPoint[0])}
9586
- cy={toSvgY(cursorPoint[1])}
9587
- fill={floorplanCursorColor}
9588
- fillOpacity={0.9}
9589
- r={FLOORPLAN_CURSOR_MARKER_CORE_RADIUS}
10223
+ cx={toSvgX(activeDraftAnchorPoint[0])}
10224
+ cy={toSvgY(activeDraftAnchorPoint[1])}
10225
+ fill={palette.anchor}
10226
+ fillOpacity={0.95}
10227
+ r="0.14"
10228
+ vectorEffect="non-scaling-stroke"
9590
10229
  />
9591
- </g>
9592
- )}
9593
-
9594
- {activeDraftAnchorPoint && (
9595
- <circle
9596
- cx={toSvgX(activeDraftAnchorPoint[0])}
9597
- cy={toSvgY(activeDraftAnchorPoint[1])}
9598
- fill={palette.anchor}
9599
- fillOpacity={0.95}
9600
- r="0.14"
9601
- vectorEffect="non-scaling-stroke"
9602
- />
9603
- )}
10230
+ )}
10231
+ </g>
9604
10232
  </svg>
9605
10233
  )}
9606
10234
  </div>