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(),
|
||||
name: 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,
|
||||
dimensions: z.tuple([z.number(), z.number(), z.number()]).default([1, 1, 1]), // [w, h, d]
|
||||
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])
|
||||
|
||||
// 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(() => {
|
||||
const keyState = {
|
||||
shiftRight: false,
|
||||
@@ -407,6 +450,7 @@ export const CustomCameraControls = () => {
|
||||
onTransitionStart={onTransitionStart}
|
||||
ref={controls}
|
||||
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'
|
||||
|
||||
import { type ReactNode, useCallback, useEffect, useRef } from 'react'
|
||||
import { useIsMobile } from '../../hooks/use-mobile'
|
||||
import useEditor from '../../store/use-editor'
|
||||
|
||||
import { useSidebarStore } from '../ui/primitives/sidebar'
|
||||
import { type SidebarTab, TabBar } from '../ui/sidebar/tab-bar'
|
||||
import { EditorLayoutMobile } from './editor-layout-mobile'
|
||||
|
||||
const SIDEBAR_MIN_WIDTH = 300
|
||||
const SIDEBAR_MAX_WIDTH = 800
|
||||
@@ -202,6 +205,23 @@ export function EditorLayoutV2({
|
||||
viewerContent,
|
||||
overlays,
|
||||
}: 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 (
|
||||
<div className="dark flex h-full w-full flex-col bg-sidebar text-foreground">
|
||||
{/* Top navbar */}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { Icon } from '@iconify/react'
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
type BuildingNode,
|
||||
type CeilingNode,
|
||||
@@ -26,7 +25,6 @@ import {
|
||||
type Point2D,
|
||||
type RoofNode,
|
||||
type RoofSegmentNode,
|
||||
sceneRegistry,
|
||||
type SiteNode,
|
||||
SlabNode,
|
||||
type StairNode,
|
||||
@@ -34,6 +32,7 @@ import {
|
||||
type StairSegmentNode,
|
||||
StairSegmentNode as StairSegmentNodeSchema,
|
||||
sampleWallCenterline,
|
||||
sceneRegistry,
|
||||
useLiveTransforms,
|
||||
useScene,
|
||||
type WallNode,
|
||||
@@ -54,6 +53,18 @@ import {
|
||||
useState,
|
||||
} from 'react'
|
||||
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 { FloorplanCursorIndicatorOverlay as Editor2dFloorplanCursorIndicatorOverlay } from '../editor-2d/floorplan-cursor-indicator-overlay'
|
||||
import {
|
||||
@@ -61,30 +72,15 @@ import {
|
||||
FloorplanSiteKeyHandler,
|
||||
} from '../editor-2d/floorplan-hotkey-handlers'
|
||||
import { FloorplanDraftLayer } from '../editor-2d/renderers/floorplan-draft-layer'
|
||||
import { FloorplanMarqueeLayer } from '../editor-2d/renderers/floorplan-marquee-layer'
|
||||
import {
|
||||
FloorplanMeasurementsLayer,
|
||||
type LinearMeasurementOverlay,
|
||||
} 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 { FloorplanStairLayer } from '../editor-2d/renderers/floorplan-stair-layer'
|
||||
import {
|
||||
buildSvgPolylinePath,
|
||||
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 { buildSvgPolylinePath, formatPolygonPath, getArcPlanPoint } from '../editor-2d/svg-paths'
|
||||
import { snapFenceDraftPoint } from '../tools/fence/fence-drafting'
|
||||
import { snapToHalf } from '../tools/item/placement-math'
|
||||
import {
|
||||
DEFAULT_STAIR_ATTACHMENT_SIDE,
|
||||
@@ -95,7 +91,6 @@ import {
|
||||
DEFAULT_STAIR_THICKNESS,
|
||||
DEFAULT_STAIR_WIDTH,
|
||||
} from '../tools/stair/stair-defaults'
|
||||
import { snapFenceDraftPoint } from '../tools/fence/fence-drafting'
|
||||
import {
|
||||
createWallOnCurrentLevel,
|
||||
isWallLongEnough,
|
||||
@@ -450,6 +445,13 @@ type FloorplanItemEntry = {
|
||||
points: string
|
||||
polygon: Point2D[]
|
||||
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 = {
|
||||
@@ -1450,8 +1452,7 @@ function movePlanPointTowards(start: Point2D, end: Point2D, distance: number): P
|
||||
|
||||
function getNormalizedFloorplanStairSweepAngle(stair: StairNode) {
|
||||
const stairType = stair.stairType ?? 'straight'
|
||||
const baseSweepAngle =
|
||||
stair.sweepAngle ?? (stairType === 'spiral' ? Math.PI * 2 : Math.PI / 2)
|
||||
const baseSweepAngle = stair.sweepAngle ?? (stairType === 'spiral' ? Math.PI * 2 : Math.PI / 2)
|
||||
|
||||
if (Math.abs(baseSweepAngle) >= Math.PI * 2) {
|
||||
return Math.sign(baseSweepAngle || 1) * (Math.PI * 2 - 0.001)
|
||||
@@ -1850,7 +1851,6 @@ function formatArea(areaSqM: number, unit: 'metric' | 'imperial') {
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
function getWallMeasurementOverlay(
|
||||
wall: WallNode,
|
||||
centerX: number,
|
||||
@@ -2035,32 +2035,20 @@ function getLinearMeasurementOverlay(
|
||||
x1: toSvgX(start.x),
|
||||
y1: toSvgY(start.y),
|
||||
x2: toSvgX(
|
||||
offsetVector
|
||||
? start.x +
|
||||
offsetVector.x *
|
||||
(offsetDistance + extensionOvershoot)
|
||||
: start.x,
|
||||
offsetVector ? start.x + offsetVector.x * (offsetDistance + extensionOvershoot) : start.x,
|
||||
),
|
||||
y2: toSvgY(
|
||||
offsetVector
|
||||
? start.y +
|
||||
offsetVector.y *
|
||||
(offsetDistance + extensionOvershoot)
|
||||
: start.y,
|
||||
offsetVector ? start.y + offsetVector.y * (offsetDistance + extensionOvershoot) : start.y,
|
||||
),
|
||||
},
|
||||
extensionEnd: {
|
||||
x1: toSvgX(end.x),
|
||||
y1: toSvgY(end.y),
|
||||
x2: toSvgX(
|
||||
offsetVector
|
||||
? end.x + offsetVector.x * (offsetDistance + extensionOvershoot)
|
||||
: end.x,
|
||||
offsetVector ? end.x + offsetVector.x * (offsetDistance + extensionOvershoot) : end.x,
|
||||
),
|
||||
y2: toSvgY(
|
||||
offsetVector
|
||||
? end.y + offsetVector.y * (offsetDistance + extensionOvershoot)
|
||||
: end.y,
|
||||
offsetVector ? end.y + offsetVector.y * (offsetDistance + extensionOvershoot) : end.y,
|
||||
),
|
||||
},
|
||||
label,
|
||||
@@ -2083,7 +2071,10 @@ type WallMeasurementFaceContext = {
|
||||
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)) {
|
||||
return null
|
||||
}
|
||||
@@ -2162,9 +2153,7 @@ function getWallMeasurementFaceContext(
|
||||
y: wallMidpoint.y - centerY,
|
||||
}
|
||||
const outwardNormal =
|
||||
fromCenter.x * normal.x + fromCenter.y * normal.y >= 0
|
||||
? normal
|
||||
: { x: -normal.x, y: -normal.y }
|
||||
fromCenter.x * normal.x + fromCenter.y * normal.y >= 0 ? normal : { x: -normal.x, y: -normal.y }
|
||||
const rightMidpoint = getLineMidpoint(faceLines.right)
|
||||
const leftMidpoint = getLineMidpoint(faceLines.left)
|
||||
const rightScore =
|
||||
@@ -2204,7 +2193,10 @@ function getAdjacentOpeningBounds(
|
||||
const startDistance = 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
|
||||
}
|
||||
|
||||
@@ -2269,8 +2261,14 @@ function getSelectedWallMeasurementOverlays(
|
||||
}
|
||||
|
||||
const { outerFace, innerFace, outwardNormal, inwardNormal } = faceContext
|
||||
const outerLength = Math.hypot(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 outerLength = Math.hypot(
|
||||
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[] = []
|
||||
|
||||
if (outerLength >= 0.1) {
|
||||
@@ -2341,8 +2339,14 @@ function getItemDimensionMeasurementOverlays(
|
||||
}
|
||||
|
||||
const centroid = polygonCentroid(polygon)
|
||||
const configuredWidth = formatMeasurement(itemEntry.item.scale[0] * itemEntry.item.asset.dimensions[0], unit)
|
||||
const configuredDepth = formatMeasurement(itemEntry.item.scale[2] * itemEntry.item.asset.dimensions[2], unit)
|
||||
const configuredWidth = formatMeasurement(
|
||||
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 edgeVector = {
|
||||
x: end.x - start.x,
|
||||
@@ -3346,7 +3350,9 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onPointerEnter={canSelectCeilings ? () => onCeilingHoverChange(ceiling.id) : undefined}
|
||||
onPointerEnter={
|
||||
canSelectCeilings ? () => onCeilingHoverChange(ceiling.id) : undefined
|
||||
}
|
||||
onPointerLeave={canSelectCeilings ? () => onCeilingHoverChange(null) : undefined}
|
||||
pointerEvents={canSelectCeilings ? undefined : 'none'}
|
||||
style={canSelectCeilings ? { cursor: EDITOR_CURSOR } : undefined}
|
||||
@@ -3410,11 +3416,7 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
|
||||
/>
|
||||
)}
|
||||
<polygon
|
||||
fill={
|
||||
isDeleteHovered
|
||||
? palette.deleteWallFill
|
||||
: palette.wallFill
|
||||
}
|
||||
fill={isDeleteHovered ? palette.deleteWallFill : palette.wallFill}
|
||||
onClick={
|
||||
canSelectGeometry
|
||||
? (event) => {
|
||||
@@ -3561,11 +3563,7 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
|
||||
<polygon
|
||||
fill={isDeleteHovered ? palette.deleteFill : symbolFill}
|
||||
points={points}
|
||||
stroke={
|
||||
isDeleteHovered
|
||||
? palette.deleteStroke
|
||||
: symbolStroke
|
||||
}
|
||||
stroke={isDeleteHovered ? palette.deleteStroke : symbolStroke}
|
||||
strokeOpacity={1}
|
||||
strokeWidth={symbolStrokeWidth}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
@@ -3578,11 +3576,7 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
|
||||
insetOuterEnd,
|
||||
insetOuterStart,
|
||||
])}
|
||||
stroke={
|
||||
isDeleteHovered
|
||||
? palette.deleteStroke
|
||||
: symbolStroke
|
||||
}
|
||||
stroke={isDeleteHovered ? palette.deleteStroke : symbolStroke}
|
||||
strokeWidth={innerStrokeWidth}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
@@ -3681,8 +3675,7 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
|
||||
const hingesSide = opening.hingesSide ?? 'left'
|
||||
const swingDirection = opening.swingDirection ?? 'inward'
|
||||
const width = opening.width
|
||||
const doorColor =
|
||||
isSelected || isSelectionHighlighted ? '#f97316' : '#3f3f46'
|
||||
const doorColor = isSelected || isSelectionHighlighted ? '#f97316' : '#3f3f46'
|
||||
const swingColor =
|
||||
isSelected || isSelectionHighlighted
|
||||
? 'rgba(249, 115, 22, 0.78)'
|
||||
@@ -3761,11 +3754,7 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
|
||||
<polygon
|
||||
fill={isDeleteHovered ? palette.deleteFill : openingFill}
|
||||
points={points}
|
||||
stroke={
|
||||
isDeleteHovered
|
||||
? palette.deleteStroke
|
||||
: doorColor
|
||||
}
|
||||
stroke={isDeleteHovered ? palette.deleteStroke : doorColor}
|
||||
strokeOpacity={1}
|
||||
strokeWidth={isSelected || isSelectionHighlighted ? '2.25' : '1.25'}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
@@ -3914,7 +3903,11 @@ const FloorplanFenceLayer = memo(function FloorplanFenceLayer({
|
||||
const fenceUnderlayWidth = isActive ? '6.5' : isHovered ? '6' : '5.2'
|
||||
const fenceStrokeWidth = isActive ? '2.6' : isHovered ? '2.35' : '2.05'
|
||||
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 slatMarkerHalf = clamp(fence.postSize * 0.42, 0.03, 0.055)
|
||||
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({
|
||||
canFocusItems,
|
||||
canFocusStairs,
|
||||
@@ -4167,7 +4196,7 @@ const FloorplanNodeLayer = memo(function FloorplanNodeLayer({
|
||||
}
|
||||
|
||||
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 isSelected = selectedIdSet.has(item.id)
|
||||
const isHighlighted = highlightedIdSet.has(item.id)
|
||||
@@ -4197,6 +4226,7 @@ const FloorplanNodeLayer = memo(function FloorplanNodeLayer({
|
||||
: isHovered
|
||||
? 0.58
|
||||
: 0.52
|
||||
const floorPlanUrl = item.asset.floorPlanUrl
|
||||
const diagonalAStart = polygon[0]
|
||||
const diagonalAEnd = polygon[2]
|
||||
const diagonalBStart = polygon[1]
|
||||
@@ -4285,31 +4315,43 @@ const FloorplanNodeLayer = memo(function FloorplanNodeLayer({
|
||||
strokeOpacity={1}
|
||||
strokeWidth={FLOORPLAN_NODE_FOOTPRINT_STROKE_WIDTH}
|
||||
/>
|
||||
{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)}
|
||||
{floorPlanUrl ? (
|
||||
<FloorplanItemImage
|
||||
center={center}
|
||||
depth={depth}
|
||||
rotation={rotation}
|
||||
url={floorPlanUrl}
|
||||
width={width}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{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 ? (
|
||||
<FloorplanMeasurementsLayer
|
||||
@@ -5571,20 +5613,19 @@ export function FloorplanPanel() {
|
||||
const live = useLiveTransforms.getState().get(fence.id)
|
||||
const fenceCenterX = (fence.start[0] + fence.end[0]) / 2
|
||||
const fenceCenterZ = (fence.start[1] + fence.end[1]) / 2
|
||||
const displayFence =
|
||||
live
|
||||
? {
|
||||
...fence,
|
||||
start: [
|
||||
fence.start[0] + (live.position[0] - fenceCenterX),
|
||||
fence.start[1] + (live.position[2] - fenceCenterZ),
|
||||
] as typeof fence.start,
|
||||
end: [
|
||||
fence.end[0] + (live.position[0] - fenceCenterX),
|
||||
fence.end[1] + (live.position[2] - fenceCenterZ),
|
||||
] as typeof fence.end,
|
||||
}
|
||||
: fence
|
||||
const displayFence = live
|
||||
? {
|
||||
...fence,
|
||||
start: [
|
||||
fence.start[0] + (live.position[0] - fenceCenterX),
|
||||
fence.start[1] + (live.position[2] - fenceCenterZ),
|
||||
] as typeof fence.start,
|
||||
end: [
|
||||
fence.end[0] + (live.position[0] - fenceCenterX),
|
||||
fence.end[1] + (live.position[2] - fenceCenterZ),
|
||||
] as typeof fence.end,
|
||||
}
|
||||
: fence
|
||||
const centerline = isCurvedWall(displayFence)
|
||||
? sampleWallCenterline(displayFence, 24)
|
||||
: [
|
||||
@@ -5677,16 +5718,22 @@ export function FloorplanPanel() {
|
||||
if (!wall) return []
|
||||
const live = useLiveTransforms.getState().get(opening.id)
|
||||
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,
|
||||
position: [
|
||||
live.position[0],
|
||||
opening.position[1],
|
||||
live.position[2],
|
||||
] 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
|
||||
const polygon = getOpeningFootprint(wall, displayOpening)
|
||||
return [
|
||||
@@ -5831,6 +5878,10 @@ export function FloorplanPanel() {
|
||||
points: formatPolygonPoints(entry.polygon),
|
||||
polygon: entry.polygon,
|
||||
usesRealMesh: entry.usesRealMesh,
|
||||
center: entry.center,
|
||||
rotation: entry.rotation,
|
||||
width: entry.width,
|
||||
depth: entry.depth,
|
||||
},
|
||||
]
|
||||
})
|
||||
@@ -5907,18 +5958,17 @@ export function FloorplanPanel() {
|
||||
buildingRotationY,
|
||||
)
|
||||
: null
|
||||
const displayRoof =
|
||||
liveRoofTransform
|
||||
? {
|
||||
...roof,
|
||||
position: [
|
||||
liveRoofPosition?.x ?? roof.position[0],
|
||||
roof.position[1],
|
||||
liveRoofPosition?.y ?? roof.position[2],
|
||||
] as RoofNode['position'],
|
||||
rotation: liveRoofTransform.rotation,
|
||||
}
|
||||
: roof
|
||||
const displayRoof = liveRoofTransform
|
||||
? {
|
||||
...roof,
|
||||
position: [
|
||||
liveRoofPosition?.x ?? roof.position[0],
|
||||
roof.position[1],
|
||||
liveRoofPosition?.y ?? roof.position[2],
|
||||
] as RoofNode['position'],
|
||||
rotation: liveRoofTransform.rotation,
|
||||
}
|
||||
: roof
|
||||
const segments = (displayRoof.children ?? [])
|
||||
.map((childId) => levelDescendantNodeById.get(childId as AnyNodeId))
|
||||
.filter(
|
||||
@@ -6046,12 +6096,10 @@ export function FloorplanPanel() {
|
||||
}
|
||||
}
|
||||
|
||||
let bestHit:
|
||||
| {
|
||||
point: Point2D
|
||||
distance: number
|
||||
}
|
||||
| null = null
|
||||
let bestHit: {
|
||||
point: Point2D
|
||||
distance: number
|
||||
} | null = null
|
||||
|
||||
for (const { polygon: wallPolygon } of displayWallPolygons) {
|
||||
for (let wallIndex = 0; wallIndex < wallPolygon.length; wallIndex += 1) {
|
||||
@@ -6123,7 +6171,9 @@ export function FloorplanPanel() {
|
||||
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)) {
|
||||
return [] as LinearMeasurementOverlay[]
|
||||
}
|
||||
@@ -6161,8 +6211,14 @@ export function FloorplanPanel() {
|
||||
)
|
||||
const faceOffsetDistance = (wall.thickness ?? 0.1) / 2
|
||||
const openingFaceStart = {
|
||||
x: wall.start[0] + tangent.x * startDistance + faceContext.outwardNormal.x * faceOffsetDistance,
|
||||
y: wall.start[1] + tangent.y * startDistance + faceContext.outwardNormal.y * faceOffsetDistance,
|
||||
x:
|
||||
wall.start[0] +
|
||||
tangent.x * startDistance +
|
||||
faceContext.outwardNormal.x * faceOffsetDistance,
|
||||
y:
|
||||
wall.start[1] +
|
||||
tangent.y * startDistance +
|
||||
faceContext.outwardNormal.y * faceOffsetDistance,
|
||||
}
|
||||
const openingFaceEnd = {
|
||||
x: wall.start[0] + tangent.x * endDistance + faceContext.outwardNormal.x * faceOffsetDistance,
|
||||
@@ -6172,15 +6228,27 @@ export function FloorplanPanel() {
|
||||
leftBoundary === null
|
||||
? faceContext.outerFace.start
|
||||
: {
|
||||
x: wall.start[0] + tangent.x * leftBoundary + faceContext.outwardNormal.x * faceOffsetDistance,
|
||||
y: wall.start[1] + tangent.y * leftBoundary + faceContext.outwardNormal.y * faceOffsetDistance,
|
||||
x:
|
||||
wall.start[0] +
|
||||
tangent.x * leftBoundary +
|
||||
faceContext.outwardNormal.x * faceOffsetDistance,
|
||||
y:
|
||||
wall.start[1] +
|
||||
tangent.y * leftBoundary +
|
||||
faceContext.outwardNormal.y * faceOffsetDistance,
|
||||
}
|
||||
const rightBoundaryPoint =
|
||||
rightBoundary === null
|
||||
? faceContext.outerFace.end
|
||||
: {
|
||||
x: wall.start[0] + tangent.x * rightBoundary + faceContext.outwardNormal.x * faceOffsetDistance,
|
||||
y: wall.start[1] + tangent.y * rightBoundary + faceContext.outwardNormal.y * faceOffsetDistance,
|
||||
x:
|
||||
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 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.
|
||||
return getWallPlanFootprint(draftWall, EMPTY_WALL_MITER_DATA)
|
||||
}, [draftEnd, draftStart, levelId])
|
||||
const draftPolygonPoints = useMemo(
|
||||
() => {
|
||||
if (isRoofBuildActive && roofDraftStart && roofDraftEnd) {
|
||||
const minX = Math.min(roofDraftStart[0], roofDraftEnd[0])
|
||||
const maxX = Math.max(roofDraftStart[0], roofDraftEnd[0])
|
||||
const minY = Math.min(roofDraftStart[1], roofDraftEnd[1])
|
||||
const maxY = Math.max(roofDraftStart[1], roofDraftEnd[1])
|
||||
const draftPolygonPoints = useMemo(() => {
|
||||
if (isRoofBuildActive && roofDraftStart && roofDraftEnd) {
|
||||
const minX = Math.min(roofDraftStart[0], roofDraftEnd[0])
|
||||
const maxX = Math.max(roofDraftStart[0], roofDraftEnd[0])
|
||||
const minY = Math.min(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) {
|
||||
return formatPolygonPoints([
|
||||
{ x: minX, y: minY },
|
||||
{ x: maxX, y: minY },
|
||||
{ x: maxX, y: maxY },
|
||||
{ x: minX, y: maxY },
|
||||
])
|
||||
}
|
||||
if (Math.abs(maxX - minX) >= 1e-6 || Math.abs(maxY - minY) >= 1e-6) {
|
||||
return formatPolygonPoints([
|
||||
{ x: minX, y: minY },
|
||||
{ x: maxX, y: minY },
|
||||
{ x: maxX, y: maxY },
|
||||
{ x: minX, y: maxY },
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
return draftPolygon ? formatPolygonPoints(draftPolygon) : null
|
||||
},
|
||||
[draftPolygon, isRoofBuildActive, roofDraftEnd, roofDraftStart],
|
||||
)
|
||||
return draftPolygon ? formatPolygonPoints(draftPolygon) : null
|
||||
}, [draftPolygon, isRoofBuildActive, roofDraftEnd, roofDraftStart])
|
||||
const fenceDraftSegment = useMemo(() => {
|
||||
if (!(isFenceBuildActive && fenceDraftStart && fenceDraftEnd)) {
|
||||
return null
|
||||
@@ -6931,7 +6996,9 @@ export function FloorplanPanel() {
|
||||
if (levelChanged) {
|
||||
previousLevelIdRef.current = levelId ?? null
|
||||
hasUserAdjustedViewportRef.current = false
|
||||
setViewport((current) => (floorplanViewportEquals(current, fittedViewport) ? current : fittedViewport))
|
||||
setViewport((current) =>
|
||||
floorplanViewportEquals(current, fittedViewport) ? current : fittedViewport,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -6949,7 +7016,9 @@ export function FloorplanPanel() {
|
||||
isPolygonDraftBuildActive
|
||||
|
||||
if (!hasUserAdjustedViewportRef.current && !transientFloorplanFit) {
|
||||
setViewport((current) => (floorplanViewportEquals(current, fittedViewport) ? current : fittedViewport))
|
||||
setViewport((current) =>
|
||||
floorplanViewportEquals(current, fittedViewport) ? current : fittedViewport,
|
||||
)
|
||||
}
|
||||
}, [
|
||||
curvingFence,
|
||||
@@ -7044,7 +7113,11 @@ export function FloorplanPanel() {
|
||||
const selectedStairActionMenuPosition = useMemo(
|
||||
() =>
|
||||
selectedStairEntry
|
||||
? getFloorplanActionMenuPosition(selectedStairEntry.hitPolygons.flat(), viewBox, surfaceSize)
|
||||
? getFloorplanActionMenuPosition(
|
||||
selectedStairEntry.hitPolygons.flat(),
|
||||
viewBox,
|
||||
surfaceSize,
|
||||
)
|
||||
: null,
|
||||
[selectedStairEntry, surfaceSize, viewBox],
|
||||
)
|
||||
@@ -7611,12 +7684,7 @@ export function FloorplanPanel() {
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
isWallBuildActive ||
|
||||
isFenceBuildActive ||
|
||||
isRoofBuildActive ||
|
||||
isPolygonDraftBuildActive
|
||||
) {
|
||||
if (isWallBuildActive || isFenceBuildActive || isRoofBuildActive || isPolygonDraftBuildActive) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -8130,7 +8198,10 @@ export function FloorplanPanel() {
|
||||
const currentWall = wallById.get(update.id)
|
||||
return (
|
||||
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) {
|
||||
setRoofDraftEnd((previousPoint) =>
|
||||
previousPoint && pointsEqual(previousPoint, snappedPoint) ? previousPoint : snappedPoint,
|
||||
previousPoint && pointsEqual(previousPoint, snappedPoint)
|
||||
? previousPoint
|
||||
: snappedPoint,
|
||||
)
|
||||
}
|
||||
return
|
||||
@@ -8938,11 +9011,7 @@ export function FloorplanPanel() {
|
||||
}
|
||||
|
||||
const firstPoint = ceilingDraftPoints[0]
|
||||
if (
|
||||
firstPoint &&
|
||||
ceilingDraftPoints.length >= 3 &&
|
||||
isPointNearPlanPoint(point, firstPoint)
|
||||
) {
|
||||
if (firstPoint && ceilingDraftPoints.length >= 3 && isPointNearPlanPoint(point, firstPoint)) {
|
||||
clearCeilingPlacementDraft()
|
||||
return
|
||||
}
|
||||
@@ -9167,7 +9236,6 @@ export function FloorplanPanel() {
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
},
|
||||
[
|
||||
draftStart,
|
||||
@@ -9511,21 +9579,24 @@ export function FloorplanPanel() {
|
||||
},
|
||||
[setSelectedReferenceId],
|
||||
)
|
||||
const handleFenceDoubleClick = useCallback((fence: FenceNode, event: ReactMouseEvent<SVGElement>) => {
|
||||
const centerX = (fence.start[0] + fence.end[0]) / 2
|
||||
const centerZ = (fence.start[1] + fence.end[1]) / 2
|
||||
const halfLength =
|
||||
Math.hypot(fence.end[0] - fence.start[0], fence.end[1] - fence.start[1]) / 2
|
||||
const handleFenceDoubleClick = useCallback(
|
||||
(fence: FenceNode, event: ReactMouseEvent<SVGElement>) => {
|
||||
const centerX = (fence.start[0] + fence.end[0]) / 2
|
||||
const centerZ = (fence.start[1] + fence.end[1]) / 2
|
||||
const halfLength =
|
||||
Math.hypot(fence.end[0] - fence.start[0], fence.end[1] - fence.start[1]) / 2
|
||||
|
||||
emitter.emit('fence:double-click', {
|
||||
node: fence,
|
||||
position: [centerX, 0, centerZ],
|
||||
localPosition: [halfLength, 0, 0],
|
||||
stopPropagation: () => event.stopPropagation(),
|
||||
nativeEvent: event.nativeEvent as any,
|
||||
} as any)
|
||||
emitter.emit('camera-controls:focus', { nodeId: fence.id })
|
||||
}, [])
|
||||
emitter.emit('fence:double-click', {
|
||||
node: fence,
|
||||
position: [centerX, 0, centerZ],
|
||||
localPosition: [halfLength, 0, 0],
|
||||
stopPropagation: () => event.stopPropagation(),
|
||||
nativeEvent: event.nativeEvent as any,
|
||||
} as any)
|
||||
emitter.emit('camera-controls:focus', { nodeId: fence.id })
|
||||
},
|
||||
[],
|
||||
)
|
||||
const emitFloorplanNodeClick = useCallback(
|
||||
(
|
||||
nodeId:
|
||||
|
||||
@@ -1076,7 +1076,13 @@ export default function Editor({
|
||||
return <Component />
|
||||
}
|
||||
|
||||
const tabBarTabs = sidebarTabs?.map(({ id, label }) => ({ id, label })) ?? []
|
||||
const tabBarTabs =
|
||||
sidebarTabs?.map(({ id, label, mobileDefaultSnap, mobileIcon }) => ({
|
||||
id,
|
||||
label,
|
||||
mobileDefaultSnap,
|
||||
mobileIcon,
|
||||
})) ?? []
|
||||
|
||||
return (
|
||||
<PresetsProvider adapter={presetsAdapter}>
|
||||
|
||||
@@ -265,18 +265,49 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
|
||||
)) as Uint8Array
|
||||
|
||||
const actualBytesPerRow = width * 4
|
||||
const tightTotal = actualBytesPerRow * height
|
||||
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
|
||||
if (paddedBytesPerRow === actualBytesPerRow) {
|
||||
tightPixels = new Uint8ClampedArray(pixels.buffer, pixels.byteOffset, pixels.byteLength)
|
||||
if (isWebGPU) {
|
||||
// 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 {
|
||||
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++) {
|
||||
const srcStart = (height - 1 - row) * actualBytesPerRow
|
||||
tightPixels.set(
|
||||
pixels.subarray(
|
||||
row * paddedBytesPerRow,
|
||||
row * paddedBytesPerRow + actualBytesPerRow,
|
||||
),
|
||||
pixels.subarray(srcStart, srcStart + actualBytesPerRow),
|
||||
row * actualBytesPerRow,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { emitter } from '@pascal-app/core'
|
||||
import Image from 'next/image'
|
||||
import { ActionButton } from './action-button'
|
||||
|
||||
export function CameraActions() {
|
||||
export function CameraActions({ hideOrbit = false }: { hideOrbit?: boolean }) {
|
||||
const goToTopView = () => {
|
||||
emitter.emit('camera-controls:top-view')
|
||||
}
|
||||
@@ -19,39 +19,43 @@ export function CameraActions() {
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Orbit CCW */}
|
||||
<ActionButton
|
||||
className="group hover:bg-white/5"
|
||||
label="Orbit Left"
|
||||
onClick={orbitCCW}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<Image
|
||||
alt="Orbit Left"
|
||||
className="h-[28px] w-[28px] -scale-x-100 object-contain opacity-70 transition-opacity group-hover:opacity-100"
|
||||
height={28}
|
||||
src="/icons/rotate.png"
|
||||
width={28}
|
||||
/>
|
||||
</ActionButton>
|
||||
{!hideOrbit && (
|
||||
<>
|
||||
{/* Orbit CCW */}
|
||||
<ActionButton
|
||||
className="group hover:bg-white/5"
|
||||
label="Orbit Left"
|
||||
onClick={orbitCCW}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<Image
|
||||
alt="Orbit Left"
|
||||
className="h-[28px] w-[28px] -scale-x-100 object-contain opacity-70 transition-opacity group-hover:opacity-100"
|
||||
height={28}
|
||||
src="/icons/rotate.png"
|
||||
width={28}
|
||||
/>
|
||||
</ActionButton>
|
||||
|
||||
{/* Orbit CW */}
|
||||
<ActionButton
|
||||
className="group hover:bg-white/5"
|
||||
label="Orbit Right"
|
||||
onClick={orbitCW}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<Image
|
||||
alt="Orbit Right"
|
||||
className="h-[28px] w-[28px] object-contain opacity-70 transition-opacity group-hover:opacity-100"
|
||||
height={28}
|
||||
src="/icons/rotate.png"
|
||||
width={28}
|
||||
/>
|
||||
</ActionButton>
|
||||
{/* Orbit CW */}
|
||||
<ActionButton
|
||||
className="group hover:bg-white/5"
|
||||
label="Orbit Right"
|
||||
onClick={orbitCW}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<Image
|
||||
alt="Orbit Right"
|
||||
className="h-[28px] w-[28px] object-contain opacity-70 transition-opacity group-hover:opacity-100"
|
||||
height={28}
|
||||
src="/icons/rotate.png"
|
||||
width={28}
|
||||
/>
|
||||
</ActionButton>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Top View */}
|
||||
<ActionButton
|
||||
|
||||
@@ -4,9 +4,10 @@ import { useScene } from '@pascal-app/core'
|
||||
import { AnimatePresence, motion } from 'motion/react'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
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 { MaterialPicker } from './../../../components/ui/controls/material-picker'
|
||||
import { useReducedMotion } from './../../../hooks/use-reduced-motion'
|
||||
import { resolvePaintTargetFromSelection } from './../../../lib/material-paint'
|
||||
import { cn } from './../../../lib/utils'
|
||||
import useEditor from './../../../store/use-editor'
|
||||
@@ -58,8 +59,23 @@ export function ActionMenu({ className }: { className?: string }) {
|
||||
const mode = useEditor((state) => state.mode)
|
||||
const tool = useEditor((state) => state.tool)
|
||||
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 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
|
||||
? { duration: 0 }
|
||||
: { type: 'spring' as const, bounce: 0.2, duration: 0.4 }
|
||||
|
||||
@@ -8,17 +8,24 @@ import {
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
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 { useShallow } from 'zustand/react/shallow'
|
||||
import { cn } from '../../../lib/utils'
|
||||
import useEditor, { type GridSnapStep } from '../../../store/use-editor'
|
||||
import { useUploadStore } from '../../../store/use-upload'
|
||||
import { SliderControl } from '../controls/slider-control'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '../primitives/popover'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../primitives/tooltip'
|
||||
import { ActionButton } from './action-button'
|
||||
|
||||
const MAX_FILE_SIZE = 200 * 1024 * 1024 // 200MB
|
||||
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 ──────────────────────────
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────────
|
||||
|
||||
function ScansControl() {
|
||||
@@ -395,3 +479,14 @@ export function ViewToggles() {
|
||||
</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)))
|
||||
}
|
||||
|
||||
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(
|
||||
baseStep: number,
|
||||
modifiers: {
|
||||
@@ -30,9 +41,7 @@ function getAdjustedStep(
|
||||
altKey?: boolean
|
||||
},
|
||||
): number {
|
||||
if (modifiers.shiftKey) return baseStep * 10
|
||||
if (modifiers.metaKey || modifiers.ctrlKey || modifiers.altKey) return baseStep * 0.1
|
||||
return baseStep
|
||||
return baseStep * getStepMultiplier(modifiers)
|
||||
}
|
||||
|
||||
export function SliderControl({
|
||||
@@ -51,7 +60,17 @@ export function SliderControl({
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
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 valueRef = useRef(value)
|
||||
valueRef.current = value
|
||||
@@ -105,7 +124,12 @@ export function SliderControl({
|
||||
if (isEditing) return
|
||||
e.preventDefault()
|
||||
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)
|
||||
useScene.temporal.getState().pause()
|
||||
},
|
||||
@@ -115,12 +139,23 @@ export function SliderControl({
|
||||
const handleLabelPointerMove = useCallback(
|
||||
(e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!dragRef.current) return
|
||||
const { startX, startValue } = dragRef.current
|
||||
const dx = e.clientX - startX
|
||||
const s = getAdjustedStep(step, e)
|
||||
const multiplier = getStepMultiplier(e)
|
||||
// If modifier keys changed mid-drag, re-anchor from the current pointer
|
||||
// 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
|
||||
const newValue = clamp(
|
||||
Number.parseFloat((startValue + (dx / 4) * s).toFixed(stepPrecision(s))),
|
||||
Number.parseFloat((anchorValue + (dx / 4) * s).toFixed(stepPrecision(s))),
|
||||
)
|
||||
onChange(newValue)
|
||||
},
|
||||
@@ -130,14 +165,14 @@ export function SliderControl({
|
||||
const handleLabelPointerUp = useCallback(
|
||||
(e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!dragRef.current) return
|
||||
const { startValue } = dragRef.current
|
||||
const { originValue } = dragRef.current
|
||||
const finalVal = valueRef.current
|
||||
dragRef.current = null
|
||||
setIsDragging(false)
|
||||
e.currentTarget.releasePointerCapture(e.pointerId)
|
||||
|
||||
if (startValue !== finalVal) {
|
||||
onChange(startValue)
|
||||
if (originValue !== finalVal) {
|
||||
onChange(originValue)
|
||||
useScene.temporal.getState().resume()
|
||||
onChange(finalVal)
|
||||
} 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'
|
||||
|
||||
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 { useCallback, useEffect, useState } from 'react'
|
||||
import { useIsMobile } from '../../../hooks/use-mobile'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
import { CeilingPanel } from './ceiling-panel'
|
||||
import { DoorPanel } from './door-panel'
|
||||
import { FencePanel } from './fence-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 { ReferencePanel } from './reference-panel'
|
||||
import { RoofPanel } from './roof-panel'
|
||||
@@ -17,7 +39,152 @@ import { StairSegmentPanel } from './stair-segment-panel'
|
||||
import { WallPanel } from './wall-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() {
|
||||
const isMobile = useIsMobile()
|
||||
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
||||
const selectedReferenceId = useEditor((s) => s.selectedReferenceId)
|
||||
const isPaintPanelOpen = useEditor((s) => s.isPaintPanelOpen)
|
||||
@@ -30,6 +197,24 @@ export function PanelManager() {
|
||||
const id = selectedIds[0]
|
||||
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
|
||||
if (selectedReferenceId) {
|
||||
@@ -46,32 +231,5 @@ export function PanelManager() {
|
||||
}
|
||||
|
||||
// Show appropriate panel based on selected node type
|
||||
if (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
|
||||
return panelForType(selectedNodeType)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { ChevronLeft, RotateCcw, X } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import { useIsMobile } from '../../../hooks/use-mobile'
|
||||
import { cn } from '../../../lib/utils'
|
||||
|
||||
interface PanelWrapperProps {
|
||||
@@ -25,53 +26,61 @@ export function PanelWrapper({
|
||||
className,
|
||||
width = 320, // default width
|
||||
}: PanelWrapperProps) {
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
return (
|
||||
<div
|
||||
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,
|
||||
)}
|
||||
style={{ width }}
|
||||
style={isMobile ? undefined : { width }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-border/50 border-b px-3 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{onBack && (
|
||||
<button
|
||||
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"
|
||||
onClick={onBack}
|
||||
type="button"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
{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>
|
||||
{/* Header — desktop only; mobile sheet provides its own header */}
|
||||
{!isMobile && (
|
||||
<div className="flex items-center justify-between border-border/50 border-b px-3 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{onBack && (
|
||||
<button
|
||||
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"
|
||||
onClick={onBack}
|
||||
type="button"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
{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>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{onReset && (
|
||||
<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"
|
||||
onClick={onReset}
|
||||
type="button"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
{onClose && (
|
||||
<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"
|
||||
onClick={onClose}
|
||||
type="button"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
<div className="flex items-center gap-1">
|
||||
{onReset && (
|
||||
<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"
|
||||
onClick={onReset}
|
||||
type="button"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
{onClose && (
|
||||
<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"
|
||||
onClick={onClose}
|
||||
type="button"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<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'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn } from './../../../lib/utils'
|
||||
|
||||
export type SidebarTab = {
|
||||
id: string
|
||||
label: string
|
||||
mobileDefaultSnap?: number
|
||||
mobileIcon?: ReactNode
|
||||
}
|
||||
|
||||
interface TabBarProps {
|
||||
|
||||
@@ -2,18 +2,18 @@ import * as React from 'react'
|
||||
|
||||
const MOBILE_BREAKPOINT = 768
|
||||
|
||||
export function useIsMobile() {
|
||||
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
|
||||
const subscribe = (callback: () => void): (() => void) => {
|
||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
|
||||
mql.addEventListener('change', callback)
|
||||
return () => mql.removeEventListener('change', callback)
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
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)
|
||||
}, [])
|
||||
const getClientSnapshot = (): boolean => window.innerWidth < MOBILE_BREAKPOINT
|
||||
|
||||
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 [width, , depth] = getScaledDimensions(item)
|
||||
|
||||
return {
|
||||
dimensionPolygon,
|
||||
item,
|
||||
polygon: realMeshPolygon,
|
||||
usesRealMesh: realMeshPolygon !== null,
|
||||
center: transform.position,
|
||||
rotation: transform.rotation,
|
||||
width,
|
||||
depth,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -389,7 +394,10 @@ function getConvexHull(points: Point[]) {
|
||||
|
||||
const lower: Point[] = []
|
||||
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.push(point)
|
||||
@@ -398,7 +406,10 @@ function getConvexHull(points: Point[]) {
|
||||
const upper: Point[] = []
|
||||
for (let index = uniquePoints.length - 1; index >= 0; index -= 1) {
|
||||
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.push(point)
|
||||
|
||||
@@ -15,6 +15,10 @@ export type FloorplanItemEntry = {
|
||||
item: ItemNode
|
||||
polygon: Point2D[]
|
||||
usesRealMesh: boolean
|
||||
center: Point2D
|
||||
rotation: number
|
||||
width: number
|
||||
depth: number
|
||||
}
|
||||
|
||||
export type FloorplanStairSegmentEntry = {
|
||||
|
||||
@@ -206,6 +206,10 @@ type EditorState = {
|
||||
setAllowUndergroundCamera: (enabled: boolean) => void
|
||||
activeSidebarPanel: string
|
||||
setActiveSidebarPanel: (id: string) => void
|
||||
mobilePanelSheetHeight: number
|
||||
setMobilePanelSheetHeight: (height: number) => void
|
||||
isCaptureMode: boolean
|
||||
setIsCaptureMode: (enabled: boolean) => void
|
||||
floorplanPaneRatio: number
|
||||
setFloorplanPaneRatio: (ratio: number) => void
|
||||
}
|
||||
@@ -658,6 +662,10 @@ const useEditor = create<EditorState>()(
|
||||
},
|
||||
activeSidebarPanel: DEFAULT_ACTIVE_SIDEBAR_PANEL,
|
||||
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,
|
||||
setFloorplanPaneRatio: (ratio) =>
|
||||
set({ floorplanPaneRatio: normalizeFloorplanPaneRatio(ratio) }),
|
||||
|
||||
@@ -1,15 +1,25 @@
|
||||
import type { ErrorInfo, ReactNode } from 'react'
|
||||
import { Component } from 'react'
|
||||
|
||||
export class ErrorBoundary extends Component<
|
||||
{ children: ReactNode; fallback: ReactNode },
|
||||
{ hasError: boolean }
|
||||
> {
|
||||
interface ErrorBoundaryProps {
|
||||
children: ReactNode
|
||||
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 }
|
||||
static getDerivedStateFromError() {
|
||||
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() {
|
||||
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 { WallCutout } from '../../systems/wall/wall-cutout'
|
||||
import { ZoneSystem } from '../../systems/zone/zone-system'
|
||||
import { ErrorBoundary } from '../error-boundary'
|
||||
import { SceneRenderer } from '../renderers/scene-renderer'
|
||||
import FrameLimiter from './frame-limiter'
|
||||
import { Lights } from './lights'
|
||||
@@ -78,6 +79,10 @@ type WebGPUDeviceLossInfo = {
|
||||
|
||||
type WebGPUDeviceLike = {
|
||||
lost: Promise<WebGPUDeviceLossInfo>
|
||||
label?: string
|
||||
features?: Set<string>
|
||||
addEventListener?: (type: string, listener: EventListener) => void
|
||||
removeEventListener?: (type: string, listener: EventListener) => void
|
||||
}
|
||||
|
||||
function GPUDeviceWatcher() {
|
||||
@@ -87,7 +92,18 @@ function GPUDeviceWatcher() {
|
||||
const backend = (gl as any).backend
|
||||
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) => {
|
||||
console.error(
|
||||
@@ -95,6 +111,17 @@ function GPUDeviceWatcher() {
|
||||
'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])
|
||||
|
||||
return null
|
||||
@@ -121,17 +148,34 @@ const Viewer: React.FC<ViewerProps> = ({
|
||||
dpr={[1, 1.5]}
|
||||
frameloop="never"
|
||||
gl={async (props) => {
|
||||
const renderer = new THREE.WebGPURenderer(props as any)
|
||||
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()
|
||||
return renderer
|
||||
try {
|
||||
// Surface the env we're about to ask WebGPU for — catches "no
|
||||
// navigator.gpu" / "adapter request failed" silently failing in
|
||||
// mobile WebViews where WebGPU is gated behind flags.
|
||||
const hasGpu = typeof navigator !== 'undefined' && 'gpu' in navigator
|
||||
console.log('[viewer] Creating WebGPURenderer', {
|
||||
hasNavigatorGPU: hasGpu,
|
||||
ua: typeof navigator !== 'undefined' ? navigator.userAgent : 'n/a',
|
||||
})
|
||||
const renderer = new THREE.WebGPURenderer(props as any)
|
||||
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={{
|
||||
debounce: 100,
|
||||
@@ -144,39 +188,41 @@ const Viewer: React.FC<ViewerProps> = ({
|
||||
<FrameLimiter fps={50} />
|
||||
{/* <AnimatedBackground isDark={theme === 'dark'} /> */}
|
||||
<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 />
|
||||
|
||||
<ItemLightSystem />
|
||||
<ItemMeshMetadataSystem />
|
||||
{selectionManager === 'default' && <SelectionManager />}
|
||||
{perf && <PerfMonitor />}
|
||||
{children}
|
||||
<ErrorBoundary fallback={null} scope="viewer-scene">
|
||||
{/* <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 /> */}
|
||||
|
||||
<ItemLightSystem />
|
||||
<ItemMeshMetadataSystem />
|
||||
{selectionManager === 'default' && <SelectionManager />}
|
||||
{perf && <PerfMonitor />}
|
||||
{children}
|
||||
</ErrorBoundary>
|
||||
</Canvas>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -174,9 +174,22 @@ const PostProcessingPasses = ({
|
||||
void pipelineVersion
|
||||
|
||||
if (!(renderer && scene && camera)) {
|
||||
console.warn('[viewer/post-processing] Skipping pipeline build — missing dependency.', {
|
||||
hasRenderer: !!renderer,
|
||||
hasScene: !!scene,
|
||||
hasCamera: !!camera,
|
||||
})
|
||||
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
|
||||
|
||||
// WebGPU availability check: SSGI, denoise, and RenderPipeline are all
|
||||
@@ -318,10 +331,16 @@ const PostProcessingPasses = ({
|
||||
renderPipeline.outputNode = finalOutput
|
||||
renderPipelineRef.current = renderPipeline
|
||||
retryCountRef.current = 0
|
||||
console.log('[viewer/post-processing] Pipeline built OK', { version: pipelineVersion })
|
||||
} catch (error) {
|
||||
hasPipelineErrorRef.current = true
|
||||
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,
|
||||
)
|
||||
if (renderPipelineRef.current) {
|
||||
@@ -366,7 +385,7 @@ const PostProcessingPasses = ({
|
||||
}
|
||||
;(renderer as any).render(scene, camera)
|
||||
} catch (fallbackError) {
|
||||
console.error('[viewer] Fallback render failed.', fallbackError)
|
||||
console.error('[viewer/post-processing] Fallback render failed.', fallbackError)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -378,7 +397,11 @@ const PostProcessingPasses = ({
|
||||
renderPipelineRef.current.render()
|
||||
} catch (error) {
|
||||
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) {
|
||||
renderPipelineRef.current.dispose()
|
||||
}
|
||||
@@ -388,7 +411,7 @@ const PostProcessingPasses = ({
|
||||
// Auto-retry: schedule a pipeline rebuild if we haven't exceeded the retry limit
|
||||
retryCountRef.current++
|
||||
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) {
|
||||
clearTimeout(rebuildTimeoutRef.current)
|
||||
@@ -396,7 +419,7 @@ const PostProcessingPasses = ({
|
||||
rebuildTimeoutRef.current = setTimeout(requestPipelineRebuild, RETRY_DELAY_MS)
|
||||
} else {
|
||||
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 _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 _textureNodeB: any
|
||||
|
||||
@@ -294,6 +302,17 @@ export class MergedOutlineNode extends TempNode {
|
||||
updateBefore(frame: any) {
|
||||
const hasPrimary = this.primaryObjects.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 { camera, scene } = this
|
||||
@@ -303,24 +322,27 @@ export class MergedOutlineNode extends TempNode {
|
||||
const size = renderer.getDrawingBufferSize(_size)
|
||||
this.setSize(size.width, size.height)
|
||||
|
||||
// Clear composites for inactive groups so stale outlines don't persist on GPU.
|
||||
// Must happen inside resetRendererAndSceneState to avoid MSAA state corruption.
|
||||
if (!hasPrimary) {
|
||||
// Clear composites for groups that just transitioned from "has content"
|
||||
// to "empty" — without this, the previous outline lingers on the GPU.
|
||||
if (needsCleanupA) {
|
||||
renderer.setRenderTarget(this._groupA.composite)
|
||||
renderer.clearColor()
|
||||
this._wroteGroupALastFrame = false
|
||||
}
|
||||
if (!hasSecondary) {
|
||||
if (needsCleanupB) {
|
||||
renderer.setRenderTarget(this._groupB.composite)
|
||||
renderer.clearColor()
|
||||
this._wroteGroupBLastFrame = false
|
||||
}
|
||||
|
||||
const hasAny = hasPrimary || hasSecondary
|
||||
if (!hasAny) {
|
||||
RendererUtils.restoreRendererAndSceneState(renderer, scene, _rendererState)
|
||||
return
|
||||
}
|
||||
|
||||
renderer.setClearColor(0xff_ff_ff, 1)
|
||||
this._wroteGroupALastFrame = hasPrimary
|
||||
this._wroteGroupBLastFrame = hasSecondary
|
||||
|
||||
if (hasPrimary) this._buildCache(this.primaryObjects, this._cacheA)
|
||||
if (hasSecondary) this._buildCache(this.secondaryObjects, this._cacheB)
|
||||
|
||||
Reference in New Issue
Block a user