From 6ec6a55b050728aafdac806bf637d9bb551eb75b Mon Sep 17 00:00:00 2001 From: wass08 Date: Tue, 10 Feb 2026 10:04:14 +0900 Subject: [PATCH 1/9] fix elevation issue --- apps/editor/components/editor/index.tsx | 2 +- .../tools/item/use-placement-coordinator.tsx | 79 +++++++++++-------- packages/core/src/index.ts | 2 +- 3 files changed, 46 insertions(+), 37 deletions(-) diff --git a/apps/editor/components/editor/index.tsx b/apps/editor/components/editor/index.tsx index 4e7da320..2a2b0bc7 100644 --- a/apps/editor/components/editor/index.tsx +++ b/apps/editor/components/editor/index.tsx @@ -28,7 +28,7 @@ export default function Editor() { - + diff --git a/apps/editor/components/tools/item/use-placement-coordinator.tsx b/apps/editor/components/tools/item/use-placement-coordinator.tsx index f1c12000..892ef50d 100644 --- a/apps/editor/components/tools/item/use-placement-coordinator.tsx +++ b/apps/editor/components/tools/item/use-placement-coordinator.tsx @@ -1,9 +1,12 @@ +import type { AssetInput } from '@pascal-app/core' import { type AnyNodeId, type CeilingEvent, emitter, type GridEvent, + resolveLevelId, sceneRegistry, + spatialGridManager, useScene, useSpatialQuery, type WallEvent, @@ -12,18 +15,17 @@ import { import { useViewer } from '@pascal-app/viewer' import { useFrame } from '@react-three/fiber' import { useEffect, useRef } from 'react' -import { BoxGeometry, Euler, type Mesh, type MeshStandardMaterial, Quaternion, Vector3 } from 'three' -import { spatialGridManager } from '../../../../../packages/core/src/hooks/spatial-grid/spatial-grid-manager' -import { resolveLevelId } from '../../../../../packages/core/src/hooks/spatial-grid/spatial-grid-sync' import { - ceilingStrategy, - checkCanPlace, - floorStrategy, - wallStrategy, -} from './placement-strategies' + BoxGeometry, + Euler, + type Mesh, + type MeshStandardMaterial, + Quaternion, + Vector3, +} from 'three' +import { ceilingStrategy, checkCanPlace, floorStrategy, wallStrategy } from './placement-strategies' import type { PlacementState, TransitionResult } from './placement-types' import type { DraftNodeHandle } from './use-draft-node' -import type { AssetInput } from '@pascal-app/core' const DEFAULT_DIMENSIONS: [number, number, number] = [1, 1, 1] @@ -43,6 +45,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea config.initialState ?? { surface: 'floor', wallId: null, ceilingId: null }, ) + // Store config callbacks in refs to avoid re-running effect when they change + const configRef = useRef(config) + configRef.current = config + const { canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling } = useSpatialQuery() const { asset, draftNode } = config @@ -52,7 +58,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const validators = { canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling } // Reset placement state - placementState.current = config.initialState ?? { + placementState.current = configRef.current.initialState ?? { surface: 'floor', wallId: null, ceilingId: null, @@ -70,9 +76,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const revalidate = (): boolean => { const placeable = checkCanPlace(getContext(), validators) - ;(cursorRef.current.material as MeshStandardMaterial).color.set( - placeable ? 'green' : 'red', - ) + ;(cursorRef.current.material as MeshStandardMaterial).color.set(placeable ? 'green' : 'red') return placeable } @@ -110,7 +114,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea } // ---- Init draft ---- - config.initDraft(gridPosition.current) + configRef.current.initDraft(gridPosition.current) // Sync cursor to the draft mesh's world position and rotation if (draftNode.current) { @@ -136,7 +140,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea if (!result) return gridPosition.current.set(...result.gridPosition) - cursorRef.current.position.set(...result.cursorPosition) + // Only update X and Z for cursor - useFrame will handle Y (slab elevation) + cursorRef.current.position.x = result.cursorPosition[0] + cursorRef.current.position.z = result.cursorPosition[2] const draft = draftNode.current if (draft) draft.position = result.gridPosition @@ -152,7 +158,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const currentRotation: [number, number, number] = [0, cursorRef.current.rotation.y, 0] draftNode.commit(result.nodeUpdate) - if (config.onCommitted()) { + if (configRef.current.onCommitted()) { draftNode.create(gridPosition.current, asset, currentRotation) revalidate() } @@ -265,7 +271,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea useScene.getState().dirtyNodes.add(result.dirtyNodeId) } - if (config.onCommitted()) { + if (configRef.current.onCommitted()) { const nodes = useScene.getState().nodes const enterResult = wallStrategy.enter(getContext(), event, resolveLevelId, nodes) if (enterResult) { @@ -289,7 +295,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea applyTransition(result) const draft = draftNode.current if (draft) { - useScene.getState().updateNode(draft.id, { parentId: result.nodeUpdate.parentId as string }) + useScene + .getState() + .updateNode(draft.id, { parentId: result.nodeUpdate.parentId as string }) } if (oldWallId) { useScene.getState().dirtyNodes.add(oldWallId as AnyNodeId) @@ -360,7 +368,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea event.stopPropagation() draftNode.commit(result.nodeUpdate) - if (config.onCommitted()) { + if (configRef.current.onCommitted()) { const nodes = useScene.getState().nodes const enterResult = ceilingStrategy.enter(getContext(), event, resolveLevelId, nodes) if (enterResult) { @@ -384,7 +392,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea applyTransition(result) const draft = draftNode.current if (draft) { - useScene.getState().updateNode(draft.id, { parentId: result.nodeUpdate.parentId as string }) + useScene + .getState() + .updateNode(draft.id, { parentId: result.nodeUpdate.parentId as string }) } if (oldCeilingId) { useScene.getState().dirtyNodes.add(oldCeilingId as AnyNodeId) @@ -404,9 +414,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const ROTATION_STEP = Math.PI / 2 const onKeyDown = (event: KeyboardEvent) => { // Escape / right-click → cancel - if (event.key === 'Escape' && config.onCancel) { + if (event.key === 'Escape' && configRef.current.onCancel) { event.preventDefault() - config.onCancel() + configRef.current.onCancel() return } @@ -434,9 +444,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea // ---- Right-click cancel ---- const onContextMenu = (event: MouseEvent) => { - if (config.onCancel) { + if (configRef.current.onCancel) { event.preventDefault() - config.onCancel() + configRef.current.onCancel() } } window.addEventListener('contextmenu', onContextMenu) @@ -501,17 +511,16 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea // Adjust Y for slab elevation (floor items on top of slabs) if (!asset.attachTo) { - const levelId = useViewer.getState().selection.levelId - if (levelId) { - 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 - } + const nodes = useScene.getState().nodes + const levelId = resolveLevelId(draftNode.current, nodes) + 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/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' From daf41f7da5cde42988c5ab645e0a75992e7f7229 Mon Sep 17 00:00:00 2001 From: wass08 Date: Tue, 10 Feb 2026 10:45:42 +0900 Subject: [PATCH 2/9] figma like tools --- .../components/editor/selection-manager.tsx | 10 - .../components/ui/panels/item-panel.tsx | 196 ++++++++++++++++++ .../components/ui/panels/panel-manager.tsx | 3 + .../components/ui/panels/reference-panel.tsx | 68 +++--- .../components/ui/panels/slab-panel.tsx | 18 +- .../components/ui/primitives/number-input.tsx | 153 ++++++++++++++ 6 files changed, 389 insertions(+), 59 deletions(-) create mode 100644 apps/editor/components/ui/panels/item-panel.tsx create mode 100644 apps/editor/components/ui/primitives/number-input.tsx diff --git a/apps/editor/components/editor/selection-manager.tsx b/apps/editor/components/editor/selection-manager.tsx index cd272ded..acd9f0cb 100644 --- a/apps/editor/components/editor/selection-manager.tsx +++ b/apps/editor/components/editor/selection-manager.tsx @@ -46,11 +46,6 @@ const SELECTION_STRATEGIES: Record = { structure: { types: ["wall", "item", "zone", "slab", "ceiling", "roof"], handleSelect: (node, isShift) => { - // Single click on item (door/window) → enter move mode - if (!isShift && node.type === 'item') { - useEditor.getState().setMovingNode(node as ItemNode); - return; - } const { selection, setSelection } = useViewer.getState(); if (node.type === 'zone') { setSelection({ zoneId: node.id }); @@ -93,11 +88,6 @@ const SELECTION_STRATEGIES: Record = { furnish: { types: ["item"], handleSelect: (node, isShift) => { - // Single click on item → enter move mode - if (!isShift && node.type === 'item') { - useEditor.getState().setMovingNode(node as ItemNode); - return; - } const { selection, setSelection } = useViewer.getState(); const nextIds = isShift ? selection.selectedIds.includes(node.id) diff --git a/apps/editor/components/ui/panels/item-panel.tsx b/apps/editor/components/ui/panels/item-panel.tsx new file mode 100644 index 00000000..50f9165a --- /dev/null +++ b/apps/editor/components/ui/panels/item-panel.tsx @@ -0,0 +1,196 @@ +'use client' + +import { type AnyNode, type ItemNode, useScene } from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import { Move, Trash2, X } from 'lucide-react' +import Image from 'next/image' +import { useCallback } from 'react' +import useEditor from '@/store/use-editor' +import { NumberInput } from '@/components/ui/primitives/number-input' + +export function ItemPanel() { + 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) + const deleteNode = useScene((s) => s.deleteNode) + const setMovingNode = useEditor((s) => s.setMovingNode) + + // Get the first selected node if it's an item + const selectedId = selectedIds[0] + const node = selectedId + ? (nodes[selectedId as AnyNode['id']] as ItemNode | undefined) + : undefined + + const handleUpdate = useCallback( + (updates: Partial) => { + if (!selectedId) return + updateNode(selectedId as AnyNode['id'], updates) + }, + [selectedId, 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..89db024d --- /dev/null +++ b/apps/editor/components/ui/primitives/number-input.tsx @@ -0,0 +1,153 @@ +'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) + finalValue = Number.parseFloat(newValue.toFixed(precision)) + onChange(finalValue) + } + + const handleMouseUp = () => { + setIsDragging(false) + document.removeEventListener('mousemove', handleMouseMove) + document.removeEventListener('mouseup', handleMouseUp) + + // Resume history tracking and commit final value + useScene.temporal.getState().resume() + onChange(finalValue) + } + + 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)} +
+ )} +
+
+ ) +} From 4dd85993cb69752ab85c16622277369537f2cdab Mon Sep 17 00:00:00 2001 From: wass08 Date: Tue, 10 Feb 2026 11:00:11 +0900 Subject: [PATCH 3/9] fix issue undo history number input --- .../editor/components/ui/primitives/number-input.tsx | 12 +++++++++--- packages/core/src/store/use-scene.ts | 2 -- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/apps/editor/components/ui/primitives/number-input.tsx b/apps/editor/components/ui/primitives/number-input.tsx index 89db024d..d2c6a30b 100644 --- a/apps/editor/components/ui/primitives/number-input.tsx +++ b/apps/editor/components/ui/primitives/number-input.tsx @@ -73,9 +73,15 @@ export function NumberInput({ document.removeEventListener('mousemove', handleMouseMove) document.removeEventListener('mouseup', handleMouseUp) - // Resume history tracking and commit final value - useScene.temporal.getState().resume() - onChange(finalValue) + // 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) 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(() => { From 8ded112da336a0d3977066ec0708ac9e80286b9d Mon Sep 17 00:00:00 2001 From: wass08 Date: Tue, 10 Feb 2026 13:28:47 +0900 Subject: [PATCH 4/9] fix item panel --- apps/editor/components/ui/panels/item-panel.tsx | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/apps/editor/components/ui/panels/item-panel.tsx b/apps/editor/components/ui/panels/item-panel.tsx index 50f9165a..f9c086be 100644 --- a/apps/editor/components/ui/panels/item-panel.tsx +++ b/apps/editor/components/ui/panels/item-panel.tsx @@ -24,10 +24,15 @@ export function ItemPanel() { const handleUpdate = useCallback( (updates: Partial) => { - if (!selectedId) return + 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) { + useScene.getState().dirtyNodes.add(node.parentId as AnyNode['id']) + } }, - [selectedId, updateNode], + [selectedId, node, updateNode], ) const handleClose = useCallback(() => { @@ -175,18 +180,18 @@ export function ItemPanel() {
From 01de83f628b91f0ce5900085a9ebfd6ba6b62c64 Mon Sep 17 00:00:00 2001 From: wass08 Date: Tue, 10 Feb 2026 13:40:49 +0900 Subject: [PATCH 5/9] loading asset handler --- .../renderers/item/item-renderer.tsx | 27 +++++++++++++++++-- .../renderers/site/site-renderer.tsx | 1 + 2 files changed, 26 insertions(+), 2 deletions(-) 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 From 80cb039f86d98f10b4641b6dfebd19604deabf81 Mon Sep 17 00:00:00 2001 From: wass08 Date: Tue, 10 Feb 2026 13:48:58 +0900 Subject: [PATCH 6/9] fix elevation for item creation --- apps/editor/components/tools/item/use-draft-node.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/editor/components/tools/item/use-draft-node.ts b/apps/editor/components/tools/item/use-draft-node.ts index ff9aeed3..d6e8ae21 100644 --- a/apps/editor/components/tools/item/use-draft-node.ts +++ b/apps/editor/components/tools/item/use-draft-node.ts @@ -50,6 +50,7 @@ export function useDraftNode(): DraftNodeHandle { rotation: rotation ?? [0, 0, 0], name: asset.name, asset, + parentId: currentLevelId, metadata: { isTransient: true }, }) From b4375792ab9d157d9636d77defd8d0b1ac8f6432 Mon Sep 17 00:00:00 2001 From: wass08 Date: Tue, 10 Feb 2026 14:07:56 +0900 Subject: [PATCH 7/9] fix extra rebuild --- apps/editor/components/editor/index.tsx | 2 +- apps/editor/components/ui/panels/item-panel.tsx | 4 +++- apps/editor/components/ui/primitives/number-input.tsx | 9 +++++++-- .../components/ui/sidebar/panels/site-panel/index.tsx | 10 ++++++++++ bun.lock | 6 +++--- packages/core/package.json | 1 + packages/core/src/systems/wall/wall-system.tsx | 4 +++- 7 files changed, 28 insertions(+), 8 deletions(-) diff --git a/apps/editor/components/editor/index.tsx b/apps/editor/components/editor/index.tsx index 2a2b0bc7..ce675101 100644 --- a/apps/editor/components/editor/index.tsx +++ b/apps/editor/components/editor/index.tsx @@ -28,7 +28,7 @@ export default function Editor() { - + diff --git a/apps/editor/components/ui/panels/item-panel.tsx b/apps/editor/components/ui/panels/item-panel.tsx index f9c086be..bed39a58 100644 --- a/apps/editor/components/ui/panels/item-panel.tsx +++ b/apps/editor/components/ui/panels/item-panel.tsx @@ -29,7 +29,9 @@ export function ItemPanel() { // Mark parent wall as dirty if item is attached to wall if (node.asset.attachTo === 'wall' && node.parentId) { - useScene.getState().dirtyNodes.add(node.parentId as AnyNode['id']) + requestAnimationFrame(() => { + useScene.getState().dirtyNodes.add(node.parentId as AnyNode['id']) + }) } }, [selectedId, node, updateNode], diff --git a/apps/editor/components/ui/primitives/number-input.tsx b/apps/editor/components/ui/primitives/number-input.tsx index d2c6a30b..eb6659ef 100644 --- a/apps/editor/components/ui/primitives/number-input.tsx +++ b/apps/editor/components/ui/primitives/number-input.tsx @@ -64,8 +64,13 @@ export function NumberInput({ const deltaValue = deltaX * step const newValue = clamp(startValueRef.current + deltaValue) - finalValue = Number.parseFloat(newValue.toFixed(precision)) - onChange(finalValue) + 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 = () => { 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/systems/wall/wall-system.tsx b/packages/core/src/systems/wall/wall-system.tsx index 8042b1da..1b7394be 100644 --- a/packages/core/src/systems/wall/wall-system.tsx +++ b/packages/core/src/systems/wall/wall-system.tsx @@ -21,6 +21,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 +34,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 +107,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 From 1467325475fc348b96a67cf97876049718699b40 Mon Sep 17 00:00:00 2001 From: wass08 Date: Tue, 10 Feb 2026 14:20:40 +0900 Subject: [PATCH 8/9] reduce warning csg lib --- packages/core/src/systems/wall/wall-system.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/core/src/systems/wall/wall-system.tsx b/packages/core/src/systems/wall/wall-system.tsx index 1b7394be..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' @@ -260,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() @@ -350,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) } From 251f73250bc4f9c18aa4c1a5f5f1f798e8cbbebe Mon Sep 17 00:00:00 2001 From: wass08 Date: Tue, 10 Feb 2026 14:42:29 +0900 Subject: [PATCH 9/9] polish ceiling --- .../renderers/ceiling/ceiling-renderer.tsx | 31 +++++++++++++++---- 1 file changed, 25 insertions(+), 6 deletions(-) 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!)