feat: backport monorepo PRs #297, #302, #306 — floorplan thumbnails + mobile editor UI

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:
Aymeric Rabot
2026-04-28 06:36:08 -07:00
committed by GitHub
co-authored by Pascal
parent 010c874963
commit aa43bb6a43
27 changed files with 1710 additions and 386 deletions
+3
View File
@@ -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,6 +4315,16 @@ const FloorplanNodeLayer = memo(function FloorplanNodeLayer({
strokeOpacity={1}
strokeWidth={FLOORPLAN_NODE_FOOTPRINT_STROKE_WIDTH}
/>
{floorPlanUrl ? (
<FloorplanItemImage
center={center}
depth={depth}
rotation={rotation}
url={floorPlanUrl}
width={width}
/>
) : (
<>
{diagonalAStart && diagonalAEnd && (
<line
pointerEvents="none"
@@ -4311,6 +4351,8 @@ const FloorplanNodeLayer = memo(function FloorplanNodeLayer({
y2={toSvgY(diagonalBEnd.y)}
/>
)}
</>
)}
{itemDimensionMeasurements.length > 0 ? (
<FloorplanMeasurementsLayer
className="item-dimension-measurement"
@@ -5571,8 +5613,7 @@ 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
const displayFence = live
? {
...fence,
start: [
@@ -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,8 +5958,7 @@ export function FloorplanPanel() {
buildingRotationY,
)
: null
const displayRoof =
liveRoofTransform
const displayRoof = liveRoofTransform
? {
...roof,
position: [
@@ -6046,12 +6096,10 @@ export function FloorplanPanel() {
}
}
let bestHit:
| {
let bestHit: {
point: Point2D
distance: number
}
| null = null
} | 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,8 +6790,7 @@ 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(
() => {
const draftPolygonPoints = useMemo(() => {
if (isRoofBuildActive && roofDraftStart && roofDraftEnd) {
const minX = Math.min(roofDraftStart[0], roofDraftEnd[0])
const maxX = Math.max(roofDraftStart[0], roofDraftEnd[0])
@@ -6741,9 +6808,7 @@ export function FloorplanPanel() {
}
return draftPolygon ? formatPolygonPoints(draftPolygon) : null
},
[draftPolygon, isRoofBuildActive, roofDraftEnd, roofDraftStart],
)
}, [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,7 +9579,8 @@ export function FloorplanPanel() {
},
[setSelectedReferenceId],
)
const handleFenceDoubleClick = useCallback((fence: FenceNode, event: ReactMouseEvent<SVGElement>) => {
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 =
@@ -9525,7 +9594,9 @@ export function FloorplanPanel() {
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,12 +265,32 @@ 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 (isWebGPU) {
// WebGPU: depad rows if needed; orientation is already top-down.
if (paddedBytesPerRow === actualBytesPerRow) {
tightPixels = new Uint8ClampedArray(pixels.buffer, pixels.byteOffset, pixels.byteLength)
tightPixels = new Uint8ClampedArray(
pixels.buffer,
pixels.byteOffset,
Math.min(pixels.byteLength, tightTotal),
)
} else {
tightPixels = new Uint8ClampedArray(width * height * 4)
tightPixels = new Uint8ClampedArray(tightTotal)
for (let row = 0; row < height; row++) {
tightPixels.set(
pixels.subarray(
@@ -281,6 +301,17 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
)
}
}
} else {
// 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(srcStart, srcStart + actualBytesPerRow),
row * actualBytesPerRow,
)
}
}
const imageData = new ImageData(
tightPixels as unknown as Uint8ClampedArray<ArrayBuffer>,
@@ -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,6 +19,8 @@ export function CameraActions() {
return (
<div className="flex items-center gap-1">
{!hideOrbit && (
<>
{/* Orbit CCW */}
<ActionButton
className="group hover:bg-white/5"
@@ -52,6 +54,8 @@ export function CameraActions() {
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,37 +39,42 @@ import { StairSegmentPanel } from './stair-segment-panel'
import { WallPanel } from './wall-panel'
import { WindowPanel } from './window-panel'
export function PanelManager() {
const selectedIds = useViewer((s) => s.selection.selectedIds)
const selectedReferenceId = useEditor((s) => s.selectedReferenceId)
const isPaintPanelOpen = useEditor((s) => s.isPaintPanelOpen)
const mode = useEditor((s) => s.mode)
const activePaintMaterial = useEditor((s) => s.activePaintMaterial)
// Only subscribe to the *type* of the single-selected node — string primitive
// so we don't re-render on unrelated scene mutations.
const selectedNodeType = useScene((s) => {
if (selectedIds.length !== 1) return null
const id = selectedIds[0]
return id ? (s.nodes[id as AnyNodeId]?.type ?? null) : null
})
type MovableNode =
| ItemNode
| WindowNode
| DoorNode
| CeilingNode
| SlabNode
| WallNode
| FenceNode
| RoofNode
| RoofSegmentNode
| StairNode
| StairSegmentNode
| BuildingNode
// Show reference panel if a reference is selected
if (selectedReferenceId) {
return <ReferencePanel />
}
const MOVABLE_TYPES = new Set<string>([
'item',
'window',
'door',
'ceiling',
'slab',
'wall',
'fence',
'roof',
'roof-segment',
'stair',
'stair-segment',
'building',
])
if (
isPaintPanelOpen &&
mode === 'material-paint' &&
activePaintMaterial?.material?.properties &&
!activePaintMaterial.materialPreset
) {
return <PaintPanel />
}
function isMovableNode(node: AnyNode | null): node is MovableNode {
return !!node && MOVABLE_TYPES.has(node.type)
}
// Show appropriate panel based on selected node type
if (selectedNodeType) {
switch (selectedNodeType) {
function panelForType(type: string | null) {
if (!type) return null
switch (type) {
case 'item':
return <ItemPanel />
case 'roof':
@@ -70,8 +97,139 @@ export function PanelManager() {
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)
const mode = useEditor((s) => s.mode)
const activePaintMaterial = useEditor((s) => s.activePaintMaterial)
// Only subscribe to the *type* of the single-selected node — string primitive
// so we don't re-render on unrelated scene mutations.
const selectedNodeType = useScene((s) => {
if (selectedIds.length !== 1) return null
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)}
/>
)
}
return null
// Show reference panel if a reference is selected
if (selectedReferenceId) {
return <ReferencePanel />
}
if (
isPaintPanelOpen &&
mode === 'material-paint' &&
activePaintMaterial?.material?.properties &&
!activePaintMaterial.materialPreset
) {
return <PaintPanel />
}
// Show appropriate panel based on selected node type
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,15 +26,20 @@ 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 */}
{/* 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 && (
@@ -48,7 +54,9 @@ export function PanelWrapper({
{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>
<h2 className="truncate font-semibold text-foreground text-sm tracking-tight">
{title}
</h2>
</div>
<div className="flex items-center gap-1">
@@ -72,6 +80,7 @@ export function PanelWrapper({
)}
</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
// (50100px) 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 {
+12 -12
View File
@@ -2,18 +2,18 @@ import * as React from 'react'
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
React.useEffect(() => {
const subscribe = (callback: () => void): (() => void) => {
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)
}, [])
mql.addEventListener('change', callback)
return () => mql.removeEventListener('change', callback)
}
return !!isMobile
const getClientSnapshot = (): boolean => window.innerWidth < MOBILE_BREAKPOINT
// 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)
}
+13 -2
View File
@@ -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 = {
+8
View File
@@ -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,6 +148,15 @@ const Viewer: React.FC<ViewerProps> = ({
dpr={[1, 1.5]}
frameloop="never"
gl={async (props) => {
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
@@ -131,7 +167,15 @@ const Viewer: React.FC<ViewerProps> = ({
// 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,7 +188,9 @@ const Viewer: React.FC<ViewerProps> = ({
<FrameLimiter fps={50} />
{/* <AnimatedBackground isDark={theme === 'dark'} /> */}
<ViewerCamera />
<GPUDeviceWatcher />
<ErrorBoundary fallback={null} scope="viewer-scene">
{/* <directionalLight position={[10, 10, 5]} intensity={0.5} castShadow
/> */}
<Lights />
@@ -170,13 +216,13 @@ const Viewer: React.FC<ViewerProps> = ({
<ZoneSystem />
<PostProcessing hoverStyles={hoverStyles} />
{/* <DebugRenderer /> */}
<GPUDeviceWatcher />
<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.',
)
}
}
+27 -5
View File
@@ -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)