diff --git a/apps/editor/app/page.tsx b/apps/editor/app/page.tsx index 9361f3696f..6ed21be894 100644 --- a/apps/editor/app/page.tsx +++ b/apps/editor/app/page.tsx @@ -1,6 +1,7 @@ 'use client' import { Editor, ItemsPanel } from '@pascal-app/editor' +import { PascalWebXRButton } from '@webxr/plugin/pascal-editor' import { Hammer, Layers, Package, Settings } from 'lucide-react' import Image from 'next/image' import Link from 'next/link' @@ -9,6 +10,11 @@ import { CommunityViewerToolbarLeft, CommunityViewerToolbarRight, } from '@/components/viewer-toolbar' +import { + useWebXRInstalled, + WebXRFeatureConsumer, + WebXRFeatureRuntime, +} from '@/components/webxr-feature-gate' // The open-source editor only ships the built-in catalog (no uploaded items), // so the Library/Community/Mine source chips and tag filters add nothing — @@ -87,30 +93,52 @@ const SIDEBAR_TABS = [ const PROJECT_ID = 'local-editor' export default function Home() { + const webXRInstalled = useWebXRInstalled() return (
- {PROJECT_ID === 'local-editor' && ( -
-
- - Blank canvas — saved scenes are under Scenes (not this page). - - - Open saved scenes - -
-
- )} - } - viewerToolbarRight={} - /> + + + {(vr) => ( + <> + {PROJECT_ID === 'local-editor' && ( +
+
+ + Blank canvas — saved scenes are under Scenes (not this page). + + + Open saved scenes + +
+
+ )} + } + viewerToolbarRight={ + + ) : null + } + vrLabel="Enter VR" + /> + } + /> + + )} +
+
) } diff --git a/apps/editor/components/build-tab.tsx b/apps/editor/components/build-tab.tsx index 995b453141..12e9cffaea 100644 --- a/apps/editor/components/build-tab.tsx +++ b/apps/editor/components/build-tab.tsx @@ -1,16 +1,7 @@ 'use client' +import { RoofType as RoofTypeSchema, useRegistryVersion } from '@pascal-app/core' import { - nodeRegistry, - type RoofType, - RoofType as RoofTypeSchema, - useRegistryVersion, -} from '@pascal-app/core' -import { - CATALOG_ITEMS, - type FloorplanMode, - getFloorplanNodeExtension, - isFloorplanToolAvailableInMode, MaterialPaintPanel, TerrainSculptPanel, ToolOptionsPanel, @@ -19,7 +10,6 @@ import { useFloorplanMode, } from '@pascal-app/editor' import { useLiquidLineToolOptions } from '@pascal-app/nodes' -import { useViewer } from '@pascal-app/viewer' import Image from 'next/image' import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react' import { @@ -28,239 +18,33 @@ import { TooltipProvider, TooltipTrigger, } from '@/components/toolbar-tooltip' +import { + activateBuildTool, + activateModularCabinetTool, + activatePaintMode, + activateRoofFeatureTool, + activateRoofType, + activateTerrainSculptMode, + BASE_BUILD_TYPES, + type BuildType, + collectBuildTypes, + collectRoofFeatures, + MEP_ITEMS, + MEP_TOOL_KINDS, + type MepItem, + MODULAR_CABINET_ICON, +} from '@/lib/build-palette' import { getActiveRoofFeatureId, ROOF_TYPE_OPTIONS } from '@/lib/build-tab-state' import { cn } from '@/lib/utils' -/** - * MEP (mechanical / plumbing) tool kinds surfaced under the Build tab's "MEP" - * group tile — its own sub-grid, like Roof's "Features". - */ -type MepToolKind = - | 'duct-segment' - | 'duct-fitting' - | 'duct-terminal' - | 'hvac-equipment' - | 'lineset' - | 'liquid-line' - | 'pipe-segment' - | 'pipe-fitting' - | 'pipe-trap' - -type BuildType = { - /** Selection id — equals `kind` for tool types, with dedicated ids for modes and groups. */ - id: string - label: string - /** Raster asset tile (legacy Build sidebar artwork). */ - iconSrc: string - /** Present for structure-tool types (absent for paint mode and the MEP group). */ - kind?: string - paletteOrder?: number - /** Non-placement special mode. */ - mode?: 'material-paint' | 'terrain-sculpt' -} - -type MepItem = { - /** Selection id — equals `kind`. */ - id: string - label: string - iconSrc: string - kind: MepToolKind -} - -// Same icons + ordering as the community Build sidebar, minus presets. -const BASE_BUILD_TYPES: BuildType[] = [ - { id: 'wall', label: 'Wall', iconSrc: '/icons/wall.webp', kind: 'wall' }, - { id: 'fence', label: 'Fence', iconSrc: '/icons/fence.webp', kind: 'fence' }, - { id: 'slab', label: 'Slab', iconSrc: '/icons/floor.webp', kind: 'slab' }, - { id: 'ceiling', label: 'Ceiling', iconSrc: '/icons/ceiling.webp', kind: 'ceiling' }, - { id: 'roof', label: 'Roof', iconSrc: '/icons/roof.webp', kind: 'roof' }, - { id: 'stair', label: 'Stairs', iconSrc: '/icons/stairs.webp', kind: 'stair' }, - { id: 'elevator', label: 'Elevator', iconSrc: '/icons/elevator.webp', kind: 'elevator' }, - { id: 'door', label: 'Door', iconSrc: '/icons/door.webp', kind: 'door' }, - { id: 'window', label: 'Window', iconSrc: '/icons/window.webp', kind: 'window' }, - { id: 'column', label: 'Column', iconSrc: '/icons/column.webp', kind: 'column' }, - { id: 'shelf', label: 'Shelf', iconSrc: '/icons/shelf.webp', kind: 'shelf' }, - { id: 'spawn', label: 'Spawn Point', iconSrc: '/icons/spawn-point.webp', kind: 'spawn' }, - { id: 'kitchen', label: 'Kitchen', iconSrc: '/icons/kitchen.webp' }, - // Group tile — no tool of its own; opens the MEP sub-grid below (like Roof). - { id: 'mep', label: 'MEP', iconSrc: '/icons/HVAC.webp' }, - { id: 'painting', label: 'Painting', iconSrc: '/icons/paint.webp', mode: 'material-paint' }, - { id: 'terrain', label: 'Terrain', iconSrc: '/icons/mesh.webp', mode: 'terrain-sculpt' }, -] - const subscribeToClientMount = () => () => {} -function collectBuildTypes(floorplanMode: FloorplanMode): BuildType[] { - const baseKinds = new Set(BASE_BUILD_TYPES.flatMap((type) => (type.kind ? [type.kind] : []))) - const tools = BASE_BUILD_TYPES.filter((type) => type.kind).map((type, index) => ({ - ...type, - paletteOrder: - nodeRegistry.get(type.kind!)?.presentation?.paletteOrder ?? type.paletteOrder ?? index * 10, - })) - for (const [kind, definition] of nodeRegistry.entries()) { - const presentation = definition.presentation - const extension = getFloorplanNodeExtension(definition) - if ( - baseKinds.has(kind) || - definition.presentation?.paletteGroup === 'roof-features' || - !extension?.tool || - !isFloorplanToolAvailableInMode(extension.availableModes, floorplanMode) || - !presentation || - presentation.hidden || - presentation.paletteSection !== 'structure' - ) { - continue - } - tools.push({ - id: kind, - kind, - label: presentation.label, - iconSrc: presentation.icon.kind === 'url' ? presentation.icon.src : '/icons/spawn-point.webp', - paletteOrder: presentation.paletteOrder ?? Number.MAX_SAFE_INTEGER, - }) - } - tools.sort((left, right) => (left.paletteOrder ?? 0) - (right.paletteOrder ?? 0)) - return [...tools, ...BASE_BUILD_TYPES.filter((type) => !type.kind)] -} - -// MEP sub-grid surfaced under the "MEP" tile — same icons + ordering the MEP -// tools had in the community Build sidebar. -const MEP_ITEMS: MepItem[] = [ - { id: 'duct-segment', label: 'Duct', iconSrc: '/icons/duct.webp', kind: 'duct-segment' }, - { - id: 'duct-terminal', - label: 'Register', - iconSrc: '/icons/registers.webp', - kind: 'duct-terminal', - }, - { id: 'hvac-equipment', label: 'HVAC Unit', iconSrc: '/icons/HVAC.webp', kind: 'hvac-equipment' }, - { id: 'lineset', label: 'Lineset', iconSrc: '/icons/lineset.webp', kind: 'lineset' }, - { id: 'liquid-line', label: 'Liquid Line', iconSrc: '/icons/lineset.webp', kind: 'liquid-line' }, - { id: 'pipe-segment', label: 'DWV Pipe', iconSrc: '/icons/dwv-pipes.webp', kind: 'pipe-segment' }, -] - -const MODULAR_CABINET_CATALOG_ITEM = CATALOG_ITEMS.find((item) => item.id === 'cabinet') -const MODULAR_CABINET_ICON = MODULAR_CABINET_CATALOG_ITEM?.thumbnail ?? '/icons/item.webp' - -/** - * Activate a raw structure draw/cursor tool. Mirrors the editor's own - * structure-tool activation (`setPhase`/`setStructureLayer`/`setMode`/`setTool`). - */ -function activateBuildTool(kind: string): void { - const ed = useEditor.getState() - const definition = nodeRegistry.get(kind) - const extension = getFloorplanNodeExtension(definition) - if ( - !isFloorplanToolAvailableInMode(extension?.availableModes, useFloorplanMode.getState().mode) - ) { - useFloorplanMode.getState().showExpertModeNotice(definition?.presentation?.label ?? kind) - return - } - const preferredView = extension?.preferredView - if (preferredView) ed.setViewMode(preferredView) - ed.setPhase('structure') - ed.setStructureLayer('elements') - ed.setCatalogCategory(null) - ed.setToolDefaults(kind, null) - ed.setMode('build') - ed.setTool(kind) -} - -function activateModularCabinetTool(): void { - const ed = useEditor.getState() - useViewer.getState().setSelection({ selectedIds: [], zoneId: null }) - if (MODULAR_CABINET_CATALOG_ITEM) ed.setSelectedItem(MODULAR_CABINET_CATALOG_ITEM) - ed.setPhase('structure') - ed.setStructureLayer('elements') - ed.setCatalogCategory(null) - ed.setMode('build') - ed.setTool('cabinet') -} - -/** Enter material-paint mode — the Build tab's "Painting" category. */ -function activatePaintMode(): void { - const ed = useEditor.getState() - ed.setPhase('structure') - ed.setStructureLayer('elements') - ed.setMode('material-paint') -} - -/** - * Enter terrain-sculpt mode — the Build tab's "Terrain" category. No `setPhase`: - * `setMode` moves to the site phase itself, since sculpting is a site-phase mode. - */ -function activateTerrainSculptMode(): void { - useEditor.getState().setMode('terrain-sculpt') -} - -type RoofFeature = { - id: string - label: string - iconSrc: string - kind?: string -} - -const ROOF_FEATURE_FALLBACK_ICON = '/icons/roof.webp' - -function collectRoofFeatures(): RoofFeature[] { - const features: RoofFeature[] = [] - for (const [kind, def] of nodeRegistry.entries()) { - if ( - def.capabilities.roofAccessory === undefined && - def.presentation?.paletteGroup !== 'roof-features' - ) { - continue - } - if (def.capabilities.wallOpeningPlacement) continue - const icon = def.presentation?.icon - features.push({ - id: kind, - kind, - label: def.presentation?.label ?? kind, - iconSrc: icon?.kind === 'url' ? icon.src : ROOF_FEATURE_FALLBACK_ICON, - }) - } - return features -} - -/** - * Roof accessories and extensions surfaced under the Roof tile. Unlike the - * community editor these aren't DB presets — each is a registry kind, either - * carrying `capabilities.roofAccessory` or explicitly classified as a roof - * extension. They are enumerated at render time because the registry is - * populated during app bootstrap. Label + icon come from `presentation`; - * non-url icons fall back to the roof icon. - */ -function activateRoofFeatureTool(feature: RoofFeature): void { - const ed = useEditor.getState() - ed.setPhase('structure') - ed.setStructureLayer('elements') - ed.setCatalogCategory(null) - ed.setMode('build') - if (feature.kind) ed.setTool(feature.kind) -} - -function activateRoofType(roofType: RoofType): void { - const editor = useEditor.getState() - if (!(editor.mode === 'build' && editor.tool === 'roof')) activateBuildTool('roof') - editor.setToolDefaults('roof', { ...editor.toolDefaults.roof, roofType }) -} - /** * Build tab for the open-source standalone editor — a preset-less replica of * the community Build sidebar. Clicking a type activates its raw tool, drawn * with the kind's own `def.defaults()`. The "Painting" type swaps in the * material-paint panel. */ -// MEP tool kinds that, when active, mean the MEP group tile (and its sub-grid) -// is what the user is working in. -const MEP_TOOL_KINDS = new Set([ - ...MEP_ITEMS.map((item) => item.kind), - 'duct-fitting', - 'pipe-fitting', - 'pipe-trap', -]) - export function BuildTab() { const [mepOpen, setMepOpen] = useState(false) const activeTool = useEditor((s) => s.tool) diff --git a/apps/editor/components/viewer-toolbar.tsx b/apps/editor/components/viewer-toolbar.tsx index a707288ecf..7822dcc800 100644 --- a/apps/editor/components/viewer-toolbar.tsx +++ b/apps/editor/components/viewer-toolbar.tsx @@ -686,6 +686,36 @@ function PreviewButton() { ) } +function VRButton({ + active, + disabled, + label, + onToggle, +}: { + active: boolean + disabled: boolean + label: string + onToggle: () => void +}) { + return ( + + + + ) +} + export function CommunityViewerToolbarLeft() { return ( <> @@ -695,7 +725,19 @@ export function CommunityViewerToolbarLeft() { ) } -export function CommunityViewerToolbarRight() { +export function CommunityViewerToolbarRight({ + onVRToggle, + vrButton, + vrActive = false, + vrDisabled = false, + vrLabel = vrActive ? 'Exit VR' : 'Enter VR', +}: { + onVRToggle?: () => void + vrButton?: ReactNode + vrActive?: boolean + vrDisabled?: boolean + vrLabel?: string +}) { return (
@@ -704,6 +746,17 @@ export function CommunityViewerToolbarRight() {
+ {onVRToggle || vrButton + ? (vrButton ?? + (onVRToggle ? ( + + ) : null)) + : null}
) diff --git a/apps/editor/components/webxr-feature-gate.tsx b/apps/editor/components/webxr-feature-gate.tsx new file mode 100644 index 0000000000..c7e0cbf88b --- /dev/null +++ b/apps/editor/components/webxr-feature-gate.tsx @@ -0,0 +1,79 @@ +'use client' + +import { useScene } from '@pascal-app/editor' +import { WEBXR_PLUGIN_ID } from '@webxr/plugin' +import { usePascalWebXR } from '@webxr/plugin/pascal-editor' +import { + createContext, + type ReactNode, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from 'react' +import { webXRWandBindings } from './webxr-wand-bindings' + +export type PascalWebXRFeature = ReturnType + +const WebXRFeatureContext = createContext(null) + +function EnabledWebXRFeature({ + onFeature, +}: { + onFeature: (feature: PascalWebXRFeature | null) => void +}) { + const feature = usePascalWebXR(webXRWandBindings) + // Keep the context stable when the plugin returns a new aggregate object. + // biome-ignore lint/correctness/useExhaustiveDependencies: stabilize on public feature fields + const stableFeature = useMemo( + () => feature, + [ + feature.enter, + feature.entering, + feature.error, + feature.exit, + feature.immersive, + feature.ready, + feature.runtime, + feature.session, + ], + ) + + useEffect(() => { + onFeature(stableFeature) + return () => onFeature(null) + }, [onFeature, stableFeature]) + + return null +} + +export function WebXRFeatureRuntime({ + enabled, + children, +}: { + enabled: boolean + children: ReactNode +}) { + const [feature, setFeature] = useState(null) + const setFeatureStable = useCallback((next: PascalWebXRFeature | null) => setFeature(next), []) + + return ( + + {children} + {enabled ? : null} + + ) +} + +export function WebXRFeatureConsumer({ + children, +}: { + children: (feature: PascalWebXRFeature | null) => ReactNode +}) { + return children(useContext(WebXRFeatureContext)) +} + +export function useWebXRInstalled() { + return useScene((state) => state.installedPlugins.includes(WEBXR_PLUGIN_ID)) +} diff --git a/apps/editor/components/webxr-wand-bindings.ts b/apps/editor/components/webxr-wand-bindings.ts new file mode 100644 index 0000000000..5bf9c93c59 --- /dev/null +++ b/apps/editor/components/webxr-wand-bindings.ts @@ -0,0 +1,40 @@ +'use client' + +import { useEditor } from '@pascal-app/editor' +import type { PascalXRWandBindings } from '@webxr/plugin/pascal-editor' +import { + activateBuildTool, + activateModularCabinetTool, + activatePaintMode, + activateRoofFeatureTool, + activateRoofFootprintSource, + activateRoofType, + activateSelectMode, + activateTerrainSculptMode, + collectBuildTypes, + collectRoofFeatures, + MEP_ITEMS, +} from '@/lib/build-palette' +import { useBuildPanelModel, useBuildToolOptions } from '@/lib/build-panel-model' +import { getRoofFootprintSources, ROOF_TYPE_OPTIONS } from '@/lib/build-tab-state' + +export const webXRWandBindings: PascalXRWandBindings = { + useBuildPalette: useBuildPanelModel, + useToolOptions: useBuildToolOptions, + activateBuildTool, + activateModularCabinetTool, + activatePaintMode, + activateRoofFeatureTool, + activateRoofFootprintSource, + activateRoofType, + activateSelectMode: () => { + activateSelectMode() + useEditor.getState().setTool(null) + }, + activateTerrainSculptMode, + collectBuildTypes, + collectRoofFeatures, + getRoofFootprintSources, + roofTypeOptions: ROOF_TYPE_OPTIONS, + xrMepItems: MEP_ITEMS, +} diff --git a/apps/editor/lib/bootstrap.ts b/apps/editor/lib/bootstrap.ts index fbebec2d12..b1456c2e92 100644 --- a/apps/editor/lib/bootstrap.ts +++ b/apps/editor/lib/bootstrap.ts @@ -19,6 +19,7 @@ import { poolHostPanel, poolPlugin } from '@pascal-app/plugin-pool' import { streetscapeHostPanel, streetscapePlugin } from '@pascal-app/plugin-streetscape' import { treesHostPanel, treesPlugin } from '@pascal-app/plugin-trees' import { registerViewerPresentation } from '@pascal-app/viewer' +import { webXRHostPanel, webXRPlugin } from '@webxr/plugin' // Idempotency guards: HMR can reload this module, but `registerNode` // throws on duplicate kinds. Flags live in the module closure so they @@ -112,6 +113,8 @@ registerEditorHostPanel({ ...streetscapeHostPanel, creator: { name: 'Sudhir Yadav', url: 'https://github.com/sudhir9297' }, }) +extendPluginDiscovery(async () => [webXRPlugin]) +registerEditorHostPanel(webXRHostPanel) loadBuiltinsSync() void loadExternalPlugins() diff --git a/apps/editor/lib/build-palette.ts b/apps/editor/lib/build-palette.ts new file mode 100644 index 0000000000..c427194df1 --- /dev/null +++ b/apps/editor/lib/build-palette.ts @@ -0,0 +1,202 @@ +import { emitter, nodeRegistry, type RoofType } from '@pascal-app/core' +import { + CATALOG_ITEMS, + type FloorplanMode, + getFloorplanNodeExtension, + isFloorplanToolAvailableInMode, + useEditor, + useFloorplanMode, +} from '@pascal-app/editor' +import { useViewer } from '@pascal-app/viewer' +import { getRoofFootprintSource, type RoofFootprintSource } from '@/lib/build-tab-state' + +export type BuildType = { + id: string + label: string + iconSrc: string + kind?: string + paletteOrder?: number + mode?: 'material-paint' | 'terrain-sculpt' +} + +export type MepItem = { + id: string + label: string + iconSrc: string + kind: string +} + +export type RoofFeature = { + id: string + label: string + iconSrc: string + kind?: string +} + +export const BASE_BUILD_TYPES: BuildType[] = [ + { id: 'wall', label: 'Wall', iconSrc: '/icons/wall.webp', kind: 'wall' }, + { id: 'fence', label: 'Fence', iconSrc: '/icons/fence.webp', kind: 'fence' }, + { id: 'slab', label: 'Slab', iconSrc: '/icons/floor.webp', kind: 'slab' }, + { id: 'ceiling', label: 'Ceiling', iconSrc: '/icons/ceiling.webp', kind: 'ceiling' }, + { id: 'roof', label: 'Roof', iconSrc: '/icons/roof.webp', kind: 'roof' }, + { id: 'stair', label: 'Stairs', iconSrc: '/icons/stairs.webp', kind: 'stair' }, + { id: 'elevator', label: 'Elevator', iconSrc: '/icons/elevator.webp', kind: 'elevator' }, + { id: 'door', label: 'Door', iconSrc: '/icons/door.webp', kind: 'door' }, + { id: 'window', label: 'Window', iconSrc: '/icons/window.webp', kind: 'window' }, + { id: 'column', label: 'Column', iconSrc: '/icons/column.webp', kind: 'column' }, + { id: 'shelf', label: 'Shelf', iconSrc: '/icons/shelf.webp', kind: 'shelf' }, + { id: 'spawn', label: 'Spawn Point', iconSrc: '/icons/spawn-point.webp', kind: 'spawn' }, + { id: 'kitchen', label: 'Kitchen', iconSrc: '/icons/kitchen.webp' }, + { id: 'mep', label: 'MEP', iconSrc: '/icons/HVAC.webp' }, + { id: 'painting', label: 'Painting', iconSrc: '/icons/paint.webp', mode: 'material-paint' }, + { id: 'terrain', label: 'Terrain', iconSrc: '/icons/mesh.webp', mode: 'terrain-sculpt' }, +] + +export const MEP_ITEMS: MepItem[] = [ + { id: 'duct-segment', label: 'Duct', iconSrc: '/icons/duct.webp', kind: 'duct-segment' }, + { + id: 'duct-terminal', + label: 'Register', + iconSrc: '/icons/registers.webp', + kind: 'duct-terminal', + }, + { id: 'hvac-equipment', label: 'HVAC Unit', iconSrc: '/icons/HVAC.webp', kind: 'hvac-equipment' }, + { id: 'lineset', label: 'Lineset', iconSrc: '/icons/lineset.webp', kind: 'lineset' }, + { id: 'liquid-line', label: 'Liquid Line', iconSrc: '/icons/lineset.webp', kind: 'liquid-line' }, + { id: 'pipe-segment', label: 'DWV Pipe', iconSrc: '/icons/dwv-pipes.webp', kind: 'pipe-segment' }, +] + +export const MEP_TOOL_KINDS = new Set([ + ...MEP_ITEMS.map((item) => item.kind), + 'duct-fitting', + 'pipe-fitting', + 'pipe-trap', +]) + +const MODULAR_CABINET_CATALOG_ITEM = CATALOG_ITEMS.find((item) => item.id === 'cabinet') +export const MODULAR_CABINET_ICON = MODULAR_CABINET_CATALOG_ITEM?.thumbnail ?? '/icons/item.webp' + +export function collectBuildTypes(floorplanMode: FloorplanMode): BuildType[] { + const baseKinds = new Set(BASE_BUILD_TYPES.flatMap((type) => (type.kind ? [type.kind] : []))) + const tools = BASE_BUILD_TYPES.filter((type) => type.kind).map((type, index) => ({ + ...type, + paletteOrder: + nodeRegistry.get(type.kind!)?.presentation?.paletteOrder ?? type.paletteOrder ?? index * 10, + })) + for (const [kind, definition] of nodeRegistry.entries()) { + const presentation = definition.presentation + const extension = getFloorplanNodeExtension(definition) + if ( + baseKinds.has(kind) || + presentation?.paletteGroup === 'roof-features' || + !extension?.tool || + !isFloorplanToolAvailableInMode(extension.availableModes, floorplanMode) || + !presentation || + presentation.hidden || + presentation.paletteSection !== 'structure' + ) + continue + tools.push({ + id: kind, + kind, + label: presentation.label, + iconSrc: presentation.icon.kind === 'url' ? presentation.icon.src : '/icons/spawn-point.webp', + paletteOrder: presentation.paletteOrder ?? Number.MAX_SAFE_INTEGER, + }) + } + tools.sort((left, right) => (left.paletteOrder ?? 0) - (right.paletteOrder ?? 0)) + return [...tools, ...BASE_BUILD_TYPES.filter((type) => !type.kind)] +} + +export function activateBuildTool(kind: string): void { + const editor = useEditor.getState() + const definition = nodeRegistry.get(kind) + const extension = getFloorplanNodeExtension(definition) + if ( + !isFloorplanToolAvailableInMode(extension?.availableModes, useFloorplanMode.getState().mode) + ) { + useFloorplanMode.getState().showExpertModeNotice(definition?.presentation?.label ?? kind) + return + } + if (extension?.preferredView) editor.setViewMode(extension.preferredView) + useViewer.getState().setSelection({ selectedIds: [], zoneId: null }) + editor.setPhase('structure') + editor.setStructureLayer('elements') + editor.setCatalogCategory(null) + editor.setToolDefaults(kind, null) + editor.setMode('build') + editor.setTool(kind) +} + +export function activateSelectMode(): void { + emitter.emit('tool:cancel') + useEditor.getState().setMode('select') +} + +export function activateModularCabinetTool(): void { + const editor = useEditor.getState() + useViewer.getState().setSelection({ selectedIds: [], zoneId: null }) + if (MODULAR_CABINET_CATALOG_ITEM) editor.setSelectedItem(MODULAR_CABINET_CATALOG_ITEM) + editor.setPhase('structure') + editor.setStructureLayer('elements') + editor.setCatalogCategory(null) + editor.setMode('build') + editor.setTool('cabinet') +} + +export function activatePaintMode(): void { + const editor = useEditor.getState() + editor.setPhase('structure') + editor.setStructureLayer('elements') + editor.setMode('material-paint') +} + +export function activateTerrainSculptMode(): void { + useEditor.getState().setMode('terrain-sculpt') +} + +export function collectRoofFeatures(): RoofFeature[] { + const features: RoofFeature[] = [] + for (const [kind, definition] of nodeRegistry.entries()) { + if ( + definition.capabilities.roofAccessory === undefined && + definition.presentation?.paletteGroup !== 'roof-features' + ) + continue + if (definition.capabilities.wallOpeningPlacement) continue + const icon = definition.presentation?.icon + features.push({ + id: kind, + kind, + label: definition.presentation?.label ?? kind, + iconSrc: icon?.kind === 'url' ? icon.src : '/icons/roof.webp', + }) + } + return features +} + +export function activateRoofFeatureTool(feature: RoofFeature): void { + const editor = useEditor.getState() + useViewer.getState().setSelection({ selectedIds: [], zoneId: null }) + editor.setPhase('structure') + editor.setStructureLayer('elements') + editor.setCatalogCategory(null) + editor.setMode('build') + if (feature.kind) editor.setTool(feature.kind) +} + +export function activateRoofType(roofType: RoofType): void { + const editor = useEditor.getState() + if (!(editor.mode === 'build' && editor.tool === 'roof')) activateBuildTool('roof') + const footprintSource = getRoofFootprintSource( + roofType, + editor.toolDefaults.roof?.footprintSource, + ) + editor.setToolDefaults('roof', { ...editor.toolDefaults.roof, roofType, footprintSource }) +} + +export function activateRoofFootprintSource(footprintSource: RoofFootprintSource): void { + const editor = useEditor.getState() + if (!(editor.mode === 'build' && editor.tool === 'roof')) activateBuildTool('roof') + editor.setToolDefaults('roof', { ...editor.toolDefaults.roof, footprintSource }) +} diff --git a/apps/editor/lib/build-panel-model.ts b/apps/editor/lib/build-panel-model.ts new file mode 100644 index 0000000000..89a0a39538 --- /dev/null +++ b/apps/editor/lib/build-panel-model.ts @@ -0,0 +1,202 @@ +'use client' + +import { RoofType as RoofTypeSchema, useRegistryVersion } from '@pascal-app/core' +import { type PanelToolOption, useEditor, useFloorplanMode } from '@pascal-app/editor' +import { useLiquidLineToolOptions } from '@pascal-app/nodes' +import type { XRWandBuildItem, XRWandBuildModel } from '@webxr/plugin' +import { + activateBuildTool, + activateModularCabinetTool, + activatePaintMode, + activateRoofFeatureTool, + activateRoofFootprintSource, + activateRoofType, + activateTerrainSculptMode, + collectBuildTypes, + collectRoofFeatures, + MEP_ITEMS, + MEP_TOOL_KINDS, + MODULAR_CABINET_ICON, +} from './build-palette' +import { + getRoofFootprintSource, + getRoofFootprintSources, + ROOF_TYPE_OPTIONS, +} from './build-tab-state' + +export function useBuildToolOptions(): PanelToolOption[] { + const mode = useEditor((state) => state.mode) + const tool = useEditor((state) => state.tool) + const defaults = useEditor((state) => state.toolDefaults.roof) + const follow = useLiquidLineToolOptions((state) => state.follow) + const toggleFollow = useLiquidLineToolOptions((state) => state.toggleFollow) + if (mode !== 'build') return [] + if (tool === 'liquid-line') { + return [ + { + id: 'liquid-line-follow', + label: 'Follow lineset', + value: follow ? 'on' : 'off', + choices: [ + { value: 'on', label: 'On' }, + { value: 'off', label: 'Off' }, + ], + set: (value) => { + if ((value === 'on') !== useLiquidLineToolOptions.getState().follow) toggleFollow() + }, + }, + ] + } + const parsed = RoofTypeSchema.safeParse(defaults?.roofType) + const roofType = parsed.success ? parsed.data : 'gable' + if (tool !== 'roof' || roofType === 'conical') return [] + return [ + { + id: 'roof-footprint-source', + label: 'Create from', + value: getRoofFootprintSource(roofType, defaults?.footprintSource), + choices: getRoofFootprintSources(roofType).map((source) => ({ + ...source, + description: + source.value === 'room' + ? 'Hover a room to preview its boundary, then click to place.' + : 'Draw the roof footprint with two corner clicks.', + })), + set: (value) => activateRoofFootprintSource(getRoofFootprintSource(roofType, value)), + }, + ] +} + +export function useBuildPanelModel(): XRWandBuildModel { + const mode = useEditor((state) => state.mode) + const tool = useEditor((state) => state.tool) + const defaults = useEditor((state) => state.toolDefaults.roof) + const floorplanMode = useFloorplanMode((state) => state.mode) + useRegistryVersion() + const features = collectRoofFeatures() + const roofActive = + mode === 'build' && (tool === 'roof' || features.some((feature) => feature.kind === tool)) + const mepActive = mode === 'build' && !!tool && MEP_TOOL_KINDS.has(tool) + const kitchenActive = mode === 'build' && tool === 'cabinet' + const duct = mode === 'build' && (tool === 'duct-segment' || tool === 'duct-fitting') + const pipe = + mode === 'build' && (tool === 'pipe-segment' || tool === 'pipe-fitting' || tool === 'pipe-trap') + const parsed = RoofTypeSchema.safeParse(defaults?.roofType) + const roofType = parsed.success ? parsed.data : 'gable' + const items = collectBuildTypes(floorplanMode).map( + (type): XRWandBuildItem => ({ + id: type.id, + label: type.label, + icon: { src: type.iconSrc }, + active: type.mode + ? mode === type.mode + : type.id === 'roof' + ? roofActive + : type.id === 'mep' + ? mepActive + : type.id === 'kitchen' + ? kitchenActive + : mode === 'build' && tool === type.kind, + onSelect: () => { + if (type.mode === 'material-paint') activatePaintMode() + else if (type.mode === 'terrain-sculpt') activateTerrainSculptMode() + else if (type.id === 'mep') activateBuildTool('duct-segment') + else if (type.id === 'kitchen') activateModularCabinetTool() + else if (type.kind) activateBuildTool(type.kind) + }, + }), + ) + const secondaryItems: XRWandBuildItem[] = roofActive + ? [ + ...ROOF_TYPE_OPTIONS.map((type) => ({ + id: `roof-${type.value}`, + label: type.label, + section: 'Roof type', + active: tool === 'roof' && roofType === type.value, + onSelect: () => activateRoofType(type.value), + })), + ...features.map((feature) => ({ + id: feature.id, + label: feature.label, + icon: { src: feature.iconSrc }, + section: 'Features & extensions', + active: feature.kind === tool, + onSelect: () => activateRoofFeatureTool(feature), + })), + ] + : mepActive + ? [ + ...MEP_ITEMS.map((item) => ({ + id: item.id, + label: item.label, + icon: { src: item.iconSrc }, + section: 'MEP', + active: + item.kind === 'duct-segment' + ? duct + : item.kind === 'pipe-segment' + ? pipe + : item.kind === tool, + onSelect: () => activateBuildTool(item.kind), + })), + ...(duct + ? [ + { + id: 'duct-fitting', + label: 'Add Fitting', + section: 'Duct', + icon: { src: '/icons/duct-fitting.webp' }, + active: tool === 'duct-fitting', + onSelect: () => + activateBuildTool(tool === 'duct-fitting' ? 'duct-segment' : 'duct-fitting'), + }, + ] + : []), + ...(pipe + ? [ + { + id: 'pipe-fitting', + label: 'Add Fitting', + section: 'DWV Pipe', + icon: { src: '/icons/duct-fitting.webp' }, + active: tool === 'pipe-fitting', + onSelect: () => + activateBuildTool(tool === 'pipe-fitting' ? 'pipe-segment' : 'pipe-fitting'), + }, + { + id: 'pipe-trap', + label: 'Add Trap', + section: 'DWV Pipe', + icon: { src: '/icons/dwv-pipes.webp' }, + active: tool === 'pipe-trap', + onSelect: () => + activateBuildTool(tool === 'pipe-trap' ? 'pipe-segment' : 'pipe-trap'), + }, + ] + : []), + ] + : kitchenActive + ? [ + { + id: 'modular-cabinet', + label: 'Modular Cabinet', + section: 'Kitchen', + icon: { src: MODULAR_CABINET_ICON }, + active: true, + onSelect: activateModularCabinetTool, + }, + ] + : [] + const section = roofActive ? 'roof' : mepActive ? 'mep' : kitchenActive ? 'kitchen' : 'main' + return { + items, + secondaryItems, + secondaryTitle: roofActive ? 'Roof' : mepActive ? 'MEP' : 'Kitchen', + section, + title: 'Build', + mark: `${items.length} tools`, + page: 0, + pageCount: 1, + detailMode: mode === 'material-paint' ? 'paint' : undefined, + } +} diff --git a/apps/editor/lib/build-tab-state.ts b/apps/editor/lib/build-tab-state.ts index 478f4e559f..812a60b5ec 100644 --- a/apps/editor/lib/build-tab-state.ts +++ b/apps/editor/lib/build-tab-state.ts @@ -5,6 +5,31 @@ export type RoofFeatureIdentity = { kind?: string } +const ROOF_FOOTPRINT_SOURCES = [ + { label: 'Room', value: 'room' }, + { label: 'Wall', value: 'walls' }, + { label: 'Draw', value: 'draw' }, +] as const + +export type RoofFootprintSource = (typeof ROOF_FOOTPRINT_SOURCES)[number]['value'] + +const CONICAL_ROOF_FOOTPRINT_SOURCES = [ROOF_FOOTPRINT_SOURCES[1]] as const +const STANDARD_ROOF_FOOTPRINT_SOURCES = [ + ROOF_FOOTPRINT_SOURCES[2], + ROOF_FOOTPRINT_SOURCES[0], +] as const + +export function getRoofFootprintSources(roofType: RoofType) { + return roofType === 'conical' ? CONICAL_ROOF_FOOTPRINT_SOURCES : STANDARD_ROOF_FOOTPRINT_SOURCES +} + +export function getRoofFootprintSource(roofType: RoofType, value: unknown): RoofFootprintSource { + const sources = getRoofFootprintSources(roofType) + return sources.some((source) => source.value === value) + ? (value as RoofFootprintSource) + : sources[0].value +} + export const ROOF_TYPE_OPTIONS: ReadonlyArray<{ label: string; value: RoofType }> = [ { label: 'Hip', value: 'hip' }, { label: 'Gable', value: 'gable' }, diff --git a/apps/editor/next.config.ts b/apps/editor/next.config.ts index 44cffefdbc..02acee4893 100644 --- a/apps/editor/next.config.ts +++ b/apps/editor/next.config.ts @@ -37,6 +37,7 @@ const nextConfig: NextConfig = { '@pascal-app/plugin-trees', '@mint/pascal-plugin', '@pascal-app/plugin-bones', + '@webxr/plugin', '@pascal-app/plugin-environment', '@dgreenheck/ez-tree', ], diff --git a/apps/editor/package.json b/apps/editor/package.json index 8132f573f0..b4b00ac873 100644 --- a/apps/editor/package.json +++ b/apps/editor/package.json @@ -28,7 +28,9 @@ "@radix-ui/react-tooltip": "^1.2.8", "@react-three/drei": "^10.7.7", "@react-three/fiber": "^9.5.0", + "@react-three/xr": "^6.6.30", "@tailwindcss/postcss": "^4.2.1", + "@webxr/plugin": "github:sudhir9297/webxr-pascal-plugin#31063230d0cd5b244b04eca12c6e31b5b757cb5e", "clsx": "^2.1.1", "geist": "^1.7.0", "lucide-react": "^1.7.0", diff --git a/apps/editor/vendor/pascal-app-plugin-pool-0.1.0-dist6.tgz b/apps/editor/vendor/pascal-app-plugin-pool-0.1.0-dist6.tgz new file mode 100644 index 0000000000..aa1fb973df Binary files /dev/null and b/apps/editor/vendor/pascal-app-plugin-pool-0.1.0-dist6.tgz differ diff --git a/bun.lock b/bun.lock index 30e10f30bd..62b870086c 100644 --- a/bun.lock +++ b/bun.lock @@ -44,7 +44,9 @@ "@radix-ui/react-tooltip": "^1.2.8", "@react-three/drei": "^10.7.7", "@react-three/fiber": "^9.5.0", + "@react-three/xr": "^6.6.30", "@tailwindcss/postcss": "^4.2.1", + "@webxr/plugin": "github:sudhir9297/webxr-pascal-plugin#31063230d0cd5b244b04eca12c6e31b5b757cb5e", "clsx": "^2.1.1", "geist": "^1.7.0", "lucide-react": "^1.7.0", @@ -345,6 +347,10 @@ "version": "0.0.0", }, }, + "patchedDependencies": { + "three@0.186.0": "patches/three@0.186.0.patch", + "iwer@2.3.0": "patches/iwer@2.3.0.patch", + }, "overrides": { "@types/react": "19.2.17", "@types/react-dom": "19.2.3", @@ -408,6 +414,8 @@ "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.16", "", { "os": "win32", "cpu": "x64" }, "sha512-Kp85jgoBHa05gix6UIRjfCDiUV3w/8VIdZ247VyyO2gEjaw12WEVhdIjlxp/AMzXxqxQwbxNTDVZ3Mwd2RG5rw=="], + "@bufbuild/protobuf": ["@bufbuild/protobuf@2.15.0", "", {}, "sha512-DAheWUkVr/SJTWCc+lg9dhY0eN4SaWlf4+bG1KzHeXbnqt0AfB/NX0Z+VunGlM1ki1B4zVvye27MpKh/svySUA=="], + "@clack/core": ["@clack/core@1.4.1", "", { "dependencies": { "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-FILJa1gGKEFTGZAJE9RpVhrjKz3c3h4ar60dSv6cGuDqufQ84YEIS3GAGvZiN+H6yaLbbvTFNejjCC4tXpZEuw=="], "@clack/prompts": ["@clack/prompts@1.5.1", "", { "dependencies": { "@clack/core": "1.4.1", "fast-string-width": "^3.0.2", "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-zccHj2z2oCCO4yrDiRSlFOxWerGqRiysP7a5jPK6uoI9URKAquwY42Dd/iUP8JWHxEzdRe4TlbvZCo8z1/mhrw=="], @@ -432,6 +440,10 @@ "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], + "@emotion/is-prop-valid": ["@emotion/is-prop-valid@1.4.0", "", { "dependencies": { "@emotion/memoize": "^0.9.0" } }, "sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw=="], + + "@emotion/memoize": ["@emotion/memoize@0.9.0", "", {}, "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ=="], + "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="], "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], @@ -458,6 +470,14 @@ "@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="], + "@fortawesome/fontawesome-common-types": ["@fortawesome/fontawesome-common-types@6.6.0", "", {}, "sha512-xyX0X9mc0kyz9plIyryrRbl7ngsA9jz77mCZJsUkLl+ZKs0KWObgaEBoSgQiYWAsSmjz/yjl0F++Got0Mdp4Rw=="], + + "@fortawesome/fontawesome-svg-core": ["@fortawesome/fontawesome-svg-core@6.6.0", "", { "dependencies": { "@fortawesome/fontawesome-common-types": "6.6.0" } }, "sha512-KHwPkCk6oRT4HADE7smhfsKudt9N/9lm6EJ5BVg0tD1yPA5hht837fB87F8pn15D8JfTqQOjhKTktwmLMiD7Kg=="], + + "@fortawesome/free-solid-svg-icons": ["@fortawesome/free-solid-svg-icons@6.6.0", "", { "dependencies": { "@fortawesome/fontawesome-common-types": "6.6.0" } }, "sha512-IYv/2skhEDFc2WGUcqvFJkeK39Q+HyPf5GHUrT/l2pKbtgEIv1al1TKd6qStR5OIwQdN1GZP54ci3y4mroJWjA=="], + + "@fortawesome/react-fontawesome": ["@fortawesome/react-fontawesome@0.2.2", "", { "dependencies": { "prop-types": "^15.8.1" }, "peerDependencies": { "@fortawesome/fontawesome-svg-core": "~1 || ~6", "react": ">=16.3" } }, "sha512-EnkrprPNqI6SXJl//m29hpaNzOp1bruISWaOiRtkMi/xSvHJlzc2j2JAYS7egxt/EbjSNV/k6Xy0AQI6vB2+1g=="], + "@gltf-transform/core": ["@gltf-transform/core@4.4.2", "", { "dependencies": { "property-graph": "^4.1.0" } }, "sha512-qsWKwNSwK+2s834Mt4xbYcyHqCrgNFP7hIv5s487JxebngRfDgelpghNF+kSswGb2/NuapasfK3UViFoSJJoMg=="], "@gltf-transform/extensions": ["@gltf-transform/extensions@4.4.2", "", { "dependencies": { "@gltf-transform/core": "^4.4.2", "ktx-parse": "^1.1.0" } }, "sha512-HJH1FM+edC5eNvl6xO0SOXJ/j/3oDoIpSu150OTdJaLBoM3TgCCGIfh4wyhgWAqZrkvgHKVGiZKxcKV5LkgPCQ=="], @@ -536,6 +556,10 @@ "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.3", "", { "os": "win32", "cpu": "x64" }, "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA=="], + "@iwer/devui": ["@iwer/devui@2.3.0", "", { "dependencies": { "@pmndrs/handle": "^6.6.29", "@pmndrs/pointer-events": "^6.6.29", "lucide-react": "^1.20.0", "react": "^19.2.6", "react-dom": "^19.2.6", "styled-components": "^6.4.1", "three": "^0.184.0", "zustand": "^5.0.13" }, "peerDependencies": { "iwer": "^2.3.0" } }, "sha512-UfBFR3qOYt/DUsLik20uF7Jn0NrNN2L0+uiDGJzuKXpXa/2btNOjOj1iSaTthz95MK5/O2HqqC+ymjkRMHi5UQ=="], + + "@iwer/sem": ["@iwer/sem@0.2.5", "", { "dependencies": { "three": "^0.165.0", "ts-proto": "^2.6.0" }, "peerDependencies": { "iwer": "^2.0.0" } }, "sha512-vMCfpu/7Qqc+hkBiGD9pxjeObgrhXOrL0KX94CA3yzJaU0dq0y49HXZT6fC+6X/jOmjaM3hjyE1m2h7ZmLzzyA=="], + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], @@ -764,6 +788,12 @@ "@pascal/typescript-config": ["@pascal/typescript-config@workspace:tooling/typescript"], + "@pmndrs/handle": ["@pmndrs/handle@6.6.30", "", { "dependencies": { "@pmndrs/pointer-events": "~6.6.30", "zustand": "^4.5.2" } }, "sha512-KPutdaLpCNPZoqXX2HvlRc+cQMETXdHTF0nKqFHMrJrne34apqELRVig53lO469rJhheedsRVepfdyo4g9Rukw=="], + + "@pmndrs/pointer-events": ["@pmndrs/pointer-events@6.6.30", "", {}, "sha512-YD2jWdgEqqAWJNOOZ1WZMunUby8jwuQyOMk8zbeqC39R4nkZnGvu1Pa5EMGbV1zd2vZTxXDxYcAVxtQuhVwf3g=="], + + "@pmndrs/xr": ["@pmndrs/xr@6.6.30", "", { "dependencies": { "@iwer/devui": "^1.1.1", "@iwer/sem": "~0.2.5", "@pmndrs/pointer-events": "~6.6.30", "iwer": "^2.1.0", "meshline": "^3.3.1", "zustand": "^4.5.2" }, "peerDependencies": { "three": "*" } }, "sha512-qy0UQHaXdZs192awbYkde+O/eKn15fz88X08+7EhlVIdsMfPAORR2JDocTzfYmtz8bKyE4z1JMUUvXb9DyUrzg=="], + "@preact/signals": ["@preact/signals@2.9.1", "", { "dependencies": { "@preact/signals-core": "^1.14.0" }, "peerDependencies": { "preact": ">= 10.25.0 || >=11.0.0-0" } }, "sha512-xVqN8mJjbSN5IB/8Ubmd9NN+Ew6zJswoRxrjZbH3YsgkMshFeO6d8zxEFpHRTq9GJZx7cnPs2CnCpFqtGXGNsw=="], "@preact/signals-core": ["@preact/signals-core@1.14.2", "", {}, "sha512-RZHdBj9ZF4n40Rp4jS052EHHjBWf96P9oNdXPfhQTovCuWY9iQn3Gq+gOTJSgBO9A/JBuPfMOWsSX/lIU9Pc/A=="], @@ -854,6 +884,8 @@ "@react-three/test-renderer": ["@react-three/test-renderer@9.1.1", "", { "peerDependencies": { "@react-three/fiber": ">=9.0.0", "react": "^19.0.0", "three": ">=0.156" } }, "sha512-4DmLn0tg+AP8aU0Mb5vekjHqFrwRJ3z11HfQGOJIDj6DxZ/5BjRHRsDxY0Y+g4l8+WHysQ6PeuSdzTDDYoGuXg=="], + "@react-three/xr": ["@react-three/xr@6.6.30", "", { "dependencies": { "@pmndrs/pointer-events": "~6.6.30", "@pmndrs/xr": "~6.6.30", "suspend-react": "^0.1.3", "tunnel-rat": "^0.1.2", "zustand": "^4.5.2" }, "peerDependencies": { "@react-three/fiber": ">=8", "react": ">=18", "react-dom": ">=18", "three": "*" } }, "sha512-C+PYxnDsWvF2WG669DXwlcKnxcy7O+FMhxlaOq75HrRSJRtk4T5iZCv6qak2+8ztcYrip8t79W9E+LSV3P93hA=="], + "@repo/eslint-config": ["@repo/eslint-config@workspace:packages/eslint-config"], "@repo/typescript-config": ["@repo/typescript-config@workspace:packages/typescript-config"], @@ -1042,6 +1074,8 @@ "@visual-json/react": ["@visual-json/react@0.4.0", "", { "dependencies": { "@visual-json/core": "0.4.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-qiDrXI7wu9VoTE0TaLoJp7kwRj8SQTpkS/wkts5sBPA8078zdrIKc+0u0Y8wAXGYd3udw9dNfD4kmpO48OEwXQ=="], + "@webxr/plugin": ["@webxr/plugin@github:sudhir9297/webxr-pascal-plugin#3106323", { "dependencies": { "@iwer/devui": "2.3.0", "iwer": "2.3.0", "lucide-react": "^1.7.0" }, "peerDependencies": { "@pascal-app/core": "^1.0.0-beta.5", "@pascal-app/editor": "^1.0.0-beta.5", "@pascal-app/viewer": "^1.0.0-beta.5", "@react-three/drei": "^10", "@react-three/fiber": "^9", "@react-three/xr": "^6", "react": "^18 || ^19", "react-dom": "^18 || ^19", "three": "^0.185 || ^0.186", "zustand": "^5" } }, "sudhir9297-webxr-pascal-plugin-3106323", "sha512-Kyfu6u8SeFtddqLcYy8iPXxQ+L2T6qIzhyDYCcy7kehhOf+qX//I7NLRVsO7dQYY+fEbErVbswj0pzdO8nYUFw=="], + "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], @@ -1128,10 +1162,14 @@ "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], + "camelize": ["camelize@1.0.1", "", {}, "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ=="], + "camera-controls": ["camera-controls@3.1.2", "", { "peerDependencies": { "three": ">=0.126.1" } }, "sha512-xkxfpG2ECZ6Ww5/9+kf4mfg1VEYAoe9aDSY+IwF0UEs7qEzwy0aVRfs2grImIECs/PoBtWFrh7RXsQkwG922JA=="], "caniuse-lite": ["caniuse-lite@1.0.30001797", "", {}, "sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w=="], + "case-anything": ["case-anything@2.1.13", "", {}, "sha512-zlOQ80VrQ2Ue+ymH5OuM/DlDq64mEm+B9UTdHULv5osUMD6HalNTblf2b1u/m6QecjsnOkBpqVZ+XPwIVsy7Ng=="], + "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "citty": ["citty@0.2.2", "", {}, "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w=="], @@ -1180,6 +1218,10 @@ "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + "css-color-keywords": ["css-color-keywords@1.0.0", "", {}, "sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg=="], + + "css-to-react-native": ["css-to-react-native@3.2.0", "", { "dependencies": { "camelize": "^1.0.0", "css-color-keywords": "^1.0.0", "postcss-value-parser": "^4.0.2" } }, "sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ=="], + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], "cwise-compiler": ["cwise-compiler@1.1.3", "", { "dependencies": { "uniq": "^1.0.0" } }, "sha512-WXlK/m+Di8DMMcCjcWr4i+XzcQra9eCdXIJrgh4TUgh0pIS/yJduLxS9JgefsHJ/YVLdgPtXm9r62W92MvanEQ=="], @@ -1226,6 +1268,8 @@ "dotenv-expand": ["dotenv-expand@12.0.3", "", { "dependencies": { "dotenv": "^16.4.5" } }, "sha512-uc47g4b+4k/M/SeaW1y4OApx+mtLWl92l5LMPP0GNXctZqELk+YGgOPIIC5elYmUH4OuoK3JLhuRUYegeySiFA=="], + "dprint-node": ["dprint-node@1.0.8", "", { "dependencies": { "detect-libc": "^1.0.3" } }, "sha512-iVKnUtYfGrYcW1ZAlfR/F59cUVL8QIhWoBJoSjkkdua/dkWIgjZfiLMeTjiB06X0ZLkQ0M2C1VbUj/CxkIf1zg=="], + "draco3d": ["draco3d@1.5.7", "", {}, "sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ=="], "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], @@ -1384,6 +1428,8 @@ "get-symbol-description": ["get-symbol-description@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6" } }, "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg=="], + "gl-matrix": ["gl-matrix@3.4.4", "", {}, "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ=="], + "glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], @@ -1522,6 +1568,8 @@ "its-fine": ["its-fine@2.0.0", "", { "dependencies": { "@types/react-reconciler": "^0.28.9" }, "peerDependencies": { "react": "^19.0.0" } }, "sha512-KLViCmWx94zOvpLwSlsx6yOCeMhZYaxrJV87Po5k/FoZzcPSahvK5qJ7fYhS61sZi5ikmh2S3Hz55A2l3U69ng=="], + "iwer": ["iwer@2.3.0", "", { "dependencies": { "gl-matrix": "^3.4.4", "webxr-layers-polyfill": "^1.1.0" } }, "sha512-+aY/BXVIjztCtS4F1hAO2rQy0P1/0JbJ6Jq5QHVscUBBlv8xJlTkkyeg+uSiD9RQ7i5B6k1Blpcx84FQaI7Myw=="], + "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], "jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="], @@ -1596,7 +1644,7 @@ "lru-cache": ["lru-cache@11.5.1", "", {}, "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A=="], - "lucide-react": ["lucide-react@1.17.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-9FA9evdox/JQL5PT57fdA1x/yg8T7knJ98+zjTL3UfKza6pflQUUh3XtaQIHKvnsJw1lmsEyHVlt5jchYxOQ5w=="], + "lucide-react": ["lucide-react@1.47.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-o8C23aXpNQypRY73W7fW02EyvWJinEMXgKeGjFoKym0zj3Q73hD97A1IQps1g8C14HTGsOpZL0Am+xjfgFZAbg=="], "maath": ["maath@0.10.8", "", { "peerDependencies": { "@types/three": ">=0.134.0", "three": ">=0.134.0" } }, "sha512-tRvbDF0Pgqz+9XUa4jjfgAQ8/aPKmQdWXilFu2tMy4GWj4NOsx99HlULO4IeREfbO3a0sA145DZYyvXPkybm0g=="], @@ -1750,6 +1798,8 @@ "postcss": ["postcss@8.5.23", "", { "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg=="], + "postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="], + "potpack": ["potpack@1.0.2", "", {}, "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ=="], "preact": ["preact@10.29.2", "", {}, "sha512-7tNmwg/7mzzAoB/8kSg6Hl37JraAZw3Z3A0JSY7VXlZwo82Xn0G7wKbNNs2qoF4ZEEsQGTwDAroNdqKs1ofJxQ=="], @@ -1898,8 +1948,12 @@ "stubborn-utils": ["stubborn-utils@1.0.2", "", {}, "sha512-zOh9jPYI+xrNOyisSelgym4tolKTJCQd5GBhK0+0xJvcYDcwlOoxF/rnFKQ2KRZknXSG9jWAp66fwP6AxN9STg=="], + "styled-components": ["styled-components@6.5.3", "", { "dependencies": { "@emotion/is-prop-valid": "1.4.0", "css-to-react-native": "3.2.0", "csstype": "3.2.3", "stylis": "4.3.6" }, "peerDependencies": { "react": ">= 16.8.0", "react-dom": ">= 16.8.0", "react-native": ">= 0.68.0" }, "optionalPeers": ["react-dom", "react-native"] }, "sha512-vAX79sfpmUerP9fsTTxoTrBDE0RuO4ahjInyWYoohNgqrdg63Ms4q6FJ/o2Fyity82NU3cujOT8Ewl9TThBdwg=="], + "styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "@babel/core": "*", "babel-plugin-macros": "*", "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" }, "optionalPeers": ["@babel/core", "babel-plugin-macros"] }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="], + "stylis": ["stylis@4.3.6", "", {}, "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ=="], + "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], @@ -1942,6 +1996,12 @@ "ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="], + "ts-poet": ["ts-poet@6.12.0", "", { "dependencies": { "dprint-node": "^1.0.8" } }, "sha512-xo+iRNMWqyvXpFTaOAvLPA5QAWO6TZrSUs5s4Odaya3epqofBu/fMLHEWl8jPmjhA0s9sgj9sNvF1BmaQlmQkA=="], + + "ts-proto": ["ts-proto@2.12.4", "", { "dependencies": { "@bufbuild/protobuf": "^2.14.1", "case-anything": "^2.1.13", "ts-poet": "^6.12.0", "ts-proto-descriptors": "2.1.0" }, "bin": { "protoc-gen-ts_proto": "protoc-gen-ts_proto" } }, "sha512-nZ2UyT+xEanL90lCnL0BrTfKtljZE4wnOvUqR0XUpuWCu3w4k8LUnR4AKJoy1UtxPDa1HpJ1z//pu2tvIPDrUA=="], + + "ts-proto-descriptors": ["ts-proto-descriptors@2.1.0", "", { "dependencies": { "@bufbuild/protobuf": "^2.0.0" } }, "sha512-S5EZYEQ6L9KLFfjSRpZWDIXDV/W7tAj8uW7pLsihIxyr62EAVSiKuVPwE8iWnr849Bqa53enex1jhDUcpgquzA=="], + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "tunnel-rat": ["tunnel-rat@0.1.2", "", { "dependencies": { "zustand": "^4.3.2" } }, "sha512-lR5VHmkPhzdhrM092lI2nACsLO4QubF0/yoOhzX7c+wIpbN1GjHNzCc91QlpxBi+cnx8vVJ+Ur6vL5cEoQPFpQ=="], @@ -2022,6 +2082,8 @@ "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], + "webxr-layers-polyfill": ["webxr-layers-polyfill@1.1.0", "", { "dependencies": { "gl-matrix": "^3.4.3" } }, "sha512-GqWE6IFlut8a1Lnh9t1RPnOXud1rZ7wLPvWp7mqTDOYtgorXqlNMhEnI9EqjU33grBx0v3jm0Oc13opkAdmgMQ=="], + "when-exit": ["when-exit@2.1.5", "", {}, "sha512-VGkKJ564kzt6Ms1dbgPP/yuIoQCrsFAnRbptpC5wOEsDaNsbCB2bnfnaA8i/vRs5tjUSEOtIuvl9/MyVsvQZCg=="], "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], @@ -2072,12 +2134,20 @@ "@oxc-resolver/binding-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], + "@pmndrs/handle/zustand": ["zustand@4.5.7", "", { "dependencies": { "use-sync-external-store": "^1.2.2" }, "peerDependencies": { "@types/react": ">=16.8", "immer": ">=9.0.6", "react": ">=16.8" }, "optionalPeers": ["@types/react", "immer", "react"] }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="], + + "@pmndrs/xr/@iwer/devui": ["@iwer/devui@1.1.2", "", { "dependencies": { "@fortawesome/fontawesome-svg-core": "6.6.0", "@fortawesome/free-solid-svg-icons": "6.6.0", "@fortawesome/react-fontawesome": "0.2.2", "@pmndrs/handle": "^6.6.17", "@pmndrs/pointer-events": "^6.6.17", "react": ">=18.3.1", "react-dom": ">=18.3.1", "styled-components": "^6.1.13", "three": "^0.165.0" }, "peerDependencies": { "iwer": "^2.0.1" } }, "sha512-ggF1lXSX14BTYP0QzB4xaurySr2PC+3+rtK/dpCR++giWquzFv2mBw3LW/PaCtdl5mqkZMrQ2GSwfUNg9ZoO+w=="], + + "@pmndrs/xr/zustand": ["zustand@4.5.7", "", { "dependencies": { "use-sync-external-store": "^1.2.2" }, "peerDependencies": { "@types/react": ">=16.8", "immer": ">=9.0.6", "react": ">=16.8" }, "optionalPeers": ["@types/react", "immer", "react"] }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="], + "@react-grab/cli/commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], "@react-grab/cli/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], "@react-three/drei/three-mesh-bvh": ["three-mesh-bvh@0.8.3", "", { "peerDependencies": { "three": ">= 0.159.0" } }, "sha512-4G5lBaF+g2auKX3P0yqx+MJC6oVt6sB5k+CchS6Ob0qvH0YIhuUk1eYr7ktsIpY+albCqE80/FVQGV190PmiAg=="], + "@react-three/xr/zustand": ["zustand@4.5.7", "", { "dependencies": { "use-sync-external-store": "^1.2.2" }, "peerDependencies": { "@types/react": ">=16.8", "immer": ">=9.0.6", "react": ">=16.8" }, "optionalPeers": ["@types/react", "immer", "react"] }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" }, "bundled": true }, "sha512-l9Oo58x0HOP5znGzVhYW9U3e5wVuA4LAZU2AGezTmkhO1CgQRFDhDg4nneHsu/t3WniXg9QrG2nIXL/ZS8ln8Q=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg=="], @@ -2112,6 +2182,8 @@ "dotenv-expand/dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], + "dprint-node/detect-libc": ["detect-libc@1.0.3", "", { "bin": { "detect-libc": "./bin/detect-libc.js" } }, "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg=="], + "editor/typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="], "eslint/ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], diff --git a/package.json b/package.json index fcea0ccdde..11827fd0b1 100644 --- a/package.json +++ b/package.json @@ -63,5 +63,9 @@ "apps/*", "packages/*", "tooling/*" - ] + ], + "patchedDependencies": { + "iwer@2.3.0": "patches/iwer@2.3.0.patch", + "three@0.186.0": "patches/three@0.186.0.patch" + } } diff --git a/packages/core/src/registry/handles.ts b/packages/core/src/registry/handles.ts index d14c8eac7f..29ca639b6d 100644 --- a/packages/core/src/registry/handles.ts +++ b/packages/core/src/registry/handles.ts @@ -329,6 +329,8 @@ export type ArcResizeHandle = { * arrow icon, intended for whole-node rotation handles. */ shape?: 'chevron' | 'rotate' + /** Disable the default 15° snap for whole-node rotation. */ + continuous?: boolean /** * Plane the angular drag is measured in: * - 'horizontal' (default): cursor bearing around +Y — whole-node yaw diff --git a/packages/core/src/services/index.ts b/packages/core/src/services/index.ts index 25d99fcd4f..178d47f5d9 100644 --- a/packages/core/src/services/index.ts +++ b/packages/core/src/services/index.ts @@ -97,6 +97,7 @@ export { DEFAULT_GRID_STEP, type SnapServices, snapAngleToList, + snapLocalXZInWorld, snapPointAlongAngleRay, snapPointToAngle, snapPointToGrid, diff --git a/packages/core/src/services/snap-local-xz.test.ts b/packages/core/src/services/snap-local-xz.test.ts new file mode 100644 index 0000000000..b67b8bec0f --- /dev/null +++ b/packages/core/src/services/snap-local-xz.test.ts @@ -0,0 +1,27 @@ +import { expect, test } from 'bun:test' +import { snapLocalXZInWorld } from './snap' + +test('world-axis callbacks receive translated and rotated model coordinates', () => { + const calls: Array<[number, number]> = [] + const result = snapLocalXZInWorld( + [1.2, 2.3], + { position: [10, 0, 20], rotationY: Math.PI / 2 }, + (value, axis) => { + calls.push([value, axis]) + return Math.round(value) + }, + ) + expect(calls[0]?.[0]).toBeCloseTo(12.3) + expect(calls[1]?.[0]).toBeCloseTo(18.8) + expect(calls.map((call) => call[1])).toEqual([0, 1]) + expect(result[0]).toBeCloseTo(1) + expect(result[1]).toBeCloseTo(2) +}) + +test('identity snapping preserves the point in every model frame', () => { + for (const rotationY of [0, 0.7, Math.PI / 2, -Math.PI]) { + const point = snapLocalXZInWorld([2, -6], { position: [5, 0, -3], rotationY }, (value) => value) + expect(point[0]).toBeCloseTo(2) + expect(point[1]).toBeCloseTo(-6) + } +}) diff --git a/packages/core/src/services/snap.ts b/packages/core/src/services/snap.ts index 3c10ba6985..15c83be745 100644 --- a/packages/core/src/services/snap.ts +++ b/packages/core/src/services/snap.ts @@ -9,6 +9,8 @@ * The functions here are stable contract — Phase 3 only adds, never removes. */ +import type { BuildingPose } from './alignment' + export type Vec2 = readonly [number, number] export type Vec3 = readonly [number, number, number] @@ -83,6 +85,22 @@ export function snapWorldXZToBuildingLocal( } } +/** Snap in model-world axes, without presentation transforms affecting the grid. */ +export function snapLocalXZInWorld( + local: Vec2, + pose: BuildingPose | null, + snap: (value: number, axis: 0 | 1) => number, +): [number, number] { + const yaw = pose?.rotationY ?? 0 + const cos = Math.cos(yaw) + const sin = Math.sin(yaw) + const px = pose?.position[0] ?? 0 + const pz = pose?.position[2] ?? 0 + const x = snap(px + local[0] * cos + local[1] * sin, 0) - px + const z = snap(pz - local[0] * sin + local[1] * cos, 1) - pz + return [x * cos - z * sin, x * sin + z * cos] +} + // ─── Angle snap ─────────────────────────────────────────────────────── /** diff --git a/packages/editor/src/components/editor/grid.tsx b/packages/editor/src/components/editor/grid.tsx index 3bdb0b6850..bda40eb44e 100644 --- a/packages/editor/src/components/editor/grid.tsx +++ b/packages/editor/src/components/editor/grid.tsx @@ -1,7 +1,12 @@ 'use client' import { type AnyNodeId, emitter, type GridEvent, sceneRegistry } from '@pascal-app/core' -import { GRID_LAYER, getSceneTheme, useViewer } from '@pascal-app/viewer' +import { + GRID_LAYER, + getSceneTheme, + useImmersiveXRPresentation, + useViewer, +} from '@pascal-app/viewer' import { useFrame } from '@react-three/fiber' import { useEffect, useMemo, useRef, useState } from 'react' import { DoubleSide, type Mesh, PlaneGeometry, Quaternion, Vector2, Vector3 } from 'three' @@ -10,9 +15,12 @@ import { MeshBasicNodeMaterial } from 'three/webgpu' import { useCeilingEvents } from '../../hooks/use-ceiling-events' import { useGridEvents } from '../../hooks/use-grid-events' import { getPlacementSurface, usesOrientedPlacementPlane } from '../../lib/active-placement-surface' -import useEditor, { isGridSnapActive } from '../../store/use-editor' +import { gridLocalNormal, gridLocalPoint } from '../../lib/grid-frame' +import useEditor, { getActiveSnapContext, isGridSnapActive } from '../../store/use-editor' import { getMovingNode } from '../../store/use-interaction-scope' +export const EDITOR_GRID_INPUT_NAME = 'pascal-editor-grid-input' + // Reveal radius (m) of the cursor-local grid patch shown while placing/moving in // grid-snap mode — much tighter than the idle reveal so only the area you're // about to snap into lights up. @@ -45,6 +53,7 @@ export const Grid = ({ fadeStrength?: number revealRadius?: number }) => { + const immersive = useImmersiveXRPresentation() const isDark = useViewer((state) => getSceneTheme(state.sceneTheme).appearance === 'dark') // Use slightly lighter colors for dark themes' grid to make it apparent @@ -60,6 +69,9 @@ export const Grid = ({ const wallCursorRef = useRef(new Vector3()) // Scratch for deriving a moving wall-item's host normal from its mesh. const wallNormalRef = useRef(new Vector3()) + const localSurfaceRef = useRef(new Vector3()) + const localNormalRef = useRef(new Vector3()) + const localCursorRef = useRef(new Vector3()) // Last Y pushed to `gridY` state, so the per-frame surface follow only triggers // a React re-render when the height actually changes (not every frame). const lastGridYRef = useRef(null) @@ -176,10 +188,11 @@ export const Grid = ({ // laid-flat orientation maps `positionLocal.y` to world `-Z` relative to the // mesh origin. The cursor is recomputed every frame from the stored world hit // so the reveal stays put regardless of where the mesh origin sits. - const lastWorldCursorRef = useRef<{ x: number; z: number } | null>(null) + const lastWorldCursorRef = useRef(null) useEffect(() => { const onGridMove = (event: GridEvent) => { - lastWorldCursorRef.current = { x: event.position[0], z: event.position[2] } + lastWorldCursorRef.current ??= new Vector3() + lastWorldCursorRef.current.set(...event.position) } emitter.on('grid:move', onGridMove) @@ -194,7 +207,12 @@ export const Grid = ({ if (levelId) { const levelMesh = sceneRegistry.nodes.get(levelId) if (levelMesh) { - levelY = levelMesh.position.y + levelMesh.getWorldPosition(worldPosRef.current) + levelY = gridLocalPoint( + gridRef.current.parent, + worldPosRef.current, + localSurfaceRef.current, + ).y } } @@ -229,6 +247,10 @@ export const Grid = ({ } const gridMesh = gridRef.current + if (surfacePoint) { + surfacePoint = gridLocalPoint(gridMesh.parent, surfacePoint, localSurfaceRef.current) + surfaceNormal = gridLocalNormal(gridMesh.parent, surfaceNormal, localNormalRef.current) + } const onOrientedPlane = surfacePoint != null && usesOrientedPlacementPlane(surfaceNormal) if (onOrientedPlane && surfacePoint) { // Surface-anchored lattice: orient the plane into the host and pin the mesh to @@ -269,7 +291,8 @@ export const Grid = ({ gridMesh.quaternion.copy(HORIZONTAL_QUATERNION) const world = lastWorldCursorRef.current if (world) { - cursorPositionRef.current.set(world.x, -world.z) + const local = gridLocalPoint(gridMesh.parent, world, localCursorRef.current) + cursorPositionRef.current.set(local.x, -local.z) } if (lastGridYRef.current !== targetY) { lastGridYRef.current = targetY @@ -293,7 +316,7 @@ export const Grid = ({ // from the interaction scope OR the armed build tool and is true only when // that context resolves to grid, so it IS the gate. (Previously this also // required a ghost in flight, so a merely-armed draft tool showed nothing.) - const snapPatchVisible = isGridSnapActive() + const snapPatchVisible = isGridSnapActive() || (immersive && getActiveSnapContext() !== null) revealRadiusUniform.value = PLACEMENT_REVEAL_RADIUS baseAlphaUniform.value = 0 cellSizeUniform.value = useEditor.getState().gridSnapStep @@ -320,6 +343,7 @@ export const Grid = ({ geometry={geometry} layers={GRID_LAYER} material={material} + name={EDITOR_GRID_INPUT_NAME} ref={gridRef} renderOrder={1} /> diff --git a/packages/editor/src/components/editor/group-rotate-handle.tsx b/packages/editor/src/components/editor/group-rotate-handle.tsx index 939f17b192..bf6b8606da 100644 --- a/packages/editor/src/components/editor/group-rotate-handle.tsx +++ b/packages/editor/src/components/editor/group-rotate-handle.tsx @@ -13,11 +13,13 @@ import { import { useViewer } from '@pascal-app/viewer' import { createPortal, type ThreeEvent, useThree } from '@react-three/fiber' import { useEffect, useMemo, useRef, useState } from 'react' -import { OrthographicCamera, Plane, Vector2, Vector3 } from 'three' +import { OrthographicCamera, Plane, type Ray, Vector2, Vector3 } from 'three' import { GROUP_MOVE_DRAG_LABEL, GROUP_ROTATE_DRAG_LABEL } from '../../lib/contextual-help' import { isHistoryShortcut } from '../../lib/history' import { sfxEmitter } from '../../lib/sfx-bus' -import useEditor from '../../store/use-editor' +import { intersectSpatialDragPlane } from '../../lib/spatial-drag-plane' +import { getSpatialPointerId, spatialPointerInput } from '../../lib/spatial-pointer-input' +import useEditor, { isAngleSnapActive } from '../../store/use-editor' import useInteractionScope, { useActiveHandleDrag, useMovingNode, @@ -167,6 +169,12 @@ function GroupRotateHandleInner({ ids, meshEpoch }: { ids: string[]; meshEpoch: if (event.button !== 0) return event.stopPropagation() suppressBoxSelectForPointer(event) + const spatialPointerId = getSpatialPointerId(event.nativeEvent) + const spatialRay = spatialPointerId ? event.ray.clone() : null + const pointerTarget = event.object as typeof event.object & { + releasePointerCapture?: (pointerId: number) => void + setPointerCapture?: (pointerId: number) => void + } frozenRest.current = { pivot: rest.pivot.clone(), corner: rest.corner.clone() } const center = rest.pivot.clone() @@ -217,11 +225,15 @@ function GroupRotateHandleInner({ ids, meshEpoch }: { ids: string[]; meshEpoch: ) } - setNDC(event.nativeEvent.clientX, event.nativeEvent.clientY) - raycaster.setFromCamera(ndc, camera) + if (!spatialRay) { + setNDC(event.nativeEvent.clientX, event.nativeEvent.clientY) + raycaster.setFromCamera(ndc, camera) + } const hit = new Vector3() - if (!raycaster.ray.intersectPlane(plane, hit)) return + if (!intersectSpatialDragPlane(spatialRay ?? raycaster.ray, plane, hit)) return const initialAngle = angleOf(hit) + if (spatialPointerId) pointerTarget.setPointerCapture?.(event.pointerId) + let altKey = event.nativeEvent.altKey document.body.style.cursor = 'grabbing' sfxEmitter.emit('sfx:item-pick') @@ -234,15 +246,14 @@ function GroupRotateHandleInner({ ids, meshEpoch }: { ids: string[]; meshEpoch: }) setIsDragging(true) - const onMove = (e: PointerEvent) => { - setNDC(e.clientX, e.clientY) - raycaster.setFromCamera(ndc, camera) + const applyRay = (ray: Ray, freeRotation = false) => { const moveHit = new Vector3() - if (!raycaster.ray.intersectPlane(plane, moveHit)) return + if (!intersectSpatialDragPlane(ray, plane, moveHit)) return let delta = angleOf(moveHit) - initialAngle while (delta > Math.PI) delta -= 2 * Math.PI while (delta < -Math.PI) delta += 2 * Math.PI - if (!e.shiftKey) delta = Math.round(delta / DEFAULT_ANGLE_STEP) * DEFAULT_ANGLE_STEP + if (!freeRotation && isAngleSnapActive()) + delta = Math.round(delta / DEFAULT_ANGLE_STEP) * DEFAULT_ANGLE_STEP // Shared rigid-rotation math (also used by the keyboard group R/T); // see `rotateGroupPatches` for the orbit/yaw handedness contract. @@ -290,8 +301,14 @@ function GroupRotateHandleInner({ ids, meshEpoch }: { ids: string[]; meshEpoch: }) } } + const onMove = (e: PointerEvent) => { + setNDC(e.clientX, e.clientY) + raycaster.setFromCamera(ndc, camera) + applyRay(raycaster.ray, e.altKey) + } const affectedIds: AnyNodeId[] = [...starts.map((s) => s.id), ...links.map((l) => l.id)] + let releaseSpatialCapture: (() => void) | null = null const clearLivePreviews = () => { const overrides = useLiveNodeOverrides.getState() const liveTransforms = useLiveTransforms.getState() @@ -307,6 +324,10 @@ function GroupRotateHandleInner({ ids, meshEpoch }: { ids: string[]; meshEpoch: window.removeEventListener('pointerup', onUp) window.removeEventListener('pointercancel', onCancel) window.removeEventListener('keydown', onKeyDown, true) + window.removeEventListener('keyup', onKeyUp, true) + releaseSpatialCapture?.() + releaseSpatialCapture = null + if (spatialPointerId) pointerTarget.releasePointerCapture?.(event.pointerId) if (document.body.style.cursor === 'grabbing') document.body.style.cursor = '' useScene.temporal.getState().resume() useViewer.getState().setInputDragging(false) @@ -356,12 +377,19 @@ function GroupRotateHandleInner({ ids, meshEpoch }: { ids: string[]; meshEpoch: // Escape / ⌘Z abort the rotate — capture phase so they win over the global // use-keyboard arms (⌘Z must never history-jump under a live pointer). const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Alt') { + altKey = true + return + } if (e.key !== 'Escape' && !isHistoryShortcut(e)) return e.preventDefault() e.stopPropagation() swallowNextClick() onCancel() } + const onKeyUp = (e: KeyboardEvent) => { + if (e.key === 'Alt') altKey = false + } dragCleanupRef.current = () => { clearLivePreviews() @@ -370,10 +398,22 @@ function GroupRotateHandleInner({ ids, meshEpoch }: { ids: string[]; meshEpoch: for (const id of affectedIds) { useLiveTransforms.getState().clear(id) } - window.addEventListener('pointermove', onMove) - window.addEventListener('pointerup', onUp) - window.addEventListener('pointercancel', onCancel) + if (spatialPointerId && spatialRay) { + releaseSpatialCapture = spatialPointerInput.capture(spatialPointerId, { + onMove: (ray) => { + spatialRay.copy(ray) + applyRay(spatialRay, altKey) + }, + onRelease: onUp, + onCancel, + }) + } else { + window.addEventListener('pointermove', onMove) + window.addEventListener('pointerup', onUp) + window.addEventListener('pointercancel', onCancel) + } window.addEventListener('keydown', onKeyDown, true) + window.addEventListener('keyup', onKeyUp, true) } return createPortal( @@ -402,6 +442,7 @@ function GroupRotateHandleInner({ ids, meshEpoch }: { ids: string[]; meshEpoch: onPointerDown={activate} onPointerEnter={onHoverEnter} onPointerLeave={onHoverLeave} + {...({ pointerEventsOrder: 10 } as Record)} scale={baseScale} /> )} renderOrder={1010} scale={scale} /> diff --git a/packages/editor/src/components/editor/handles/handle-arrow.tsx b/packages/editor/src/components/editor/handles/handle-arrow.tsx index 727c75f948..72773917cb 100644 --- a/packages/editor/src/components/editor/handles/handle-arrow.tsx +++ b/packages/editor/src/components/editor/handles/handle-arrow.tsx @@ -1,6 +1,6 @@ 'use client' -import { type Cursor, emitter } from '@pascal-app/core' +import { type Cursor, emitter, sceneRegistry } from '@pascal-app/core' import type { ThreeEvent } from '@react-three/fiber' import { type ReactNode, useEffect, useMemo, useRef } from 'react' import { @@ -17,11 +17,15 @@ import { type Raycaster, Shape, TorusGeometry, + Vector3, } from 'three' import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' import { MeshBasicNodeMaterial } from 'three/webgpu' import { EDITOR_LAYER } from '../../../lib/constants' import { EDITOR_HANDLE_HIT_AREA_USER_DATA_KEY } from '../../../lib/direct-manipulation' +import { createSpatialDragPlane, intersectSpatialDragPlane } from '../../../lib/spatial-drag-plane' +import { getSpatialPointerId, spatialPointerInput } from '../../../lib/spatial-pointer-input' +import { getActiveBuildingId } from '../../../lib/world-grid-snap' import useEditor from '../../../store/use-editor' // While a press-drag move is in flight (`placementDragMode`), the move tool @@ -42,6 +46,7 @@ export const NO_RAYCAST = () => null export const HIT_AREA_MARGIN = 0.035 const HIT_AREA_RENDER_ORDER = 1011 +const HIT_AREA_POINTER_EVENTS_ORDER = 10 const HIT_AREA_THICKNESS = 0.08 const CHEVRON_MIN_X = -0.2 const CHEVRON_MAX_X = 0.22 @@ -492,15 +497,80 @@ export function InvisibleHandleHitArea({ onPointerLeave: PointerHandler scale: number }) { + const handlePointerDown: PointerHandler = (event) => { + const spatialPointerId = getSpatialPointerId(event.nativeEvent) + if (spatialPointerId) { + const pointerTarget = event.object as typeof event.object & { + releasePointerCapture?: (pointerId: number) => void + setPointerCapture?: (pointerId: number) => void + } + pointerTarget.setPointerCapture?.(event.pointerId) + const buildingId = getActiveBuildingId() + const building = buildingId ? sceneRegistry.nodes.get(buildingId) : undefined + building?.updateWorldMatrix(true, false) + const inverse = building?.matrixWorld.clone().invert() + const initialPoint = event.point.clone() + const plane = createSpatialDragPlane(initialPoint, event.ray, building?.matrixWorld) + const point = new Vector3() + let seeded = false + const emitPoint = (position: Vector3, ray = event.ray) => { + const local = inverse ? position.clone().applyMatrix4(inverse) : position + emitter.emit('grid:move', { + position: [position.x, position.y, position.z], + localPosition: [local.x, local.y, local.z], + nativeEvent: { + ...event.nativeEvent, + inputSource: spatialPointerId, + pointerType: 'xr', + pointerId: event.pointerId, + target: event.nativeEvent.target, + ray: ray.clone(), + } as never, + }) + } + // Direct resize sessions replace this capture in onPointerDown below. + // Move/reshape tools consume grid events in a plane frozen at the grip. + spatialPointerInput.capture(spatialPointerId, { + onMove: (ray) => { + if (!seeded) { + emitPoint(initialPoint) + seeded = true + } + if (intersectSpatialDragPlane(ray, plane, point)) emitPoint(point, ray) + }, + onRelease: () => { + pointerTarget.releasePointerCapture?.(event.pointerId) + window.dispatchEvent( + new PointerEvent('pointerup', { + bubbles: true, + button: 0, + pointerId: event.pointerId, + pointerType: 'xr', + }), + ) + }, + onCancel: () => { + pointerTarget.releasePointerCapture?.(event.pointerId) + emitter.emit('tool:cancel') + }, + onReplace: () => { + pointerTarget.releasePointerCapture?.(event.pointerId) + }, + }) + } + onPointerDown(event) + } + return ( )} raycast={hitAreaRaycast} renderOrder={HIT_AREA_RENDER_ORDER} scale={scale} diff --git a/packages/editor/src/components/editor/handles/use-handle-drag.ts b/packages/editor/src/components/editor/handles/use-handle-drag.ts index fba485dc2c..f602f70489 100644 --- a/packages/editor/src/components/editor/handles/use-handle-drag.ts +++ b/packages/editor/src/components/editor/handles/use-handle-drag.ts @@ -17,6 +17,8 @@ import { useEffect, useRef } from 'react' import { type Camera, type Object3D, type Plane, type Ray, Vector2, type Vector3 } from 'three' import { isHistoryShortcut } from '../../../lib/history' import { sfxEmitter } from '../../../lib/sfx-bus' +import { intersectSpatialDragPlane } from '../../../lib/spatial-drag-plane' +import { getSpatialPointerId, spatialPointerInput } from '../../../lib/spatial-pointer-input' import { suppressBoxSelectForPointer } from '../../tools/select/box-select-state' import { commitHandleDragPatch } from './handle-drag-history' @@ -107,6 +109,7 @@ function suppressInputDraggingUntilPointerRelease(pointerId: number) { window.addEventListener('pointerup', restore) window.addEventListener('pointercancel', restore) window.addEventListener('blur', onBlur) + return () => restore() } export function useHandleDrag(args: UseHandleDragArgs) { @@ -121,9 +124,17 @@ export function useHandleDrag(args: UseHandleDragArgs) { if (event.button !== 0) return event.stopPropagation() suppressBoxSelectForPointer(event) + const spatialPointerId = getSpatialPointerId(event.nativeEvent) if (args.kind === 'tap') { - suppressInputDraggingUntilPointerRelease(event.nativeEvent.pointerId) + const restoreInputDragging = suppressInputDraggingUntilPointerRelease(event.pointerId) + if (spatialPointerId) { + spatialPointerInput.capture(spatialPointerId, { + onMove: () => undefined, + onRelease: restoreInputDragging, + onCancel: restoreInputDragging, + }) + } swallowNextClick() sfxEmitter.emit('sfx:item-pick') document.body.style.cursor = '' @@ -133,6 +144,7 @@ export function useHandleDrag(args: UseHandleDragArgs) { const { cursor, dragControls, handleIndex, node, rideObject, setIsDragging } = args rideObject.updateMatrixWorld() + const spatialRay = spatialPointerId ? event.ray.clone() : null const ndc = new Vector2() const setPointerRay = (clientX: number, clientY: number) => { @@ -144,10 +156,12 @@ export function useHandleDrag(args: UseHandleDragArgs) { raycaster.setFromCamera(ndc, camera) } const getPointerRay: GetPointerRay = (clientX, clientY, target) => { + if (spatialRay) return target.copy(spatialRay) setPointerRay(clientX, clientY) return target.copy(raycaster.ray) } const intersectPlane: IntersectPlane = (clientX, clientY, plane, target) => { + if (spatialRay) return intersectSpatialDragPlane(spatialRay, plane, target) setPointerRay(clientX, clientY) return raycaster.ray.intersectPlane(plane, target) } @@ -180,7 +194,10 @@ export function useHandleDrag(args: UseHandleDragArgs) { let lastPatch: Partial | null = null let historyPaused = true + let releaseSpatialCapture: (() => void) | null = null let altKey = event.nativeEvent.altKey + let pendingMoveEvent: PointerEvent | null = null + let moveFrame = 0 const resumeHistory = () => { if (!historyPaused) return @@ -188,7 +205,7 @@ export function useHandleDrag(args: UseHandleDragArgs) { useScene.temporal.getState().resume() } - const onMove = (moveEvent: PointerEvent) => { + const processMove = (moveEvent: PointerEvent) => { const patch = session.move({ event: moveEvent, modifiers: { altKey }, @@ -204,12 +221,35 @@ export function useHandleDrag(args: UseHandleDragArgs) { } } + // Coalesce high-frequency pointer events so live React/store updates happen + // at most once per animation frame while retaining the newest position. + const flushMove = () => { + moveFrame = 0 + const moveEvent = pendingMoveEvent + pendingMoveEvent = null + if (moveEvent) processMove(moveEvent) + } + const onMove = (moveEvent: PointerEvent) => { + pendingMoveEvent = moveEvent + if (moveFrame !== 0) return + if (typeof window.requestAnimationFrame === 'function') { + moveFrame = window.requestAnimationFrame(flushMove) + } else { + flushMove() + } + } + const cleanup = () => { + if (moveFrame !== 0) window.cancelAnimationFrame?.(moveFrame) + moveFrame = 0 + pendingMoveEvent = null window.removeEventListener('pointermove', onMove) window.removeEventListener('pointerup', onUp) window.removeEventListener('pointercancel', onCancel) window.removeEventListener('keydown', onKeyDown, true) window.removeEventListener('keyup', onKeyUp, true) + releaseSpatialCapture?.() + releaseSpatialCapture = null if (document.body.style.cursor === cursor) { document.body.style.cursor = '' } @@ -230,6 +270,8 @@ export function useHandleDrag(args: UseHandleDragArgs) { } const onUp = () => { + if (moveFrame !== 0) window.cancelAnimationFrame?.(moveFrame) + flushMove() swallowNextClick() sfxEmitter.emit('sfx:item-place') if (lastPatch) { @@ -267,9 +309,28 @@ export function useHandleDrag(args: UseHandleDragArgs) { } dragCleanupRef.current = onCancel - window.addEventListener('pointermove', onMove) - window.addEventListener('pointerup', onUp) - window.addEventListener('pointercancel', onCancel) + if (spatialPointerId && spatialRay) { + releaseSpatialCapture = spatialPointerInput.capture(spatialPointerId, { + onMove: (ray) => { + spatialRay.copy(ray) + processMove( + new PointerEvent('pointermove', { + button: 0, + buttons: 1, + altKey, + pointerId: event.pointerId, + pointerType: 'xr', + }), + ) + }, + onRelease: onUp, + onCancel, + }) + } else { + window.addEventListener('pointermove', onMove) + window.addEventListener('pointerup', onUp) + window.addEventListener('pointercancel', onCancel) + } window.addEventListener('keydown', onKeyDown, true) window.addEventListener('keyup', onKeyUp, true) } diff --git a/packages/editor/src/components/editor/index.tsx b/packages/editor/src/components/editor/index.tsx index c9d2bbc890..157809cdf6 100644 --- a/packages/editor/src/components/editor/index.tsx +++ b/packages/editor/src/components/editor/index.tsx @@ -19,6 +19,7 @@ import { SceneEnvironment, useViewer, Viewer, + type ViewerImmersiveSession, ViewerPresentations, } from '@pascal-app/viewer' import { @@ -235,6 +236,9 @@ export interface EditorProps { */ disablePostFx?: boolean + /** Host-provided immersive XR runtime for the main 3D canvas. */ + immersive?: ViewerImmersiveSession + // Version preview overlays (rendered by host app) sidebarOverlay?: ReactNode viewerBanner?: ReactNode @@ -784,6 +788,7 @@ const ViewerSceneContent = memo(function ViewerSceneContent({ isVersionPreviewMode, isLoading, isFirstPersonMode, + isXRMode, isStudioMode, onThumbnailCapture, viewerSceneSlot, @@ -792,6 +797,7 @@ const ViewerSceneContent = memo(function ViewerSceneContent({ isVersionPreviewMode: boolean isLoading: boolean isFirstPersonMode: boolean + isXRMode: boolean isStudioMode: boolean onThumbnailCapture?: (blob: Blob, cameraData: SnapshotCameraData) => void viewerSceneSlot?: ReactNode @@ -808,7 +814,7 @@ const ViewerSceneContent = memo(function ViewerSceneContent({ <> {!(isFirstPersonMode || isStudioMode || isCaptureMode) && } - {!noEditing && } + {!(noEditing || isXRMode) && } {!noEditing && } {!noEditing && } {!noEditing && } @@ -816,10 +822,10 @@ const ViewerSceneContent = memo(function ViewerSceneContent({ {!noEditing && } {!noEditing && } {!noEditing && } - {!noEditing && } - {!noEditing && } - {!noEditing && } - {!isFirstPersonMode && } + {!(noEditing || isXRMode) && } + {!(noEditing || isXRMode) && } + {!(noEditing || isXRMode) && } + {!(isFirstPersonMode || isXRMode) && } {isFirstPersonMode ? : } @@ -830,10 +836,10 @@ const ViewerSceneContent = memo(function ViewerSceneContent({ {!(isLoading || isFirstPersonMode) && } {!(isLoading || noEditing) && } {isFirstPersonMode && } - {isCaptureMode && } - - - {!isFirstPersonMode && } + {isCaptureMode && !isXRMode && } + {!isXRMode && } + {!isXRMode && } + {!(isFirstPersonMode || isXRMode) && } {presentationsReady ? : null} {!noEditing && viewerSceneSlot} @@ -1023,6 +1029,7 @@ const ViewerCanvas = memo(function ViewerCanvas({ viewerSceneSlot, floorplanSceneSlot, disablePostFx = false, + immersive, }: { isVersionPreviewMode: boolean isLoading: boolean @@ -1037,6 +1044,7 @@ const ViewerCanvas = memo(function ViewerCanvas({ viewerSceneSlot?: ReactNode floorplanSceneSlot?: ReactNode disablePostFx?: boolean + immersive?: ViewerImmersiveSession }) { const viewMode = useEditor((s) => s.viewMode) const floorplanPaneRatio = useEditor((s) => s.floorplanPaneRatio) @@ -1165,6 +1173,7 @@ const ViewerCanvas = memo(function ViewerCanvas({ renderContext="editor" renderPaused={!show3d && !showLoader} sceneReadyKey={sceneReadyKey} + immersive={immersive} // Walk/drone framing during snapshot capture is camera-only: the // viewer's default selection manager would hover-highlight whatever // the cursor crosses, which orbit capture never does. @@ -1173,6 +1182,7 @@ const ViewerCanvas = memo(function ViewerCanvas({ ) diff --git a/packages/editor/src/components/editor/node-arrow-handles.tsx b/packages/editor/src/components/editor/node-arrow-handles.tsx index 28d5d32b86..652d56cf98 100644 --- a/packages/editor/src/components/editor/node-arrow-handles.tsx +++ b/packages/editor/src/components/editor/node-arrow-handles.tsx @@ -46,7 +46,11 @@ import { RESIZE_HANDLE_DRAG_LABEL, ROTATE_HANDLE_DRAG_LABEL } from '../../lib/co import { createEditorApi } from '../../lib/editor-api' import { sfxEmitter } from '../../lib/sfx-bus' import useDirectManipulationFeedback from '../../store/use-direct-manipulation-feedback' -import useEditor, { isGridSnapActive, isMagneticSnapActive } from '../../store/use-editor' +import useEditor, { + isAngleSnapActive, + isGridSnapActive, + isMagneticSnapActive, +} from '../../store/use-editor' import { useHandleGroup } from '../../store/use-handle-group' import useInteractionScope, { useEndpointReshape, @@ -1219,10 +1223,7 @@ function ArcArrow({ } const initialAngle = angleOf(hitWorld) - // Advertise the rotate interaction so the contextual HUD can surface the - // Shift = free-rotation toggle (the angle-step bypass below). Resize - // handles route a measurement label here; rotate gets a sentinel label so - // the HUD shows the rotate hint, not a dimension pill. + // A distinct label selects rotation snapping instead of resize measurements. if (isRotateShape) { useInteractionScope .getState() @@ -1244,7 +1245,12 @@ function ArcArrow({ while (delta > Math.PI) delta -= 2 * Math.PI while (delta < -Math.PI) delta += 2 * Math.PI - if (!moveEvent.shiftKey && descriptor.shape === 'rotate') { + if ( + !descriptor.continuous && + !moveEvent.altKey && + isAngleSnapActive() && + descriptor.shape === 'rotate' + ) { delta = Math.round(delta / DEFAULT_ANGLE_STEP) * DEFAULT_ANGLE_STEP } diff --git a/packages/editor/src/components/editor/wall-move-side-handles.tsx b/packages/editor/src/components/editor/wall-move-side-handles.tsx index 80568c44ee..18ff741485 100644 --- a/packages/editor/src/components/editor/wall-move-side-handles.tsx +++ b/packages/editor/src/components/editor/wall-move-side-handles.tsx @@ -49,6 +49,8 @@ import { import { isHistoryShortcut } from '../../lib/history' import { endpointReshapeScope } from '../../lib/interaction/scope' import { sfxEmitter } from '../../lib/sfx-bus' +import { intersectSpatialDragPlane, spatialDragLocalY } from '../../lib/spatial-drag-plane' +import { getSpatialPointerId, spatialPointerInput } from '../../lib/spatial-pointer-input' import useEditor, { isGridSnapActive, isMagneticSnapActive } from '../../store/use-editor' import useInteractionScope, { useEndpointReshape, @@ -769,7 +771,9 @@ function WallBaseElevationHandle({ const midpointWorld = new Vector3(midpoint[0], initialBase, midpoint[1]).applyMatrix4( levelObject.matrixWorld, ) - const planeNormal = new Vector3().subVectors(camera.position, midpointWorld).setY(0) + const planeNormal = new Vector3() + .subVectors(camera.getWorldPosition(new Vector3()), midpointWorld) + .setY(0) if (planeNormal.lengthSq() === 0) return null planeNormal.normalize() const plane = new Plane().setFromNormalAndCoplanarPoint(planeNormal, midpointWorld) @@ -908,16 +912,12 @@ function WallHeightArrowHandle({ wall }: { wall: WallNode }) { const levelObject = wall.parentId ? sceneRegistry.nodes.get(wall.parentId) : null if (!levelObject) return - // Vertical plane through the wall midpoint whose normal points toward - // the camera (projected to horizontal). Raycasting against it converts - // pointer movement into a world-space Y value. + levelObject.updateWorldMatrix(true, false) + const worldToLocal = levelObject.matrixWorld.clone().invert() const midpointWorld = new Vector3(midX, 0, midZ).applyMatrix4(levelObject.matrixWorld) - const planeNormal = new Vector3().subVectors(camera.position, midpointWorld).setY(0) - if (planeNormal.lengthSq() === 0) return - planeNormal.normalize() - const plane = new Plane().setFromNormalAndCoplanarPoint(planeNormal, midpointWorld) - const ndc = new Vector2() + const spatialPointerId = getSpatialPointerId(event.nativeEvent) + const spatialRay = spatialPointerId ? event.ray.clone() : null const setNDC = (clientX: number, clientY: number) => { const rect = gl.domElement.getBoundingClientRect() ndc.set( @@ -926,17 +926,39 @@ function WallHeightArrowHandle({ wall }: { wall: WallNode }) { ) } - setNDC(event.nativeEvent.clientX, event.nativeEvent.clientY) - raycaster.setFromCamera(ndc, camera) - const hit = new Vector3() - if (!raycaster.ray.intersectPlane(plane, hit)) return + let plane: Plane | null = null + let initialY: number + if (spatialRay) { + const worldUp = new Vector3(0, 1, 0).transformDirection(levelObject.matrixWorld) + const axisPoint = new Vector3() + const spatialRayLocalY = (ray: Ray) => { + const axisParameter = closestAxisParameterToRay(midpointWorld, worldUp, ray) + axisPoint.copy(midpointWorld).addScaledVector(worldUp, axisParameter) + return spatialDragLocalY(axisPoint, worldToLocal) + } + initialY = spatialRayLocalY(spatialRay) + } else { + // A camera-facing vertical plane gives desktop pointer movement a stable + // world-space height. Spatial pointers use the closest point on the + // wall's vertical axis instead, which remains defined for vertical rays. + const planeNormal = new Vector3() + .subVectors(camera.getWorldPosition(new Vector3()), midpointWorld) + .setY(0) + if (planeNormal.lengthSq() === 0) return + plane = new Plane().setFromNormalAndCoplanarPoint(planeNormal.normalize(), midpointWorld) + setNDC(event.nativeEvent.clientX, event.nativeEvent.clientY) + raycaster.setFromCamera(ndc, camera) + const hit = new Vector3() + if (!intersectSpatialDragPlane(raycaster.ray, plane, hit)) return + initialY = spatialDragLocalY(hit, worldToLocal) + } // Dragging the top makes the wall custom-height; seed from the resolved // effective height so a plane-bound wall's drag starts at its real top. const initialHeight = getWallEffectiveHeightForNodes(wall, useScene.getState().nodes) - const initialY = hit.y const wallId = wall.id as AnyNodeId let pendingHeight = initialHeight + let releaseSpatialCapture: (() => void) | null = null document.body.style.cursor = 'ns-resize' sfxEmitter.emit('sfx:item-pick') @@ -954,9 +976,23 @@ function WallHeightArrowHandle({ wall }: { wall: WallNode }) { const onMove = (e: PointerEvent) => { setNDC(e.clientX, e.clientY) raycaster.setFromCamera(ndc, camera) - const intersection = new Vector3() - if (!raycaster.ray.intersectPlane(plane, intersection)) return - const newHeight = Math.max(MIN_WALL_HEIGHT, initialHeight + (intersection.y - initialY)) + applyRay(raycaster.ray) + } + const applyRay = (ray: Ray) => { + let currentY: number + if (spatialRay) { + const worldUp = new Vector3(0, 1, 0).transformDirection(levelObject.matrixWorld) + const axisParameter = closestAxisParameterToRay(midpointWorld, worldUp, ray) + const axisPoint = midpointWorld.clone().addScaledVector(worldUp, axisParameter) + currentY = spatialDragLocalY(axisPoint, worldToLocal) + } else { + if (!plane) return + const intersection = new Vector3() + if (!intersectSpatialDragPlane(ray, plane, intersection)) return + currentY = spatialDragLocalY(intersection, worldToLocal) + } + const newHeight = Math.max(MIN_WALL_HEIGHT, initialHeight + (currentY - initialY)) + if (Math.abs(newHeight - pendingHeight) < 1e-6) return pendingHeight = newHeight useLiveNodeOverrides.getState().set(wallId, { height: newHeight }) useScene.getState().markDirty(wallId) @@ -967,6 +1003,8 @@ function WallHeightArrowHandle({ wall }: { wall: WallNode }) { window.removeEventListener('pointerup', onUp) window.removeEventListener('pointercancel', onCancel) window.removeEventListener('keydown', onKeyDown, true) + releaseSpatialCapture?.() + releaseSpatialCapture = null if (document.body.style.cursor === 'ns-resize') { document.body.style.cursor = '' } @@ -981,6 +1019,7 @@ function WallHeightArrowHandle({ wall }: { wall: WallNode }) { // Commit: write the final override-merged value to zustand once // (tracked, undoable), then drop the override so the renderer // falls back to the scene store. + useScene.temporal.getState().resume() if (pendingHeight !== initialHeight) { useScene.getState().updateNode(wallId, { height: pendingHeight }) } @@ -1006,10 +1045,21 @@ function WallHeightArrowHandle({ wall }: { wall: WallNode }) { onCancel() } - dragCleanupRef.current = cleanup - window.addEventListener('pointermove', onMove) - window.addEventListener('pointerup', onUp) - window.addEventListener('pointercancel', onCancel) + dragCleanupRef.current = onCancel + if (spatialPointerId && spatialRay) { + releaseSpatialCapture = spatialPointerInput.capture(spatialPointerId, { + onMove: (ray) => { + spatialRay.copy(ray) + applyRay(spatialRay) + }, + onRelease: onUp, + onCancel, + }) + } else { + window.addEventListener('pointermove', onMove) + window.addEventListener('pointerup', onUp) + window.addEventListener('pointercancel', onCancel) + } window.addEventListener('keydown', onKeyDown, true) } diff --git a/packages/editor/src/components/editor/wall-opening-highlights.tsx b/packages/editor/src/components/editor/wall-opening-highlights.tsx index a72bfc1b2b..1f22b690b9 100644 --- a/packages/editor/src/components/editor/wall-opening-highlights.tsx +++ b/packages/editor/src/components/editor/wall-opening-highlights.tsx @@ -11,10 +11,10 @@ import { type Group, type Mesh, MeshBasicMaterial, - Vector3, } from 'three' import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu' import { EDITOR_LAYER } from '../../lib/constants' +import { copyOverlayWorldTransform } from '../../lib/overlay-transform' // How far the outline sits outside the opening's own extents, so it reads as // a frame *around* the opening rather than coinciding with its edges. @@ -23,7 +23,6 @@ const PAD = 0.05 const ACCENT = 0x83_81_ed const NO_RAYCAST = () => null -const scratchScale = new Vector3() // Indigo accent matching the resize-arrow handles — deliberately distinct // from the white selection outline so the highlight reads as "editable child @@ -182,7 +181,7 @@ function OpeningHighlight({ openingId, depth }: { openingId: string; depth: numb return } group.visible = true - obj.matrixWorld.decompose(group.position, group.quaternion, scratchScale) + copyOverlayWorldTransform(obj, group) }) if (!isOpening || !outlineGeometry || !fillGeometry) return null diff --git a/packages/editor/src/components/tools/item/placement-strategies.ts b/packages/editor/src/components/tools/item/placement-strategies.ts index ea23736c0d..346689c7be 100644 --- a/packages/editor/src/components/tools/item/placement-strategies.ts +++ b/packages/editor/src/components/tools/item/placement-strategies.ts @@ -24,12 +24,13 @@ import { resolveSurfacePlacement, roofFacePointToSegment, sceneRegistry, + snapLocalXZInWorld, useScene, wouldCreateHostingCycle, } from '@pascal-app/core' import { Euler, Quaternion, Vector3 } from 'three' import { hasRoofFaceChildOverlap, resolveRoofWallHit } from '../../../lib/roof-wall-hit' -import { snapWorldXZForActiveBuilding } from '../../../lib/world-grid-snap' +import { getActiveBuildingPose } from '../../../lib/world-grid-snap' import { itemEventToSurfaceHit, surfaceWorldNormalY } from '../shared/surface-hit' import { calculateItemRotation, @@ -76,11 +77,12 @@ export const floorStrategy = { // the world grid. // Snapping is governed by the active mode (snapToGrid returns raw in Off / // non-grid modes); Alt is force-place only and never bypasses snapping here. - const [x, z] = snapWorldXZForActiveBuilding( - snapToGrid(event.position[0], swapDims ? dimZ : dimX), - snapToGrid(event.position[2], swapDims ? dimX : dimZ), - 0, - ).local + const dimensions = [swapDims ? dimZ : dimX, swapDims ? dimX : dimZ] as const + const [x, z] = snapLocalXZInWorld( + [event.localPosition[0], event.localPosition[2]], + getActiveBuildingPose(), + (value, axis) => snapToGrid(value, dimensions[axis]), + ) const y = ctx.gridPosition.y return { diff --git a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx index 38b7efc4a6..0964eaefb8 100644 --- a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx +++ b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx @@ -82,7 +82,6 @@ import { } from '../shared/placement-box-geometry' import { type PointerSupportSurface, - resolvePointerSupportElevation, resolvePointerSupportSurface, } from '../shared/pointer-support-cap' import { createShelfStickiness } from '../shared/shelf-stickiness' @@ -1082,7 +1081,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea // perspective-skewed along the ray whenever the plane sits on a // different storey than the pointed surface (the skew is what made // a drag over a deck-above-a-floor hop between the two surfaces). - const pointed = resolvePointerSupportSurface(cameraRef.current, event.position) + const pointed = resolvePointerSupportSurface(cameraRef.current, event.position, { + pointerRay: event.nativeEvent.ray, + }) pointerSupportCapRef.current = pointed?.elevation ?? null pointerSupportSurfaceRef.current = pointed const surfaceEvent: GridEvent = @@ -1773,11 +1774,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea // Landing back on the floor: refresh the pointer surface cap from // this event's world hit so the first floor position already targets // the aimed-at surface (not a deck above it). - pointerSupportCapRef.current = resolvePointerSupportElevation(cameraRef.current, [ - event.position[0], - event.position[1], - event.position[2], - ]) + pointerSupportCapRef.current = + resolvePointerSupportSurface(cameraRef.current, event.position, { + pointerRay: event.nativeEvent.ray, + })?.elevation ?? null // Coming back from a host: forget the floor grab too, so the item // centers under the cursor instead of restoring the pre-drag offset — // and landing on the floor is "anchoring elsewhere", so a later return @@ -2875,8 +2875,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea // instead of drawing an inline triangle. Only this coordinator publishes — a // moving existing node has no draft here, so the grid reads that case straight // off the node's mesh. Cleared when idle. - const surfaceNormalRef = useRef(new Vector3(0, 1, 0)) const surfaceWorldPointRef = useRef(new Vector3()) + const surfaceNormalRef = useRef(new Vector3(0, 1, 0)) const facingForwardRef = useRef(new Vector3(0, 0, 1)) const facingQuatRef = useRef(new Quaternion()) const ghostSurfaceQuatRef = useRef(new Quaternion()) diff --git a/packages/editor/src/components/tools/shared/affordance-dispatch.test.ts b/packages/editor/src/components/tools/shared/affordance-dispatch.test.ts new file mode 100644 index 0000000000..53e0fb68ed --- /dev/null +++ b/packages/editor/src/components/tools/shared/affordance-dispatch.test.ts @@ -0,0 +1,24 @@ +import { afterAll, afterEach, expect, spyOn, test } from 'bun:test' +import { type AnyNodeDefinition, nodeRegistry } from '@pascal-app/core' +import { getRegistryAffordanceTool, preloadRegistryAffordanceTools } from './affordance-dispatch' + +const get = spyOn(nodeRegistry, 'get') +afterEach(() => get.mockReset()) +afterAll(() => get.mockRestore()) + +test('a preloaded drag tool mounts directly without first-gesture suspense', async () => { + const Tool = () => null + const loader = async () => ({ default: Tool }) + get.mockReturnValue({ affordanceTools: { move: loader } } as unknown as AnyNodeDefinition) + await preloadRegistryAffordanceTools('wall') + expect(getRegistryAffordanceTool('wall', 'move')).toBe(Tool) + expect(getRegistryAffordanceTool('wall', 'move')).toBe(Tool) +}) + +test('preloading does not change an already mounted lazy component identity', async () => { + const loader = async () => ({ default: () => null }) + get.mockReturnValue({ affordanceTools: { move: loader } } as unknown as AnyNodeDefinition) + const mounted = getRegistryAffordanceTool('wall', 'move') + await preloadRegistryAffordanceTools('wall') + expect(getRegistryAffordanceTool('wall', 'move')).toBe(mounted) +}) diff --git a/packages/editor/src/components/tools/shared/affordance-dispatch.ts b/packages/editor/src/components/tools/shared/affordance-dispatch.ts index 0fc35c6a10..85fb86f6a4 100644 --- a/packages/editor/src/components/tools/shared/affordance-dispatch.ts +++ b/packages/editor/src/components/tools/shared/affordance-dispatch.ts @@ -15,6 +15,19 @@ import { type ComponentType, lazy } from 'react' */ const lazyToolCache = new WeakMap<() => Promise, ComponentType>() +export async function preloadRegistryAffordanceTools(kind: string): Promise { + const tools = nodeRegistry.get(kind)?.affordanceTools + await Promise.allSettled( + Object.values(tools ?? {}).map(async (loader) => { + if (!loader || lazyToolCache.has(loader)) return + const module = (await loader()) as { default: ComponentType } + // Keep an already-mounted lazy component stable. Otherwise use the resolved + // component directly so grabbing a preloaded handle never suspends its tool. + if (!lazyToolCache.has(loader)) lazyToolCache.set(loader, module.default) + }), + ) +} + export function getRegistryAffordanceTool( kind: string, affordance: string, diff --git a/packages/editor/src/components/tools/shared/pointer-support-cap.test.ts b/packages/editor/src/components/tools/shared/pointer-support-cap.test.ts index 9f7904c742..3bb14988ad 100644 --- a/packages/editor/src/components/tools/shared/pointer-support-cap.test.ts +++ b/packages/editor/src/components/tools/shared/pointer-support-cap.test.ts @@ -19,6 +19,7 @@ import { MeshBasicMaterial, OrthographicCamera, PerspectiveCamera, + Ray, Vector3, } from 'three' import { z } from 'zod' @@ -161,6 +162,32 @@ describe('resolvePointerSupportSurface node tops', () => { expect(support?.worldPoint).toEqual([0, 2, 0]) }) + test('XR floor drawing uses the controller ray even when the camera sees a raised surface', () => { + addPluginPlatform(2, [4, 2, 2]) + const camera = new PerspectiveCamera() + camera.position.set(0, 5, 5) + camera.updateMatrixWorld(true) + const controllerRay = new Ray(new Vector3(0, 1.5, 0), new Vector3(0, -1, 0)) + const desktop = resolvePointerSupportSurface(camera, [0, 0, 0], { + includeNodeTopSurfaces: true, + }) + expect(desktop?.sourceNodeId).toBe(PLATFORM_ID) + const xr = resolvePointerSupportSurface(camera, [0, 0, 0], { + includeNodeTopSurfaces: true, + pointerRay: controllerRay, + }) + expect(xr?.sourceNodeId).toBeNull() + expect(xr?.worldPoint).toEqual([0, 0, 0]) + camera.position.set(12, 8, -10) + camera.updateMatrixWorld(true) + expect( + resolvePointerSupportSurface(camera, [0, 0, 0], { + includeNodeTopSurfaces: true, + pointerRay: controllerRay, + })?.worldPoint, + ).toEqual([0, 0, 0]) + }) + test('keeps an unhovered item top above a batched slab across batch transitions', () => { const restoreRegistry = nodeRegistry._snapshot() const item = ItemNode.parse({ diff --git a/packages/editor/src/components/tools/shared/pointer-support-cap.ts b/packages/editor/src/components/tools/shared/pointer-support-cap.ts index 40f0bc196c..12161afebe 100644 --- a/packages/editor/src/components/tools/shared/pointer-support-cap.ts +++ b/packages/editor/src/components/tools/shared/pointer-support-cap.ts @@ -11,7 +11,7 @@ import { useScene, } from '@pascal-app/core' import { setSurfaceRaycastLayers, useViewer } from '@pascal-app/viewer' -import { type Camera, type Object3D, Raycaster, Vector3 } from 'three' +import { type Camera, type Object3D, type Ray, Raycaster, Vector3 } from 'three' import { resolveTerrainGroundHit } from '../../../lib/ground-surface' import { scopeNodeId } from '../../../lib/interaction/scope' import useInteractionScope from '../../../store/use-interaction-scope' @@ -79,16 +79,22 @@ export type PointerSupportSurface = { export function resolvePointerSupportSurface( camera: Camera, worldHit: readonly [number, number, number], - options?: { includeNodeTopSurfaces?: boolean }, + options?: { includeNodeTopSurfaces?: boolean; pointerRay?: Ray }, ): PointerSupportSurface | null { const levelId = useViewer.getState().selection.levelId if (!levelId) return null // The world ray, kept before the level conversion below: the terrain field is // world-space (site geometry, not level-local), so the march needs this frame. - camera.getWorldPosition(worldRayOrigin) + if (options?.pointerRay) worldRayOrigin.copy(options.pointerRay.origin) + else camera.getWorldPosition(worldRayOrigin) + const isOrthographic = + !options?.pointerRay && + (camera as Camera & { isOrthographicCamera?: boolean }).isOrthographicCamera === true const cameraToHit = hitScratch.set(worldHit[0], worldHit[1], worldHit[2]).sub(worldRayOrigin) - if ((camera as Camera & { isOrthographicCamera?: boolean }).isOrthographicCamera) { + if (options?.pointerRay) { + worldRayDirection.copy(options.pointerRay.direction).normalize() + } else if (isOrthographic) { // For an orthographic camera every screen pixel has the same direction. The // hit point is offset from the camera along the view plane, so using // `camera.position -> hit` tilts the ray toward the screen centre and makes @@ -102,7 +108,13 @@ export function resolvePointerSupportSurface( } originScratch.copy(worldRayOrigin) - hitScratch.set(worldHit[0], worldHit[1], worldHit[2]) + // Second world point defining the ray fed to the surface solve. In the plain + // perspective case `worldHit` already lies on the true pointer ray, so using + // it keeps `t === 1` (and the returned plan point exact) whenever the pointed + // surface IS the event plane — the pointer-ray and orthographic branches have + // no such hit, so they step one unit along the resolved direction. + if (options?.pointerRay || isOrthographic) hitScratch.copy(worldRayOrigin).add(worldRayDirection) + else hitScratch.set(worldHit[0], worldHit[1], worldHit[2]) // Slab polygons/elevations live in the level frame; the level mesh // carries the storey Y offset and any building rotation. const levelMesh = sceneRegistry.nodes.get(levelId as AnyNodeId) diff --git a/packages/editor/src/components/tools/tool-manager.tsx b/packages/editor/src/components/tools/tool-manager.tsx index 5bc5e7478f..a0c5f32cb5 100644 --- a/packages/editor/src/components/tools/tool-manager.tsx +++ b/packages/editor/src/components/tools/tool-manager.tsx @@ -34,7 +34,10 @@ import { WallSnapBeaconLayer } from '../editor/wall-snap-beacon-layer' import { ElevatorTool } from './elevator/elevator-tool' import { MoveTool } from './item/move-tool' import { RegistryToolProvider } from './registry-tool-context' -import { getRegistryAffordanceTool } from './shared/affordance-dispatch' +import { + getRegistryAffordanceTool, + preloadRegistryAffordanceTools, +} from './shared/affordance-dispatch' import { FacingPoseIndicator } from './shared/facing-pose-indicator' import { SiteBoundaryEditor } from './site/site-boundary-editor' import { TerrainSculptTool } from './site/terrain-sculpt-tool' @@ -70,8 +73,7 @@ export function preloadRegistryToolModules(tool: string | null): Promise { if (def.system) loaders.push(def.system.module) if (def.parametrics?.customPanel) loaders.push(def.parametrics.customPanel) if (def.parametrics?.trailingSection) loaders.push(def.parametrics.trailingSection) - const moveTool = def.affordanceTools?.move - if (moveTool) loaders.push(moveTool) + loaders.push(() => preloadRegistryAffordanceTools(tool)) const preload = Promise.allSettled(loaders.map((loader) => loader())).then(() => undefined) registryToolPreloadCache.set(def, preload) diff --git a/packages/editor/src/components/ui/controls/material-paint-panel.tsx b/packages/editor/src/components/ui/controls/material-paint-panel.tsx index 3927dff7b7..28da022101 100644 --- a/packages/editor/src/components/ui/controls/material-paint-panel.tsx +++ b/packages/editor/src/components/ui/controls/material-paint-panel.tsx @@ -1,20 +1,9 @@ 'use client' -import { - type AnyNodeId, - generateSceneMaterialId, - type SceneMaterialId, - toSceneMaterialRef, - useScene, -} from '@pascal-app/core' -import { useViewer } from '@pascal-app/viewer' +import { type SceneMaterialId } from '@pascal-app/core' import { Eraser, Plus, RotateCcw } from 'lucide-react' -import { useEffect, useState } from 'react' -import { - buildResetSurfaceMaterialUpdates, - resolvePaintTargetFromSelection, -} from './../../../lib/material-paint' -import useEditor from './../../../store/use-editor' +import { useState } from 'react' +import { useMaterialPaintPanelModel } from '../../../lib/material-paint-panel-model' import { Button } from '../primitives/button' import { Tooltip, TooltipContent, TooltipTrigger } from '../primitives/tooltip' import { MaterialPicker } from './material-picker' @@ -33,57 +22,9 @@ export type MaterialPaintPanelProps = { } export function MaterialPaintPanel({ onCreateMaterialRequest }: MaterialPaintPanelProps) { - const activePaintMaterial = useEditor((state) => state.activePaintMaterial) - const activePaintTarget = useEditor((state) => state.activePaintTarget) - const armMaterialPaint = useEditor((state) => state.armMaterialPaint) - const setActivePaintTarget = useEditor((state) => state.setActivePaintTarget) - const paintEraser = useEditor((state) => state.paintEraser) - const setPaintEraser = useEditor((state) => state.setPaintEraser) - // Id of a just-created scene material whose inline editor should open on mount. + const { activePaintMaterial, paintEraser, setPaintEraser, canResetSelection, resetSelection, materialCount, selectMaterial, createCustomMaterial: createMaterial } = useMaterialPaintPanelModel() const [autoEditMaterialId, setAutoEditMaterialId] = useState(null) - const selectedIds = useViewer((state) => state.selection.selectedIds) - const nodes = useScene((state) => state.nodes) - const materialCount = useScene((state) => Object.keys(state.materials).length) - const selectedId = selectedIds.length === 1 ? (selectedIds[0] ?? null) : null - const selectedNode = selectedId ? nodes[selectedId as AnyNodeId] : null - const canResetSelection = - selectedNode != null && resolvePaintTargetFromSelection({ nodes, selectedId }) != null - - useEffect(() => { - const selectedPaintTarget = resolvePaintTargetFromSelection({ nodes, selectedId }) - if (selectedPaintTarget) { - setActivePaintTarget(selectedPaintTarget) - } - }, [nodes, selectedId, setActivePaintTarget]) - - const resetSelection = () => { - if (!selectedNode) return - useScene.getState().updateNodes(buildResetSurfaceMaterialUpdates(nodes, selectedNode)) - } - - // Create a blank custom scene material, select it as the brush (`scene:` ref so - // edits propagate), and open its inline editor. Available from any category. - const createCustomMaterial = () => { - const id = generateSceneMaterialId() - const count = Object.keys(useScene.getState().materials).length - useScene.getState().addSceneMaterial({ - id, - name: `Material ${count + 1}`, - material: { - preset: 'custom', - properties: { - color: '#ffffff', - roughness: 0.5, - metalness: 0, - opacity: 1, - transparent: false, - side: 'front', - }, - }, - }) - armMaterialPaint({ materialPreset: toSceneMaterialRef(id), sourceTarget: activePaintTarget }) - setAutoEditMaterialId(id) - } + const createCustomMaterial = () => setAutoEditMaterialId(createMaterial()) return (
@@ -117,9 +58,7 @@ export function MaterialPaintPanel({ onCreateMaterialRequest }: MaterialPaintPan
{ - armMaterialPaint({ materialPreset, sourceTarget: activePaintTarget }) - }} + onSelectMaterialPreset={selectMaterial} selectedMaterialPreset={activePaintMaterial?.materialPreset} />
diff --git a/packages/editor/src/components/ui/controls/material-picker.tsx b/packages/editor/src/components/ui/controls/material-picker.tsx index 39f2019db2..a80c22f46a 100644 --- a/packages/editor/src/components/ui/controls/material-picker.tsx +++ b/packages/editor/src/components/ui/controls/material-picker.tsx @@ -1,23 +1,11 @@ 'use client' -import { - getCatalogMaterialById, - getDynamicLibraryMaterials, - getLibraryMaterialIdFromRef, - getLibraryMaterialsVersion, - getMaterialsForCategory, - MATERIAL_CATEGORIES, - type MaterialCatalogItem, - type MaterialSource, - type MaterialTarget, - subscribeLibraryMaterials, - toLibraryMaterialRef, -} from '@pascal-app/core' +import { type MaterialTarget, toLibraryMaterialRef } from '@pascal-app/core' import { Plus } from 'lucide-react' -import { useEffect, useMemo, useState, useSyncExternalStore } from 'react' +import { useMaterialCatalogModel, type MaterialSourceFilter } from '../../../lib/material-catalog-model' import { triggerSFX } from '../../../lib/sfx-bus' -export type MaterialSourceFilter = MaterialSource +export type { MaterialSourceFilter } from '../../../lib/material-catalog-model' export type MaterialPickerProps = { selectedMaterialPreset?: string @@ -28,23 +16,10 @@ export type MaterialPickerProps = { onCreateMaterialRequest?: () => void } -// No 'All': the browse surfaces (Items / Rooms / Build) dropped it and default -// to the Pascal library — the combined list buried the curated set. -const SOURCE_FILTERS: { id: MaterialSourceFilter; label: string }[] = [ - { id: 'pascal', label: 'Pascal' }, - { id: 'mine', label: 'Mine' }, - { id: 'workspace', label: 'Workspace' }, - { id: 'community', label: 'Community' }, -] - -function getCategoryLabel(category: (typeof MATERIAL_CATEGORIES)[number]) { +function getCategoryLabel(category: string) { return category.charAt(0).toUpperCase() + category.slice(1) } -function filterBySource(items: MaterialCatalogItem[], filter: MaterialSourceFilter) { - return items.filter((item) => (item.source ?? 'pascal') === filter) -} - /** * Catalog material picker: a fixed row of category tabs and a source filter row * over a scrollable grid of swatches. Scene-material creation lives in the @@ -57,40 +32,7 @@ export function MaterialPicker({ disabled = false, onCreateMaterialRequest, }: MaterialPickerProps) { - const [selectedCategory, setSelectedCategory] = useState<(typeof MATERIAL_CATEGORIES)[number]>( - MATERIAL_CATEGORIES[0], - ) - const [sourceFilter, setSourceFilter] = useState('pascal') - // Version counter so host registrations/unregistrations re-render the picker. - const libraryVersion = useSyncExternalStore( - subscribeLibraryMaterials, - getLibraryMaterialsVersion, - getLibraryMaterialsVersion, - ) - const hasWorkspaceMaterials = useMemo( - () => getDynamicLibraryMaterials().some((item) => item.source === 'workspace'), - [libraryVersion], - ) - const visibleSourceFilters = SOURCE_FILTERS.filter( - (filter) => filter.id !== 'workspace' || hasWorkspaceMaterials, - ) - const availableCategories = MATERIAL_CATEGORIES.filter( - (category) => getMaterialsForCategory(category).length > 0, - ) - const catalogItems = filterBySource(getMaterialsForCategory(selectedCategory), sourceFilter) - - // Keep the visible category in sync with the externally-selected catalog - // material (a `scene:` ref matches no catalog entry, so the tab stays put). - useEffect(() => { - const catalogId = getLibraryMaterialIdFromRef(selectedMaterialPreset) ?? undefined - const entry = getCatalogMaterialById(catalogId) - if (entry?.category) setSelectedCategory(entry.category) - }, [selectedMaterialPreset]) - - const handleCatalogSelect = (materialId: string) => { - if (disabled) return - onSelectMaterialPreset?.(toLibraryMaterialRef(materialId)) - } + const { selectedCategory, setSelectedCategory, sourceFilter, setSourceFilter, visibleSourceFilters, availableCategories, catalogItems, select: handleCatalogSelect } = useMaterialCatalogModel(selectedMaterialPreset, onSelectMaterialPreset, disabled) return (
{ setSelectedCategory(category) - // Auto-select the first material in the category so the brush is - // immediately ready (and the swatch shows as selected). - const first = filterBySource(getMaterialsForCategory(category), sourceFilter)[0] - if (first) handleCatalogSelect(first.id) }} type="button" > diff --git a/packages/editor/src/components/ui/helpers/helper-manager.tsx b/packages/editor/src/components/ui/helpers/helper-manager.tsx index 2d5cc3f100..730f4b453d 100644 --- a/packages/editor/src/components/ui/helpers/helper-manager.tsx +++ b/packages/editor/src/components/ui/helpers/helper-manager.tsx @@ -85,6 +85,7 @@ function terrainSculptHints(verb: TerrainVerb, sampling: boolean): ContextualSho } type ActiveModifierKeys = { + alt: boolean command: boolean shift: boolean } @@ -94,6 +95,7 @@ const NO_CONTEXTUAL_HELP_SUBSCRIPTION = () => () => {} function useActiveModifierKeys(): ActiveModifierKeys { const [modifiers, setModifiers] = useState({ + alt: false, command: false, shift: false, }) @@ -102,6 +104,7 @@ function useActiveModifierKeys(): ActiveModifierKeys { const updateModifiers = (event: KeyboardEvent) => { const isKeyDown = event.type === 'keydown' setModifiers({ + alt: event.altKey || (isKeyDown && event.key === 'Alt'), command: event.metaKey || event.ctrlKey || @@ -110,7 +113,7 @@ function useActiveModifierKeys(): ActiveModifierKeys { }) } const clearModifiers = () => { - setModifiers({ command: false, shift: false }) + setModifiers({ alt: false, command: false, shift: false }) } window.addEventListener('keydown', updateModifiers) @@ -213,15 +216,16 @@ export function HelperManager() { // tools — editor shortcut hints would only mislead there. if (workspaceMode === 'studio') return null - // Rotating a node (or a multi-selection group) via its in-world gizmo: - // advertise Shift = free rotation, the same angle-step bypass wall drafting - // exposes. Takes priority over the idle select-mode hints since a handle - // drag is the active interaction. if ( activeHandleDrag?.label === ROTATE_HANDLE_DRAG_LABEL || activeHandleDrag?.label === GROUP_ROTATE_DRAG_LABEL ) { - return + return ( + + ) } // Group-move drag / pick-up: the drag resolves to the 'item' snap context diff --git a/packages/editor/src/components/ui/helpers/registered-tool-helper.tsx b/packages/editor/src/components/ui/helpers/registered-tool-helper.tsx index 85483fa208..b77460177a 100644 --- a/packages/editor/src/components/ui/helpers/registered-tool-helper.tsx +++ b/packages/editor/src/components/ui/helpers/registered-tool-helper.tsx @@ -1,8 +1,7 @@ import type { ToolHint } from '@pascal-app/core' -import { useMemo, useSyncExternalStore } from 'react' import type { ContinuationContext } from '../../../lib/continuation' import type { SnapContext } from '../../../lib/snapping-mode' -import useEditor from '../../../store/use-editor' +import { useVisibleToolHints } from '../../../lib/panel-tool-options' import { ContextualHelperPanel } from './contextual-helper-panel' /** @@ -25,36 +24,7 @@ export function RegisteredToolHelper({ snapContext?: SnapContext | null continuationContext?: ContinuationContext | null }) { - // Live vertex count of an in-progress polygon draft, so hints gated on a - // minimum (e.g. "Finish" at ≥ 3) only appear once they're actually possible. - const draftVertexCount = useEditor((s) => s.draftVertexCount) - const visibilityStore = useMemo( - () => ({ - subscribe: (onChange: () => void) => { - const unsubscribers = hints.flatMap((hint) => - hint.visible ? [hint.visible.subscribe(onChange)] : [], - ) - return () => { - for (const unsubscribe of unsubscribers) unsubscribe() - } - }, - getSnapshot: () => hints.map((hint) => (hint.visible?.value() === false ? '0' : '1')).join(''), - }), - [hints], - ) - useSyncExternalStore( - visibilityStore.subscribe, - visibilityStore.getSnapshot, - visibilityStore.getSnapshot, - ) - // Some hints are replaced by live contextual chips, so keep the generic - // registry renderer from duplicating stale/static versions. - const visible = hints.filter( - (hint) => - !(hint.key === 'Shift' && hint.label === 'Cycle snapping mode') && - hint.visible?.value() !== false && - (hint.minDraftVertices == null || draftVertexCount >= hint.minDraftVertices), - ) + const visible = useVisibleToolHints(hints) if (visible.length === 0 && !snapContext && !continuationContext) return null // Hints carrying a live-state `chip` render as mode chips next to the // snapping / continuation rows; the rest stay static key rows. diff --git a/packages/editor/src/components/ui/item-catalog/item-catalog.tsx b/packages/editor/src/components/ui/item-catalog/item-catalog.tsx index b111e62404..010545666b 100644 --- a/packages/editor/src/components/ui/item-catalog/item-catalog.tsx +++ b/packages/editor/src/components/ui/item-catalog/item-catalog.tsx @@ -1,13 +1,13 @@ 'use client' import type { AssetInput } from '@pascal-app/core' -import { resolveCdnUrl, useViewer } from '@pascal-app/viewer' +import { resolveCdnUrl } from '@pascal-app/viewer' import { useEffect } from 'react' import { triggerSFX } from './../../../lib/sfx-bus' import { cn } from './../../../lib/utils' import useEditor, { type CatalogCategory } from './../../../store/use-editor' import { resolveAssetSnapTarget, SnapTargetBadge } from '../snap-target-badge' -import { CATALOG_ITEMS, type CatalogItem } from './catalog-items' +import { activateCatalogItem, filterCatalogItems, isCatalogItemSelected } from '../../../lib/catalog-panel-model' export function ItemCatalog({ category, @@ -32,26 +32,7 @@ export function ItemCatalog({ emptyState?: React.ReactNode }) { const selectedItem = useEditor((state) => state.selectedItem) - const setSelectedItem = useEditor((state) => state.setSelectedItem) - const setMode = useEditor((state) => state.setMode) - const setTool = useEditor((state) => state.setTool) - - const sourceItems: CatalogItem[] = itemsOverride ?? CATALOG_ITEMS - // Server-provided results bypass all local filtering; otherwise filter by category/search/tags - const filteredItems: CatalogItem[] = - overrideItems ?? - (() => { - const categoryItems = search - ? sourceItems - : sourceItems.filter((item) => item.category === category) - return categoryItems.filter((item) => { - const tags = item.tags ?? [] - if (activePlacementTag && !tags.includes(activePlacementTag)) return false - if (activeFunctionalTag && !tags.includes(activeFunctionalTag)) return false - if (search && !item.name.toLowerCase().includes(search.toLowerCase())) return false - return true - }) - })() + const filteredItems = filterCatalogItems({ category, items: itemsOverride, overrideItems, search, activePlacementTag, activeFunctionalTag }) if (filteredItems.length === 0 && emptyState) { return <>{emptyState} @@ -64,7 +45,7 @@ export function ItemCatalog({ > {leadingTile} {filteredItems.map((item, index) => { - const isSelected = selectedItem?.src === item?.src + const isSelected = isCatalogItemSelected(item, selectedItem) const snapTarget = resolveAssetSnapTarget(item?.attachTo) return ( + +
+ +
+ {levels.length === 0 ? ( +
No floors yet
+ ) : ( + levels.map((level) => { + const isSelected = level.id === selectedLevelId + const canDelete = level.level !== 0 + + return ( +
+ + +
+ ) + }) + )} +
+

+ Select a floor to make it active. The ground floor is always kept as the building datum. +

+
+ ) +} + export function SettingsPanel({ className, projectId, @@ -664,6 +790,9 @@ export function SettingsPanel({ )} >
+
+ +
{projectId && ( (null) useEffect(() => { + // XR placement is driven by the controller-ray bridge, never screen coordinates. + if (gl.xr.isPresenting) return const canvas = gl.domElement const isActive = (): boolean => { diff --git a/packages/editor/src/hooks/use-grid-events.test.ts b/packages/editor/src/hooks/use-grid-events.test.ts index f8ccf91297..c75f3b20ec 100644 --- a/packages/editor/src/hooks/use-grid-events.test.ts +++ b/packages/editor/src/hooks/use-grid-events.test.ts @@ -70,6 +70,7 @@ test('grid moves throttle camera drags at 100 ms and resume immediately during t render() {}, setSize() {}, setPixelRatio() {}, + xr: { isPresenting: false }, } as unknown as WebGLRenderer, camera, frameloop: 'never', @@ -199,6 +200,7 @@ test.each([ render() {}, setSize() {}, setPixelRatio() {}, + xr: { isPresenting: false }, } as unknown as WebGLRenderer, camera, frameloop: 'never', diff --git a/packages/editor/src/hooks/use-grid-events.ts b/packages/editor/src/hooks/use-grid-events.ts index ef671f443b..ca42b157c8 100644 --- a/packages/editor/src/hooks/use-grid-events.ts +++ b/packages/editor/src/hooks/use-grid-events.ts @@ -59,6 +59,8 @@ export function useGridEvents(gridY: number) { }, [gridY]) useEffect(() => { + // XR placement is driven by the controller-ray bridge, never screen coordinates. + if (gl.xr.isPresenting) return const canvas = gl.domElement setSurfaceRaycastLayers(raycaster.current.layers) diff --git a/packages/editor/src/hooks/use-keyboard.ts b/packages/editor/src/hooks/use-keyboard.ts index ad9d6457e5..3c5c009143 100644 --- a/packages/editor/src/hooks/use-keyboard.ts +++ b/packages/editor/src/hooks/use-keyboard.ts @@ -135,6 +135,20 @@ const exitToSelectAfterUnconsumedCancel = () => { useEditor.getState().setSelectedReferenceId(null) } +// Cancel the active editor action with the same consume-or-exit semantics as +// Escape. Spatial inputs use this instead of unconditionally selecting the +// Select tool, so multi-step tools can keep their tool active after clearing +// the current draft. +export const cancelActiveTool = () => { + _toolCancelConsumed = false + emitter.emit('tool:cancel') + if (!_toolCancelConsumed) { + if (leaveUnitFocus()) useEditor.getState().armToolMode({ mode: 'select' }) + else exitToSelectAfterUnconsumedCancel() + } + return _toolCancelConsumed +} + // ⌘Z pressed mid-interaction (moving a node, drawing a wall, mid-placement…) // reads as "abort this action", not history undo — behave exactly like Escape // and report whether anything was in flight so the undo/redo arms know to @@ -390,15 +404,9 @@ export const useKeyboard = ({ return } - _toolCancelConsumed = false - emitter.emit('tool:cancel') - // Only switch to select mode if no tool had an active mid-action to cancel. // (e.g. mid-wall draw or mid-slab polygon should only cancel the action, not exit the tool) - if (!_toolCancelConsumed) { - if (leaveUnitFocus()) useEditor.getState().armToolMode({ mode: 'select' }) - else exitToSelectAfterUnconsumedCancel() - } + cancelActiveTool() } else if (e.key === '1' && !e.metaKey && !e.ctrlKey) { e.preventDefault() useEditor.getState().setPhase('site') diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index 8053318d2d..5c8bebbc52 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -49,7 +49,7 @@ export { FloatingActionMenu as FloatingMenu } from './components/editor/floating // camera controls via the `useViewer.inputDragging` / `useEditor.movingNode` // flags. Tools place onto `useViewer.selection.levelId`, so the host must set a // building + level selection first. -export { Grid } from './components/editor/grid' +export { EDITOR_GRID_INPUT_NAME, Grid } from './components/editor/grid' export { DimensionPill, type DimensionPillPart, @@ -79,10 +79,12 @@ export { useInvisibleHitAreaMaterial, } from './components/editor/node-arrow-handles' export { QuickMeasurementCard } from './components/editor/quick-measurement-card' +export { SelectionManager } from './components/editor/selection-manager' export { type SnapshotCameraData, ThumbnailGenerator, } from './components/editor/thumbnail-generator' +export { WallMoveSideHandles } from './components/editor/wall-move-side-handles' export { useFloorplanRender } from './components/editor-2d/floorplan-render-context' export { FloorplanDimensionRenderer } from './components/editor-2d/renderers/floorplan-dimension-renderer' export { @@ -229,6 +231,7 @@ export { CameraActions as ToolbarRight, CameraActions as ViewerToolbarRight, } from './components/ui/action-menu/camera-actions' +export { furnishTools } from './components/ui/action-menu/furnish-tools' export { ViewToggles as ToolbarLeft, ViewToggles as ViewerToolbarLeft, @@ -255,6 +258,19 @@ export { FloatingLevelSelector } from './components/ui/floating-level-selector' export { CATALOG_ITEMS } from './components/ui/item-catalog/catalog-items' // Item collections UI — used by the kind-owned ItemPanel in nodes/. export { CollectionsPopover } from './components/ui/panels/collections/collections-popover' +export { + resolveHomogeneousSelection, + resolveUniqueSelectionIds, +} from './components/ui/panels/homogeneous-selection' +export { + commitMultiNodeFields, + fieldVisibleForAll, + firstNumericFieldValue, + firstVec3FieldValue, + reduceFieldValue, + reduceHeightBoundMode, +} from './components/ui/panels/multi-field-value' +export { applyMultiHeightMode } from './components/ui/panels/multi-height-mode' // Phase 5 Stage E — kinds with bespoke editors (slab holes list, // ceiling height presets, etc.) use `parametrics.customPanel` to mount // a kind-owned panel and need PanelWrapper for the chrome. @@ -339,7 +355,7 @@ export type { SaveStatus } from './hooks/use-auto-save' // can express their affordances declaratively in their own folder. export { type UseDragActionArgs, useDragAction } from './hooks/use-drag-action' // Phase 5 Stage D — extras for kind-owned placement tools (FenceTool etc.). -export { markToolCancelConsumed } from './hooks/use-keyboard' +export { cancelActiveTool, markToolCancelConsumed } from './hooks/use-keyboard' export { useReducedMotion } from './hooks/use-reduced-motion' export { type Selection, useSelection } from './hooks/use-selection' export { @@ -348,6 +364,11 @@ export { type PlacementSurface, publishPlacementSurface, } from './lib/active-placement-surface' +export { + activateCatalogItem, + filterCatalogItems, + isCatalogItemSelected, +} from './lib/catalog-panel-model' export { CEILING_ALIGNMENT_THRESHOLD_M, type CeilingPlanSnapInput, @@ -370,6 +391,7 @@ export { continuationContextOf, nextContinuation, } from './lib/continuation' +export { canDirectMoveNode } from './lib/direct-manipulation' export { createEditorApi } from './lib/editor-api' export { clearStructuralElevationGuide, @@ -485,6 +507,7 @@ export { scopeNodeId, } from './lib/interaction/scope' export { isEditableKeyboardTarget } from './lib/keyboard-pan' +export { useMaterialCatalogModel } from './lib/material-catalog-model' export { type ActivePaintMaterial, buildResetSurfaceMaterialUpdates, @@ -494,6 +517,7 @@ export { getActivePaintMaterialLabel, hasActivePaintMaterial, } from './lib/material-paint' +export { useMaterialPaintPanelModel } from './lib/material-paint-panel-model' export { CREATABLE_MEASUREMENT_KINDS, type CreatableMeasurementKind, @@ -540,6 +564,21 @@ export type { ModelExportFormat, ModelExportOptions, } from './lib/model-export' +export { + cyclePaintScope, + type PaintHoverInfo, + type PaintScope, + paintScopeLabel, +} from './lib/paint-scope' +export { + getNodePanelModel, + type NodePanelModel, + PANEL_MODEL_EXTENSION, + type PanelAction, + type PanelRow, +} from './lib/panel-rows' +export { type PanelToolOption, usePanelToolHints } from './lib/panel-tool-options' +export { commitParametricNodeFields } from './lib/parametric-node-update' export { consumePlacementDragRelease } from './lib/placement-drag-release' export { addFreshPlacementMetadata, @@ -573,7 +612,7 @@ export { hasRoofFaceChildOverlap, type RoofWallHit, resolveRoofWallHit } from '. export type { SceneGraph } from './lib/scene' export { applySceneGraphToEditor } from './lib/scene' export { movementSfxStepKey } from './lib/sfx/movement-tick' -export { triggerSFX } from './lib/sfx-bus' +export { emitDeleteSFX, triggerSFX } from './lib/sfx-bus' export { playSFX, type SFXName, type SFXPlaybackOptions } from './lib/sfx-player' export { clearSlabSnapFeedback, @@ -586,12 +625,14 @@ export { type SlabPlanSnapResult, } from './lib/slab-plan-snap' export { + cycleSnappingModeIn, getSnappingModeLabel, resolveSnapFlags, type SnapContext, type SnapFlags, type SnappingMode, } from './lib/snapping-mode' +export { getSpatialPointerId, spatialPointerInput } from './lib/spatial-pointer-input' export { duplicateStairSubtree } from './lib/stair-duplication' export { getBuildingLevelsForLevel, @@ -609,12 +650,17 @@ export { type SurfacePlanSnapInput, type SurfacePlanSnapResult, } from './lib/surface-plan-snap' +export { useTerrainPanelRows } from './lib/terrain-panel-model' export { + brushRadiusRange, + clipTerrainPatchToSite, + commitStroke, fieldExtentForSite, flattenSite, resetSiteTerrain, resolveFlattenTarget, sculptFieldForSite, + terrainPointInsideSite, } from './lib/terrain-sculpt' export { exportSceneToUsdz, type UsdzExportOptions } from './lib/usdz-export' export { useLinearDisplay } from './lib/use-linear-display' @@ -665,6 +711,7 @@ export { isAngleSnapActive, isGridSnapActive, isMagneticSnapActive, + selectDefaultBuildingAndLevel, } from './store/use-editor' export { default as useFacingPose, type FacingPose } from './store/use-facing-pose' export { default as useFenceCurveDraft } from './store/use-fence-curve-draft' diff --git a/packages/editor/src/lib/catalog-panel-model.ts b/packages/editor/src/lib/catalog-panel-model.ts new file mode 100644 index 0000000000..553bf36883 --- /dev/null +++ b/packages/editor/src/lib/catalog-panel-model.ts @@ -0,0 +1,39 @@ +import { useViewer } from '@pascal-app/viewer' +import { CATALOG_ITEMS, type CatalogItem } from '../components/ui/item-catalog/catalog-items' +import useEditor, { type CatalogCategory } from '../store/use-editor' + +export function filterCatalogItems({ + category, + items = CATALOG_ITEMS, + search = '', + activePlacementTag, + activeFunctionalTag, + overrideItems, +}: { + category?: CatalogCategory + items?: readonly CatalogItem[] + search?: string + activePlacementTag?: string | null + activeFunctionalTag?: string | null + overrideItems?: readonly CatalogItem[] +}): readonly CatalogItem[] { + return ( + overrideItems ?? + items.filter((item) => { + if (!search && category && item.category !== category) return false + if (activePlacementTag && !item.tags?.includes(activePlacementTag)) return false + if (activeFunctionalTag && !item.tags?.includes(activeFunctionalTag)) return false + return !search || item.name.toLowerCase().includes(search.toLowerCase()) + }) + ) +} + +export function activateCatalogItem(item: CatalogItem) { + useViewer.getState().setSelection({ selectedIds: [], zoneId: null }) + const editor = useEditor.getState() + editor.setSelectedItem(item) + editor.setTool(item.tool ?? 'item') + editor.setMode('build') +} + +export { isCatalogItemSelected } from './catalog-selection' diff --git a/packages/editor/src/lib/catalog-selection.test.ts b/packages/editor/src/lib/catalog-selection.test.ts new file mode 100644 index 0000000000..ac3cee6664 --- /dev/null +++ b/packages/editor/src/lib/catalog-selection.test.ts @@ -0,0 +1,27 @@ +import { test } from 'bun:test' +import assert from 'node:assert/strict' +import { isCatalogItemSelected } from './catalog-selection' + +test('no selection leaves every tile unhighlighted, including items without model URLs', () => { + for (const item of [{ id: 'chair' }, { id: 'cabinet' }]) { + assert.equal(isCatalogItemSelected(item, null), false) + } +}) + +test('only the chosen item is highlighted when model URLs are missing or shared', () => { + for (const src of [undefined, '/shared-model.glb']) { + const items = [ + { id: 'first', src }, + { id: 'second', src }, + ] + assert.deepEqual( + items.map((item) => isCatalogItemSelected(item, items[1]!)), + [false, true], + ) + } +}) + +test('selection follows identity across reloaded catalog objects', () => { + assert.equal(isCatalogItemSelected({ id: 'chair' }, { id: 'chair' }), true) + assert.equal(isCatalogItemSelected({ id: 'chair' }, { id: 'table' }), false) +}) diff --git a/packages/editor/src/lib/catalog-selection.ts b/packages/editor/src/lib/catalog-selection.ts new file mode 100644 index 0000000000..eb385f1747 --- /dev/null +++ b/packages/editor/src/lib/catalog-selection.ts @@ -0,0 +1,8 @@ +import type { CatalogItem } from '../components/ui/item-catalog/catalog-items' + +export function isCatalogItemSelected( + item: Pick, + selected: Pick | null, +) { + return selected != null && item.id === selected.id +} diff --git a/packages/editor/src/lib/contextual-help.test.ts b/packages/editor/src/lib/contextual-help.test.ts index 991c2b8f3d..72aec2c3af 100644 --- a/packages/editor/src/lib/contextual-help.test.ts +++ b/packages/editor/src/lib/contextual-help.test.ts @@ -1,5 +1,14 @@ import { describe, expect, test } from 'bun:test' -import { resolveSelectModeHelpHints } from './contextual-help' +import { resolveRotateHandleHelpHints, resolveSelectModeHelpHints } from './contextual-help' + +test('rotation advertises Alt as the temporary free-rotation override', () => { + expect(resolveRotateHandleHelpHints(false)).toEqual([ + { keys: ['Alt'], label: 'Hold to rotate freely', active: false }, + ]) + expect(resolveRotateHandleHelpHints(true)).toEqual([ + { keys: ['Alt'], label: 'Rotating freely (no angle step)', active: true }, + ]) +}) describe('resolveSelectModeHelpHints', () => { test('stays hidden in idle select mode with no selection', () => { diff --git a/packages/editor/src/lib/contextual-help.ts b/packages/editor/src/lib/contextual-help.ts index cf0bf4efc4..62825c57c7 100644 --- a/packages/editor/src/lib/contextual-help.ts +++ b/packages/editor/src/lib/contextual-help.ts @@ -11,8 +11,8 @@ export type ContextualShortcutHint = { } // `activeHandleDrag.label` value a rotate gizmo sets while dragging, so the -// contextual HUD can surface the Shift = free-rotation toggle for the duration -// (mirrors how wall drafting advertises Shift). Distinct from resize handles, +// contextual HUD can surface the rotation snapping mode for the duration. +// Distinct from resize handles, // which route their own measurement label here. export const ROTATE_HANDLE_DRAG_LABEL = 'rotate-handle' @@ -29,15 +29,12 @@ export const RESIZE_HANDLE_DRAG_LABEL = 'resize-handle' export const GROUP_MOVE_DRAG_LABEL = 'group-move-handle' export const GROUP_ROTATE_DRAG_LABEL = 'group-rotate-handle' -// Hints shown while a rotate gizmo is mid-drag: Shift bypasses the angle step -// (free rotation), the same toggle wall drafting exposes. `active` lights the -// pill while Shift is held. -export function resolveRotateHandleHelpHints(shiftPressed: boolean): ContextualShortcutHint[] { +export function resolveRotateHandleHelpHints(altPressed: boolean): ContextualShortcutHint[] { return [ { - keys: [SHIFT_KEY], - label: shiftPressed ? 'Rotating freely (no angle step)' : 'Hold to rotate freely', - active: shiftPressed, + keys: [ALT_KEY], + label: altPressed ? 'Rotating freely (no angle step)' : 'Hold to rotate freely', + active: altPressed, }, ] } diff --git a/packages/editor/src/lib/grid-frame.test.ts b/packages/editor/src/lib/grid-frame.test.ts new file mode 100644 index 0000000000..3ff9920231 --- /dev/null +++ b/packages/editor/src/lib/grid-frame.test.ts @@ -0,0 +1,19 @@ +import { expect, test } from 'bun:test' +import { Group, Vector3 } from 'three' +import { gridLocalNormal, gridLocalPoint } from './grid-frame' + +test('grid floor and cursor stay in model coordinates after God-view pan, scale and yaw', () => { + const root = new Group() + root.position.set(2, 4, -3) + root.rotation.y = Math.PI / 3 + root.scale.setScalar(0.2) + root.updateMatrixWorld(true) + const local = new Vector3(3, 2.5, 7) + const world = root.localToWorld(local.clone()) + expect(gridLocalPoint(root, world, new Vector3()).distanceTo(local)).toBeLessThan(1e-8) + const normal = new Vector3(1, 0, 0).transformDirection(root.matrixWorld) + expect( + gridLocalNormal(root, normal, new Vector3()).distanceTo(new Vector3(1, 0, 0)), + ).toBeLessThan(1e-8) + expect(world.distanceTo(local)).toBeGreaterThan(1) +}) diff --git a/packages/editor/src/lib/grid-frame.ts b/packages/editor/src/lib/grid-frame.ts new file mode 100644 index 0000000000..189ef9636b --- /dev/null +++ b/packages/editor/src/lib/grid-frame.ts @@ -0,0 +1,18 @@ +import type { Object3D, Vector3 } from 'three' + +// Placement surfaces are world-space; the grid lives under the scalable XR +// scene root. Keep its lattice and reveal in that root's model coordinates. +export function gridLocalPoint(parent: Object3D | null, world: Vector3, target: Vector3) { + target.copy(world) + if (parent) { + parent.updateWorldMatrix(true, false) + parent.worldToLocal(target) + } + return target +} + +export function gridLocalNormal(parent: Object3D | null, normal: Vector3, target: Vector3) { + target.copy(normal) + if (parent) target.transformDirection(parent.matrixWorld.clone().invert()) + return target +} diff --git a/packages/editor/src/lib/material-catalog-model.ts b/packages/editor/src/lib/material-catalog-model.ts new file mode 100644 index 0000000000..d82816bca0 --- /dev/null +++ b/packages/editor/src/lib/material-catalog-model.ts @@ -0,0 +1,72 @@ +'use client' + +import { + getCatalogMaterialById, + getDynamicLibraryMaterials, + getLibraryMaterialIdFromRef, + getLibraryMaterialsVersion, + getMaterialsForCategory, + MATERIAL_CATEGORIES, + type MaterialSource, + subscribeLibraryMaterials, + toLibraryMaterialRef, +} from '@pascal-app/core' +import { useEffect, useState, useSyncExternalStore } from 'react' + +export type MaterialSourceFilter = MaterialSource +const SOURCE_FILTERS: { id: MaterialSourceFilter; label: string }[] = [ + { id: 'pascal', label: 'Pascal' }, + { id: 'mine', label: 'Mine' }, + { id: 'workspace', label: 'Workspace' }, + { id: 'community', label: 'Community' }, +] + +export function useMaterialCatalogModel( + selectedMaterialPreset?: string, + onSelectMaterialPreset?: (ref: string) => void, + disabled = false, +) { + const [selectedCategory, setCategory] = useState<(typeof MATERIAL_CATEGORIES)[number]>( + MATERIAL_CATEGORIES[0], + ) + const [sourceFilter, setSourceFilter] = useState('pascal') + useSyncExternalStore( + subscribeLibraryMaterials, + getLibraryMaterialsVersion, + getLibraryMaterialsVersion, + ) + const availableCategories = MATERIAL_CATEGORIES.filter( + (category) => getMaterialsForCategory(category).length > 0, + ) + const visibleSourceFilters = SOURCE_FILTERS.filter( + (filter) => + filter.id !== 'workspace' || + getDynamicLibraryMaterials().some((item) => item.source === 'workspace'), + ) + const itemsFor = (category: typeof selectedCategory) => + getMaterialsForCategory(category).filter((item) => (item.source ?? 'pascal') === sourceFilter) + useEffect(() => { + const entry = getCatalogMaterialById( + getLibraryMaterialIdFromRef(selectedMaterialPreset) ?? undefined, + ) + if (entry?.category) setCategory(entry.category) + }, [selectedMaterialPreset]) + const select = (id: string) => { + if (!disabled) onSelectMaterialPreset?.(toLibraryMaterialRef(id)) + } + const setSelectedCategory = (category: typeof selectedCategory) => { + setCategory(category) + const first = itemsFor(category)[0] + if (first) select(first.id) + } + return { + selectedCategory, + setSelectedCategory, + sourceFilter, + setSourceFilter, + visibleSourceFilters, + availableCategories, + catalogItems: itemsFor(selectedCategory), + select, + } +} diff --git a/packages/editor/src/lib/material-paint-panel-model.ts b/packages/editor/src/lib/material-paint-panel-model.ts new file mode 100644 index 0000000000..35d2583496 --- /dev/null +++ b/packages/editor/src/lib/material-paint-panel-model.ts @@ -0,0 +1,76 @@ +'use client' + +import { + type AnyNodeId, + generateSceneMaterialId, + toSceneMaterialRef, + useScene, +} from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import { useEffect } from 'react' +import useEditor from '../store/use-editor' +import { buildResetSurfaceMaterialUpdates, resolvePaintTargetFromSelection } from './material-paint' + +export function useMaterialPaintPanelModel(enabled = true) { + const activePaintMaterial = useEditor((state) => state.activePaintMaterial) + const activePaintTarget = useEditor((state) => state.activePaintTarget) + const setActivePaintMaterial = useEditor((state) => state.armMaterialPaint) + const setActivePaintTarget = useEditor((state) => state.setActivePaintTarget) + const paintEraser = useEditor((state) => state.paintEraser) + const setPaintEraser = useEditor((state) => state.setPaintEraser) + const selectedId = useViewer((state) => + state.selection.selectedIds.length === 1 ? (state.selection.selectedIds[0] ?? null) : null, + ) + const paintTarget = useScene((state) => + resolvePaintTargetFromSelection({ nodes: state.nodes, selectedId }), + ) + const materials = useScene((state) => state.materials) + const canResetSelection = paintTarget !== null + useEffect(() => { + if (enabled && paintTarget) setActivePaintTarget(paintTarget) + }, [enabled, paintTarget, setActivePaintTarget]) + const resetSelection = () => { + const scene = useScene.getState() + const node = selectedId ? scene.nodes[selectedId as AnyNodeId] : undefined + if (node) scene.updateNodes(buildResetSurfaceMaterialUpdates(scene.nodes, node)) + } + const createCustomMaterial = () => { + const scene = useScene.getState() + const id = generateSceneMaterialId() + scene.addSceneMaterial({ + id, + name: `Material ${Object.keys(scene.materials).length + 1}`, + material: { + preset: 'custom', + properties: { + color: '#ffffff', + roughness: 0.5, + metalness: 0, + opacity: 1, + transparent: false, + side: 'front', + }, + }, + }) + setActivePaintMaterial({ + materialPreset: toSceneMaterialRef(id), + sourceTarget: useEditor.getState().activePaintTarget, + }) + return id + } + const selectMaterial = (materialPreset: string) => + setActivePaintMaterial({ materialPreset, sourceTarget: useEditor.getState().activePaintTarget }) + return { + activePaintMaterial, + activePaintTarget, + setActivePaintMaterial, + paintEraser, + setPaintEraser, + materials, + materialCount: Object.keys(materials).length, + canResetSelection, + resetSelection, + createCustomMaterial, + selectMaterial, + } +} diff --git a/packages/editor/src/lib/overlay-transform.test.ts b/packages/editor/src/lib/overlay-transform.test.ts new file mode 100644 index 0000000000..1656422277 --- /dev/null +++ b/packages/editor/src/lib/overlay-transform.test.ts @@ -0,0 +1,28 @@ +import { test } from 'bun:test' +import assert from 'node:assert/strict' +import { Group } from 'three' +import { copyOverlayWorldTransform } from './overlay-transform' + +test('opening overlays retain the source transform through God-mode zoom and rotation', () => { + const model = new Group() + const wall = new Group() + const opening = new Group() + model.add(wall) + wall.add(opening) + wall.rotation.y = 0.8 + opening.position.set(2, 1.5, 0.1) + const portal = new Group() + portal.position.set(4, 1, -2) + portal.rotation.y = -0.4 + const overlay = new Group() + portal.add(overlay) + for (const scale of [0.01, 0.2, 1, 4, 0.05]) { + model.scale.set(scale, scale * 2, scale) + model.rotation.y += 0.2 + copyOverlayWorldTransform(opening, overlay) + overlay.updateWorldMatrix(true, false) + overlay.matrixWorld.elements.forEach((value, i) => { + assert.ok(Math.abs(value - opening.matrixWorld.elements[i]!) < 1e-8) + }) + } +}) diff --git a/packages/editor/src/lib/overlay-transform.ts b/packages/editor/src/lib/overlay-transform.ts new file mode 100644 index 0000000000..70f3b5e491 --- /dev/null +++ b/packages/editor/src/lib/overlay-transform.ts @@ -0,0 +1,12 @@ +import type { Object3D } from 'three' + +/** Preserve scale and shear when an overlay is portalled outside its source hierarchy. */ +export function copyOverlayWorldTransform(source: Object3D, overlay: Object3D) { + source.updateWorldMatrix(true, false) + overlay.parent?.updateWorldMatrix(true, false) + overlay.matrixAutoUpdate = false + if (overlay.parent) + overlay.matrix.copy(overlay.parent.matrixWorld).invert().multiply(source.matrixWorld) + else overlay.matrix.copy(source.matrixWorld) + overlay.matrixWorldNeedsUpdate = true +} diff --git a/packages/editor/src/lib/panel-rows.ts b/packages/editor/src/lib/panel-rows.ts new file mode 100644 index 0000000000..9e922eb3f3 --- /dev/null +++ b/packages/editor/src/lib/panel-rows.ts @@ -0,0 +1,75 @@ +import type { AnyNode, AnyNodeDefinition, AnyNodeId } from '@pascal-app/core' + +export type PanelIcon = { color?: string; src?: string } + +export type PanelAction = { + icon?: PanelIcon + disabled?: boolean + id: string + label: string + onSelect?: () => void +} + +export type PanelRow = { section?: string } & ( + | { + id: string + kind: 'action' + icon?: PanelIcon + label: string + onSelect?: () => void + disabled?: boolean + } + | { + actions: PanelAction[] + id: string + kind: 'actions' + } + | { + id: string + kind: 'choice' + label: string + onSelect?: () => void + value: string + } + | { + getValue: () => string + id: string + kind: 'subscribed-choice' + label: string + onSelect?: () => void + subscribe: (listener: () => void) => () => void + } + | { + id: string + kind: 'cycle' + label: string + next: () => void + previous: () => void + value: string + } + | { + id: string + kind: 'stepper' + label: string + max: number + min: number + onChange: (value: number) => void + step: number + unit?: string + value: number + } +) + +export const PANEL_MODEL_EXTENSION = 'pascal:editor/panel-model' + +export type NodePanelModel = { + rows: (context: { + node: N + nodes: Record + update: (patch: Partial) => void + }) => PanelRow[] +} + +export function getNodePanelModel(definition: AnyNodeDefinition | undefined) { + return definition?.extensions?.[PANEL_MODEL_EXTENSION] as NodePanelModel | undefined +} diff --git a/packages/editor/src/lib/panel-tool-options.test.ts b/packages/editor/src/lib/panel-tool-options.test.ts new file mode 100644 index 0000000000..74b584abb0 --- /dev/null +++ b/packages/editor/src/lib/panel-tool-options.test.ts @@ -0,0 +1,34 @@ +import { expect, test } from 'bun:test' +import { createToolHintsStore } from './panel-tool-options' + +test('tool hints share subscriptions, react to visibility and chip state, and unsubscribe', () => { + let visible = false + let value = 'auto' + let subscriptions = 0 + let cleanups = 0 + const subscribe = (_listener: () => void) => { + subscriptions++ + return () => { + cleanups++ + } + } + const store = createToolHintsStore([ + { + key: 'P', + label: 'Placement', + visible: { subscribe, value: () => visible }, + chip: { subscribe, value: () => value, cycle: () => {}, labels: { auto: 'Auto' } }, + }, + ]) + const cleanup = store.subscribe(() => {}) + expect(subscriptions).toBe(1) + const hidden = store.getSnapshot() + visible = true + expect(store.getSnapshot()).not.toBe(hidden) + const shown = store.getSnapshot() + value = 'roof' + expect(store.getSnapshot()).not.toBe(shown) + expect(store.getSnapshot()).toBe(store.getSnapshot()) + cleanup() + expect(cleanups).toBe(1) +}) diff --git a/packages/editor/src/lib/panel-tool-options.ts b/packages/editor/src/lib/panel-tool-options.ts new file mode 100644 index 0000000000..d27abc95ba --- /dev/null +++ b/packages/editor/src/lib/panel-tool-options.ts @@ -0,0 +1,46 @@ +'use client' + +import { nodeRegistry, type ToolHint, useRegistryVersion } from '@pascal-app/core' +import { useMemo, useSyncExternalStore } from 'react' +import useEditor from '../store/use-editor' + +export type PanelToolOption = { + id: string + label: string + value: string + choices: readonly { value: string; label: string; description?: string }[] + set: (value: string) => void +} + +export function createToolHintsStore(hints: readonly ToolHint[]) { + return { + subscribe(listener: () => void) { + const subscriptions = hints.flatMap((hint) => [hint.chip?.subscribe, hint.visible?.subscribe]) + const cleanups = [...new Set(subscriptions)].flatMap((subscribe) => + subscribe ? [subscribe(listener)] : [], + ) + return () => { + for (const cleanup of cleanups) cleanup() + } + }, + getSnapshot: () => + JSON.stringify(hints.map((hint) => [hint.visible?.value() ?? true, hint.chip?.value()])), + } +} + +export function useVisibleToolHints(hints: readonly ToolHint[] = []) { + const draftVertexCount = useEditor((state) => state.draftVertexCount) + const store = useMemo(() => createToolHintsStore(hints), [hints]) + useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot) + return hints.filter( + (hint) => + !(hint.key === 'Shift' && hint.label === 'Cycle snapping mode') && + (hint.visible?.value() ?? true) && + (hint.minDraftVertices == null || draftVertexCount >= hint.minDraftVertices), + ) +} + +export function usePanelToolHints(kind: string | null | undefined) { + useRegistryVersion() + return useVisibleToolHints(kind ? nodeRegistry.get(kind)?.toolHints : undefined) +} diff --git a/packages/editor/src/lib/parametric-node-update.ts b/packages/editor/src/lib/parametric-node-update.ts new file mode 100644 index 0000000000..814ea15be6 --- /dev/null +++ b/packages/editor/src/lib/parametric-node-update.ts @@ -0,0 +1,37 @@ +import { + type AnyNode, + type AnyNodeId, + nodeRegistry, + type ParametricDescriptor, + useScene, +} from '@pascal-app/core' + +export function commitParametricNodeFields( + nodeId: AnyNodeId, + requestedPatch: Partial, +): void { + const scene = useScene.getState() + const node = scene.nodes[nodeId] + if (!node) return + + const parametrics = nodeRegistry.get(node.type)?.parametrics as + | ParametricDescriptor + | undefined + let patch = requestedPatch as Record + if (parametrics?.derive) { + const next = { ...node, ...patch } as AnyNode + patch = { + ...patch, + ...parametrics.derive(next, patch as Partial, node), + } + } + + const updates: { id: AnyNodeId; data: Partial }[] = [ + { id: nodeId, data: patch as Partial }, + ] + if (parametrics?.reconcile) { + const next = { ...node, ...patch } as AnyNode + updates.push(...parametrics.reconcile(node, next)) + } + scene.updateNodes(updates) +} diff --git a/packages/editor/src/lib/scene.test.ts b/packages/editor/src/lib/scene.test.ts new file mode 100644 index 0000000000..9f06d85738 --- /dev/null +++ b/packages/editor/src/lib/scene.test.ts @@ -0,0 +1,99 @@ +import { beforeEach, describe, expect, test } from 'bun:test' +import { nodeRegistry, registerNode, useScene } from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import { z } from 'zod' +import useEditor from '../store/use-editor' +import { normalizeSceneGraphNodes, syncEditorSelectionFromCurrentScene } from './scene' + +const building = { + children: ['level_scene-root'], + id: 'building_scene-root', + object: 'node', + parentId: null, + position: [0, 0, 0], + rotation: [0, 0, 0], + type: 'building', + visible: true, +} + +const level = { + children: ['wall_scene-root'], + id: 'level_scene-root', + level: 0, + object: 'node', + parentId: building.id, + type: 'level', + visible: true, +} + +const wall = { + children: [], + end: [4, 0], + id: 'wall_scene-root', + object: 'node', + parentId: level.id, + start: [0, 0], + type: 'wall', + visible: true, +} + +describe('scene selection synchronization', () => { + beforeEach(() => { + useViewer.getState().resetSelection() + useEditor.setState({ mode: 'select', phase: 'site', tool: null }) + }) + + test('enters the first level when a scene graph is rooted at a building', () => { + useScene.setState({ + nodes: { + [building.id]: building, + [level.id]: level, + [wall.id]: wall, + }, + rootNodeIds: [building.id], + } as never) + + syncEditorSelectionFromCurrentScene() + + expect(useViewer.getState().selection).toMatchObject({ + buildingId: building.id, + levelId: level.id, + }) + expect(useEditor.getState().phase).toBe('structure') + }) +}) + +describe('scene graph normalization', () => { + test('materializes registered schema defaults before the graph reaches renderers', () => { + const restoreRegistry = nodeRegistry._snapshot() + try { + registerNode({ + kind: 'test-scene-normalization', + schema: z.object({ + id: z.string(), + position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + type: z.literal('test-scene-normalization'), + }), + schemaVersion: 1, + } as never) + + expect( + normalizeSceneGraphNodes({ + test: { id: 'test', type: 'test-scene-normalization' }, + unknown: { id: 'unknown', type: 'unknown-kind', custom: true }, + }), + ).toEqual({ + test: { + id: 'test', + position: [0, 0, 0], + rotation: [0, 0, 0], + type: 'test-scene-normalization', + }, + unknown: { id: 'unknown', type: 'unknown-kind', custom: true }, + }) + } finally { + restoreRegistry() + } + }) +}) diff --git a/packages/editor/src/lib/scene.ts b/packages/editor/src/lib/scene.ts index 52bc3795e9..45c3358b76 100644 --- a/packages/editor/src/lib/scene.ts +++ b/packages/editor/src/lib/scene.ts @@ -276,9 +276,13 @@ function getRestoredSelectionForScene( export function syncEditorSelectionFromCurrentScene() { const sceneNodes = useScene.getState().nodes as Record const sceneRootIds = useScene.getState().rootNodeIds - const siteNode = sceneRootIds[0] ? sceneNodes[sceneRootIds[0]] : null const resolve = (child: any) => (typeof child === 'string' ? sceneNodes[child] : child) - const firstBuilding = siteNode?.children?.map(resolve).find((n: any) => n?.type === 'building') + const rootNodes = sceneRootIds.map((id) => sceneNodes[id]).filter(Boolean) + const firstBuilding = + rootNodes.find((node) => node.type === 'building') ?? + rootNodes + .flatMap((node) => (Array.isArray(node.children) ? node.children.map(resolve) : [])) + .find((node) => node?.type === 'building') const firstLevel = firstBuilding?.children?.map(resolve).find((n: any) => n?.type === 'level') const restoredEditorUiState = normalizePersistedEditorUiState(useEditor.getState()) const shouldRestoreEditorUiState = hasCustomPersistedEditorUiState(restoredEditorUiState) @@ -396,11 +400,25 @@ function hasUsableSceneGraph(sceneGraph?: SceneGraph | null): sceneGraph is Scen ) } +export function normalizeSceneGraphNodes( + nodes: Readonly>, +): Record { + return Object.fromEntries( + Object.entries(nodes).map(([id, value]) => { + if (!value || typeof value !== 'object' || Array.isArray(value)) return [id, value] + const type = (value as { type?: unknown }).type + if (typeof type !== 'string') return [id, value] + const parsed = nodeRegistry.get(type)?.schema.safeParse(value) + return [id, parsed?.success ? parsed.data : value] + }), + ) +} + export function applySceneGraphToEditor(sceneGraph?: SceneGraph | null) { const defaultInstalledPlugins = editorHostPanelRegistry.getDefaultInstalledPluginIds() if (hasUsableSceneGraph(sceneGraph)) { const { nodes, rootNodeIds, collections, materials, installedPlugins } = sceneGraph - useScene.getState().setScene(nodes as any, rootNodeIds as any, { + useScene.getState().setScene(normalizeSceneGraphNodes(nodes) as any, rootNodeIds as any, { collections: collections as any, materials: materials as any, installedPlugins: installedPlugins ?? defaultInstalledPlugins, diff --git a/packages/editor/src/lib/snapping-mode.test.ts b/packages/editor/src/lib/snapping-mode.test.ts index 658d47654d..3df8027819 100644 --- a/packages/editor/src/lib/snapping-mode.test.ts +++ b/packages/editor/src/lib/snapping-mode.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'bun:test' -import { GROUP_MOVE_DRAG_LABEL, ROTATE_HANDLE_DRAG_LABEL } from './contextual-help' +import { + GROUP_MOVE_DRAG_LABEL, + GROUP_ROTATE_DRAG_LABEL, + ROTATE_HANDLE_DRAG_LABEL, +} from './contextual-help' import { cycleSnappingModeIn, DEFAULT_SNAPPING_MODE, @@ -49,6 +53,12 @@ describe('resolveSnapFlags', () => { }) describe('per-context snapping', () => { + it('rotation defaults to angles and cycles independently to free rotation', () => { + expect(defaultSnappingModeFor('rotation')).toBe('angles') + expect(snappingModesFor('rotation')).toEqual(['angles', 'off']) + expect(cycleSnappingModeIn('rotation', 'angles')).toBe('off') + expect(cycleSnappingModeIn('rotation', 'off')).toBe('angles') + }) it('items default to magnetic alignment with no angle lock', () => { expect(defaultSnappingModeFor('item')).toBe('lines') expect(snappingModesFor('item')).toEqual(['lines', 'grid', 'off']) @@ -132,7 +142,8 @@ describe('snapContextOf (profile-driven, node-declared)', () => { expect(ctx({ kind: 'handle-drag', nodeId: 'unknown_1' })).toBeNull() expect( ctx({ kind: 'handle-drag', nodeId: 'cabinet-module_1', handle: ROTATE_HANDLE_DRAG_LABEL }), - ).toBeNull() + ).toBe('rotation') + expect(ctx({ kind: 'handle-drag', handle: GROUP_ROTATE_DRAG_LABEL })).toBe('rotation') }) it('gives mesh rotation the angle context and other edit operations polygon snapping', () => { diff --git a/packages/editor/src/lib/snapping-mode.ts b/packages/editor/src/lib/snapping-mode.ts index 1bd8467928..e16d623ea1 100644 --- a/packages/editor/src/lib/snapping-mode.ts +++ b/packages/editor/src/lib/snapping-mode.ts @@ -1,5 +1,9 @@ import type { SnapProfile } from '@pascal-app/core' -import { GROUP_MOVE_DRAG_LABEL, ROTATE_HANDLE_DRAG_LABEL } from './contextual-help' +import { + GROUP_MOVE_DRAG_LABEL, + GROUP_ROTATE_DRAG_LABEL, + ROTATE_HANDLE_DRAG_LABEL, +} from './contextual-help' /** * Snapping mode is a single global, user-cyclable control that maps onto the @@ -68,14 +72,13 @@ export function nextSnappingMode(mode: SnappingMode): SnappingMode { // alignment lines). The mode is remembered per context and shown live, so it's // never a silent surprise — it just matches what you're doing. -export type SnapContext = 'wall' | 'item' | 'polygon' +export type SnapContext = 'wall' | 'item' | 'polygon' | 'rotation' // The cyclable mode-set for a context (distinct from the node's `SnapProfile`). type SnapModeSet = { modes: SnappingMode[]; default: SnappingMode } // `modes[0]` is the cycle's first entry; `default` is what a context starts at. -// The 'wall' set is the ONLY one with an angle lock — it applies solely when -// you're setting a segment's DIRECTION (wall/fence drafting + endpoint drag). +// The 'wall' set includes angle lock when setting a segment's direction. // Translating a whole wall, curving it, or drawing/moving a slab can't change an // angle, so those use the no-angle 'polygon' set. const SNAP_PROFILES: Record = { @@ -88,9 +91,10 @@ const SNAP_PROFILES: Record = { // whole wall/fence translate, curve reshape, polygon boundary edit. Grid by // default, NO angle lock. polygon: { modes: ['grid', 'lines', 'off'], default: 'grid' }, + rotation: { modes: ['angles', 'off'], default: 'angles' }, } -export const SNAP_CONTEXTS: SnapContext[] = ['wall', 'item', 'polygon'] +export const SNAP_CONTEXTS: SnapContext[] = ['wall', 'item', 'polygon', 'rotation'] export function snappingModesFor(context: SnapContext): SnappingMode[] { return SNAP_PROFILES[context].modes @@ -171,7 +175,8 @@ export function snapContextOf(args: { ? contextForProfile(profileOfNode?.(scope.nodeId), scope.operator === 'rotate') : null case 'handle-drag': - if (scope.handle === ROTATE_HANDLE_DRAG_LABEL) return null + if (scope.handle === ROTATE_HANDLE_DRAG_LABEL || scope.handle === GROUP_ROTATE_DRAG_LABEL) + return 'rotation' return scope.nodeId ? contextForProfile(profileOfNode?.(scope.nodeId), false) : null case 'placing': case 'moving': diff --git a/packages/editor/src/lib/spatial-drag-plane.test.ts b/packages/editor/src/lib/spatial-drag-plane.test.ts new file mode 100644 index 0000000000..255b8a632d --- /dev/null +++ b/packages/editor/src/lib/spatial-drag-plane.test.ts @@ -0,0 +1,53 @@ +import { expect, test } from 'bun:test' +import { Matrix4, Ray, Vector3 } from 'three' +import { + createSpatialDragPlane, + intersectSpatialDragPlane, + spatialDragLocalY, +} from './spatial-drag-plane' + +test('controller translation gives continuous motion at the grabbed height', () => { + const start = new Vector3(2, 2.4, 1) + const ray = new Ray(new Vector3(2, 3.4, 2), new Vector3(0, -1, -1).normalize()) + const plane = createSpatialDragPlane(start, ray) + for (let step = 0; step <= 10; step++) { + const moved = ray.clone() + moved.origin.z += step / 10 + const hit = intersectSpatialDragPlane(moved, plane, new Vector3())! + expect(hit.x).toBeCloseTo(2) + expect(hit.y).toBeCloseTo(2.4) + expect(hit.z).toBeCloseTo(1 + step / 10) + } +}) + +test('horizontal rays use a stable facing plane and grazing rays are ignored', () => { + const ray = new Ray(new Vector3(0, 1, 2), new Vector3(0, 0, -1)) + const plane = createSpatialDragPlane(new Vector3(0, 1, 0), ray) + ray.origin.x = 0.1 + expect(intersectSpatialDragPlane(ray, plane, new Vector3())?.x).toBeCloseTo(0.1) + ray.direction.set(1, 0, -0.001).normalize() + expect(intersectSpatialDragPlane(ray, plane, new Vector3())).toBeNull() +}) + +test('drag plane follows a rotated, scaled building frame', () => { + const transform = new Matrix4().makeRotationZ(Math.PI / 4).scale(new Vector3(0.2, 0.2, 0.2)) + const point = new Vector3(0, 2, 0).applyMatrix4(transform) + const up = new Vector3(0, 1, 0).transformDirection(transform) + const ray = new Ray(point.clone().add(up), up.clone().negate()) + const plane = createSpatialDragPlane(point, ray, transform) + expect(intersectSpatialDragPlane(ray, plane, new Vector3())?.distanceTo(point)).toBeLessThan(1e-8) +}) + +test('height drag uses model metres at human and miniature scales without an initial jump', () => { + for (const scale of [1, 0.2, 2]) { + const frame = new Matrix4().makeRotationY(0.7).scale(new Vector3(scale, scale, scale)) + frame.setPosition(2, 4, -3) + const inverse = frame.clone().invert() + const start = new Vector3(1, 2.5, 3).applyMatrix4(frame) + const initialY = spatialDragLocalY(start, inverse) + for (let step = 0; step <= 10; step++) { + const moved = start.clone().add(new Vector3(0, (scale * step) / 10, 0)) + expect(2.5 + spatialDragLocalY(moved, inverse) - initialY).toBeCloseTo(2.5 + step / 10) + } + } +}) diff --git a/packages/editor/src/lib/spatial-drag-plane.ts b/packages/editor/src/lib/spatial-drag-plane.ts new file mode 100644 index 0000000000..c2daed2478 --- /dev/null +++ b/packages/editor/src/lib/spatial-drag-plane.ts @@ -0,0 +1,19 @@ +import { type Matrix4, Plane, type Ray, Vector3 } from 'three' + +export function createSpatialDragPlane(point: Vector3, ray: Ray, localToWorld?: Matrix4) { + const normal = new Vector3(0, 1, 0) + if (localToWorld) normal.transformDirection(localToWorld) + // A nearly horizontal controller ray cannot reliably intersect a floor plane. + if (Math.abs(normal.dot(ray.direction)) < 0.2) normal.copy(ray.direction) + return new Plane().setFromNormalAndCoplanarPoint(normal, point) +} + +export function intersectSpatialDragPlane(ray: Ray, plane: Plane, target: Vector3) { + if (Math.abs(plane.normal.dot(ray.direction)) < 0.05) return null + return ray.intersectPlane(plane, target) +} + +// Freeze this frame at grab time; world metres differ from model metres in God view. +export function spatialDragLocalY(worldPoint: Vector3, worldToLocal: Matrix4) { + return worldPoint.clone().applyMatrix4(worldToLocal).y +} diff --git a/packages/editor/src/lib/spatial-pointer-input.test.ts b/packages/editor/src/lib/spatial-pointer-input.test.ts new file mode 100644 index 0000000000..701f6231db --- /dev/null +++ b/packages/editor/src/lib/spatial-pointer-input.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, test } from 'bun:test' +import { Ray, Vector3 } from 'three' +import { getSpatialPointerId, SpatialPointerInput } from './spatial-pointer-input' + +describe('SpatialPointerInput', () => { + test('recognizes current and legacy XR native event shapes', () => { + const source = {} + expect(getSpatialPointerId({ inputSource: source })).toBe(source) + expect(getSpatialPointerId({ pointerState: { inputSource: source } })).toBe(source) + expect(getSpatialPointerId({ pointerType: 'mouse' })).toBeNull() + }) + + test('keeps move and release bound to the pointer that captured a handle', () => { + const input = new SpatialPointerInput() + const moves: Ray[] = [] + let releases = 0 + input.capture(42, { + onMove: (ray) => moves.push(ray.clone()), + onRelease: () => releases++, + onCancel: () => undefined, + }) + const ray = new Ray(new Vector3(1, 2, 3), new Vector3(0, 1, 0)) + expect(input.move(7, ray)).toBe(false) + expect(input.move(42, ray)).toBe(true) + expect(input.release(7)).toBe(false) + expect(input.release(42)).toBe(true) + expect(moves).toHaveLength(1) + expect(moves[0]?.origin.toArray()).toEqual([1, 2, 3]) + expect(releases).toBe(1) + expect(input.move(42, ray)).toBe(false) + }) + + test('cancels a captured handle without releasing it', () => { + const input = new SpatialPointerInput() + let cancels = 0 + let releases = 0 + + input.capture(9, { + onMove: () => undefined, + onRelease: () => releases++, + onCancel: () => cancels++, + }) + + expect(input.cancel(9)).toBe(true) + expect(cancels).toBe(1) + expect(releases).toBe(0) + }) + + test('cleans up a replaced capture without letting its stale disposer clear the replacement', () => { + const input = new SpatialPointerInput() + const ray = new Ray(new Vector3(), new Vector3(0, 0, -1)) + let replacements = 0 + let replacementMoves = 0 + + const disposeOriginal = input.capture(12, { + onMove: () => undefined, + onRelease: () => undefined, + onCancel: () => undefined, + onReplace: () => replacements++, + }) + input.capture(12, { + onMove: () => replacementMoves++, + onRelease: () => undefined, + onCancel: () => undefined, + }) + + expect(replacements).toBe(1) + disposeOriginal() + expect(input.move(12, ray)).toBe(true) + expect(replacementMoves).toBe(1) + }) +}) diff --git a/packages/editor/src/lib/spatial-pointer-input.ts b/packages/editor/src/lib/spatial-pointer-input.ts new file mode 100644 index 0000000000..773eb44e1f --- /dev/null +++ b/packages/editor/src/lib/spatial-pointer-input.ts @@ -0,0 +1,62 @@ +import type { Ray } from 'three' + +type SpatialPointerCapture = { + onMove: (ray: Ray) => void + onRelease: () => void + onCancel: () => void + onReplace?: () => void +} + +export type SpatialPointerId = object | number | string + +export function getSpatialPointerId(nativeEvent: unknown): SpatialPointerId | null { + if (!nativeEvent || typeof nativeEvent !== 'object') return null + const event = nativeEvent as { + inputSource?: object + pointerState?: { inputSource?: object } + } + return event.inputSource ?? event.pointerState?.inputSource ?? null +} + +export class SpatialPointerInput { + private readonly captures = new Map() + + capture(pointerId: SpatialPointerId, capture: SpatialPointerCapture): () => void { + const previous = this.captures.get(pointerId) + if (previous && previous !== capture) { + this.captures.delete(pointerId) + ;(previous.onReplace ?? previous.onCancel)() + } + this.captures.set(pointerId, capture) + return () => { + if (this.captures.get(pointerId) === capture) { + this.captures.delete(pointerId) + } + } + } + + move(pointerId: SpatialPointerId, ray: Ray): boolean { + const capture = this.captures.get(pointerId) + if (!capture) return false + capture.onMove(ray) + return true + } + + release(pointerId: SpatialPointerId): boolean { + const capture = this.captures.get(pointerId) + if (!capture) return false + this.captures.delete(pointerId) + capture.onRelease() + return true + } + + cancel(pointerId: SpatialPointerId): boolean { + const capture = this.captures.get(pointerId) + if (!capture) return false + this.captures.delete(pointerId) + capture.onCancel() + return true + } +} + +export const spatialPointerInput = new SpatialPointerInput() diff --git a/packages/editor/src/lib/spatial-snap.test.ts b/packages/editor/src/lib/spatial-snap.test.ts new file mode 100644 index 0000000000..20ae38ff3a --- /dev/null +++ b/packages/editor/src/lib/spatial-snap.test.ts @@ -0,0 +1,47 @@ +import { test } from 'bun:test' +import assert from 'node:assert/strict' +import { snapLocalXZInWorld } from '@pascal-app/core' +import { Group, Vector3 } from 'three' + +const close = (a: number, b: number) => assert.ok(Math.abs(a - b) < 1e-8) + +test('free placement stays under the same hit through God-mode zoom and rotation', () => { + const pose = { position: [5, 0, -3] as [number, number, number], rotationY: 0.7 } + const world = new Group() + const building = new Group() + building.position.fromArray(pose.position) + building.rotation.y = pose.rotationY + world.add(building) + for (const scale of [0.01, 0.1, 1, 4]) { + world.scale.setScalar(scale) + world.position.set(8, 1, -4) + world.rotation.y = 1.2 + world.updateWorldMatrix(true, true) + const hit = building.localToWorld(new Vector3(2, 0, -6)) + const local = building.worldToLocal(hit.clone()) + const snapped = snapLocalXZInWorld([local.x, local.z], pose, (value) => value) + const placed = building.localToWorld(new Vector3(snapped[0], 0, snapped[1])) + close(placed.distanceTo(hit), 0) + } +}) + +test('grid snapping still uses model units and the item footprint', () => { + const dimensions: number[] = [] + const result = snapLocalXZInWorld([1.23, -2.16], null, (value, axis) => { + dimensions.push([1.8, 0.6][axis]!) + return Math.round(value / 0.1) * 0.1 + }) + close(result[0], 1.2) + close(result[1], -2.2) + assert.deepEqual(dimensions, [1.8, 0.6]) +}) + +test('rotated buildings snap in model-world axes then return building-local coordinates', () => { + const result = snapLocalXZInWorld( + [1.2, 2.3], + { position: [10, 0, 20], rotationY: Math.PI / 2 }, + Math.round, + ) + close(result[0], 1) + close(result[1], 2) +}) diff --git a/packages/editor/src/lib/terrain-panel-model.ts b/packages/editor/src/lib/terrain-panel-model.ts new file mode 100644 index 0000000000..153547c55b --- /dev/null +++ b/packages/editor/src/lib/terrain-panel-model.ts @@ -0,0 +1,117 @@ +'use client' + +import { type SiteNode, type TerrainVerb, useScene } from '@pascal-app/core' +import useEditor from '../store/use-editor' +import type { PanelRow } from './panel-rows' +import { brushRadiusRange, flattenSite, resetSiteTerrain } from './terrain-sculpt' + +const TERRAIN_VERBS: TerrainVerb[] = ['raise', 'lower', 'flatten', 'smooth'] + +export function useTerrainPanelRows(): PanelRow[] { + const verb = useEditor((state) => state.terrainVerb) + const setVerb = useEditor((state) => state.setTerrainVerb) + const brush = useEditor((state) => state.terrainBrush) + const setBrush = useEditor((state) => state.setTerrainBrush) + const flattenTarget = useEditor((state) => state.terrainFlattenTarget) + const setFlattenTarget = useEditor((state) => state.setTerrainFlattenTarget) + const sampling = useEditor((state) => state.terrainSampling) + const setSampling = useEditor((state) => state.setTerrainSampling) + const site = useScene((state) => { + const root = state.rootNodeIds[0] + const node = root ? state.nodes[root] : undefined + return node?.type === 'site' ? (node as SiteNode) : null + }) + const [minRadius, maxRadius] = brushRadiusRange(site) + const verbIndex = TERRAIN_VERBS.indexOf(verb) + const cycleVerb = (direction: -1 | 1) => { + const next = + TERRAIN_VERBS[(verbIndex + direction + TERRAIN_VERBS.length) % TERRAIN_VERBS.length] + if (next) setVerb(next) + } + + const rows: PanelRow[] = [ + { + id: 'terrain-verb', + kind: 'cycle', + label: 'Brush', + next: () => cycleVerb(1), + previous: () => cycleVerb(-1), + value: verb.charAt(0).toUpperCase() + verb.slice(1), + }, + { + id: 'terrain-radius', + kind: 'stepper', + label: 'Size', + max: maxRadius, + min: minRadius, + onChange: (radius) => setBrush({ radius }), + step: 0.5, + unit: 'm', + value: Math.max(minRadius, Math.min(maxRadius, brush.radius)), + }, + { + id: 'terrain-strength', + kind: 'stepper', + label: 'Strength', + max: 1, + min: 0.05, + onChange: (strength) => setBrush({ strength }), + step: 0.05, + value: brush.strength, + }, + { + id: 'terrain-softness', + kind: 'stepper', + label: 'Softness', + max: 1, + min: 0, + onChange: (falloff) => setBrush({ falloff }), + step: 0.05, + value: brush.falloff, + }, + { + id: 'terrain-shape', + kind: 'choice', + label: 'Shape', + onSelect: () => setBrush({ shape: brush.shape === 'round' ? 'square' : 'round' }), + value: brush.shape === 'round' ? 'Round' : 'Square', + }, + ...(verb === 'flatten' + ? ([ + { + id: 'terrain-target', + kind: 'stepper', + label: 'Target', + max: 50, + min: -50, + onChange: setFlattenTarget, + step: 0.1, + unit: 'm', + value: flattenTarget ?? 0, + }, + { + id: 'terrain-sampling', + kind: 'choice', + label: 'Pick height', + onSelect: () => setSampling(!sampling), + value: sampling ? 'Armed' : 'Off', + }, + ] satisfies PanelRow[]) + : []), + { + id: 'terrain-level-lot', + kind: 'choice', + label: 'Whole lot', + onSelect: site ? () => flattenSite(site, flattenTarget ?? 0) : undefined, + value: 'Level', + }, + { + id: 'terrain-clear', + kind: 'choice', + label: 'Terrain', + onSelect: site?.terrain ? () => resetSiteTerrain(site) : undefined, + value: site?.terrain ? 'Clear' : 'Empty', + }, + ] + return rows +} diff --git a/packages/editor/src/lib/world-grid-snap.test.ts b/packages/editor/src/lib/world-grid-snap.test.ts new file mode 100644 index 0000000000..70436b2692 --- /dev/null +++ b/packages/editor/src/lib/world-grid-snap.test.ts @@ -0,0 +1,48 @@ +import { afterEach, describe, expect, test } from 'bun:test' +import { BuildingNode, LevelNode, useScene } from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import { getActiveBuildingId } from './world-grid-snap' + +const originalNodes = useScene.getState().nodes +const originalSelection = useViewer.getState().selection + +afterEach(() => { + useScene.setState({ nodes: originalNodes } as never) + useViewer.setState({ selection: originalSelection } as never) +}) + +describe('getActiveBuildingId', () => { + test('prefers the active level owner over a stale selected building', () => { + const staleBuilding = BuildingNode.parse({}) + const activeBuilding = BuildingNode.parse({}) + const level = LevelNode.parse({ parentId: activeBuilding.id }) + useScene.setState({ + nodes: { + [staleBuilding.id]: staleBuilding, + [activeBuilding.id]: activeBuilding, + [level.id]: level, + }, + } as never) + useViewer.setState({ + selection: { + buildingId: staleBuilding.id, + levelId: level.id, + zoneId: null, + selectedIds: [], + }, + } as never) + + expect(getActiveBuildingId()).toBe(activeBuilding.id) + }) + + test('resolves legacy level ownership from the building children list', () => { + const level = LevelNode.parse({}) + const building = BuildingNode.parse({ children: [level.id] }) + useScene.setState({ nodes: { [building.id]: building, [level.id]: level } } as never) + useViewer.setState({ + selection: { buildingId: null, levelId: level.id, zoneId: null, selectedIds: [] }, + } as never) + + expect(getActiveBuildingId()).toBe(building.id) + }) +}) diff --git a/packages/editor/src/lib/world-grid-snap.ts b/packages/editor/src/lib/world-grid-snap.ts index 1e255a6ce5..2889cad3e0 100644 --- a/packages/editor/src/lib/world-grid-snap.ts +++ b/packages/editor/src/lib/world-grid-snap.ts @@ -15,6 +15,7 @@ import { type BuildingPose, type ResolveAlignmentInBuildingResult, resolveAlignmentInBuildingWorld, + resolveBuildingForLevel, snapWorldXZToBuildingLocal, useLiveTransforms, useScene, @@ -39,7 +40,7 @@ import { useViewer } from '@pascal-app/viewer' * guides drift off the visible grid mid-drag (and through any post-drag * frame where the live override is still set). */ -export function getActiveBuildingPose(): BuildingPose | null { +export function getActiveBuildingId(): AnyNodeId | null { const sel = useViewer.getState().selection const nodes = useScene.getState().nodes // Match `use-floorplan-scene-data.ts`: prefer the active level's @@ -48,12 +49,15 @@ export function getActiveBuildingPose(): BuildingPose | null { // different level, the level path is the authoritative one. let buildingId: AnyNodeId | null = null if (sel.levelId) { - const level = nodes[sel.levelId] - if (level && level.type === 'level' && level.parentId) { - buildingId = level.parentId as AnyNodeId - } + buildingId = resolveBuildingForLevel(sel.levelId as AnyNodeId, nodes) } if (!buildingId) buildingId = sel.buildingId ?? null + return buildingId +} + +export function getActiveBuildingPose(): BuildingPose | null { + const buildingId = getActiveBuildingId() + const nodes = useScene.getState().nodes const building = buildingId ? nodes[buildingId] : null if (building?.type !== 'building') return null const live = useLiveTransforms.getState().transforms.get(buildingId as string) diff --git a/packages/editor/src/store/rotation-snapping.test.ts b/packages/editor/src/store/rotation-snapping.test.ts new file mode 100644 index 0000000000..a309b69606 --- /dev/null +++ b/packages/editor/src/store/rotation-snapping.test.ts @@ -0,0 +1,53 @@ +import { afterEach, expect, test } from 'bun:test' +import { GROUP_ROTATE_DRAG_LABEL, ROTATE_HANDLE_DRAG_LABEL } from '../lib/contextual-help' +import useEditor, { + getActiveSnapContext, + isAngleSnapActive, + normalizePersistedEditorLayoutState, +} from './use-editor' +import useInteractionScope from './use-interaction-scope' + +const initialEditor = useEditor.getState() +const initialScope = useInteractionScope.getState() +afterEach(() => { + useEditor.setState(initialEditor) + useInteractionScope.setState(initialScope) +}) + +for (const handle of [ROTATE_HANDLE_DRAG_LABEL, GROUP_ROTATE_DRAG_LABEL]) { + test(`${handle} reads and cycles the shared mode without changing placement modes`, () => { + useInteractionScope.getState().begin({ kind: 'handle-drag', nodeId: 'item_test', handle }) + const placementModes = useEditor.getState().snappingModeByContext + expect(getActiveSnapContext()).toBe('rotation') + expect(isAngleSnapActive()).toBe(true) + expect(useEditor.getState().cycleSnappingMode()).toBe('off') + expect(isAngleSnapActive()).toBe(false) + expect(useEditor.getState().snappingModeByContext.wall).toBe(placementModes.wall) + expect(useEditor.getState().snappingModeByContext.item).toBe(placementModes.item) + expect(useEditor.getState().cycleSnappingMode()).toBe('angles') + expect(isAngleSnapActive()).toBe(true) + }) +} + +test('older persisted preferences receive the rotation default and retain other modes', () => { + const merged = normalizePersistedEditorLayoutState({ + snappingModeByContext: { wall: 'off', item: 'lines', polygon: 'grid' }, + }) + expect(merged?.snappingModeByContext).toEqual({ + wall: 'off', + item: 'lines', + polygon: 'grid', + rotation: 'angles', + }) +}) + +test('persisted rotation mode is retained when valid and reset when invalid', () => { + expect( + normalizePersistedEditorLayoutState({ snappingModeByContext: { rotation: 'off' } }) + .snappingModeByContext.rotation, + ).toBe('off') + expect( + normalizePersistedEditorLayoutState({ snappingModeByContext: { rotation: 'grid' } }) + .snappingModeByContext.rotation, + ).toBe('angles') +}) diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx index c64d6ef15c..20a95cf329 100644 --- a/packages/editor/src/store/use-editor.tsx +++ b/packages/editor/src/store/use-editor.tsx @@ -570,6 +570,7 @@ export const DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE: PersistedEditorLayoutState = wall: defaultSnappingModeFor('wall'), item: defaultSnappingModeFor('item'), polygon: defaultSnappingModeFor('polygon'), + rotation: defaultSnappingModeFor('rotation'), }, continuationByContext: { wall: CONTINUATION_PROFILES.wall.default, @@ -800,8 +801,14 @@ function normalizeContinuationByContext( } } -function normalizePersistedEditorLayoutState( - state: (Partial & LegacyContinuationState) | null | undefined, +export function normalizePersistedEditorLayoutState( + state: + | (Omit, 'snappingModeByContext'> & + LegacyContinuationState & { + snappingModeByContext?: Partial> + }) + | null + | undefined, ): PersistedEditorLayoutState { return { activeSidebarPanel: @@ -821,6 +828,7 @@ function normalizePersistedEditorLayoutState( wall: migrateSnappingMode(state?.snappingModeByContext?.wall, 'wall'), item: migrateSnappingMode(state?.snappingModeByContext?.item, 'item'), polygon: migrateSnappingMode(state?.snappingModeByContext?.polygon, 'polygon'), + rotation: migrateSnappingMode(state?.snappingModeByContext?.rotation, 'rotation'), }, continuationByContext: normalizeContinuationByContext(state), showReferenceFloor: state?.showReferenceFloor === true, diff --git a/packages/nodes/src/cabinet/__tests__/placement-snap.test.ts b/packages/nodes/src/cabinet/__tests__/placement-snap.test.ts index 3db949c3a4..080f7ab707 100644 --- a/packages/nodes/src/cabinet/__tests__/placement-snap.test.ts +++ b/packages/nodes/src/cabinet/__tests__/placement-snap.test.ts @@ -1,9 +1,33 @@ import { describe, expect, test } from 'bun:test' -import { resolveCabinetGridPosition, resolveCabinetGridPositionInFrame } from '../placement-snap' +import type { AnyNode } from '@pascal-app/core' +import { + resolveCabinetGridPosition, + resolveCabinetGridPositionInFrame, + resolveCabinetLevelPlanFrame, +} from '../placement-snap' const DIMENSIONS: [number, number, number] = [0.6, 0.84, 0.58] describe('cabinet placement grid snap', () => { + test('uses the origin for legacy buildings without explicit transforms', () => { + const nodes = { + building_test: { + id: 'building_test', + type: 'building', + }, + level_test: { + id: 'level_test', + parentId: 'building_test', + type: 'level', + }, + } as unknown as Record + + expect(resolveCabinetLevelPlanFrame('level_test', nodes)).toEqual({ + position: [0, 0], + rotationY: 0, + }) + }) + test('aligns the footprint edges to grid lines', () => { const position = resolveCabinetGridPosition({ raw: [0.12, 0, 0.17], diff --git a/packages/nodes/src/cabinet/placement-snap.ts b/packages/nodes/src/cabinet/placement-snap.ts index b5cf702ee9..c85585eff8 100644 --- a/packages/nodes/src/cabinet/placement-snap.ts +++ b/packages/nodes/src/cabinet/placement-snap.ts @@ -17,9 +17,11 @@ export function resolveCabinetLevelPlanFrame( const level = nodes[levelId] const building = level?.parentId ? nodes[level.parentId] : undefined if (building?.type !== 'building') return { position: [0, 0], rotationY: 0 } + const position = Array.isArray(building.position) ? building.position : [0, 0, 0] + const rotation = Array.isArray(building.rotation) ? building.rotation : [0, 0, 0] return { - position: [building.position[0], building.position[2]], - rotationY: building.rotation[1], + position: [position[0] ?? 0, position[2] ?? 0], + rotationY: rotation[1] ?? 0, } } diff --git a/packages/nodes/src/cabinet/tool.tsx b/packages/nodes/src/cabinet/tool.tsx index 2fb78a2065..8ddb2dafbd 100644 --- a/packages/nodes/src/cabinet/tool.tsx +++ b/packages/nodes/src/cabinet/tool.tsx @@ -411,6 +411,7 @@ const CabinetTool = () => { const [placement, setPlacement] = useState(null) const [draftSegments, setDraftSegments] = useState([]) const [yaw, setYaw] = useState(0) + const toolDefaults = useEditor((state) => state.toolDefaults.cabinet) const placementType = useCabinetPlacementType((s) => s.type) const islandMode = placementType === 'island' const yawRef = useRef(0) @@ -438,6 +439,7 @@ const CabinetTool = () => { return CabinetModuleNode.parse({ ...cabinetModuleDefinition.defaults(), ...DEFAULT_PLACEMENT_PRESET.createPatch(), + ...toolDefaults, showPlinth: runDefaults.showPlinth, plinthHeight: runDefaults.plinthHeight, toeKickDepth: runDefaults.toeKickDepth, @@ -446,7 +448,7 @@ const CabinetTool = () => { countertopOverhang: runDefaults.countertopOverhang, countertopBackOverhang: runDefaults.countertopBackOverhang, }) - }, []) + }, [toolDefaults]) const [previewSize, setPreviewSize] = useState(() => ({ depth: previewNodeTemplate.depth, height: previewNodeTemplate.carcassHeight, @@ -1165,12 +1167,13 @@ const CabinetTool = () => { const island = islandModeRef.current const cabinet = CabinetNode.parse({ ...cabinetDefinition.defaults(), + ...toolDefaults, name: island ? 'Kitchen Island' : 'Modular Cabinet', position, rotation: yaw, parentId: activeLevelId, - depth: patch.depth ?? cabinetDefinition.defaults().depth, - carcassHeight: patch.carcassHeight ?? cabinetDefinition.defaults().carcassHeight, + depth: previewNode.depth, + carcassHeight: previewNode.carcassHeight, ...(island && { countertopBackOverhang: ISLAND_SEATING_OVERHANG, withFinishedBack: true, @@ -1180,6 +1183,7 @@ const CabinetTool = () => { CabinetModuleNode.parse({ ...cabinetModuleDefinition.defaults(), ...patch, + ...toolDefaults, name: index === 0 ? (patch.name ?? 'Base Cabinet') : `Base Cabinet ${index + 1}`, parentId: cabinet.id, position: [localX, runModuleBaseY(cabinet.plinthHeight, cabinet.showPlinth), 0], @@ -1774,7 +1778,7 @@ const CabinetTool = () => { useAlignmentGuides.getState().clear() useCabinetPlacementStatus.getState().setBlocked(false) } - }, [activeLevelId, metricNotation, publishFloorplanPreview, unit]) + }, [activeLevelId, metricNotation, previewNode, publishFloorplanPreview, toolDefaults, unit]) if (!activeLevelId || !placement) return null const stretch = placement.stretch diff --git a/packages/nodes/src/ceiling/renderer.tsx b/packages/nodes/src/ceiling/renderer.tsx index 781c4c5a79..d82a1c03f3 100644 --- a/packages/nodes/src/ceiling/renderer.tsx +++ b/packages/nodes/src/ceiling/renderer.tsx @@ -13,6 +13,7 @@ import { createSurfaceRoleMaterial, NodeRenderer, resolveSurfaceColor, + useNodeEvents, useViewer, } from '@pascal-app/viewer' import { useEffect, useLayoutEffect, useMemo, useRef } from 'react' @@ -27,6 +28,7 @@ function createEmptyGeometry() { export const CeilingRenderer = ({ node }: { node: CeilingNode }) => { const ref = useRef(null!) + const handlers = useNodeEvents(node, 'ceiling') const placeholderGeometry = useMemo(createEmptyGeometry, []) const gridPlaceholderGeometry = useMemo(createEmptyGeometry, []) @@ -116,6 +118,7 @@ export const CeilingRenderer = ({ node }: { node: CeilingNode }) => { material={materials.bottomMaterial} position={position} ref={ref} + {...handlers} > { event.stopPropagation() } - const onWallLeave = () => { + // XR runtimes deliver selectend as a grid click, but do not replay the + // R3F wall pointer-up event. Commit the latest valid wall hover so a + // controller/hand release places the opening on the wall it visibly hit. + const onXRGridClick = (event: GridEvent) => { + if (event.nativeEvent?.pointerType !== 'xr' || !lastWallEvent) return + onWallClick(lastWallEvent) + } + + const onWallLeave = (event: WallEvent) => { + if (!shouldHandleOpeningHostLeave(event.nativeEvent)) return if (hostKind !== 'wall') return lastWallEvent = null destroyDraft() @@ -544,7 +557,15 @@ const DoorTool: React.FC = () => { // timeStamp) — it owns the frame and has snapped the draft, so skip the // floor follow this tick. const ts = event.nativeEvent?.timeStamp ?? -1 - if (ts === lastMeshEventTime) return + if ( + !shouldFollowOpeningGrid({ + eventTime: ts, + hasActiveHost: hostKind !== null, + lastHostEventTime: lastMeshEventTime, + pointerType: event.nativeEvent?.pointerType, + }) + ) + return // Fresh floor-only frame: the cursor is off any wall/roof. Drop any draft // and free-follow the cursor with the invalid (unplaceable) ghost. hostKind = null @@ -720,6 +741,7 @@ const DoorTool: React.FC = () => { emitter.on('wall:enter', onWallHover) emitter.on('wall:move', onWallHover) emitter.on('wall:click', onWallClick) + emitter.on('grid:click', onXRGridClick) emitter.on('wall:leave', onWallLeave) emitter.on('roof:enter', onRoofHover) emitter.on('roof:move', onRoofHover) @@ -745,6 +767,7 @@ const DoorTool: React.FC = () => { emitter.off('wall:enter', onWallHover) emitter.off('wall:move', onWallHover) emitter.off('wall:click', onWallClick) + emitter.off('grid:click', onXRGridClick) emitter.off('wall:leave', onWallLeave) emitter.off('roof:enter', onRoofHover) emitter.off('roof:move', onRoofHover) diff --git a/packages/nodes/src/duct-segment/tool.tsx b/packages/nodes/src/duct-segment/tool.tsx index 4ead264450..5ccf1bc180 100644 --- a/packages/nodes/src/duct-segment/tool.tsx +++ b/packages/nodes/src/duct-segment/tool.tsx @@ -512,6 +512,9 @@ export function planDuctDraw( const DuctSegmentTool = () => { const { activeLevelId, sceneApi, unit } = useRegistryToolContext() + const toolDefaults = useEditor((s) => s.toolDefaults['duct-segment']) as + | Partial + | undefined const cursorRef = useRef(null) const continuationSeedRef = useRef(currentDuctContinuationSeed()) const continuationSeed = continuationSeedRef.current @@ -675,6 +678,16 @@ const DuctSegmentTool = () => { }, }) + useEffect(() => { + if (!toolDefaults) return + setProfile((current) => ({ + shape: toolDefaults.shape ?? current.shape, + diameter: toolDefaults.diameter ?? current.diameter, + width: toolDefaults.width ?? current.width, + height: toolDefaults.height ?? current.height, + })) + }, [toolDefaults]) + const previewPlan = useMemo(() => { if (!(activeLevelId && run.start && run.cursor)) return null return planDuctDraw( diff --git a/packages/nodes/src/duct-terminal/tool.tsx b/packages/nodes/src/duct-terminal/tool.tsx index e5c04d0e23..7f3f16228c 100644 --- a/packages/nodes/src/duct-terminal/tool.tsx +++ b/packages/nodes/src/duct-terminal/tool.tsx @@ -5,6 +5,8 @@ import { type CeilingNode, DuctTerminalNode, emitter, + type GridEvent, + type NodeEvent, pointInPolygon, resolveCeilingHeight, resolveLevelId, @@ -16,6 +18,7 @@ import { import { CursorSphere, getFloorStackPreviewPosition, + getSpatialPointerId, isGridSnapActive, isMagneticSnapActive, triggerSFX, @@ -155,6 +158,7 @@ function resolvePortSnap( const DuctTerminalTool = () => { const { camera, gl } = useThree() const activeLevelId = useViewer((s) => s.selection.levelId) + const toolDefaults = useEditor((s) => s.toolDefaults['duct-terminal']) const [mount, setMount] = useState('floor') const [placement, setPlacement] = useState(null) @@ -167,14 +171,18 @@ const DuctTerminalTool = () => { // override the manual M selection (port direction picks floor / ceiling / // wall), so the preview must show the inferred mount, not the toolbar one. const effectiveMount = placement?.mount ?? mount - const previewNode = useMemo( + const configuredNode = useMemo( () => DuctTerminalNode.parse({ ...ductTerminalDefinition.defaults(), + ...toolDefaults, name: 'Register', - mount: effectiveMount, }), - [effectiveMount], + [toolDefaults], + ) + const previewNode = useMemo( + () => DuctTerminalNode.parse({ ...configuredNode, mount: effectiveMount }), + [configuredNode, effectiveMount], ) const ghost = useMemo(() => { const group = buildDuctTerminalGeometry(previewNode) @@ -188,6 +196,12 @@ const DuctTerminalTool = () => { return group }, [previewNode]) + useEffect(() => { + mountRef.current = configuredNode.mount + setMount(configuredNode.mount) + setPlacement(null) + }, [configuredNode.mount]) + useEffect(() => { if (!activeLevelId) return const canvas = gl.domElement @@ -285,6 +299,7 @@ const DuctTerminalTool = () => { const commit = (p: Placement) => { const terminal = DuctTerminalNode.parse({ ...ductTerminalDefinition.defaults(), + ...toolDefaults, name: 'Register', mount: p.mount, position: p.position, @@ -300,17 +315,66 @@ const DuctTerminalTool = () => { triggerSFX('sfx:item-place') } - // ---- Floor / ceiling: own raycast against a horizontal plane ---- let lastPointer: PointerEvent | null = null let lastWall: WallEvent | null = null + let lastGrid: GridEvent | null = null + let lastXRNode: NodeEvent | null = null + const resolveGrid = (event: GridEvent): Placement => { + const step = isGridSnapActive() ? useEditor.getState().gridSnapStep : 0 + const position = alignDrawPoint( + [snap(event.localPosition[0], step), 0, snap(event.localPosition[2], step)], + { applySnap: isMagneticSnapActive(), bypass: !isMagneticSnapActive() }, + ) + const snapEnabled = isGridSnapActive() || isMagneticSnapActive() + const mated = snapEnabled ? resolvePortSnap(position, yawRef.current) : null + return mated + ? { position: mated.position, yaw: mated.yaw, mount: mated.mount, snapped: true } + : { position, yaw: yawRef.current, mount: 'floor' } + } + + const onGridMove = (event: GridEvent) => { + lastGrid = event + if (mountRef.current === 'floor') setPlacement(resolveGrid(event)) + } + + const onGridClick = (event: GridEvent) => { + if (mountRef.current === 'floor') commit(resolveGrid(event)) + } + + const resolveXRCeiling = (event: NodeEvent): Placement | null => { + if (mountRef.current !== 'ceiling' || event.node.type !== 'ceiling') return null + if (getSpatialPointerId(event.nativeEvent) == null) return null + const world = new Vector3(...event.position) + const level = activeLevelMesh() + const local = level ? level.worldToLocal(world) : world + return { + position: [local.x, resolveCeilingHeight(event.node, useScene.getState().nodes), local.z], + yaw: yawRef.current, + mount: 'ceiling', + } + } + + const onXRNodeMove = (event: NodeEvent) => { + lastXRNode = event + const p = resolveXRCeiling(event) + if (p) setPlacement(p) + } + + const onXRNodeClick = (event: NodeEvent) => { + const p = resolveXRCeiling(event) + if (p) commit(p) + } + + // ---- Ceiling: desktop owns a canvas ray; XR uses the spatial node ray. ---- const onPointerMove = (e: PointerEvent) => { lastPointer = e - if (mountRef.current === 'wall') return + lastXRNode = null + if (mountRef.current !== 'ceiling') return setPlacement(resolvePlanar(e)) } const onCanvasClick = (e: MouseEvent) => { - if (mountRef.current === 'wall') return + if (mountRef.current !== 'ceiling') return if (useViewer.getState().cameraDragging) return if ((e as PointerEvent).button !== undefined && (e as PointerEvent).button !== 0) return const p = resolvePlanar(e) @@ -388,10 +452,18 @@ const DuctTerminalTool = () => { const unsubscribeSnapping = subscribeAccessorySnapping(() => { if (mountRef.current === 'wall') { if (lastWall) onWallMove(lastWall) + } else if (mountRef.current === 'floor') { + if (lastGrid) onGridMove(lastGrid) + } else if (lastXRNode && getSpatialPointerId(lastXRNode.nativeEvent) != null) { + onXRNodeMove(lastXRNode) } else if (lastPointer) onPointerMove(lastPointer) }) canvas.addEventListener('pointermove', onPointerMove) canvas.addEventListener('click', onCanvasClick) + emitter.on('grid:move', onGridMove) + emitter.on('grid:click', onGridClick) + emitter.on('node:move', onXRNodeMove) + emitter.on('node:click', onXRNodeClick) emitter.on('wall:move', onWallMove) emitter.on('wall:click', onWallClick) window.addEventListener('keydown', onKeyDown, true) @@ -399,12 +471,16 @@ const DuctTerminalTool = () => { unsubscribeSnapping() canvas.removeEventListener('pointermove', onPointerMove) canvas.removeEventListener('click', onCanvasClick) + emitter.off('grid:move', onGridMove) + emitter.off('grid:click', onGridClick) + emitter.off('node:move', onXRNodeMove) + emitter.off('node:click', onXRNodeClick) emitter.off('wall:move', onWallMove) emitter.off('wall:click', onWallClick) window.removeEventListener('keydown', onKeyDown, true) clearDrawAlignment() } - }, [activeLevelId, camera, gl]) + }, [activeLevelId, camera, gl, toolDefaults]) if (!activeLevelId || !placement) return null diff --git a/packages/nodes/src/elevator/parametrics.ts b/packages/nodes/src/elevator/parametrics.ts index 767800b9cf..4d604fc848 100644 --- a/packages/nodes/src/elevator/parametrics.ts +++ b/packages/nodes/src/elevator/parametrics.ts @@ -1,6 +1,103 @@ import type { ElevatorNode, ParametricDescriptor } from '@pascal-app/core' export const elevatorParametrics: ParametricDescriptor = { - groups: [], + groups: [ + { + label: 'Position', + fields: [ + { key: 'position', kind: 'vec3' }, + { + key: 'rotation', + kind: 'number', + label: 'Rotation (rad)', + min: -Math.PI, + max: Math.PI, + step: Math.PI / 36, + }, + ], + }, + { + label: 'Cab', + fields: [ + { key: 'width', kind: 'number', unit: 'm', min: 0.8, max: 1000, step: 0.05 }, + { key: 'depth', kind: 'number', unit: 'm', min: 0.8, max: 1000, step: 0.05 }, + { key: 'cabHeight', kind: 'number', unit: 'm', min: 1.8, max: 1000, step: 0.05 }, + ], + }, + { + label: 'Shaft', + fields: [ + { + key: 'shaftWallThickness', + kind: 'number', + label: 'Wall thickness', + unit: 'm', + min: 0.04, + max: 0.4, + step: 0.01, + }, + { + key: 'shaftStyle', + kind: 'enum', + options: ['solid', 'glass'], + display: 'segmented', + }, + ], + }, + { + label: 'Doors', + fields: [ + { key: 'doorWidth', kind: 'number', unit: 'm', min: 0.45, max: 1000, step: 0.05 }, + { key: 'doorHeight', kind: 'number', unit: 'm', min: 1.2, max: 1000, step: 0.05 }, + { + key: 'doorStyle', + kind: 'enum', + options: ['center-opening', 'single-left', 'single-right'], + }, + { + key: 'doorPanelStyle', + kind: 'enum', + label: 'Panel style', + options: ['glass-frame', 'solid-panel', 'segmented-panel'], + }, + ], + }, + { + label: 'Levels', + fields: [ + { key: 'fromLevelId', kind: 'ref', label: 'From level', refKind: 'level' }, + { key: 'toLevelId', kind: 'ref', label: 'To level', refKind: 'level' }, + { key: 'defaultLevelId', kind: 'ref', label: 'Default level', refKind: 'level' }, + ], + }, + { + label: 'Motion', + fields: [ + { key: 'speed', kind: 'number', unit: 'm/s', min: 0.5, max: 8, step: 0.1 }, + { + key: 'doorDurationMs', + kind: 'number', + label: 'Door time', + unit: 'ms', + min: 300, + max: 2200, + step: 50, + }, + { + key: 'dwellMs', + kind: 'number', + label: 'Dwell', + unit: 'ms', + min: 300, + max: 5000, + step: 100, + }, + ], + }, + ], + derive: (next) => ({ + doorHeight: Math.min(next.doorHeight, Math.max(1.2, next.cabHeight - 0.1)), + doorWidth: Math.min(next.doorWidth, Math.max(0.45, next.width - 0.1)), + }), customPanel: () => import('./panel'), } diff --git a/packages/nodes/src/hvac-equipment/tool.tsx b/packages/nodes/src/hvac-equipment/tool.tsx index 06783b38a0..66fa196697 100644 --- a/packages/nodes/src/hvac-equipment/tool.tsx +++ b/packages/nodes/src/hvac-equipment/tool.tsx @@ -36,13 +36,19 @@ function snap(value: number, step: number): number { */ const HvacEquipmentTool = () => { const activeLevelId = useViewer((s) => s.selection.levelId) + const toolDefaults = useEditor((s) => s.toolDefaults['hvac-equipment']) const [cursor, setCursor] = useState<[number, number, number] | null>(null) const [yaw, setYaw] = useState(0) const yawRef = useRef(0) const previewNode = useMemo( - () => HvacEquipmentNode.parse({ ...hvacEquipmentDefinition.defaults(), name: 'Furnace' }), - [], + () => + HvacEquipmentNode.parse({ + ...hvacEquipmentDefinition.defaults(), + ...toolDefaults, + name: 'Furnace', + }), + [toolDefaults], ) const ghost = useMemo(() => { const group = buildHvacEquipmentGeometry(previewNode) @@ -84,6 +90,7 @@ const HvacEquipmentTool = () => { const position = resolveAligned(event) const unit = HvacEquipmentNode.parse({ ...hvacEquipmentDefinition.defaults(), + ...toolDefaults, name: 'Furnace', position, rotation: yawRef.current, @@ -126,7 +133,7 @@ const HvacEquipmentTool = () => { window.removeEventListener('keydown', onKeyDown, true) clearDrawAlignment() } - }, [activeLevelId]) + }, [activeLevelId, toolDefaults]) if (!activeLevelId || !cursor) return null diff --git a/packages/nodes/src/lineset/tool.tsx b/packages/nodes/src/lineset/tool.tsx index 4c8fe92e00..6672235cfc 100644 --- a/packages/nodes/src/lineset/tool.tsx +++ b/packages/nodes/src/lineset/tool.tsx @@ -120,6 +120,7 @@ const LinesetTool = () => { // mitered polyline run. const lineset = LinesetNode.parse({ ...linesetDefinition.defaults(), + ...useEditor.getState().toolDefaults.lineset, name: 'Lineset', path: [start, end], }) diff --git a/packages/nodes/src/liquid-line/tool.tsx b/packages/nodes/src/liquid-line/tool.tsx index 05f5761e0c..d56c4feb4e 100644 --- a/packages/nodes/src/liquid-line/tool.tsx +++ b/packages/nodes/src/liquid-line/tool.tsx @@ -308,6 +308,7 @@ const LiquidLineTool = () => { // rather than folding into one mitered polyline run. const line = LiquidLineNode.parse({ ...liquidLineDefinition.defaults(), + ...useEditor.getState().toolDefaults['liquid-line'], name: 'Liquid Line', path: [start, end], }) @@ -325,7 +326,10 @@ const LiquidLineTool = () => { const commitTrace = (target: FollowTarget) => { const traced = tracePath(target) if (!traced) return - const defaults = liquidLineDefinition.defaults() + const defaults = { + ...liquidLineDefinition.defaults(), + ...useEditor.getState().toolDefaults['liquid-line'], + } const create = [] for (let i = 0; i < traced.length - 1; i++) { const a = traced[i]! diff --git a/packages/nodes/src/pipe-segment/tool.tsx b/packages/nodes/src/pipe-segment/tool.tsx index 0006dd53e8..9dfe5710fc 100644 --- a/packages/nodes/src/pipe-segment/tool.tsx +++ b/packages/nodes/src/pipe-segment/tool.tsx @@ -63,6 +63,9 @@ function getConnectionPorts( const PipeSegmentTool = () => { const { activeLevelId, sceneApi, unit } = useRegistryToolContext() + const toolDefaults = useEditor((s) => s.toolDefaults['pipe-segment']) as + | Partial + | undefined const continuationSeedRef = useRef(currentPipeContinuationSeed()) const continuationSeed = continuationSeedRef.current const hangerDefaults = useEditor((state) => state.toolDefaults['pipe-segment']) @@ -87,16 +90,18 @@ const PipeSegmentTool = () => { system: PipeSegmentNode['system'] } const [system, setSystem] = useState<'waste' | 'vent'>( - continuationSeed?.pipe.system ?? defaults.system, + continuationSeed?.pipe.system ?? toolDefaults?.system ?? defaults.system, ) const [sloped, setSloped] = useState(false) const slopePercent = 100 / 48 const slopeDirection = 1 const gradeRef = useRef(slopePercent / 100) gradeRef.current = (slopeDirection * slopePercent) / 100 - const [diameter, setDiameter] = useState(continuationSeed?.pipe.diameter ?? defaults.diameter) + const [diameter, setDiameter] = useState( + continuationSeed?.pipe.diameter ?? toolDefaults?.diameter ?? defaults.diameter, + ) const [pipeMaterial, setPipeMaterial] = useState( - continuationSeed?.pipe.pipeMaterial ?? defaults.pipeMaterial, + continuationSeed?.pipe.pipeMaterial ?? toolDefaults?.pipeMaterial ?? defaults.pipeMaterial, ) const systemRef = useRef(system) systemRef.current = system @@ -113,6 +118,13 @@ const PipeSegmentTool = () => { return () => mode.setEnabled('pipe-segment', false) }, []) + useEffect(() => { + if (!toolDefaults) return + if (toolDefaults.system) setSystem(toolDefaults.system) + if (typeof toolDefaults.diameter === 'number') setDiameter(toolDefaults.diameter) + if (toolDefaults.pipeMaterial) setPipeMaterial(toolDefaults.pipeMaterial) + }, [toolDefaults]) + const commitSegment = ({ start: rawStart, end, diff --git a/packages/nodes/src/pipe-trap/tool.tsx b/packages/nodes/src/pipe-trap/tool.tsx index d61abefa7e..2fc1916265 100644 --- a/packages/nodes/src/pipe-trap/tool.tsx +++ b/packages/nodes/src/pipe-trap/tool.tsx @@ -27,20 +27,18 @@ function snap(value: number, step: number): number { */ const PipeTrapTool = () => { const activeLevelId = useViewer((s) => s.selection.levelId) + const toolDefaults = useEditor((s) => s.toolDefaults['pipe-trap']) const [cursor, setCursor] = useState<[number, number, number] | null>(null) const [yaw, setYaw] = useState(0) - const [diameter] = useState(pipeTrapDefinition.defaults().diameter) const yawRef = useRef(0) - const diameterRef = useRef(diameter) - diameterRef.current = diameter const previewNode = useMemo( () => PipeTrapNode.parse({ ...pipeTrapDefinition.defaults(), - diameter, + ...toolDefaults, }), - [diameter], + [toolDefaults], ) const ghost = useMemo(() => { const group = buildPipeTrapGeometry(previewNode) @@ -67,7 +65,7 @@ const PipeTrapTool = () => { bypass: !isMagneticSnapActive(), }, ), - diameter: diameterRef.current, + diameter: previewNode.diameter, } } @@ -81,6 +79,7 @@ const PipeTrapTool = () => { const r = resolve(event) const trap = PipeTrapNode.parse({ ...pipeTrapDefinition.defaults(), + ...toolDefaults, diameter: r.diameter, position: r.position, rotation: yawRef.current, @@ -117,7 +116,7 @@ const PipeTrapTool = () => { window.removeEventListener('keydown', onKeyDown, true) clearDrawAlignment() } - }, [activeLevelId]) + }, [activeLevelId, previewNode.diameter, toolDefaults]) if (!activeLevelId || !cursor) return null @@ -133,7 +132,7 @@ const PipeTrapTool = () => { zIndexRange={[100, 0]} >
- {diameter}" Trap + {previewNode.diameter}" Trap · diff --git a/packages/nodes/src/roof-segment/parametrics.ts b/packages/nodes/src/roof-segment/parametrics.ts index 6c816a8567..56d2da33e1 100644 --- a/packages/nodes/src/roof-segment/parametrics.ts +++ b/packages/nodes/src/roof-segment/parametrics.ts @@ -1,6 +1,182 @@ import type { ParametricDescriptor, RoofSegmentNode } from '@pascal-app/core' export const roofSegmentParametrics: ParametricDescriptor = { - groups: [], + groups: [ + { + label: 'Footprint', + fields: [ + { key: 'width', kind: 'number', unit: 'm', min: 0.5, max: 1000, step: 0.5 }, + { + key: 'depth', + kind: 'number', + unit: 'm', + min: 0.5, + max: 1000, + step: 0.5, + visibleIf: (node) => node.roofType !== 'conical', + }, + ], + }, + { + label: 'Shape', + fields: [ + { + key: 'wallHeight', + kind: 'number', + label: 'Wall', + unit: 'm', + min: 0, + max: 1000, + step: 0.1, + }, + { key: 'pitch', kind: 'number', label: 'Pitch', unit: '°', min: 0, max: 85, step: 1 }, + { + key: 'conicalFullCircle', + kind: 'boolean', + label: 'Full circle', + visibleIf: (node) => node.roofType === 'conical', + }, + { + key: 'gambrelLowerWidthRatio', + kind: 'number', + label: 'Kink depth', + min: 0.1, + max: 0.9, + step: 0.01, + visibleIf: (node) => node.roofType === 'gambrel', + }, + { + key: 'gambrelLowerHeightRatio', + kind: 'number', + label: 'Kink height', + min: 0.1, + max: 0.9, + step: 0.01, + visibleIf: (node) => node.roofType === 'gambrel', + }, + { + key: 'mansardSteepWidthRatio', + kind: 'number', + label: 'Waist width', + min: 0.05, + max: 0.45, + step: 0.01, + visibleIf: (node) => node.roofType === 'mansard', + }, + { + key: 'mansardSteepHeightRatio', + kind: 'number', + label: 'Waist height', + min: 0.1, + max: 0.9, + step: 0.01, + visibleIf: (node) => node.roofType === 'mansard', + }, + { + key: 'dutchHipWidthRatio', + kind: 'number', + label: 'Waist width', + min: 0.05, + max: 0.45, + step: 0.01, + visibleIf: (node) => node.roofType === 'dutch', + }, + { + key: 'dutchHipHeightRatio', + kind: 'number', + label: 'Waist height', + min: 0.1, + max: 0.9, + step: 0.01, + visibleIf: (node) => node.roofType === 'dutch', + }, + { + key: 'dutchWaistLengthRatio', + kind: 'number', + label: 'Waist length', + min: 0.1, + max: 1, + step: 0.01, + visibleIf: (node) => node.roofType === 'dutch', + }, + { + key: 'dutchTopRakeThickness', + kind: 'number', + label: 'Top rake thickness', + unit: 'm', + min: 0.01, + max: 0.5, + step: 0.01, + visibleIf: (node) => node.roofType === 'dutch', + }, + { + key: 'dutchGabletRake', + kind: 'number', + label: 'Top rake length', + unit: 'm', + min: 0, + max: 3, + step: 0.01, + visibleIf: (node) => node.roofType === 'dutch', + }, + ], + }, + { + label: 'Structure', + fields: [ + { + key: 'wallThickness', + kind: 'number', + label: 'Wall thickness', + unit: 'm', + min: 0.05, + max: 1, + step: 0.05, + }, + { + key: 'deckThickness', + kind: 'number', + label: 'Deck thickness', + unit: 'm', + min: 0.04, + max: 0.3, + step: 0.01, + }, + { key: 'overhang', kind: 'number', unit: 'm', min: 0, max: 1, step: 0.05 }, + { + key: 'shingleThickness', + kind: 'number', + label: 'Shingle thickness', + unit: 'm', + min: 0.02, + max: 0.3, + step: 0.01, + }, + { + key: 'wallShell', + kind: 'enum', + label: 'Wall shell', + options: ['auto', 'include', 'omit'], + }, + ], + }, + { + label: 'Position', + fields: [ + { key: 'position', kind: 'vec3' }, + { + key: 'rotation', + kind: 'number', + label: 'Rotation (rad)', + min: -Math.PI, + max: Math.PI, + step: Math.PI / 36, + visibleIf: (node) => node.roofType !== 'conical', + }, + ], + }, + ], + derive: (next, patch) => + next.roofType === 'conical' && patch.width !== undefined ? { depth: next.width } : {}, customPanel: () => import('./panel'), } diff --git a/packages/nodes/src/roof/definition.ts b/packages/nodes/src/roof/definition.ts index 0b52a822cb..94a011a018 100644 --- a/packages/nodes/src/roof/definition.ts +++ b/packages/nodes/src/roof/definition.ts @@ -7,8 +7,13 @@ import { type RoofSegmentNode, type SceneApi, } from '@pascal-app/core' -import { DRAFTING_SURFACE_EXTENSION_KEY, type DraftingSurfaceExtension } from '@pascal-app/editor' +import { + DRAFTING_SURFACE_EXTENSION_KEY, + type DraftingSurfaceExtension, + PANEL_MODEL_EXTENSION, +} from '@pascal-app/editor' import { buildRoofFloorplan } from './floorplan' +import { roofPanelModel } from './panel-model' import { roofParametrics } from './parametrics' import useRoofFootprintSource from './roof-footprint-source' import useRoofPlacementMode, { @@ -117,6 +122,7 @@ export const roofDefinition: NodeDefinition = { category: 'structure', surfaceRole: 'roof', extensions: { + [PANEL_MODEL_EXTENSION]: roofPanelModel, [DRAFTING_SURFACE_EXTENSION_KEY]: { kind: 'roof', } satisfies DraftingSurfaceExtension, diff --git a/packages/nodes/src/roof/panel-model.test.ts b/packages/nodes/src/roof/panel-model.test.ts new file mode 100644 index 0000000000..7848b16f39 --- /dev/null +++ b/packages/nodes/src/roof/panel-model.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, test } from 'bun:test' +import { + type AnyNode, + type AnyNodeId, + ChimneyNode, + RoofNode, + RoofSegmentNode, +} from '@pascal-app/core' +import { roofPanelModel } from './panel-model' + +describe('shared roof inspector', () => { + test('finds accessories by hosted segment, not parent', () => { + const segment = RoofSegmentNode.parse({}) + const roof = RoofNode.parse({ children: [segment.id] }) + const chimney = ChimneyNode.parse({ roofSegmentId: segment.id, parentId: roof.id }) + const unrelated = ChimneyNode.parse({}) + const nodes: Record = Object.fromEntries( + [roof, segment, chimney, unrelated].map((node) => [node.id, node]), + ) + const rows = roofPanelModel.rows({ node: roof, nodes, update: () => {} }) + expect(rows.some((row) => row.id === chimney.id)).toBe(true) + expect(rows.some((row) => row.id === unrelated.id)).toBe(false) + expect(rows.some((row) => row.id === 'add-segment')).toBe(true) + }) + test('keeps position axes and converts degrees to stored radians', () => { + let node = RoofNode.parse({ position: [1, 2, 3] }) + const rows = roofPanelModel.rows({ + node, + nodes: { [node.id]: node }, + update: (patch) => { + node = { ...node, ...patch } + }, + }) + const position = rows.find((row) => row.id === 'position-1') + const rotation = rows.find((row) => row.id === 'rotation') + if (position?.kind !== 'stepper' || rotation?.kind !== 'stepper') + throw new Error('Missing transforms') + position.onChange(4) + rotation.onChange(90) + expect(node.position).toEqual([1, 4, 3]) + expect(node.rotation).toBeCloseTo(Math.PI / 2) + }) +}) diff --git a/packages/nodes/src/roof/panel-model.ts b/packages/nodes/src/roof/panel-model.ts new file mode 100644 index 0000000000..cfa27bfcf7 --- /dev/null +++ b/packages/nodes/src/roof/panel-model.ts @@ -0,0 +1,166 @@ +import { + type AnyNode, + type AnyNodeId, + nodeRegistry, + type RoofNode, + useScene, +} from '@pascal-app/core' +import { + duplicateRoofSubtree, + type NodePanelModel, + type PanelRow, + triggerSFX, + useEditor, +} from '@pascal-app/editor' +import { useViewer } from '@pascal-app/viewer' + +export const roofPanelModel: NodePanelModel = { + rows({ node, nodes, update }) { + const select = (id: AnyNodeId) => useViewer.getState().setSelection({ selectedIds: [id] }) + const activate = (kind: string) => { + triggerSFX('sfx:item-pick') + const editor = useEditor.getState() + editor.setTool(kind) + if (editor.mode !== 'build') editor.setMode('build') + } + const segments = (node.children ?? []) + .map((id) => nodes[id]) + .filter((child) => child?.type === 'roof-segment') + const segmentIds = new Set(segments.map((segment) => segment.id)) + const rows: PanelRow[] = segments.map((segment, index) => ({ + id: segment.id, + kind: 'choice', + section: 'Segments', + label: segment.name || `Segment ${index + 1}`, + value: segment.roofType, + onSelect: () => select(segment.id), + })) + rows.push({ + id: 'add-segment', + kind: 'action', + section: 'Segments', + label: 'Draw Segment', + onSelect: () => activate('roof'), + }) + for (const axis of [0, 1, 2] as const) + rows.push({ + id: `position-${axis}`, + kind: 'stepper', + section: 'Position', + label: 'XYZ'[axis]!, + value: node.position[axis], + unit: 'm', + step: 0.05, + min: -Infinity, + max: Infinity, + onChange: (value) => { + const live = useScene.getState().nodes[node.id] as RoofNode | undefined + const position = [...(live?.position ?? node.position)] as [number, number, number] + position[axis] = value + update({ position }) + }, + }) + rows.push( + { + id: 'rotation', + kind: 'stepper', + section: 'Position', + label: 'Rotation', + value: Math.round((node.rotation * 180) / Math.PI), + unit: '°', + step: 1, + min: -180, + max: 180, + onChange: (degrees) => update({ rotation: (degrees * Math.PI) / 180 }), + }, + { + id: 'rotate', + kind: 'actions', + section: 'Position', + actions: [-1, 1].map((direction) => ({ + id: `rotate-${direction}`, + label: direction < 0 ? '-45°' : '+45°', + onSelect: () => { + triggerSFX('sfx:item-rotate') + const live = useScene.getState().nodes[node.id] as RoofNode | undefined + update({ rotation: (live?.rotation ?? node.rotation) + (direction * Math.PI) / 4 }) + }, + })), + }, + ) + for (const child of Object.values(nodes)) { + if ( + !child || + !('roofSegmentId' in child) || + !child.roofSegmentId || + !segmentIds.has(child.roofSegmentId) + ) + continue + rows.push({ + id: child.id, + kind: 'action', + section: 'Elements', + label: child.name || nodeRegistry.get(child.type)?.presentation?.label || child.type, + onSelect: () => select(child.id), + }) + } + for (const [kind, definition] of nodeRegistry.entries()) { + if ( + definition.presentation?.hidden || + definition.capabilities.wallOpeningPlacement || + (definition.capabilities.roofAccessory === undefined && + definition.presentation?.paletteGroup !== 'roof-features') + ) + continue + const icon = definition.presentation?.icon + rows.push({ + id: `add-${kind}`, + kind: 'action', + section: 'Features & extensions', + label: `Add ${definition.presentation?.label ?? kind}`, + icon: icon?.kind === 'url' ? { src: icon.src } : undefined, + onSelect: () => activate(kind), + }) + } + rows.push({ + id: 'roof-actions', + kind: 'actions', + section: 'Actions', + actions: [ + { + id: 'move', + label: 'Move', + onSelect: () => { + const live = useScene.getState().nodes[node.id] + if (!live) return + triggerSFX('sfx:item-pick') + useEditor.getState().setMovingNode(live) + useViewer.getState().setSelection({ selectedIds: [] }) + }, + }, + { + id: 'duplicate', + label: 'Duplicate', + onSelect: () => { + if (!useScene.getState().nodes[node.id]) return + triggerSFX('sfx:item-pick') + duplicateRoofSubtree(node.id, { mode: 'move' }) + }, + }, + { + id: 'delete', + label: 'Delete', + onSelect: () => { + const live: AnyNode | undefined = useScene.getState().nodes[node.id] + if (!live) return + triggerSFX('sfx:item-delete') + useScene.getState().deleteNode(node.id) + if (live.parentId) useScene.getState().markDirty(live.parentId as AnyNodeId) + useViewer.getState().setSelection({ selectedIds: [] }) + }, + }, + ], + }) + return rows + }, +} diff --git a/packages/nodes/src/roof/panel.tsx b/packages/nodes/src/roof/panel.tsx index 201bf90a70..6313fdf8a9 100644 --- a/packages/nodes/src/roof/panel.tsx +++ b/packages/nodes/src/roof/panel.tsx @@ -18,6 +18,7 @@ import { import { ActionButton, ActionGroup, + commitParametricNodeFields, duplicateRoofSubtree, formatLinearMeasurement, PanelSection, @@ -38,7 +39,6 @@ export default function RoofPanel() { const unit = useViewer((s) => s.unit) const metricNotation = useViewer((s) => s.metricNotation) const setSelection = useViewer((s) => s.setSelection) - const updateNode = useScene((s) => s.updateNode) const setMovingNode = useEditor((s) => s.setMovingNode) const node = useScene((s) => @@ -158,9 +158,9 @@ export default function RoofPanel() { const handleUpdate = useCallback( (updates: Partial) => { if (!selectedId) return - updateNode(selectedId as AnyNode['id'], updates) + commitParametricNodeFields(selectedId as AnyNode['id'], updates) }, - [selectedId, updateNode], + [selectedId], ) const handleClose = useCallback(() => { diff --git a/packages/nodes/src/roof/parametrics.ts b/packages/nodes/src/roof/parametrics.ts index 36207f1c9f..7830d689b0 100644 --- a/packages/nodes/src/roof/parametrics.ts +++ b/packages/nodes/src/roof/parametrics.ts @@ -1,6 +1,21 @@ import type { ParametricDescriptor, RoofNode } from '@pascal-app/core' export const roofParametrics: ParametricDescriptor = { - groups: [], + groups: [ + { + label: 'Position', + fields: [ + { key: 'position', kind: 'vec3' }, + { + key: 'rotation', + kind: 'number', + label: 'Rotation (rad)', + min: -Math.PI, + max: Math.PI, + step: Math.PI / 36, + }, + ], + }, + ], customPanel: () => import('./panel'), } diff --git a/packages/nodes/src/shared/opening-grid-follow.test.ts b/packages/nodes/src/shared/opening-grid-follow.test.ts new file mode 100644 index 0000000000..4e09cdccf1 --- /dev/null +++ b/packages/nodes/src/shared/opening-grid-follow.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from 'bun:test' +import { shouldFollowOpeningGrid, shouldHandleOpeningHostLeave } from './opening-grid-follow' + +describe('opening grid free-follow', () => { + test('keeps an active wall preview when XR also emits a floor move', () => { + expect( + shouldFollowOpeningGrid({ + eventTime: 20, + hasActiveHost: true, + lastHostEventTime: 10, + pointerType: 'xr', + }), + ).toBe(false) + }) + + test('follows the XR grid again after leaving the host', () => { + expect( + shouldFollowOpeningGrid({ + eventTime: 20, + hasActiveHost: false, + lastHostEventTime: 10, + pointerType: 'xr', + }), + ).toBe(true) + }) + + test('preserves desktop same-event arbitration', () => { + expect( + shouldFollowOpeningGrid({ + eventTime: 10, + hasActiveHost: true, + lastHostEventTime: 10, + pointerType: 'mouse', + }), + ).toBe(false) + expect( + shouldFollowOpeningGrid({ + eventTime: 20, + hasActiveHost: true, + lastHostEventTime: 10, + pointerType: 'mouse', + }), + ).toBe(true) + }) + + test('lets the XR bridge, rather than competing pointer leaves, end the host preview', () => { + const inputSource = {} + expect(shouldHandleOpeningHostLeave({ inputSource })).toBe(false) + expect(shouldHandleOpeningHostLeave({ pointerState: { inputSource } })).toBe(false) + expect(shouldHandleOpeningHostLeave({ inputSource, openingHoverBridge: true })).toBe(true) + expect(shouldHandleOpeningHostLeave({ pointerType: 'mouse' })).toBe(true) + }) +}) diff --git a/packages/nodes/src/shared/opening-grid-follow.ts b/packages/nodes/src/shared/opening-grid-follow.ts new file mode 100644 index 0000000000..725cf9fe13 --- /dev/null +++ b/packages/nodes/src/shared/opening-grid-follow.ts @@ -0,0 +1,20 @@ +export function shouldFollowOpeningGrid(args: { + eventTime: number + hasActiveHost: boolean + lastHostEventTime: number + pointerType?: string +}): boolean { + if (args.pointerType === 'xr' && args.hasActiveHost) return false + return args.eventTime !== args.lastHostEventTime +} + +export function shouldHandleOpeningHostLeave(nativeEvent: unknown): boolean { + if (!nativeEvent || typeof nativeEvent !== 'object') return true + const event = nativeEvent as { + inputSource?: object + openingHoverBridge?: boolean + pointerState?: { inputSource?: object } + } + const isSpatialPointer = event.inputSource != null || event.pointerState?.inputSource != null + return !isSpatialPointer || event.openingHoverBridge === true +} diff --git a/packages/nodes/src/shared/structural-parametrics.test.ts b/packages/nodes/src/shared/structural-parametrics.test.ts new file mode 100644 index 0000000000..b9d787cd49 --- /dev/null +++ b/packages/nodes/src/shared/structural-parametrics.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, test } from 'bun:test' +import { + ElevatorNode, + RoofNode, + RoofSegmentNode, + StairNode, + StairSegmentNode, +} from '@pascal-app/core' +import { elevatorParametrics } from '../elevator/parametrics' +import { roofParametrics } from '../roof/parametrics' +import { roofSegmentParametrics } from '../roof-segment/parametrics' +import { stairParametrics } from '../stair/parametrics' +import { stairSegmentParametrics } from '../stair-segment/parametrics' + +function visibleKeys( + node: N, + groups: Array<{ + fields: Array<{ key: keyof N | string; visibleIf?: (value: N) => boolean }> + }>, +) { + return groups.flatMap((group) => + group.fields + .filter((field) => field.visibleIf?.(node) ?? true) + .map((field) => String(field.key)), + ) +} + +describe('structural spatial settings', () => { + test('exposes elevator controls and preserves door fit invariants', () => { + const elevator = ElevatorNode.parse({ type: 'elevator' }) + + expect(visibleKeys(elevator, elevatorParametrics.groups)).toContain('doorStyle') + const derived = elevatorParametrics.derive?.({ ...elevator, width: 0.8 }, { width: 0.8 }) + expect(derived?.doorWidth).toBeCloseTo(0.7) + }) + + test('exposes the settings appropriate to each stair shape', () => { + const straight = StairNode.parse({ type: 'stair' }) + const spiral = StairNode.parse({ type: 'stair', stairType: 'spiral' }) + + expect(visibleKeys(straight, stairParametrics.groups)).not.toContain('innerRadius') + expect(visibleKeys(spiral, stairParametrics.groups)).toEqual( + expect.arrayContaining(['innerRadius', 'showCenterColumn', 'topLandingMode']), + ) + }) + + test('keeps stair-segment type changes internally consistent', () => { + const flight = StairSegmentNode.parse({ type: 'stair-segment' }) + const landing = { ...flight, segmentType: 'landing' as const } + + expect(stairSegmentParametrics.derive?.(landing, { segmentType: 'landing' }, flight)).toEqual({ + height: 0, + length: 1, + stepCount: 0, + }) + }) + + test('exposes roof transforms and shape-specific segment controls', () => { + const roof = RoofNode.parse({ type: 'roof' }) + const gable = RoofSegmentNode.parse({ type: 'roof-segment' }) + const dutch = RoofSegmentNode.parse({ type: 'roof-segment', roofType: 'dutch' }) + + expect(visibleKeys(roof, roofParametrics.groups)).toEqual(['position', 'rotation']) + expect(visibleKeys(gable, roofSegmentParametrics.groups)).not.toContain('dutchGabletRake') + expect(visibleKeys(dutch, roofSegmentParametrics.groups)).toContain('dutchGabletRake') + }) +}) diff --git a/packages/nodes/src/site/renderer.tsx b/packages/nodes/src/site/renderer.tsx index 440ab835af..bc58850e4d 100644 --- a/packages/nodes/src/site/renderer.tsx +++ b/packages/nodes/src/site/renderer.tsx @@ -17,6 +17,7 @@ import { getSceneTheme, horizonHazeColor, NodeRenderer, + useImmersiveXRPresentation, useNodeEvents, useSceneAtmosphere, useSceneGroundReplacement, @@ -137,6 +138,7 @@ function addSlabHoles( export const SiteRenderer = ({ node }: { node: SiteNode }) => { const ref = useRef(null!) + const immersiveXR = useImmersiveXRPresentation() const atmosphere = useSceneAtmosphere() const groundReplaced = useSceneGroundReplacement() @@ -407,7 +409,7 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => { )} {/* Infinite-ground presentation disc fading into the sky at the horizon */} - {horizonGeometry && horizonMaterial && fadeBounds && ( + {!immersiveXR && horizonGeometry && horizonMaterial && fadeBounds && ( [x + 40, z]), - }) - const wall = WallNode.parse({ - id: 'wall_tracker', - parentId: level.id, - start: [0, 0], - end: [4, 0], - thickness: 0.2, - }) - const nodes: Record = Object.fromEntries( - [building, level, slab, remote, wall].map((node) => [node.id, node]), - ) - return { nodes, building, level, slab, remote, wall } -} - -describe('slab dependency tracker', () => { - test('marks only the adopted slab for wall edits and restoration', () => { - const { nodes, wall, slab } = fixture() - const changed = createSlabDependencyTracker(nodes) - const next = { ...nodes, [wall.id]: { ...wall, thickness: 0.4 } } - expect(changed(next)).toEqual([slab.id]) - expect(changed(nodes)).toEqual([slab.id]) - expect(changed({ ...nodes })).toEqual([]) - }) - - test('ignores wall changes that do not change any rendered polygon', () => { - const { nodes, wall } = fixture() - const changed = createSlabDependencyTracker(nodes) - expect(changed({ ...nodes, [wall.id]: { ...wall, height: 3 } })).toEqual([]) - const remote = WallNode.parse({ - id: 'wall_remote', - parentId: wall.parentId, - start: [80, 0], - end: [84, 0], - }) - expect(changed({ ...nodes, [remote.id]: remote })).toEqual([]) - }) - - test('tracks sibling creation, floating classification, elevation and deletion', () => { - const { nodes, level, slab, wall } = fixture() - const sibling = SlabNode.parse({ - id: 'slab_sibling', - parentId: slab.parentId, +describe('createSlabDependencyTracker', () => { + test('ignores an incomplete building transform instead of crashing', () => { + const slab = SlabNode.parse({ + parentId: 'level', + elevation: 0, polygon: [ - [0, -4], - [4, -4], - [4, 0], [0, 0], - ], - elevation: 0.2, - thickness: 0.05, - }) - const changed = createSlabDependencyTracker(nodes) - const withSibling = { - ...nodes, - [level.id]: { ...level, children: [...level.children, sibling.id] }, - } - expect(changed({ ...withSibling, [sibling.id]: sibling })).toEqual([sibling.id]) - const grounded = { ...sibling, thickness: 0.2 } - const joined = { ...withSibling, [sibling.id]: grounded } - expect(changed(joined).sort()).toEqual([slab.id, sibling.id].sort()) - expect(changed({ ...joined, [sibling.id]: { ...grounded, elevation: 0.05 } }).sort()).toEqual( - [slab.id, sibling.id].sort(), - ) - expect(changed(nodes)).toEqual([slab.id]) - expect(wall.thickness).toBe(0.2) - }) - - test('recessed floating siblings become seam partners', () => { - const { nodes, level, slab } = fixture() - const sibling = SlabNode.parse({ - id: 'slab_pool', - parentId: slab.parentId, - polygon: [ - [0, -4], - [4, -4], [4, 0], - [0, 0], + [4, 4], + [0, 4], ], - elevation: 0.2, }) - const next = { - ...nodes, - [sibling.id]: sibling, - [level.id]: { ...level, children: [...level.children, sibling.id] }, - } - const changed = createSlabDependencyTracker(next) - expect(changed({ ...next, [sibling.id]: { ...sibling, recessed: true } }).sort()).toEqual( - [slab.id, sibling.id].sort(), - ) - }) - - test('curved adopted bands change the local slab only', () => { - const { nodes, slab, wall } = fixture() - const changed = createSlabDependencyTracker(nodes) - expect(changed({ ...nodes, [wall.id]: { ...wall, curveOffset: 0.3 } })).toEqual([slab.id]) - }) + const nodes = { + building: { id: 'building', type: 'building', children: ['level'] }, + level: { id: 'level', type: 'level', parentId: 'building', children: [slab.id] }, + [slab.id]: slab, + } as never - test('building transforms invalidate terrain fills only', () => { - const { nodes, building, slab } = fixture() - const initial = { ...nodes, [slab.id]: { ...slab, fillToTerrain: true } } - const changed = createSlabDependencyTracker(initial) - expect( - changed({ - ...initial, - [building.id]: { ...building, position: [1, 0, 2] as [number, number, number] }, - }), - ).toEqual([slab.id]) - }) -}) - -test('adopts the first tied wall in level.children order and invalidates reordered membership', () => { - const { nodes, level, wall, slab } = fixture() - const second = WallNode.parse({ ...wall, id: 'wall_second', thickness: 0.4 }) - const initial = { - ...nodes, - [second.id]: second, - [level.id]: { ...level, children: [second.id, ...level.children] }, - } - const changed = createSlabDependencyTracker(initial) - const next = { ...initial, [second.id]: { ...second, thickness: 0.6 } } - expect(changed(next)).toEqual([slab.id]) - expect( - changed({ ...next, [level.id]: { ...level, children: [...level.children, second.id] } }), - ).toEqual([slab.id]) - expect( - changed({ - ...next, - [level.id]: { ...level, children: level.children.filter((id) => id !== wall.id) }, - }), - ).toEqual([slab.id]) -}) - -test('Maxi tracker context matches the renderer membership, order and polygons', () => { - const nodes: Record = Object.fromEntries( - maxi.nodes.map((raw) => { - const node = AnyNode.parse(raw) - return [node.id, node] - }), - ) - const level = nodes[maxi.levelId] as LevelNode - const context = slabPolygonContextForLevel(level, (id) => nodes[id]) - expect(context.walls).toHaveLength(312) - expect(context.siblingSlabs).toHaveLength(72) - const prepared = prepareSlabPolygonContext(context) - for (const slab of context.siblingSlabs) { - const renderer = slabPolygonContextFromGeometry({ - parent: level, - resolve: (id) => nodes[id], - siblings: level.children - .map((id) => nodes[id]) - .filter((node) => node?.type === 'slab' && node.id !== slab.id), - } as GeometryContext) - const tracker = { - walls: context.walls, - siblingSlabs: context.siblingSlabs.filter((node) => node.id !== slab.id), - } - expect(getRenderableSlabPolygon(slab, scopeSlabPolygonContext(slab, prepared))).toEqual( - getRenderableSlabPolygon(slab, renderer), - ) - expect(tracker).toEqual(renderer) - expect(getRenderableSlabPolygon(slab, tracker)).toEqual( - getRenderableSlabPolygon(slab, renderer), - ) - } -}) - -test('unchanged input references skip serialization and equal-value replacements stay clean', () => { - const { nodes, wall, slab } = fixture() - const changed = createSlabDependencyTracker(nodes) - const stringify = spyOn(JSON, 'stringify') - try { - const dirty = changed({ - ...nodes, - unrelated: { ...nodes[slab.id], id: 'ceiling_unrelated', type: 'ceiling' } as AnyNode, - }) - expect(stringify).not.toHaveBeenCalled() - expect(dirty).toEqual([]) - } finally { - stringify.mockRestore() - } - const replaced = Object.fromEntries( - Object.entries(nodes).map(([id, node]) => [id, structuredClone(node)]), - ) - expect(changed(replaced)).toEqual([]) - expect(changed({ ...replaced, [wall.id]: { ...wall, thickness: 0.4 } })).toEqual([slab.id]) -}) - -test('new adoption, deletion and reparenting match the unfiltered renderer', () => { - const { nodes, level, slab, wall } = fixture() - const otherLevel = LevelNode.parse({ id: 'level_other', children: [] }) - let previous = { ...nodes, [otherLevel.id]: otherLevel } - const changed = createSlabDependencyTracker(previous) - const rendered = (snapshot: Record, slab: SlabNode) => { - const siblings = Object.values(snapshot).filter( - (node): node is SlabNode => node.type === 'slab' && node.parentId === slab.parentId, - ) - const context = slabPolygonContextForLevel( - snapshot[slab.parentId!] ?? null, - (id) => snapshot[id], - siblings, - ) - return getRenderableSlabPolygon(slab, { - ...context, - siblingSlabs: context.siblingSlabs.filter((node) => node.id !== slab.id), - }) - } - const verify = (next: Record) => { - const expected = Object.values(next) - .filter((node): node is SlabNode => node.type === 'slab') - .filter((node) => { - const before = previous[node.id] - return ( - before?.type !== 'slab' || - JSON.stringify(rendered(previous, before)) !== JSON.stringify(rendered(next, node)) - ) - }) - .map((node) => node.id) - .sort() - expect(changed(next).sort()).toEqual(expected) - previous = next - } - verify({ ...previous, [wall.id]: { ...wall, start: [0, 20], end: [4, 20] } }) - verify({ ...previous, [wall.id]: { ...wall, thickness: 0.7, curveOffset: 0.2 } }) - const { [wall.id]: _deleted, ...withoutWall } = previous - verify(withoutWall) - verify({ ...previous, [wall.id]: wall }) - verify({ - ...previous, - [slab.id]: { ...slab, parentId: otherLevel.id }, - [level.id]: { ...level, children: level.children.filter((id) => id !== slab.id) }, - [otherLevel.id]: { ...otherLevel, children: [slab.id] }, + expect(() => createSlabDependencyTracker(nodes)).not.toThrow() }) }) diff --git a/packages/nodes/src/stair-segment/parametrics.ts b/packages/nodes/src/stair-segment/parametrics.ts index 719b602287..13c20ffe83 100644 --- a/packages/nodes/src/stair-segment/parametrics.ts +++ b/packages/nodes/src/stair-segment/parametrics.ts @@ -1,6 +1,85 @@ import type { ParametricDescriptor, StairSegmentNode } from '@pascal-app/core' export const stairSegmentParametrics: ParametricDescriptor = { - groups: [], + groups: [ + { + label: 'Type', + fields: [ + { + key: 'segmentType', + kind: 'enum', + options: ['stair', 'landing'], + display: 'segmented', + }, + { + key: 'attachmentSide', + kind: 'enum', + label: 'Attachment', + options: ['front', 'left', 'right'], + display: 'segmented', + }, + ], + }, + { + label: 'Dimensions', + fields: [ + { key: 'width', kind: 'number', unit: 'm', min: 0.5, max: 1000, step: 0.1 }, + { key: 'length', kind: 'number', unit: 'm', min: 0.5, max: 1000, step: 0.1 }, + { + key: 'height', + kind: 'number', + unit: 'm', + min: 0.5, + max: 1000, + step: 0.1, + visibleIf: (node) => node.segmentType === 'stair', + }, + { + key: 'stepCount', + kind: 'number', + label: 'Steps', + min: 2, + max: 30, + step: 1, + visibleIf: (node) => node.segmentType === 'stair', + }, + ], + }, + { + label: 'Structure', + fields: [ + { key: 'fillToFloor', kind: 'boolean', label: 'Fill to floor' }, + { + key: 'thickness', + kind: 'number', + unit: 'm', + min: 0.05, + max: 1000, + step: 0.05, + visibleIf: (node) => !node.fillToFloor, + }, + ], + }, + { + label: 'Position', + fields: [ + { key: 'position', kind: 'vec3' }, + { + key: 'rotation', + kind: 'number', + label: 'Rotation (rad)', + min: -Math.PI, + max: Math.PI, + step: Math.PI / 36, + }, + ], + }, + ], + derive: (_next, patch, previous) => { + if (!(patch.segmentType && patch.segmentType !== previous?.segmentType)) return {} + return patch.segmentType === 'landing' + ? { height: 0, length: 1, stepCount: 0 } + : { height: 2.5, length: 3, stepCount: 10 } + }, customPanel: () => import('./panel'), } diff --git a/packages/nodes/src/stair/parametrics.ts b/packages/nodes/src/stair/parametrics.ts index 1a3c3edb80..cde060fc28 100644 --- a/packages/nodes/src/stair/parametrics.ts +++ b/packages/nodes/src/stair/parametrics.ts @@ -1,6 +1,170 @@ import type { ParametricDescriptor, StairNode } from '@pascal-app/core' export const stairParametrics: ParametricDescriptor = { - groups: [], + groups: [ + { + label: 'Type', + fields: [ + { + key: 'stairType', + kind: 'enum', + options: ['straight', 'curved', 'spiral'], + display: 'segmented', + }, + ], + }, + { + label: 'Opening', + fields: [ + { key: 'fromLevelId', kind: 'ref', label: 'From level', refKind: 'level' }, + { key: 'toLevelId', kind: 'ref', label: 'To level', refKind: 'level' }, + { + key: 'slabOpeningMode', + kind: 'enum', + label: 'Auto cutout', + options: ['none', 'destination'], + display: 'segmented', + }, + { + key: 'openingOffset', + kind: 'number', + label: 'Opening offset', + unit: 'm', + min: 0, + max: 0.5, + step: 0.01, + visibleIf: (node) => node.slabOpeningMode === 'destination', + }, + ], + }, + { + label: 'Geometry', + fields: [ + { + key: 'width', + kind: 'number', + unit: 'm', + min: 0.4, + max: 1000, + step: 0.05, + visibleIf: (node) => node.stairType !== 'straight', + }, + { + key: 'stepCount', + kind: 'number', + label: 'Steps', + min: 2, + max: 32, + step: 1, + visibleIf: (node) => node.stairType !== 'straight', + }, + { + key: 'fillToFloor', + kind: 'boolean', + label: 'Fill to floor', + visibleIf: (node) => node.stairType === 'curved', + }, + { + key: 'thickness', + kind: 'number', + unit: 'm', + min: 0.02, + max: 1000, + step: 0.01, + visibleIf: (node) => node.stairType === 'spiral' || !node.fillToFloor, + }, + { + key: 'innerRadius', + kind: 'number', + label: 'Inner radius', + unit: 'm', + min: 0.05, + max: 1000, + step: 0.05, + visibleIf: (node) => node.stairType !== 'straight', + }, + { + key: 'sweepAngle', + kind: 'number', + label: 'Sweep (rad)', + min: -Math.PI * 4, + max: Math.PI * 4, + step: Math.PI / 180, + visibleIf: (node) => node.stairType !== 'straight', + }, + { + key: 'showCenterColumn', + kind: 'boolean', + label: 'Center column', + visibleIf: (node) => node.stairType === 'spiral', + }, + { + key: 'showStepSupports', + kind: 'boolean', + label: 'Step supports', + visibleIf: (node) => node.stairType === 'spiral', + }, + { + key: 'topLandingMode', + kind: 'enum', + label: 'Top landing', + options: ['none', 'integrated'], + display: 'segmented', + visibleIf: (node) => node.stairType === 'spiral', + }, + { + key: 'topLandingDepth', + kind: 'number', + label: 'Landing depth', + unit: 'm', + min: 0.3, + max: 1000, + step: 0.05, + visibleIf: (node) => node.stairType === 'spiral' && node.topLandingMode === 'integrated', + }, + ], + }, + { + label: 'Position', + fields: [ + { key: 'position', kind: 'vec3' }, + { + key: 'rotation', + kind: 'number', + label: 'Rotation (rad)', + min: -Math.PI, + max: Math.PI, + step: Math.PI / 36, + }, + ], + }, + { + label: 'Railing', + fields: [ + { + key: 'railingMode', + kind: 'enum', + options: ['none', 'left', 'right', 'both'], + }, + { + key: 'railingHeight', + kind: 'number', + label: 'Railing height', + unit: 'm', + min: 0.7, + max: 1.4, + step: 0.02, + visibleIf: (node) => node.railingMode !== 'none', + }, + ], + }, + ], + derive: (next, patch, previous) => + patch.stairType === 'spiral' && previous?.stairType !== 'spiral' + ? { + position: [next.position[0], 0, next.position[2]], + sweepAngle: (400 * Math.PI) / 180, + } + : {}, customPanel: () => import('./panel'), } diff --git a/packages/nodes/src/wall/definition.ts b/packages/nodes/src/wall/definition.ts index 88a880db80..32f9f5986b 100644 --- a/packages/nodes/src/wall/definition.ts +++ b/packages/nodes/src/wall/definition.ts @@ -9,6 +9,8 @@ import { DRAFTING_SURFACE_EXTENSION_KEY, type DraftingSurfaceExtension, type FloorplanNodeExtension, + type NodePanelModel, + PANEL_MODEL_EXTENSION, } from '@pascal-app/editor' import { buildWallContextualDimensions } from './contextual-dimensions' import { hasWallCurveBlockingChildren } from './curve-eligibility' @@ -27,6 +29,7 @@ import { wallMeasurementFeatures, } from './measurement' import { wallPaint } from './paint' +import { wallSettings } from './panel-model' import { wallParametrics } from './parametrics' import { wallQuickMeasurement } from './quick-measurement' import { WallNode } from './schema' @@ -62,6 +65,9 @@ export const wallDefinition: NodeDefinition = { category: 'structure', surfaceRole: 'wall', extensions: { + [PANEL_MODEL_EXTENSION]: { + rows: ({ node, nodes, update }) => wallSettings(node, nodes, update), + } satisfies NodePanelModel, [DRAFTING_SURFACE_EXTENSION_KEY]: { kind: 'wall', classifyFace: (node, localNormal) => { diff --git a/packages/nodes/src/wall/length-patch.test.ts b/packages/nodes/src/wall/length-patch.test.ts new file mode 100644 index 0000000000..13d0e9ea3b --- /dev/null +++ b/packages/nodes/src/wall/length-patch.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'bun:test' +import { getWallCurveLength, WallNode } from '@pascal-app/core' +import { buildWallLengthPatch } from './length-patch' + +describe('buildWallLengthPatch', () => { + it('keeps the start and direction when scaling a straight wall', () => { + const node = WallNode.parse({ start: [2, 3], end: [5, 7] }) + const patch = buildWallLengthPatch(node, 10) + expect(patch.end?.[0]).toBeCloseTo(8) + expect(patch.end?.[1]).toBeCloseTo(11) + expect(patch.curveOffset).toBeUndefined() + expect(node.end).toEqual([5, 7]) + }) + + it('scales the chord and sagitta together to preserve curved shape', () => { + const node = WallNode.parse({ start: [2, 3], end: [6, 3], curveOffset: -0.5 }) + const length = getWallCurveLength(node) + const patch = buildWallLengthPatch(node, length * 2) + expect(patch).toEqual({ end: [10, 3], curveOffset: -1 }) + expect(getWallCurveLength({ ...node, ...patch })).toBeCloseTo(length * 2) + }) + + it('does not produce invalid geometry for zero-length walls or invalid input', () => { + const node = WallNode.parse({ start: [0, 0], end: [4, 0] }) + for (const length of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) { + expect(buildWallLengthPatch(node, length)).toEqual({}) + } + expect(buildWallLengthPatch({ ...node, end: node.start }, 2)).toEqual({}) + }) +}) diff --git a/packages/nodes/src/wall/length-patch.ts b/packages/nodes/src/wall/length-patch.ts new file mode 100644 index 0000000000..203179bd50 --- /dev/null +++ b/packages/nodes/src/wall/length-patch.ts @@ -0,0 +1,14 @@ +import { getClampedWallCurveOffset, getWallCurveLength, type WallNode } from '@pascal-app/core' + +export function buildWallLengthPatch(node: WallNode, nextLength: number): Partial { + const length = getWallCurveLength(node) + if (!length || !Number.isFinite(nextLength) || nextLength <= 0) return {} + const ratio = nextLength / length + return { + end: [ + node.start[0] + (node.end[0] - node.start[0]) * ratio, + node.start[1] + (node.end[1] - node.start[1]) * ratio, + ], + ...(node.curveOffset ? { curveOffset: getClampedWallCurveOffset(node) * ratio } : {}), + } +} diff --git a/packages/nodes/src/wall/move-tool.tsx b/packages/nodes/src/wall/move-tool.tsx index 293b40c4a3..aebd55dd16 100644 --- a/packages/nodes/src/wall/move-tool.tsx +++ b/packages/nodes/src/wall/move-tool.tsx @@ -214,6 +214,7 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { const levelId = node.parentId ?? null pauseSceneHistory(useScene) let shouldRestoreOnCleanup = true + let active = true // Wall ids that currently carry a live position override. Cleared on commit // (after the final store write lands) and on cancel / external unmount. @@ -421,6 +422,15 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { } const applyPreview = (nextStart: [number, number], nextEnd: [number, number]) => { + const previous = previewRef.current ?? { start: originalStart, end: originalEnd } + if ( + nextStart[0] === previous.start[0] && + nextStart[1] === previous.start[1] && + nextEnd[0] === previous.end[0] && + nextEnd[1] === previous.end[1] + ) + return + hasDraggedRef.current = true previewRef.current = { start: nextStart, end: nextEnd } const centerX = (nextStart[0] + nextEnd[0]) / 2 const centerZ = (nextStart[1] + nextEnd[1]) / 2 @@ -466,6 +476,7 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { } const onGridMove = (event: GridEvent) => { + if (!active) return const rawX = event.localPosition[0] const rawZ = event.localPosition[2] const snapStep = getSegmentGridStep() @@ -473,10 +484,15 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { // Anchor at the raw cursor so the displacement is measured in // continuous space. const anchor = dragAnchorRef.current ?? [rawX, rawZ] + const firstMove = dragAnchorRef.current === null dragAnchorRef.current = anchor + // Seeding a controller grab is not movement; don't snap an off-grid wall + // to the lattice before the user has moved their hand. + if (firstMove) return const rawDeltaX = rawX - anchor[0] const rawDeltaZ = rawZ - anchor[1] + if (!hasDraggedRef.current && Math.hypot(rawDeltaX, rawDeltaZ) < 1e-6) return // When the move is axis-locked (side-handle drag), snap the wall // center's absolute perpendicular offset to a multiple of @@ -527,7 +543,6 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { const nextCenter: [number, number] = [originalCenter[0] + deltaX, originalCenter[1] + deltaZ] const nextWall = buildWallFromCenter(nextCenter) - hasDraggedRef.current = true applyPreview(nextWall.start, nextWall.end) } @@ -628,6 +643,8 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { } const onPointerUp = () => { + if (!active) return + active = false // Press-release without drag: dismiss the tool without committing. // This is the same UX as MoveWallEndpointTool / WallHeightArrowHandle // — pointer-down on the affordance starts the move, drag updates the @@ -669,6 +686,8 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => { } const onCancel = () => { + if (!active) return + active = false shouldRestoreOnCleanup = false restoreOriginal() useViewer.getState().setSelection({ selectedIds: [nodeId] }) diff --git a/packages/nodes/src/wall/panel-model.test.ts b/packages/nodes/src/wall/panel-model.test.ts new file mode 100644 index 0000000000..3ad703e564 --- /dev/null +++ b/packages/nodes/src/wall/panel-model.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, test } from 'bun:test' +import { + type AnyNode, + type AnyNodeId, + DoorNode, + getWallCurveLength, + getWallEffectiveHeightForNodes, + WallNode, +} from '@pascal-app/core' +import { wallSettings } from './panel-model' + +function fixture() { + let node = WallNode.parse({ start: [1, 2], end: [4, 2], children: [] }) + const nodes: Record = { [node.id]: node } + const rows = () => + wallSettings(node, nodes, (patch) => { + node = { ...node, ...patch } + nodes[node.id] = node + }) + const row = (id: string) => { + const result = rows().find((row) => row.id === id) + if (!result) throw new Error(`Missing ${id}`) + return result + } + const click = (id: string) => { + const r = row(id) + if (r.kind === 'choice') r.onSelect?.() + else throw new Error('Expected choice') + } + const change = (id: string, value: number) => { + const r = row(id) + if (r.kind === 'stepper') r.onChange(value) + else throw new Error('Expected stepper') + } + return { + rows, + row, + click, + change, + nodes, + get node() { + return node + }, + } +} + +describe('Wall shared inspector', () => { + test('height follows the level until explicitly detached; terrain infill preserves the top mode', () => { + const f = fixture() + const height = getWallEffectiveHeightForNodes(f.node, f.nodes) + f.click('wall-bottom') + expect(f.node.fillToTerrain).toBe(true) + expect(f.node.height).toBeUndefined() + f.click('wall-top') + expect(f.node.height).toBe(Math.max(0.1, height)) + f.change('height', 3.2) + expect(f.node.height).toBe(3.2) + f.click('wall-top') + expect(f.node.height).toBeUndefined() + f.click('wall-bottom') + expect(f.node.fillToTerrain).toBeUndefined() + }) + test('length preserves start and direction; dimensions enforce their bounds', () => { + const f = fixture() + f.change('wall-length', 5) + expect(f.node.start).toEqual([1, 2]) + expect(f.node.end).toEqual([6, 2]) + f.change('thickness', -2) + expect(f.node.thickness).toBe(0.05) + f.change('curveOffset', 999) + expect(Math.abs(f.node.curveOffset ?? 0)).toBeLessThanOrEqual(2.5) + }) + test('curved length edits scale the arc instead of confusing arc and chord length', () => { + const f = fixture() + f.change('curveOffset', 0.5) + const previous = getWallCurveLength(f.node) + f.change('wall-length', previous + 0.1) + expect(getWallCurveLength(f.node)).toBeCloseTo(previous + 0.1, 6) + expect(f.node.start).toEqual([1, 2]) + }) + + test('bands reveal lower, middle and upper controls and preserve previous values', () => { + const f = fixture() + for (const count of [2, 3, 4]) { + f.change('wall-band-count', count) + expect(f.rows().filter((r) => r.id.startsWith('wall-band-'))).toHaveLength(count) + } + f.change('wall-band-lowerHeight', 0.3) + f.change('wall-band-middleHeight', 0.4) + f.change('wall-band-upperHeight', 0.5) + expect(f.node.faceBands).toMatchObject({ + count: 4, + enabled: true, + lowerHeight: 0.3, + middleHeight: 0.4, + upperHeight: 0.5, + }) + f.change('wall-band-count', 1) + expect(f.rows().filter((r) => r.id.startsWith('wall-band-'))).toHaveLength(1) + }) + for (const key of ['skirting', 'crown', 'chairRail'] as const) + test(`${key}: every side/profile and dimension works without losing nested values`, () => { + const f = fixture() + f.click(`${key}-enabled`) + const seen = new Set() + for (let index = 0; index < 5; index++) { + const r = f.row(`${key}-profile`) + if (r.kind !== 'cycle') throw new Error('Expected cycle') + seen.add(r.value) + r.next() + } + expect(seen.size).toBe(5) + const sides = new Set() + for (let index = 0; index < 3; index++) { + const r = f.row(`${key}-sides`) + if (r.kind !== 'cycle') throw new Error('Expected cycle') + sides.add(r.value) + r.next() + } + expect(sides.size).toBe(3) + f.change(`${key}-height`, 0.12) + f.change(`${key}-proud`, 0.03) + if (key === 'chairRail') f.change(`${key}-offset`, 0.8) + expect(f.node[key]).toMatchObject({ enabled: true, height: 0.12, proud: 0.03 }) + f.click(`${key}-enabled`) + expect(f.node[key]?.enabled).toBe(false) + expect(f.node[key]?.height).toBe(0.12) + expect(f.rows().some((r) => r.id === `${key}-profile`)).toBe(false) + }) + test('hosted openings suppress curve editing', () => { + const f = fixture() + const id = 'door_test' as const + f.node.children.push(id) + f.nodes[id] = DoorNode.parse({ id }) + expect(f.rows().some((r) => r.id === 'curveOffset')).toBe(false) + }) +}) diff --git a/packages/nodes/src/wall/panel-model.ts b/packages/nodes/src/wall/panel-model.ts new file mode 100644 index 0000000000..e291612901 --- /dev/null +++ b/packages/nodes/src/wall/panel-model.ts @@ -0,0 +1,261 @@ +import { + type AnyNode, + type AnyNodeId, + buildWallFaceBandCountPatch, + GROUND_SUPPORT_ID, + getClampedWallCurveOffset, + getMaxWallCurveOffset, + getWallCurveLength, + getWallFaceBandConfig, + normalizeWallCurveOffset, + terrainSupportLift, + WALL_CHAIR_RAIL_DEFAULT, + WALL_CROWN_DEFAULT, + WALL_FACE_BAND_DEFAULT, + WALL_SKIRTING_DEFAULT, + type WallNode, + type WallTrimProfile, +} from '@pascal-app/core' +import { + curveReshapeScope, + type PanelRow, + triggerSFX, + useInteractionScope, +} from '@pascal-app/editor' +import { useViewer } from '@pascal-app/viewer' +import { resolveWallOpeningCeiling } from '../shared/wall-opening-ceiling' +import { hasWallCurveBlockingChildren } from './curve-eligibility' +import { buildWallLengthPatch } from './length-patch' + +type TrimKey = 'skirting' | 'crown' | 'chairRail' +const profiles: Record = { + skirting: ['flat', 'base-modern', 'base-colonial', 'base-shoe', 'base-ogee'], + crown: ['flat', 'crown-cove', 'crown-ogee', 'crown-craftsman', 'crown-layered'], + chairRail: ['flat', 'rail-rounded', 'rail-ogee', 'rail-picture', 'rail-stepped'], +} +const pretty = (value: string) => + value + .replace(/^(base|crown|rail)-/, '') + .replace(/(^|[- ])\w/g, (s) => s.replace('-', ' ').toUpperCase()) + +export function wallSettings( + node: WallNode, + nodes: Record, + update: (patch: Partial) => void, + reshape: () => void = () => { + triggerSFX('sfx:item-pick') + useInteractionScope.getState().begin(curveReshapeScope(node.id)) + useViewer.getState().setSelection({ selectedIds: [] }) + }, +): PanelRow[] { + const rows: PanelRow[] = [] + const height = resolveWallOpeningCeiling(node, nodes) + const repairBase = () => ({ + supportOffset: undefined, + ...(node.supportSlabId === GROUND_SUPPORT_ID && + !(node.parentId && terrainSupportLift(nodes, node.parentId, ...node.start) != null) + ? { supportSlabId: undefined } + : {}), + }) + const number = ( + section: string, + id: string, + label: string, + value: number, + min: number, + max: number, + step: number, + onChange: (value: number) => void, + unit: string | undefined = 'm', + ) => + rows.push({ + section, + id, + kind: 'stepper', + label, + value, + min, + max, + step, + unit, + onChange: (next) => onChange(Math.max(min, Math.min(max, next))), + }) + const choice = ( + section: string, + id: string, + label: string, + value: string, + onSelect?: () => void, + ) => rows.push({ section, id, kind: 'choice', label, value, onSelect }) + const cycle = ( + section: string, + id: string, + label: string, + value: string, + values: readonly string[], + onChange: (value: string) => void, + ) => { + const change = (direction: number) => + onChange( + values[(Math.max(0, values.indexOf(value)) + direction + values.length) % values.length]!, + ) + rows.push({ + section, + id, + kind: 'cycle', + label, + value: pretty(value), + next: () => change(1), + previous: () => change(-1), + }) + } + const length = getWallCurveLength(node) + number('Dimensions', 'wall-length', 'Length', length, 0.1, 1000, 0.05, (next) => { + update(buildWallLengthPatch(node, next)) + }) + choice( + 'Dimensions', + 'wall-top', + 'Top', + node.height == null ? 'Follows level' : 'Custom height', + () => + update( + node.height == null + ? { height: Math.max(0.1, height) } + : { height: undefined, ...repairBase() }, + ), + ) + if (node.height == null) + choice('Dimensions', 'wall-height-auto', 'Current height', `${Number(height.toFixed(3))} m`) + else + number('Dimensions', 'height', 'Height', node.height, 0.1, 1000, 0.05, (height) => + update({ height }), + ) + choice( + 'Dimensions', + 'wall-bottom', + 'Bottom', + node.fillToTerrain ? 'Fill to terrain' : 'Auto', + () => + update( + node.fillToTerrain + ? { fillToTerrain: undefined, ...repairBase() } + : { fillToTerrain: true }, + ), + ) + number( + 'Dimensions', + 'thickness', + 'Thickness', + node.thickness ?? 0.1, + 0.05, + 1000, + 0.01, + (thickness) => update({ thickness }), + ) + const blockedCurve = hasWallCurveBlockingChildren( + (node.children ?? []).flatMap((id) => + nodes[id as AnyNodeId] ? [nodes[id as AnyNodeId]!] : [], + ), + ) + if (!blockedCurve) { + const limit = Math.max(0.01, getMaxWallCurveOffset(node)) + number( + 'Dimensions', + 'curveOffset', + 'Curve', + getClampedWallCurveOffset(node), + -limit, + limit, + 0.05, + (next) => update({ curveOffset: normalizeWallCurveOffset(node, next) }), + ) + if (reshape) + rows.push({ + section: 'Dimensions', + id: 'wall-reshape', + kind: 'action', + label: 'Reshape curve in scene', + onSelect: reshape, + }) + } + const bands = getWallFaceBandConfig(node, height) + number( + 'Wall bands', + 'wall-band-count', + 'Bands', + bands.count, + 1, + 4, + 1, + (count) => update(buildWallFaceBandCountPatch(node, Math.round(count))), + '', + ) + const band = (key: 'lowerHeight' | 'middleHeight' | 'upperHeight', label: string, max: number) => + number( + 'Wall bands', + `wall-band-${key}`, + label, + bands[key], + 0, + Math.max(0, max), + 0.01, + (value) => + update({ + faceBands: { + ...WALL_FACE_BAND_DEFAULT, + ...node.faceBands, + enabled: bands.count > 1, + count: bands.count, + [key]: value, + }, + }), + ) + if (bands.count >= 2) band('lowerHeight', 'Lower', height) + if (bands.count >= 3) band('middleHeight', 'Middle', height - bands.lowerHeight) + if (bands.count >= 4) + band('upperHeight', 'Upper', height - bands.lowerHeight - bands.middleHeight) + for (const [key, title, defaults] of [ + ['skirting', 'Skirting', WALL_SKIRTING_DEFAULT], + ['crown', 'Crown molding', WALL_CROWN_DEFAULT], + ['chairRail', 'Chair rail', WALL_CHAIR_RAIL_DEFAULT], + ] as const) { + const trim = { ...defaults, ...node[key] } + const patch = (value: Partial) => update({ [key]: { ...trim, ...value } }) + choice(title, `${key}-enabled`, title, trim.enabled ? 'On' : 'Off', () => + patch({ enabled: !trim.enabled }), + ) + if (!trim.enabled) continue + cycle(title, `${key}-sides`, 'Sides', trim.sides, ['interior', 'exterior', 'both'], (sides) => + patch({ sides: sides as typeof trim.sides }), + ) + cycle(title, `${key}-profile`, 'Profile', trim.profile, profiles[key], (profile) => + patch({ profile: profile as WallTrimProfile }), + ) + number( + title, + `${key}-height`, + 'Height', + trim.height, + 0.01, + Math.max(0.05, height), + 0.01, + (height) => patch({ height }), + ) + number(title, `${key}-proud`, 'Projection', trim.proud, 0.001, 0.2, 0.005, (proud) => + patch({ proud }), + ) + if (key === 'chairRail') + number( + title, + `${key}-offset`, + 'Offset', + trim.offsetY ?? 0, + 0, + Math.max(0.05, height - trim.height), + 0.01, + (offsetY) => patch({ offsetY }), + ) + } + return rows +} diff --git a/packages/nodes/src/wall/panel.tsx b/packages/nodes/src/wall/panel.tsx index a85e9abae3..654e71282f 100644 --- a/packages/nodes/src/wall/panel.tsx +++ b/packages/nodes/src/wall/panel.tsx @@ -40,6 +40,7 @@ import { Spline } from 'lucide-react' import { useCallback, useMemo, useRef } from 'react' import { resolveWallOpeningCeiling } from '../shared/wall-opening-ceiling' import { hasWallCurveBlockingChildren } from './curve-eligibility' +import { buildWallLengthPatch } from './length-patch' /** * Base half of the plane-bound repair: a stamped draft offset goes, and a @@ -154,21 +155,7 @@ export default function WallPanel() { const n = nodeRef.current if (!n || newLength <= 0) return - const dx = n.end[0] - n.start[0] - const dz = n.end[1] - n.start[1] - const currentLength = Math.sqrt(dx * dx + dz * dz) - - if (currentLength === 0) return - - const dirX = dx / currentLength - const dirZ = dz / currentLength - - const newEnd: [number, number] = [ - n.start[0] + dirX * newLength, - n.start[1] + dirZ * newLength, - ] - - handleUpdate({ end: newEnd }) + handleUpdate(buildWallLengthPatch(n, newLength)) }, [handleUpdate], ) diff --git a/packages/nodes/src/wall/renderer.tsx b/packages/nodes/src/wall/renderer.tsx index 04cecec679..b670aa95d5 100644 --- a/packages/nodes/src/wall/renderer.tsx +++ b/packages/nodes/src/wall/renderer.tsx @@ -185,12 +185,9 @@ const WallRenderer = ({ node }: { node: WallNode }) => { ref={ref} visible={node.visible} > - + + + {hasWallTreatments(treatmentNode) && ( { event.nativeEvent?.target instanceof HTMLCanvasElement ? resolvePointerSupportSurface(cameraRef.current, event.position, { includeNodeTopSurfaces: true, + pointerRay: event.nativeEvent.ray, }) : null diff --git a/packages/nodes/src/window/tool.tsx b/packages/nodes/src/window/tool.tsx index 37c4f1583e..3b044382a3 100644 --- a/packages/nodes/src/window/tool.tsx +++ b/packages/nodes/src/window/tool.tsx @@ -47,6 +47,10 @@ import { getDormerWindowWorldYaw, resolveDormerWindowTarget, } from '../shared/dormer-wall-opening-placement' +import { + shouldFollowOpeningGrid, + shouldHandleOpeningHostLeave, +} from '../shared/opening-grid-follow' import { clearOpeningGuides3D, publishOpeningGuidesForWallEvent, @@ -723,7 +727,15 @@ const WindowTool: React.FC = () => { event.stopPropagation() } - const onWallLeave = () => { + // XR selectend produces a grid click without replaying the wall mesh's + // R3F pointer-up event. Use the latest valid wall hover for that release. + const onXRGridClick = (event: GridEvent) => { + if (event.nativeEvent?.pointerType !== 'xr' || !lastWallEvent) return + onWallClick(lastWallEvent) + } + + const onWallLeave = (event: WallEvent) => { + if (!shouldHandleOpeningHostLeave(event.nativeEvent)) return if (hostKind !== 'wall') return lastWallEvent = null destroyDraft() @@ -741,7 +753,15 @@ const WindowTool: React.FC = () => { // timeStamp) — it owns the frame and has snapped the draft, so skip the // floor follow this tick. const ts = event.nativeEvent?.timeStamp ?? -1 - if (ts === lastMeshEventTime) return + if ( + !shouldFollowOpeningGrid({ + eventTime: ts, + hasActiveHost: hostKind !== null, + lastHostEventTime: lastMeshEventTime, + pointerType: event.nativeEvent?.pointerType, + }) + ) + return const wallEvent = wallEventFromGrid( event, activeLevelId, @@ -1019,6 +1039,7 @@ const WindowTool: React.FC = () => { emitter.on('wall:enter', onWallHover) emitter.on('wall:move', onWallHover) emitter.on('wall:click', onWallClick) + emitter.on('grid:click', onXRGridClick) emitter.on('wall:leave', onWallLeave) emitter.on('roof:enter', onRoofHover) emitter.on('roof:move', onRoofHover) @@ -1053,6 +1074,7 @@ const WindowTool: React.FC = () => { emitter.off('wall:enter', onWallHover) emitter.off('wall:move', onWallHover) emitter.off('wall:click', onWallClick) + emitter.off('grid:click', onXRGridClick) emitter.off('wall:leave', onWallLeave) emitter.off('roof:enter', onRoofHover) emitter.off('roof:move', onRoofHover) diff --git a/packages/viewer/src/components/viewer/index.tsx b/packages/viewer/src/components/viewer/index.tsx index e03f8b7d0e..8ac40a2bc5 100644 --- a/packages/viewer/src/components/viewer/index.tsx +++ b/packages/viewer/src/components/viewer/index.tsx @@ -10,6 +10,7 @@ import { } from '@pascal-app/core' import { Canvas, extend, type ThreeElement, useFrame, useThree } from '@react-three/fiber' import { + type ComponentType, forwardRef, useEffect, useImperativeHandle, @@ -32,6 +33,7 @@ import { FloorElevationSystem } from '../../systems/floor-elevation/floor-elevat import { GeometrySystem } from '../../systems/geometry/geometry-system' import { PerfActionSettleSystem } from '../../systems/perf-action-settle/perf-action-settle-system' import { subscribeWallBuildInteractions } from '../../systems/wall/wall-build-lifecycle' +import { ImmersiveXRPresentationProvider } from '../../xr/presentation-context' import { ErrorBoundary } from '../error-boundary' import { SceneRenderer } from '../renderers/scene-renderer' import { BATCH_SPIKE_ENABLED, BatchedMeshSpike } from './batched-mesh-spike' @@ -171,7 +173,7 @@ type WebGPUDeviceLike = { removeEventListener?: (type: string, listener: EventListener) => void } -function GPUDeviceWatcher() { +function GPUDeviceWatcher({ intentionalWebGL = false }: { intentionalWebGL?: boolean }) { const gl = useThree((s) => s.gl) useEffect(() => { @@ -183,13 +185,14 @@ function GPUDeviceWatcher() { const backend = (gl as any).backend const device = backend?.device as WebGPUDeviceLike | undefined - if (!device) { + if (!device && !intentionalWebGL) { 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 } + if (!device) return console.log('[viewer] WebGPU device ready', { label: device.label, @@ -213,7 +216,7 @@ function GPUDeviceWatcher() { return () => { device.removeEventListener?.('uncapturederror', onUncapturedError) } - }, [gl]) + }, [gl, intentionalWebGL]) return null } @@ -238,6 +241,11 @@ function ToneMappingExposure() { return null } +function ImmersiveXRBackground() { + const background = useViewer((state) => getSceneTheme(state.sceneTheme).background) + return +} + function hasPendingSceneBuildWork() { const { dirtyNodes, nodes, rootNodeIds } = useScene.getState() @@ -332,6 +340,12 @@ function SceneReadyTracker({ return null } +export interface ViewerImmersiveSession { + Session: ComponentType<{ children: React.ReactNode }> + Scene: ComponentType<{ children: React.ReactNode }> + onError?: (cause: unknown) => void +} + interface ViewerProps { children?: React.ReactNode hoverStyles?: HoverStyles @@ -392,6 +406,8 @@ interface ViewerProps { disablePostFx?: boolean /** Keep the mounted renderer/context warm without advancing scene frames. */ renderPaused?: boolean + /** Host-provided immersive XR session wrappers for the main scene. */ + immersive?: ViewerImmersiveSession } /** Imperative handle exposed via `ref` on ``. */ @@ -422,6 +438,7 @@ const Viewer = forwardRef(function Viewer( maxFps = 50, disablePostFx = false, renderPaused = false, + immersive, }, ref, ) { @@ -527,6 +544,15 @@ const Viewer = forwardRef(function Viewer( if (showGpuFallback) onSceneReadyChange?.(true) }, [showGpuFallback, onSceneReadyChange]) + useEffect(() => { + if (!immersive) return + const timeout = window.setTimeout(() => window.dispatchEvent(new Event('resize')), 0) + return () => window.clearTimeout(timeout) + }, [immersive]) + + const ImmersiveSession = immersive?.Session + const immersiveActive = immersive != null + if (showGpuFallback) { return } @@ -537,6 +563,7 @@ const Viewer = forwardRef(function Viewer( {(perf || PERF_OVERLAY_ENABLED) && } (function Viewer( if (cached) return cached const promise = (async () => { const result = await initializeGpuRenderer({ + forceWebGL: immersiveActive, // Supplying `device` makes three skip its own `requestAdapter`, // so R3F's `powerPreference` only reaches the GPU if we forward it. powerPreference: props.powerPreference, @@ -600,61 +628,117 @@ const Viewer = forwardRef(function Viewer( enabled: shadowsEnabled, }} > - - - - - - - - - {/* */} - - {useBvh ? ( - - - + + {ImmersiveSession ? ( + + + {children} + + ) : ( - + <> + + + {children} + + )} - - {/* Generic slab-elevation lift for any kind that declares - `capabilities.floorPlaced`. Runs at frame priority 1 so it - lands its mesh.position.y override before the priority-2 - systems below clear the dirty mark. */} - - {/* Generic geometry rebuild loop for any registered kind that - ships `def.geometry`. Reads dirtyNodes, calls the kind's pure - builder, swaps the registered group's children. See - wiki/architecture/node-definitions.md. */} - - {/* Automated stair opening sync — updates slab/ceiling cutouts - whenever stairs, slabs, or levels change. */} - - - {/* Mounts systems contributed by registry-backed kinds. Each - kind's `def.system` is loaded via lazy() and rendered here, - ordered by `system.priority`. */} - - - {selectionManager === 'default' && } - {(perf || PERF_OVERLAY_ENABLED) && } - {/* Feeds the action-cost ledger the frame's settle state (dirty - queue + deferred wall rebuilds) at a priority after every other - system, so a receipt closes when the user can actually see the - edit. */} - {(perf || PERF_OVERLAY_ENABLED) && } - {BATCH_SPIKE_ENABLED && } - {children} - + ) }) +function ViewerScene({ + children, + disablePostFx, + hoverStyles, + immersiveXR = false, + onRenderError, + onSceneReadyChange, + perf, + sceneReadyKey, + sceneReadyMaxWaitMs, + selectionManager, + useBvh, + SceneWrapper, +}: { + children?: React.ReactNode + disablePostFx: boolean + hoverStyles: HoverStyles + immersiveXR?: boolean + onRenderError?: (cause: unknown) => void + onSceneReadyChange?: (ready: boolean) => void + perf: boolean + sceneReadyKey?: string | number | null + sceneReadyMaxWaitMs?: number + selectionManager: 'default' | 'custom' + useBvh: boolean + SceneWrapper?: ViewerImmersiveSession['Scene'] +}) { + const renderedScene = useBvh ? ( + + + + ) : ( + + ) + const spatialScene = ( + <> + {renderedScene} + + + + + + {BATCH_SPIKE_ENABLED && } + {children} + + ) + + return ( + <> + + {immersiveXR && } + + + + + + + {SceneWrapper ? {spatialScene} : spatialScene} + {!immersiveXR && } + {selectionManager === 'default' && } + {(perf || PERF_OVERLAY_ENABLED) && } + {(perf || PERF_OVERLAY_ENABLED) && } + + + ) +} + export default Viewer diff --git a/packages/viewer/src/components/viewer/viewer-camera.test.ts b/packages/viewer/src/components/viewer/viewer-camera.test.ts new file mode 100644 index 0000000000..5bbed00ff9 --- /dev/null +++ b/packages/viewer/src/components/viewer/viewer-camera.test.ts @@ -0,0 +1,28 @@ +// @ts-expect-error Bun provides this module only to the test runtime. +import { describe, expect, test } from 'bun:test' +import { Layers } from 'three' +import { + enableImmersiveXRViewLayers, + viewerCameraClipping, + viewerUsesPerspectiveCamera, +} from './viewer-camera' + +describe('immersive viewer camera', () => { + test('uses XR clipping and perspective projection', () => { + expect(viewerCameraClipping(true)).toEqual({ far: 10_000, near: 0.001 }) + expect(viewerUsesPerspectiveCamera('orthographic', true)).toBe(true) + }) + + test('enables and restores the XR presentation layers', () => { + const cameraLayers = new Layers() + const raycasterLayers = new Layers() + const cameraMask = cameraLayers.mask + const raycasterMask = raycasterLayers.mask + const restore = enableImmersiveXRViewLayers(cameraLayers, raycasterLayers) + expect(cameraLayers.mask).not.toBe(cameraMask) + expect(raycasterLayers.mask).not.toBe(raycasterMask) + restore() + expect(cameraLayers.mask).toBe(cameraMask) + expect(raycasterLayers.mask).toBe(raycasterMask) + }) +}) diff --git a/packages/viewer/src/components/viewer/viewer-camera.tsx b/packages/viewer/src/components/viewer/viewer-camera.tsx index 121ee81c08..500f06aec8 100644 --- a/packages/viewer/src/components/viewer/viewer-camera.tsx +++ b/packages/viewer/src/components/viewer/viewer-camera.tsx @@ -1,15 +1,75 @@ import { OrthographicCamera, PerspectiveCamera } from '@react-three/drei' +import { useThree } from '@react-three/fiber' +import { useEffect } from 'react' +import type { Layers } from 'three' +import { GRID_LAYER, OVERLAY_LAYER, ZONE_LAYER } from '../../lib/layers' import useViewer from '../../store/use-viewer' import { useSceneGroundReplacement } from './scene-ground-replacement' -export const ViewerCamera = () => { +const IMMERSIVE_XR_VISIBLE_LAYERS = [OVERLAY_LAYER, ZONE_LAYER, GRID_LAYER] as const + +export function enableImmersiveXRViewLayers(cameraLayers: Layers, raycasterLayers: Layers) { + const cameraMask = cameraLayers.mask + const raycasterMask = raycasterLayers.mask + for (const layer of IMMERSIVE_XR_VISIBLE_LAYERS) { + cameraLayers.enable(layer) + raycasterLayers.enable(layer) + } + return () => { + cameraLayers.mask = cameraMask + raycasterLayers.mask = raycasterMask + } +} + +function ImmersiveXRViewLayers({ enabled }: { enabled: boolean }) { + const camera = useThree((state) => state.camera) + const raycaster = useThree((state) => state.raycaster) + + useEffect(() => { + if (!enabled) return + return enableImmersiveXRViewLayers(camera.layers, raycaster.layers) + }, [camera, enabled, raycaster]) + + return null +} + +export function viewerCameraClipping(immersiveXR: boolean) { + return immersiveXR ? { far: 10_000, near: 0.001 } : { far: 1000, near: 0.1 } +} + +export function applyViewerCameraClipping( + camera: { far: number; near: number; updateProjectionMatrix(): void }, + immersiveXR: boolean, +) { + const clipping = viewerCameraClipping(immersiveXR) + camera.far = clipping.far + camera.near = clipping.near + camera.updateProjectionMatrix() +} + +export function viewerUsesPerspectiveCamera(cameraMode: string, immersiveXR: boolean) { + return immersiveXR || cameraMode === 'perspective' +} + +export const ViewerCamera = ({ immersiveXR = false }: { immersiveXR?: boolean }) => { const cameraMode = useViewer((state) => state.cameraMode) - // Exterior ground can include a coarse ocean horizon beyond the local terrain. - const far = useSceneGroundReplacement() ? 20_000 : 1000 + const clipping = viewerCameraClipping(immersiveXR) + const far = useSceneGroundReplacement() ? 20_000 : clipping.far - return cameraMode === 'perspective' ? ( - - ) : ( - + return ( + <> + {viewerUsesPerspectiveCamera(cameraMode, immersiveXR) ? ( + + ) : ( + + )} + + ) } diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index fd77c3851c..22c7f94fa1 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -12,7 +12,11 @@ export { ErrorBoundary } from './components/error-boundary' // `@pascal-app/nodes//renderer.tsx` and are loaded by the registry // — no per-kind re-exports needed. export { NodeRenderer } from './components/renderers/node-renderer' -export { default as Viewer, type ViewerHandle } from './components/viewer' +export { + default as Viewer, + type ViewerHandle, + type ViewerImmersiveSession, +} from './components/viewer' export { type BVHEcctrlApi, default as BVHEcctrl, @@ -302,3 +306,4 @@ export { } from './systems/window/window-animation-system' export { buildWindowPreviewMesh, WindowSystem } from './systems/window/window-system' export { ZoneSystem } from './systems/zone/zone-system' +export { useImmersiveXRPresentation } from './xr/presentation-context' diff --git a/packages/viewer/src/lib/renderer-capability.test.tsx b/packages/viewer/src/lib/renderer-capability.test.tsx index b52e6fcaa5..aac1e3463a 100644 --- a/packages/viewer/src/lib/renderer-capability.test.tsx +++ b/packages/viewer/src/lib/renderer-capability.test.tsx @@ -14,6 +14,21 @@ function canvasWithContexts(contexts: Partial>) { } describe('GPU renderer capability and initialization', () => { + test('forces WebGL without requesting a WebGPU adapter', async () => { + const requestAdapter = mock(async () => ({ requestDevice: async () => ({}) })) + const createRenderer = mock(() => ({ init: async () => undefined })) + const result = await initializeGpuRenderer({ + createRenderer, + forceWebGL: true, + gpu: { requestAdapter }, + probeCanvas: canvasWithContexts({ webgl2: {} }), + }) + expect(result.status).toBe('ready') + if (result.status === 'ready') expect(result.backend).toBe('webgl') + expect(requestAdapter).not.toHaveBeenCalled() + expect(createRenderer).toHaveBeenCalledWith({ forceWebGL: true }) + }) + test('uses a working WebGPU device without requiring WebGL', async () => { const device = {} const createRenderer = mock(() => ({ init: async () => undefined })) diff --git a/packages/viewer/src/lib/renderer-capability.ts b/packages/viewer/src/lib/renderer-capability.ts index 0bf90e6967..90572312f9 100644 --- a/packages/viewer/src/lib/renderer-capability.ts +++ b/packages/viewer/src/lib/renderer-capability.ts @@ -121,12 +121,14 @@ export async function detectRendererCapability({ export async function initializeGpuRenderer({ createRenderer, + forceWebGL = false, gpu, powerPreference, probeCanvas = browserCanvas(), webgpuTimeoutMs = WEBGPU_INITIALIZATION_TIMEOUT_MS, }: { createRenderer: (parameters: RendererBackendParameters) => Renderer + forceWebGL?: boolean gpu?: RendererGpu | null powerPreference?: RendererPowerPreference probeCanvas?: RendererCapabilityCanvas | null @@ -134,7 +136,7 @@ export async function initializeGpuRenderer> { const capability = await detectRendererCapability({ canvas: probeCanvas, - gpu, + gpu: forceWebGL ? null : gpu, powerPreference, webgpuTimeoutMs, }) diff --git a/packages/viewer/src/xr/presentation-context.tsx b/packages/viewer/src/xr/presentation-context.tsx new file mode 100644 index 0000000000..968aec6bcc --- /dev/null +++ b/packages/viewer/src/xr/presentation-context.tsx @@ -0,0 +1,23 @@ +'use client' + +import { createContext, type ReactNode, useContext } from 'react' + +const ImmersiveXRPresentationContext = createContext(false) + +export function ImmersiveXRPresentationProvider({ + children, + enabled, +}: { + children: ReactNode + enabled: boolean +}) { + return ( + + {children} + + ) +} + +export function useImmersiveXRPresentation() { + return useContext(ImmersiveXRPresentationContext) +} diff --git a/patches/iwer@2.3.0.patch b/patches/iwer@2.3.0.patch new file mode 100644 index 0000000000..9e6cfd1195 --- /dev/null +++ b/patches/iwer@2.3.0.patch @@ -0,0 +1,26 @@ +diff --git a/lib/device/XRTrackedInput.js b/lib/device/XRTrackedInput.js +index c767f8d90ce42049d6c5db5320ca78f6766ee162..1cf696736f6e7865d42e6b84c7be177c49d765d6 100644 +--- a/lib/device/XRTrackedInput.js ++++ b/lib/device/XRTrackedInput.js +@@ -67,10 +67,6 @@ export class XRTrackedInput { + if (button[P_GAMEPAD].eventTrigger != null) { + if (button[P_GAMEPAD].lastFrameValue === 0 && + button[P_GAMEPAD].value > 0) { +- session.dispatchEvent(new XRInputSourceEvent(button[P_GAMEPAD].eventTrigger, { +- frame, +- inputSource: this[P_TRACKED_INPUT].inputSource, +- })); + session.dispatchEvent(new XRInputSourceEvent(button[P_GAMEPAD].eventTrigger + 'start', { + frame, + inputSource: this[P_TRACKED_INPUT].inputSource, +@@ -82,6 +78,10 @@ export class XRTrackedInput { + frame, + inputSource: this[P_TRACKED_INPUT].inputSource, + })); ++ session.dispatchEvent(new XRInputSourceEvent(button[P_GAMEPAD].eventTrigger, { ++ frame, ++ inputSource: this[P_TRACKED_INPUT].inputSource, ++ })); + } + } + } diff --git a/patches/three@0.186.0.patch b/patches/three@0.186.0.patch new file mode 100644 index 0000000000..3427e480e4 --- /dev/null +++ b/patches/three@0.186.0.patch @@ -0,0 +1,82 @@ +diff --git a/build/three.webgpu.js b/build/three.webgpu.js +index 8cbb77a7bd36c6a89d397f18f8758fc60ff8146e..c1f87477640e683e447bcbf97645a64e8b5a22c6 100644 +--- a/build/three.webgpu.js ++++ b/build/three.webgpu.js +@@ -14952,7 +14952,11 @@ const cameraPosition = /*@__PURE__*/ ( Fn( ( { camera } ) => { + + if ( _cameraPositionArray === null ) { + +- _cameraPositionArray = uniformArray( positions ).setGroup( renderGroup ).setName( 'cameraPositions' ).onRenderUpdate( ( { camera }, self ) => { ++ _cameraPositionArray = uniformArray( positions ).setGroup( renderGroup ).setName( 'cameraPositions' ).onRenderUpdate( ( frame, self ) => { ++ ++ if ( frame === undefined ) return; ++ ++ const { camera } = frame; + + const subCameras = camera.cameras; + const array = self.array; +@@ -71131,7 +71135,7 @@ class WebGLState { + + let needsUpdate = false; + +- if ( renderContext.textures !== null ) { ++ if ( renderContext.textures !== null && framebuffer !== null ) { + + drawBuffers = this.currentDrawbuffers.get( framebuffer ); + +diff --git a/build/three.webgpu.nodes.js b/build/three.webgpu.nodes.js +index fda563ffd328b99dc693d884f51fd39beb530a47..ee31416d454d94469465bd949b2d9ac0636b3266 100644 +--- a/build/three.webgpu.nodes.js ++++ b/build/three.webgpu.nodes.js +@@ -14952,7 +14952,11 @@ const cameraPosition = /*@__PURE__*/ ( Fn( ( { camera } ) => { + + if ( _cameraPositionArray === null ) { + +- _cameraPositionArray = uniformArray( positions ).setGroup( renderGroup ).setName( 'cameraPositions' ).onRenderUpdate( ( { camera }, self ) => { ++ _cameraPositionArray = uniformArray( positions ).setGroup( renderGroup ).setName( 'cameraPositions' ).onRenderUpdate( ( frame, self ) => { ++ ++ if ( frame === undefined ) return; ++ ++ const { camera } = frame; + + const subCameras = camera.cameras; + const array = self.array; +@@ -71131,7 +71135,7 @@ class WebGLState { + + let needsUpdate = false; + +- if ( renderContext.textures !== null ) { ++ if ( renderContext.textures !== null && framebuffer !== null ) { + + drawBuffers = this.currentDrawbuffers.get( framebuffer ); + +diff --git a/src/nodes/accessors/Camera.js b/src/nodes/accessors/Camera.js +index 5e89cb93c61f37a1a2d03a27735c74287c0f0e9f..167c7226183d62af9ec61911a742e2445f58a5bc 100644 +--- a/src/nodes/accessors/Camera.js ++++ b/src/nodes/accessors/Camera.js +@@ -315,7 +315,11 @@ export const cameraPosition = /*@__PURE__*/ ( Fn( ( { camera } ) => { + + if ( _cameraPositionArray === null ) { + +- _cameraPositionArray = uniformArray( positions ).setGroup( renderGroup ).setName( 'cameraPositions' ).onRenderUpdate( ( { camera }, self ) => { ++ _cameraPositionArray = uniformArray( positions ).setGroup( renderGroup ).setName( 'cameraPositions' ).onRenderUpdate( ( frame, self ) => { ++ ++ if ( frame === undefined ) return; ++ ++ const { camera } = frame; + + const subCameras = camera.cameras; + const array = self.array; +diff --git a/src/renderers/webgl-fallback/utils/WebGLState.js b/src/renderers/webgl-fallback/utils/WebGLState.js +index 897be18b97d0810929e0e60b7700ca1c0c81f547..3881fdca8c926129ee4787179957af13053db83b 100644 +--- a/src/renderers/webgl-fallback/utils/WebGLState.js ++++ b/src/renderers/webgl-fallback/utils/WebGLState.js +@@ -1154,7 +1154,7 @@ class WebGLState { + + let needsUpdate = false; + +- if ( renderContext.textures !== null ) { ++ if ( renderContext.textures !== null && framebuffer !== null ) { + + drawBuffers = this.currentDrawbuffers.get( framebuffer ); +