-
- {projectName || 'Untitled'}
+ {/* Unified top-left card */}
+
+
+ {/* Project info + back */}
+
+
+
+
+
+
+ {projectName || 'Untitled'}
+
+ {owner?.username && (
+
+ @{owner.username}
+
+ )}
- {owner?.username && (
-
- @{owner.username}
-
- )}
-
- {/* Breadcrumb — only shown when navigated into a building */}
- {building && (
-
-
-
-
- {building && (
- <>
-
+ {/* Breadcrumb — only shown when navigated into a building */}
+ {building && (
+
+
- >
- )}
- {level && (
- <>
-
-
- >
- )}
+ {building && (
+ <>
+
+
+ >
+ )}
- {zone && (
- <>
-
-
- {zone.name}
-
- >
- )}
+ {level && (
+ <>
+
+
+ >
+ )}
- {selectedNode && zone && (
- <>
-
- {getNodeName(selectedNode)}
- >
- )}
-
-
- )}
-
+ {zone && (
+ <>
+
+
+ {zone.name}
+
+ >
+ )}
- {/* Level List (only when building is selected) */}
- {building && levels.length > 0 && (
-
- Levels
- {levels.map((lvl) => (
-
- ))}
-
- )}
-
-
- {/* Controls Panel - Top Right */}
-
- {/* Visibility Controls */}
- {(canShowScans || canShowGuides) && (
-
- Visibility
- {canShowScans && (
-
- )}
- {canShowGuides && (
-
- )}
-
- )}
-
- {/* Camera Mode */}
-
- Camera
-
+
)}
- {cameraMode === 'perspective' ? 'Perspective' : 'Orthographic'}
-
+
+
+ {/* Level List (only when building is selected) */}
+ {building && levels.length > 0 && (
+
+ Levels
+ {levels.map((lvl) => (
+
+ ))}
+
+ )}
- {/* Level Mode */}
-
- Level Mode
-
-
-
-
+ {/* Controls Panel - Top Right */}
+
+ {/* Visibility Controls */}
+ {(canShowScans || canShowGuides) && (
+
+ Visibility
+ {canShowScans && (
+
+ )}
+ {canShowGuides && (
+
+ )}
+
+ )}
- {/* Wall Mode */}
-
-
Wall Mode
-
-
-
+ {/* Camera Mode */}
+
+ Camera
+
+
+
+ {/* Level Mode */}
+
+ Level Mode
+
+
+
+
+
+ {/* Wall Mode */}
+
+
Wall Mode
+
+
+
+
-
>
)
}
diff --git a/apps/editor/app/viewer/[id]/viewer-zone-system.tsx b/apps/editor/app/viewer/[id]/viewer-zone-system.tsx
index 5f21786e..80b48ca4 100644
--- a/apps/editor/app/viewer/[id]/viewer-zone-system.tsx
+++ b/apps/editor/app/viewer/[id]/viewer-zone-system.tsx
@@ -1,6 +1,6 @@
'use client'
-import { type ZoneNode, sceneRegistry, useScene } from '@pascal-app/core'
+import { sceneRegistry, useScene, type ZoneNode } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useFrame } from '@react-three/fiber'
@@ -28,7 +28,13 @@ export const ViewerZoneSystem = () => {
// Also hide the label
const label = obj.getObjectByName('label')
if (label) {
- label.position.y = shouldShow ? 1 : -1000
+ // Hide label if zone layer is off OR if in solo mode on a different level
+ const labelPosition = obj.userData.labelPosition as [number, number, number] | undefined
+ if (shouldShow && labelPosition) {
+ label.position.set(...labelPosition)
+ } else {
+ label.position.set(-9999, -9999, -9999)
+ }
}
})
})
diff --git a/apps/editor/components/pascal-radio.tsx b/apps/editor/components/pascal-radio.tsx
index f02dbf9b..30ee227e 100644
--- a/apps/editor/components/pascal-radio.tsx
+++ b/apps/editor/components/pascal-radio.tsx
@@ -75,6 +75,15 @@ export function PascalRadio() {
// Calculate effective volume (masterVolume * radioVolume, both are 0-100)
const effectiveVolume = (masterVolume / 100) * (radioVolume / 100)
+ // Keep a ref so the track-init effect can read current volume/muted/isPlaying
+ // without those values being part of its dependency array (which would restart the song).
+ const effectiveVolumeRef = useRef(effectiveVolume)
+ const mutedRef = useRef(muted)
+ const isPlayingRef = useRef(isPlaying)
+ effectiveVolumeRef.current = effectiveVolume
+ mutedRef.current = muted
+ isPlayingRef.current = isPlaying
+
const handleNext = useCallback(() => {
setCurrentTrackIndex((prev) => (prev + 1) % shuffledPlaylist.length)
}, [shuffledPlaylist.length])
@@ -83,32 +92,29 @@ export function PascalRadio() {
setCurrentTrackIndex((prev) => (prev - 1 + shuffledPlaylist.length) % shuffledPlaylist.length)
}, [shuffledPlaylist.length])
- // Initialize Howler when track changes
+ // Initialize Howler only when the track changes — not on volume/mute/play-state changes.
+ // Volume and mute are handled by the separate effect below.
useEffect(() => {
- // Clean up previous sound
if (soundRef.current) {
soundRef.current.unload()
}
- const wasPlaying = isPlaying
+ const wasPlaying = isPlayingRef.current
- // Create new sound
soundRef.current = new Howl({
src: [currentTrack.file],
- volume: muted ? 0 : effectiveVolume,
+ volume: mutedRef.current ? 0 : effectiveVolumeRef.current,
onend: handleNext,
})
- // If was playing, play new track
- if (wasPlaying && !muted) {
+ if (wasPlaying && !mutedRef.current) {
soundRef.current?.play()
}
return () => {
soundRef.current?.unload()
}
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [handleNext, currentTrack.file, muted, isPlaying, effectiveVolume])
+ }, [handleNext, currentTrack.file])
// Update volume when settings change
useEffect(() => {
diff --git a/apps/editor/components/systems/zone/zone-system.tsx b/apps/editor/components/systems/zone/zone-system.tsx
index 2c775526..1c5e2cf6 100644
--- a/apps/editor/components/systems/zone/zone-system.tsx
+++ b/apps/editor/components/systems/zone/zone-system.tsx
@@ -1,4 +1,4 @@
-import { type ZoneNode, sceneRegistry, useScene } from '@pascal-app/core'
+import { sceneRegistry, useScene, type ZoneNode } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useEffect } from 'react'
import useEditor from '@/store/use-editor'
@@ -24,10 +24,17 @@ export const ZoneSystem = () => {
const hideInSoloMode = levelMode === 'solo' && selectedLevelId && !isOnSelectedLevel
obj.visible = visible
+
const label = obj.getObjectByName('label')
if (label) {
// Hide label if zone layer is off OR if in solo mode on a different level
- label.position.y = (visible && !hideInSoloMode) ? 1 : -1000
+ const showLabel = visible && !hideInSoloMode;
+ const labelPosition = obj.userData.labelPosition as [number, number, number] | undefined
+ if (showLabel && labelPosition) {
+ label.position.set(...labelPosition)
+ } else {
+ label.position.set(-9999, -9999, -9999)
+ }
}
})
}, [structureLayer, levelMode, selectedLevelId])
diff --git a/apps/editor/components/tools/ceiling/ceiling-tool.tsx b/apps/editor/components/tools/ceiling/ceiling-tool.tsx
index 3a56a74b..89567f85 100644
--- a/apps/editor/components/tools/ceiling/ceiling-tool.tsx
+++ b/apps/editor/components/tools/ceiling/ceiling-tool.tsx
@@ -2,8 +2,9 @@ import { CeilingNode, emitter, type GridEvent, type LevelNode, useScene } from '
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useRef, useState } from 'react'
import { BufferGeometry, DoubleSide, type Line, type Mesh, Shape, Vector3 } from 'three'
-import useEditor from '@/store/use-editor'
import { sfxEmitter } from '@/lib/sfx-bus'
+import useEditor from '@/store/use-editor'
+import { CursorSphere } from '../shared/cursor-sphere'
const CEILING_HEIGHT = 2.52
const GRID_OFFSET = 0.02
@@ -97,12 +98,19 @@ export const CeilingTool: React.FC = () => {
// Calculate snapped display position (bypass snap when Shift is held)
const lastPoint = points[points.length - 1]
- const displayPoint = (shiftPressed.current || !lastPoint) ? gridPosition : calculateSnapPoint(lastPoint, gridPosition)
+ const displayPoint =
+ shiftPressed.current || !lastPoint
+ ? gridPosition
+ : calculateSnapPoint(lastPoint, gridPosition)
setSnappedCursorPosition(displayPoint)
// Play snap sound when the snapped position actually changes (only when drawing)
- if (points.length > 0 && previousSnappedPointRef.current &&
- (displayPoint[0] !== previousSnappedPointRef.current[0] || displayPoint[1] !== previousSnappedPointRef.current[1])) {
+ if (
+ points.length > 0 &&
+ previousSnappedPointRef.current &&
+ (displayPoint[0] !== previousSnappedPointRef.current[0] ||
+ displayPoint[1] !== previousSnappedPointRef.current[1])
+ ) {
sfxEmitter.emit('sfx:grid-snap')
}
@@ -150,8 +158,12 @@ export const CeilingTool: React.FC = () => {
setPoints([])
}
- const onKeyDown = (e: KeyboardEvent) => { if (e.key === 'Shift') shiftPressed.current = true }
- const onKeyUp = (e: KeyboardEvent) => { if (e.key === 'Shift') shiftPressed.current = false }
+ const onKeyDown = (e: KeyboardEvent) => {
+ if (e.key === 'Shift') shiftPressed.current = true
+ }
+ const onKeyUp = (e: KeyboardEvent) => {
+ if (e.key === 'Shift') shiftPressed.current = false
+ }
document.addEventListener('keydown', onKeyDown)
document.addEventListener('keyup', onKeyUp)
@@ -242,15 +254,12 @@ export const CeilingTool: React.FC = () => {
return (
{/* Cursor at ceiling height */}
-
-
-
-
+
{/* Grid-level cursor indicator */}
-
+
-
+
{/* Preview fill */}
@@ -294,14 +303,11 @@ export const CeilingTool: React.FC = () => {
{/* Point markers */}
{points.map(([x, z], index) => (
-
-
-
-
+
))}
)
diff --git a/apps/editor/components/tools/roof/roof-tool.tsx b/apps/editor/components/tools/roof/roof-tool.tsx
index 9f351007..6698ae75 100644
--- a/apps/editor/components/tools/roof/roof-tool.tsx
+++ b/apps/editor/components/tools/roof/roof-tool.tsx
@@ -1,37 +1,44 @@
-import { emitter, type GridEvent, useScene, RoofNode, type LevelNode, type AnyNode } from "@pascal-app/core";
-import { useViewer } from "@pascal-app/viewer";
-import { useEffect, useMemo, useRef, useState } from "react";
-import { BufferGeometry, DoubleSide, type Line, Vector3 } from "three";
-import useEditor from "@/store/use-editor";
-import { sfxEmitter } from '@/lib/sfx-bus';
+import {
+ type AnyNode,
+ emitter,
+ type GridEvent,
+ type LevelNode,
+ RoofNode,
+ useScene,
+} from '@pascal-app/core'
+import { useViewer } from '@pascal-app/viewer'
+import { useEffect, useMemo, useRef, useState } from 'react'
+import { BufferGeometry, DoubleSide, type Line, Vector3 } from 'three'
+import { sfxEmitter } from '@/lib/sfx-bus'
+import useEditor from '@/store/use-editor'
// Default roof dimensions
-const DEFAULT_HEIGHT = 1.5;
-const PREVIEW_LINE_HEIGHT = 0.03; // Very thin preview
+const DEFAULT_HEIGHT = 1.5
+const PREVIEW_LINE_HEIGHT = 0.03 // Very thin preview
/**
* Creates a roof with the given corners
*/
const commitRoofPlacement = (
- levelId: LevelNode["id"],
+ levelId: LevelNode['id'],
corner1: [number, number, number],
- corner2: [number, number, number]
-): RoofNode["id"] => {
- const { createNode, nodes } = useScene.getState();
+ corner2: [number, number, number],
+): RoofNode['id'] => {
+ const { createNode, nodes } = useScene.getState()
// Calculate center position and dimensions from corners
- const centerX = (corner1[0] + corner2[0]) / 2;
- const centerZ = (corner1[2] + corner2[2]) / 2;
+ const centerX = (corner1[0] + corner2[0]) / 2
+ const centerZ = (corner1[2] + corner2[2]) / 2
- const length = Math.abs(corner2[0] - corner1[0]);
- const width = Math.abs(corner2[2] - corner1[2]);
+ const length = Math.abs(corner2[0] - corner1[0])
+ const width = Math.abs(corner2[2] - corner1[2])
// Split width evenly between left and right slopes
- const slopeWidth = Math.max(width / 2, 0.5);
+ const slopeWidth = Math.max(width / 2, 0.5)
// Count existing roofs for naming
- const roofCount = Object.values(nodes).filter((n) => n.type === "roof").length;
- const name = `Roof ${roofCount + 1}`;
+ const roofCount = Object.values(nodes).filter((n) => n.type === 'roof').length
+ const name = `Roof ${roofCount + 1}`
const roof = RoofNode.parse({
name,
@@ -40,151 +47,153 @@ const commitRoofPlacement = (
height: DEFAULT_HEIGHT,
leftWidth: slopeWidth,
rightWidth: slopeWidth,
- });
+ })
- createNode(roof, levelId);
- sfxEmitter.emit('sfx:structure-build');
- return roof.id;
-};
+ createNode(roof, levelId)
+ sfxEmitter.emit('sfx:structure-build')
+ return roof.id
+}
type PreviewState = {
- corner1: [number, number, number] | null;
- cursorPosition: [number, number, number];
- levelY: number;
-};
+ corner1: [number, number, number] | null
+ cursorPosition: [number, number, number]
+ levelY: number
+}
export const RoofTool: React.FC = () => {
- const outlineRef = useRef
(null!);
- const currentLevelId = useViewer((state) => state.selection.levelId);
- const setSelection = useViewer((state) => state.setSelection);
- const setTool = useEditor((state) => state.setTool);
- const setMode = useEditor((state) => state.setMode);
+ const outlineRef = useRef(null!)
+ const currentLevelId = useViewer((state) => state.selection.levelId)
+ const setSelection = useViewer((state) => state.setSelection)
+ const setTool = useEditor((state) => state.setTool)
+ const setMode = useEditor((state) => state.setMode)
- const corner1Ref = useRef<[number, number, number] | null>(null);
- const previousGridPosRef = useRef<[number, number] | null>(null);
+ const corner1Ref = useRef<[number, number, number] | null>(null)
+ const previousGridPosRef = useRef<[number, number] | null>(null)
const [preview, setPreview] = useState({
corner1: null,
cursorPosition: [0, 0, 0],
levelY: 0,
- });
+ })
useEffect(() => {
- if (!currentLevelId) return;
+ if (!currentLevelId) return
// Initialize outline geometry
- outlineRef.current.geometry = new BufferGeometry();
+ outlineRef.current.geometry = new BufferGeometry()
- const updateOutline = (corner1: [number, number, number], corner2: [number, number, number]) => {
- const y = corner1[1] + PREVIEW_LINE_HEIGHT;
+ const updateOutline = (
+ corner1: [number, number, number],
+ corner2: [number, number, number],
+ ) => {
+ const y = corner1[1] + PREVIEW_LINE_HEIGHT
const points = [
new Vector3(corner1[0], y, corner1[2]),
new Vector3(corner2[0], y, corner1[2]),
new Vector3(corner2[0], y, corner2[2]),
new Vector3(corner1[0], y, corner2[2]),
new Vector3(corner1[0], y, corner1[2]), // Close the loop
- ];
- outlineRef.current.geometry.dispose();
- outlineRef.current.geometry = new BufferGeometry().setFromPoints(points);
- outlineRef.current.visible = true;
- };
+ ]
+ outlineRef.current.geometry.dispose()
+ outlineRef.current.geometry = new BufferGeometry().setFromPoints(points)
+ outlineRef.current.visible = true
+ }
const onGridMove = (event: GridEvent) => {
// Snap to 0.5 grid
- const gridX = Math.round(event.position[0] * 2) / 2;
- const gridZ = Math.round(event.position[2] * 2) / 2;
- const y = event.position[1];
+ const gridX = Math.round(event.position[0] * 2) / 2
+ const gridZ = Math.round(event.position[2] * 2) / 2
+ const y = event.position[1]
- const cursorPosition: [number, number, number] = [gridX, y, gridZ];
+ const cursorPosition: [number, number, number] = [gridX, y, gridZ]
// Play snap sound when grid position changes (only when placing)
- if (corner1Ref.current && previousGridPosRef.current &&
- (gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1])) {
- sfxEmitter.emit('sfx:grid-snap');
+ if (
+ corner1Ref.current &&
+ previousGridPosRef.current &&
+ (gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1])
+ ) {
+ sfxEmitter.emit('sfx:grid-snap')
}
- previousGridPosRef.current = [gridX, gridZ];
+ previousGridPosRef.current = [gridX, gridZ]
setPreview({
corner1: corner1Ref.current,
cursorPosition,
levelY: y,
- });
+ })
// Update outline if we have first corner
if (corner1Ref.current) {
- updateOutline(corner1Ref.current, cursorPosition);
+ updateOutline(corner1Ref.current, cursorPosition)
}
- };
+ }
const onGridClick = (event: GridEvent) => {
- if (!currentLevelId) return;
+ if (!currentLevelId) return
- const gridX = Math.round(event.position[0] * 2) / 2;
- const gridZ = Math.round(event.position[2] * 2) / 2;
- const y = event.position[1];
+ const gridX = Math.round(event.position[0] * 2) / 2
+ const gridZ = Math.round(event.position[2] * 2) / 2
+ const y = event.position[1]
if (!corner1Ref.current) {
// First click - set corner 1
- corner1Ref.current = [gridX, y, gridZ];
+ corner1Ref.current = [gridX, y, gridZ]
setPreview((prev) => ({
...prev,
corner1: corner1Ref.current,
- }));
+ }))
} else {
// Second click - create the roof
- const roofId = commitRoofPlacement(
- currentLevelId,
- corner1Ref.current,
- [gridX, y, gridZ]
- );
+ const roofId = commitRoofPlacement(currentLevelId, corner1Ref.current, [gridX, y, gridZ])
// Auto-select the newly created roof
- setSelection({ selectedIds: [roofId as AnyNode["id"]] });
+ setSelection({ selectedIds: [roofId as AnyNode['id']] })
// Reset state
- corner1Ref.current = null;
- outlineRef.current.visible = false;
+ corner1Ref.current = null
+ outlineRef.current.visible = false
// Switch to select mode and deactivate tool
- setMode('select');
- setTool(null);
+ setMode('select')
+ setTool(null)
}
- };
+ }
const onCancel = () => {
if (corner1Ref.current) {
- corner1Ref.current = null;
- outlineRef.current.visible = false;
- setPreview((prev) => ({ ...prev, corner1: null }));
+ corner1Ref.current = null
+ outlineRef.current.visible = false
+ setPreview((prev) => ({ ...prev, corner1: null }))
}
- };
+ }
// Subscribe to events
- emitter.on("grid:move", onGridMove);
- emitter.on("grid:click", onGridClick);
- emitter.on("tool:cancel", onCancel);
+ emitter.on('grid:move', onGridMove)
+ emitter.on('grid:click', onGridClick)
+ emitter.on('tool:cancel', onCancel)
return () => {
- emitter.off("grid:move", onGridMove);
- emitter.off("grid:click", onGridClick);
- emitter.off("tool:cancel", onCancel);
+ emitter.off('grid:move', onGridMove)
+ emitter.off('grid:click', onGridClick)
+ emitter.off('tool:cancel', onCancel)
// Reset state on unmount
- corner1Ref.current = null;
- };
- }, [currentLevelId, setTool, setSelection, setMode]);
+ corner1Ref.current = null
+ }
+ }, [currentLevelId, setTool, setSelection, setMode])
- const { corner1, cursorPosition, levelY } = preview;
+ const { corner1, cursorPosition, levelY } = preview
// Calculate preview dimensions for display
const previewDimensions = useMemo(() => {
- if (!corner1) return null;
- const length = Math.abs(cursorPosition[0] - corner1[0]);
- const width = Math.abs(cursorPosition[2] - corner1[2]);
- const centerX = (corner1[0] + cursorPosition[0]) / 2;
- const centerZ = (corner1[2] + cursorPosition[2]) / 2;
- return { length, width, centerX, centerZ };
- }, [corner1, cursorPosition]);
+ if (!corner1) return null
+ const length = Math.abs(cursorPosition[0] - corner1[0])
+ const width = Math.abs(cursorPosition[2] - corner1[2])
+ const centerX = (corner1[0] + cursorPosition[0]) / 2
+ const centerZ = (corner1[2] + cursorPosition[2]) / 2
+ return { length, width, centerX, centerZ }
+ }, [corner1, cursorPosition])
return (
@@ -192,34 +201,25 @@ export const RoofTool: React.FC = () => {
{/* @ts-ignore */}
-
+
{/* First corner marker */}
{corner1 && (
-
+
-
+
)}
{/* Cursor marker on ground */}
-
+
-
+
{/* Thin preview fill when drawing */}
@@ -240,5 +240,5 @@ export const RoofTool: React.FC = () => {
)}
- );
-};
+ )
+}
diff --git a/apps/editor/components/tools/shared/cursor-sphere.tsx b/apps/editor/components/tools/shared/cursor-sphere.tsx
new file mode 100644
index 00000000..c7578fd3
--- /dev/null
+++ b/apps/editor/components/tools/shared/cursor-sphere.tsx
@@ -0,0 +1,20 @@
+import type { ThreeElements } from '@react-three/fiber'
+import { forwardRef } from 'react'
+import type { Mesh } from 'three'
+
+interface CursorSphereProps extends Omit {
+ color?: string
+ depthWrite?: boolean
+}
+
+export const CursorSphere = forwardRef(function CursorSphere(
+ { color = '#f1c066', ...props },
+ ref,
+) {
+ return (
+
+
+
+
+ )
+})
diff --git a/apps/editor/components/tools/slab/slab-tool.tsx b/apps/editor/components/tools/slab/slab-tool.tsx
index facdc8de..7f88f54f 100644
--- a/apps/editor/components/tools/slab/slab-tool.tsx
+++ b/apps/editor/components/tools/slab/slab-tool.tsx
@@ -3,6 +3,7 @@ import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useRef, useState } from 'react'
import { BufferGeometry, DoubleSide, type Line, type Mesh, Shape, Vector3 } from 'three'
import { sfxEmitter } from '@/lib/sfx-bus'
+import { CursorSphere } from '../shared/cursor-sphere'
const Y_OFFSET = 0.02
@@ -236,10 +237,7 @@ export const SlabTool: React.FC = () => {
return (
{/* Cursor */}
-
-
-
-
+
{/* Preview fill */}
{previewShape && (
@@ -282,14 +280,7 @@ export const SlabTool: React.FC = () => {
{/* Point markers */}
{points.map(([x, z], index) => (
-
-
-
-
+
))}
)
diff --git a/apps/editor/components/tools/wall/wall-tool.tsx b/apps/editor/components/tools/wall/wall-tool.tsx
index fa51d65b..72755cb0 100644
--- a/apps/editor/components/tools/wall/wall-tool.tsx
+++ b/apps/editor/components/tools/wall/wall-tool.tsx
@@ -3,6 +3,7 @@ import { useViewer } from '@pascal-app/viewer'
import { useEffect, useRef } from 'react'
import { DoubleSide, type Mesh, Shape, ShapeGeometry, Vector3 } from 'three'
import { sfxEmitter } from '@/lib/sfx-bus'
+import { CursorSphere } from '../shared/cursor-sphere'
const WALL_HEIGHT = 2.5
const WALL_THICKNESS = 0.15
@@ -183,10 +184,7 @@ export const WallTool: React.FC = () => {
return (
{/* Cursor indicator */}
-
-
-
-
+
{/* Wall preview */}
diff --git a/apps/editor/components/tools/zone/zone-tool.tsx b/apps/editor/components/tools/zone/zone-tool.tsx
index c9af47b9..dfc5dae7 100644
--- a/apps/editor/components/tools/zone/zone-tool.tsx
+++ b/apps/editor/components/tools/zone/zone-tool.tsx
@@ -3,6 +3,7 @@ import { useViewer } from "@pascal-app/viewer";
import { useEffect, useMemo, useRef, useState } from "react";
import { BufferGeometry, DoubleSide, type Line, type Mesh, Shape, Vector3 } from "three";
import useEditor from "@/store/use-editor";
+import { CursorSphere } from "../shared/cursor-sphere";
// Zone colors for cycling through
const ZONE_COLORS = [
@@ -317,14 +318,7 @@ export const ZoneTool: React.FC = () => {
return (
{/* Cursor */}
-
-
-
-
+
{/* Preview fill */}
{previewShape && (
@@ -373,14 +367,7 @@ export const ZoneTool: React.FC = () => {
{/* Point markers */}
{points.map(([x, z], index) =>
isValidPoint([x, z]) ? (
-
-
-
-
+
) : null
)}
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 54581749..edd234be 100644
--- a/apps/editor/components/ui/sidebar/panels/site-panel/index.tsx
+++ b/apps/editor/components/ui/sidebar/panels/site-panel/index.tsx
@@ -1,4 +1,5 @@
import {
+ type AnyNodeId,
type BuildingNode,
emitter,
LevelNode,
@@ -225,20 +226,122 @@ function PropertyLineSection() {
// SITE PHASE VIEW - Property line + building buttons
// ============================================================================
+function CameraPopover({
+ nodeId,
+ hasCamera,
+ open,
+ onOpenChange,
+ buttonClassName,
+}: {
+ nodeId: AnyNodeId;
+ hasCamera: boolean;
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ buttonClassName?: string;
+}) {
+ const updateNode = useScene((state) => state.updateNode);
+ return (
+
+
+
+
+ e.stopPropagation()}
+ >
+
+ {hasCamera && (
+
+ )}
+
+ {hasCamera && (
+
+ )}
+
+
+
+ );
+}
+
function SitePhaseView() {
const nodes = useScene((state) => state.nodes);
const rootNodeIds = useScene((state) => state.rootNodeIds);
+ const updateNode = useScene((state) => state.updateNode);
const selectedBuildingId = useViewer((state) => state.selection.buildingId);
const setSelection = useViewer((state) => state.setSelection);
+ const [siteCameraOpen, setSiteCameraOpen] = useState(false);
+ const [buildingCameraOpen, setBuildingCameraOpen] = useState(null);
- // Get site node and its building children
const siteNode = rootNodeIds[0] ? nodes[rootNodeIds[0]] : null;
const buildings = (siteNode?.type === 'site' ? siteNode.children : [])
- .map((child) => typeof child === 'string' ? nodes[child] : child)
+ .map((child) => {
+ const id = typeof child === 'string' ? child : child.id;
+ return nodes[id] as BuildingNode | undefined;
+ })
.filter((node): node is BuildingNode => node?.type === "building");
return (
+ {/* Site row */}
+ {siteNode && (
+
+
+
+ {siteNode.name || "Site"}
+
+
+
+ )}
{buildings.length === 0 ? (
@@ -247,19 +350,91 @@ function SitePhaseView() {
) : (
{buildings.map((building) => (
-
+
+
setBuildingCameraOpen(open ? building.id : null)}
+ >
+
+
+
+ e.stopPropagation()}
+ >
+
+ {building.camera && (
+
+ )}
+
+ {building.camera && (
+
+ )}
+
+
+
+
))}
)}
@@ -280,7 +455,10 @@ function BuildingSelector() {
// Get site node and its building children
const siteNode = rootNodeIds[0] ? nodes[rootNodeIds[0]] : null;
const buildings = (siteNode?.type === 'site' ? siteNode.children : [])
- .map((child) => typeof child === 'string' ? nodes[child] : child)
+ .map((child) => {
+ const id = typeof child === 'string' ? child : child.id;
+ return nodes[id] as BuildingNode | undefined;
+ })
.filter((node): node is BuildingNode => node?.type === "building");
const selectedBuilding = selectedBuildingId
diff --git a/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts b/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts
index ebd5e6a9..945ced5c 100644
--- a/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts
+++ b/packages/core/src/hooks/spatial-grid/spatial-grid-manager.ts
@@ -201,6 +201,21 @@ export function wallOverlapsPolygon(
const nz = (dz / len) * step
if (pointInPolygon(start[0] + nx, start[1] + nz, polygon)) return true
if (pointInPolygon(end[0] - nx, end[1] - nz, polygon)) return true
+
+ // Also nudge perpendicular to the wall (into the slab interior) for walls that
+ // lie exactly on the slab boundary. The along-wall nudge keeps points on the
+ // boundary where pointInPolygon is unreliable; a perpendicular inward nudge
+ // moves the point clearly inside (or outside) the polygon.
+ // Sample the wall at 1/4, 1/2, 3/4 positions with a perpendicular nudge.
+ const PERP_STEP = 1e-4
+ const pnx = (-nz / step) * PERP_STEP // perpendicular left
+ const pnz = (nx / step) * PERP_STEP
+ for (const t of [0.25, 0.5, 0.75]) {
+ const bx = start[0] + dx * t
+ const bz = start[1] + dz * t
+ if (pointInPolygon(bx + pnx, bz + pnz, polygon)) return true
+ if (pointInPolygon(bx - pnx, bz - pnz, polygon)) return true
+ }
}
// Check if midpoint is inside (catches walls crossing through)
@@ -557,26 +572,42 @@ export class SpatialGridManager {
let maxElevation = -Infinity
for (const slab of slabMap.values()) {
if (slab.polygon.length < 3) continue
- if (wallOverlapsPolygon(start, end, slab.polygon)) {
- // Check if wall midpoint is in a hole (if so, ignore this slab)
+ if (!wallOverlapsPolygon(start, end, slab.polygon)) continue
+
+ const holes = slab.holes || []
+ if (holes.length === 0) {
+ // No holes: wall is on this slab
+ const elevation = slab.elevation ?? 0.05
+ if (elevation > maxElevation) maxElevation = elevation
+ continue
+ }
+
+ // Sample multiple points along the wall to check whether any portion lies on
+ // solid slab (not inside any hole). Checking only the midpoint fails when the
+ // midpoint falls in a staircase hole but the wall's endpoints are on solid slab.
+ const dx = end[0] - start[0]
+ const dz = end[1] - start[1]
+ let hasValidPoint = false
+ for (const t of [0, 0.25, 0.5, 0.75, 1]) {
+ const px = start[0] + dx * t
+ const pz = start[1] + dz * t
let inHole = false
- const midX = (start[0] + end[0]) / 2
- const midZ = (start[1] + end[1]) / 2
- const holes = slab.holes || []
for (const hole of holes) {
- if (hole.length >= 3 && pointInPolygon(midX, midZ, hole)) {
+ if (hole.length >= 3 && pointInPolygon(px, pz, hole)) {
inHole = true
break
}
}
-
if (!inHole) {
- const elevation = slab.elevation ?? 0.05
- if (elevation > maxElevation) {
- maxElevation = elevation
- }
+ hasValidPoint = true
+ break
}
}
+
+ if (hasValidPoint) {
+ const elevation = slab.elevation ?? 0.05
+ if (elevation > maxElevation) maxElevation = elevation
+ }
}
return maxElevation === -Infinity ? 0 : maxElevation
}
diff --git a/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts b/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts
index 9566159e..c6505eb3 100644
--- a/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts
+++ b/packages/core/src/hooks/spatial-grid/spatial-grid-sync.ts
@@ -85,7 +85,7 @@ export function initSpatialGridSync() {
}
}
} else if (node.type === 'slab' && prev.type === 'slab') {
- if (node.polygon !== prev.polygon || node.elevation !== prev.elevation) {
+ if (node.polygon !== prev.polygon || node.elevation !== prev.elevation || node.holes !== prev.holes) {
const levelId = resolveLevelId(node, state.nodes)
spatialGridManager.handleNodeUpdated(node, levelId)
diff --git a/packages/core/src/store/actions/node-actions.ts b/packages/core/src/store/actions/node-actions.ts
index 5ced050b..f42c24cf 100644
--- a/packages/core/src/store/actions/node-actions.ts
+++ b/packages/core/src/store/actions/node-actions.ts
@@ -152,6 +152,10 @@ export const deleteNodesAction = (
return { nodes: nextNodes, rootNodeIds: nextRootIds }
})
- // Notify systems that the parent has changed (e.g. Wall needs to fill a window hole)
- parentsToMarkDirty.forEach((pId) => get().markDirty(pId))
+
+ // Trigger a full scene re-validation after deleting node (as deleting a slab can cause widespread changes to level elevations)
+ const currentNodes = get().nodes
+ Object.values(currentNodes).forEach((node) => {
+ get().markDirty(node.id)
+ })
}
diff --git a/packages/core/src/store/use-scene.ts b/packages/core/src/store/use-scene.ts
index 12a2c859..7d8c0211 100644
--- a/packages/core/src/store/use-scene.ts
+++ b/packages/core/src/store/use-scene.ts
@@ -170,7 +170,6 @@ const useScene: UseSceneStore = create
()(
rootNodeIds: state.rootNodeIds,
}),
merge: (persistedState, currentState) => {
- console.log('merge calling...', persistedState, currentState)
const persisted = persistedState as Partial
// Backward compat: add default scale to item nodes saved before scale was added
if (persisted.nodes) {
diff --git a/packages/core/src/systems/item/item-system.tsx b/packages/core/src/systems/item/item-system.tsx
index b2876c29..dbfbf7e3 100644
--- a/packages/core/src/systems/item/item-system.tsx
+++ b/packages/core/src/systems/item/item-system.tsx
@@ -3,7 +3,7 @@ import type * as THREE from 'three'
import { sceneRegistry } from '../../hooks/scene-registry/scene-registry'
import { spatialGridManager } from '../../hooks/spatial-grid/spatial-grid-manager'
import { resolveLevelId } from '../../hooks/spatial-grid/spatial-grid-sync'
-import { getScaledDimensions, type AnyNodeId, type ItemNode, type WallNode } from '../../schema'
+import { type AnyNodeId, getScaledDimensions, type ItemNode, type WallNode } from '../../schema'
import useScene from '../../store/use-scene'
// ============================================================================
@@ -32,7 +32,7 @@ export const ItemSystem = () => {
if (parentWall && parentWall.type === 'wall') {
const wallThickness = (parentWall as WallNode).thickness ?? 0.1
const side = item.side === 'front' ? 1 : -1
- mesh.position.z = (wallThickness / 2) * side;
+ mesh.position.z = (wallThickness / 2) * side
}
} else if (!item.asset.attachTo) {
// If parented to another item (surface placement), R3F handles positioning via the hierarchy
@@ -51,8 +51,8 @@ export const ItemSystem = () => {
}
clearDirty(id as AnyNodeId)
- }, 2)
- })
+ })
+ }, 2)
return null
}
diff --git a/packages/viewer/src/components/renderers/ceiling/ceiling-renderer.tsx b/packages/viewer/src/components/renderers/ceiling/ceiling-renderer.tsx
index 7094a4d9..ee86c9a5 100644
--- a/packages/viewer/src/components/renderers/ceiling/ceiling-renderer.tsx
+++ b/packages/viewer/src/components/renderers/ceiling/ceiling-renderer.tsx
@@ -1,7 +1,7 @@
import { type CeilingNode, useRegistry } from '@pascal-app/core'
import { useRef } from 'react'
import { faceDirection, float, mix, positionWorld, smoothstep, step } from 'three/tsl'
-import { DoubleSide, type Mesh, MeshBasicNodeMaterial } from 'three/webgpu'
+import { type Mesh, MeshBasicNodeMaterial } from 'three/webgpu'
import { useNodeEvents } from '../../../hooks/use-node-events'
import { NodeRenderer } from '../node-renderer'
@@ -10,7 +10,6 @@ import { NodeRenderer } from '../node-renderer'
// - Front face (looking down at ceiling from above): 30% opacity
const ceilingMaterial = new MeshBasicNodeMaterial({
color: 0x999999,
- side: DoubleSide,
transparent: true,
depthWrite: false,
})
@@ -30,8 +29,8 @@ const lineY = smoothstep(lineWidth, 0, gridY).add(smoothstep(1.0 - lineWidth, 1.
// Combine: if either X or Y is a line, show the line
const gridPattern = lineX.max(lineY)
-// Grid lines at 0.8 opacity, spaces at 0.1 opacity
-const gridOpacity = mix(float(0.1), float(0.8), gridPattern)
+// Grid lines at 0.5 opacity, spaces at 0 opacity
+const gridOpacity = mix(float(0.0), float(0.5), gridPattern)
// faceDirection is 1.0 for front face, -1.0 for back face
// Front face (top, looking down): grid pattern, Back face (bottom, looking up): solid
diff --git a/packages/viewer/src/components/renderers/zone/zone-renderer.tsx b/packages/viewer/src/components/renderers/zone/zone-renderer.tsx
index 7fe2496a..22e3c128 100644
--- a/packages/viewer/src/components/renderers/zone/zone-renderer.tsx
+++ b/packages/viewer/src/components/renderers/zone/zone-renderer.tsx
@@ -177,7 +177,9 @@ export const ZoneRenderer = ({ node }: { node: ZoneNode }) => {
}
return (
-
+
void
}
-const useViewer = create()((set, get) => ({
- selection: { buildingId: null, levelId: null, zoneId: null, selectedIds: [] },
- hoveredId: null,
- setHoveredId: (id) => set({ hoveredId: id }),
+const useViewer = create()(
+ persist(
+ (set) => ({
+ selection: { buildingId: null, levelId: null, zoneId: null, selectedIds: [] },
+ hoveredId: null,
+ setHoveredId: (id) => set({ hoveredId: id }),
- cameraMode: "perspective",
- setCameraMode: (mode) => set({ cameraMode: mode }),
+ cameraMode: "perspective",
+ setCameraMode: (mode) => set({ cameraMode: mode }),
- levelMode: "stacked",
- setLevelMode: (mode) => set({ levelMode: mode }),
+ levelMode: "stacked",
+ setLevelMode: (mode) => set({ levelMode: mode }),
- wallMode: 'cutaway',
- setWallMode: (mode) => set({ wallMode: mode }),
+ wallMode: 'cutaway',
+ setWallMode: (mode) => set({ wallMode: mode }),
- showScans: true,
- setShowScans: (show) => set({ showScans: show }),
+ showScans: true,
+ setShowScans: (show) => set({ showScans: show }),
- showGuides: true,
- setShowGuides: (show) => set({ showGuides: show }),
+ showGuides: true,
+ setShowGuides: (show) => set({ showGuides: show }),
- setSelection: (updates) =>
- set((state) => {
- const newSelection = { ...state.selection, ...updates };
+ setSelection: (updates) =>
+ set((state) => {
+ const newSelection = { ...state.selection, ...updates };
- // Hierarchy Guard: If we change a high-level parent, reset the children
- if (updates.buildingId !== undefined) {
- newSelection.levelId = null;
- newSelection.zoneId = null;
- newSelection.selectedIds = [];
- } else if (updates.levelId !== undefined) {
- newSelection.zoneId = null;
- newSelection.selectedIds = [];
- } else if (updates.zoneId !== undefined) {
- newSelection.selectedIds = [];
- }
+ // Hierarchy Guard: If we change a high-level parent, reset the children
+ if (updates.buildingId !== undefined) {
+ newSelection.levelId = null;
+ newSelection.zoneId = null;
+ newSelection.selectedIds = [];
+ } else if (updates.levelId !== undefined) {
+ newSelection.zoneId = null;
+ newSelection.selectedIds = [];
+ } else if (updates.zoneId !== undefined) {
+ newSelection.selectedIds = [];
+ }
- return { selection: newSelection };
+ return { selection: newSelection };
+ }),
+
+ resetSelection: () =>
+ set({
+ selection: {
+ buildingId: null,
+ levelId: null,
+ zoneId: null,
+ selectedIds: [],
+ },
+ }),
+
+ outliner: { selectedObjects: [], hoveredObjects: [] },
+
+ exportScene: null,
+ setExportScene: (fn) => set({ exportScene: fn }),
+
+ cameraDragging: false,
+ setCameraDragging: (dragging) => set({ cameraDragging: dragging }),
}),
-
- resetSelection: () =>
- set({
- selection: {
- buildingId: null,
- levelId: null,
- zoneId: null,
- selectedIds: [],
- },
- }),
-
- outliner: { selectedObjects: [], hoveredObjects: [] },
-
- exportScene: null,
- setExportScene: (fn) => set({ exportScene: fn }),
-
- cameraDragging: false,
- setCameraDragging: (dragging) => set({ cameraDragging: dragging }),
-}));
+ {
+ name: 'viewer-preferences',
+ partialize: (state) => ({
+ cameraMode: state.cameraMode,
+ levelMode: state.levelMode,
+ wallMode: state.wallMode,
+ showScans: state.showScans,
+ showGuides: state.showGuides,
+ }),
+ },
+ ),
+);
export default useViewer;