+ {/* Vertical tree line */}
+
+ {/* Horizontal branch line */}
+
+
setSelection({ levelId: level.id })}
onDoubleClick={() => setIsEditing(true)}
>
@@ -496,23 +509,30 @@ function LevelsSection() {
};
return (
-
- {/* Header */}
-
-
+
{/* Level buttons */}
- {levels.map((level) => (
+
+ {levels.length === 0 && (
+
+ {/* Vertical tree line */}
+
+ {/* Horizontal branch line */}
+
+ No levels yet
+
+ )}
+ {[...levels].reverse().map((level, index) => (
))}
- {levels.length === 0 && (
-
- No levels yet
-
- )}
{/* References dialog */}
@@ -707,80 +723,84 @@ function ZoneItem({ zone }: { zone: ZoneNode }) {
-
setIsEditing(false)}
- onStartEditing={() => setIsEditing(true)}
- defaultName={defaultName}
- />
- {/* Camera snapshot button */}
-
-
-
+
+ {/* Camera snapshot button */}
+
+
+
+
+ e.stopPropagation()}
- title="Camera snapshot"
>
-
- {zone.camera && (
-
- )}
-
-
- e.stopPropagation()}
- >
-
- {zone.camera && (
+
+ {zone.camera && (
+
+ )}
- )}
-
- {zone.camera && (
-
- )}
-
-
-
-
+ {zone.camera && (
+
+ )}
+
+
+
+
+
);
}
@@ -911,11 +931,11 @@ function BuildingItem({
}, [isBuildingActive]);
return (
-
+
@@ -1045,7 +1069,7 @@ export function SitePanel() {
{siteNode && (
setPhase("site")}
@@ -1068,9 +1092,9 @@ export function SitePanel() {
)}
-
+
{/* When phase is site, show property line immediately under site header */}
- {phase === "site" &&
}
+ {phase === "site" &&
}
{/* Buildings List */}
{buildings.length === 0 ? (
@@ -1078,7 +1102,7 @@ export function SitePanel() {
No buildings yet
) : (
-
+
{buildings.map((building) => {
const isBuildingActive = (phase === "structure" || phase === "furnish") && selectedBuildingId === building.id;
diff --git a/apps/editor/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx b/apps/editor/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx
index 78e59afd..294916e8 100644
--- a/apps/editor/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx
+++ b/apps/editor/components/ui/sidebar/panels/site-panel/inline-rename-input.tsx
@@ -57,7 +57,7 @@ export function InlineRenameInput({
if (!isEditing) {
return (
-
+
@@ -88,7 +88,7 @@ export function InlineRenameInput({
onBlur={handleSave}
placeholder={defaultName}
className={cn(
- "flex-1 w-full bg-transparent text-foreground outline-none border-b border-primary/50 focus:border-primary rounded-none px-0 py-0 m-0 h-auto text-sm leading-none",
+ "flex-1 w-full bg-transparent text-foreground outline-none border-b border-primary/50 focus:border-primary rounded-none px-0 py-0 m-0 h-5 text-sm",
className
)}
onClick={(e) => e.stopPropagation()}
diff --git a/apps/editor/components/ui/sidebar/panels/site-panel/item-tree-node.tsx b/apps/editor/components/ui/sidebar/panels/site-panel/item-tree-node.tsx
index 8f9c87f5..27704b7e 100644
--- a/apps/editor/components/ui/sidebar/panels/site-panel/item-tree-node.tsx
+++ b/apps/editor/components/ui/sidebar/panels/site-panel/item-tree-node.tsx
@@ -1,7 +1,7 @@
-import { type AnyNodeId, ItemNode } from "@pascal-app/core";
+import { type AnyNodeId, ItemNode, useScene } from "@pascal-app/core";
import { useViewer } from "@pascal-app/viewer";
import Image from "next/image";
-import { useState } from "react";
+import { useState, useEffect } from "react";
import { InlineRenameInput } from "./inline-rename-input";
import { TreeNode, TreeNodeWrapper } from "./tree-node";
import { TreeNodeActions } from "./tree-node-actions";
@@ -25,11 +25,32 @@ export function ItemTreeNode({ node, depth }: ItemTreeNodeProps) {
const [isEditing, setIsEditing] = useState(false);
const [expanded, setExpanded] = useState(true);
const iconSrc = CATEGORY_ICONS[node.asset.category] || "/icons/couch.png";
- const isSelected = useViewer((state) => state.selection.selectedIds.includes(node.id));
+ const selectedIds = useViewer((state) => state.selection.selectedIds);
+ const isSelected = selectedIds.includes(node.id);
const isHovered = useViewer((state) => state.hoveredId === node.id);
const setSelection = useViewer((state) => state.setSelection);
const setHoveredId = useViewer((state) => state.setHoveredId);
+ useEffect(() => {
+ if (selectedIds.length === 0) return;
+ const nodes = useScene.getState().nodes;
+ let isDescendant = false;
+ for (const id of selectedIds) {
+ let current = nodes[id as AnyNodeId];
+ while (current && current.parentId) {
+ if (current.parentId === node.id) {
+ isDescendant = true;
+ break;
+ }
+ current = nodes[current.parentId as AnyNodeId];
+ }
+ if (isDescendant) break;
+ }
+ if (isDescendant) {
+ setExpanded(true);
+ }
+ }, [selectedIds, node.id]);
+
const handleClick = () => {
setSelection({ selectedIds: [node.id] });
};
diff --git a/apps/editor/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx b/apps/editor/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx
index 70d82b36..a6935926 100644
--- a/apps/editor/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx
+++ b/apps/editor/components/ui/sidebar/panels/site-panel/wall-tree-node.tsx
@@ -1,7 +1,7 @@
-import { WallNode } from "@pascal-app/core";
+import { type AnyNodeId, WallNode, useScene } from "@pascal-app/core";
import { useViewer } from "@pascal-app/viewer";
import Image from "next/image";
-import { useState } from "react";
+import { useState, useEffect } from "react";
import { InlineRenameInput } from "./inline-rename-input";
import { TreeNode, TreeNodeWrapper } from "./tree-node";
import { TreeNodeActions } from "./tree-node-actions";
@@ -14,11 +14,32 @@ interface WallTreeNodeProps {
export function WallTreeNode({ node, depth }: WallTreeNodeProps) {
const [expanded, setExpanded] = useState(false);
const [isEditing, setIsEditing] = useState(false);
- const isSelected = useViewer((state) => state.selection.selectedIds.includes(node.id));
+ const selectedIds = useViewer((state) => state.selection.selectedIds);
+ const isSelected = selectedIds.includes(node.id);
const isHovered = useViewer((state) => state.hoveredId === node.id);
const setSelection = useViewer((state) => state.setSelection);
const setHoveredId = useViewer((state) => state.setHoveredId);
+ useEffect(() => {
+ if (selectedIds.length === 0) return;
+ const nodes = useScene.getState().nodes;
+ let isDescendant = false;
+ for (const id of selectedIds) {
+ let current = nodes[id as AnyNodeId];
+ while (current && current.parentId) {
+ if (current.parentId === node.id) {
+ isDescendant = true;
+ break;
+ }
+ current = nodes[current.parentId as AnyNodeId];
+ }
+ if (isDescendant) break;
+ }
+ if (isDescendant) {
+ setExpanded(true);
+ }
+ }, [selectedIds, node.id]);
+
const handleClick = () => {
setSelection({ selectedIds: [node.id] });
};
@@ -35,12 +56,7 @@ export function WallTreeNode({ node, depth }: WallTreeNodeProps) {
setHoveredId(null);
};
- const wallLength = Math.sqrt(
- Math.pow(node.end[0] - node.start[0], 2) +
- Math.pow(node.end[1] - node.start[1], 2),
- ).toFixed(1);
-
- const defaultName = `Wall (${wallLength}m/${node.height || 2.5}m)`;
+ const defaultName = "Wall";
return (
state.setSelection)
const setHoveredId = useViewer((state) => state.setHoveredId)
- const defaultName = `Window (${node.width}×${node.height}m)`
+ const defaultName = "Window"
return (
{
if (e.key === 'v' && !e.metaKey && !e.ctrlKey) {
e.preventDefault()
useEditor.getState().setMode('select')
+ } else if (e.key === 'b' && !e.metaKey && !e.ctrlKey) {
+ e.preventDefault()
+ useEditor.getState().setMode('build')
} else if (e.key === 'z' && (e.metaKey || e.ctrlKey)) {
e.preventDefault()
useScene.temporal.getState().undo()
diff --git a/apps/editor/store/use-audio.tsx b/apps/editor/store/use-audio.tsx
index 4f43964d..4bda84bd 100644
--- a/apps/editor/store/use-audio.tsx
+++ b/apps/editor/store/use-audio.tsx
@@ -7,11 +7,14 @@ interface AudioState {
masterVolume: number
sfxVolume: number
radioVolume: number
+ isRadioPlaying: boolean
muted: boolean
autoplay: boolean
setMasterVolume: (v: number) => void
setSfxVolume: (v: number) => void
setRadioVolume: (v: number) => void
+ setRadioPlaying: (v: boolean) => void
+ toggleRadioPlaying: () => void
toggleMute: () => void
setAutoplay: (v: boolean) => void
}
@@ -22,11 +25,14 @@ const useAudio = create()(
masterVolume: 70,
sfxVolume: 50,
radioVolume: 25,
+ isRadioPlaying: false,
muted: false,
autoplay: true,
setMasterVolume: (v) => set({ masterVolume: v }),
setSfxVolume: (v) => set({ sfxVolume: v }),
setRadioVolume: (v) => set({ radioVolume: v }),
+ setRadioPlaying: (v) => set({ isRadioPlaying: v }),
+ toggleRadioPlaying: () => set((state) => ({ isRadioPlaying: !state.isRadioPlaying })),
toggleMute: () => set((state) => ({ muted: !state.muted })),
setAutoplay: (v) => set({ autoplay: v }),
}),
diff --git a/apps/editor/store/use-editor.tsx b/apps/editor/store/use-editor.tsx
index 3d0451af..6c625043 100644
--- a/apps/editor/store/use-editor.tsx
+++ b/apps/editor/store/use-editor.tsx
@@ -86,8 +86,8 @@ const useEditor = create()((set, get) => ({
set({ phase })
- // Clear tool and catalog when switching phases
- set({ tool: null, catalogCategory: null })
+ // Reset to select mode and clear tool/catalog when switching phases
+ set({ mode: 'select', tool: null, catalogCategory: null })
const viewer = useViewer.getState()
const scene = useScene.getState()
@@ -156,13 +156,20 @@ const useEditor = create()((set, get) => ({
selectedIds: [],
zoneId: null,
})
- }
- // When entering build mode in structure phase with zones layer, activate zone tool
- if (mode === 'build' && phase === 'structure' && structureLayer === 'zones') {
- set({ tool: 'zone' })
+
+ // Ensure a tool is selected in build mode
+ if (!tool) {
+ if (phase === 'structure' && structureLayer === 'zones') {
+ set({ tool: 'zone' })
+ } else if (phase === 'structure' && structureLayer === 'elements') {
+ set({ tool: 'wall' })
+ } else if (phase === 'furnish') {
+ set({ tool: 'item', catalogCategory: 'furniture' })
+ }
+ }
}
// When leaving build mode, clear tool
- else if (mode !== 'build' && tool) {
+ else if (tool) {
set({ tool: null })
}
},
@@ -170,27 +177,13 @@ const useEditor = create()((set, get) => ({
setTool: (tool) => set({ tool }),
structureLayer: 'elements',
setStructureLayer: (layer) => {
- const { mode, tool } = get()
- set({ structureLayer: layer })
+ set({ structureLayer: layer, mode: 'select', tool: null })
const viewer = useViewer.getState()
viewer.setSelection({
selectedIds: [],
zoneId: null,
})
-
- // Handle tool changes based on layer
- if (layer === 'zones') {
- // In zones layer with build mode, activate zone tool
- if (mode === 'build') {
- set({ tool: 'zone' })
- }
- } else {
- // In elements layer, clear zone tool if it was active
- if (tool === 'zone') {
- set({ tool: null })
- }
- }
},
catalogCategory: null,
setCatalogCategory: (category) => set({ catalogCategory: category }),
diff --git a/packages/viewer/src/components/renderers/site/site-renderer.tsx b/packages/viewer/src/components/renderers/site/site-renderer.tsx
index 3286a5f4..d1d01144 100644
--- a/packages/viewer/src/components/renderers/site/site-renderer.tsx
+++ b/packages/viewer/src/components/renderers/site/site-renderer.tsx
@@ -3,6 +3,7 @@ import { Html } from '@react-three/drei'
import { useMemo, useRef } from 'react'
import { BufferGeometry, Float32BufferAttribute, type Group, Shape } from 'three'
import { useNodeEvents } from '../../../hooks/use-node-events'
+import useViewer from '../../../store/use-viewer'
import { NodeRenderer } from '../node-renderer'
const Y_OFFSET = 0.01
@@ -61,8 +62,11 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => {
return createBoundaryLineGeometry(node.polygon.points)
}, [node?.polygon?.points])
+ const isEditor = useViewer((state) => state.isEditor)
+
// Edge distances for labels
const edges = useMemo(() => {
+ if (!isEditor) return []
const polygon = node?.polygon?.points ?? []
if (polygon.length < 2) return []
return polygon.map(([x1, z1], i) => {
@@ -72,7 +76,7 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => {
const dist = Math.sqrt((x2 - x1!) ** 2 + (z2 - z1!) ** 2)
return { midX, midZ, dist }
})
- }, [node?.polygon?.points])
+ }, [node?.polygon?.points, isEditor])
const handlers = useNodeEvents(node, 'site')
@@ -103,7 +107,7 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => {
{/* Edge distance labels */}
- {edges.map((edge, i) => (
+ {isEditor && edges.map((edge, i) => (
): BufferGeometry =>
export const ZoneRenderer = ({ node }: { node: ZoneNode }) => {
const ref = useRef(null!)
+ const updateNode = useScene((s) => s.updateNode)
+ const isEditor = useViewer((state) => state.isEditor)
+
+ const [isEditing, setIsEditing] = useState(false)
+ const [isHovered, setIsHovered] = useState(false)
+ const [editValue, setEditValue] = useState(node.name || '')
+ const inputRef = useRef(null)
+
+ useEffect(() => {
+ if (isEditing) {
+ setEditValue(node.name || '')
+ setTimeout(() => {
+ if (inputRef.current) {
+ inputRef.current.focus()
+ inputRef.current.select()
+ }
+ }, 0)
+ }
+ }, [isEditing, node.name])
+
+ const handleSave = () => {
+ const trimmed = editValue.trim()
+ if (trimmed !== node.name) {
+ updateNode(node.id, { name: trimmed || 'Zone' })
+ }
+ setIsEditing(false)
+ }
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === 'Enter') {
+ e.preventDefault()
+ handleSave()
+ } else if (e.key === 'Escape') {
+ e.preventDefault()
+ setIsEditing(false)
+ }
+ }
useRegistry(node.id, 'zone', ref)
@@ -181,7 +219,7 @@ export const ZoneRenderer = ({ node }: { node: ZoneNode }) => {
labelPosition: [centroid[0], 1, centroid[1]]
}}>
{
display: 'flex',
gap: '8px',
alignItems: 'center',
+ cursor: isEditor && !isEditing ? 'text' : 'default',
+ }}
+ onMouseEnter={() => isEditor && setIsHovered(true)}
+ onMouseLeave={() => isEditor && setIsHovered(false)}
+ onClick={(e) => {
+ if (isEditor && !isEditing) {
+ e.stopPropagation()
+ setIsEditing(true)
+ }
}}>
- {node.name}
+ {isEditing ? (
+ setEditValue(e.target.value)}
+ onKeyDown={handleKeyDown}
+ onBlur={handleSave}
+ placeholder="Zone"
+ style={{
+ background: 'transparent',
+ color: 'white',
+ textShadow: 'inherit',
+ border: 'none',
+ borderBottom: '1px solid white',
+ outline: 'none',
+ padding: 0,
+ margin: 0,
+ fontSize: 'inherit',
+ fontFamily: 'inherit',
+ width: `${Math.max(editValue.length, 4) + 1}ch`,
+ minWidth: '50px',
+ textAlign: 'center'
+ }}
+ onClick={(e) => e.stopPropagation()}
+ onDoubleClick={(e) => e.stopPropagation()}
+ />
+ ) : (
+ <>
+ {node.name}
+ {isEditor && (
+
+ )}
+ >
+ )}
+
{/* Floor fill */}
diff --git a/packages/viewer/src/components/viewer/index.tsx b/packages/viewer/src/components/viewer/index.tsx
index acb05c90..a59fd45e 100644
--- a/packages/viewer/src/components/viewer/index.tsx
+++ b/packages/viewer/src/components/viewer/index.tsx
@@ -15,6 +15,9 @@ import PostProcessing from './post-processing'
import { SelectionManager } from './selection-manager'
import { ViewerCamera } from './viewer-camera'
+import { useEffect } from 'react'
+import useViewer from '../../store/use-viewer'
+
declare module '@react-three/fiber' {
interface ThreeElements extends ThreeToJSXElements {}
}
@@ -24,9 +27,16 @@ extend(THREE as any)
interface ViewerProps {
children?: React.ReactNode
selectionManager?: 'default' | 'custom'
+ isEditor?: boolean
}
-const Viewer: React.FC = ({ children, selectionManager = 'default' }) => {
+const Viewer: React.FC = ({ children, selectionManager = 'default', isEditor = false }) => {
+ const setIsEditor = useViewer((state) => state.setIsEditor)
+
+ useEffect(() => {
+ setIsEditor(isEditor)
+ }, [isEditor, setIsEditor])
+
return (