From 95f3911ea4cff7d0533d5a4605f729bc6a6c0ea2 Mon Sep 17 00:00:00 2001 From: Pascal Date: Tue, 28 Apr 2026 13:10:35 +0000 Subject: [PATCH] feat: backport monorepo PRs #297, #302, #306 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) --- packages/core/src/schema/nodes/item.ts | 3 + .../src/components/editor/bottom-sheet.tsx | 149 ++++++ .../editor/custom-camera-controls.tsx | 44 ++ .../editor/editor-layout-mobile.tsx | 264 ++++++++++ .../components/editor/editor-layout-v2.tsx | 20 + .../src/components/editor/floorplan-panel.tsx | 457 ++++++++++-------- .../editor/src/components/editor/index.tsx | 8 +- .../components/editor/thumbnail-generator.tsx | 45 +- .../ui/action-menu/camera-actions.tsx | 70 +-- .../src/components/ui/action-menu/index.tsx | 18 +- .../ui/action-menu/view-toggles.tsx | 97 +++- .../components/ui/controls/slider-control.tsx | 59 ++- .../ui/panels/mobile-panel-sheet.tsx | 108 +++++ .../ui/panels/mobile-selection-bar.tsx | 100 ++++ .../src/components/ui/panels/node-display.ts | 38 ++ .../components/ui/panels/panel-manager.tsx | 216 +++++++-- .../components/ui/panels/panel-wrapper.tsx | 87 ++-- .../components/ui/sidebar/mobile-tab-bar.tsx | 46 ++ .../src/components/ui/sidebar/tab-bar.tsx | 3 + packages/editor/src/hooks/use-mobile.ts | 24 +- packages/editor/src/lib/floorplan/items.ts | 15 +- packages/editor/src/lib/floorplan/types.ts | 4 + packages/editor/src/store/use-editor.tsx | 8 + .../viewer/src/components/error-boundary.tsx | 20 +- .../viewer/src/components/viewer/index.tsx | 132 +++-- .../src/components/viewer/post-processing.tsx | 33 +- .../viewer/src/lib/merged-outline-node.ts | 32 +- 27 files changed, 1712 insertions(+), 388 deletions(-) create mode 100644 packages/editor/src/components/editor/bottom-sheet.tsx create mode 100644 packages/editor/src/components/editor/editor-layout-mobile.tsx create mode 100644 packages/editor/src/components/ui/panels/mobile-panel-sheet.tsx create mode 100644 packages/editor/src/components/ui/panels/mobile-selection-bar.tsx create mode 100644 packages/editor/src/components/ui/panels/node-display.ts create mode 100644 packages/editor/src/components/ui/sidebar/mobile-tab-bar.tsx diff --git a/packages/core/src/schema/nodes/item.ts b/packages/core/src/schema/nodes/item.ts index 1d980da5d..160e724c8 100644 --- a/packages/core/src/schema/nodes/item.ts +++ b/packages/core/src/schema/nodes/item.ts @@ -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(), diff --git a/packages/editor/src/components/editor/bottom-sheet.tsx b/packages/editor/src/components/editor/bottom-sheet.tsx new file mode 100644 index 000000000..29f822d72 --- /dev/null +++ b/packages/editor/src/components/editor/bottom-sheet.tsx @@ -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(function BottomSheet( + { initialHeightPx, snapPointsPx, onCommit, children }, + ref, +) { + const height = useMotionValue(initialHeightPx) + const dragStartY = useRef(null) + const dragStartHeight = useRef(0) + const hasDragged = useRef(false) + const animationRef = useRef | 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 ( + +
+
+
+
{children}
+ + ) +}) diff --git a/packages/editor/src/components/editor/custom-camera-controls.tsx b/packages/editor/src/components/editor/custom-camera-controls.tsx index e6bfc7f82..ba10a4063 100644 --- a/packages/editor/src/components/editor/custom-camera-controls.tsx +++ b/packages/editor/src/components/editor/custom-camera-controls.tsx @@ -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} /> ) } diff --git a/packages/editor/src/components/editor/editor-layout-mobile.tsx b/packages/editor/src/components/editor/editor-layout-mobile.tsx new file mode 100644 index 000000000..5a364ea02 --- /dev/null +++ b/packages/editor/src/components/editor/editor-layout-mobile.tsx @@ -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(null) + const sheetRef = useRef(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(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 ( +
+ {navbarSlot} + +
+ {/* Viewer column: sized by committed sheet height */} +
+
+ {(viewerToolbarLeft || viewerToolbarRight) && !isCaptureMode && ( +
+
+ {viewerToolbarLeft} +
+
+ {viewerToolbarRight} +
+
+ )} +
{viewerContent}
+ {overlays && ( +
+ {overlays} +
+ )} +
+
+ + {/* Bottom sheet: overlays the lower part of the middle area */} + {!isCaptureMode && sidebarTabs.length > 0 && ( + +
+ {renderTabContent(activePanel)} + {sidebarOverlay &&
{sidebarOverlay}
} +
+
+ )} +
+ + {!isCaptureMode && sidebarTabs.length > 0 && ( + + )} +
+ ) +} diff --git a/packages/editor/src/components/editor/editor-layout-v2.tsx b/packages/editor/src/components/editor/editor-layout-v2.tsx index 1a571527c..e1016bb08 100755 --- a/packages/editor/src/components/editor/editor-layout-v2.tsx +++ b/packages/editor/src/components/editor/editor-layout-v2.tsx @@ -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 ( + + ) + } + return (
{/* Top navbar */} diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index c897e01a5..4843da830 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -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({ /> )} { @@ -3561,11 +3563,7 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({ @@ -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({ + + + ) +} + 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 && ( - - )} - {diagonalBStart && diagonalBEnd && ( - + ) : ( + <> + {diagonalAStart && diagonalAEnd && ( + + )} + {diagonalBStart && diagonalBEnd && ( + + )} + )} {itemDimensionMeasurements.length > 0 ? ( 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]) - - 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 }, - ]) - } + 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 }, + ]) } + } - 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) => { - 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 }) - }, []) + const handleFenceDoubleClick = useCallback( + (fence: FenceNode, event: ReactMouseEvent) => { + 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 }) + }, + [], + ) const emitFloorplanNodeClick = useCallback( ( nodeId: diff --git a/packages/editor/src/components/editor/index.tsx b/packages/editor/src/components/editor/index.tsx index 58dc40754..de20291e9 100644 --- a/packages/editor/src/components/editor/index.tsx +++ b/packages/editor/src/components/editor/index.tsx @@ -1076,7 +1076,13 @@ export default function Editor({ return } - const tabBarTabs = sidebarTabs?.map(({ id, label }) => ({ id, label })) ?? [] + const tabBarTabs = + sidebarTabs?.map(({ id, label, mobileDefaultSnap, mobileIcon }) => ({ + id, + label, + mobileDefaultSnap, + mobileIcon, + })) ?? [] return ( diff --git a/packages/editor/src/components/editor/thumbnail-generator.tsx b/packages/editor/src/components/editor/thumbnail-generator.tsx index ffe40a898..826eb09ae 100644 --- a/packages/editor/src/components/editor/thumbnail-generator.tsx +++ b/packages/editor/src/components/editor/thumbnail-generator.tsx @@ -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, ) } diff --git a/packages/editor/src/components/ui/action-menu/camera-actions.tsx b/packages/editor/src/components/ui/action-menu/camera-actions.tsx index f65363ccd..4a86fb7d8 100755 --- a/packages/editor/src/components/ui/action-menu/camera-actions.tsx +++ b/packages/editor/src/components/ui/action-menu/camera-actions.tsx @@ -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 (
- {/* Orbit CCW */} - - Orbit Left - + {!hideOrbit && ( + <> + {/* Orbit CCW */} + + Orbit Left + - {/* Orbit CW */} - - Orbit Right - + {/* Orbit CW */} + + Orbit Right + + + )} {/* Top View */} 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 } diff --git a/packages/editor/src/components/ui/action-menu/view-toggles.tsx b/packages/editor/src/components/ui/action-menu/view-toggles.tsx index 83f4a12f1..c40e40245 100755 --- a/packages/editor/src/components/ui/action-menu/view-toggles.tsx +++ b/packages/editor/src/components/ui/action-menu/view-toggles.tsx @@ -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 ( + + + + + + + + Grid snap: {formatGridSnapStep(gridSnapStep)} + + + +
+ {GRID_SNAP_STEPS.map((step) => { + const isActive = step === gridSnapStep + return ( + + ) + })} +
+
+
+ ) +} + // ── Scans toggle + dropdown ───────────────────────────────────────────────── function ScansControl() { @@ -395,3 +479,14 @@ export function ViewToggles() {
) } + +// Secondary toggles for mobile (grid snap + scans + guides) +export function SecondaryToggles() { + return ( +
+ + + +
+ ) +} diff --git a/packages/editor/src/components/ui/controls/slider-control.tsx b/packages/editor/src/components/ui/controls/slider-control.tsx index 937f637db..0d03fc41f 100644 --- a/packages/editor/src/components/ui/controls/slider-control.tsx +++ b/packages/editor/src/components/ui/controls/slider-control.tsx @@ -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(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) => { 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) => { 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 { diff --git a/packages/editor/src/components/ui/panels/mobile-panel-sheet.tsx b/packages/editor/src/components/ui/panels/mobile-panel-sheet.tsx new file mode 100644 index 000000000..8239ea48a --- /dev/null +++ b/packages/editor/src/components/ui/panels/mobile-panel-sheet.tsx @@ -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( + + {open && ( + { + if (info.offset.y > DRAG_CLOSE_THRESHOLD_PX) onClose() + }} + style={{ height: `${HEIGHT_VH}dvh` }} + transition={{ type: 'spring', stiffness: 320, damping: 32, mass: 0.8 }} + > +
+
+
+ +
+
+ {icon && ( + + )} +

+ {title} +

+
+ +
+ +
+ {children} +
+ + )} + , + document.body, + ) +} diff --git a/packages/editor/src/components/ui/panels/mobile-selection-bar.tsx b/packages/editor/src/components/ui/panels/mobile-selection-bar.tsx new file mode 100644 index 000000000..a9d49ffd5 --- /dev/null +++ b/packages/editor/src/components/ui/panels/mobile-selection-bar.tsx @@ -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 = (e) => e.stopPropagation() + + return ( +
+ + +
+ + + + +
+
+ ) +} diff --git a/packages/editor/src/components/ui/panels/node-display.ts b/packages/editor/src/components/ui/panels/node-display.ts new file mode 100644 index 000000000..6cc26c666 --- /dev/null +++ b/packages/editor/src/components/ui/panels/node-display.ts @@ -0,0 +1,38 @@ +import type { AnyNode } from '@pascal-app/core' + +export type NodeDisplay = { + icon: string + label: string +} + +const TYPE_DEFAULTS: Record = { + 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, + } +} diff --git a/packages/editor/src/components/ui/panels/panel-manager.tsx b/packages/editor/src/components/ui/panels/panel-manager.tsx index cc20f364b..6e0bfeb4d 100755 --- a/packages/editor/src/components/ui/panels/panel-manager.tsx +++ b/packages/editor/src/components/ui/panels/panel-manager.tsx @@ -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([ + '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 + case 'roof': + return + case 'roof-segment': + return + case 'stair': + return + case 'stair-segment': + return + case 'slab': + return + case 'ceiling': + return + case 'wall': + return + case 'fence': + return + case 'door': + return + case 'window': + return + 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) + : {} + 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 && ( + setIsSheetOpen((v) => !v)} + onMove={handleMove} + /> + )} + setIsSheetOpen(false)} + open={isSheetOpen} + title={display.label} + > + {panel} + + + ) +} + 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 } /> + } + return ( + + ) + } // 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 - case 'roof': - return - case 'roof-segment': - return - case 'stair': - return - case 'stair-segment': - return - case 'slab': - return - case 'ceiling': - return - case 'wall': - return - case 'fence': - return - case 'door': - return - case 'window': - return - } - } - - return null + return panelForType(selectedNodeType) } diff --git a/packages/editor/src/components/ui/panels/panel-wrapper.tsx b/packages/editor/src/components/ui/panels/panel-wrapper.tsx index 95723e7f6..e735b6984 100644 --- a/packages/editor/src/components/ui/panels/panel-wrapper.tsx +++ b/packages/editor/src/components/ui/panels/panel-wrapper.tsx @@ -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 (
- {/* Header */} -
-
- {onBack && ( - - )} - {icon && ( - - )} -

{title}

-
+ {/* Header — desktop only; mobile sheet provides its own header */} + {!isMobile && ( +
+
+ {onBack && ( + + )} + {icon && ( + + )} +

+ {title} +

+
-
- {onReset && ( - - )} - {onClose && ( - - )} +
+ {onReset && ( + + )} + {onClose && ( + + )} +
-
+ )} {/* Content */}
{children}
diff --git a/packages/editor/src/components/ui/sidebar/mobile-tab-bar.tsx b/packages/editor/src/components/ui/sidebar/mobile-tab-bar.tsx new file mode 100644 index 000000000..702d01e6f --- /dev/null +++ b/packages/editor/src/components/ui/sidebar/mobile-tab-bar.tsx @@ -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 ( +
+ {tabs.map((tab) => { + const isActive = activeTab === tab.id + return ( + + ) + })} +
+ ) +} diff --git a/packages/editor/src/components/ui/sidebar/tab-bar.tsx b/packages/editor/src/components/ui/sidebar/tab-bar.tsx index 0f10e3def..89393c0e7 100644 --- a/packages/editor/src/components/ui/sidebar/tab-bar.tsx +++ b/packages/editor/src/components/ui/sidebar/tab-bar.tsx @@ -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 { diff --git a/packages/editor/src/hooks/use-mobile.ts b/packages/editor/src/hooks/use-mobile.ts index 4331d5c56..9a549a245 100644 --- a/packages/editor/src/hooks/use-mobile.ts +++ b/packages/editor/src/hooks/use-mobile.ts @@ -2,18 +2,18 @@ import * as React from 'react' const MOBILE_BREAKPOINT = 768 -export function useIsMobile() { - const [isMobile, setIsMobile] = React.useState(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) +} + +const getClientSnapshot = (): boolean => window.innerWidth < MOBILE_BREAKPOINT - 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) - }, []) +// 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 - return !!isMobile +export function useIsMobile(): boolean { + return React.useSyncExternalStore(subscribe, getClientSnapshot, getServerSnapshot) } diff --git a/packages/editor/src/lib/floorplan/items.ts b/packages/editor/src/lib/floorplan/items.ts index 272aa9ba5..8f999e56c 100644 --- a/packages/editor/src/lib/floorplan/items.ts +++ b/packages/editor/src/lib/floorplan/items.ts @@ -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) diff --git a/packages/editor/src/lib/floorplan/types.ts b/packages/editor/src/lib/floorplan/types.ts index 0684eb870..aefaf257e 100644 --- a/packages/editor/src/lib/floorplan/types.ts +++ b/packages/editor/src/lib/floorplan/types.ts @@ -15,6 +15,10 @@ export type FloorplanItemEntry = { item: ItemNode polygon: Point2D[] usesRealMesh: boolean + center: Point2D + rotation: number + width: number + depth: number } export type FloorplanStairSegmentEntry = { diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx index f6d0488b6..d25b33a59 100644 --- a/packages/editor/src/store/use-editor.tsx +++ b/packages/editor/src/store/use-editor.tsx @@ -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()( }, 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) }), diff --git a/packages/viewer/src/components/error-boundary.tsx b/packages/viewer/src/components/error-boundary.tsx index f61b10f6b..eada133ac 100644 --- a/packages/viewer/src/components/error-boundary.tsx +++ b/packages/viewer/src/components/error-boundary.tsx @@ -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 { 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 } diff --git a/packages/viewer/src/components/viewer/index.tsx b/packages/viewer/src/components/viewer/index.tsx index 4cd9f4b37..5b05b0a00 100644 --- a/packages/viewer/src/components/viewer/index.tsx +++ b/packages/viewer/src/components/viewer/index.tsx @@ -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 + label?: string + features?: Set + 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 = ({ 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 = ({ {/* */} - - {/* */} - - - - - - {/* Default Systems */} - - - - - {/* Core systems */} - - - - - - - - - - - - {/* */} - - - {selectionManager === 'default' && } - {perf && } - {children} + + {/* */} + + + + + + {/* Default Systems */} + + + + + {/* Core systems */} + + + + + + + + + + + + {/* */} + + + + {selectionManager === 'default' && } + {perf && } + {children} + ) } diff --git a/packages/viewer/src/components/viewer/post-processing.tsx b/packages/viewer/src/components/viewer/post-processing.tsx index 96c8c6904..8dd494b11 100644 --- a/packages/viewer/src/components/viewer/post-processing.tsx +++ b/packages/viewer/src/components/viewer/post-processing.tsx @@ -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.', ) } } diff --git a/packages/viewer/src/lib/merged-outline-node.ts b/packages/viewer/src/lib/merged-outline-node.ts index f3617b35f..54642e3cf 100644 --- a/packages/viewer/src/lib/merged-outline-node.ts +++ b/packages/viewer/src/lib/merged-outline-node.ts @@ -166,6 +166,14 @@ export class MergedOutlineNode extends TempNode { private readonly _cacheA = new Set() private readonly _cacheB = new Set() + // 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)