diff --git a/apps/editor/components/editor/index.tsx b/apps/editor/components/editor/index.tsx
index 49b90abd..e20d54bd 100644
--- a/apps/editor/components/editor/index.tsx
+++ b/apps/editor/components/editor/index.tsx
@@ -13,8 +13,7 @@ import { useKeyboard } from '@/hooks/use-keyboard'
import { ZoneSystem } from '../systems/zone/zone-system'
import { ToolManager } from '../tools/tool-manager'
import { ActionMenu } from '../ui/action-menu'
-import { ReferencePanel } from '../ui/panels/reference-panel'
-import { RoofPanel } from '../ui/panels/roof-panel'
+import { PanelManager } from '../ui/panels/panel-manager'
import { SidebarProvider } from '../ui/primitives/sidebar'
import { AppSidebar } from '../ui/sidebar/app-sidebar'
import { CustomCameraControls } from './custom-camera-controls'
@@ -31,8 +30,7 @@ export default function Editor() {
return (
-
-
+
diff --git a/apps/editor/components/tools/item/use-placement-coordinator.tsx b/apps/editor/components/tools/item/use-placement-coordinator.tsx
index 34d02fae..f1c12000 100644
--- a/apps/editor/components/tools/item/use-placement-coordinator.tsx
+++ b/apps/editor/components/tools/item/use-placement-coordinator.tsx
@@ -503,12 +503,14 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
if (!asset.attachTo) {
const levelId = useViewer.getState().selection.levelId
if (levelId) {
- mesh.position.y = spatialGridManager.getSlabElevationForItem(
+ const slabElevation = spatialGridManager.getSlabElevationForItem(
levelId,
[gridPosition.current.x, gridPosition.current.y, gridPosition.current.z],
asset.dimensions ?? DEFAULT_DIMENSIONS,
draftNode.current.rotation,
)
+ mesh.position.y = slabElevation
+ cursorRef.current.position.y = slabElevation
}
}
}
diff --git a/apps/editor/components/tools/slab/slab-tool.tsx b/apps/editor/components/tools/slab/slab-tool.tsx
index 5f915278..6440b302 100644
--- a/apps/editor/components/tools/slab/slab-tool.tsx
+++ b/apps/editor/components/tools/slab/slab-tool.tsx
@@ -46,12 +46,12 @@ const calculateSnapPoint = (
};
/**
- * Creates a slab with the given polygon points
+ * Creates a slab with the given polygon points and returns its ID
*/
const commitSlabDrawing = (
levelId: LevelNode["id"],
points: Array<[number, number]>
-) => {
+): string => {
const { createNode, nodes } = useScene.getState();
// Count existing slabs for naming
@@ -64,6 +64,7 @@ const commitSlabDrawing = (
});
createNode(slab, levelId);
+ return slab.id;
};
type PreviewState = {
@@ -87,6 +88,7 @@ export const SlabTool: React.FC = () => {
const pointsRef = useRef>([]);
const levelYRef = useRef(0); // Track current level Y position
const currentLevelId = useViewer((state) => state.selection.levelId);
+ const setSelection = useViewer((state) => state.setSelection);
const setTool = useEditor((state) => state.setTool);
// Preview state for reactive rendering (for shape and point markers)
@@ -213,17 +215,15 @@ export const SlabTool: React.FC = () => {
Math.abs(clickPoint[0] - firstPoint[0]) < 0.25 &&
Math.abs(clickPoint[1] - firstPoint[1]) < 0.25
) {
- // Create the slab
- commitSlabDrawing(currentLevelId, pointsRef.current);
+ // Create the slab and select it
+ const slabId = commitSlabDrawing(currentLevelId, pointsRef.current);
+ setSelection({ selectedIds: [slabId] });
// Reset state
pointsRef.current = [];
setPreview({ points: [], cursorPoint: null, levelY: 0 });
mainLineRef.current.visible = false;
closingLineRef.current.visible = false;
-
- // Deactivate tool
- setTool(null);
} else {
// Add point to polygon
pointsRef.current = [...pointsRef.current, clickPoint];
@@ -236,16 +236,15 @@ export const SlabTool: React.FC = () => {
// Need at least 3 points to form a polygon
if (pointsRef.current.length >= 3) {
- commitSlabDrawing(currentLevelId, pointsRef.current);
+ // Create the slab and select it
+ const slabId = commitSlabDrawing(currentLevelId, pointsRef.current);
+ setSelection({ selectedIds: [slabId] });
// Reset state
pointsRef.current = [];
setPreview({ points: [], cursorPoint: null, levelY: 0 });
mainLineRef.current.visible = false;
closingLineRef.current.visible = false;
-
- // Deactivate tool
- setTool(null);
}
};
@@ -262,7 +261,7 @@ export const SlabTool: React.FC = () => {
// Reset state on unmount
pointsRef.current = [];
};
- }, [currentLevelId, setTool]);
+ }, [currentLevelId, setSelection]);
const { points, cursorPoint, levelY } = preview;
diff --git a/apps/editor/components/ui/panels/panel-manager.tsx b/apps/editor/components/ui/panels/panel-manager.tsx
new file mode 100644
index 00000000..92d26f68
--- /dev/null
+++ b/apps/editor/components/ui/panels/panel-manager.tsx
@@ -0,0 +1,35 @@
+'use client'
+
+import { AnyNodeId, useScene } from '@pascal-app/core'
+import { useViewer } from '@pascal-app/viewer'
+import useEditor from '@/store/use-editor'
+import { ReferencePanel } from './reference-panel'
+import { RoofPanel } from './roof-panel'
+import { SlabPanel } from './slab-panel'
+
+export function PanelManager() {
+ const selectedIds = useViewer((s) => s.selection.selectedIds)
+ const selectedReferenceId = useEditor((s) => s.selectedReferenceId)
+ const nodes = useScene((s) => s.nodes)
+
+ // Show reference panel if a reference is selected
+ if (selectedReferenceId) {
+ return
+ }
+
+ // Show appropriate panel based on selected node type
+ if (selectedIds.length === 1) {
+ const selectedNode = selectedIds[0]
+ const node = nodes[selectedNode as AnyNodeId]
+ if (node) {
+ switch (node.type) {
+ case 'roof':
+ return
+ case 'slab':
+ return
+ }
+ }
+ }
+
+ return null
+}
diff --git a/apps/editor/components/ui/panels/slab-panel.tsx b/apps/editor/components/ui/panels/slab-panel.tsx
new file mode 100644
index 00000000..cd0c1f7e
--- /dev/null
+++ b/apps/editor/components/ui/panels/slab-panel.tsx
@@ -0,0 +1,148 @@
+'use client'
+
+import { type AnyNode, type SlabNode, useScene } from '@pascal-app/core'
+import { useViewer } from '@pascal-app/viewer'
+import { X } from 'lucide-react'
+import Image from 'next/image'
+import { useCallback } from 'react'
+
+export function SlabPanel() {
+ const selectedIds = useViewer((s) => s.selection.selectedIds)
+ const setSelection = useViewer((s) => s.setSelection)
+ const nodes = useScene((s) => s.nodes)
+ const updateNode = useScene((s) => s.updateNode)
+
+ // Get the first selected node if it's a slab
+ const selectedId = selectedIds[0]
+ const node = selectedId
+ ? (nodes[selectedId as AnyNode['id']] as SlabNode | undefined)
+ : undefined
+
+ const handleUpdate = useCallback(
+ (updates: Partial) => {
+ if (!selectedId) return
+ updateNode(selectedId as AnyNode['id'], updates)
+ },
+ [selectedId, updateNode],
+ )
+
+ const handleClose = useCallback(() => {
+ setSelection({ selectedIds: [] })
+ }, [setSelection])
+
+ // Only show if exactly one slab is selected
+ if (!node || node.type !== 'slab' || selectedIds.length !== 1) return null
+
+ // Calculate approximate area from polygon
+ const calculateArea = (polygon: Array<[number, number]>): number => {
+ if (polygon.length < 3) return 0
+ let area = 0
+ const n = polygon.length
+ for (let i = 0; i < n; i++) {
+ const j = (i + 1) % n
+ area += polygon[i]![0] * polygon[j]![1]
+ area -= polygon[j]![0] * polygon[i]![1]
+ }
+ return Math.abs(area) / 2
+ }
+
+ const area = calculateArea(node.polygon)
+
+ return (
+
+ {/* Header */}
+
+
+
+
+ {node.name || `Slab (${area.toFixed(1)}m²)`}
+
+
+
+
+
+ {/* Content */}
+
+
+ {/* Elevation */}
+
+
+
+ {
+ const value = Number.parseFloat(e.target.value)
+ if (!Number.isNaN(value)) {
+ handleUpdate({ elevation: value })
+ }
+ }}
+ step="0.05"
+ type="number"
+ value={Math.round(node.elevation * 1000) / 1000}
+ />
+ m
+
+
+ Height offset from the level base (positive = raised, negative = sunken)
+
+
+
+ {/* Quick preset buttons */}
+
+
+
+
+
+
+
+
+
+
+ {/* Area info */}
+
+
+
+ {area.toFixed(2)} m²
+
+
+
+
+
+ )
+}
diff --git a/apps/editor/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx b/apps/editor/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx
index ea87fb74..017d4dba 100644
--- a/apps/editor/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx
+++ b/apps/editor/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx
@@ -59,6 +59,7 @@ export function CeilingTreeNode({ node, depth }: CeilingTreeNodeProps) {
onMouseLeave={handleMouseLeave}
isSelected={isSelected}
isHovered={isHovered}
+ isVisible={node.visible !== false}
actions={}
>
{node.children.map((childId) => (
diff --git a/apps/editor/components/ui/sidebar/panels/site-panel/index.tsx b/apps/editor/components/ui/sidebar/panels/site-panel/index.tsx
index b53da349..a6924faf 100644
--- a/apps/editor/components/ui/sidebar/panels/site-panel/index.tsx
+++ b/apps/editor/components/ui/sidebar/panels/site-panel/index.tsx
@@ -1,5 +1,6 @@
import {
type BuildingNode,
+ emitter,
LevelNode,
useScene,
type ZoneNode,
@@ -7,6 +8,7 @@ import {
import { useViewer } from "@pascal-app/viewer";
import {
Building2,
+ Camera,
ChevronDown,
Layers,
MoreHorizontal,
@@ -155,11 +157,13 @@ function BuildingSelector() {
function LevelsSection() {
const nodes = useScene((state) => state.nodes);
const createNode = useScene((state) => state.createNode);
+ const updateNode = useScene((state) => state.updateNode);
const selectedBuildingId = useViewer((state) => state.selection.buildingId);
const selectedLevelId = useViewer((state) => state.selection.levelId);
const setSelection = useViewer((state) => state.setSelection);
const [referencesLevelId, setReferencesLevelId] = useState(null);
+ const [cameraPopoverOpen, setCameraPopoverOpen] = useState(null);
const building = selectedBuildingId
? (nodes[selectedBuildingId] as BuildingNode)
@@ -215,6 +219,72 @@ function LevelsSection() {
{level.name || `Level ${level.level}`}
+ {/* Camera snapshot button */}
+ setCameraPopoverOpen(open ? level.id : null)}>
+
+
+
+ e.stopPropagation()}
+ >
+
+ {level.camera && (
+
+ )}
+
+ {level.camera && (
+
+ )}
+
+
+
{zone.name || defaultName}
+ {/* Camera snapshot button */}
+
+
+
+
+ e.stopPropagation()}
+ >
+
+ {zone.camera && (
+
+ )}
+
+ {zone.camera && (
+
+ )}
+
+
+
}
/>
diff --git a/apps/editor/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx b/apps/editor/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx
index 26f2ac0b..026020bf 100644
--- a/apps/editor/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx
+++ b/apps/editor/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx
@@ -59,6 +59,7 @@ export function RoofTreeNode({ node, depth }: RoofTreeNodeProps) {
onMouseLeave={handleMouseLeave}
isSelected={isSelected}
isHovered={isHovered}
+ isVisible={node.visible !== false}
actions={}
/>
diff --git a/apps/editor/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx b/apps/editor/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx
index 86c43860..9c222102 100644
--- a/apps/editor/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx
+++ b/apps/editor/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx
@@ -58,6 +58,7 @@ export function SlabTreeNode({ node, depth }: SlabTreeNodeProps) {
onMouseLeave={handleMouseLeave}
isSelected={isSelected}
isHovered={isHovered}
+ isVisible={node.visible !== false}
actions={}
/>
diff --git a/apps/editor/components/ui/sidebar/panels/site-panel/tree-node.tsx b/apps/editor/components/ui/sidebar/panels/site-panel/tree-node.tsx
index f5a447fc..a7f829f9 100644
--- a/apps/editor/components/ui/sidebar/panels/site-panel/tree-node.tsx
+++ b/apps/editor/components/ui/sidebar/panels/site-panel/tree-node.tsx
@@ -58,6 +58,7 @@ interface TreeNodeWrapperProps {
children?: React.ReactNode;
isSelected?: boolean;
isHovered?: boolean;
+ isVisible?: boolean;
}
export const TreeNodeWrapper = forwardRef(
@@ -77,6 +78,7 @@ export const TreeNodeWrapper = forwardRef(
children,
isSelected,
isHovered,
+ isVisible = true,
},
ref
) {
@@ -89,7 +91,8 @@ export const TreeNodeWrapper = forwardRef(
? "text-primary-foreground bg-primary/80 hover:bg-primary/90"
: isHovered
? "bg-accent/70 text-foreground"
- : "text-muted-foreground hover:bg-accent/50"
+ : "text-muted-foreground hover:bg-accent/50",
+ !isVisible && "opacity-50"
)}
style={{ paddingLeft: depth * 12 + 4 }}
onMouseEnter={onMouseEnter}
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 a5a4625e..e55b9bc1 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
@@ -62,6 +62,7 @@ export function WallTreeNode({ node, depth }: WallTreeNodeProps) {
onMouseLeave={handleMouseLeave}
isSelected={isSelected}
isHovered={isHovered}
+ isVisible={node.visible !== false}
actions={}
>
{node.children.map((childId) => (
diff --git a/apps/editor/components/ui/sidebar/panels/zone-panel/index.tsx b/apps/editor/components/ui/sidebar/panels/zone-panel/index.tsx
index 94202963..cb7138dd 100644
--- a/apps/editor/components/ui/sidebar/panels/zone-panel/index.tsx
+++ b/apps/editor/components/ui/sidebar/panels/zone-panel/index.tsx
@@ -1,6 +1,7 @@
-import { useScene, type ZoneNode } from "@pascal-app/core";
+import { emitter, useScene, type ZoneNode } from "@pascal-app/core";
import { useViewer } from "@pascal-app/viewer";
-import { Hexagon, Trash2 } from "lucide-react";
+import { Camera, Hexagon, Trash2 } from "lucide-react";
+import { useState } from "react";
import { cn } from "@/lib/utils";
import useEditor from "@/store/use-editor";
import {
@@ -22,6 +23,7 @@ const PRESET_COLORS = [
];
function ZoneItem({ zone }: { zone: ZoneNode }) {
+ const [cameraPopoverOpen, setCameraPopoverOpen] = useState(false);
const deleteNode = useScene((state) => state.deleteNode);
const updateNode = useScene((state) => state.updateNode);
const selectedZoneId = useViewer((state) => state.selection.zoneId);
@@ -85,6 +87,67 @@ function ZoneItem({ zone }: { zone: ZoneNode }) {
{zone.name}
+ {/* Camera snapshot button */}
+
+
+
+
+ e.stopPropagation()}
+ >
+
+ {zone.camera && (
+
+ )}
+
+ {zone.camera && (
+
+ )}
+
+
+