) => {
+ if (!selectedId || !node) return
+ updateNode(selectedId as AnyNode['id'], updates)
+
+ // Mark parent wall as dirty if item is attached to wall
+ if (node.asset.attachTo === 'wall' && node.parentId) {
+ requestAnimationFrame(() => {
+ useScene.getState().dirtyNodes.add(node.parentId as AnyNode['id'])
+ })
+ }
+ },
+ [selectedId, node, updateNode],
+ )
+
+ const handleClose = useCallback(() => {
+ setSelection({ selectedIds: [] })
+ }, [setSelection])
+
+ const handleMove = useCallback(() => {
+ if (node) {
+ setMovingNode(node)
+ // Deselect so the panel closes
+ setSelection({ selectedIds: [] })
+ }
+ }, [node, setMovingNode, setSelection])
+
+ const handleDelete = useCallback(() => {
+ if (!selectedId) return
+ deleteNode(selectedId as AnyNode['id'])
+ setSelection({ selectedIds: [] })
+ }, [selectedId, deleteNode, setSelection])
+
+ // Only show if exactly one item is selected
+ if (!node || node.type !== 'item' || selectedIds.length !== 1) return null
+
+ return (
+
+ {/* Header */}
+
+
+
+
+ {node.name || node.asset.name}
+
+
+
+
+
+ {/* Content */}
+
+
+ {/* Position */}
+
+
+
+ {
+ handleUpdate({ position: [value, node.position[1], node.position[2]] })
+ }}
+ precision={2}
+ />
+ {
+ handleUpdate({ position: [node.position[0], value, node.position[2]] })
+ }}
+ precision={2}
+ />
+ {
+ handleUpdate({ position: [node.position[0], node.position[1], value] })
+ }}
+ precision={2}
+ />
+
+
+
+ {/* Rotation */}
+
+
+
+ {
+ const radians = (degrees * Math.PI) / 180
+ handleUpdate({ rotation: [node.rotation[0], radians, node.rotation[2]] })
+ }}
+ precision={0}
+ className="flex-1"
+ />
+ °
+
+
+
+
+
+
+
+ {/* Dimensions (read-only) */}
+
+
+
+ {node.asset.dimensions[0]}m × {node.asset.dimensions[1]}m × {node.asset.dimensions[2]}m
+
+
+
+
+
+ {/* Action Buttons */}
+
+
+
+
+
+
+
+ )
+}
diff --git a/apps/editor/components/ui/panels/panel-manager.tsx b/apps/editor/components/ui/panels/panel-manager.tsx
index 92d26f68..f5a6951a 100644
--- a/apps/editor/components/ui/panels/panel-manager.tsx
+++ b/apps/editor/components/ui/panels/panel-manager.tsx
@@ -3,6 +3,7 @@
import { AnyNodeId, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import useEditor from '@/store/use-editor'
+import { ItemPanel } from './item-panel'
import { ReferencePanel } from './reference-panel'
import { RoofPanel } from './roof-panel'
import { SlabPanel } from './slab-panel'
@@ -23,6 +24,8 @@ export function PanelManager() {
const node = nodes[selectedNode as AnyNodeId]
if (node) {
switch (node.type) {
+ case 'item':
+ return
case 'roof':
return
case 'slab':
diff --git a/apps/editor/components/ui/panels/reference-panel.tsx b/apps/editor/components/ui/panels/reference-panel.tsx
index 3956e0e7..5bbc644e 100644
--- a/apps/editor/components/ui/panels/reference-panel.tsx
+++ b/apps/editor/components/ui/panels/reference-panel.tsx
@@ -4,6 +4,7 @@ import { type AnyNode, type GuideNode, type ScanNode, useScene } from '@pascal-a
import { Box, Image, X } from 'lucide-react'
import { useCallback } from 'react'
import useEditor from '@/store/use-editor'
+import { NumberInput } from '@/components/ui/primitives/number-input'
type ReferenceNode = ScanNode | GuideNode
@@ -65,23 +66,17 @@ export function ReferencePanel() {
{([0, 1, 2] as const).map((i) => (
-
-
- {
- const value = Number.parseFloat(e.target.value)
- if (!Number.isNaN(value)) {
- const pos = [...node.position] as [number, number, number]
- pos[i] = value
- handleUpdate({ position: pos })
- }
- }}
- step="0.1"
- type="number"
- value={Math.round(node.position[i] * 100) / 100}
- />
-
+
{
+ const pos = [...node.position] as [number, number, number]
+ pos[i] = value
+ handleUpdate({ position: pos })
+ }}
+ precision={2}
+ />
))}
@@ -92,20 +87,17 @@ export function ReferencePanel() {
Rotation
- {
- const degrees = Number.parseFloat(e.target.value)
- if (!Number.isNaN(degrees)) {
- const radians = (degrees * Math.PI) / 180
- handleUpdate({
- rotation: [node.rotation[0], radians, node.rotation[2]],
- })
- }
- }}
- step="1"
- type="number"
+ {
+ const radians = (degrees * Math.PI) / 180
+ handleUpdate({
+ rotation: [node.rotation[0], radians, node.rotation[2]],
+ })
+ }}
+ precision={0}
+ className="min-w-0 flex-1"
/>
°
diff --git a/apps/editor/components/ui/panels/slab-panel.tsx b/apps/editor/components/ui/panels/slab-panel.tsx
index cd0c1f7e..1c126b77 100644
--- a/apps/editor/components/ui/panels/slab-panel.tsx
+++ b/apps/editor/components/ui/panels/slab-panel.tsx
@@ -5,6 +5,7 @@ import { useViewer } from '@pascal-app/viewer'
import { X } from 'lucide-react'
import Image from 'next/image'
import { useCallback } from 'react'
+import { NumberInput } from '@/components/ui/primitives/number-input'
export function SlabPanel() {
const selectedIds = useViewer((s) => s.selection.selectedIds)
@@ -76,17 +77,14 @@ export function SlabPanel() {
Elevation
- {
- const value = Number.parseFloat(e.target.value)
- if (!Number.isNaN(value)) {
- handleUpdate({ elevation: value })
- }
- }}
- step="0.05"
- type="number"
+ {
+ handleUpdate({ elevation: value })
+ }}
+ precision={3}
+ className="flex-1"
/>
m
diff --git a/apps/editor/components/ui/primitives/number-input.tsx b/apps/editor/components/ui/primitives/number-input.tsx
new file mode 100644
index 00000000..eb6659ef
--- /dev/null
+++ b/apps/editor/components/ui/primitives/number-input.tsx
@@ -0,0 +1,164 @@
+'use client'
+
+import { useScene } from '@pascal-app/core'
+import { useCallback, useRef, useState } from 'react'
+
+interface NumberInputProps {
+ label: string
+ value: number
+ onChange: (value: number) => void
+ min?: number
+ max?: number
+ precision?: number
+ className?: string
+}
+
+export function NumberInput({
+ label,
+ value,
+ onChange,
+ min,
+ max,
+ precision = 2,
+ className = '',
+}: NumberInputProps) {
+ const [isEditing, setIsEditing] = useState(false)
+ const [isDragging, setIsDragging] = useState(false)
+ const [inputValue, setInputValue] = useState(value.toFixed(precision))
+ const startXRef = useRef(0)
+ const startValueRef = useRef(0)
+ const labelRef = useRef(null)
+
+ const clamp = useCallback(
+ (val: number) => {
+ if (min !== undefined && val < min) return min
+ if (max !== undefined && val > max) return max
+ return val
+ },
+ [min, max],
+ )
+
+ const handleLabelMouseDown = useCallback(
+ (e: React.MouseEvent) => {
+ if (isEditing) return
+ e.preventDefault()
+ setIsDragging(true)
+ startXRef.current = e.clientX
+ startValueRef.current = value
+
+ // Pause history tracking during drag
+ useScene.temporal.getState().pause()
+
+ let finalValue = value
+
+ const handleMouseMove = (moveEvent: MouseEvent) => {
+ const deltaX = moveEvent.clientX - startXRef.current
+
+ // Determine step size based on modifier keys
+ let step = 0.1 // Default
+ if (moveEvent.shiftKey) {
+ step = 1.0 // Coarse
+ } else if (moveEvent.altKey) {
+ step = 0.01 // Fine
+ }
+
+ const deltaValue = deltaX * step
+ const newValue = clamp(startValueRef.current + deltaValue)
+ const newFinalValue = Number.parseFloat(newValue.toFixed(precision))
+
+ // Only call onChange if value actually changed (avoid extra processing on tiny moves)
+ if (newFinalValue !== finalValue) {
+ finalValue = newFinalValue
+ onChange(finalValue)
+ }
+ }
+
+ const handleMouseUp = () => {
+ setIsDragging(false)
+ document.removeEventListener('mousemove', handleMouseMove)
+ document.removeEventListener('mouseup', handleMouseUp)
+
+ // Reset to initial value while still paused (no history entry)
+ // Then resume and apply final value (creates single history entry)
+ if (finalValue !== startValueRef.current) {
+ onChange(startValueRef.current)
+ useScene.temporal.getState().resume()
+ onChange(finalValue)
+ } else {
+ useScene.temporal.getState().resume()
+ }
+ }
+
+ document.addEventListener('mousemove', handleMouseMove)
+ document.addEventListener('mouseup', handleMouseUp)
+ },
+ [isEditing, value, onChange, clamp, precision],
+ )
+
+ const handleValueClick = useCallback(() => {
+ setIsEditing(true)
+ setInputValue(value.toFixed(precision))
+ }, [value, precision])
+
+ const handleInputChange = useCallback((e: React.ChangeEvent) => {
+ setInputValue(e.target.value)
+ }, [])
+
+ const handleInputBlur = useCallback(() => {
+ const numValue = Number.parseFloat(inputValue)
+ if (!Number.isNaN(numValue)) {
+ onChange(clamp(Number.parseFloat(numValue.toFixed(precision))))
+ }
+ setIsEditing(false)
+ }, [inputValue, onChange, clamp, precision])
+
+ const handleInputKeyDown = useCallback(
+ (e: React.KeyboardEvent) => {
+ if (e.key === 'Enter') {
+ const numValue = Number.parseFloat(inputValue)
+ if (!Number.isNaN(numValue)) {
+ onChange(clamp(Number.parseFloat(numValue.toFixed(precision))))
+ }
+ setIsEditing(false)
+ } else if (e.key === 'Escape') {
+ setInputValue(value.toFixed(precision))
+ setIsEditing(false)
+ }
+ },
+ [inputValue, onChange, value, clamp, precision],
+ )
+
+ return (
+
+
+
+ {isEditing ? (
+
+ ) : (
+
+ {value.toFixed(precision)}
+
+ )}
+
+
+ )
+}
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 67aa1d74..54581749 100644
--- a/apps/editor/components/ui/sidebar/panels/site-panel/index.tsx
+++ b/apps/editor/components/ui/sidebar/panels/site-panel/index.tsx
@@ -345,6 +345,7 @@ function LevelsSection() {
const nodes = useScene((state) => state.nodes);
const createNode = useScene((state) => state.createNode);
const updateNode = useScene((state) => state.updateNode);
+ const deleteNode = useScene((state) => state.deleteNode);
const selectedBuildingId = useViewer((state) => state.selection.buildingId);
const selectedLevelId = useViewer((state) => state.selection.levelId);
const setSelection = useViewer((state) => state.setSelection);
@@ -493,6 +494,15 @@ function LevelsSection() {
>
References
+ {level.level !== 0 && (
+
+ )}
diff --git a/bun.lock b/bun.lock
index 93c676af..5ed59f8c 100644
--- a/bun.lock
+++ b/bun.lock
@@ -1,6 +1,5 @@
{
"lockfileVersion": 1,
- "configVersion": 0,
"workspaces": {
"": {
"name": "editor",
@@ -63,13 +62,14 @@
},
"packages/core": {
"name": "@pascal-app/core",
- "version": "0.1.10",
+ "version": "0.1.11",
"dependencies": {
"dedent": "^1.7.1",
"idb-keyval": "^6.2.2",
"mitt": "^3.0.1",
"nanoid": "^5.1.6",
"three-bvh-csg": "^0.0.17",
+ "three-mesh-bvh": "^0.9.8",
"zod": "^4.3.5",
"zundo": "^2.3.0",
"zustand": "^5",
@@ -127,7 +127,7 @@
},
"packages/viewer": {
"name": "@pascal-app/viewer",
- "version": "0.1.10",
+ "version": "0.1.11",
"dependencies": {
"zustand": "^5",
},
diff --git a/packages/core/package.json b/packages/core/package.json
index a7b98961..9adac83f 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -33,6 +33,7 @@
"mitt": "^3.0.1",
"nanoid": "^5.1.6",
"three-bvh-csg": "^0.0.17",
+ "three-mesh-bvh": "^0.9.8",
"zod": "^4.3.5",
"zundo": "^2.3.0",
"zustand": "^5"
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index d86a0ffd..4ee679cf 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -27,7 +27,7 @@ export {
resolveLevelId,
} from './hooks/spatial-grid/spatial-grid-sync'
export { useSpatialQuery } from './hooks/spatial-grid/use-spatial-query'
-export { pointInPolygon } from './hooks/spatial-grid/spatial-grid-manager'
+export { pointInPolygon, spatialGridManager } from './hooks/spatial-grid/spatial-grid-manager'
// Schema
export * from './schema'
export { default as useScene } from './store/use-scene'
diff --git a/packages/core/src/store/use-scene.ts b/packages/core/src/store/use-scene.ts
index 3708f460..d1db3812 100644
--- a/packages/core/src/store/use-scene.ts
+++ b/packages/core/src/store/use-scene.ts
@@ -215,13 +215,11 @@ useScene.temporal.subscribe((state) => {
const currentPastLength = state.pastStates.length
const currentFutureLength = state.futureStates.length
-
// Undo: futureStates increases (state moved from past to future)
// Redo: pastStates increases while futureStates decreases (state moved from future to past)
const didUndo = currentFutureLength > prevFutureLength
const didRedo = currentPastLength > prevPastLength && currentFutureLength < prevFutureLength
-
if (didUndo || didRedo) {
// Use RAF to ensure all middleware and store updates are complete
requestAnimationFrame(() => {
diff --git a/packages/core/src/systems/wall/wall-system.tsx b/packages/core/src/systems/wall/wall-system.tsx
index 8042b1da..133f3d1f 100644
--- a/packages/core/src/systems/wall/wall-system.tsx
+++ b/packages/core/src/systems/wall/wall-system.tsx
@@ -1,5 +1,6 @@
import { useFrame } from '@react-three/fiber'
import * as THREE from 'three'
+import { computeBoundsTree } from 'three-mesh-bvh'
import { Brush, Evaluator, SUBTRACTION } from 'three-bvh-csg'
import { sceneRegistry } from '../../hooks/scene-registry/scene-registry'
import { spatialGridManager } from '../../hooks/spatial-grid/spatial-grid-manager'
@@ -21,6 +22,7 @@ const csgEvaluator = new Evaluator()
// WALL SYSTEM
// ============================================================================
+let useFrameNb = 0;
export const WallSystem = () => {
const dirtyNodes = useScene((state) => state.dirtyNodes)
const clearDirty = useScene((state) => state.clearDirty)
@@ -33,7 +35,7 @@ export const WallSystem = () => {
// Collect dirty walls and their levels
const dirtyWallsByLevel = new Map>()
-
+ useFrameNb += 1;
dirtyNodes.forEach((id) => {
const node = nodes[id]
if (!node || node.type !== 'wall') return
@@ -106,6 +108,7 @@ function updateWallGeometry(wallId: string, miterData: WallMiterData) {
const node = nodes[wallId as WallNode['id']]
if (!node || node.type !== 'wall') return
+
const mesh = sceneRegistry.nodes.get(wallId) as THREE.Mesh
if (!mesh) return
@@ -258,6 +261,10 @@ export function generateExtrudedWall(
}
// Create wall brush from geometry
+ // Pre-compute BVH with new API to avoid deprecation warning
+ geometry.computeBoundsTree = computeBoundsTree
+ geometry.computeBoundsTree({ maxLeafSize: 10 })
+
const wallBrush = new Brush(geometry)
wallBrush.updateMatrixWorld()
@@ -348,6 +355,10 @@ function collectCutoutBrushes(
0, // Center on Z axis (wall thickness direction)
)
+ // Pre-compute BVH with new API to avoid deprecation warning
+ boxGeo.computeBoundsTree = computeBoundsTree
+ boxGeo.computeBoundsTree({ maxLeafSize: 10 })
+
const brush = new Brush(boxGeo)
brushes.push(brush)
}
diff --git a/packages/viewer/src/components/renderers/ceiling/ceiling-renderer.tsx b/packages/viewer/src/components/renderers/ceiling/ceiling-renderer.tsx
index 27636de2..283497f4 100644
--- a/packages/viewer/src/components/renderers/ceiling/ceiling-renderer.tsx
+++ b/packages/viewer/src/components/renderers/ceiling/ceiling-renderer.tsx
@@ -1,22 +1,41 @@
import { type CeilingNode, useRegistry } from '@pascal-app/core'
import { useRef } from 'react'
-import { faceDirection, float, mix } from 'three/tsl'
-import { DoubleSide, type Mesh, MeshStandardNodeMaterial } from 'three/webgpu'
+import { faceDirection, float, mix, positionWorld, smoothstep } from 'three/tsl'
+import { DoubleSide, type Mesh, MeshBasicNodeMaterial } from 'three/webgpu'
import { useNodeEvents } from '../../../hooks/use-node-events'
import { NodeRenderer } from '../node-renderer'
// TSL material that renders differently based on face direction:
// - Back face (looking up at ceiling from below): solid
// - Front face (looking down at ceiling from above): 30% opacity
-const ceilingMaterial = new MeshStandardNodeMaterial({
- color: 0xffffff,
+const ceilingMaterial = new MeshBasicNodeMaterial({
+ color: 0x999999,
side: DoubleSide,
transparent: true,
+ depthWrite: false,
})
+// Create grid pattern based on local position
+const gridScale = 5 // Grid cells per meter (1 = 1m grid)
+const gridX = positionWorld.x.mul(gridScale).fract()
+const gridY = positionWorld.z.mul(gridScale).fract()
+
+// Create grid lines - they are at 0 and 1
+const lineWidth = 0.05 // Width of grid lines (0-1 range within cell)
+
+// Create visible lines at edges (near 0 and near 1)
+const lineX = smoothstep(lineWidth, 0, gridX).add(smoothstep(1.0 - lineWidth, 1.0, gridX))
+const lineY = smoothstep(lineWidth, 0, gridY).add(smoothstep(1.0 - lineWidth, 1.0, gridY))
+
+// 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)
+
// faceDirection is 1.0 for front face, -1.0 for back face
-// We want: front face (top, looking down) = 0.3 opacity, back face (bottom, looking up) = 1.0 opacity
-ceilingMaterial.opacityNode = mix(float(1.0), float(0.3), faceDirection.greaterThan(0.0))
+// Front face (top, looking down): grid pattern, Back face (bottom, looking up): solid
+ceilingMaterial.opacityNode = mix(float(1.0), gridOpacity, faceDirection.greaterThan(0.0))
export const CeilingRenderer = ({ node }: { node: CeilingNode }) => {
const ref = useRef(null!)
diff --git a/packages/viewer/src/components/renderers/item/item-renderer.tsx b/packages/viewer/src/components/renderers/item/item-renderer.tsx
index 34bc1d9f..c8d94dc5 100644
--- a/packages/viewer/src/components/renderers/item/item-renderer.tsx
+++ b/packages/viewer/src/components/renderers/item/item-renderer.tsx
@@ -3,9 +3,10 @@ import { Clone } from '@react-three/drei/core/Clone'
import { useGLTF } from '@react-three/drei/core/Gltf'
import { Suspense, useEffect, useMemo, useRef } from 'react'
import type { Group, Material, Mesh } from 'three'
+import { positionLocal, smoothstep, time } from 'three/tsl'
import { DoubleSide, MeshStandardNodeMaterial } from 'three/webgpu'
-import { resolveCdnUrl } from '../../../lib/asset-url'
import { useNodeEvents } from '../../../hooks/use-node-events'
+import { resolveCdnUrl } from '../../../lib/asset-url'
// Shared materials to avoid creating new instances for every mesh
const defaultMaterial = new MeshStandardNodeMaterial({
@@ -39,13 +40,35 @@ export const ItemRenderer = ({ node }: { node: ItemNode }) => {
return (
-
+ }>
)
}
+const previewMaterial = new MeshStandardNodeMaterial({
+ color: '#cccccc',
+ roughness: 1,
+ metalness: 0,
+ depthTest: false,
+})
+
+const previewOpacity = smoothstep(0.42, 0.55, positionLocal.y.add(time.mul(-0.2)).mul(10).fract())
+
+previewMaterial.opacityNode = previewOpacity
+previewMaterial.transparent = true
+
+const PreviewModel = ({ node }: { node: ItemNode }) => {
+ return (
+
+
+
+ )
+}
+
const ModelRenderer = ({ node }: { node: ItemNode }) => {
const { scene, nodes } = useGLTF(resolveCdnUrl(node.asset.src) || '')
diff --git a/packages/viewer/src/components/renderers/site/site-renderer.tsx b/packages/viewer/src/components/renderers/site/site-renderer.tsx
index ce50a4bc..dd872404 100644
--- a/packages/viewer/src/components/renderers/site/site-renderer.tsx
+++ b/packages/viewer/src/components/renderers/site/site-renderer.tsx
@@ -121,6 +121,7 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => {
position={[edge.midX, 0.5, edge.midZ]}
style={{ pointerEvents: 'none', userSelect: 'none' }}
zIndexRange={[10, 0]}
+ occlude
>
{edge.dist.toFixed(2)}m