Add lower floor reference overlay
This commit is contained in:
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { Icon } from '@iconify/react'
|
import { Icon } from '@iconify/react'
|
||||||
import {
|
import {
|
||||||
|
type AnyNode,
|
||||||
type AnyNodeId,
|
type AnyNodeId,
|
||||||
type BuildingNode,
|
type BuildingNode,
|
||||||
type CeilingNode,
|
type CeilingNode,
|
||||||
@@ -57,6 +58,7 @@ import { createPortal } from 'react-dom'
|
|||||||
import {
|
import {
|
||||||
buildFloorplanItemEntry,
|
buildFloorplanItemEntry,
|
||||||
buildFloorplanStairEntry as buildSharedFloorplanStairEntry,
|
buildFloorplanStairEntry as buildSharedFloorplanStairEntry,
|
||||||
|
collectLevelDescendants,
|
||||||
getFloorplanWall as getSharedFloorplanWall,
|
getFloorplanWall as getSharedFloorplanWall,
|
||||||
rotatePlanVector as rotateSharedPlanVector,
|
rotatePlanVector as rotateSharedPlanVector,
|
||||||
type FloorplanNodeTransform as SharedFloorplanNodeTransform,
|
type FloorplanNodeTransform as SharedFloorplanNodeTransform,
|
||||||
@@ -582,6 +584,15 @@ type FloorplanItemEntry = {
|
|||||||
depth: number
|
depth: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ReferenceFloorData = {
|
||||||
|
ceilingPolygons: CeilingPolygonEntry[]
|
||||||
|
fenceEntries: FloorplanFenceEntry[]
|
||||||
|
itemEntries: FloorplanItemEntry[]
|
||||||
|
openingPolygons: OpeningPolygonEntry[]
|
||||||
|
slabPolygons: SlabPolygonEntry[]
|
||||||
|
wallPolygons: WallPolygonEntry[]
|
||||||
|
}
|
||||||
|
|
||||||
type FloorplanStairSegmentEntry = {
|
type FloorplanStairSegmentEntry = {
|
||||||
centerLine: FloorplanLineSegment | null
|
centerLine: FloorplanLineSegment | null
|
||||||
innerPoints: string
|
innerPoints: string
|
||||||
@@ -3611,6 +3622,97 @@ function FloorplanGuideHandleHint({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const FloorplanReferenceFloorLayer = memo(function FloorplanReferenceFloorLayer({
|
||||||
|
data,
|
||||||
|
opacity,
|
||||||
|
}: {
|
||||||
|
data: ReferenceFloorData | null
|
||||||
|
opacity: number
|
||||||
|
}) {
|
||||||
|
if (!data) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const clampedOpacity = clamp(opacity, 0.1, 0.8)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g opacity={clampedOpacity} pointerEvents="none">
|
||||||
|
{data.slabPolygons.map(({ path, slab }) => (
|
||||||
|
<path
|
||||||
|
d={path}
|
||||||
|
fill="rgba(100, 116, 139, 0.14)"
|
||||||
|
fillRule="evenodd"
|
||||||
|
key={slab.id}
|
||||||
|
stroke="rgba(100, 116, 139, 0.45)"
|
||||||
|
strokeWidth={1.2}
|
||||||
|
vectorEffect="non-scaling-stroke"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{data.ceilingPolygons.map(({ ceiling, path }) => (
|
||||||
|
<path
|
||||||
|
d={path}
|
||||||
|
fill="rgba(245, 158, 11, 0.06)"
|
||||||
|
fillRule="evenodd"
|
||||||
|
key={ceiling.id}
|
||||||
|
stroke="rgba(245, 158, 11, 0.28)"
|
||||||
|
strokeDasharray="6 4"
|
||||||
|
strokeWidth={1}
|
||||||
|
vectorEffect="non-scaling-stroke"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{data.wallPolygons.map(({ polygon, points, wall }) =>
|
||||||
|
polygon.length >= 3 ? (
|
||||||
|
<polygon
|
||||||
|
fill="rgba(100, 116, 139, 0.18)"
|
||||||
|
key={wall.id}
|
||||||
|
points={points}
|
||||||
|
stroke="rgba(71, 85, 105, 0.7)"
|
||||||
|
strokeWidth={1.25}
|
||||||
|
vectorEffect="non-scaling-stroke"
|
||||||
|
/>
|
||||||
|
) : null,
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data.fenceEntries.map(({ fence, path }) => (
|
||||||
|
<path
|
||||||
|
d={path}
|
||||||
|
fill="none"
|
||||||
|
key={fence.id}
|
||||||
|
stroke="rgba(71, 85, 105, 0.65)"
|
||||||
|
strokeDasharray="5 4"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
vectorEffect="non-scaling-stroke"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{data.openingPolygons.map(({ opening, points }) => (
|
||||||
|
<polygon
|
||||||
|
fill="rgba(255, 255, 255, 0.72)"
|
||||||
|
key={opening.id}
|
||||||
|
points={points}
|
||||||
|
stroke="rgba(51, 65, 85, 0.72)"
|
||||||
|
strokeWidth={1.1}
|
||||||
|
vectorEffect="non-scaling-stroke"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{data.itemEntries.map(({ item, points }) => (
|
||||||
|
<polygon
|
||||||
|
fill="rgba(71, 85, 105, 0.12)"
|
||||||
|
key={item.id}
|
||||||
|
points={points}
|
||||||
|
stroke="rgba(71, 85, 105, 0.5)"
|
||||||
|
strokeWidth={1}
|
||||||
|
vectorEffect="non-scaling-stroke"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
|
const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
|
||||||
canFocusGeometry,
|
canFocusGeometry,
|
||||||
canSelectGeometry,
|
canSelectGeometry,
|
||||||
@@ -6065,6 +6167,10 @@ export function FloorplanPanel() {
|
|||||||
const [hoveredGuideCorner, setHoveredGuideCorner] = useState<GuideCorner | null>(null)
|
const [hoveredGuideCorner, setHoveredGuideCorner] = useState<GuideCorner | null>(null)
|
||||||
const floorplanSelectionTool = useEditor((s) => s.floorplanSelectionTool)
|
const floorplanSelectionTool = useEditor((s) => s.floorplanSelectionTool)
|
||||||
const setFloorplanSelectionTool = useEditor((s) => s.setFloorplanSelectionTool)
|
const setFloorplanSelectionTool = useEditor((s) => s.setFloorplanSelectionTool)
|
||||||
|
const showReferenceFloor = useEditor((s) => s.showReferenceFloor)
|
||||||
|
const referenceFloorOffset = useEditor((s) => s.referenceFloorOffset)
|
||||||
|
const referenceFloorOpacity = useEditor((s) => s.referenceFloorOpacity)
|
||||||
|
const sceneNodes = useScene((state) => state.nodes)
|
||||||
const [floorplanMarqueeState, setFloorplanMarqueeState] = useState<FloorplanMarqueeState | null>(
|
const [floorplanMarqueeState, setFloorplanMarqueeState] = useState<FloorplanMarqueeState | null>(
|
||||||
null,
|
null,
|
||||||
)
|
)
|
||||||
@@ -6658,6 +6764,174 @@ export function FloorplanPanel() {
|
|||||||
]
|
]
|
||||||
})
|
})
|
||||||
}, [cursorPoint, floorplanItems, levelDescendantNodeById, movingFloorplanNodeRevision])
|
}, [cursorPoint, floorplanItems, levelDescendantNodeById, movingFloorplanNodeRevision])
|
||||||
|
const referenceFloorLevel = useMemo(() => {
|
||||||
|
if (!(showReferenceFloor && levelNode)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const lowerLevels = floorplanLevels
|
||||||
|
.filter((floorLevel) => floorLevel.id !== levelNode.id && floorLevel.level < levelNode.level)
|
||||||
|
.sort((a, b) => b.level - a.level)
|
||||||
|
|
||||||
|
return lowerLevels[referenceFloorOffset - 1] ?? lowerLevels[0] ?? null
|
||||||
|
}, [floorplanLevels, levelNode, referenceFloorOffset, showReferenceFloor])
|
||||||
|
const referenceFloorData = useMemo<ReferenceFloorData | null>(() => {
|
||||||
|
if (!referenceFloorLevel) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const children = referenceFloorLevel.children
|
||||||
|
.map((childId) => sceneNodes[childId])
|
||||||
|
.filter((node): node is AnyNode => Boolean(node && node.visible !== false))
|
||||||
|
const referenceWalls = children.filter((node): node is WallNode => node.type === 'wall')
|
||||||
|
const referenceFences = children.filter((node): node is FenceNode => node.type === 'fence')
|
||||||
|
const referenceSlabs = children.filter((node): node is SlabNode => node.type === 'slab')
|
||||||
|
const referenceCeilings = children.filter(
|
||||||
|
(node): node is CeilingNode => node.type === 'ceiling',
|
||||||
|
)
|
||||||
|
const referenceDescendants = collectLevelDescendants(
|
||||||
|
referenceFloorLevel,
|
||||||
|
sceneNodes as Record<string, AnyNode>,
|
||||||
|
).filter((node) => node.visible !== false)
|
||||||
|
const referenceDescendantById = new Map(referenceDescendants.map((node) => [node.id, node]))
|
||||||
|
|
||||||
|
const referenceFloorplanWalls = referenceWalls.map(getFloorplanWall)
|
||||||
|
const referenceWallMiterData = calculateLevelMiters(referenceFloorplanWalls)
|
||||||
|
const referenceFloorplanWallById = new Map(
|
||||||
|
referenceFloorplanWalls.map((wall) => [wall.id, wall] as const),
|
||||||
|
)
|
||||||
|
|
||||||
|
const wallPolygons = referenceWalls.map((wall) => {
|
||||||
|
const floorplanWall = referenceFloorplanWallById.get(wall.id) ?? getFloorplanWall(wall)
|
||||||
|
const polygon = getWallPlanFootprint(floorplanWall, referenceWallMiterData)
|
||||||
|
|
||||||
|
return {
|
||||||
|
points: formatPolygonPoints(polygon),
|
||||||
|
polygon,
|
||||||
|
wall,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const openingPolygons = referenceDescendants.flatMap((node) => {
|
||||||
|
if (!(node.type === 'door' || node.type === 'window')) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const wall = referenceFloorplanWallById.get(node.parentId as WallNode['id'])
|
||||||
|
if (!wall) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const polygon = getOpeningFootprint(wall, node)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
opening: node,
|
||||||
|
points: formatPolygonPoints(polygon),
|
||||||
|
polygon,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const slabPolygons = referenceSlabs.flatMap((slab) => {
|
||||||
|
const polygon = toFloorplanPolygon(slab.polygon)
|
||||||
|
if (polygon.length < 3) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const holes = (slab.holes ?? [])
|
||||||
|
.map((hole) => toFloorplanPolygon(hole))
|
||||||
|
.filter((hole) => hole.length >= 3)
|
||||||
|
const visualPolygon = toFloorplanPolygon(getRenderableSlabPolygon(slab))
|
||||||
|
const visualHoles = holes
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
slab,
|
||||||
|
polygon,
|
||||||
|
holes,
|
||||||
|
visualPolygon,
|
||||||
|
visualHoles,
|
||||||
|
path: formatPolygonPath(visualPolygon, visualHoles),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const ceilingPolygons = referenceCeilings.flatMap((ceiling) => {
|
||||||
|
const polygon = toFloorplanPolygon(ceiling.polygon)
|
||||||
|
if (polygon.length < 3) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const holes = (ceiling.holes ?? [])
|
||||||
|
.map((hole) => toFloorplanPolygon(hole))
|
||||||
|
.filter((hole) => hole.length >= 3)
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
ceiling,
|
||||||
|
polygon,
|
||||||
|
holes,
|
||||||
|
path: formatPolygonPath(polygon, holes),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const fenceEntries = referenceFences.flatMap((fence) => {
|
||||||
|
const centerline = isCurvedWall(fence)
|
||||||
|
? sampleWallCenterline(fence, 24)
|
||||||
|
: [
|
||||||
|
{ x: fence.start[0], y: fence.start[1] },
|
||||||
|
{ x: fence.end[0], y: fence.end[1] },
|
||||||
|
]
|
||||||
|
const path = buildSvgPolylinePath(centerline)
|
||||||
|
if (!path) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return [{ fence, centerline, markerFrames: [], path }]
|
||||||
|
})
|
||||||
|
|
||||||
|
const transformCache = new Map<string, SharedFloorplanNodeTransform | null>()
|
||||||
|
const itemEntries = referenceDescendants.flatMap((node) => {
|
||||||
|
if (
|
||||||
|
!(
|
||||||
|
node.type === 'item' &&
|
||||||
|
node.asset.category !== 'door' &&
|
||||||
|
node.asset.category !== 'window'
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = buildFloorplanItemEntry(node, referenceDescendantById, transformCache)
|
||||||
|
if (!entry) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
dimensionPolygon: entry.dimensionPolygon,
|
||||||
|
item: entry.item,
|
||||||
|
points: formatPolygonPoints(entry.polygon),
|
||||||
|
polygon: entry.polygon,
|
||||||
|
usesRealMesh: entry.usesRealMesh,
|
||||||
|
center: entry.center,
|
||||||
|
rotation: entry.rotation,
|
||||||
|
width: entry.width,
|
||||||
|
depth: entry.depth,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
ceilingPolygons,
|
||||||
|
fenceEntries,
|
||||||
|
itemEntries,
|
||||||
|
openingPolygons,
|
||||||
|
slabPolygons,
|
||||||
|
wallPolygons,
|
||||||
|
}
|
||||||
|
}, [referenceFloorLevel, sceneNodes])
|
||||||
const hasPendingItemMeshFootprints = floorplanItemEntries.some((entry) => !entry.usesRealMesh)
|
const hasPendingItemMeshFootprints = floorplanItemEntries.some((entry) => !entry.usesRealMesh)
|
||||||
const floorplanStairEntries = useMemo(
|
const floorplanStairEntries = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -13901,6 +14175,11 @@ export function FloorplanPanel() {
|
|||||||
showGrid={showGrid}
|
showGrid={showGrid}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<FloorplanReferenceFloorLayer
|
||||||
|
data={referenceFloorData}
|
||||||
|
opacity={referenceFloorOpacity}
|
||||||
|
/>
|
||||||
|
|
||||||
<FloorplanGuideLayer
|
<FloorplanGuideLayer
|
||||||
activeGuideInteractionGuideId={activeGuideInteractionGuideId}
|
activeGuideInteractionGuideId={activeGuideInteractionGuideId}
|
||||||
activeGuideInteractionMode={activeGuideInteractionMode}
|
activeGuideInteractionMode={activeGuideInteractionMode}
|
||||||
|
|||||||
@@ -2,13 +2,14 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
type AnyNodeId,
|
type AnyNodeId,
|
||||||
|
type BuildingNode,
|
||||||
type GuideNode,
|
type GuideNode,
|
||||||
type LevelNode,
|
type LevelNode,
|
||||||
type ScanNode,
|
type ScanNode,
|
||||||
useScene,
|
useScene,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { Check, ChevronDown, Plus, Trash2 } from 'lucide-react'
|
import { Check, ChevronDown, Eye, EyeOff, Layers2, Plus, Trash2 } from 'lucide-react'
|
||||||
import { useCallback, useRef, useState } from 'react'
|
import { useCallback, useRef, useState } from 'react'
|
||||||
import { useShallow } from 'zustand/react/shallow'
|
import { useShallow } from 'zustand/react/shallow'
|
||||||
import { createLocalGuideImage } from '../../../lib/local-guide-image'
|
import { createLocalGuideImage } from '../../../lib/local-guide-image'
|
||||||
@@ -60,6 +61,28 @@ function useLevelScans(): ScanNode[] {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function useLowerReferenceLevels(): LevelNode[] {
|
||||||
|
const levelId = useViewer((s) => s.selection.levelId)
|
||||||
|
return useScene(
|
||||||
|
useShallow((state) => {
|
||||||
|
if (!levelId) return [] as LevelNode[]
|
||||||
|
const activeLevel = state.nodes[levelId]
|
||||||
|
if (!activeLevel || activeLevel.type !== 'level') return [] as LevelNode[]
|
||||||
|
const buildingId = activeLevel.parentId as BuildingNode['id'] | undefined
|
||||||
|
const building = buildingId ? state.nodes[buildingId] : null
|
||||||
|
if (!building || building.type !== 'building') return [] as LevelNode[]
|
||||||
|
|
||||||
|
return (building.children ?? [])
|
||||||
|
.map((id) => state.nodes[id])
|
||||||
|
.filter(
|
||||||
|
(node): node is LevelNode =>
|
||||||
|
node?.type === 'level' && node.id !== levelId && node.level < activeLevel.level,
|
||||||
|
)
|
||||||
|
.sort((a, b) => b.level - a.level)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ── Shared upload button for dropdowns ──────────────────────────────────────
|
// ── Shared upload button for dropdowns ──────────────────────────────────────
|
||||||
|
|
||||||
function UploadButton({ onError }: { onError: (message: string | null) => void }) {
|
function UploadButton({ onError }: { onError: (message: string | null) => void }) {
|
||||||
@@ -578,6 +601,156 @@ function ScansControl() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ReferenceFloorControl() {
|
||||||
|
const showReferenceFloor = useEditor((state) => state.showReferenceFloor)
|
||||||
|
const toggleReferenceFloor = useEditor((state) => state.toggleReferenceFloor)
|
||||||
|
const referenceFloorOffset = useEditor((state) => state.referenceFloorOffset)
|
||||||
|
const setReferenceFloorOffset = useEditor((state) => state.setReferenceFloorOffset)
|
||||||
|
const referenceFloorOpacity = useEditor((state) => state.referenceFloorOpacity)
|
||||||
|
const setReferenceFloorOpacity = useEditor((state) => state.setReferenceFloorOpacity)
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
const lowerLevels = useLowerReferenceLevels()
|
||||||
|
const hasLowerLevels = lowerLevels.length > 0
|
||||||
|
const selectedLevel = lowerLevels[referenceFloorOffset - 1] ?? lowerLevels[0] ?? null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover onOpenChange={setIsOpen} open={isOpen}>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<ActionButton
|
||||||
|
className={cn(
|
||||||
|
'rounded-r-none p-0',
|
||||||
|
showReferenceFloor && selectedLevel
|
||||||
|
? 'bg-white/15'
|
||||||
|
: 'opacity-60 grayscale hover:bg-white/5 hover:opacity-100 hover:grayscale-0',
|
||||||
|
)}
|
||||||
|
disabled={!hasLowerLevels}
|
||||||
|
label={
|
||||||
|
selectedLevel && showReferenceFloor
|
||||||
|
? `Reference floor: ${selectedLevel.name}`
|
||||||
|
: 'Reference floor'
|
||||||
|
}
|
||||||
|
onClick={() => {
|
||||||
|
if (hasLowerLevels) toggleReferenceFloor()
|
||||||
|
}}
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
<div className="relative">
|
||||||
|
<Layers2 className="h-4 w-4" />
|
||||||
|
<span className="absolute -right-1.5 -bottom-1 min-w-[14px] rounded-full bg-white/20 px-[3px] text-center font-medium text-[9px] text-white/70 leading-[14px]">
|
||||||
|
{lowerLevels.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</ActionButton>
|
||||||
|
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<button
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
aria-label="Reference floor settings"
|
||||||
|
className={cn(
|
||||||
|
'flex h-11 w-6 items-center justify-center rounded-r-lg transition-colors',
|
||||||
|
showReferenceFloor && selectedLevel
|
||||||
|
? isOpen
|
||||||
|
? 'bg-white/10'
|
||||||
|
: 'bg-white/5 hover:bg-white/8'
|
||||||
|
: isOpen
|
||||||
|
? 'bg-white/8'
|
||||||
|
: 'opacity-60 hover:bg-white/5 hover:opacity-100',
|
||||||
|
)}
|
||||||
|
disabled={!hasLowerLevels}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<ChevronDown className={cn('h-3 w-3 transition-transform', isOpen && 'rotate-180')} />
|
||||||
|
</button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PopoverContent
|
||||||
|
align="center"
|
||||||
|
className="w-72 rounded-xl border-border/45 bg-background/96 p-3 shadow-[0_14px_28px_-18px_rgba(15,23,42,0.55),0_6px_16px_-10px_rgba(15,23,42,0.2)] backdrop-blur-xl"
|
||||||
|
side="top"
|
||||||
|
sideOffset={14}
|
||||||
|
>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-background/80">
|
||||||
|
<Layers2 className="h-4 w-4" />
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="font-medium text-foreground text-sm">Reference floor</p>
|
||||||
|
{selectedLevel && (
|
||||||
|
<p className="truncate text-muted-foreground text-xs">{selectedLevel.name}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
aria-label={showReferenceFloor ? 'Hide reference floor' : 'Show reference floor'}
|
||||||
|
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full border border-border/40 text-muted-foreground transition-colors hover:bg-white/10 hover:text-foreground disabled:pointer-events-none disabled:opacity-40"
|
||||||
|
disabled={!hasLowerLevels}
|
||||||
|
onClick={toggleReferenceFloor}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{showReferenceFloor ? <Eye className="h-3.5 w-3.5" /> : <EyeOff className="h-3.5 w-3.5" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasLowerLevels ? (
|
||||||
|
<>
|
||||||
|
<div className="max-h-44 space-y-1 overflow-y-auto rounded-xl border border-border/45 bg-background/60 p-1.5">
|
||||||
|
{lowerLevels.map((level, index) => {
|
||||||
|
const isSelected = referenceFloorOffset === index + 1
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
'flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-sm transition-colors hover:bg-white/8',
|
||||||
|
isSelected && showReferenceFloor && 'bg-white/10 text-foreground',
|
||||||
|
)}
|
||||||
|
key={level.id}
|
||||||
|
onClick={() => {
|
||||||
|
setReferenceFloorOffset(index + 1)
|
||||||
|
if (!showReferenceFloor) {
|
||||||
|
toggleReferenceFloor()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'h-3.5 w-3.5 rounded-full border',
|
||||||
|
isSelected && showReferenceFloor
|
||||||
|
? 'border-foreground bg-foreground'
|
||||||
|
: 'border-muted-foreground/35',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span className="min-w-0 flex-1 truncate">{level.name}</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground">
|
||||||
|
{index + 1} below
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SliderControl
|
||||||
|
label="Opacity"
|
||||||
|
max={0.8}
|
||||||
|
min={0.1}
|
||||||
|
onChange={setReferenceFloorOpacity}
|
||||||
|
precision={2}
|
||||||
|
step={0.05}
|
||||||
|
value={referenceFloorOpacity}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-xl border border-border/45 border-dashed bg-background/60 px-3 py-4 text-muted-foreground text-sm">
|
||||||
|
No lower floor available.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ── Main ViewToggles ────────────────────────────────────────────────────────
|
// ── Main ViewToggles ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function ViewToggles() {
|
export function ViewToggles() {
|
||||||
@@ -588,6 +761,8 @@ export function ViewToggles() {
|
|||||||
|
|
||||||
{/* Guides (toggle + dropdown) */}
|
{/* Guides (toggle + dropdown) */}
|
||||||
<GuidesControl />
|
<GuidesControl />
|
||||||
|
|
||||||
|
<ReferenceFloorControl />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -599,6 +774,7 @@ export function SecondaryToggles() {
|
|||||||
<GridSnapControl />
|
<GridSnapControl />
|
||||||
<ScansControl />
|
<ScansControl />
|
||||||
<GuidesControl />
|
<GuidesControl />
|
||||||
|
<ReferenceFloorControl />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,7 +96,11 @@ export type MovingFenceEndpoint = {
|
|||||||
endpoint: 'start' | 'end'
|
endpoint: 'start' | 'end'
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MaterialTargetRole = WallSurfaceSide | StairSurfaceMaterialRole | RoofSurfaceMaterialRole | SingleSurfaceMaterialRole
|
export type MaterialTargetRole =
|
||||||
|
| WallSurfaceSide
|
||||||
|
| StairSurfaceMaterialRole
|
||||||
|
| RoofSurfaceMaterialRole
|
||||||
|
| SingleSurfaceMaterialRole
|
||||||
|
|
||||||
export type SelectedMaterialTarget = {
|
export type SelectedMaterialTarget = {
|
||||||
nodeId: AnyNodeId
|
nodeId: AnyNodeId
|
||||||
@@ -197,6 +201,13 @@ type EditorState = {
|
|||||||
setFloorplanSelectionTool: (tool: FloorplanSelectionTool) => void
|
setFloorplanSelectionTool: (tool: FloorplanSelectionTool) => void
|
||||||
gridSnapStep: GridSnapStep
|
gridSnapStep: GridSnapStep
|
||||||
setGridSnapStep: (step: GridSnapStep) => void
|
setGridSnapStep: (step: GridSnapStep) => void
|
||||||
|
showReferenceFloor: boolean
|
||||||
|
toggleReferenceFloor: () => void
|
||||||
|
setShowReferenceFloor: (show: boolean) => void
|
||||||
|
referenceFloorOffset: number
|
||||||
|
setReferenceFloorOffset: (offset: number) => void
|
||||||
|
referenceFloorOpacity: number
|
||||||
|
setReferenceFloorOpacity: (opacity: number) => void
|
||||||
// First-person walkthrough mode (street view)
|
// First-person walkthrough mode (street view)
|
||||||
isFirstPersonMode: boolean
|
isFirstPersonMode: boolean
|
||||||
_viewModeBeforeFirstPerson: ViewMode | null
|
_viewModeBeforeFirstPerson: ViewMode | null
|
||||||
@@ -226,6 +237,9 @@ type PersistedEditorLayoutState = Pick<
|
|||||||
| 'splitOrientation'
|
| 'splitOrientation'
|
||||||
| 'floorplanSelectionTool'
|
| 'floorplanSelectionTool'
|
||||||
| 'gridSnapStep'
|
| 'gridSnapStep'
|
||||||
|
| 'showReferenceFloor'
|
||||||
|
| 'referenceFloorOffset'
|
||||||
|
| 'referenceFloorOpacity'
|
||||||
>
|
>
|
||||||
type PersistedEditorState = PersistedEditorUiState & PersistedEditorLayoutState
|
type PersistedEditorState = PersistedEditorUiState & PersistedEditorLayoutState
|
||||||
|
|
||||||
@@ -245,6 +259,9 @@ export const DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE: PersistedEditorLayoutState =
|
|||||||
splitOrientation: 'horizontal',
|
splitOrientation: 'horizontal',
|
||||||
floorplanSelectionTool: 'click',
|
floorplanSelectionTool: 'click',
|
||||||
gridSnapStep: 0.5,
|
gridSnapStep: 0.5,
|
||||||
|
showReferenceFloor: false,
|
||||||
|
referenceFloorOffset: 1,
|
||||||
|
referenceFloorOpacity: 0.35,
|
||||||
}
|
}
|
||||||
|
|
||||||
const GRID_SNAP_STEPS: GridSnapStep[] = [0.5, 0.25, 0.1, 0.05]
|
const GRID_SNAP_STEPS: GridSnapStep[] = [0.5, 0.25, 0.1, 0.05]
|
||||||
@@ -354,6 +371,16 @@ function normalizePersistedEditorLayoutState(
|
|||||||
gridSnapStep: GRID_SNAP_STEPS.includes(state?.gridSnapStep as GridSnapStep)
|
gridSnapStep: GRID_SNAP_STEPS.includes(state?.gridSnapStep as GridSnapStep)
|
||||||
? (state?.gridSnapStep as GridSnapStep)
|
? (state?.gridSnapStep as GridSnapStep)
|
||||||
: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.gridSnapStep,
|
: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.gridSnapStep,
|
||||||
|
showReferenceFloor: state?.showReferenceFloor === true,
|
||||||
|
referenceFloorOffset:
|
||||||
|
typeof state?.referenceFloorOffset === 'number' && state.referenceFloorOffset >= 1
|
||||||
|
? Math.floor(state.referenceFloorOffset)
|
||||||
|
: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.referenceFloorOffset,
|
||||||
|
referenceFloorOpacity:
|
||||||
|
typeof state?.referenceFloorOpacity === 'number' &&
|
||||||
|
Number.isFinite(state.referenceFloorOpacity)
|
||||||
|
? Math.min(0.8, Math.max(0.1, state.referenceFloorOpacity))
|
||||||
|
: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.referenceFloorOpacity,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -632,6 +659,16 @@ const useEditor = create<EditorState>()(
|
|||||||
setFloorplanSelectionTool: (tool) => set({ floorplanSelectionTool: tool }),
|
setFloorplanSelectionTool: (tool) => set({ floorplanSelectionTool: tool }),
|
||||||
gridSnapStep: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.gridSnapStep,
|
gridSnapStep: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.gridSnapStep,
|
||||||
setGridSnapStep: (step) => set({ gridSnapStep: step }),
|
setGridSnapStep: (step) => set({ gridSnapStep: step }),
|
||||||
|
showReferenceFloor: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.showReferenceFloor,
|
||||||
|
toggleReferenceFloor: () =>
|
||||||
|
set((state) => ({ showReferenceFloor: !state.showReferenceFloor })),
|
||||||
|
setShowReferenceFloor: (show) => set({ showReferenceFloor: show }),
|
||||||
|
referenceFloorOffset: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.referenceFloorOffset,
|
||||||
|
setReferenceFloorOffset: (offset) =>
|
||||||
|
set({ referenceFloorOffset: Math.max(1, Math.floor(offset)) }),
|
||||||
|
referenceFloorOpacity: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.referenceFloorOpacity,
|
||||||
|
setReferenceFloorOpacity: (opacity) =>
|
||||||
|
set({ referenceFloorOpacity: Math.min(0.8, Math.max(0.1, opacity)) }),
|
||||||
allowUndergroundCamera: false,
|
allowUndergroundCamera: false,
|
||||||
setAllowUndergroundCamera: (enabled) => set({ allowUndergroundCamera: enabled }),
|
setAllowUndergroundCamera: (enabled) => set({ allowUndergroundCamera: enabled }),
|
||||||
isFirstPersonMode: false,
|
isFirstPersonMode: false,
|
||||||
@@ -702,6 +739,9 @@ const useEditor = create<EditorState>()(
|
|||||||
splitOrientation: state.splitOrientation,
|
splitOrientation: state.splitOrientation,
|
||||||
floorplanSelectionTool: state.floorplanSelectionTool,
|
floorplanSelectionTool: state.floorplanSelectionTool,
|
||||||
gridSnapStep: state.gridSnapStep,
|
gridSnapStep: state.gridSnapStep,
|
||||||
|
showReferenceFloor: state.showReferenceFloor,
|
||||||
|
referenceFloorOffset: state.referenceFloorOffset,
|
||||||
|
referenceFloorOpacity: state.referenceFloorOpacity,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
Reference in New Issue
Block a user