Port floorplan item thumbnails, full mobile editor UI, and mobile polish from the private monorepo into the public editor. Monorepo PR #297 (feat/item-admin-advanced): - Add optional floorPlanUrl to item asset schema (core) - Render 2D floor-plan images inside item footprints on floorplan - Improve slider drag with modifier key re-anchoring Monorepo PR #302 (feat/mobile-ui): - Mobile editor layout with draggable bottom sheet - Mobile tab bar, selection bar, and panel sheet - Mobile-aware panel manager and panel wrapper - useIsMobile rewrite (useSyncExternalStore, SSR-safe) - Camera actions hideOrbit prop - GridSnapControl and SecondaryToggles exports - Action menu hides on mobile contextual tabs - SidebarTab extended with mobileDefaultSnap/mobileIcon Monorepo PR #306 (feat/mobile-ui-polish): - Touch gesture mapping for camera controls (one/two/three finger) - Snap ratio constants for bottom sheet - Thumbnail generator WebGL2 fallback (bottom-up row flip) - ErrorBoundary scope logging, viewer scene wrap - GPUDeviceWatcher enhanced logging and uncaptured error handler - WebGPURenderer init error handling and diagnostics - Post-processing log improvements - MergedOutlineNode WebGL2 FBO corruption fix Excluded: apps/community/*, apps/editor/*, packages/community-*, .cursor/*, .env.example (monorepo-only files) Co-authored-by: Pascal <open@pascal.app>
This commit is contained in:
@@ -80,6 +80,9 @@ const assetSchema = z.object({
|
|||||||
category: z.string(),
|
category: z.string(),
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
thumbnail: z.string(),
|
thumbnail: z.string(),
|
||||||
|
// Optional top-down 2D image shown inside the item's footprint on the
|
||||||
|
// floor plan. When present, replaces the default diagonal-cross marker.
|
||||||
|
floorPlanUrl: z.string().optional(),
|
||||||
src: AssetUrl,
|
src: AssetUrl,
|
||||||
dimensions: z.tuple([z.number(), z.number(), z.number()]).default([1, 1, 1]), // [w, h, d]
|
dimensions: z.tuple([z.number(), z.number(), z.number()]).default([1, 1, 1]), // [w, h, d]
|
||||||
attachTo: z.enum(['wall', 'wall-side', 'ceiling']).optional(),
|
attachTo: z.enum(['wall', 'wall-side', 'ceiling']).optional(),
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { animate, motion, useMotionValue } from 'motion/react'
|
||||||
|
import {
|
||||||
|
forwardRef,
|
||||||
|
type ReactNode,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useImperativeHandle,
|
||||||
|
useRef,
|
||||||
|
} from 'react'
|
||||||
|
|
||||||
|
export type BottomSheetHandle = {
|
||||||
|
snapTo: (heightPx: number) => void
|
||||||
|
getHeight: () => number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BottomSheetProps {
|
||||||
|
initialHeightPx: number
|
||||||
|
snapPointsPx: number[]
|
||||||
|
onCommit: (heightPx: number) => void
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
const DRAG_THRESHOLD_PX = 6
|
||||||
|
|
||||||
|
export const BottomSheet = forwardRef<BottomSheetHandle, BottomSheetProps>(function BottomSheet(
|
||||||
|
{ initialHeightPx, snapPointsPx, onCommit, children },
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
const height = useMotionValue(initialHeightPx)
|
||||||
|
const dragStartY = useRef<number | null>(null)
|
||||||
|
const dragStartHeight = useRef(0)
|
||||||
|
const hasDragged = useRef(false)
|
||||||
|
const animationRef = useRef<ReturnType<typeof animate> | null>(null)
|
||||||
|
|
||||||
|
const clamp = useCallback(
|
||||||
|
(px: number) => {
|
||||||
|
const min = Math.min(...snapPointsPx)
|
||||||
|
const max = Math.max(...snapPointsPx)
|
||||||
|
return Math.max(min, Math.min(max, px))
|
||||||
|
},
|
||||||
|
[snapPointsPx],
|
||||||
|
)
|
||||||
|
|
||||||
|
const nearestSnap = useCallback(
|
||||||
|
(px: number) => {
|
||||||
|
let best = snapPointsPx[0] ?? 0
|
||||||
|
let bestDist = Number.POSITIVE_INFINITY
|
||||||
|
for (const p of snapPointsPx) {
|
||||||
|
const d = Math.abs(p - px)
|
||||||
|
if (d < bestDist) {
|
||||||
|
bestDist = d
|
||||||
|
best = p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
},
|
||||||
|
[snapPointsPx],
|
||||||
|
)
|
||||||
|
|
||||||
|
const animateTo = useCallback(
|
||||||
|
(targetPx: number) => {
|
||||||
|
animationRef.current?.stop()
|
||||||
|
const controls = animate(height, targetPx, {
|
||||||
|
type: 'spring',
|
||||||
|
stiffness: 320,
|
||||||
|
damping: 32,
|
||||||
|
mass: 0.8,
|
||||||
|
onComplete: () => {
|
||||||
|
onCommit(targetPx)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
animationRef.current = controls
|
||||||
|
},
|
||||||
|
[height, onCommit],
|
||||||
|
)
|
||||||
|
|
||||||
|
useImperativeHandle(
|
||||||
|
ref,
|
||||||
|
() => ({
|
||||||
|
snapTo: (px: number) => animateTo(clamp(px)),
|
||||||
|
getHeight: () => height.get(),
|
||||||
|
}),
|
||||||
|
[animateTo, clamp, height],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handlePointerDown = useCallback(
|
||||||
|
(e: React.PointerEvent) => {
|
||||||
|
if (e.button !== 0 && e.pointerType === 'mouse') return
|
||||||
|
e.currentTarget.setPointerCapture(e.pointerId)
|
||||||
|
animationRef.current?.stop()
|
||||||
|
dragStartY.current = e.clientY
|
||||||
|
dragStartHeight.current = height.get()
|
||||||
|
hasDragged.current = false
|
||||||
|
},
|
||||||
|
[height],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handlePointerMove = useCallback(
|
||||||
|
(e: React.PointerEvent) => {
|
||||||
|
if (dragStartY.current === null) return
|
||||||
|
const dy = e.clientY - dragStartY.current
|
||||||
|
if (!hasDragged.current && Math.abs(dy) < DRAG_THRESHOLD_PX) return
|
||||||
|
hasDragged.current = true
|
||||||
|
const next = clamp(dragStartHeight.current - dy)
|
||||||
|
height.set(next)
|
||||||
|
},
|
||||||
|
[clamp, height],
|
||||||
|
)
|
||||||
|
|
||||||
|
const endDrag = useCallback(
|
||||||
|
(e: React.PointerEvent) => {
|
||||||
|
if (dragStartY.current === null) return
|
||||||
|
dragStartY.current = null
|
||||||
|
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
|
||||||
|
e.currentTarget.releasePointerCapture(e.pointerId)
|
||||||
|
}
|
||||||
|
if (!hasDragged.current) return
|
||||||
|
const target = nearestSnap(height.get())
|
||||||
|
animateTo(target)
|
||||||
|
},
|
||||||
|
[animateTo, height, nearestSnap],
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
animationRef.current?.stop()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
className="absolute right-0 bottom-0 left-0 z-40 flex flex-col overflow-hidden rounded-t-2xl bg-sidebar text-sidebar-foreground shadow-[0_-4px_16px_rgba(0,0,0,0.12)]"
|
||||||
|
style={{ height }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex h-6 shrink-0 cursor-grab touch-none items-center justify-center active:cursor-grabbing"
|
||||||
|
onPointerCancel={endDrag}
|
||||||
|
onPointerDown={handlePointerDown}
|
||||||
|
onPointerMove={handlePointerMove}
|
||||||
|
onPointerUp={endDrag}
|
||||||
|
>
|
||||||
|
<div className="h-1 w-10 rounded-full bg-muted-foreground/40" />
|
||||||
|
</div>
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">{children}</div>
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -118,6 +118,49 @@ export const CustomCameraControls = () => {
|
|||||||
}
|
}
|
||||||
}, [cameraMode, isPreviewMode])
|
}, [cameraMode, isPreviewMode])
|
||||||
|
|
||||||
|
// Touch gestures (mobile / trackpad).
|
||||||
|
// - One finger drag → rotate by default (much easier on a phone), but
|
||||||
|
// falls back to NONE while the user is actively
|
||||||
|
// placing/moving something OR in box-select mode,
|
||||||
|
// so the editor's pointer handlers (place tool,
|
||||||
|
// drag-to-move endpoint, marquee selection drag)
|
||||||
|
// keep priority over the camera.
|
||||||
|
// In preview mode it's TOUCH_TRUCK (pan), matching
|
||||||
|
// preview's left = SCREEN_PAN.
|
||||||
|
// - Two finger pinch → zoom + pan together (TOUCH_DOLLY_TRUCK for
|
||||||
|
// perspective, TOUCH_ZOOM_TRUCK for orthographic).
|
||||||
|
// - Three finger drag → rotate, so the camera is always orbitable even
|
||||||
|
// when one-finger is suppressed by an active
|
||||||
|
// editor action.
|
||||||
|
const tool = useEditor((s) => s.tool)
|
||||||
|
const mode = useEditor((s) => s.mode)
|
||||||
|
const selectionTool = useEditor((s) => s.floorplanSelectionTool)
|
||||||
|
const movingNode = useEditor((s) => s.movingNode)
|
||||||
|
const movingWallEndpoint = useEditor((s) => s.movingWallEndpoint)
|
||||||
|
const movingFenceEndpoint = useEditor((s) => s.movingFenceEndpoint)
|
||||||
|
const isBoxSelectActive = mode === 'select' && selectionTool === 'marquee'
|
||||||
|
const isInteracting = Boolean(
|
||||||
|
tool || movingNode || movingWallEndpoint || movingFenceEndpoint || isBoxSelectActive,
|
||||||
|
)
|
||||||
|
const touches = useMemo(() => {
|
||||||
|
const twoFingerAction =
|
||||||
|
cameraMode === 'orthographic'
|
||||||
|
? CameraControlsImpl.ACTION.TOUCH_ZOOM_TRUCK
|
||||||
|
: CameraControlsImpl.ACTION.TOUCH_DOLLY_TRUCK
|
||||||
|
|
||||||
|
const oneFingerAction = isPreviewMode
|
||||||
|
? CameraControlsImpl.ACTION.TOUCH_TRUCK
|
||||||
|
: isInteracting
|
||||||
|
? CameraControlsImpl.ACTION.NONE
|
||||||
|
: CameraControlsImpl.ACTION.TOUCH_ROTATE
|
||||||
|
|
||||||
|
return {
|
||||||
|
one: oneFingerAction,
|
||||||
|
two: twoFingerAction,
|
||||||
|
three: CameraControlsImpl.ACTION.TOUCH_ROTATE,
|
||||||
|
}
|
||||||
|
}, [cameraMode, isPreviewMode, isInteracting])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const keyState = {
|
const keyState = {
|
||||||
shiftRight: false,
|
shiftRight: false,
|
||||||
@@ -407,6 +450,7 @@ export const CustomCameraControls = () => {
|
|||||||
onTransitionStart={onTransitionStart}
|
onTransitionStart={onTransitionStart}
|
||||||
ref={controls}
|
ref={controls}
|
||||||
restThreshold={0.01}
|
restThreshold={0.01}
|
||||||
|
touches={touches}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,264 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
|
import { type ReactNode, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||||
|
import useEditor from '../../store/use-editor'
|
||||||
|
import { MobileTabBar } from '../ui/sidebar/mobile-tab-bar'
|
||||||
|
import type { SidebarTab } from '../ui/sidebar/tab-bar'
|
||||||
|
import { BottomSheet, type BottomSheetHandle } from './bottom-sheet'
|
||||||
|
|
||||||
|
const MIN_SNAP = 0
|
||||||
|
const MAX_SNAP = 1
|
||||||
|
const DEFAULT_SNAP = 0.5
|
||||||
|
// Viewer extends this many pixels behind the sheet's rounded top corners
|
||||||
|
// so the curve reveals viewer content underneath.
|
||||||
|
const SHEET_OVERLAP_PX = 16
|
||||||
|
// Sheet never collapses below the drag handle so the user can always grab it.
|
||||||
|
const SHEET_HANDLE_PX = 24
|
||||||
|
|
||||||
|
// Match the viewer's scene background colors (packages/viewer/src/components/viewer/index.tsx)
|
||||||
|
const VIEWER_BG_DARK = '#1f2433'
|
||||||
|
const VIEWER_BG_LIGHT = '#ffffff'
|
||||||
|
|
||||||
|
// Fixed set of intermediate snap heights (handle + middleH are added on top).
|
||||||
|
// Per-tab `mobileDefaultSnap` decides the OPENING height; this list bounds
|
||||||
|
// what the user can drag to.
|
||||||
|
const SNAP_RATIOS = [0.5, 0.66] as const
|
||||||
|
|
||||||
|
function getDefaultSnap(tab: SidebarTab | undefined): number {
|
||||||
|
const s = tab?.mobileDefaultSnap
|
||||||
|
if (typeof s !== 'number') return DEFAULT_SNAP
|
||||||
|
return Math.max(MIN_SNAP, Math.min(MAX_SNAP, s))
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EditorLayoutMobileProps {
|
||||||
|
navbarSlot?: ReactNode
|
||||||
|
sidebarTabs?: SidebarTab[]
|
||||||
|
renderTabContent: (tabId: string) => ReactNode
|
||||||
|
sidebarOverlay?: ReactNode
|
||||||
|
viewerToolbarLeft?: ReactNode
|
||||||
|
viewerToolbarRight?: ReactNode
|
||||||
|
viewerContent: ReactNode
|
||||||
|
overlays?: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditorLayoutMobile({
|
||||||
|
navbarSlot,
|
||||||
|
sidebarTabs = [],
|
||||||
|
renderTabContent,
|
||||||
|
sidebarOverlay,
|
||||||
|
viewerToolbarLeft,
|
||||||
|
viewerToolbarRight,
|
||||||
|
viewerContent,
|
||||||
|
overlays,
|
||||||
|
}: EditorLayoutMobileProps) {
|
||||||
|
const isCaptureMode = useEditor((s) => s.isCaptureMode)
|
||||||
|
const activePanel = useEditor((s) => s.activeSidebarPanel)
|
||||||
|
const setActivePanel = useEditor((s) => s.setActiveSidebarPanel)
|
||||||
|
const panelSheetHeight = useEditor((s) => s.mobilePanelSheetHeight)
|
||||||
|
const theme = useViewer((s) => s.theme)
|
||||||
|
const viewerBg = theme === 'light' ? VIEWER_BG_LIGHT : VIEWER_BG_DARK
|
||||||
|
|
||||||
|
const middleRef = useRef<HTMLDivElement>(null)
|
||||||
|
const sheetRef = useRef<BottomSheetHandle>(null)
|
||||||
|
const [middleH, setMiddleH] = useState(0)
|
||||||
|
// Distance from the middle area's bottom edge to the viewport's bottom edge
|
||||||
|
// (i.e. the tab bar height incl. safe area). Needed to translate the panel
|
||||||
|
// sheet's viewport-relative height into middle-area coordinates.
|
||||||
|
const [middleBottomFromViewport, setMiddleBottomFromViewport] = useState(0)
|
||||||
|
const [committedSheetH, setCommittedSheetH] = useState(0)
|
||||||
|
|
||||||
|
const currentTab = sidebarTabs.find((t) => t.id === activePanel)
|
||||||
|
|
||||||
|
// Keep active panel valid
|
||||||
|
useEffect(() => {
|
||||||
|
if (sidebarTabs.length > 0 && !sidebarTabs.some((t) => t.id === activePanel)) {
|
||||||
|
setActivePanel(sidebarTabs[0]!.id)
|
||||||
|
}
|
||||||
|
}, [sidebarTabs, activePanel, setActivePanel])
|
||||||
|
|
||||||
|
// Sync editor phase / mode with the active tab:
|
||||||
|
// - Entering Chat always drops to Select (chat is a composing context).
|
||||||
|
// - Entering Items snaps the editor into furnish-build (matches the
|
||||||
|
// desktop "Furnish" action which itself opens the Items panel).
|
||||||
|
// - Leaving Items while still furnishing exits the build mode.
|
||||||
|
useEffect(() => {
|
||||||
|
const { phase, mode, setMode, setPhase } = useEditor.getState()
|
||||||
|
if (activePanel === 'ai' && mode === 'build') {
|
||||||
|
setMode('select')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (activePanel === 'items') {
|
||||||
|
if (phase !== 'furnish') setPhase('furnish')
|
||||||
|
if (mode !== 'build') setMode('build')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (phase === 'furnish' && mode === 'build') {
|
||||||
|
setMode('select')
|
||||||
|
}
|
||||||
|
}, [activePanel])
|
||||||
|
|
||||||
|
// Measure middle area height + its bottom offset from viewport bottom
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const el = middleRef.current
|
||||||
|
if (!el) return
|
||||||
|
const measure = () => {
|
||||||
|
const rect = el.getBoundingClientRect()
|
||||||
|
setMiddleH(rect.height)
|
||||||
|
setMiddleBottomFromViewport(Math.max(0, window.innerHeight - rect.bottom))
|
||||||
|
}
|
||||||
|
const ro = new ResizeObserver(measure)
|
||||||
|
ro.observe(el)
|
||||||
|
measure()
|
||||||
|
window.addEventListener('resize', measure)
|
||||||
|
return () => {
|
||||||
|
ro.disconnect()
|
||||||
|
window.removeEventListener('resize', measure)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Initialise sheet to current tab default once we know the middle height
|
||||||
|
const didInit = useRef(false)
|
||||||
|
useEffect(() => {
|
||||||
|
if (didInit.current || middleH <= 0) return
|
||||||
|
didInit.current = true
|
||||||
|
const targetPx = getDefaultSnap(currentTab) * middleH
|
||||||
|
setCommittedSheetH(targetPx)
|
||||||
|
sheetRef.current?.snapTo(targetPx)
|
||||||
|
}, [middleH, currentTab])
|
||||||
|
|
||||||
|
// When middle height changes (rotation / resize), keep sheet in proportion
|
||||||
|
const prevMiddleH = useRef(0)
|
||||||
|
useEffect(() => {
|
||||||
|
if (middleH <= 0) return
|
||||||
|
if (prevMiddleH.current === 0) {
|
||||||
|
prevMiddleH.current = middleH
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (prevMiddleH.current === middleH) return
|
||||||
|
const ratio = committedSheetH / prevMiddleH.current
|
||||||
|
const nextPx = Math.max(SHEET_HANDLE_PX, Math.min(middleH, ratio * middleH))
|
||||||
|
prevMiddleH.current = middleH
|
||||||
|
setCommittedSheetH(nextPx)
|
||||||
|
sheetRef.current?.snapTo(nextPx)
|
||||||
|
}, [middleH, committedSheetH])
|
||||||
|
|
||||||
|
const handleTabPress = useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
if (middleH <= 0) return
|
||||||
|
const tab = sidebarTabs.find((t) => t.id === id)
|
||||||
|
if (!tab) return
|
||||||
|
const defaultPx = getDefaultSnap(tab) * middleH
|
||||||
|
if (id !== activePanel) {
|
||||||
|
setActivePanel(id)
|
||||||
|
sheetRef.current?.snapTo(defaultPx)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Same tab tapped — toggle
|
||||||
|
const current = sheetRef.current?.getHeight() ?? committedSheetH
|
||||||
|
const expandedThreshold = Math.max(SHEET_HANDLE_PX, defaultPx * 0.5)
|
||||||
|
if (current > expandedThreshold) {
|
||||||
|
sheetRef.current?.snapTo(SHEET_HANDLE_PX)
|
||||||
|
} else {
|
||||||
|
sheetRef.current?.snapTo(defaultPx)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[sidebarTabs, activePanel, setActivePanel, middleH, committedSheetH],
|
||||||
|
)
|
||||||
|
|
||||||
|
const snapPointsPx = (() => {
|
||||||
|
if (middleH <= 0) return [SHEET_HANDLE_PX]
|
||||||
|
const intermediate = SNAP_RATIOS.map((r) => r * middleH)
|
||||||
|
return Array.from(new Set([SHEET_HANDLE_PX, ...intermediate, middleH])).sort((a, b) => a - b)
|
||||||
|
})()
|
||||||
|
|
||||||
|
// When the secondary panel sheet is open, it covers the tab bar + part of
|
||||||
|
// the middle area; translate its viewport height into middle-area units.
|
||||||
|
const panelPenetrationInMiddle = Math.max(0, panelSheetHeight - middleBottomFromViewport)
|
||||||
|
// The effective "sheet height" that the viewer sits above is the larger of
|
||||||
|
// the primary sidebar sheet and the secondary panel sheet's penetration.
|
||||||
|
const effectiveSheetH = Math.max(committedSheetH, panelPenetrationInMiddle)
|
||||||
|
|
||||||
|
// In capture mode the sheet and tab bar are hidden — the viewer should fill
|
||||||
|
// the entire middle area regardless of the stored sheet height.
|
||||||
|
// Otherwise, the viewer extends SHEET_OVERLAP_PX behind the sheet's rounded
|
||||||
|
// corners so the curve reveals viewer content underneath.
|
||||||
|
const baseViewerHeight = Math.max(0, middleH - effectiveSheetH)
|
||||||
|
const viewerHeight = isCaptureMode
|
||||||
|
? middleH
|
||||||
|
: baseViewerHeight === 0
|
||||||
|
? 0
|
||||||
|
: Math.min(middleH, baseViewerHeight + SHEET_OVERLAP_PX)
|
||||||
|
|
||||||
|
// While the panel sheet is open, collapse the primary sheet to its handle so
|
||||||
|
// it doesn't peek above. Remember the previous height and restore it on close.
|
||||||
|
const sheetHeightBeforePanel = useRef<number | null>(null)
|
||||||
|
useEffect(() => {
|
||||||
|
if (panelSheetHeight > 0) {
|
||||||
|
if (sheetHeightBeforePanel.current === null && committedSheetH > SHEET_HANDLE_PX) {
|
||||||
|
sheetHeightBeforePanel.current = committedSheetH
|
||||||
|
sheetRef.current?.snapTo(SHEET_HANDLE_PX)
|
||||||
|
}
|
||||||
|
} else if (sheetHeightBeforePanel.current !== null) {
|
||||||
|
const target = sheetHeightBeforePanel.current
|
||||||
|
sheetHeightBeforePanel.current = null
|
||||||
|
sheetRef.current?.snapTo(target)
|
||||||
|
}
|
||||||
|
}, [panelSheetHeight, committedSheetH])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="dark flex h-full w-full flex-col bg-sidebar text-foreground">
|
||||||
|
{navbarSlot}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="relative flex min-h-0 flex-1"
|
||||||
|
ref={middleRef}
|
||||||
|
style={{ backgroundColor: viewerBg }}
|
||||||
|
>
|
||||||
|
{/* Viewer column: sized by committed sheet height */}
|
||||||
|
<div className="absolute inset-x-0 top-0 overflow-hidden" style={{ height: viewerHeight }}>
|
||||||
|
<div className="relative h-full w-full">
|
||||||
|
{(viewerToolbarLeft || viewerToolbarRight) && !isCaptureMode && (
|
||||||
|
<div className="pointer-events-none absolute top-3 right-3 left-3 z-20 flex items-center justify-between gap-2">
|
||||||
|
<div className="pointer-events-auto flex items-center gap-2">
|
||||||
|
{viewerToolbarLeft}
|
||||||
|
</div>
|
||||||
|
<div className="pointer-events-auto flex items-center gap-2">
|
||||||
|
{viewerToolbarRight}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="relative h-full w-full overflow-hidden">{viewerContent}</div>
|
||||||
|
{overlays && (
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute inset-0 z-30"
|
||||||
|
style={{ transform: 'translateZ(0)' }}
|
||||||
|
>
|
||||||
|
{overlays}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom sheet: overlays the lower part of the middle area */}
|
||||||
|
{!isCaptureMode && sidebarTabs.length > 0 && (
|
||||||
|
<BottomSheet
|
||||||
|
initialHeightPx={SHEET_HANDLE_PX}
|
||||||
|
onCommit={setCommittedSheetH}
|
||||||
|
ref={sheetRef}
|
||||||
|
snapPointsPx={snapPointsPx}
|
||||||
|
>
|
||||||
|
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||||
|
{renderTabContent(activePanel)}
|
||||||
|
{sidebarOverlay && <div className="absolute inset-0 z-50">{sidebarOverlay}</div>}
|
||||||
|
</div>
|
||||||
|
</BottomSheet>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isCaptureMode && sidebarTabs.length > 0 && (
|
||||||
|
<MobileTabBar activeTab={activePanel} onTabPress={handleTabPress} tabs={sidebarTabs} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { type ReactNode, useCallback, useEffect, useRef } from 'react'
|
import { type ReactNode, useCallback, useEffect, useRef } from 'react'
|
||||||
|
import { useIsMobile } from '../../hooks/use-mobile'
|
||||||
import useEditor from '../../store/use-editor'
|
import useEditor from '../../store/use-editor'
|
||||||
|
|
||||||
import { useSidebarStore } from '../ui/primitives/sidebar'
|
import { useSidebarStore } from '../ui/primitives/sidebar'
|
||||||
import { type SidebarTab, TabBar } from '../ui/sidebar/tab-bar'
|
import { type SidebarTab, TabBar } from '../ui/sidebar/tab-bar'
|
||||||
|
import { EditorLayoutMobile } from './editor-layout-mobile'
|
||||||
|
|
||||||
const SIDEBAR_MIN_WIDTH = 300
|
const SIDEBAR_MIN_WIDTH = 300
|
||||||
const SIDEBAR_MAX_WIDTH = 800
|
const SIDEBAR_MAX_WIDTH = 800
|
||||||
@@ -202,6 +205,23 @@ export function EditorLayoutV2({
|
|||||||
viewerContent,
|
viewerContent,
|
||||||
overlays,
|
overlays,
|
||||||
}: EditorLayoutV2Props) {
|
}: EditorLayoutV2Props) {
|
||||||
|
const isMobile = useIsMobile()
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
return (
|
||||||
|
<EditorLayoutMobile
|
||||||
|
navbarSlot={navbarSlot}
|
||||||
|
overlays={overlays}
|
||||||
|
renderTabContent={renderTabContent}
|
||||||
|
sidebarOverlay={sidebarOverlay}
|
||||||
|
sidebarTabs={sidebarTabs}
|
||||||
|
viewerContent={viewerContent}
|
||||||
|
viewerToolbarLeft={viewerToolbarLeft}
|
||||||
|
viewerToolbarRight={viewerToolbarRight}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="dark flex h-full w-full flex-col bg-sidebar text-foreground">
|
<div className="dark flex h-full w-full flex-col bg-sidebar text-foreground">
|
||||||
{/* Top navbar */}
|
{/* Top navbar */}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
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,
|
||||||
@@ -26,7 +25,6 @@ import {
|
|||||||
type Point2D,
|
type Point2D,
|
||||||
type RoofNode,
|
type RoofNode,
|
||||||
type RoofSegmentNode,
|
type RoofSegmentNode,
|
||||||
sceneRegistry,
|
|
||||||
type SiteNode,
|
type SiteNode,
|
||||||
SlabNode,
|
SlabNode,
|
||||||
type StairNode,
|
type StairNode,
|
||||||
@@ -34,6 +32,7 @@ import {
|
|||||||
type StairSegmentNode,
|
type StairSegmentNode,
|
||||||
StairSegmentNode as StairSegmentNodeSchema,
|
StairSegmentNode as StairSegmentNodeSchema,
|
||||||
sampleWallCenterline,
|
sampleWallCenterline,
|
||||||
|
sceneRegistry,
|
||||||
useLiveTransforms,
|
useLiveTransforms,
|
||||||
useScene,
|
useScene,
|
||||||
type WallNode,
|
type WallNode,
|
||||||
@@ -54,6 +53,18 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
} from 'react'
|
} from 'react'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
|
import {
|
||||||
|
buildFloorplanItemEntry,
|
||||||
|
buildFloorplanStairEntry as buildSharedFloorplanStairEntry,
|
||||||
|
getFloorplanWall as getSharedFloorplanWall,
|
||||||
|
rotatePlanVector as rotateSharedPlanVector,
|
||||||
|
type FloorplanNodeTransform as SharedFloorplanNodeTransform,
|
||||||
|
} from '../../lib/floorplan'
|
||||||
|
import { duplicateRoofSubtree } from '../../lib/roof-duplication'
|
||||||
|
import { sfxEmitter } from '../../lib/sfx-bus'
|
||||||
|
import { duplicateStairSubtree } from '../../lib/stair-duplication'
|
||||||
|
import { cn } from '../../lib/utils'
|
||||||
|
import useEditor from '../../store/use-editor'
|
||||||
import { FloorplanActionMenuLayer as Editor2dFloorplanActionMenuLayer } from '../editor-2d/floorplan-action-menu-layer'
|
import { FloorplanActionMenuLayer as Editor2dFloorplanActionMenuLayer } from '../editor-2d/floorplan-action-menu-layer'
|
||||||
import { FloorplanCursorIndicatorOverlay as Editor2dFloorplanCursorIndicatorOverlay } from '../editor-2d/floorplan-cursor-indicator-overlay'
|
import { FloorplanCursorIndicatorOverlay as Editor2dFloorplanCursorIndicatorOverlay } from '../editor-2d/floorplan-cursor-indicator-overlay'
|
||||||
import {
|
import {
|
||||||
@@ -61,30 +72,15 @@ import {
|
|||||||
FloorplanSiteKeyHandler,
|
FloorplanSiteKeyHandler,
|
||||||
} from '../editor-2d/floorplan-hotkey-handlers'
|
} from '../editor-2d/floorplan-hotkey-handlers'
|
||||||
import { FloorplanDraftLayer } from '../editor-2d/renderers/floorplan-draft-layer'
|
import { FloorplanDraftLayer } from '../editor-2d/renderers/floorplan-draft-layer'
|
||||||
|
import { FloorplanMarqueeLayer } from '../editor-2d/renderers/floorplan-marquee-layer'
|
||||||
import {
|
import {
|
||||||
FloorplanMeasurementsLayer,
|
FloorplanMeasurementsLayer,
|
||||||
type LinearMeasurementOverlay,
|
type LinearMeasurementOverlay,
|
||||||
} from '../editor-2d/renderers/floorplan-measurements-layer'
|
} from '../editor-2d/renderers/floorplan-measurements-layer'
|
||||||
import { FloorplanMarqueeLayer } from '../editor-2d/renderers/floorplan-marquee-layer'
|
|
||||||
import { FloorplanRoofLayer } from '../editor-2d/renderers/floorplan-roof-layer'
|
import { FloorplanRoofLayer } from '../editor-2d/renderers/floorplan-roof-layer'
|
||||||
import { FloorplanStairLayer } from '../editor-2d/renderers/floorplan-stair-layer'
|
import { FloorplanStairLayer } from '../editor-2d/renderers/floorplan-stair-layer'
|
||||||
import {
|
import { buildSvgPolylinePath, formatPolygonPath, getArcPlanPoint } from '../editor-2d/svg-paths'
|
||||||
buildSvgPolylinePath,
|
import { snapFenceDraftPoint } from '../tools/fence/fence-drafting'
|
||||||
formatPolygonPath,
|
|
||||||
getArcPlanPoint,
|
|
||||||
} from '../editor-2d/svg-paths'
|
|
||||||
import {
|
|
||||||
buildFloorplanItemEntry,
|
|
||||||
buildFloorplanStairEntry as buildSharedFloorplanStairEntry,
|
|
||||||
getFloorplanWall as getSharedFloorplanWall,
|
|
||||||
type FloorplanNodeTransform as SharedFloorplanNodeTransform,
|
|
||||||
rotatePlanVector as rotateSharedPlanVector,
|
|
||||||
} from '../../lib/floorplan'
|
|
||||||
import { duplicateRoofSubtree } from '../../lib/roof-duplication'
|
|
||||||
import { duplicateStairSubtree } from '../../lib/stair-duplication'
|
|
||||||
import { sfxEmitter } from '../../lib/sfx-bus'
|
|
||||||
import { cn } from '../../lib/utils'
|
|
||||||
import useEditor from '../../store/use-editor'
|
|
||||||
import { snapToHalf } from '../tools/item/placement-math'
|
import { snapToHalf } from '../tools/item/placement-math'
|
||||||
import {
|
import {
|
||||||
DEFAULT_STAIR_ATTACHMENT_SIDE,
|
DEFAULT_STAIR_ATTACHMENT_SIDE,
|
||||||
@@ -95,7 +91,6 @@ import {
|
|||||||
DEFAULT_STAIR_THICKNESS,
|
DEFAULT_STAIR_THICKNESS,
|
||||||
DEFAULT_STAIR_WIDTH,
|
DEFAULT_STAIR_WIDTH,
|
||||||
} from '../tools/stair/stair-defaults'
|
} from '../tools/stair/stair-defaults'
|
||||||
import { snapFenceDraftPoint } from '../tools/fence/fence-drafting'
|
|
||||||
import {
|
import {
|
||||||
createWallOnCurrentLevel,
|
createWallOnCurrentLevel,
|
||||||
isWallLongEnough,
|
isWallLongEnough,
|
||||||
@@ -450,6 +445,13 @@ type FloorplanItemEntry = {
|
|||||||
points: string
|
points: string
|
||||||
polygon: Point2D[]
|
polygon: Point2D[]
|
||||||
usesRealMesh: boolean
|
usesRealMesh: boolean
|
||||||
|
// Scene-space center (x, y = plan coords) and rotation in radians, plus the
|
||||||
|
// footprint dimensions. Used to place the optional floor-plan image overlay
|
||||||
|
// in the correct position, orientation, and size.
|
||||||
|
center: Point2D
|
||||||
|
rotation: number
|
||||||
|
width: number
|
||||||
|
depth: number
|
||||||
}
|
}
|
||||||
|
|
||||||
type FloorplanStairSegmentEntry = {
|
type FloorplanStairSegmentEntry = {
|
||||||
@@ -1450,8 +1452,7 @@ function movePlanPointTowards(start: Point2D, end: Point2D, distance: number): P
|
|||||||
|
|
||||||
function getNormalizedFloorplanStairSweepAngle(stair: StairNode) {
|
function getNormalizedFloorplanStairSweepAngle(stair: StairNode) {
|
||||||
const stairType = stair.stairType ?? 'straight'
|
const stairType = stair.stairType ?? 'straight'
|
||||||
const baseSweepAngle =
|
const baseSweepAngle = stair.sweepAngle ?? (stairType === 'spiral' ? Math.PI * 2 : Math.PI / 2)
|
||||||
stair.sweepAngle ?? (stairType === 'spiral' ? Math.PI * 2 : Math.PI / 2)
|
|
||||||
|
|
||||||
if (Math.abs(baseSweepAngle) >= Math.PI * 2) {
|
if (Math.abs(baseSweepAngle) >= Math.PI * 2) {
|
||||||
return Math.sign(baseSweepAngle || 1) * (Math.PI * 2 - 0.001)
|
return Math.sign(baseSweepAngle || 1) * (Math.PI * 2 - 0.001)
|
||||||
@@ -1850,7 +1851,6 @@ function formatArea(areaSqM: number, unit: 'metric' | 'imperial') {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function getWallMeasurementOverlay(
|
function getWallMeasurementOverlay(
|
||||||
wall: WallNode,
|
wall: WallNode,
|
||||||
centerX: number,
|
centerX: number,
|
||||||
@@ -2035,32 +2035,20 @@ function getLinearMeasurementOverlay(
|
|||||||
x1: toSvgX(start.x),
|
x1: toSvgX(start.x),
|
||||||
y1: toSvgY(start.y),
|
y1: toSvgY(start.y),
|
||||||
x2: toSvgX(
|
x2: toSvgX(
|
||||||
offsetVector
|
offsetVector ? start.x + offsetVector.x * (offsetDistance + extensionOvershoot) : start.x,
|
||||||
? start.x +
|
|
||||||
offsetVector.x *
|
|
||||||
(offsetDistance + extensionOvershoot)
|
|
||||||
: start.x,
|
|
||||||
),
|
),
|
||||||
y2: toSvgY(
|
y2: toSvgY(
|
||||||
offsetVector
|
offsetVector ? start.y + offsetVector.y * (offsetDistance + extensionOvershoot) : start.y,
|
||||||
? start.y +
|
|
||||||
offsetVector.y *
|
|
||||||
(offsetDistance + extensionOvershoot)
|
|
||||||
: start.y,
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
extensionEnd: {
|
extensionEnd: {
|
||||||
x1: toSvgX(end.x),
|
x1: toSvgX(end.x),
|
||||||
y1: toSvgY(end.y),
|
y1: toSvgY(end.y),
|
||||||
x2: toSvgX(
|
x2: toSvgX(
|
||||||
offsetVector
|
offsetVector ? end.x + offsetVector.x * (offsetDistance + extensionOvershoot) : end.x,
|
||||||
? end.x + offsetVector.x * (offsetDistance + extensionOvershoot)
|
|
||||||
: end.x,
|
|
||||||
),
|
),
|
||||||
y2: toSvgY(
|
y2: toSvgY(
|
||||||
offsetVector
|
offsetVector ? end.y + offsetVector.y * (offsetDistance + extensionOvershoot) : end.y,
|
||||||
? end.y + offsetVector.y * (offsetDistance + extensionOvershoot)
|
|
||||||
: end.y,
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
label,
|
label,
|
||||||
@@ -2083,7 +2071,10 @@ type WallMeasurementFaceContext = {
|
|||||||
inwardNormal: Point2D
|
inwardNormal: Point2D
|
||||||
}
|
}
|
||||||
|
|
||||||
function getWallFaceLines(polygon: Point2D[], wall: WallNode): { left: WallFaceLine; right: WallFaceLine } | null {
|
function getWallFaceLines(
|
||||||
|
polygon: Point2D[],
|
||||||
|
wall: WallNode,
|
||||||
|
): { left: WallFaceLine; right: WallFaceLine } | null {
|
||||||
if (polygon.length < 4 || isCurvedWall(wall)) {
|
if (polygon.length < 4 || isCurvedWall(wall)) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -2162,9 +2153,7 @@ function getWallMeasurementFaceContext(
|
|||||||
y: wallMidpoint.y - centerY,
|
y: wallMidpoint.y - centerY,
|
||||||
}
|
}
|
||||||
const outwardNormal =
|
const outwardNormal =
|
||||||
fromCenter.x * normal.x + fromCenter.y * normal.y >= 0
|
fromCenter.x * normal.x + fromCenter.y * normal.y >= 0 ? normal : { x: -normal.x, y: -normal.y }
|
||||||
? normal
|
|
||||||
: { x: -normal.x, y: -normal.y }
|
|
||||||
const rightMidpoint = getLineMidpoint(faceLines.right)
|
const rightMidpoint = getLineMidpoint(faceLines.right)
|
||||||
const leftMidpoint = getLineMidpoint(faceLines.left)
|
const leftMidpoint = getLineMidpoint(faceLines.left)
|
||||||
const rightScore =
|
const rightScore =
|
||||||
@@ -2204,7 +2193,10 @@ function getAdjacentOpeningBounds(
|
|||||||
const startDistance = opening.position[0] - opening.width / 2
|
const startDistance = opening.position[0] - opening.width / 2
|
||||||
const endDistance = opening.position[0] + opening.width / 2
|
const endDistance = opening.position[0] + opening.width / 2
|
||||||
|
|
||||||
if (endDistance <= current.startDistance && (leftBoundary === null || endDistance > leftBoundary)) {
|
if (
|
||||||
|
endDistance <= current.startDistance &&
|
||||||
|
(leftBoundary === null || endDistance > leftBoundary)
|
||||||
|
) {
|
||||||
leftBoundary = endDistance
|
leftBoundary = endDistance
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2269,8 +2261,14 @@ function getSelectedWallMeasurementOverlays(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { outerFace, innerFace, outwardNormal, inwardNormal } = faceContext
|
const { outerFace, innerFace, outwardNormal, inwardNormal } = faceContext
|
||||||
const outerLength = Math.hypot(outerFace.end.x - outerFace.start.x, outerFace.end.y - outerFace.start.y)
|
const outerLength = Math.hypot(
|
||||||
const innerLength = Math.hypot(innerFace.end.x - innerFace.start.x, innerFace.end.y - innerFace.start.y)
|
outerFace.end.x - outerFace.start.x,
|
||||||
|
outerFace.end.y - outerFace.start.y,
|
||||||
|
)
|
||||||
|
const innerLength = Math.hypot(
|
||||||
|
innerFace.end.x - innerFace.start.x,
|
||||||
|
innerFace.end.y - innerFace.start.y,
|
||||||
|
)
|
||||||
const overlays: LinearMeasurementOverlay[] = []
|
const overlays: LinearMeasurementOverlay[] = []
|
||||||
|
|
||||||
if (outerLength >= 0.1) {
|
if (outerLength >= 0.1) {
|
||||||
@@ -2341,8 +2339,14 @@ function getItemDimensionMeasurementOverlays(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const centroid = polygonCentroid(polygon)
|
const centroid = polygonCentroid(polygon)
|
||||||
const configuredWidth = formatMeasurement(itemEntry.item.scale[0] * itemEntry.item.asset.dimensions[0], unit)
|
const configuredWidth = formatMeasurement(
|
||||||
const configuredDepth = formatMeasurement(itemEntry.item.scale[2] * itemEntry.item.asset.dimensions[2], unit)
|
itemEntry.item.scale[0] * itemEntry.item.asset.dimensions[0],
|
||||||
|
unit,
|
||||||
|
)
|
||||||
|
const configuredDepth = formatMeasurement(
|
||||||
|
itemEntry.item.scale[2] * itemEntry.item.asset.dimensions[2],
|
||||||
|
unit,
|
||||||
|
)
|
||||||
const buildSideOverlay = (id: string, start: Point2D, end: Point2D) => {
|
const buildSideOverlay = (id: string, start: Point2D, end: Point2D) => {
|
||||||
const edgeVector = {
|
const edgeVector = {
|
||||||
x: end.x - start.x,
|
x: end.x - start.x,
|
||||||
@@ -3346,7 +3350,9 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
|
|||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
onPointerEnter={canSelectCeilings ? () => onCeilingHoverChange(ceiling.id) : undefined}
|
onPointerEnter={
|
||||||
|
canSelectCeilings ? () => onCeilingHoverChange(ceiling.id) : undefined
|
||||||
|
}
|
||||||
onPointerLeave={canSelectCeilings ? () => onCeilingHoverChange(null) : undefined}
|
onPointerLeave={canSelectCeilings ? () => onCeilingHoverChange(null) : undefined}
|
||||||
pointerEvents={canSelectCeilings ? undefined : 'none'}
|
pointerEvents={canSelectCeilings ? undefined : 'none'}
|
||||||
style={canSelectCeilings ? { cursor: EDITOR_CURSOR } : undefined}
|
style={canSelectCeilings ? { cursor: EDITOR_CURSOR } : undefined}
|
||||||
@@ -3410,11 +3416,7 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<polygon
|
<polygon
|
||||||
fill={
|
fill={isDeleteHovered ? palette.deleteWallFill : palette.wallFill}
|
||||||
isDeleteHovered
|
|
||||||
? palette.deleteWallFill
|
|
||||||
: palette.wallFill
|
|
||||||
}
|
|
||||||
onClick={
|
onClick={
|
||||||
canSelectGeometry
|
canSelectGeometry
|
||||||
? (event) => {
|
? (event) => {
|
||||||
@@ -3561,11 +3563,7 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
|
|||||||
<polygon
|
<polygon
|
||||||
fill={isDeleteHovered ? palette.deleteFill : symbolFill}
|
fill={isDeleteHovered ? palette.deleteFill : symbolFill}
|
||||||
points={points}
|
points={points}
|
||||||
stroke={
|
stroke={isDeleteHovered ? palette.deleteStroke : symbolStroke}
|
||||||
isDeleteHovered
|
|
||||||
? palette.deleteStroke
|
|
||||||
: symbolStroke
|
|
||||||
}
|
|
||||||
strokeOpacity={1}
|
strokeOpacity={1}
|
||||||
strokeWidth={symbolStrokeWidth}
|
strokeWidth={symbolStrokeWidth}
|
||||||
vectorEffect="non-scaling-stroke"
|
vectorEffect="non-scaling-stroke"
|
||||||
@@ -3578,11 +3576,7 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
|
|||||||
insetOuterEnd,
|
insetOuterEnd,
|
||||||
insetOuterStart,
|
insetOuterStart,
|
||||||
])}
|
])}
|
||||||
stroke={
|
stroke={isDeleteHovered ? palette.deleteStroke : symbolStroke}
|
||||||
isDeleteHovered
|
|
||||||
? palette.deleteStroke
|
|
||||||
: symbolStroke
|
|
||||||
}
|
|
||||||
strokeWidth={innerStrokeWidth}
|
strokeWidth={innerStrokeWidth}
|
||||||
vectorEffect="non-scaling-stroke"
|
vectorEffect="non-scaling-stroke"
|
||||||
/>
|
/>
|
||||||
@@ -3681,8 +3675,7 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
|
|||||||
const hingesSide = opening.hingesSide ?? 'left'
|
const hingesSide = opening.hingesSide ?? 'left'
|
||||||
const swingDirection = opening.swingDirection ?? 'inward'
|
const swingDirection = opening.swingDirection ?? 'inward'
|
||||||
const width = opening.width
|
const width = opening.width
|
||||||
const doorColor =
|
const doorColor = isSelected || isSelectionHighlighted ? '#f97316' : '#3f3f46'
|
||||||
isSelected || isSelectionHighlighted ? '#f97316' : '#3f3f46'
|
|
||||||
const swingColor =
|
const swingColor =
|
||||||
isSelected || isSelectionHighlighted
|
isSelected || isSelectionHighlighted
|
||||||
? 'rgba(249, 115, 22, 0.78)'
|
? 'rgba(249, 115, 22, 0.78)'
|
||||||
@@ -3761,11 +3754,7 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
|
|||||||
<polygon
|
<polygon
|
||||||
fill={isDeleteHovered ? palette.deleteFill : openingFill}
|
fill={isDeleteHovered ? palette.deleteFill : openingFill}
|
||||||
points={points}
|
points={points}
|
||||||
stroke={
|
stroke={isDeleteHovered ? palette.deleteStroke : doorColor}
|
||||||
isDeleteHovered
|
|
||||||
? palette.deleteStroke
|
|
||||||
: doorColor
|
|
||||||
}
|
|
||||||
strokeOpacity={1}
|
strokeOpacity={1}
|
||||||
strokeWidth={isSelected || isSelectionHighlighted ? '2.25' : '1.25'}
|
strokeWidth={isSelected || isSelectionHighlighted ? '2.25' : '1.25'}
|
||||||
vectorEffect="non-scaling-stroke"
|
vectorEffect="non-scaling-stroke"
|
||||||
@@ -3914,7 +3903,11 @@ const FloorplanFenceLayer = memo(function FloorplanFenceLayer({
|
|||||||
const fenceUnderlayWidth = isActive ? '6.5' : isHovered ? '6' : '5.2'
|
const fenceUnderlayWidth = isActive ? '6.5' : isHovered ? '6' : '5.2'
|
||||||
const fenceStrokeWidth = isActive ? '2.6' : isHovered ? '2.35' : '2.05'
|
const fenceStrokeWidth = isActive ? '2.6' : isHovered ? '2.35' : '2.05'
|
||||||
const privacyMarkerWidth = clamp(fence.postSize * 0.58, 0.038, 0.068)
|
const privacyMarkerWidth = clamp(fence.postSize * 0.58, 0.038, 0.068)
|
||||||
const privacyMarkerHeight = clamp(Math.max(fence.baseHeight * 0.5, fence.postSize * 1.4), 0.1, 0.17)
|
const privacyMarkerHeight = clamp(
|
||||||
|
Math.max(fence.baseHeight * 0.5, fence.postSize * 1.4),
|
||||||
|
0.1,
|
||||||
|
0.17,
|
||||||
|
)
|
||||||
const railMarkerRadius = clamp(fence.postSize * 0.52, 0.048, 0.078)
|
const railMarkerRadius = clamp(fence.postSize * 0.52, 0.048, 0.078)
|
||||||
const slatMarkerHalf = clamp(fence.postSize * 0.42, 0.03, 0.055)
|
const slatMarkerHalf = clamp(fence.postSize * 0.42, 0.03, 0.055)
|
||||||
const markerStrokeWidth = isActive ? '1.65' : '1.35'
|
const markerStrokeWidth = isActive ? '1.65' : '1.35'
|
||||||
@@ -4111,6 +4104,42 @@ const FloorplanFenceLayer = memo(function FloorplanFenceLayer({
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Renders an item's 2D floor-plan image (top-down view, object-fit:contain)
|
||||||
|
// inside its footprint rectangle. Placed at the same scene position/rotation
|
||||||
|
// as the polygon so it lines up exactly.
|
||||||
|
function FloorplanItemImage({
|
||||||
|
url,
|
||||||
|
center,
|
||||||
|
rotation,
|
||||||
|
width,
|
||||||
|
depth,
|
||||||
|
}: {
|
||||||
|
url: string
|
||||||
|
center: Point2D
|
||||||
|
rotation: number
|
||||||
|
width: number
|
||||||
|
depth: number
|
||||||
|
}) {
|
||||||
|
const resolvedUrl = useResolvedAssetUrl(url)
|
||||||
|
if (!resolvedUrl) return null
|
||||||
|
const rotationDeg = (-rotation * 180) / Math.PI
|
||||||
|
return (
|
||||||
|
<g
|
||||||
|
pointerEvents="none"
|
||||||
|
transform={`translate(${toSvgX(center.x)} ${toSvgY(center.y)}) rotate(${rotationDeg})`}
|
||||||
|
>
|
||||||
|
<image
|
||||||
|
height={depth}
|
||||||
|
href={resolvedUrl}
|
||||||
|
preserveAspectRatio="xMidYMid meet"
|
||||||
|
width={width}
|
||||||
|
x={-width / 2}
|
||||||
|
y={-depth / 2}
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const FloorplanNodeLayer = memo(function FloorplanNodeLayer({
|
const FloorplanNodeLayer = memo(function FloorplanNodeLayer({
|
||||||
canFocusItems,
|
canFocusItems,
|
||||||
canFocusStairs,
|
canFocusStairs,
|
||||||
@@ -4167,7 +4196,7 @@ const FloorplanNodeLayer = memo(function FloorplanNodeLayer({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const itemNodes = itemEntries.map((itemEntry) => {
|
const itemNodes = itemEntries.map((itemEntry) => {
|
||||||
const { item, points, polygon } = itemEntry
|
const { item, points, polygon, center, rotation, width, depth } = itemEntry
|
||||||
const itemDimensionMeasurements = getItemDimensionMeasurementOverlays(itemEntry, unit)
|
const itemDimensionMeasurements = getItemDimensionMeasurementOverlays(itemEntry, unit)
|
||||||
const isSelected = selectedIdSet.has(item.id)
|
const isSelected = selectedIdSet.has(item.id)
|
||||||
const isHighlighted = highlightedIdSet.has(item.id)
|
const isHighlighted = highlightedIdSet.has(item.id)
|
||||||
@@ -4197,6 +4226,7 @@ const FloorplanNodeLayer = memo(function FloorplanNodeLayer({
|
|||||||
: isHovered
|
: isHovered
|
||||||
? 0.58
|
? 0.58
|
||||||
: 0.52
|
: 0.52
|
||||||
|
const floorPlanUrl = item.asset.floorPlanUrl
|
||||||
const diagonalAStart = polygon[0]
|
const diagonalAStart = polygon[0]
|
||||||
const diagonalAEnd = polygon[2]
|
const diagonalAEnd = polygon[2]
|
||||||
const diagonalBStart = polygon[1]
|
const diagonalBStart = polygon[1]
|
||||||
@@ -4285,31 +4315,43 @@ const FloorplanNodeLayer = memo(function FloorplanNodeLayer({
|
|||||||
strokeOpacity={1}
|
strokeOpacity={1}
|
||||||
strokeWidth={FLOORPLAN_NODE_FOOTPRINT_STROKE_WIDTH}
|
strokeWidth={FLOORPLAN_NODE_FOOTPRINT_STROKE_WIDTH}
|
||||||
/>
|
/>
|
||||||
{diagonalAStart && diagonalAEnd && (
|
{floorPlanUrl ? (
|
||||||
<line
|
<FloorplanItemImage
|
||||||
pointerEvents="none"
|
center={center}
|
||||||
stroke={stroke}
|
depth={depth}
|
||||||
strokeLinecap="round"
|
rotation={rotation}
|
||||||
strokeOpacity={crossStrokeOpacity}
|
url={floorPlanUrl}
|
||||||
strokeWidth={FLOORPLAN_NODE_FOOTPRINT_CROSS_STROKE_WIDTH}
|
width={width}
|
||||||
x1={toSvgX(diagonalAStart.x)}
|
|
||||||
x2={toSvgX(diagonalAEnd.x)}
|
|
||||||
y1={toSvgY(diagonalAStart.y)}
|
|
||||||
y2={toSvgY(diagonalAEnd.y)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{diagonalBStart && diagonalBEnd && (
|
|
||||||
<line
|
|
||||||
pointerEvents="none"
|
|
||||||
stroke={stroke}
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeOpacity={crossStrokeOpacity}
|
|
||||||
strokeWidth={FLOORPLAN_NODE_FOOTPRINT_CROSS_STROKE_WIDTH}
|
|
||||||
x1={toSvgX(diagonalBStart.x)}
|
|
||||||
x2={toSvgX(diagonalBEnd.x)}
|
|
||||||
y1={toSvgY(diagonalBStart.y)}
|
|
||||||
y2={toSvgY(diagonalBEnd.y)}
|
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{diagonalAStart && diagonalAEnd && (
|
||||||
|
<line
|
||||||
|
pointerEvents="none"
|
||||||
|
stroke={stroke}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeOpacity={crossStrokeOpacity}
|
||||||
|
strokeWidth={FLOORPLAN_NODE_FOOTPRINT_CROSS_STROKE_WIDTH}
|
||||||
|
x1={toSvgX(diagonalAStart.x)}
|
||||||
|
x2={toSvgX(diagonalAEnd.x)}
|
||||||
|
y1={toSvgY(diagonalAStart.y)}
|
||||||
|
y2={toSvgY(diagonalAEnd.y)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{diagonalBStart && diagonalBEnd && (
|
||||||
|
<line
|
||||||
|
pointerEvents="none"
|
||||||
|
stroke={stroke}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeOpacity={crossStrokeOpacity}
|
||||||
|
strokeWidth={FLOORPLAN_NODE_FOOTPRINT_CROSS_STROKE_WIDTH}
|
||||||
|
x1={toSvgX(diagonalBStart.x)}
|
||||||
|
x2={toSvgX(diagonalBEnd.x)}
|
||||||
|
y1={toSvgY(diagonalBStart.y)}
|
||||||
|
y2={toSvgY(diagonalBEnd.y)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{itemDimensionMeasurements.length > 0 ? (
|
{itemDimensionMeasurements.length > 0 ? (
|
||||||
<FloorplanMeasurementsLayer
|
<FloorplanMeasurementsLayer
|
||||||
@@ -5571,20 +5613,19 @@ export function FloorplanPanel() {
|
|||||||
const live = useLiveTransforms.getState().get(fence.id)
|
const live = useLiveTransforms.getState().get(fence.id)
|
||||||
const fenceCenterX = (fence.start[0] + fence.end[0]) / 2
|
const fenceCenterX = (fence.start[0] + fence.end[0]) / 2
|
||||||
const fenceCenterZ = (fence.start[1] + fence.end[1]) / 2
|
const fenceCenterZ = (fence.start[1] + fence.end[1]) / 2
|
||||||
const displayFence =
|
const displayFence = live
|
||||||
live
|
? {
|
||||||
? {
|
...fence,
|
||||||
...fence,
|
start: [
|
||||||
start: [
|
fence.start[0] + (live.position[0] - fenceCenterX),
|
||||||
fence.start[0] + (live.position[0] - fenceCenterX),
|
fence.start[1] + (live.position[2] - fenceCenterZ),
|
||||||
fence.start[1] + (live.position[2] - fenceCenterZ),
|
] as typeof fence.start,
|
||||||
] as typeof fence.start,
|
end: [
|
||||||
end: [
|
fence.end[0] + (live.position[0] - fenceCenterX),
|
||||||
fence.end[0] + (live.position[0] - fenceCenterX),
|
fence.end[1] + (live.position[2] - fenceCenterZ),
|
||||||
fence.end[1] + (live.position[2] - fenceCenterZ),
|
] as typeof fence.end,
|
||||||
] as typeof fence.end,
|
}
|
||||||
}
|
: fence
|
||||||
: fence
|
|
||||||
const centerline = isCurvedWall(displayFence)
|
const centerline = isCurvedWall(displayFence)
|
||||||
? sampleWallCenterline(displayFence, 24)
|
? sampleWallCenterline(displayFence, 24)
|
||||||
: [
|
: [
|
||||||
@@ -5677,16 +5718,22 @@ export function FloorplanPanel() {
|
|||||||
if (!wall) return []
|
if (!wall) return []
|
||||||
const live = useLiveTransforms.getState().get(opening.id)
|
const live = useLiveTransforms.getState().get(opening.id)
|
||||||
const displayOpening =
|
const displayOpening =
|
||||||
live && (movingNode?.type === 'door' || movingNode?.type === 'window') && movingNode.id === opening.id
|
live &&
|
||||||
? ({
|
(movingNode?.type === 'door' || movingNode?.type === 'window') &&
|
||||||
|
movingNode.id === opening.id
|
||||||
|
? {
|
||||||
...opening,
|
...opening,
|
||||||
position: [
|
position: [
|
||||||
live.position[0],
|
live.position[0],
|
||||||
opening.position[1],
|
opening.position[1],
|
||||||
live.position[2],
|
live.position[2],
|
||||||
] as typeof opening.position,
|
] as typeof opening.position,
|
||||||
rotation: [opening.rotation[0], live.rotation, opening.rotation[2]] as typeof opening.rotation,
|
rotation: [
|
||||||
})
|
opening.rotation[0],
|
||||||
|
live.rotation,
|
||||||
|
opening.rotation[2],
|
||||||
|
] as typeof opening.rotation,
|
||||||
|
}
|
||||||
: opening
|
: opening
|
||||||
const polygon = getOpeningFootprint(wall, displayOpening)
|
const polygon = getOpeningFootprint(wall, displayOpening)
|
||||||
return [
|
return [
|
||||||
@@ -5831,6 +5878,10 @@ export function FloorplanPanel() {
|
|||||||
points: formatPolygonPoints(entry.polygon),
|
points: formatPolygonPoints(entry.polygon),
|
||||||
polygon: entry.polygon,
|
polygon: entry.polygon,
|
||||||
usesRealMesh: entry.usesRealMesh,
|
usesRealMesh: entry.usesRealMesh,
|
||||||
|
center: entry.center,
|
||||||
|
rotation: entry.rotation,
|
||||||
|
width: entry.width,
|
||||||
|
depth: entry.depth,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
@@ -5907,18 +5958,17 @@ export function FloorplanPanel() {
|
|||||||
buildingRotationY,
|
buildingRotationY,
|
||||||
)
|
)
|
||||||
: null
|
: null
|
||||||
const displayRoof =
|
const displayRoof = liveRoofTransform
|
||||||
liveRoofTransform
|
? {
|
||||||
? {
|
...roof,
|
||||||
...roof,
|
position: [
|
||||||
position: [
|
liveRoofPosition?.x ?? roof.position[0],
|
||||||
liveRoofPosition?.x ?? roof.position[0],
|
roof.position[1],
|
||||||
roof.position[1],
|
liveRoofPosition?.y ?? roof.position[2],
|
||||||
liveRoofPosition?.y ?? roof.position[2],
|
] as RoofNode['position'],
|
||||||
] as RoofNode['position'],
|
rotation: liveRoofTransform.rotation,
|
||||||
rotation: liveRoofTransform.rotation,
|
}
|
||||||
}
|
: roof
|
||||||
: roof
|
|
||||||
const segments = (displayRoof.children ?? [])
|
const segments = (displayRoof.children ?? [])
|
||||||
.map((childId) => levelDescendantNodeById.get(childId as AnyNodeId))
|
.map((childId) => levelDescendantNodeById.get(childId as AnyNodeId))
|
||||||
.filter(
|
.filter(
|
||||||
@@ -6046,12 +6096,10 @@ export function FloorplanPanel() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let bestHit:
|
let bestHit: {
|
||||||
| {
|
point: Point2D
|
||||||
point: Point2D
|
distance: number
|
||||||
distance: number
|
} | null = null
|
||||||
}
|
|
||||||
| null = null
|
|
||||||
|
|
||||||
for (const { polygon: wallPolygon } of displayWallPolygons) {
|
for (const { polygon: wallPolygon } of displayWallPolygons) {
|
||||||
for (let wallIndex = 0; wallIndex < wallPolygon.length; wallIndex += 1) {
|
for (let wallIndex = 0; wallIndex < wallPolygon.length; wallIndex += 1) {
|
||||||
@@ -6123,7 +6171,9 @@ export function FloorplanPanel() {
|
|||||||
return [] as LinearMeasurementOverlay[]
|
return [] as LinearMeasurementOverlay[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const wallEntry = displayWallPolygons.find(({ wall }) => wall.id === openingEntry.opening.parentId)
|
const wallEntry = displayWallPolygons.find(
|
||||||
|
({ wall }) => wall.id === openingEntry.opening.parentId,
|
||||||
|
)
|
||||||
if (!wallEntry || isCurvedWall(wallEntry.wall)) {
|
if (!wallEntry || isCurvedWall(wallEntry.wall)) {
|
||||||
return [] as LinearMeasurementOverlay[]
|
return [] as LinearMeasurementOverlay[]
|
||||||
}
|
}
|
||||||
@@ -6161,8 +6211,14 @@ export function FloorplanPanel() {
|
|||||||
)
|
)
|
||||||
const faceOffsetDistance = (wall.thickness ?? 0.1) / 2
|
const faceOffsetDistance = (wall.thickness ?? 0.1) / 2
|
||||||
const openingFaceStart = {
|
const openingFaceStart = {
|
||||||
x: wall.start[0] + tangent.x * startDistance + faceContext.outwardNormal.x * faceOffsetDistance,
|
x:
|
||||||
y: wall.start[1] + tangent.y * startDistance + faceContext.outwardNormal.y * faceOffsetDistance,
|
wall.start[0] +
|
||||||
|
tangent.x * startDistance +
|
||||||
|
faceContext.outwardNormal.x * faceOffsetDistance,
|
||||||
|
y:
|
||||||
|
wall.start[1] +
|
||||||
|
tangent.y * startDistance +
|
||||||
|
faceContext.outwardNormal.y * faceOffsetDistance,
|
||||||
}
|
}
|
||||||
const openingFaceEnd = {
|
const openingFaceEnd = {
|
||||||
x: wall.start[0] + tangent.x * endDistance + faceContext.outwardNormal.x * faceOffsetDistance,
|
x: wall.start[0] + tangent.x * endDistance + faceContext.outwardNormal.x * faceOffsetDistance,
|
||||||
@@ -6172,15 +6228,27 @@ export function FloorplanPanel() {
|
|||||||
leftBoundary === null
|
leftBoundary === null
|
||||||
? faceContext.outerFace.start
|
? faceContext.outerFace.start
|
||||||
: {
|
: {
|
||||||
x: wall.start[0] + tangent.x * leftBoundary + faceContext.outwardNormal.x * faceOffsetDistance,
|
x:
|
||||||
y: wall.start[1] + tangent.y * leftBoundary + faceContext.outwardNormal.y * faceOffsetDistance,
|
wall.start[0] +
|
||||||
|
tangent.x * leftBoundary +
|
||||||
|
faceContext.outwardNormal.x * faceOffsetDistance,
|
||||||
|
y:
|
||||||
|
wall.start[1] +
|
||||||
|
tangent.y * leftBoundary +
|
||||||
|
faceContext.outwardNormal.y * faceOffsetDistance,
|
||||||
}
|
}
|
||||||
const rightBoundaryPoint =
|
const rightBoundaryPoint =
|
||||||
rightBoundary === null
|
rightBoundary === null
|
||||||
? faceContext.outerFace.end
|
? faceContext.outerFace.end
|
||||||
: {
|
: {
|
||||||
x: wall.start[0] + tangent.x * rightBoundary + faceContext.outwardNormal.x * faceOffsetDistance,
|
x:
|
||||||
y: wall.start[1] + tangent.y * rightBoundary + faceContext.outwardNormal.y * faceOffsetDistance,
|
wall.start[0] +
|
||||||
|
tangent.x * rightBoundary +
|
||||||
|
faceContext.outwardNormal.x * faceOffsetDistance,
|
||||||
|
y:
|
||||||
|
wall.start[1] +
|
||||||
|
tangent.y * rightBoundary +
|
||||||
|
faceContext.outwardNormal.y * faceOffsetDistance,
|
||||||
}
|
}
|
||||||
const overlays: LinearMeasurementOverlay[] = []
|
const overlays: LinearMeasurementOverlay[] = []
|
||||||
const leftDistance = getPlanPointDistance(leftBoundaryPoint, openingFaceStart)
|
const leftDistance = getPlanPointDistance(leftBoundaryPoint, openingFaceStart)
|
||||||
@@ -6722,28 +6790,25 @@ export function FloorplanPanel() {
|
|||||||
// Keep the live draft preview cheap; full level-wide mitering here runs on every mouse move.
|
// Keep the live draft preview cheap; full level-wide mitering here runs on every mouse move.
|
||||||
return getWallPlanFootprint(draftWall, EMPTY_WALL_MITER_DATA)
|
return getWallPlanFootprint(draftWall, EMPTY_WALL_MITER_DATA)
|
||||||
}, [draftEnd, draftStart, levelId])
|
}, [draftEnd, draftStart, levelId])
|
||||||
const draftPolygonPoints = useMemo(
|
const draftPolygonPoints = useMemo(() => {
|
||||||
() => {
|
if (isRoofBuildActive && roofDraftStart && roofDraftEnd) {
|
||||||
if (isRoofBuildActive && roofDraftStart && roofDraftEnd) {
|
const minX = Math.min(roofDraftStart[0], roofDraftEnd[0])
|
||||||
const minX = Math.min(roofDraftStart[0], roofDraftEnd[0])
|
const maxX = Math.max(roofDraftStart[0], roofDraftEnd[0])
|
||||||
const maxX = Math.max(roofDraftStart[0], roofDraftEnd[0])
|
const minY = Math.min(roofDraftStart[1], roofDraftEnd[1])
|
||||||
const minY = Math.min(roofDraftStart[1], roofDraftEnd[1])
|
const maxY = Math.max(roofDraftStart[1], roofDraftEnd[1])
|
||||||
const maxY = Math.max(roofDraftStart[1], roofDraftEnd[1])
|
|
||||||
|
|
||||||
if (Math.abs(maxX - minX) >= 1e-6 || Math.abs(maxY - minY) >= 1e-6) {
|
if (Math.abs(maxX - minX) >= 1e-6 || Math.abs(maxY - minY) >= 1e-6) {
|
||||||
return formatPolygonPoints([
|
return formatPolygonPoints([
|
||||||
{ x: minX, y: minY },
|
{ x: minX, y: minY },
|
||||||
{ x: maxX, y: minY },
|
{ x: maxX, y: minY },
|
||||||
{ x: maxX, y: maxY },
|
{ x: maxX, y: maxY },
|
||||||
{ x: minX, y: maxY },
|
{ x: minX, y: maxY },
|
||||||
])
|
])
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return draftPolygon ? formatPolygonPoints(draftPolygon) : null
|
return draftPolygon ? formatPolygonPoints(draftPolygon) : null
|
||||||
},
|
}, [draftPolygon, isRoofBuildActive, roofDraftEnd, roofDraftStart])
|
||||||
[draftPolygon, isRoofBuildActive, roofDraftEnd, roofDraftStart],
|
|
||||||
)
|
|
||||||
const fenceDraftSegment = useMemo(() => {
|
const fenceDraftSegment = useMemo(() => {
|
||||||
if (!(isFenceBuildActive && fenceDraftStart && fenceDraftEnd)) {
|
if (!(isFenceBuildActive && fenceDraftStart && fenceDraftEnd)) {
|
||||||
return null
|
return null
|
||||||
@@ -6931,7 +6996,9 @@ export function FloorplanPanel() {
|
|||||||
if (levelChanged) {
|
if (levelChanged) {
|
||||||
previousLevelIdRef.current = levelId ?? null
|
previousLevelIdRef.current = levelId ?? null
|
||||||
hasUserAdjustedViewportRef.current = false
|
hasUserAdjustedViewportRef.current = false
|
||||||
setViewport((current) => (floorplanViewportEquals(current, fittedViewport) ? current : fittedViewport))
|
setViewport((current) =>
|
||||||
|
floorplanViewportEquals(current, fittedViewport) ? current : fittedViewport,
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6949,7 +7016,9 @@ export function FloorplanPanel() {
|
|||||||
isPolygonDraftBuildActive
|
isPolygonDraftBuildActive
|
||||||
|
|
||||||
if (!hasUserAdjustedViewportRef.current && !transientFloorplanFit) {
|
if (!hasUserAdjustedViewportRef.current && !transientFloorplanFit) {
|
||||||
setViewport((current) => (floorplanViewportEquals(current, fittedViewport) ? current : fittedViewport))
|
setViewport((current) =>
|
||||||
|
floorplanViewportEquals(current, fittedViewport) ? current : fittedViewport,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
curvingFence,
|
curvingFence,
|
||||||
@@ -7044,7 +7113,11 @@ export function FloorplanPanel() {
|
|||||||
const selectedStairActionMenuPosition = useMemo(
|
const selectedStairActionMenuPosition = useMemo(
|
||||||
() =>
|
() =>
|
||||||
selectedStairEntry
|
selectedStairEntry
|
||||||
? getFloorplanActionMenuPosition(selectedStairEntry.hitPolygons.flat(), viewBox, surfaceSize)
|
? getFloorplanActionMenuPosition(
|
||||||
|
selectedStairEntry.hitPolygons.flat(),
|
||||||
|
viewBox,
|
||||||
|
surfaceSize,
|
||||||
|
)
|
||||||
: null,
|
: null,
|
||||||
[selectedStairEntry, surfaceSize, viewBox],
|
[selectedStairEntry, surfaceSize, viewBox],
|
||||||
)
|
)
|
||||||
@@ -7611,12 +7684,7 @@ export function FloorplanPanel() {
|
|||||||
])
|
])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (isWallBuildActive || isFenceBuildActive || isRoofBuildActive || isPolygonDraftBuildActive) {
|
||||||
isWallBuildActive ||
|
|
||||||
isFenceBuildActive ||
|
|
||||||
isRoofBuildActive ||
|
|
||||||
isPolygonDraftBuildActive
|
|
||||||
) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -8130,7 +8198,10 @@ export function FloorplanPanel() {
|
|||||||
const currentWall = wallById.get(update.id)
|
const currentWall = wallById.get(update.id)
|
||||||
return (
|
return (
|
||||||
currentWall &&
|
currentWall &&
|
||||||
!(pointsEqual(update.start, currentWall.start) && pointsEqual(update.end, currentWall.end))
|
!(
|
||||||
|
pointsEqual(update.start, currentWall.start) &&
|
||||||
|
pointsEqual(update.end, currentWall.end)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -8723,7 +8794,9 @@ export function FloorplanPanel() {
|
|||||||
|
|
||||||
if (roofDraftStart) {
|
if (roofDraftStart) {
|
||||||
setRoofDraftEnd((previousPoint) =>
|
setRoofDraftEnd((previousPoint) =>
|
||||||
previousPoint && pointsEqual(previousPoint, snappedPoint) ? previousPoint : snappedPoint,
|
previousPoint && pointsEqual(previousPoint, snappedPoint)
|
||||||
|
? previousPoint
|
||||||
|
: snappedPoint,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
@@ -8938,11 +9011,7 @@ export function FloorplanPanel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const firstPoint = ceilingDraftPoints[0]
|
const firstPoint = ceilingDraftPoints[0]
|
||||||
if (
|
if (firstPoint && ceilingDraftPoints.length >= 3 && isPointNearPlanPoint(point, firstPoint)) {
|
||||||
firstPoint &&
|
|
||||||
ceilingDraftPoints.length >= 3 &&
|
|
||||||
isPointNearPlanPoint(point, firstPoint)
|
|
||||||
) {
|
|
||||||
clearCeilingPlacementDraft()
|
clearCeilingPlacementDraft()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -9167,7 +9236,6 @@ export function FloorplanPanel() {
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
draftStart,
|
draftStart,
|
||||||
@@ -9511,21 +9579,24 @@ export function FloorplanPanel() {
|
|||||||
},
|
},
|
||||||
[setSelectedReferenceId],
|
[setSelectedReferenceId],
|
||||||
)
|
)
|
||||||
const handleFenceDoubleClick = useCallback((fence: FenceNode, event: ReactMouseEvent<SVGElement>) => {
|
const handleFenceDoubleClick = useCallback(
|
||||||
const centerX = (fence.start[0] + fence.end[0]) / 2
|
(fence: FenceNode, event: ReactMouseEvent<SVGElement>) => {
|
||||||
const centerZ = (fence.start[1] + fence.end[1]) / 2
|
const centerX = (fence.start[0] + fence.end[0]) / 2
|
||||||
const halfLength =
|
const centerZ = (fence.start[1] + fence.end[1]) / 2
|
||||||
Math.hypot(fence.end[0] - fence.start[0], fence.end[1] - fence.start[1]) / 2
|
const halfLength =
|
||||||
|
Math.hypot(fence.end[0] - fence.start[0], fence.end[1] - fence.start[1]) / 2
|
||||||
|
|
||||||
emitter.emit('fence:double-click', {
|
emitter.emit('fence:double-click', {
|
||||||
node: fence,
|
node: fence,
|
||||||
position: [centerX, 0, centerZ],
|
position: [centerX, 0, centerZ],
|
||||||
localPosition: [halfLength, 0, 0],
|
localPosition: [halfLength, 0, 0],
|
||||||
stopPropagation: () => event.stopPropagation(),
|
stopPropagation: () => event.stopPropagation(),
|
||||||
nativeEvent: event.nativeEvent as any,
|
nativeEvent: event.nativeEvent as any,
|
||||||
} as any)
|
} as any)
|
||||||
emitter.emit('camera-controls:focus', { nodeId: fence.id })
|
emitter.emit('camera-controls:focus', { nodeId: fence.id })
|
||||||
}, [])
|
},
|
||||||
|
[],
|
||||||
|
)
|
||||||
const emitFloorplanNodeClick = useCallback(
|
const emitFloorplanNodeClick = useCallback(
|
||||||
(
|
(
|
||||||
nodeId:
|
nodeId:
|
||||||
|
|||||||
@@ -1076,7 +1076,13 @@ export default function Editor({
|
|||||||
return <Component />
|
return <Component />
|
||||||
}
|
}
|
||||||
|
|
||||||
const tabBarTabs = sidebarTabs?.map(({ id, label }) => ({ id, label })) ?? []
|
const tabBarTabs =
|
||||||
|
sidebarTabs?.map(({ id, label, mobileDefaultSnap, mobileIcon }) => ({
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
mobileDefaultSnap,
|
||||||
|
mobileIcon,
|
||||||
|
})) ?? []
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PresetsProvider adapter={presetsAdapter}>
|
<PresetsProvider adapter={presetsAdapter}>
|
||||||
|
|||||||
@@ -265,18 +265,49 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
|
|||||||
)) as Uint8Array
|
)) as Uint8Array
|
||||||
|
|
||||||
const actualBytesPerRow = width * 4
|
const actualBytesPerRow = width * 4
|
||||||
|
const tightTotal = actualBytesPerRow * height
|
||||||
const paddedBytesPerRow = Math.ceil(actualBytesPerRow / 256) * 256
|
const paddedBytesPerRow = Math.ceil(actualBytesPerRow / 256) * 256
|
||||||
|
// Two readback shapes to handle:
|
||||||
|
// - WebGPU (`copyTextureToBuffer`): top-down + 256-byte row padding
|
||||||
|
// when width*4 isn't already a multiple of 256.
|
||||||
|
// - WebGL2 fallback (iOS Chrome, etc.): tightly-packed but bottom-up
|
||||||
|
// (OpenGL framebuffer convention).
|
||||||
|
// `isWebGPURenderer` lies — it stays true even when the renderer
|
||||||
|
// falls back to the WebGL backend. Inspect the actual backend
|
||||||
|
// instead (presence of a GPU device, or backend constructor name).
|
||||||
|
const backend = (renderer as any).backend
|
||||||
|
const isWebGPU =
|
||||||
|
!!backend?.device ||
|
||||||
|
backend?.isWebGPUBackend === true ||
|
||||||
|
backend?.constructor?.name === 'WebGPUBackend'
|
||||||
let tightPixels: Uint8ClampedArray
|
let tightPixels: Uint8ClampedArray
|
||||||
if (paddedBytesPerRow === actualBytesPerRow) {
|
if (isWebGPU) {
|
||||||
tightPixels = new Uint8ClampedArray(pixels.buffer, pixels.byteOffset, pixels.byteLength)
|
// WebGPU: depad rows if needed; orientation is already top-down.
|
||||||
|
if (paddedBytesPerRow === actualBytesPerRow) {
|
||||||
|
tightPixels = new Uint8ClampedArray(
|
||||||
|
pixels.buffer,
|
||||||
|
pixels.byteOffset,
|
||||||
|
Math.min(pixels.byteLength, tightTotal),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
tightPixels = new Uint8ClampedArray(tightTotal)
|
||||||
|
for (let row = 0; row < height; row++) {
|
||||||
|
tightPixels.set(
|
||||||
|
pixels.subarray(
|
||||||
|
row * paddedBytesPerRow,
|
||||||
|
row * paddedBytesPerRow + actualBytesPerRow,
|
||||||
|
),
|
||||||
|
row * actualBytesPerRow,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
tightPixels = new Uint8ClampedArray(width * height * 4)
|
// WebGL2: tight buffer in bottom-up order — flip rows.
|
||||||
|
tightPixels = new Uint8ClampedArray(tightTotal)
|
||||||
for (let row = 0; row < height; row++) {
|
for (let row = 0; row < height; row++) {
|
||||||
|
const srcStart = (height - 1 - row) * actualBytesPerRow
|
||||||
tightPixels.set(
|
tightPixels.set(
|
||||||
pixels.subarray(
|
pixels.subarray(srcStart, srcStart + actualBytesPerRow),
|
||||||
row * paddedBytesPerRow,
|
|
||||||
row * paddedBytesPerRow + actualBytesPerRow,
|
|
||||||
),
|
|
||||||
row * actualBytesPerRow,
|
row * actualBytesPerRow,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { emitter } from '@pascal-app/core'
|
|||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
import { ActionButton } from './action-button'
|
import { ActionButton } from './action-button'
|
||||||
|
|
||||||
export function CameraActions() {
|
export function CameraActions({ hideOrbit = false }: { hideOrbit?: boolean }) {
|
||||||
const goToTopView = () => {
|
const goToTopView = () => {
|
||||||
emitter.emit('camera-controls:top-view')
|
emitter.emit('camera-controls:top-view')
|
||||||
}
|
}
|
||||||
@@ -19,39 +19,43 @@ export function CameraActions() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{/* Orbit CCW */}
|
{!hideOrbit && (
|
||||||
<ActionButton
|
<>
|
||||||
className="group hover:bg-white/5"
|
{/* Orbit CCW */}
|
||||||
label="Orbit Left"
|
<ActionButton
|
||||||
onClick={orbitCCW}
|
className="group hover:bg-white/5"
|
||||||
size="icon"
|
label="Orbit Left"
|
||||||
variant="ghost"
|
onClick={orbitCCW}
|
||||||
>
|
size="icon"
|
||||||
<Image
|
variant="ghost"
|
||||||
alt="Orbit Left"
|
>
|
||||||
className="h-[28px] w-[28px] -scale-x-100 object-contain opacity-70 transition-opacity group-hover:opacity-100"
|
<Image
|
||||||
height={28}
|
alt="Orbit Left"
|
||||||
src="/icons/rotate.png"
|
className="h-[28px] w-[28px] -scale-x-100 object-contain opacity-70 transition-opacity group-hover:opacity-100"
|
||||||
width={28}
|
height={28}
|
||||||
/>
|
src="/icons/rotate.png"
|
||||||
</ActionButton>
|
width={28}
|
||||||
|
/>
|
||||||
|
</ActionButton>
|
||||||
|
|
||||||
{/* Orbit CW */}
|
{/* Orbit CW */}
|
||||||
<ActionButton
|
<ActionButton
|
||||||
className="group hover:bg-white/5"
|
className="group hover:bg-white/5"
|
||||||
label="Orbit Right"
|
label="Orbit Right"
|
||||||
onClick={orbitCW}
|
onClick={orbitCW}
|
||||||
size="icon"
|
size="icon"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
alt="Orbit Right"
|
alt="Orbit Right"
|
||||||
className="h-[28px] w-[28px] object-contain opacity-70 transition-opacity group-hover:opacity-100"
|
className="h-[28px] w-[28px] object-contain opacity-70 transition-opacity group-hover:opacity-100"
|
||||||
height={28}
|
height={28}
|
||||||
src="/icons/rotate.png"
|
src="/icons/rotate.png"
|
||||||
width={28}
|
width={28}
|
||||||
/>
|
/>
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Top View */}
|
{/* Top View */}
|
||||||
<ActionButton
|
<ActionButton
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ import { useScene } from '@pascal-app/core'
|
|||||||
import { AnimatePresence, motion } from 'motion/react'
|
import { AnimatePresence, motion } from 'motion/react'
|
||||||
import { useEffect, useMemo } from 'react'
|
import { useEffect, useMemo } from 'react'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
|
import { useReducedMotion } from './../../../hooks/use-reduced-motion'
|
||||||
|
import { useIsMobile } from './../../../hooks/use-mobile'
|
||||||
import { TooltipProvider } from './../../../components/ui/primitives/tooltip'
|
import { TooltipProvider } from './../../../components/ui/primitives/tooltip'
|
||||||
import { MaterialPicker } from './../../../components/ui/controls/material-picker'
|
import { MaterialPicker } from './../../../components/ui/controls/material-picker'
|
||||||
import { useReducedMotion } from './../../../hooks/use-reduced-motion'
|
|
||||||
import { resolvePaintTargetFromSelection } from './../../../lib/material-paint'
|
import { resolvePaintTargetFromSelection } from './../../../lib/material-paint'
|
||||||
import { cn } from './../../../lib/utils'
|
import { cn } from './../../../lib/utils'
|
||||||
import useEditor from './../../../store/use-editor'
|
import useEditor from './../../../store/use-editor'
|
||||||
@@ -58,8 +59,23 @@ export function ActionMenu({ className }: { className?: string }) {
|
|||||||
const mode = useEditor((state) => state.mode)
|
const mode = useEditor((state) => state.mode)
|
||||||
const tool = useEditor((state) => state.tool)
|
const tool = useEditor((state) => state.tool)
|
||||||
const catalogCategory = useEditor((state) => state.catalogCategory)
|
const catalogCategory = useEditor((state) => state.catalogCategory)
|
||||||
|
const isMobile = useIsMobile()
|
||||||
|
const hasSelectionOnMobile = useViewer((s) => isMobile && s.selection.selectedIds.length > 0)
|
||||||
|
const hasReferenceOnMobile = useEditor((s) => isMobile && Boolean(s.selectedReferenceId))
|
||||||
|
const CONTEXTUAL_TABS = new Set(['ai', 'items', 'studio'])
|
||||||
|
const isContextualPanelOnMobile = useEditor(
|
||||||
|
(s) => isMobile && CONTEXTUAL_TABS.has(s.activeSidebarPanel),
|
||||||
|
)
|
||||||
const reducedMotion = useReducedMotion()
|
const reducedMotion = useReducedMotion()
|
||||||
const showPaintTray = useMemo(() => mode === 'material-paint', [mode])
|
const showPaintTray = useMemo(() => mode === 'material-paint', [mode])
|
||||||
|
|
||||||
|
// On mobile, defer the bottom rail to the selection bar when something
|
||||||
|
// is selected — the contextual actions take priority over mode controls.
|
||||||
|
// Also hide on Chat / Items / Studio tabs; those are contextual workflows
|
||||||
|
// (composing / picking furniture / generating renders) where the build
|
||||||
|
// menu is irrelevant.
|
||||||
|
if (hasSelectionOnMobile || hasReferenceOnMobile || isContextualPanelOnMobile) return null
|
||||||
|
|
||||||
const transition = reducedMotion
|
const transition = reducedMotion
|
||||||
? { duration: 0 }
|
? { duration: 0 }
|
||||||
: { type: 'spring' as const, bounce: 0.2, duration: 0.4 }
|
: { type: 'spring' as const, bounce: 0.2, duration: 0.4 }
|
||||||
|
|||||||
@@ -8,17 +8,24 @@ import {
|
|||||||
useScene,
|
useScene,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { ChevronDown, Plus, Trash2 } from 'lucide-react'
|
import { Check, ChevronDown, 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 { cn } from '../../../lib/utils'
|
import { cn } from '../../../lib/utils'
|
||||||
|
import useEditor, { type GridSnapStep } from '../../../store/use-editor'
|
||||||
import { useUploadStore } from '../../../store/use-upload'
|
import { useUploadStore } from '../../../store/use-upload'
|
||||||
import { SliderControl } from '../controls/slider-control'
|
import { SliderControl } from '../controls/slider-control'
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '../primitives/popover'
|
import { Popover, PopoverContent, PopoverTrigger } from '../primitives/popover'
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from '../primitives/tooltip'
|
||||||
import { ActionButton } from './action-button'
|
import { ActionButton } from './action-button'
|
||||||
|
|
||||||
const MAX_FILE_SIZE = 200 * 1024 * 1024 // 200MB
|
const MAX_FILE_SIZE = 200 * 1024 * 1024 // 200MB
|
||||||
const ACCEPTED_FILE_TYPES = '.glb,.gltf,image/jpeg,image/png,image/webp,image/gif'
|
const ACCEPTED_FILE_TYPES = '.glb,.gltf,image/jpeg,image/png,image/webp,image/gif'
|
||||||
|
const GRID_SNAP_STEPS: GridSnapStep[] = [0.5, 0.25, 0.1, 0.05]
|
||||||
|
|
||||||
|
function formatGridSnapStep(step: GridSnapStep) {
|
||||||
|
return step.toFixed(2)
|
||||||
|
}
|
||||||
|
|
||||||
// ── Helper: get guide images for the current level ──────────────────────────
|
// ── Helper: get guide images for the current level ──────────────────────────
|
||||||
|
|
||||||
@@ -246,6 +253,83 @@ function GuidesControl() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Grid snap ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function GridSnapControl() {
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
const gridSnapStep = useEditor((state) => state.gridSnapStep)
|
||||||
|
const setGridSnapStep = useEditor((state) => state.setGridSnapStep)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover onOpenChange={setIsOpen} open={isOpen}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<button
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
aria-label={`Grid snap: ${formatGridSnapStep(gridSnapStep)}`}
|
||||||
|
className={cn(
|
||||||
|
'flex h-11 w-11 flex-col items-center justify-center rounded-lg text-muted-foreground transition-all hover:bg-white/5 hover:text-foreground',
|
||||||
|
isOpen && 'bg-white/10 text-foreground',
|
||||||
|
)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M3 3h7v7H3V3zm11 0h7v7h-7V3zm0 11h7v7h-7v-7zm-11 0h7v7H3v-7z"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span className="mt-1 font-medium text-[9px] leading-none">
|
||||||
|
{formatGridSnapStep(gridSnapStep)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top">Grid snap: {formatGridSnapStep(gridSnapStep)}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<PopoverContent
|
||||||
|
align="center"
|
||||||
|
className="w-36 rounded-xl border-border/45 bg-background/96 p-2 shadow-elevation-3 backdrop-blur-xl"
|
||||||
|
side="top"
|
||||||
|
sideOffset={14}
|
||||||
|
>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{GRID_SNAP_STEPS.map((step) => {
|
||||||
|
const isActive = step === gridSnapStep
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
'flex w-full items-center justify-between rounded-lg px-2.5 py-2 text-left text-sm transition-colors hover:bg-white/8',
|
||||||
|
isActive && 'bg-white/10 text-foreground',
|
||||||
|
)}
|
||||||
|
key={step}
|
||||||
|
onClick={() => {
|
||||||
|
setGridSnapStep(step)
|
||||||
|
setIsOpen(false)
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span>{formatGridSnapStep(step)}</span>
|
||||||
|
{isActive ? <Check className="h-3.5 w-3.5" /> : <span className="h-3.5 w-3.5" />}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ── Scans toggle + dropdown ─────────────────────────────────────────────────
|
// ── Scans toggle + dropdown ─────────────────────────────────────────────────
|
||||||
|
|
||||||
function ScansControl() {
|
function ScansControl() {
|
||||||
@@ -395,3 +479,14 @@ export function ViewToggles() {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Secondary toggles for mobile (grid snap + scans + guides)
|
||||||
|
export function SecondaryToggles() {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<GridSnapControl />
|
||||||
|
<ScansControl />
|
||||||
|
<GuidesControl />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,6 +21,17 @@ function stepPrecision(s: number): number {
|
|||||||
return Math.max(0, Math.ceil(-Math.log10(s)))
|
return Math.max(0, Math.ceil(-Math.log10(s)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getStepMultiplier(modifiers: {
|
||||||
|
shiftKey?: boolean
|
||||||
|
metaKey?: boolean
|
||||||
|
ctrlKey?: boolean
|
||||||
|
altKey?: boolean
|
||||||
|
}): number {
|
||||||
|
if (modifiers.shiftKey) return 10
|
||||||
|
if (modifiers.metaKey || modifiers.ctrlKey || modifiers.altKey) return 0.1
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
function getAdjustedStep(
|
function getAdjustedStep(
|
||||||
baseStep: number,
|
baseStep: number,
|
||||||
modifiers: {
|
modifiers: {
|
||||||
@@ -30,9 +41,7 @@ function getAdjustedStep(
|
|||||||
altKey?: boolean
|
altKey?: boolean
|
||||||
},
|
},
|
||||||
): number {
|
): number {
|
||||||
if (modifiers.shiftKey) return baseStep * 10
|
return baseStep * getStepMultiplier(modifiers)
|
||||||
if (modifiers.metaKey || modifiers.ctrlKey || modifiers.altKey) return baseStep * 0.1
|
|
||||||
return baseStep
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SliderControl({
|
export function SliderControl({
|
||||||
@@ -51,7 +60,17 @@ export function SliderControl({
|
|||||||
const [isHovered, setIsHovered] = useState(false)
|
const [isHovered, setIsHovered] = useState(false)
|
||||||
const [inputValue, setInputValue] = useState(value.toFixed(precision))
|
const [inputValue, setInputValue] = useState(value.toFixed(precision))
|
||||||
|
|
||||||
const dragRef = useRef<{ startX: number; startValue: number } | null>(null)
|
const dragRef = useRef<{
|
||||||
|
// Original value at drag start — preserved across modifier re-anchors so
|
||||||
|
// undo/redo rolls back to the pre-drag state, not to a mid-drag anchor.
|
||||||
|
originValue: number
|
||||||
|
// Anchor pointer position and value — updated whenever modifier keys
|
||||||
|
// change so the delta calculation continues smoothly from the current
|
||||||
|
// position at the new step size.
|
||||||
|
anchorX: number
|
||||||
|
anchorValue: number
|
||||||
|
stepMultiplier: number
|
||||||
|
} | null>(null)
|
||||||
const labelRef = useRef<HTMLDivElement>(null)
|
const labelRef = useRef<HTMLDivElement>(null)
|
||||||
const valueRef = useRef(value)
|
const valueRef = useRef(value)
|
||||||
valueRef.current = value
|
valueRef.current = value
|
||||||
@@ -105,7 +124,12 @@ export function SliderControl({
|
|||||||
if (isEditing) return
|
if (isEditing) return
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.currentTarget.setPointerCapture(e.pointerId)
|
e.currentTarget.setPointerCapture(e.pointerId)
|
||||||
dragRef.current = { startX: e.clientX, startValue: valueRef.current }
|
dragRef.current = {
|
||||||
|
originValue: valueRef.current,
|
||||||
|
anchorX: e.clientX,
|
||||||
|
anchorValue: valueRef.current,
|
||||||
|
stepMultiplier: getStepMultiplier(e),
|
||||||
|
}
|
||||||
setIsDragging(true)
|
setIsDragging(true)
|
||||||
useScene.temporal.getState().pause()
|
useScene.temporal.getState().pause()
|
||||||
},
|
},
|
||||||
@@ -115,12 +139,23 @@ export function SliderControl({
|
|||||||
const handleLabelPointerMove = useCallback(
|
const handleLabelPointerMove = useCallback(
|
||||||
(e: React.PointerEvent<HTMLDivElement>) => {
|
(e: React.PointerEvent<HTMLDivElement>) => {
|
||||||
if (!dragRef.current) return
|
if (!dragRef.current) return
|
||||||
const { startX, startValue } = dragRef.current
|
const multiplier = getStepMultiplier(e)
|
||||||
const dx = e.clientX - startX
|
// If modifier keys changed mid-drag, re-anchor from the current pointer
|
||||||
const s = getAdjustedStep(step, e)
|
// position and value — otherwise the accumulated dx would be applied
|
||||||
|
// with a new step size and jump the value (e.g. pressing Cmd while
|
||||||
|
// already far from the starting point would snap back toward it).
|
||||||
|
if (multiplier !== dragRef.current.stepMultiplier) {
|
||||||
|
dragRef.current.anchorX = e.clientX
|
||||||
|
dragRef.current.anchorValue = valueRef.current
|
||||||
|
dragRef.current.stepMultiplier = multiplier
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const { anchorX, anchorValue } = dragRef.current
|
||||||
|
const dx = e.clientX - anchorX
|
||||||
|
const s = step * multiplier
|
||||||
// 4 px per step at default sensitivity
|
// 4 px per step at default sensitivity
|
||||||
const newValue = clamp(
|
const newValue = clamp(
|
||||||
Number.parseFloat((startValue + (dx / 4) * s).toFixed(stepPrecision(s))),
|
Number.parseFloat((anchorValue + (dx / 4) * s).toFixed(stepPrecision(s))),
|
||||||
)
|
)
|
||||||
onChange(newValue)
|
onChange(newValue)
|
||||||
},
|
},
|
||||||
@@ -130,14 +165,14 @@ export function SliderControl({
|
|||||||
const handleLabelPointerUp = useCallback(
|
const handleLabelPointerUp = useCallback(
|
||||||
(e: React.PointerEvent<HTMLDivElement>) => {
|
(e: React.PointerEvent<HTMLDivElement>) => {
|
||||||
if (!dragRef.current) return
|
if (!dragRef.current) return
|
||||||
const { startValue } = dragRef.current
|
const { originValue } = dragRef.current
|
||||||
const finalVal = valueRef.current
|
const finalVal = valueRef.current
|
||||||
dragRef.current = null
|
dragRef.current = null
|
||||||
setIsDragging(false)
|
setIsDragging(false)
|
||||||
e.currentTarget.releasePointerCapture(e.pointerId)
|
e.currentTarget.releasePointerCapture(e.pointerId)
|
||||||
|
|
||||||
if (startValue !== finalVal) {
|
if (originValue !== finalVal) {
|
||||||
onChange(startValue)
|
onChange(originValue)
|
||||||
useScene.temporal.getState().resume()
|
useScene.temporal.getState().resume()
|
||||||
onChange(finalVal)
|
onChange(finalVal)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { X } from 'lucide-react'
|
||||||
|
import { AnimatePresence, motion } from 'motion/react'
|
||||||
|
import Image from 'next/image'
|
||||||
|
import { type ReactNode, useEffect, useState } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
|
import useEditor from '../../../store/use-editor'
|
||||||
|
|
||||||
|
interface MobilePanelSheetProps {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
icon?: string
|
||||||
|
title: string
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
const HEIGHT_VH = 50
|
||||||
|
const DRAG_CLOSE_THRESHOLD_PX = 120
|
||||||
|
|
||||||
|
export function MobilePanelSheet({ open, onClose, icon, title, children }: MobilePanelSheetProps) {
|
||||||
|
const [mounted, setMounted] = useState(false)
|
||||||
|
const setMobilePanelSheetHeight = useEditor((s) => s.setMobilePanelSheetHeight)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Publish the sheet's pixel height to the shared store so the mobile layout
|
||||||
|
// can shrink the viewer container and preview edits live. 0 means closed.
|
||||||
|
// Tracks visualViewport so the value follows the on-screen keyboard on iOS.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setMobilePanelSheetHeight(0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const compute = () => {
|
||||||
|
const vh = window.visualViewport?.height ?? window.innerHeight
|
||||||
|
setMobilePanelSheetHeight(Math.round((vh * HEIGHT_VH) / 100))
|
||||||
|
}
|
||||||
|
compute()
|
||||||
|
const vv = window.visualViewport
|
||||||
|
vv?.addEventListener('resize', compute)
|
||||||
|
window.addEventListener('resize', compute)
|
||||||
|
return () => {
|
||||||
|
vv?.removeEventListener('resize', compute)
|
||||||
|
window.removeEventListener('resize', compute)
|
||||||
|
setMobilePanelSheetHeight(0)
|
||||||
|
}
|
||||||
|
}, [open, setMobilePanelSheetHeight])
|
||||||
|
|
||||||
|
if (!mounted) return null
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<AnimatePresence>
|
||||||
|
{open && (
|
||||||
|
<motion.div
|
||||||
|
animate={{ y: 0 }}
|
||||||
|
className="dark fixed right-0 bottom-0 left-0 z-[60] flex flex-col overflow-hidden rounded-t-2xl bg-sidebar text-sidebar-foreground shadow-[0_-8px_24px_rgba(0,0,0,0.24)]"
|
||||||
|
drag="y"
|
||||||
|
dragConstraints={{ top: 0, bottom: 0 }}
|
||||||
|
dragElastic={{ top: 0, bottom: 0.4 }}
|
||||||
|
exit={{ y: '100%' }}
|
||||||
|
initial={{ y: '100%' }}
|
||||||
|
onDragEnd={(_, info) => {
|
||||||
|
if (info.offset.y > DRAG_CLOSE_THRESHOLD_PX) onClose()
|
||||||
|
}}
|
||||||
|
style={{ height: `${HEIGHT_VH}dvh` }}
|
||||||
|
transition={{ type: 'spring', stiffness: 320, damping: 32, mass: 0.8 }}
|
||||||
|
>
|
||||||
|
<div className="flex h-6 shrink-0 cursor-grab touch-none items-center justify-center active:cursor-grabbing">
|
||||||
|
<div className="h-1 w-10 rounded-full bg-muted-foreground/40" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex shrink-0 items-center justify-between border-border/50 border-b px-3 pt-1 pb-3">
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
{icon && (
|
||||||
|
<Image
|
||||||
|
alt=""
|
||||||
|
className="shrink-0 object-contain"
|
||||||
|
height={18}
|
||||||
|
src={icon}
|
||||||
|
width={18}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<h2 className="truncate font-semibold text-foreground text-sm tracking-tight">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
aria-label="Close"
|
||||||
|
className="flex h-8 w-8 items-center justify-center rounded-md bg-[#2C2C2E] text-muted-foreground transition-colors hover:bg-[#3e3e3e] hover:text-foreground"
|
||||||
|
onClick={onClose}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="no-scrollbar flex min-h-0 flex-1 flex-col overflow-y-auto">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>,
|
||||||
|
document.body,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import type { AnyNode } from '@pascal-app/core'
|
||||||
|
import { Copy, Move, SlidersHorizontal, Trash2 } from 'lucide-react'
|
||||||
|
import Image from 'next/image'
|
||||||
|
import type { MouseEventHandler } from 'react'
|
||||||
|
import { cn } from '../../../lib/utils'
|
||||||
|
import { getNodeDisplay } from './node-display'
|
||||||
|
|
||||||
|
interface MobileSelectionBarProps {
|
||||||
|
node: AnyNode
|
||||||
|
onMove: () => void
|
||||||
|
onDuplicate: () => void
|
||||||
|
onDelete: () => void
|
||||||
|
onEdit: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACTION_BTN =
|
||||||
|
'flex h-9 w-9 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-white/8 hover:text-foreground'
|
||||||
|
|
||||||
|
export function MobileSelectionBar({
|
||||||
|
node,
|
||||||
|
onMove,
|
||||||
|
onDuplicate,
|
||||||
|
onDelete,
|
||||||
|
onEdit,
|
||||||
|
}: MobileSelectionBarProps) {
|
||||||
|
const { icon, label } = getNodeDisplay(node)
|
||||||
|
|
||||||
|
const stop: MouseEventHandler<HTMLButtonElement> = (e) => e.stopPropagation()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pointer-events-auto absolute right-3 bottom-6 left-3 z-50 flex h-12 items-stretch gap-1 rounded-2xl border border-border/50 bg-background/95 px-2 shadow-2xl backdrop-blur-xl">
|
||||||
|
<button
|
||||||
|
aria-label={`Edit ${label}`}
|
||||||
|
className={cn(
|
||||||
|
'flex min-w-0 flex-1 items-center gap-2 rounded-lg px-2 text-left transition-colors hover:bg-white/8',
|
||||||
|
)}
|
||||||
|
onClick={onEdit}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
alt=""
|
||||||
|
className="shrink-0 rounded object-contain"
|
||||||
|
height={20}
|
||||||
|
src={icon}
|
||||||
|
width={20}
|
||||||
|
/>
|
||||||
|
<span className="truncate font-medium text-foreground text-sm">{label}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-0.5 border-border/40 border-l pl-1">
|
||||||
|
<button
|
||||||
|
aria-label="Move"
|
||||||
|
className={ACTION_BTN}
|
||||||
|
onClick={(e) => {
|
||||||
|
stop(e)
|
||||||
|
onMove()
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Move className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
aria-label="Duplicate"
|
||||||
|
className={ACTION_BTN}
|
||||||
|
onClick={(e) => {
|
||||||
|
stop(e)
|
||||||
|
onDuplicate()
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
aria-label="Delete"
|
||||||
|
className={cn(ACTION_BTN, 'hover:bg-red-500/15 hover:text-red-400')}
|
||||||
|
onClick={(e) => {
|
||||||
|
stop(e)
|
||||||
|
onDelete()
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
aria-label="Edit properties"
|
||||||
|
className={ACTION_BTN}
|
||||||
|
onClick={(e) => {
|
||||||
|
stop(e)
|
||||||
|
onEdit()
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<SlidersHorizontal className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import type { AnyNode } from '@pascal-app/core'
|
||||||
|
|
||||||
|
export type NodeDisplay = {
|
||||||
|
icon: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const TYPE_DEFAULTS: Record<string, NodeDisplay> = {
|
||||||
|
item: { icon: '/icons/furniture.png', label: 'Item' },
|
||||||
|
wall: { icon: '/icons/wall.png', label: 'Wall' },
|
||||||
|
door: { icon: '/icons/door.png', label: 'Door' },
|
||||||
|
window: { icon: '/icons/window.png', label: 'Window' },
|
||||||
|
slab: { icon: '/icons/floor.png', label: 'Slab' },
|
||||||
|
ceiling: { icon: '/icons/ceiling.png', label: 'Ceiling' },
|
||||||
|
fence: { icon: '/icons/fence.png', label: 'Fence' },
|
||||||
|
roof: { icon: '/icons/roof.png', label: 'Roof' },
|
||||||
|
'roof-segment': { icon: '/icons/roof.png', label: 'Roof segment' },
|
||||||
|
stair: { icon: '/icons/stair.png', label: 'Stair' },
|
||||||
|
'stair-segment': { icon: '/icons/stair.png', label: 'Stair segment' },
|
||||||
|
scan: { icon: '/icons/mesh.png', label: '3D Scan' },
|
||||||
|
guide: { icon: '/icons/floorplan.png', label: 'Guide image' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNodeDisplay(node: AnyNode | null | undefined): NodeDisplay {
|
||||||
|
if (!node) return { icon: '/icons/select.png', label: 'Selection' }
|
||||||
|
const fallback = TYPE_DEFAULTS[node.type] ?? { icon: '/icons/select.png', label: node.type }
|
||||||
|
// Item nodes carry an asset with its own thumbnail/name
|
||||||
|
if (node.type === 'item') {
|
||||||
|
return {
|
||||||
|
icon: node.asset?.thumbnail || fallback.icon,
|
||||||
|
label: node.name || node.asset?.name || fallback.label,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
icon: fallback.icon,
|
||||||
|
label: node.name || fallback.label,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,34 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { type AnyNodeId, useScene } from '@pascal-app/core'
|
import {
|
||||||
|
type AnyNode,
|
||||||
|
type AnyNodeId,
|
||||||
|
type BuildingNode,
|
||||||
|
type CeilingNode,
|
||||||
|
type DoorNode,
|
||||||
|
type FenceNode,
|
||||||
|
type ItemNode,
|
||||||
|
type RoofNode,
|
||||||
|
type RoofSegmentNode,
|
||||||
|
type SlabNode,
|
||||||
|
type StairNode,
|
||||||
|
type StairSegmentNode,
|
||||||
|
useScene,
|
||||||
|
type WallNode,
|
||||||
|
type WindowNode,
|
||||||
|
} from '@pascal-app/core'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { useIsMobile } from '../../../hooks/use-mobile'
|
||||||
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||||
import useEditor from '../../../store/use-editor'
|
import useEditor from '../../../store/use-editor'
|
||||||
import { CeilingPanel } from './ceiling-panel'
|
import { CeilingPanel } from './ceiling-panel'
|
||||||
import { DoorPanel } from './door-panel'
|
import { DoorPanel } from './door-panel'
|
||||||
import { FencePanel } from './fence-panel'
|
import { FencePanel } from './fence-panel'
|
||||||
import { ItemPanel } from './item-panel'
|
import { ItemPanel } from './item-panel'
|
||||||
|
import { MobilePanelSheet } from './mobile-panel-sheet'
|
||||||
|
import { MobileSelectionBar } from './mobile-selection-bar'
|
||||||
|
import { getNodeDisplay } from './node-display'
|
||||||
import { PaintPanel } from './paint-panel'
|
import { PaintPanel } from './paint-panel'
|
||||||
import { ReferencePanel } from './reference-panel'
|
import { ReferencePanel } from './reference-panel'
|
||||||
import { RoofPanel } from './roof-panel'
|
import { RoofPanel } from './roof-panel'
|
||||||
@@ -17,7 +39,152 @@ import { StairSegmentPanel } from './stair-segment-panel'
|
|||||||
import { WallPanel } from './wall-panel'
|
import { WallPanel } from './wall-panel'
|
||||||
import { WindowPanel } from './window-panel'
|
import { WindowPanel } from './window-panel'
|
||||||
|
|
||||||
|
type MovableNode =
|
||||||
|
| ItemNode
|
||||||
|
| WindowNode
|
||||||
|
| DoorNode
|
||||||
|
| CeilingNode
|
||||||
|
| SlabNode
|
||||||
|
| WallNode
|
||||||
|
| FenceNode
|
||||||
|
| RoofNode
|
||||||
|
| RoofSegmentNode
|
||||||
|
| StairNode
|
||||||
|
| StairSegmentNode
|
||||||
|
| BuildingNode
|
||||||
|
|
||||||
|
const MOVABLE_TYPES = new Set<string>([
|
||||||
|
'item',
|
||||||
|
'window',
|
||||||
|
'door',
|
||||||
|
'ceiling',
|
||||||
|
'slab',
|
||||||
|
'wall',
|
||||||
|
'fence',
|
||||||
|
'roof',
|
||||||
|
'roof-segment',
|
||||||
|
'stair',
|
||||||
|
'stair-segment',
|
||||||
|
'building',
|
||||||
|
])
|
||||||
|
|
||||||
|
function isMovableNode(node: AnyNode | null): node is MovableNode {
|
||||||
|
return !!node && MOVABLE_TYPES.has(node.type)
|
||||||
|
}
|
||||||
|
|
||||||
|
function panelForType(type: string | null) {
|
||||||
|
if (!type) return null
|
||||||
|
switch (type) {
|
||||||
|
case 'item':
|
||||||
|
return <ItemPanel />
|
||||||
|
case 'roof':
|
||||||
|
return <RoofPanel />
|
||||||
|
case 'roof-segment':
|
||||||
|
return <RoofSegmentPanel />
|
||||||
|
case 'stair':
|
||||||
|
return <StairPanel />
|
||||||
|
case 'stair-segment':
|
||||||
|
return <StairSegmentPanel />
|
||||||
|
case 'slab':
|
||||||
|
return <SlabPanel />
|
||||||
|
case 'ceiling':
|
||||||
|
return <CeilingPanel />
|
||||||
|
case 'wall':
|
||||||
|
return <WallPanel />
|
||||||
|
case 'fence':
|
||||||
|
return <FencePanel />
|
||||||
|
case 'door':
|
||||||
|
return <DoorPanel />
|
||||||
|
case 'window':
|
||||||
|
return <WindowPanel />
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function MobilePanelLayer({
|
||||||
|
node,
|
||||||
|
panel,
|
||||||
|
isReference,
|
||||||
|
}: {
|
||||||
|
node: AnyNode | null
|
||||||
|
panel: React.ReactNode
|
||||||
|
isReference: boolean
|
||||||
|
}) {
|
||||||
|
const setSelection = useViewer((s) => s.setSelection)
|
||||||
|
const setSelectedReferenceId = useEditor((s) => s.setSelectedReferenceId)
|
||||||
|
const setMovingNode = useEditor((s) => s.setMovingNode)
|
||||||
|
const deleteNode = useScene((s) => s.deleteNode)
|
||||||
|
const [isSheetOpen, setIsSheetOpen] = useState(false)
|
||||||
|
|
||||||
|
// Reset sheet open state when the selection changes / clears
|
||||||
|
const selectionKey = node?.id ?? (isReference ? 'reference' : null)
|
||||||
|
useEffect(() => {
|
||||||
|
setIsSheetOpen(false)
|
||||||
|
}, [selectionKey])
|
||||||
|
|
||||||
|
const clearSelection = useCallback(() => {
|
||||||
|
setSelection({ selectedIds: [] })
|
||||||
|
setSelectedReferenceId(null)
|
||||||
|
}, [setSelection, setSelectedReferenceId])
|
||||||
|
|
||||||
|
const handleMove = useCallback(() => {
|
||||||
|
if (!isMovableNode(node)) return
|
||||||
|
sfxEmitter.emit('sfx:item-pick')
|
||||||
|
setMovingNode(node)
|
||||||
|
clearSelection()
|
||||||
|
}, [node, setMovingNode, clearSelection])
|
||||||
|
|
||||||
|
const handleDuplicate = useCallback(() => {
|
||||||
|
if (!isMovableNode(node)) return
|
||||||
|
sfxEmitter.emit('sfx:item-pick')
|
||||||
|
const cloned = structuredClone(node) as MovableNode & { id?: AnyNodeId }
|
||||||
|
delete (cloned as { id?: AnyNodeId }).id
|
||||||
|
const prevMeta =
|
||||||
|
cloned.metadata && typeof cloned.metadata === 'object' && !Array.isArray(cloned.metadata)
|
||||||
|
? (cloned.metadata as Record<string, unknown>)
|
||||||
|
: {}
|
||||||
|
cloned.metadata = { ...prevMeta, isNew: true }
|
||||||
|
setMovingNode(cloned as MovableNode)
|
||||||
|
clearSelection()
|
||||||
|
}, [node, setMovingNode, clearSelection])
|
||||||
|
|
||||||
|
const handleDelete = useCallback(() => {
|
||||||
|
if (!node) return
|
||||||
|
sfxEmitter.emit('sfx:item-delete')
|
||||||
|
deleteNode(node.id)
|
||||||
|
clearSelection()
|
||||||
|
}, [node, deleteNode, clearSelection])
|
||||||
|
|
||||||
|
if (!(node || isReference)) return null
|
||||||
|
|
||||||
|
const display = getNodeDisplay(node)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{node && (
|
||||||
|
<MobileSelectionBar
|
||||||
|
node={node}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
onDuplicate={handleDuplicate}
|
||||||
|
onEdit={() => setIsSheetOpen((v) => !v)}
|
||||||
|
onMove={handleMove}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<MobilePanelSheet
|
||||||
|
icon={display.icon}
|
||||||
|
onClose={() => setIsSheetOpen(false)}
|
||||||
|
open={isSheetOpen}
|
||||||
|
title={display.label}
|
||||||
|
>
|
||||||
|
{panel}
|
||||||
|
</MobilePanelSheet>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function PanelManager() {
|
export function PanelManager() {
|
||||||
|
const isMobile = useIsMobile()
|
||||||
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
||||||
const selectedReferenceId = useEditor((s) => s.selectedReferenceId)
|
const selectedReferenceId = useEditor((s) => s.selectedReferenceId)
|
||||||
const isPaintPanelOpen = useEditor((s) => s.isPaintPanelOpen)
|
const isPaintPanelOpen = useEditor((s) => s.isPaintPanelOpen)
|
||||||
@@ -30,6 +197,24 @@ export function PanelManager() {
|
|||||||
const id = selectedIds[0]
|
const id = selectedIds[0]
|
||||||
return id ? (s.nodes[id as AnyNodeId]?.type ?? null) : null
|
return id ? (s.nodes[id as AnyNodeId]?.type ?? null) : null
|
||||||
})
|
})
|
||||||
|
const selectedNode = useScene((s) => {
|
||||||
|
if (selectedIds.length !== 1) return null
|
||||||
|
const id = selectedIds[0]
|
||||||
|
return id ? (s.nodes[id as AnyNodeId] ?? null) : null
|
||||||
|
})
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
if (selectedReferenceId) {
|
||||||
|
return <MobilePanelLayer isReference={true} node={null} panel={<ReferencePanel />} />
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<MobilePanelLayer
|
||||||
|
isReference={false}
|
||||||
|
node={selectedNode}
|
||||||
|
panel={panelForType(selectedNodeType)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Show reference panel if a reference is selected
|
// Show reference panel if a reference is selected
|
||||||
if (selectedReferenceId) {
|
if (selectedReferenceId) {
|
||||||
@@ -46,32 +231,5 @@ export function PanelManager() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Show appropriate panel based on selected node type
|
// Show appropriate panel based on selected node type
|
||||||
if (selectedNodeType) {
|
return panelForType(selectedNodeType)
|
||||||
switch (selectedNodeType) {
|
|
||||||
case 'item':
|
|
||||||
return <ItemPanel />
|
|
||||||
case 'roof':
|
|
||||||
return <RoofPanel />
|
|
||||||
case 'roof-segment':
|
|
||||||
return <RoofSegmentPanel />
|
|
||||||
case 'stair':
|
|
||||||
return <StairPanel />
|
|
||||||
case 'stair-segment':
|
|
||||||
return <StairSegmentPanel />
|
|
||||||
case 'slab':
|
|
||||||
return <SlabPanel />
|
|
||||||
case 'ceiling':
|
|
||||||
return <CeilingPanel />
|
|
||||||
case 'wall':
|
|
||||||
return <WallPanel />
|
|
||||||
case 'fence':
|
|
||||||
return <FencePanel />
|
|
||||||
case 'door':
|
|
||||||
return <DoorPanel />
|
|
||||||
case 'window':
|
|
||||||
return <WindowPanel />
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { ChevronLeft, RotateCcw, X } from 'lucide-react'
|
import { ChevronLeft, RotateCcw, X } from 'lucide-react'
|
||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
|
import { useIsMobile } from '../../../hooks/use-mobile'
|
||||||
import { cn } from '../../../lib/utils'
|
import { cn } from '../../../lib/utils'
|
||||||
|
|
||||||
interface PanelWrapperProps {
|
interface PanelWrapperProps {
|
||||||
@@ -25,53 +26,61 @@ export function PanelWrapper({
|
|||||||
className,
|
className,
|
||||||
width = 320, // default width
|
width = 320, // default width
|
||||||
}: PanelWrapperProps) {
|
}: PanelWrapperProps) {
|
||||||
|
const isMobile = useIsMobile()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'pointer-events-auto fixed top-20 right-4 z-50 flex max-h-[calc(100dvh-100px)] flex-col overflow-hidden rounded-xl border border-border/50 bg-sidebar/95 shadow-2xl backdrop-blur-xl dark:text-foreground',
|
isMobile
|
||||||
|
? 'flex h-full w-full flex-col overflow-hidden bg-transparent dark:text-foreground'
|
||||||
|
: 'pointer-events-auto fixed top-20 right-4 z-50 flex max-h-[calc(100dvh-100px)] flex-col overflow-hidden rounded-xl border border-border/50 bg-sidebar/95 shadow-2xl backdrop-blur-xl dark:text-foreground',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
style={{ width }}
|
style={isMobile ? undefined : { width }}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header — desktop only; mobile sheet provides its own header */}
|
||||||
<div className="flex items-center justify-between border-border/50 border-b px-3 py-3">
|
{!isMobile && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center justify-between border-border/50 border-b px-3 py-3">
|
||||||
{onBack && (
|
<div className="flex items-center gap-2">
|
||||||
<button
|
{onBack && (
|
||||||
className="mr-1 flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-[#3e3e3e] hover:text-foreground"
|
<button
|
||||||
onClick={onBack}
|
className="mr-1 flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-[#3e3e3e] hover:text-foreground"
|
||||||
type="button"
|
onClick={onBack}
|
||||||
>
|
type="button"
|
||||||
<ChevronLeft className="h-4 w-4" />
|
>
|
||||||
</button>
|
<ChevronLeft className="h-4 w-4" />
|
||||||
)}
|
</button>
|
||||||
{icon && (
|
)}
|
||||||
<Image alt="" className="shrink-0 object-contain" height={16} src={icon} width={16} />
|
{icon && (
|
||||||
)}
|
<Image alt="" className="shrink-0 object-contain" height={16} src={icon} width={16} />
|
||||||
<h2 className="truncate font-semibold text-foreground text-sm tracking-tight">{title}</h2>
|
)}
|
||||||
</div>
|
<h2 className="truncate font-semibold text-foreground text-sm tracking-tight">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{onReset && (
|
{onReset && (
|
||||||
<button
|
<button
|
||||||
className="flex h-7 w-7 items-center justify-center rounded-md bg-[#2C2C2E] text-muted-foreground transition-colors hover:bg-[#3e3e3e] hover:text-foreground"
|
className="flex h-7 w-7 items-center justify-center rounded-md bg-[#2C2C2E] text-muted-foreground transition-colors hover:bg-[#3e3e3e] hover:text-foreground"
|
||||||
onClick={onReset}
|
onClick={onReset}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<RotateCcw className="h-4 w-4" />
|
<RotateCcw className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{onClose && (
|
{onClose && (
|
||||||
<button
|
<button
|
||||||
className="flex h-7 w-7 items-center justify-center rounded-md bg-[#2C2C2E] text-muted-foreground transition-colors hover:bg-[#3e3e3e] hover:text-foreground"
|
className="flex h-7 w-7 items-center justify-center rounded-md bg-[#2C2C2E] text-muted-foreground transition-colors hover:bg-[#3e3e3e] hover:text-foreground"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="no-scrollbar flex min-h-0 flex-1 flex-col overflow-y-auto">{children}</div>
|
<div className="no-scrollbar flex min-h-0 flex-1 flex-col overflow-y-auto">{children}</div>
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { cn } from './../../../lib/utils'
|
||||||
|
import type { SidebarTab } from './tab-bar'
|
||||||
|
|
||||||
|
interface MobileTabBarProps {
|
||||||
|
tabs: SidebarTab[]
|
||||||
|
activeTab: string
|
||||||
|
onTabPress: (id: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MobileTabBar({ tabs, activeTab, onTabPress }: MobileTabBarProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="z-50 flex h-14 shrink-0 border-border/50 border-t bg-sidebar text-sidebar-foreground"
|
||||||
|
style={{
|
||||||
|
// Cap the safe-area inset — iOS Chrome can report its bottom UI bar
|
||||||
|
// (50–100px) as part of the safe area which would balloon the tab bar.
|
||||||
|
// 34px matches the iPhone home-indicator height (the typical max).
|
||||||
|
paddingBottom: 'min(env(safe-area-inset-bottom, 0px), 34px)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tabs.map((tab) => {
|
||||||
|
const isActive = activeTab === tab.id
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
'flex flex-1 flex-col items-center justify-center gap-0.5 text-xs transition-colors',
|
||||||
|
isActive ? 'text-foreground' : 'text-muted-foreground',
|
||||||
|
)}
|
||||||
|
key={tab.id}
|
||||||
|
onClick={() => onTabPress(tab.id)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{tab.mobileIcon ? (
|
||||||
|
<span className={cn('flex h-5 w-5 items-center justify-center')}>
|
||||||
|
{tab.mobileIcon}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<span className="font-medium">{tab.label}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
import { cn } from './../../../lib/utils'
|
import { cn } from './../../../lib/utils'
|
||||||
|
|
||||||
export type SidebarTab = {
|
export type SidebarTab = {
|
||||||
id: string
|
id: string
|
||||||
label: string
|
label: string
|
||||||
|
mobileDefaultSnap?: number
|
||||||
|
mobileIcon?: ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TabBarProps {
|
interface TabBarProps {
|
||||||
|
|||||||
@@ -2,18 +2,18 @@ import * as React from 'react'
|
|||||||
|
|
||||||
const MOBILE_BREAKPOINT = 768
|
const MOBILE_BREAKPOINT = 768
|
||||||
|
|
||||||
export function useIsMobile() {
|
const subscribe = (callback: () => void): (() => void) => {
|
||||||
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
|
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
|
||||||
|
mql.addEventListener('change', callback)
|
||||||
|
return () => mql.removeEventListener('change', callback)
|
||||||
|
}
|
||||||
|
|
||||||
React.useEffect(() => {
|
const getClientSnapshot = (): boolean => window.innerWidth < MOBILE_BREAKPOINT
|
||||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
|
|
||||||
const onChange = () => {
|
|
||||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
|
||||||
}
|
|
||||||
mql.addEventListener('change', onChange)
|
|
||||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
|
||||||
return () => mql.removeEventListener('change', onChange)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return !!isMobile
|
// Server can't know the viewport — assume desktop. React's useSyncExternalStore
|
||||||
|
// reconciles the SSR / client snapshots without a hydration mismatch warning.
|
||||||
|
const getServerSnapshot = (): boolean => false
|
||||||
|
|
||||||
|
export function useIsMobile(): boolean {
|
||||||
|
return React.useSyncExternalStore(subscribe, getClientSnapshot, getServerSnapshot)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -148,12 +148,17 @@ export function buildFloorplanItemEntry(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const dimensionPolygon = getItemDimensionPolygon(item, transform)
|
const dimensionPolygon = getItemDimensionPolygon(item, transform)
|
||||||
|
const [width, , depth] = getScaledDimensions(item)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
dimensionPolygon,
|
dimensionPolygon,
|
||||||
item,
|
item,
|
||||||
polygon: realMeshPolygon,
|
polygon: realMeshPolygon,
|
||||||
usesRealMesh: realMeshPolygon !== null,
|
usesRealMesh: realMeshPolygon !== null,
|
||||||
|
center: transform.position,
|
||||||
|
rotation: transform.rotation,
|
||||||
|
width,
|
||||||
|
depth,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -389,7 +394,10 @@ function getConvexHull(points: Point[]) {
|
|||||||
|
|
||||||
const lower: Point[] = []
|
const lower: Point[] = []
|
||||||
for (const point of uniquePoints) {
|
for (const point of uniquePoints) {
|
||||||
while (lower.length >= 2 && cross(lower[lower.length - 2]!, lower[lower.length - 1]!, point) <= 0) {
|
while (
|
||||||
|
lower.length >= 2 &&
|
||||||
|
cross(lower[lower.length - 2]!, lower[lower.length - 1]!, point) <= 0
|
||||||
|
) {
|
||||||
lower.pop()
|
lower.pop()
|
||||||
}
|
}
|
||||||
lower.push(point)
|
lower.push(point)
|
||||||
@@ -398,7 +406,10 @@ function getConvexHull(points: Point[]) {
|
|||||||
const upper: Point[] = []
|
const upper: Point[] = []
|
||||||
for (let index = uniquePoints.length - 1; index >= 0; index -= 1) {
|
for (let index = uniquePoints.length - 1; index >= 0; index -= 1) {
|
||||||
const point = uniquePoints[index]!
|
const point = uniquePoints[index]!
|
||||||
while (upper.length >= 2 && cross(upper[upper.length - 2]!, upper[upper.length - 1]!, point) <= 0) {
|
while (
|
||||||
|
upper.length >= 2 &&
|
||||||
|
cross(upper[upper.length - 2]!, upper[upper.length - 1]!, point) <= 0
|
||||||
|
) {
|
||||||
upper.pop()
|
upper.pop()
|
||||||
}
|
}
|
||||||
upper.push(point)
|
upper.push(point)
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ export type FloorplanItemEntry = {
|
|||||||
item: ItemNode
|
item: ItemNode
|
||||||
polygon: Point2D[]
|
polygon: Point2D[]
|
||||||
usesRealMesh: boolean
|
usesRealMesh: boolean
|
||||||
|
center: Point2D
|
||||||
|
rotation: number
|
||||||
|
width: number
|
||||||
|
depth: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FloorplanStairSegmentEntry = {
|
export type FloorplanStairSegmentEntry = {
|
||||||
|
|||||||
@@ -206,6 +206,10 @@ type EditorState = {
|
|||||||
setAllowUndergroundCamera: (enabled: boolean) => void
|
setAllowUndergroundCamera: (enabled: boolean) => void
|
||||||
activeSidebarPanel: string
|
activeSidebarPanel: string
|
||||||
setActiveSidebarPanel: (id: string) => void
|
setActiveSidebarPanel: (id: string) => void
|
||||||
|
mobilePanelSheetHeight: number
|
||||||
|
setMobilePanelSheetHeight: (height: number) => void
|
||||||
|
isCaptureMode: boolean
|
||||||
|
setIsCaptureMode: (enabled: boolean) => void
|
||||||
floorplanPaneRatio: number
|
floorplanPaneRatio: number
|
||||||
setFloorplanPaneRatio: (ratio: number) => void
|
setFloorplanPaneRatio: (ratio: number) => void
|
||||||
}
|
}
|
||||||
@@ -658,6 +662,10 @@ const useEditor = create<EditorState>()(
|
|||||||
},
|
},
|
||||||
activeSidebarPanel: DEFAULT_ACTIVE_SIDEBAR_PANEL,
|
activeSidebarPanel: DEFAULT_ACTIVE_SIDEBAR_PANEL,
|
||||||
setActiveSidebarPanel: (id) => set({ activeSidebarPanel: id }),
|
setActiveSidebarPanel: (id) => set({ activeSidebarPanel: id }),
|
||||||
|
mobilePanelSheetHeight: 0,
|
||||||
|
setMobilePanelSheetHeight: (height) => set({ mobilePanelSheetHeight: height }),
|
||||||
|
isCaptureMode: false,
|
||||||
|
setIsCaptureMode: (enabled) => set({ isCaptureMode: enabled }),
|
||||||
floorplanPaneRatio: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.floorplanPaneRatio,
|
floorplanPaneRatio: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.floorplanPaneRatio,
|
||||||
setFloorplanPaneRatio: (ratio) =>
|
setFloorplanPaneRatio: (ratio) =>
|
||||||
set({ floorplanPaneRatio: normalizeFloorplanPaneRatio(ratio) }),
|
set({ floorplanPaneRatio: normalizeFloorplanPaneRatio(ratio) }),
|
||||||
|
|||||||
@@ -1,15 +1,25 @@
|
|||||||
import type { ErrorInfo, ReactNode } from 'react'
|
import type { ErrorInfo, ReactNode } from 'react'
|
||||||
import { Component } from 'react'
|
import { Component } from 'react'
|
||||||
|
|
||||||
export class ErrorBoundary extends Component<
|
interface ErrorBoundaryProps {
|
||||||
{ children: ReactNode; fallback: ReactNode },
|
children: ReactNode
|
||||||
{ hasError: boolean }
|
fallback: ReactNode
|
||||||
> {
|
/** Tag for log lines so we can tell which boundary swallowed an error. */
|
||||||
|
scope?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ErrorBoundary extends Component<ErrorBoundaryProps, { hasError: boolean }> {
|
||||||
state = { hasError: false }
|
state = { hasError: false }
|
||||||
static getDerivedStateFromError() {
|
static getDerivedStateFromError() {
|
||||||
return { hasError: true }
|
return { hasError: true }
|
||||||
}
|
}
|
||||||
componentDidCatch(_e: Error, _i: ErrorInfo) {}
|
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||||
|
console.error(
|
||||||
|
`[viewer] ErrorBoundary caught${this.props.scope ? ` (${this.props.scope})` : ''}:`,
|
||||||
|
error,
|
||||||
|
info.componentStack,
|
||||||
|
)
|
||||||
|
}
|
||||||
render() {
|
render() {
|
||||||
return this.state.hasError ? this.props.fallback : this.props.children
|
return this.state.hasError ? this.props.fallback : this.props.children
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { LevelSystem } from '../../systems/level/level-system'
|
|||||||
import { ScanSystem } from '../../systems/scan/scan-system'
|
import { ScanSystem } from '../../systems/scan/scan-system'
|
||||||
import { WallCutout } from '../../systems/wall/wall-cutout'
|
import { WallCutout } from '../../systems/wall/wall-cutout'
|
||||||
import { ZoneSystem } from '../../systems/zone/zone-system'
|
import { ZoneSystem } from '../../systems/zone/zone-system'
|
||||||
|
import { ErrorBoundary } from '../error-boundary'
|
||||||
import { SceneRenderer } from '../renderers/scene-renderer'
|
import { SceneRenderer } from '../renderers/scene-renderer'
|
||||||
import FrameLimiter from './frame-limiter'
|
import FrameLimiter from './frame-limiter'
|
||||||
import { Lights } from './lights'
|
import { Lights } from './lights'
|
||||||
@@ -78,6 +79,10 @@ type WebGPUDeviceLossInfo = {
|
|||||||
|
|
||||||
type WebGPUDeviceLike = {
|
type WebGPUDeviceLike = {
|
||||||
lost: Promise<WebGPUDeviceLossInfo>
|
lost: Promise<WebGPUDeviceLossInfo>
|
||||||
|
label?: string
|
||||||
|
features?: Set<string>
|
||||||
|
addEventListener?: (type: string, listener: EventListener) => void
|
||||||
|
removeEventListener?: (type: string, listener: EventListener) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function GPUDeviceWatcher() {
|
function GPUDeviceWatcher() {
|
||||||
@@ -87,7 +92,18 @@ function GPUDeviceWatcher() {
|
|||||||
const backend = (gl as any).backend
|
const backend = (gl as any).backend
|
||||||
const device = backend?.device as WebGPUDeviceLike | undefined
|
const device = backend?.device as WebGPUDeviceLike | undefined
|
||||||
|
|
||||||
if (!device) return
|
if (!device) {
|
||||||
|
console.warn('[viewer] No WebGPU device on backend — running on a fallback renderer.', {
|
||||||
|
backend: backend?.constructor?.name ?? 'unknown',
|
||||||
|
rendererType: (gl as any).constructor?.name ?? 'unknown',
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[viewer] WebGPU device ready', {
|
||||||
|
label: device.label,
|
||||||
|
features: device.features ? Array.from(device.features) : [],
|
||||||
|
})
|
||||||
|
|
||||||
device.lost.then((info: WebGPUDeviceLossInfo) => {
|
device.lost.then((info: WebGPUDeviceLossInfo) => {
|
||||||
console.error(
|
console.error(
|
||||||
@@ -95,6 +111,17 @@ function GPUDeviceWatcher() {
|
|||||||
'The page must be reloaded to recover the GPU context.',
|
'The page must be reloaded to recover the GPU context.',
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Uncaptured errors are normally silent (only console-warned by Chrome at
|
||||||
|
// best). Pipe them to console.error so silent mobile crashes show up.
|
||||||
|
const onUncapturedError = (event: any) => {
|
||||||
|
console.error('[viewer] WebGPU uncaptured error:', event?.error?.message, event?.error)
|
||||||
|
}
|
||||||
|
device.addEventListener?.('uncapturederror', onUncapturedError)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
device.removeEventListener?.('uncapturederror', onUncapturedError)
|
||||||
|
}
|
||||||
}, [gl])
|
}, [gl])
|
||||||
|
|
||||||
return null
|
return null
|
||||||
@@ -121,17 +148,34 @@ const Viewer: React.FC<ViewerProps> = ({
|
|||||||
dpr={[1, 1.5]}
|
dpr={[1, 1.5]}
|
||||||
frameloop="never"
|
frameloop="never"
|
||||||
gl={async (props) => {
|
gl={async (props) => {
|
||||||
const renderer = new THREE.WebGPURenderer(props as any)
|
try {
|
||||||
renderer.toneMapping = THREE.ACESFilmicToneMapping
|
// Surface the env we're about to ask WebGPU for — catches "no
|
||||||
renderer.toneMappingExposure = 0.9
|
// navigator.gpu" / "adapter request failed" silently failing in
|
||||||
// Awaiting init() is required when the browser falls back to the
|
// mobile WebViews where WebGPU is gated behind flags.
|
||||||
// WebGL2 backend (Safari without the WebGPU flag, older Chrome on
|
const hasGpu = typeof navigator !== 'undefined' && 'gpu' in navigator
|
||||||
// machines without a WebGPU device). In native WebGPU mode the
|
console.log('[viewer] Creating WebGPURenderer', {
|
||||||
// init resolves almost instantly. Without this await, the first
|
hasNavigatorGPU: hasGpu,
|
||||||
// render throws "Renderer: .render() called before the backend is
|
ua: typeof navigator !== 'undefined' ? navigator.userAgent : 'n/a',
|
||||||
// initialized" from the post-processing fallback path.
|
})
|
||||||
await renderer.init()
|
const renderer = new THREE.WebGPURenderer(props as any)
|
||||||
return renderer
|
renderer.toneMapping = THREE.ACESFilmicToneMapping
|
||||||
|
renderer.toneMappingExposure = 0.9
|
||||||
|
// Awaiting init() is required when the browser falls back to the
|
||||||
|
// WebGL2 backend (Safari without the WebGPU flag, older Chrome on
|
||||||
|
// machines without a WebGPU device). In native WebGPU mode the
|
||||||
|
// init resolves almost instantly. Without this await, the first
|
||||||
|
// render throws "Renderer: .render() called before the backend is
|
||||||
|
// initialized" from the post-processing fallback path.
|
||||||
|
await renderer.init()
|
||||||
|
console.log('[viewer] WebGPURenderer ready', {
|
||||||
|
backend: (renderer as any).backend?.constructor?.name,
|
||||||
|
isWebGPU: (renderer as any).isWebGPURenderer === true,
|
||||||
|
})
|
||||||
|
return renderer
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[viewer] WebGPURenderer init failed', err)
|
||||||
|
throw err
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
resize={{
|
resize={{
|
||||||
debounce: 100,
|
debounce: 100,
|
||||||
@@ -144,39 +188,41 @@ const Viewer: React.FC<ViewerProps> = ({
|
|||||||
<FrameLimiter fps={50} />
|
<FrameLimiter fps={50} />
|
||||||
{/* <AnimatedBackground isDark={theme === 'dark'} /> */}
|
{/* <AnimatedBackground isDark={theme === 'dark'} /> */}
|
||||||
<ViewerCamera />
|
<ViewerCamera />
|
||||||
|
|
||||||
{/* <directionalLight position={[10, 10, 5]} intensity={0.5} castShadow
|
|
||||||
/> */}
|
|
||||||
<Lights />
|
|
||||||
<Bvh>
|
|
||||||
<SceneRenderer />
|
|
||||||
</Bvh>
|
|
||||||
|
|
||||||
{/* Default Systems */}
|
|
||||||
<LevelSystem />
|
|
||||||
<GuideSystem />
|
|
||||||
<ScanSystem />
|
|
||||||
<WallCutout />
|
|
||||||
{/* Core systems */}
|
|
||||||
<CeilingSystem />
|
|
||||||
<DoorSystem />
|
|
||||||
<FenceSystem />
|
|
||||||
<ItemSystem />
|
|
||||||
<RoofSystem />
|
|
||||||
<SlabSystem />
|
|
||||||
<StairSystem />
|
|
||||||
<WallSystem />
|
|
||||||
<WindowSystem />
|
|
||||||
<ZoneSystem />
|
|
||||||
<PostProcessing hoverStyles={hoverStyles} />
|
|
||||||
{/* <DebugRenderer /> */}
|
|
||||||
<GPUDeviceWatcher />
|
<GPUDeviceWatcher />
|
||||||
|
|
||||||
<ItemLightSystem />
|
<ErrorBoundary fallback={null} scope="viewer-scene">
|
||||||
<ItemMeshMetadataSystem />
|
{/* <directionalLight position={[10, 10, 5]} intensity={0.5} castShadow
|
||||||
{selectionManager === 'default' && <SelectionManager />}
|
/> */}
|
||||||
{perf && <PerfMonitor />}
|
<Lights />
|
||||||
{children}
|
<Bvh>
|
||||||
|
<SceneRenderer />
|
||||||
|
</Bvh>
|
||||||
|
|
||||||
|
{/* Default Systems */}
|
||||||
|
<LevelSystem />
|
||||||
|
<GuideSystem />
|
||||||
|
<ScanSystem />
|
||||||
|
<WallCutout />
|
||||||
|
{/* Core systems */}
|
||||||
|
<CeilingSystem />
|
||||||
|
<DoorSystem />
|
||||||
|
<FenceSystem />
|
||||||
|
<ItemSystem />
|
||||||
|
<RoofSystem />
|
||||||
|
<SlabSystem />
|
||||||
|
<StairSystem />
|
||||||
|
<WallSystem />
|
||||||
|
<WindowSystem />
|
||||||
|
<ZoneSystem />
|
||||||
|
<PostProcessing hoverStyles={hoverStyles} />
|
||||||
|
{/* <DebugRenderer /> */}
|
||||||
|
|
||||||
|
<ItemLightSystem />
|
||||||
|
<ItemMeshMetadataSystem />
|
||||||
|
{selectionManager === 'default' && <SelectionManager />}
|
||||||
|
{perf && <PerfMonitor />}
|
||||||
|
{children}
|
||||||
|
</ErrorBoundary>
|
||||||
</Canvas>
|
</Canvas>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -174,9 +174,22 @@ const PostProcessingPasses = ({
|
|||||||
void pipelineVersion
|
void pipelineVersion
|
||||||
|
|
||||||
if (!(renderer && scene && camera)) {
|
if (!(renderer && scene && camera)) {
|
||||||
|
console.warn('[viewer/post-processing] Skipping pipeline build — missing dependency.', {
|
||||||
|
hasRenderer: !!renderer,
|
||||||
|
hasScene: !!scene,
|
||||||
|
hasCamera: !!camera,
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log('[viewer/post-processing] Building pipeline', {
|
||||||
|
version: pipelineVersion,
|
||||||
|
ssgi: SSGI_PARAMS.enabled,
|
||||||
|
hoverHighlightMode,
|
||||||
|
projectId,
|
||||||
|
rendererCtor: (renderer as any).constructor?.name,
|
||||||
|
})
|
||||||
|
|
||||||
hasPipelineErrorRef.current = false
|
hasPipelineErrorRef.current = false
|
||||||
|
|
||||||
// WebGPU availability check: SSGI, denoise, and RenderPipeline are all
|
// WebGPU availability check: SSGI, denoise, and RenderPipeline are all
|
||||||
@@ -318,10 +331,16 @@ const PostProcessingPasses = ({
|
|||||||
renderPipeline.outputNode = finalOutput
|
renderPipeline.outputNode = finalOutput
|
||||||
renderPipelineRef.current = renderPipeline
|
renderPipelineRef.current = renderPipeline
|
||||||
retryCountRef.current = 0
|
retryCountRef.current = 0
|
||||||
|
console.log('[viewer/post-processing] Pipeline built OK', { version: pipelineVersion })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
hasPipelineErrorRef.current = true
|
hasPipelineErrorRef.current = true
|
||||||
console.error(
|
console.error(
|
||||||
'[viewer] Failed to set up post-processing pipeline. Rendering without post FX.',
|
'[viewer/post-processing] Failed to set up post-processing pipeline. Rendering without post FX.',
|
||||||
|
{
|
||||||
|
version: pipelineVersion,
|
||||||
|
ssgi: SSGI_PARAMS.enabled,
|
||||||
|
rendererCtor: (renderer as any).constructor?.name,
|
||||||
|
},
|
||||||
error,
|
error,
|
||||||
)
|
)
|
||||||
if (renderPipelineRef.current) {
|
if (renderPipelineRef.current) {
|
||||||
@@ -366,7 +385,7 @@ const PostProcessingPasses = ({
|
|||||||
}
|
}
|
||||||
;(renderer as any).render(scene, camera)
|
;(renderer as any).render(scene, camera)
|
||||||
} catch (fallbackError) {
|
} catch (fallbackError) {
|
||||||
console.error('[viewer] Fallback render failed.', fallbackError)
|
console.error('[viewer/post-processing] Fallback render failed.', fallbackError)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -378,7 +397,11 @@ const PostProcessingPasses = ({
|
|||||||
renderPipelineRef.current.render()
|
renderPipelineRef.current.render()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
hasPipelineErrorRef.current = true
|
hasPipelineErrorRef.current = true
|
||||||
console.error('[viewer] Post-processing render pass failed.', error)
|
console.error('[viewer/post-processing] Render pass failed.', {
|
||||||
|
retryCount: retryCountRef.current,
|
||||||
|
rendererCtor: (renderer as any).constructor?.name,
|
||||||
|
error,
|
||||||
|
})
|
||||||
if (renderPipelineRef.current) {
|
if (renderPipelineRef.current) {
|
||||||
renderPipelineRef.current.dispose()
|
renderPipelineRef.current.dispose()
|
||||||
}
|
}
|
||||||
@@ -388,7 +411,7 @@ const PostProcessingPasses = ({
|
|||||||
// Auto-retry: schedule a pipeline rebuild if we haven't exceeded the retry limit
|
// Auto-retry: schedule a pipeline rebuild if we haven't exceeded the retry limit
|
||||||
retryCountRef.current++
|
retryCountRef.current++
|
||||||
console.warn(
|
console.warn(
|
||||||
`[viewer] Scheduling post-processing rebuild (attempt ${retryCountRef.current}/${MAX_PIPELINE_RETRIES})`,
|
`[viewer/post-processing] Scheduling pipeline rebuild (attempt ${retryCountRef.current}/${MAX_PIPELINE_RETRIES})`,
|
||||||
)
|
)
|
||||||
if (rebuildTimeoutRef.current !== null) {
|
if (rebuildTimeoutRef.current !== null) {
|
||||||
clearTimeout(rebuildTimeoutRef.current)
|
clearTimeout(rebuildTimeoutRef.current)
|
||||||
@@ -396,7 +419,7 @@ const PostProcessingPasses = ({
|
|||||||
rebuildTimeoutRef.current = setTimeout(requestPipelineRebuild, RETRY_DELAY_MS)
|
rebuildTimeoutRef.current = setTimeout(requestPipelineRebuild, RETRY_DELAY_MS)
|
||||||
} else {
|
} else {
|
||||||
console.error(
|
console.error(
|
||||||
'[viewer] Post-processing retries exhausted. Rendering without post FX for this session.',
|
'[viewer/post-processing] Retries exhausted. Rendering without post FX for this session.',
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -166,6 +166,14 @@ export class MergedOutlineNode extends TempNode {
|
|||||||
private readonly _cacheA = new Set<Object3D>()
|
private readonly _cacheA = new Set<Object3D>()
|
||||||
private readonly _cacheB = new Set<Object3D>()
|
private readonly _cacheB = new Set<Object3D>()
|
||||||
|
|
||||||
|
// Tracks whether either group rendered last frame. We use this to decide
|
||||||
|
// when it's safe to skip renderer state manipulation entirely — touching
|
||||||
|
// the renderer (resetRendererAndSceneState + setRenderTarget + clearColor)
|
||||||
|
// corrupts the FBO state on the WebGL2 backend (iOS Chrome fallback) and
|
||||||
|
// the subsequent scene render comes out blank.
|
||||||
|
private _wroteGroupALastFrame = false
|
||||||
|
private _wroteGroupBLastFrame = false
|
||||||
|
|
||||||
private readonly _textureNodeA: any
|
private readonly _textureNodeA: any
|
||||||
private readonly _textureNodeB: any
|
private readonly _textureNodeB: any
|
||||||
|
|
||||||
@@ -294,6 +302,17 @@ export class MergedOutlineNode extends TempNode {
|
|||||||
updateBefore(frame: any) {
|
updateBefore(frame: any) {
|
||||||
const hasPrimary = this.primaryObjects.length > 0
|
const hasPrimary = this.primaryObjects.length > 0
|
||||||
const hasSecondary = this.secondaryObjects.length > 0
|
const hasSecondary = this.secondaryObjects.length > 0
|
||||||
|
const hasAny = hasPrimary || hasSecondary
|
||||||
|
|
||||||
|
// Fast-path: nothing to render and nothing was rendered last frame either,
|
||||||
|
// so there are no stale composites to clear. Touch nothing — on the WebGL2
|
||||||
|
// backend (iOS Chrome fallback) even an empty reset/setRenderTarget cycle
|
||||||
|
// corrupts the framebuffer state and the next scene render goes blank.
|
||||||
|
const needsCleanupA = !hasPrimary && this._wroteGroupALastFrame
|
||||||
|
const needsCleanupB = !hasSecondary && this._wroteGroupBLastFrame
|
||||||
|
if (!(hasAny || needsCleanupA || needsCleanupB)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const { renderer } = frame
|
const { renderer } = frame
|
||||||
const { camera, scene } = this
|
const { camera, scene } = this
|
||||||
@@ -303,24 +322,27 @@ export class MergedOutlineNode extends TempNode {
|
|||||||
const size = renderer.getDrawingBufferSize(_size)
|
const size = renderer.getDrawingBufferSize(_size)
|
||||||
this.setSize(size.width, size.height)
|
this.setSize(size.width, size.height)
|
||||||
|
|
||||||
// Clear composites for inactive groups so stale outlines don't persist on GPU.
|
// Clear composites for groups that just transitioned from "has content"
|
||||||
// Must happen inside resetRendererAndSceneState to avoid MSAA state corruption.
|
// to "empty" — without this, the previous outline lingers on the GPU.
|
||||||
if (!hasPrimary) {
|
if (needsCleanupA) {
|
||||||
renderer.setRenderTarget(this._groupA.composite)
|
renderer.setRenderTarget(this._groupA.composite)
|
||||||
renderer.clearColor()
|
renderer.clearColor()
|
||||||
|
this._wroteGroupALastFrame = false
|
||||||
}
|
}
|
||||||
if (!hasSecondary) {
|
if (needsCleanupB) {
|
||||||
renderer.setRenderTarget(this._groupB.composite)
|
renderer.setRenderTarget(this._groupB.composite)
|
||||||
renderer.clearColor()
|
renderer.clearColor()
|
||||||
|
this._wroteGroupBLastFrame = false
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasAny = hasPrimary || hasSecondary
|
|
||||||
if (!hasAny) {
|
if (!hasAny) {
|
||||||
RendererUtils.restoreRendererAndSceneState(renderer, scene, _rendererState)
|
RendererUtils.restoreRendererAndSceneState(renderer, scene, _rendererState)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
renderer.setClearColor(0xff_ff_ff, 1)
|
renderer.setClearColor(0xff_ff_ff, 1)
|
||||||
|
this._wroteGroupALastFrame = hasPrimary
|
||||||
|
this._wroteGroupBLastFrame = hasSecondary
|
||||||
|
|
||||||
if (hasPrimary) this._buildCache(this.primaryObjects, this._cacheA)
|
if (hasPrimary) this._buildCache(this.primaryObjects, this._cacheA)
|
||||||
if (hasSecondary) this._buildCache(this.secondaryObjects, this._cacheB)
|
if (hasSecondary) this._buildCache(this.secondaryObjects, this._cacheB)
|
||||||
|
|||||||
Reference in New Issue
Block a user