From 47dda15096be818460db44a39bfab5b1114245b8 Mon Sep 17 00:00:00 2001 From: wass08 Date: Fri, 24 Oct 2025 13:32:09 +0900 Subject: [PATCH 1/6] camera & grid adjustments --- components/editor/custom-controls.tsx | 2 +- components/editor/index.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/components/editor/custom-controls.tsx b/components/editor/custom-controls.tsx index 4230d364e..502eeedab 100644 --- a/components/editor/custom-controls.tsx +++ b/components/editor/custom-controls.tsx @@ -25,7 +25,7 @@ export function CustomControls() { // In select mode, left-click can pan the camera if (controlMode === 'select') { return { - left: CameraControlsImpl.ACTION.TRUCK, + left: CameraControlsImpl.ACTION.SCREEN_PAN, // Similar to the sims middle: CameraControlsImpl.ACTION.SCREEN_PAN, right: CameraControlsImpl.ACTION.ROTATE, wheel: CameraControlsImpl.ACTION.DOLLY, diff --git a/components/editor/index.tsx b/components/editor/index.tsx index 6421c642f..cb26a1de5 100644 --- a/components/editor/index.tsx +++ b/components/editor/index.tsx @@ -748,7 +748,7 @@ export default function Editor({ className }: { className?: string }) { cellSize={tileSize} cellThickness={0.5} cellColor="#aaaabf" - sectionSize={tileSize * 5} + sectionSize={tileSize * 2} sectionThickness={1} sectionColor="#9d4b4b" fadeDistance={GRID_SIZE * 2} From f254c8b47764c5227becff882d9e0d188992d8c2 Mon Sep 17 00:00:00 2001 From: wass08 Date: Fri, 24 Oct 2025 14:17:17 +0900 Subject: [PATCH 2/6] levels notion --- hooks/use-editor.tsx | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/hooks/use-editor.tsx b/hooks/use-editor.tsx index 44ad59634..f002fd058 100644 --- a/hooks/use-editor.tsx +++ b/hooks/use-editor.tsx @@ -65,6 +65,8 @@ type HistoryState = { type StoreState = { walls: string[] images: ReferenceImage[] + groups: ComponentGroup[] + selectedFloorId: string | null selectedWallIds: string[] selectedImageIds: string[] isHelpOpen: boolean @@ -79,6 +81,9 @@ type StoreState = { handleClear: () => void } & { setWalls: (walls: string[]) => void + addGroup: (group: ComponentGroup) => void + deleteGroup: (groupId: string) => void + selectFloor: (floorId: string | null) => void setImages: (images: ReferenceImage[], pushToUndo?: boolean) => void setSelectedWallIds: (ids: string[]) => void setSelectedImageIds: (ids: string[]) => void @@ -110,6 +115,16 @@ const useStore = create()( (set, get) => ({ walls: [], images: [], + groups: [{ + id: 'ground-floor', + name: 'Ground Floor', + type: 'floor', + color: '#ffffff' + }], + addGroup: (group) => set(state => ({ groups: [...state.groups, group] })), + deleteGroup: (groupId) => set(state => ({ groups: state.groups.filter(group => group.id !== groupId) })), + selectedFloorId: null, + selectFloor: (floorId) => set({ selectedFloorId: floorId }), selectedWallIds: [], selectedImageIds: [], isHelpOpen: false, From 866058f4edd87eab5255165cc15937f97fb0be5b Mon Sep 17 00:00:00 2001 From: wass08 Date: Fri, 24 Oct 2025 15:16:12 +0900 Subject: [PATCH 3/6] floor animation navigation --- components/app-sidebar.tsx | 401 +++++++++++++++++--------- components/editor/custom-controls.tsx | 9 + components/editor/index.tsx | 9 +- hooks/use-editor.tsx | 161 +++++++++-- 4 files changed, 405 insertions(+), 175 deletions(-) diff --git a/components/app-sidebar.tsx b/components/app-sidebar.tsx index 98ffddc51..b97063adc 100644 --- a/components/app-sidebar.tsx +++ b/components/app-sidebar.tsx @@ -1,17 +1,6 @@ "use client" -import { useEffect, useState } from "react" -import { - Sidebar, - SidebarContent, - SidebarHeader, - SidebarMenu, - SidebarMenuButton, - SidebarMenuItem, -} from "@/components/ui/sidebar" -import { Home, Settings, Upload, Download, HelpCircle, Trash2, Save, FolderOpen, FileCode, ChevronDown, ChevronRight } from "lucide-react" import { Button } from "@/components/ui/button" -import { Input } from "@/components/ui/input" import { Dialog, DialogContent, @@ -20,30 +9,46 @@ import { DialogTitle, DialogTrigger, } from "@/components/ui/dialog" +import { Input } from "@/components/ui/input" +import { + Sidebar, + SidebarContent, + SidebarHeader, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, +} from "@/components/ui/sidebar" import type { WallSegment } from "@/hooks/use-editor" import { useEditorContext } from "@/hooks/use-editor" import JsonView from '@uiw/react-json-view' +import { ArrowLeft, ChevronDown, ChevronRight, Download, FileCode, HelpCircle, Layers, Plus, Save, Trash2 } from "lucide-react" +import { useEffect, useState } from "react" export function AppSidebar() { - const { - isHelpOpen, - setIsHelpOpen, - isJsonInspectorOpen, - setIsJsonInspectorOpen, - handleExport, - handleUpload, - wallSegments, - selectedWallIds, - setSelectedWallIds, - handleDeleteSelectedWalls, - handleSaveLayout, - handleLoadLayout, - serializeLayout, + const { + isHelpOpen, + setIsHelpOpen, + isJsonInspectorOpen, + setIsJsonInspectorOpen, + handleExport, + handleUpload, + wallSegments, + selectedWallIds, + setSelectedWallIds, + handleDeleteSelectedWalls, + handleSaveLayout, + handleLoadLayout, + serializeLayout, handleClear, images, selectedImageIds, setSelectedImageIds, handleDeleteSelectedImages, + groups, + selectedFloorId, + selectFloor, + addGroup, + deleteGroup, } = useEditorContext() const [jsonCollapsed, setJsonCollapsed] = useState(1) const [mounted, setMounted] = useState(false) @@ -151,146 +156,256 @@ export function AppSidebar() { const formatWallDescription = (segment: WallSegment) => { const [x1, y1] = segment.start const [x2, y2] = segment.end - + const dx = Math.abs(x2 - x1) const dy = Math.abs(y2 - y1) const length = Math.sqrt(dx * dx + dy * dy) * 0.5 // 0.5m per grid spacing - + const orientation = segment.isHorizontal ? 'Horizontal' : 'Vertical' const position = `(${x1},${y1}) → (${x2},${y2})` - + return `${orientation} wall: ${length.toFixed(2)}m ${position}` } + const handleAddFloor = () => { + // Get all existing floor numbers + const floorNumbers = groups + .filter(g => g.type === 'floor') + .map(g => { + const match = g.name.match(/(\d+)f$/i) + return match ? parseInt(match[1]) : 0 + }) + .filter(n => n > 0) + + // Find the next available number (starting from 2) + let nextNumber = 2 + while (floorNumbers.includes(nextNumber)) { + nextNumber++ + } + + const newFloor = { + id: `floor-${nextNumber}`, + name: `${nextNumber}f`, + type: 'floor' as const, + color: '#ffffff', + level: nextNumber, + } + + addGroup(newFloor) + } + + const selectedFloor = selectedFloorId ? groups.find(g => g.id === selectedFloorId) : null + return ( -

Pascal Editor

+ {selectedFloor ? ( +
+ +

+ + {selectedFloor.name} +

+
+ ) : ( +

Pascal Editor

+ )}
- {/* Editor Controls */} - -
- - { const file = e.target.files?.[0]; if (file) handleUpload(file); }} - className="w-full text-xs" - /> -
-
+ {/* Floor List or Wall Segments based on selection */} + {!selectedFloor ? ( + /* Floor List */ + +
+
+ + +
+
+ {!mounted ? ( +
+ Loading... +
+ ) : ( + groups + .filter(g => g.type === 'floor') + .map((floor) => ( +
+
selectFloor(floor.id)} + > +
+ + {floor.name} +
+
+ {floor.id !== 'ground-floor' && ( + + )} +
+ )) + )} +
+
+
+ ) : ( + <> + {/* Reference Image Upload */} + +
+ + { const file = e.target.files?.[0]; if (file) handleUpload(file); }} + className="w-full text-xs" + /> +
+
- {/* Reference Images List */} - -
- -
- {!mounted ? ( -
- Loading... -
- ) : images.length === 0 ? ( -
- No images uploaded yet -
- ) : ( - images.map((image, index) => ( -
handleImageSelect(image.id, e)} - > -
- Image {index + 1} + {/* Reference Images List */} + +
+ +
+ {!mounted ? ( +
+ Loading...
-
- {image.name} + ) : images.length === 0 ? ( +
+ No images uploaded yet
-
- )) - )} -
-
-
+ ) : ( + images.map((image, index) => ( +
handleImageSelect(image.id, e)} + > +
+ Image {index + 1} +
+
+ {image.name} +
+
+ )) + )} +
+
+ - {/* Delete Selected Images Button */} - - - - - + {/* Delete Selected Images Button */} + + + + + - {/* Wall Segments List */} - -
- -
- {!mounted ? ( -
- Loading... -
- ) : wallSegments.length === 0 ? ( -
- No walls placed yet -
- ) : ( - wallSegments.map((segment, index) => ( -
handleWallSelect(segment.id, e)} - > -
- Wall {index + 1} + {/* Wall Segments List */} + +
+ +
+ {!mounted ? ( +
+ Loading...
-
- {formatWallDescription(segment)} + ) : wallSegments.length === 0 ? ( +
+ No walls placed yet
-
- )) - )} -
-
-
+ ) : ( + wallSegments.map((segment, index) => ( +
handleWallSelect(segment.id, e)} + > +
+ Wall {index + 1} +
+
+ {formatWallDescription(segment)} +
+
+ )) + )} +
+
+ - {/* Delete Selected Walls Button */} - - - - - + {/* Delete Selected Walls Button */} + + + + + + + )} diff --git a/components/editor/custom-controls.tsx b/components/editor/custom-controls.tsx index 502eeedab..2715f2451 100644 --- a/components/editor/custom-controls.tsx +++ b/components/editor/custom-controls.tsx @@ -12,14 +12,23 @@ export function CustomControls() { const setMovingCamera = useEditor((state) => state.setMovingCamera); const controls = useThree((state) => state.controls); const controlsRef = useRef(null); + const currentLevel = useEditor(state => state.currentLevel); useEffect(() => { if (!controls) return; + (controls as CameraControlsImpl).setLookAt(30, 30, 30, 0, 0, 0, false); (controls as CameraControlsImpl).setLookAt(10, 10, 10, 0, 0, 0, true); }, [controls]); + + useEffect(() => { + if (!controls) return; + + (controls as CameraControlsImpl).setLookAt(10, 10 * currentLevel, 10, 0, 10 * (currentLevel - 1), 0, true); + }, [currentLevel, controls]); + // Configure mouse buttons based on control mode const mouseButtons = useMemo(() => { // In select mode, left-click can pan the camera diff --git a/components/editor/index.tsx b/components/editor/index.tsx index cb26a1de5..74440ba8c 100644 --- a/components/editor/index.tsx +++ b/components/editor/index.tsx @@ -5,7 +5,7 @@ import { ControlModeMenu } from '@/components/editor/control-mode-menu' import { GridTiles } from '@/components/editor/elements/grid' import { ReferenceImage } from '@/components/editor/elements/reference-image' import { Walls } from '@/components/editor/elements/wall' -import { useEditorContext, type WallSegment } from '@/hooks/use-editor' +import { useEditor, useEditorContext, type WallSegment } from '@/hooks/use-editor' import { cn } from '@/lib/utils' import { Environment, GizmoHelper, GizmoViewport, Grid, Line, OrthographicCamera, PerspectiveCamera } from '@react-three/drei' import { Canvas } from '@react-three/fiber' @@ -698,6 +698,8 @@ export default function Editor({ className }: { className?: string }) { const imagePosition = IMAGE_POSITION const imageRotation = IMAGE_ROTATION + const currentLevel = useEditor(state => state.currentLevel); + return (
- + + {/* LEVELS */} + {/* Drei Grid for visual reference only - not interactive */} {showGrid && ( null}> @@ -852,6 +856,7 @@ export default function Editor({ className }: { className?: string }) { onDeleteWalls={handleDeleteSelectedWalls} /> + diff --git a/hooks/use-editor.tsx b/hooks/use-editor.tsx index f002fd058..4a4bac5eb 100644 --- a/hooks/use-editor.tsx +++ b/hooks/use-editor.tsx @@ -1,10 +1,10 @@ 'use client' +import type { SetStateAction } from 'react' import * as THREE from 'three' +import { GLTFExporter } from 'three/addons/exporters/GLTFExporter.js' import { create } from 'zustand' import { persist } from 'zustand/middleware' -import type { SetStateAction } from 'react' -import { GLTFExporter } from 'three/addons/exporters/GLTFExporter.js' export interface WallSegment { start: [number, number] // [x, y] intersection coordinates @@ -45,6 +45,7 @@ export type ComponentGroup = { name: string type: 'room' | 'floor' | 'outdoor' color: string + level?: number } export type LayoutJSON = { @@ -65,7 +66,9 @@ type HistoryState = { type StoreState = { walls: string[] images: ReferenceImage[] + components: Component[] groups: ComponentGroup[] + currentLevel: number selectedFloorId: string | null selectedWallIds: string[] selectedImageIds: string[] @@ -115,16 +118,57 @@ const useStore = create()( (set, get) => ({ walls: [], images: [], + components: [], groups: [{ id: 'ground-floor', name: 'Ground Floor', type: 'floor', - color: '#ffffff' + color: '#ffffff', + level: 1, }], + currentLevel: -1, addGroup: (group) => set(state => ({ groups: [...state.groups, group] })), - deleteGroup: (groupId) => set(state => ({ groups: state.groups.filter(group => group.id !== groupId) })), + deleteGroup: (groupId) => set(state => ({ + groups: state.groups.filter(group => group.id !== groupId), + components: state.components.filter(comp => comp.group !== groupId) + })), selectedFloorId: null, - selectFloor: (floorId) => set({ selectedFloorId: floorId }), + selectFloor: (floorId) => { + const state = get() + + if (!floorId) { + set({ selectedFloorId: null, walls: [], currentLevel: -1 }) + return + } + + // Find or create the component for this floor + let component = state.components.find(c => c.type === 'wall' && c.group === floorId) + + const group = state.groups.find(g => g.id === floorId) + + if (!component) { + // Create a new wall component for this floor + component = { + id: `walls-${floorId}`, + type: 'wall', + label: `Walls - ${group?.name || floorId}`, + group: floorId, + data: { segments: [] }, + createdAt: new Date().toISOString() + } + + set({ + components: [...state.components, component], + currentLevel: group?.level || 1, + selectedFloorId: floorId, + walls: [] + }) + } else { + // Load the walls from the existing component + const wallKeys = component.data.segments.map(seg => seg.id) + set({ selectedFloorId: floorId, walls: wallKeys, currentLevel: group?.level || 1 }) + } + }, selectedWallIds: [], selectedImageIds: [], isHelpOpen: false, @@ -142,10 +186,41 @@ const useStore = create()( if (sortedNew.length === sortedCurrent.length && sortedNew.every((v, i) => v === sortedCurrent[i])) { return state } + + // Update the component for the current floor + let updatedComponents = state.components + if (state.selectedFloorId) { + const wallSegments = get().wallSegments() + + updatedComponents = state.components.map(comp => { + if (comp.type === 'wall' && comp.group === state.selectedFloorId) { + return { + ...comp, + data: { segments: wallSegments } + } + } + return comp + }) + + // If no component exists for this floor yet, create one + if (!state.components.find(c => c.type === 'wall' && c.group === state.selectedFloorId)) { + const newComponent = { + id: `walls-${state.selectedFloorId}`, + type: 'wall' as const, + label: `Walls - ${state.groups.find(g => g.id === state.selectedFloorId)?.name || state.selectedFloorId}`, + group: state.selectedFloorId, + data: { segments: wallSegments }, + createdAt: new Date().toISOString() + } + updatedComponents = [...state.components, newComponent] + } + } + return { undoStack: [...state.undoStack, { walls: state.walls, images: state.images }].slice(-50), redoStack: [], - walls + walls, + components: updatedComponents } }), setImages: (images, pushToUndo = true) => set(state => { @@ -308,42 +383,63 @@ const useStore = create()( set({ selectedWallIds: [] }) }, serializeLayout: () => { - const wallSegments = get().wallSegments() - const images = get().images + const state = get() + const images = state.images + + // Make sure current floor's walls are saved to components before serializing + if (state.selectedFloorId && state.walls.length > 0) { + const wallSegments = state.wallSegments() + const existingComponent = state.components.find(c => c.type === 'wall' && c.group === state.selectedFloorId) + + if (existingComponent) { + // Update existing component + state.components = state.components.map(comp => + comp.id === existingComponent.id + ? { ...comp, data: { segments: wallSegments } } + : comp + ) + } else { + // Create new component + const newComponent = { + id: `walls-${state.selectedFloorId}`, + type: 'wall' as const, + label: `Walls - ${state.groups.find(g => g.id === state.selectedFloorId)?.name || state.selectedFloorId}`, + group: state.selectedFloorId, + data: { segments: wallSegments }, + createdAt: new Date().toISOString() + } + state.components = [...state.components, newComponent] + } + } return { version: '2.0', // Updated version for intersection-based walls grid: { size: 61 }, // 61 intersections (60 divisions + 1) - components: [{ - id: 'walls-default', - type: 'wall', - label: 'All Walls', - group: null, - data: { - segments: wallSegments - }, - createdAt: new Date().toISOString() - }], - groups: [], + components: state.components, + groups: state.groups, images // Include reference images in the layout } }, loadLayout: (json: LayoutJSON) => { - set({ selectedWallIds: [], selectedImageIds: [] }) - - // Load walls - const wallComponent = json.components.find(c => c.type === 'wall') - if (wallComponent?.data.segments) { - const newWalls = wallComponent.data.segments.map(seg => - `${seg.start[0]},${seg.start[1]}-${seg.end[0]},${seg.end[1]}` - ) - get().setWalls(newWalls) + set({ selectedWallIds: [], selectedImageIds: [], selectedFloorId: null }) + + // Load groups (floors) + if (json.groups && Array.isArray(json.groups)) { + set({ groups: json.groups }) } - + + // Load all components + if (json.components && Array.isArray(json.components)) { + set({ components: json.components }) + } + // Load reference images (if present in the JSON) if (json.images && Array.isArray(json.images)) { - get().setImages(json.images) + get().setImages(json.images, false) // Don't push to undo stack } + + // Clear current walls since no floor is selected + set({ walls: [] }) }, handleSaveLayout: () => { const layout = get().serializeLayout() @@ -479,5 +575,10 @@ export const useEditorContext = () => { undo: store.undo, redo: store.redo, handleClear: store.handleClear, + groups: store.groups, + selectedFloorId: store.selectedFloorId, + selectFloor: store.selectFloor, + addGroup: store.addGroup, + deleteGroup: store.deleteGroup, } } From 897fa70566c3879fdd82440d9c76493805f58865 Mon Sep 17 00:00:00 2001 From: wass08 Date: Fri, 24 Oct 2025 15:48:25 +0900 Subject: [PATCH 4/6] handle multiple floors --- components/app-sidebar.tsx | 148 ++++++++-------- components/editor/custom-controls.tsx | 11 +- components/editor/elements/wall.tsx | 29 +-- components/editor/index.tsx | 144 ++++++++------- hooks/use-editor.tsx | 246 ++++++++++++-------------- 5 files changed, 289 insertions(+), 289 deletions(-) diff --git a/components/app-sidebar.tsx b/components/app-sidebar.tsx index b97063adc..d94f24efb 100644 --- a/components/app-sidebar.tsx +++ b/components/app-sidebar.tsx @@ -21,7 +21,7 @@ import { import type { WallSegment } from "@/hooks/use-editor" import { useEditorContext } from "@/hooks/use-editor" import JsonView from '@uiw/react-json-view' -import { ArrowLeft, ChevronDown, ChevronRight, Download, FileCode, HelpCircle, Layers, Plus, Save, Trash2 } from "lucide-react" +import { ChevronDown, ChevronRight, Download, FileCode, HelpCircle, Layers, Plus, Save, Trash2 } from "lucide-react" import { useEffect, useState } from "react" export function AppSidebar() { @@ -192,6 +192,8 @@ export function AppSidebar() { } addGroup(newFloor) + // Automatically select the newly created floor + selectFloor(newFloor.id) } const selectedFloor = selectedFloorId ? groups.find(g => g.id === selectedFloorId) : null @@ -199,88 +201,80 @@ export function AppSidebar() { return ( - {selectedFloor ? ( -
- -

- - {selectedFloor.name} -

-
- ) : ( -

Pascal Editor

- )} +

Pascal Editor

- {/* Floor List or Wall Segments based on selection */} - {!selectedFloor ? ( - /* Floor List */ - -
-
- - -
-
- {!mounted ? ( -
- Loading... -
- ) : ( - groups - .filter(g => g.type === 'floor') - .map((floor) => ( -
-
selectFloor(floor.id)} - > -
- - {floor.name} -
+ {/* Floor List - Always visible with highlighted selection */} + +
+
+ + +
+
+ {!mounted ? ( +
+ Loading... +
+ ) : ( + groups + .filter(g => g.type === 'floor') + .sort((a, b) => (b.level || 0) - (a.level || 0)) // Reverse order: highest to lowest + .map((floor) => ( +
{ + // Toggle selection: clicking again deselects + if (selectedFloorId === floor.id) { + selectFloor(null) + } else { + selectFloor(floor.id) + } + }} + > +
+
+ + {floor.name}
- {floor.id !== 'ground-floor' && ( - - )}
- )) - )} -
+ {floor.id !== 'ground-floor' && ( + + )} +
+ )) + )}
-
- ) : ( +
+ + + {/* Wall Segments and Images - Only visible when a floor is selected */} + {selectedFloor && ( <> {/* Reference Image Upload */} diff --git a/components/editor/custom-controls.tsx b/components/editor/custom-controls.tsx index 2715f2451..ef304c90a 100644 --- a/components/editor/custom-controls.tsx +++ b/components/editor/custom-controls.tsx @@ -13,21 +13,24 @@ export function CustomControls() { const controls = useThree((state) => state.controls); const controlsRef = useRef(null); const currentLevel = useEditor(state => state.currentLevel); + const selectedFloorId = useEditor(state => state.selectedFloorId); useEffect(() => { if (!controls) return; - (controls as CameraControlsImpl).setLookAt(30, 30, 30, 0, 0, 0, false); - (controls as CameraControlsImpl).setLookAt(10, 10, 10, 0, 0, 0, true); }, [controls]); useEffect(() => { if (!controls) return; - (controls as CameraControlsImpl).setLookAt(10, 10 * currentLevel, 10, 0, 10 * (currentLevel - 1), 0, true); - }, [currentLevel, controls]); + if (!selectedFloorId) { + (controls as CameraControlsImpl).setLookAt(40, 40, 40, 0, 0, 0, true); + } else { + (controls as CameraControlsImpl).setLookAt(10, 10 * currentLevel, 10, 0, 10 * (currentLevel - 1), 0, true); + } + }, [currentLevel, controls, selectedFloorId]); // Configure mouse buttons based on control mode const mouseButtons = useMemo(() => { diff --git a/components/editor/elements/wall.tsx b/components/editor/elements/wall.tsx index 0ed804aa5..d69478e2b 100644 --- a/components/editor/elements/wall.tsx +++ b/components/editor/elements/wall.tsx @@ -1,13 +1,14 @@ 'use client' import type { WallSegment } from '@/hooks/use-editor' +import { useEditor } from '@/hooks/use-editor' import { forwardRef, memo, type Ref } from 'react' import * as THREE from 'three' const WALL_THICKNESS = 0.2 // 20cm wall thickness type WallsProps = { - wallSegments: WallSegment[] + floorId: string tileSize: number wallHeight: number hoveredWallIndex: number | null @@ -21,20 +22,24 @@ type WallsProps = { onDeleteWalls: () => void } -export const Walls = memo(forwardRef(({ - wallSegments, - tileSize, - wallHeight, - hoveredWallIndex, - selectedWallIds, - setSelectedWallIds, - onWallHover, - onWallRightClick, - isCameraEnabled, - controlMode, +export const Walls = memo(forwardRef(({ + floorId, + tileSize, + wallHeight, + hoveredWallIndex, + selectedWallIds, + setSelectedWallIds, + onWallHover, + onWallRightClick, + isCameraEnabled, + controlMode, movingCamera, onDeleteWalls }: WallsProps, ref: Ref) => { + // Fetch wall segments for this floor from the store + const components = useEditor(state => state.components) + const wallComponent = components.find(c => c.type === 'wall' && c.group === floorId) + const wallSegments = wallComponent?.data.segments || [] return ( {wallSegments.map((seg, i) => { diff --git a/components/editor/index.tsx b/components/editor/index.tsx index 74440ba8c..47f2a5478 100644 --- a/components/editor/index.tsx +++ b/components/editor/index.tsx @@ -28,7 +28,7 @@ const GRID_DIVISIONS = Math.floor(GRID_SIZE / TILE_SIZE) // 60 divisions const GRID_INTERSECTIONS = GRID_DIVISIONS + 1 // 61 intersections per axis export default function Editor({ className }: { className?: string }) { - const { walls, setWalls, images, setImages, wallSegments, selectedWallIds, setSelectedWallIds, selectedImageIds, setSelectedImageIds, handleDeleteSelectedWalls, undo, redo, activeTool, controlMode, setControlMode, setActiveTool, movingCamera, setIsManipulatingImage } = useEditorContext() + const { walls, setWalls, images, setImages, selectedWallIds, setSelectedWallIds, selectedImageIds, setSelectedImageIds, handleDeleteSelectedWalls, undo, redo, activeTool, controlMode, setControlMode, setActiveTool, movingCamera, setIsManipulatingImage, groups, selectedFloorId } = useEditorContext() const wallsGroupRef = useRef(null) const { setWallsGroupRef } = useEditorContext() @@ -698,8 +698,6 @@ export default function Editor({ className }: { className?: string }) { const imagePosition = IMAGE_POSITION const imageRotation = IMAGE_ROTATION - const currentLevel = useEditor(state => state.currentLevel); - return (
- {/* LEVELS */} - - {/* Drei Grid for visual reference only - not interactive */} - {showGrid && ( - null}> - - - )} - {/* Infinite dashed axis lines - visual only, not interactive */} null}> {/* X axis (red) */} @@ -799,9 +775,9 @@ export default function Editor({ className }: { className?: string }) { depthTest={false} /> - + {images.map((image) => ( - ))} - - - - - + {/* Loop through all floors and render grid + walls for each */} + {groups + .filter(g => g.type === 'floor') + .map((floor) => { + const floorLevel = floor.level || 1 + const yPosition = 10 * (floorLevel - 1) // 10m vertical spacing between floors + const isActiveFloor = selectedFloorId === floor.id + + return ( + + {/* Drei Grid for visual reference only - not interactive */} + {showGrid && ( + null}> + + + )} + + + {/* Only show interactive grid tiles for the active floor */} + {isActiveFloor && ( + + )} + + {/* Walls component fetches its own data based on floorId */} + + + + ) + }) + } diff --git a/hooks/use-editor.tsx b/hooks/use-editor.tsx index 4a4bac5eb..725e302ad 100644 --- a/hooks/use-editor.tsx +++ b/hooks/use-editor.tsx @@ -59,12 +59,11 @@ export type LayoutJSON = { } type HistoryState = { - walls: string[] images: ReferenceImage[] + components: Component[] } type StoreState = { - walls: string[] images: ReferenceImage[] components: Component[] groups: ComponentGroup[] @@ -116,7 +115,6 @@ type StoreState = { const useStore = create()( persist( (set, get) => ({ - walls: [], images: [], components: [], groups: [{ @@ -137,7 +135,7 @@ const useStore = create()( const state = get() if (!floorId) { - set({ selectedFloorId: null, walls: [], currentLevel: -1 }) + set({ selectedFloorId: null, currentLevel: -1 }) return } @@ -160,13 +158,10 @@ const useStore = create()( set({ components: [...state.components, component], currentLevel: group?.level || 1, - selectedFloorId: floorId, - walls: [] + selectedFloorId: floorId }) } else { - // Load the walls from the existing component - const wallKeys = component.data.segments.map(seg => seg.id) - set({ selectedFloorId: floorId, walls: wallKeys, currentLevel: group?.level || 1 }) + set({ selectedFloorId: floorId, currentLevel: group?.level || 1 }) } }, selectedWallIds: [], @@ -181,74 +176,99 @@ const useStore = create()( movingCamera: false, isManipulatingImage: false, setWalls: (walls) => set(state => { + if (!state.selectedFloorId) { + return state + } + + // Get current walls from component for comparison + const currentComponent = state.components.find(c => c.type === 'wall' && c.group === state.selectedFloorId) + const currentWalls = currentComponent?.data.segments.map(seg => seg.id) || [] + const sortedNew = [...walls].sort() - const sortedCurrent = [...state.walls].sort() + const sortedCurrent = [...currentWalls].sort() if (sortedNew.length === sortedCurrent.length && sortedNew.every((v, i) => v === sortedCurrent[i])) { return state } - // Update the component for the current floor - let updatedComponents = state.components - if (state.selectedFloorId) { - const wallSegments = get().wallSegments() - - updatedComponents = state.components.map(comp => { - if (comp.type === 'wall' && comp.group === state.selectedFloorId) { - return { - ...comp, - data: { segments: wallSegments } - } - } - return comp + // Convert wall keys to segments + const segments: WallSegment[] = [] + for (const wallKey of walls) { + if (!wallKey.includes('-')) continue + const parts = wallKey.split('-') + if (parts.length !== 2) continue + + const [start, end] = parts + const [x1, y1] = start.split(',').map(Number) + const [x2, y2] = end.split(',').map(Number) + + if (isNaN(x1) || isNaN(y1) || isNaN(x2) || isNaN(y2)) continue + + const isHorizontal = y1 === y2 + + segments.push({ + start: [x1, y1], + end: [x2, y2], + id: wallKey, + isHorizontal }) + } - // If no component exists for this floor yet, create one - if (!state.components.find(c => c.type === 'wall' && c.group === state.selectedFloorId)) { - const newComponent = { - id: `walls-${state.selectedFloorId}`, - type: 'wall' as const, - label: `Walls - ${state.groups.find(g => g.id === state.selectedFloorId)?.name || state.selectedFloorId}`, - group: state.selectedFloorId, - data: { segments: wallSegments }, - createdAt: new Date().toISOString() + // Update the component for the current floor + let updatedComponents = state.components.map(comp => { + if (comp.type === 'wall' && comp.group === state.selectedFloorId) { + return { + ...comp, + data: { segments } } - updatedComponents = [...state.components, newComponent] } + return comp + }) + + // If no component exists for this floor yet, create one + if (!state.components.find(c => c.type === 'wall' && c.group === state.selectedFloorId)) { + const newComponent = { + id: `walls-${state.selectedFloorId}`, + type: 'wall' as const, + label: `Walls - ${state.groups.find(g => g.id === state.selectedFloorId)?.name || state.selectedFloorId}`, + group: state.selectedFloorId, + data: { segments }, + createdAt: new Date().toISOString() + } + updatedComponents = [...state.components, newComponent] } return { - undoStack: [...state.undoStack, { walls: state.walls, images: state.images }].slice(-50), + undoStack: [...state.undoStack, { images: state.images, components: state.components }].slice(-50), redoStack: [], - walls, components: updatedComponents } }), setImages: (images, pushToUndo = true) => set(state => { // Deep comparison to avoid unnecessary undo stack pushes - const areEqual = state.images.length === images.length && + const areEqual = state.images.length === images.length && state.images.every((img, i) => { const newImg = images[i] - return img.id === newImg.id && - img.position[0] === newImg.position[0] && - img.position[1] === newImg.position[1] && - img.rotation === newImg.rotation && + return img.id === newImg.id && + img.position[0] === newImg.position[0] && + img.position[1] === newImg.position[1] && + img.rotation === newImg.rotation && img.scale === newImg.scale && img.url === newImg.url }) - + if (areEqual) { return state } - + // Only push to undo stack if requested (used for final commit, not intermediate updates) if (pushToUndo) { return { - undoStack: [...state.undoStack, { walls: state.walls, images: state.images }].slice(-50), + undoStack: [...state.undoStack, { images: state.images, components: state.components }].slice(-50), redoStack: [], images } } - + // Just update images without affecting undo stack (for intermediate drag updates) return { images } }), @@ -275,42 +295,25 @@ const useStore = create()( }, setMovingCamera: (moving) => set({ movingCamera: moving }), setIsManipulatingImage: (manipulating) => set({ isManipulatingImage: manipulating }), - getWallsSet: () => new Set(get().walls), + getWallsSet: () => { + const state = get() + if (!state.selectedFloorId) return new Set() + + const component = state.components.find(c => c.type === 'wall' && c.group === state.selectedFloorId) + if (!component) return new Set() + + return new Set(component.data.segments.map(seg => seg.id)) + }, getSelectedWallIdsSet: () => new Set(get().selectedWallIds), getSelectedImageIdsSet: () => new Set(get().selectedImageIds), wallSegments: () => { - const walls = get().getWallsSet() - const segments: WallSegment[] = [] - - for (const wallKey of walls) { - // Check if this is the new format "x1,y1-x2,y2" or old format "x,y" - if (!wallKey.includes('-')) { - // Skip old format tiles - they're incompatible with the new system - continue - } - - // Parse "x1,y1-x2,y2" format - const parts = wallKey.split('-') - if (parts.length !== 2) continue // Invalid format - - const [start, end] = parts - const [x1, y1] = start.split(',').map(Number) - const [x2, y2] = end.split(',').map(Number) - - // Validate all coordinates are numbers - if (isNaN(x1) || isNaN(y1) || isNaN(x2) || isNaN(y2)) continue - - const isHorizontal = y1 === y2 - - segments.push({ - start: [x1, y1], - end: [x2, y2], - id: wallKey, - isHorizontal - }) - } - - return segments + const state = get() + if (!state.selectedFloorId) return [] + + const component = state.components.find(c => c.type === 'wall' && c.group === state.selectedFloorId) + if (!component) return [] + + return component.data.segments }, handleExport: () => { const ref = get().wallsGroupRef @@ -365,18 +368,20 @@ const useStore = create()( }) }, handleDeleteSelectedWalls: () => { - set(state => { - if (state.selectedWallIds.length === 0) return state - const newWallsSet = new Set(state.walls) - // segmentId is now the wall key in format "x1,y1-x2,y2" - for (const wallKey of state.selectedWallIds) { - newWallsSet.delete(wallKey) - } - const newWalls = Array.from(newWallsSet) - // Since setWalls will handle undoStack, call it - get().setWalls(newWalls) - return { selectedWallIds: [] } - }) + const state = get() + if (state.selectedWallIds.length === 0) return + + const currentWalls = state.getWallsSet() + const newWallsSet = new Set(currentWalls) + + // Remove selected walls + for (const wallKey of state.selectedWallIds) { + newWallsSet.delete(wallKey) + } + + const newWalls = Array.from(newWallsSet) + get().setWalls(newWalls) + set({ selectedWallIds: [] }) }, handleClear: () => { get().setWalls([]) @@ -386,31 +391,7 @@ const useStore = create()( const state = get() const images = state.images - // Make sure current floor's walls are saved to components before serializing - if (state.selectedFloorId && state.walls.length > 0) { - const wallSegments = state.wallSegments() - const existingComponent = state.components.find(c => c.type === 'wall' && c.group === state.selectedFloorId) - - if (existingComponent) { - // Update existing component - state.components = state.components.map(comp => - comp.id === existingComponent.id - ? { ...comp, data: { segments: wallSegments } } - : comp - ) - } else { - // Create new component - const newComponent = { - id: `walls-${state.selectedFloorId}`, - type: 'wall' as const, - label: `Walls - ${state.groups.find(g => g.id === state.selectedFloorId)?.name || state.selectedFloorId}`, - group: state.selectedFloorId, - data: { segments: wallSegments }, - createdAt: new Date().toISOString() - } - state.components = [...state.components, newComponent] - } - } + // Walls are already saved in components, no need to update return { version: '2.0', // Updated version for intersection-based walls @@ -437,9 +418,6 @@ const useStore = create()( if (json.images && Array.isArray(json.images)) { get().setImages(json.images, false) // Don't push to undo stack } - - // Clear current walls since no floor is selected - set({ walls: [] }) }, handleSaveLayout: () => { const layout = get().serializeLayout() @@ -469,10 +447,10 @@ const useStore = create()( if (state.undoStack.length === 0) return state const previous = state.undoStack[state.undoStack.length - 1] return { - walls: previous.walls, + components: previous.components, images: previous.images, undoStack: state.undoStack.slice(0, -1), - redoStack: [...state.redoStack, { walls: state.walls, images: state.images }], + redoStack: [...state.redoStack, { components: state.components, images: state.images }], selectedWallIds: [], selectedImageIds: [] } @@ -481,10 +459,10 @@ const useStore = create()( if (state.redoStack.length === 0) return state const next = state.redoStack[state.redoStack.length - 1] return { - walls: next.walls, + components: next.components, images: next.images, redoStack: state.redoStack.slice(0, -1), - undoStack: [...state.undoStack, { walls: state.walls, images: state.images }], + undoStack: [...state.undoStack, { components: state.components, images: state.images }], selectedWallIds: [], selectedImageIds: [] } @@ -493,22 +471,14 @@ const useStore = create()( { name: 'editor-storage', partialize: (state) => ({ - walls: state.walls, + components: state.components, + groups: state.groups, images: state.images, selectedWallIds: state.selectedWallIds, selectedImageIds: state.selectedImageIds, }), onRehydrateStorage: () => (state) => { if (state) { - // Migrate: Remove old format walls (tile-based "x,y" format) - // Keep only new format walls (line-based "x1,y1-x2,y2" format) - const validWalls = state.walls.filter(wallKey => wallKey.includes('-')) - if (validWalls.length !== state.walls.length) { - console.log(`Migrated: Removed ${state.walls.length - validWalls.length} old format walls`) - state.walls = validWalls - state.selectedWallIds = [] - } - // Migrate: Add missing position, rotation, scale to existing images if (state.images && state.images.length > 0) { state.images = state.images.map((img: any) => ({ @@ -518,6 +488,20 @@ const useStore = create()( scale: img.scale ?? 1 })) } + + // Ensure components and groups are initialized + if (!state.components) { + state.components = [] + } + if (!state.groups) { + state.groups = [{ + id: 'ground-floor', + name: 'Ground Floor', + type: 'floor', + color: '#ffffff', + level: 1, + }] + } } }, } From 7ebe88e33e4c80fda8676dda231937f1044424ad Mon Sep 17 00:00:00 2001 From: wass08 Date: Fri, 24 Oct 2025 16:03:35 +0900 Subject: [PATCH 5/6] smooth transition + transparency non-concerned floors --- components/editor/custom-controls.tsx | 5 ++- components/editor/elements/wall.tsx | 21 +++++++---- components/editor/index.tsx | 51 +++++++++++++++++++-------- hooks/use-editor.tsx | 10 ++++-- 4 files changed, 61 insertions(+), 26 deletions(-) diff --git a/components/editor/custom-controls.tsx b/components/editor/custom-controls.tsx index ef304c90a..21384466e 100644 --- a/components/editor/custom-controls.tsx +++ b/components/editor/custom-controls.tsx @@ -4,8 +4,7 @@ import { useEditor } from '@/hooks/use-editor' import { CameraControls, CameraControlsImpl } from '@react-three/drei' import { useThree } from '@react-three/fiber' import { useEffect, useMemo, useRef } from 'react' - - +import { FLOOR_SPACING } from './index' export function CustomControls() { const controlMode = useEditor((state) => state.controlMode); @@ -28,7 +27,7 @@ export function CustomControls() { if (!selectedFloorId) { (controls as CameraControlsImpl).setLookAt(40, 40, 40, 0, 0, 0, true); } else { - (controls as CameraControlsImpl).setLookAt(10, 10 * currentLevel, 10, 0, 10 * (currentLevel - 1), 0, true); + (controls as CameraControlsImpl).setLookAt(10, FLOOR_SPACING * currentLevel, 10, 0, FLOOR_SPACING * (currentLevel - 1), 0, true); } }, [currentLevel, controls, selectedFloorId]); diff --git a/components/editor/elements/wall.tsx b/components/editor/elements/wall.tsx index d69478e2b..2b66dbabf 100644 --- a/components/editor/elements/wall.tsx +++ b/components/editor/elements/wall.tsx @@ -9,6 +9,7 @@ const WALL_THICKNESS = 0.2 // 20cm wall thickness type WallsProps = { floorId: string + isActive: boolean tileSize: number wallHeight: number hoveredWallIndex: number | null @@ -22,8 +23,9 @@ type WallsProps = { onDeleteWalls: () => void } -export const Walls = memo(forwardRef(({ +export const Walls = forwardRef(({ floorId, + isActive, tileSize, wallHeight, hoveredWallIndex, @@ -36,10 +38,11 @@ export const Walls = memo(forwardRef(({ movingCamera, onDeleteWalls }: WallsProps, ref: Ref) => { - // Fetch wall segments for this floor from the store - const components = useEditor(state => state.components) - const wallComponent = components.find(c => c.type === 'wall' && c.group === floorId) - const wallSegments = wallComponent?.data.segments || [] + // Fetch wall segments for this floor from the store (optimized selector) + const wallSegments = useEditor(state => { + const wallComponent = state.components.find(c => c.type === 'wall' && c.group === floorId) + return wallComponent?.data.segments || [] + }) return ( {wallSegments.map((seg, i) => { @@ -82,6 +85,10 @@ export const Walls = memo(forwardRef(({ emissive = "#331111"; } + // Reduce opacity for inactive floors + const opacity = isActive ? 1 : 0.2 + const transparent = opacity < 1 + return ( @@ -190,7 +199,7 @@ export const Walls = memo(forwardRef(({ })} ); -})); +}) Walls.displayName = 'Walls' diff --git a/components/editor/index.tsx b/components/editor/index.tsx index 47f2a5478..e38714e9d 100644 --- a/components/editor/index.tsx +++ b/components/editor/index.tsx @@ -27,6 +27,8 @@ const IMAGE_ROTATION = 0 // Reference image rotation const GRID_DIVISIONS = Math.floor(GRID_SIZE / TILE_SIZE) // 60 divisions const GRID_INTERSECTIONS = GRID_DIVISIONS + 1 // 61 intersections per axis +export const FLOOR_SPACING = 10 // 10m vertical spacing between floors + export default function Editor({ className }: { className?: string }) { const { walls, setWalls, images, setImages, selectedWallIds, setSelectedWallIds, selectedImageIds, setSelectedImageIds, handleDeleteSelectedWalls, undo, redo, activeTool, controlMode, setControlMode, setActiveTool, movingCamera, setIsManipulatingImage, groups, selectedFloorId } = useEditorContext() @@ -800,7 +802,7 @@ export default function Editor({ className }: { className?: string }) { .filter(g => g.type === 'floor') .map((floor) => { const floorLevel = floor.level || 1 - const yPosition = 10 * (floorLevel - 1) // 10m vertical spacing between floors + const yPosition = FLOOR_SPACING * (floorLevel - 1) const isActiveFloor = selectedFloorId === floor.id return ( @@ -808,20 +810,37 @@ export default function Editor({ className }: { className?: string }) { {/* Drei Grid for visual reference only - not interactive */} {showGrid && ( null}> - + {isActiveFloor ? ( + + ) : ( + + )} )} @@ -852,7 +871,9 @@ export default function Editor({ className }: { className?: string }) { {/* Walls component fetches its own data based on floorId */} ()( color: '#ffffff', level: 1, }], - currentLevel: -1, + currentLevel: 1, addGroup: (group) => set(state => ({ groups: [...state.groups, group] })), deleteGroup: (groupId) => set(state => ({ groups: state.groups.filter(group => group.id !== groupId), components: state.components.filter(comp => comp.group !== groupId) })), - selectedFloorId: null, + selectedFloorId: 'ground-floor', selectFloor: (floorId) => { const state = get() @@ -502,6 +502,12 @@ const useStore = create()( level: 1, }] } + + // Preselect ground floor if no floor is selected + if (!state.selectedFloorId) { + state.selectedFloorId = 'ground-floor' + state.currentLevel = 1 + } } }, } From 0f1942299fd72ff8135c0b5df20bffb0094581de Mon Sep 17 00:00:00 2001 From: wass08 Date: Fri, 24 Oct 2025 16:20:54 +0900 Subject: [PATCH 6/6] fix wall selected wrong floor --- components/editor/elements/wall.tsx | 36 ++++++++++++++++++----------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/components/editor/elements/wall.tsx b/components/editor/elements/wall.tsx index 2b66dbabf..235099f2a 100644 --- a/components/editor/elements/wall.tsx +++ b/components/editor/elements/wall.tsx @@ -68,7 +68,8 @@ export const Walls = forwardRef(({ const angle = Math.atan2(-dz, dx) const isSelected = selectedWallIds.has(seg.id); - const isHovered = hoveredWallIndex === i; + // Only apply hover effect if this floor is active + const isHovered = isActive && hoveredWallIndex === i; // Determine color based on selection and hover state let color = "#aaaabf"; // default @@ -95,33 +96,36 @@ export const Walls = forwardRef(({ castShadow receiveShadow onPointerEnter={(e) => { - // Don't highlight walls in delete or guide mode - if (controlMode !== 'delete' && controlMode !== 'guide') { + // Only allow hover on active floor walls, and not in delete or guide mode + if (isActive && controlMode !== 'delete' && controlMode !== 'guide') { e.stopPropagation(); onWallHover(i); } }} onPointerLeave={(e) => { - // Don't highlight walls in delete or guide mode - if (controlMode !== 'delete' && controlMode !== 'guide') { + // Only allow hover on active floor walls, and not in delete or guide mode + if (isActive && controlMode !== 'delete' && controlMode !== 'guide') { e.stopPropagation(); onWallHover(null); } }} onPointerDown={(e) => { + // Only allow interactions on active floor + if (!isActive) return; + // Don't handle interactions while camera is moving if (movingCamera) return; - + // Delete mode: interactions now handled through grid intersections if (controlMode === 'delete') { return } - + // Guide mode: no wall interactions while manipulating reference images if (controlMode === 'guide') { return } - + e.stopPropagation(); // Check for right-click (button 2) and camera not enabled and walls selected @@ -134,6 +138,9 @@ export const Walls = forwardRef(({ } }} onContextMenu={(e) => { + // Only allow context menu on active floor + if (!isActive) return; + // Prevent default browser context menu for walls (only when camera not enabled and walls selected) if (!isCameraEnabled && selectedWallIds.size > 0) { e.stopPropagation(); @@ -143,26 +150,29 @@ export const Walls = forwardRef(({ } }} onClick={(e) => { + // Only allow interactions on active floor + if (!isActive) return; + // Don't handle clicks while camera is moving if (movingCamera) return; - + e.stopPropagation(); - + // Building mode: no wall selection while placing walls if (controlMode === 'building') { return } - + // Delete mode: handled in onPointerDown/Up if (controlMode === 'delete') { return } - + // Guide mode: no wall selection while manipulating reference images if (controlMode === 'guide') { return } - + // Select mode: normal selection behavior if (controlMode === 'select') { setSelectedWallIds(prev => {