From eb0ccac30f938430eacb1472cd603adecdebb0b2 Mon Sep 17 00:00:00 2001 From: wass08 Date: Thu, 22 Jan 2026 12:24:55 +0900 Subject: [PATCH] zone editor --- apps/editor/components/tools/tool-manager.tsx | 19 +- .../tools/zone/zone-boundary-editor.tsx | 292 ++++++++++++++++++ packages/core/src/events/bus.ts | 13 + packages/core/src/index.ts | 1 + .../renderers/zone/zone-renderer.tsx | 25 +- packages/viewer/src/store/use-viewer.ts | 4 +- 6 files changed, 346 insertions(+), 8 deletions(-) create mode 100644 apps/editor/components/tools/zone/zone-boundary-editor.tsx diff --git a/apps/editor/components/tools/tool-manager.tsx b/apps/editor/components/tools/tool-manager.tsx index 9244a989..914f2a48 100644 --- a/apps/editor/components/tools/tool-manager.tsx +++ b/apps/editor/components/tools/tool-manager.tsx @@ -1,6 +1,8 @@ import useEditor, { type Phase, type Tool } from "@/store/use-editor"; +import { useViewer } from "@pascal-app/viewer"; import { ItemTool } from "./item/item-tool"; import { WallTool } from "./wall/wall-tool"; +import { ZoneBoundaryEditor } from "./zone/zone-boundary-editor"; import { ZoneTool } from "./zone/zone-tool"; const tools: Record>> = { @@ -19,10 +21,21 @@ export const ToolManager: React.FC = () => { const phase = useEditor((state) => state.phase); const mode = useEditor((state) => state.mode); const tool = useEditor((state) => state.tool); + const selectedZoneId = useViewer((state) => state.selection.zoneId); - if (mode !== "build" || tool === null) return null; + // Show zone boundary editor when in structure/select mode with a zone selected + const showZoneBoundaryEditor = + phase === "structure" && mode === "select" && selectedZoneId !== null; - const Component = tools[phase]?.[tool]; + // Show build tools when in build mode + const showBuildTool = mode === "build" && tool !== null; - return Component ? : null; + const BuildToolComponent = showBuildTool ? tools[phase]?.[tool] : null; + + return ( + <> + {showZoneBoundaryEditor && } + {BuildToolComponent && } + + ); }; diff --git a/apps/editor/components/tools/zone/zone-boundary-editor.tsx b/apps/editor/components/tools/zone/zone-boundary-editor.tsx new file mode 100644 index 00000000..aa2514d7 --- /dev/null +++ b/apps/editor/components/tools/zone/zone-boundary-editor.tsx @@ -0,0 +1,292 @@ +import { emitter, type GridEvent, useScene } from "@pascal-app/core"; +import { useViewer } from "@pascal-app/viewer"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + BufferGeometry, + Float32BufferAttribute, + type Mesh, + Plane, + Raycaster, + Vector2, + Vector3, +} from "three"; +import { useThree } from "@react-three/fiber"; + +const Y_OFFSET = 0.02; + +type DragState = { + isDragging: boolean; + vertexIndex: number; + initialPosition: [number, number]; +}; + +/** + * Zone boundary editor - allows editing zone polygon vertices when a zone is selected + * Uses the event emitter system for grid interactions + */ +export const ZoneBoundaryEditor: React.FC = () => { + const { gl, camera } = useThree(); + const selectedZoneId = useViewer((state) => state.selection.zoneId); + const zone = useScene((state) => + selectedZoneId ? state.zones[selectedZoneId] : null + ); + const updateZone = useScene((state) => state.updateZone); + + // Local state for dragging + const [dragState, setDragState] = useState(null); + const [previewPolygon, setPreviewPolygon] = useState< + Array<[number, number]> | null + >(null); + const [hoveredVertex, setHoveredVertex] = useState(null); + const [hoveredMidpoint, setHoveredMidpoint] = useState(null); + + // Refs for raycasting during drag + const dragPlane = useRef(new Plane(new Vector3(0, 1, 0), -Y_OFFSET)); + const raycaster = useRef(new Raycaster()); + const lineRef = useRef(null!); + + // The polygon to display (preview during drag, or actual zone polygon) + const displayPolygon = previewPolygon ?? zone?.polygon ?? []; + + // Calculate midpoints for adding new vertices + const midpoints = useMemo(() => { + if (displayPolygon.length < 2) return []; + return displayPolygon.map(([x1, z1], index) => { + const nextIndex = (index + 1) % displayPolygon.length; + const [x2, z2] = displayPolygon[nextIndex]!; + return [(x1! + x2) / 2, (z1! + z2) / 2] as [number, number]; + }); + }, [displayPolygon]); + + // Handle vertex drag + const handleVertexDrag = useCallback( + (clientX: number, clientY: number, vertexIndex: number) => { + if (!zone) return; + + const canvas = gl.domElement; + const rect = canvas.getBoundingClientRect(); + const x = ((clientX - rect.left) / rect.width) * 2 - 1; + const y = -((clientY - rect.top) / rect.height) * 2 + 1; + + raycaster.current.setFromCamera(new Vector2(x, y), camera); + const intersection = new Vector3(); + raycaster.current.ray.intersectPlane(dragPlane.current, intersection); + + if (intersection) { + // Snap to 0.5 grid + const gridX = Math.round(intersection.x * 2) / 2; + const gridZ = Math.round(intersection.z * 2) / 2; + + const basePolygon = previewPolygon ?? zone.polygon; + const newPolygon = [...basePolygon]; + newPolygon[vertexIndex] = [gridX, gridZ]; + setPreviewPolygon(newPolygon); + } + }, + [zone, gl, camera, previewPolygon] + ); + + // Commit polygon changes + const commitPolygonChange = useCallback(() => { + if (previewPolygon && selectedZoneId) { + updateZone(selectedZoneId, { polygon: previewPolygon }); + } + setPreviewPolygon(null); + setDragState(null); + }, [previewPolygon, selectedZoneId, updateZone]); + + // Handle adding a new vertex at midpoint + const handleAddVertex = useCallback( + (afterIndex: number, position: [number, number]) => { + if (!zone) return -1; + + const basePolygon = previewPolygon ?? zone.polygon; + const newPolygon = [ + ...basePolygon.slice(0, afterIndex + 1), + position, + ...basePolygon.slice(afterIndex + 1), + ]; + + setPreviewPolygon(newPolygon); + return afterIndex + 1; // Return new vertex index + }, + [zone, previewPolygon] + ); + + // Handle deleting a vertex + const handleDeleteVertex = useCallback( + (index: number) => { + if (!zone || !selectedZoneId) return; + + const basePolygon = previewPolygon ?? zone.polygon; + if (basePolygon.length <= 3) return; // Need at least 3 points + + const newPolygon = basePolygon.filter((_, i) => i !== index); + updateZone(selectedZoneId, { polygon: newPolygon }); + setPreviewPolygon(null); + }, + [zone, selectedZoneId, previewPolygon, updateZone] + ); + + // Set up pointer move/up listeners for dragging + useEffect(() => { + if (!dragState?.isDragging) return; + + const canvas = gl.domElement; + + const handlePointerMove = (e: PointerEvent) => { + handleVertexDrag(e.clientX, e.clientY, dragState.vertexIndex); + }; + + const handlePointerUp = () => { + commitPolygonChange(); + }; + + canvas.addEventListener("pointermove", handlePointerMove); + canvas.addEventListener("pointerup", handlePointerUp); + + return () => { + canvas.removeEventListener("pointermove", handlePointerMove); + canvas.removeEventListener("pointerup", handlePointerUp); + }; + }, [dragState, gl, handleVertexDrag, commitPolygonChange]); + + // Update line geometry when polygon changes + useEffect(() => { + if (!lineRef.current || displayPolygon.length < 2) return; + + const positions: number[] = []; + for (const [x, z] of displayPolygon) { + positions.push(x!, Y_OFFSET + 0.01, z!); + } + // Close the loop + const first = displayPolygon[0]!; + positions.push(first[0]!, Y_OFFSET + 0.01, first[1]!); + + const geometry = new BufferGeometry(); + geometry.setAttribute( + "position", + new Float32BufferAttribute(positions, 3) + ); + + lineRef.current.geometry.dispose(); + lineRef.current.geometry = geometry; + }, [displayPolygon]); + + if (!zone || displayPolygon.length < 3) return null; + + const canDelete = displayPolygon.length > 3; + const zoneColor = zone.color || "#3b82f6"; + + return ( + + {/* Border line */} + {/* @ts-ignore */} + + + + + + {/* Vertex handles */} + {displayPolygon.map(([x, z], index) => { + const isHovered = hoveredVertex === index; + const isDragging = dragState?.vertexIndex === index; + + return ( + { + e.stopPropagation(); + setHoveredVertex(index); + }} + onPointerLeave={(e) => { + e.stopPropagation(); + setHoveredVertex(null); + }} + onPointerDown={(e) => { + e.stopPropagation(); + setDragState({ + isDragging: true, + vertexIndex: index, + initialPosition: [x!, z!], + }); + }} + onDoubleClick={(e) => { + e.stopPropagation(); + if (canDelete) { + handleDeleteVertex(index); + } + }} + > + + + + ); + })} + + {/* Midpoint handles for adding vertices (hidden while dragging) */} + {!dragState && + midpoints.map(([x, z], index) => { + const isHovered = hoveredMidpoint === index; + + return ( + { + e.stopPropagation(); + setHoveredMidpoint(index); + }} + onPointerLeave={(e) => { + e.stopPropagation(); + setHoveredMidpoint(null); + }} + onPointerDown={(e) => { + e.stopPropagation(); + const newVertexIndex = handleAddVertex(index, [x!, z!]); + if (newVertexIndex >= 0) { + setDragState({ + isDragging: true, + vertexIndex: newVertexIndex, + initialPosition: [x!, z!], + }); + setHoveredMidpoint(null); + } + }} + > + + + + ); + })} + + ); +}; diff --git a/packages/core/src/events/bus.ts b/packages/core/src/events/bus.ts index 900d1d5c..88bbc7a2 100644 --- a/packages/core/src/events/bus.ts +++ b/packages/core/src/events/bus.ts @@ -2,6 +2,7 @@ import type { ThreeEvent } from "@react-three/fiber"; import mitt from "mitt"; import type { BuildingNode, ItemNode, WallNode } from "../schema"; import type { AnyNode } from "../schema/types"; +import type { Zone } from "../schema/zone"; // Base event interfaces export interface GridEvent { @@ -18,6 +19,13 @@ export interface NodeEvent { nativeEvent: ThreeEvent; } +export interface ZoneEvent { + zone: Zone; + position: [number, number, number]; + stopPropagation: () => void; + nativeEvent: ThreeEvent; +} + export type WallEvent = NodeEvent; export type ItemEvent = NodeEvent; export type BuildingEvent = NodeEvent; @@ -52,10 +60,15 @@ type CameraControlEvents = { "camera-controls:view": CameraControlEvent; "camera-controls:capture": CameraControlEvent; }; +type ZoneEvents = { + [K in `zone:${EventSuffix}`]: ZoneEvent; +}; + type EditorEvents = GridEvents & NodeEvents<"wall", WallEvent> & NodeEvents<"item", ItemEvent> & NodeEvents<"building", BuildingEvent> & + ZoneEvents & CameraControlEvents; export const emitter = mitt(); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 7abd820d..29393cac 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -7,6 +7,7 @@ export type { ItemEvent, NodeEvent, WallEvent, + ZoneEvent, } from './events/bus' // Events export { emitter, eventSuffixes } from './events/bus' diff --git a/packages/viewer/src/components/renderers/zone/zone-renderer.tsx b/packages/viewer/src/components/renderers/zone/zone-renderer.tsx index 0211e5af..4e76dab7 100644 --- a/packages/viewer/src/components/renderers/zone/zone-renderer.tsx +++ b/packages/viewer/src/components/renderers/zone/zone-renderer.tsx @@ -1,6 +1,7 @@ -import { useRegistry, useScene, type Zone } from "@pascal-app/core"; +import { emitter, useRegistry, useScene, type Zone } from "@pascal-app/core"; -import { useMemo, useRef } from "react"; +import type { ThreeEvent } from "@react-three/fiber"; +import { useCallback, useMemo, useRef } from "react"; import { BufferGeometry, Color, @@ -162,8 +163,26 @@ export const ZoneRenderer = ({ zoneId }: { zoneId: Zone["id"] }) => { return null; } + const emitZoneEvent = useCallback( + (suffix: string, e: ThreeEvent) => { + const eventKey = `zone:${suffix}` as `zone:${typeof suffix}`; + emitter.emit(eventKey, { + zone, + position: [e.point.x, e.point.y, e.point.z], + stopPropagation: () => e.stopPropagation(), + nativeEvent: e, + }); + }, + [zone] + ); + return ( - + emitZoneEvent("click", e)} + onPointerEnter={(e) => emitZoneEvent("enter", e)} + onPointerLeave={(e) => emitZoneEvent("leave", e)} + > {/* Floor fill */} void; + hoveredId: AnyNode["id"] | Zone["id"] | null; + setHoveredId: (id: AnyNode["id"] | Zone["id"] | null) => void; cameraMode: "perspective" | "orthographic"; setCameraMode: (mode: "perspective" | "orthographic") => void;