From 68e573f49415f8b84ed897ce332723830330f66f Mon Sep 17 00:00:00 2001 From: wass08 Date: Thu, 22 Jan 2026 10:11:15 +0900 Subject: [PATCH] zone creation editor --- apps/editor/components/tools/tool-manager.tsx | 24 +- .../components/tools/zone/zone-tool.tsx | 383 +++++++++++++++++- .../components/ui/sidebar/app-sidebar.tsx | 9 +- .../ui/sidebar/panels/zone-panel/index.tsx | 147 +++++++ packages/core/src/schema/index.ts | 1 + 5 files changed, 550 insertions(+), 14 deletions(-) create mode 100644 apps/editor/components/ui/sidebar/panels/zone-panel/index.tsx diff --git a/apps/editor/components/tools/tool-manager.tsx b/apps/editor/components/tools/tool-manager.tsx index 3087370b..9244a989 100644 --- a/apps/editor/components/tools/tool-manager.tsx +++ b/apps/editor/components/tools/tool-manager.tsx @@ -1,26 +1,28 @@ -import useEditor, { type Phase, type Tool } from '@/store/use-editor' -import { ItemTool } from './item/item-tool' -import { WallTool } from './wall/wall-tool' +import useEditor, { type Phase, type Tool } from "@/store/use-editor"; +import { ItemTool } from "./item/item-tool"; +import { WallTool } from "./wall/wall-tool"; +import { ZoneTool } from "./zone/zone-tool"; const tools: Record>> = { site: {}, structure: { wall: WallTool, item: ItemTool, + zone: ZoneTool, }, furnish: { item: ItemTool, }, -} +}; export const ToolManager: React.FC = () => { - const phase = useEditor((state) => state.phase) - const mode = useEditor((state) => state.mode) - const tool = useEditor((state) => state.tool) + const phase = useEditor((state) => state.phase); + const mode = useEditor((state) => state.mode); + const tool = useEditor((state) => state.tool); - if (mode !== 'build' || tool === null) return null + if (mode !== "build" || tool === null) return null; - const Component = tools[phase]?.[tool] + const Component = tools[phase]?.[tool]; - return Component ? : null -} + return Component ? : null; +}; diff --git a/apps/editor/components/tools/zone/zone-tool.tsx b/apps/editor/components/tools/zone/zone-tool.tsx index 1b3b3715..5ed5b9ea 100644 --- a/apps/editor/components/tools/zone/zone-tool.tsx +++ b/apps/editor/components/tools/zone/zone-tool.tsx @@ -1,3 +1,384 @@ +import { emitter, type GridEvent, useScene, ZoneSchema } from "@pascal-app/core"; +import { useViewer } from "@pascal-app/viewer"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { BufferGeometry, DoubleSide, type Line, type Mesh, Shape, Vector3 } from "three"; +import useEditor from "@/store/use-editor"; + +// Zone colors for cycling through +const ZONE_COLORS = [ + "#3b82f6", // blue + "#ef4444", // red + "#22c55e", // green + "#f59e0b", // amber + "#8b5cf6", // violet + "#06b6d4", // cyan + "#ec4899", // pink + "#84cc16", // lime +]; + +const Y_OFFSET = 0.02; + +/** + * Snaps a point to the nearest axis-aligned or 45-degree diagonal from the last point + */ +const calculateSnapPoint = ( + lastPoint: [number, number], + currentPoint: [number, number] +): [number, number] => { + const [x1, y1] = lastPoint; + const [x, y] = currentPoint; + + const dx = x - x1; + const dy = y - y1; + const absDx = Math.abs(dx); + const absDy = Math.abs(dy); + + // Calculate distances to horizontal, vertical, and diagonal lines + const horizontalDist = absDy; + const verticalDist = absDx; + const diagonalDist = Math.abs(absDx - absDy); + + // Find the minimum distance to determine which axis to snap to + const minDist = Math.min(horizontalDist, verticalDist, diagonalDist); + + if (minDist === diagonalDist) { + // Snap to 45° diagonal + const diagonalLength = Math.min(absDx, absDy); + return [ + x1 + Math.sign(dx) * diagonalLength, + y1 + Math.sign(dy) * diagonalLength, + ]; + } else if (minDist === horizontalDist) { + // Snap to horizontal + return [x, y1]; + } else { + // Snap to vertical + return [x1, y]; + } +}; + +/** + * Creates a zone with the given polygon points + */ +const commitZoneDrawing = ( + levelId: string, + points: Array<[number, number]> +) => { + const { createZone, zoneIds } = useScene.getState(); + + // Get next zone number + const zoneCount = zoneIds.length; + const name = `Zone ${zoneCount + 1}`; + + // Cycle through colors + const color = ZONE_COLORS[zoneCount % ZONE_COLORS.length]; + + const zone = ZoneSchema.parse({ + levelId, + name, + polygon: points, + color, + }); + + createZone(zone); + + // Select the newly created zone + useViewer.getState().setSelection({ zoneId: zone.id }); +}; + +type PreviewState = { + points: Array<[number, number]>; + cursorPoint: [number, number] | null; +}; + +// Helper to validate point values (no NaN or Infinity) +const isValidPoint = ( + pt: [number, number] | null | undefined +): pt is [number, number] => { + if (!pt) return false; + return Number.isFinite(pt[0]) && Number.isFinite(pt[1]); +}; + export const ZoneTool: React.FC = () => { - return null; + const cursorRef = useRef(null); + const mainLineRef = useRef(null!); + const closingLineRef = useRef(null!); + const pointsRef = useRef>([]); + const currentLevelId = useViewer((state) => state.selection.levelId); + const setTool = useEditor((state) => state.setTool); + + // Preview state for reactive rendering (for shape and point markers) + const [preview, setPreview] = useState({ + points: [], + cursorPoint: null, + }); + + useEffect(() => { + if (!currentLevelId) return; + + let cursorPosition: [number, number] = [0, 0]; + + // Initialize line geometries + mainLineRef.current.geometry = new BufferGeometry(); + closingLineRef.current.geometry = new BufferGeometry(); + + const updateLines = () => { + const points = pointsRef.current; + + if (points.length === 0) { + mainLineRef.current.visible = false; + closingLineRef.current.visible = false; + return; + } + + // Build main line points + const linePoints: Vector3[] = points.map( + ([x, z]) => new Vector3(x, Y_OFFSET, z) + ); + + // Add cursor point + const lastPoint = points[points.length - 1]; + if (lastPoint) { + const snapped = calculateSnapPoint(lastPoint, cursorPosition); + if (isValidPoint(snapped)) { + linePoints.push(new Vector3(snapped[0], Y_OFFSET, snapped[1])); + } + } + + // Update main line geometry + if (linePoints.length >= 2) { + mainLineRef.current.geometry.dispose(); + mainLineRef.current.geometry = new BufferGeometry().setFromPoints(linePoints); + mainLineRef.current.visible = true; + } else { + mainLineRef.current.visible = false; + } + + // Update closing line (from cursor back to first point) + const firstPoint = points[0]; + if (points.length >= 2 && lastPoint && isValidPoint(firstPoint)) { + const snapped = calculateSnapPoint(lastPoint, cursorPosition); + if (isValidPoint(snapped)) { + const closingPoints = [ + new Vector3(snapped[0], Y_OFFSET, snapped[1]), + new Vector3(firstPoint[0], Y_OFFSET, firstPoint[1]), + ]; + closingLineRef.current.geometry.dispose(); + closingLineRef.current.geometry = new BufferGeometry().setFromPoints(closingPoints); + closingLineRef.current.visible = true; + } + } else { + closingLineRef.current.visible = false; + } + }; + + const updatePreview = () => { + const points = pointsRef.current; + const lastPoint = points[points.length - 1]; + + let cursorPt: [number, number] | null = null; + if (lastPoint) { + cursorPt = calculateSnapPoint(lastPoint, cursorPosition); + } else if (points.length === 0) { + cursorPt = cursorPosition; + } + + setPreview({ points: [...points], cursorPoint: cursorPt }); + updateLines(); + }; + + const onGridMove = (event: GridEvent) => { + if (!cursorRef.current) return; + + // Snap to 0.5 grid + const gridX = Math.round(event.position[0] * 2) / 2; + const gridZ = Math.round(event.position[2] * 2) / 2; + cursorPosition = [gridX, gridZ]; + + // If we have points, snap to axis from last point + const lastPoint = pointsRef.current[pointsRef.current.length - 1]; + if (lastPoint) { + const snapped = calculateSnapPoint(lastPoint, cursorPosition); + cursorRef.current.position.set(snapped[0], event.position[1], snapped[1]); + } else { + cursorRef.current.position.set(gridX, event.position[1], gridZ); + } + + updatePreview(); + }; + + const onGridClick = (event: GridEvent) => { + if (!currentLevelId) return; + + const gridX = Math.round(event.position[0] * 2) / 2; + const gridZ = Math.round(event.position[2] * 2) / 2; + let clickPoint: [number, number] = [gridX, gridZ]; + + // Snap to axis from last point + const lastPoint = pointsRef.current[pointsRef.current.length - 1]; + if (lastPoint) { + clickPoint = calculateSnapPoint(lastPoint, clickPoint); + } + + // Check if clicking on the first point to close the shape + const firstPoint = pointsRef.current[0]; + if ( + pointsRef.current.length >= 3 && + firstPoint && + Math.abs(clickPoint[0] - firstPoint[0]) < 0.25 && + Math.abs(clickPoint[1] - firstPoint[1]) < 0.25 + ) { + // Create the zone + commitZoneDrawing(currentLevelId, pointsRef.current); + + // Reset state + pointsRef.current = []; + setPreview({ points: [], cursorPoint: null }); + mainLineRef.current.visible = false; + closingLineRef.current.visible = false; + + // Deactivate tool + setTool(null); + } else { + // Add point to polygon + pointsRef.current = [...pointsRef.current, clickPoint]; + updatePreview(); + } + }; + + const onGridDoubleClick = (_event: GridEvent) => { + if (!currentLevelId) return; + + // Need at least 3 points to form a polygon + if (pointsRef.current.length >= 3) { + commitZoneDrawing(currentLevelId, pointsRef.current); + + // Reset state + pointsRef.current = []; + setPreview({ points: [], cursorPoint: null }); + mainLineRef.current.visible = false; + closingLineRef.current.visible = false; + + // Deactivate tool + setTool(null); + } + }; + + // Subscribe to events + emitter.on("grid:move", onGridMove); + emitter.on("grid:click", onGridClick); + emitter.on("grid:double-click", onGridDoubleClick); + + return () => { + emitter.off("grid:move", onGridMove); + emitter.off("grid:click", onGridClick); + emitter.off("grid:double-click", onGridDoubleClick); + + // Reset state on unmount + pointsRef.current = []; + }; + }, [currentLevelId, setTool]); + + const { points, cursorPoint } = preview; + + // Create preview shape when we have 3+ points + const previewShape = useMemo(() => { + if (points.length < 3) return null; + + const allPoints = [...points]; + if (isValidPoint(cursorPoint)) { + allPoints.push(cursorPoint); + } + + // THREE.Shape is in X-Y plane. After rotation of -PI/2 around X: + // - Shape X -> World X + // - Shape Y -> World -Z (so we negate Z to get correct orientation) + const firstPt = allPoints[0]; + if (!isValidPoint(firstPt)) return null; + + const shape = new Shape(); + shape.moveTo(firstPt[0], -firstPt[1]); + + for (let i = 1; i < allPoints.length; i++) { + const pt = allPoints[i]; + if (isValidPoint(pt)) { + shape.lineTo(pt[0], -pt[1]); + } + } + shape.closePath(); + + return shape; + }, [points, cursorPoint]); + + return ( + + {/* Cursor */} + + + + + + {/* Preview fill */} + {previewShape && ( + + + + + )} + + {/* Main line - uses native line element with TSL-compatible material */} + {/* @ts-ignore */} + + + + + + {/* Closing line - uses native line element with TSL-compatible material */} + {/* @ts-ignore */} + + + + + + {/* Point markers */} + {points.map(([x, z], index) => + isValidPoint([x, z]) ? ( + + + + + ) : null + )} + + ); }; diff --git a/apps/editor/components/ui/sidebar/app-sidebar.tsx b/apps/editor/components/ui/sidebar/app-sidebar.tsx index 39ad0a21..1159f6bb 100644 --- a/apps/editor/components/ui/sidebar/app-sidebar.tsx +++ b/apps/editor/components/ui/sidebar/app-sidebar.tsx @@ -28,6 +28,7 @@ import { cn } from "@/lib/utils"; import useEditor from "@/store/use-editor"; import { useViewer } from "@pascal-app/viewer"; import { SitePanel } from "./panels/site-panel"; +import { ZonePanel } from "./panels/zone-panel"; export function AppSidebar() { // const isHelpOpen = useEditor((state) => state.isHelpOpen); @@ -41,6 +42,8 @@ export function AppSidebar() { // const serializeLayout = useEditor((state) => state.serializeLayout); const activeTool = useEditor((state) => state.tool); const currentLevelId = useViewer((state) => state.selection.levelId); + const setPhase = useEditor((state) => state.setPhase); + const setMode = useEditor((state) => state.setMode); const setActiveTool = useEditor((state) => state.setTool); const [jsonCollapsed, setJsonCollapsed] = useState(1); @@ -76,8 +79,8 @@ export function AppSidebar() { switch (activePanel) { case "site": return ; - // case "zones": - // return ; + case "zones": + return ; // case "collections": // return ; // case "settings": @@ -109,6 +112,8 @@ export function AppSidebar() { const handleAddZone = () => { if (currentLevelId) { + setPhase("structure"); + setMode("build"); setActiveTool("zone"); } }; diff --git a/apps/editor/components/ui/sidebar/panels/zone-panel/index.tsx b/apps/editor/components/ui/sidebar/panels/zone-panel/index.tsx new file mode 100644 index 00000000..d29288c6 --- /dev/null +++ b/apps/editor/components/ui/sidebar/panels/zone-panel/index.tsx @@ -0,0 +1,147 @@ +import { useScene, type Zone } from "@pascal-app/core"; +import { useViewer } from "@pascal-app/viewer"; +import { Hexagon, Trash2 } from "lucide-react"; +import { cn } from "@/lib/utils"; +import useEditor from "@/store/use-editor"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/primitives/popover"; + +// Preset colors for zones +const PRESET_COLORS = [ + "#3b82f6", // blue + "#22c55e", // green + "#eab308", // yellow + "#f97316", // orange + "#ef4444", // red + "#a855f7", // purple + "#ec4899", // pink + "#06b6d4", // cyan +]; + +function ZoneItem({ zone }: { zone: Zone }) { + const deleteZone = useScene((state) => state.deleteZone); + const updateZone = useScene((state) => state.updateZone); + const selectedZoneId = useViewer((state) => state.selection.zoneId); + const setSelection = useViewer((state) => state.setSelection); + + const isSelected = selectedZoneId === zone.id; + + const handleClick = () => { + setSelection({ zoneId: zone.id }); + }; + + const handleDelete = (e: React.MouseEvent) => { + e.stopPropagation(); + deleteZone(zone.id); + if (isSelected) { + setSelection({ zoneId: null }); + } + }; + + const handleColorChange = (color: string) => { + updateZone(zone.id, { color }); + }; + + return ( +
+ + +
+ + + + {zone.name} + + + ); +} + +export function ZonePanel() { + const zones = useScene((state) => state.zones); + const zoneIds = useScene((state) => state.zoneIds); + const currentLevelId = useViewer((state) => state.selection.levelId); + const setPhase = useEditor((state) => state.setPhase); + const setMode = useEditor((state) => state.setMode); + const setTool = useEditor((state) => state.setTool); + + // Filter zones to only show those for the current level + const levelZones = zoneIds + .map((id) => zones[id]) + .filter( + (zone): zone is Zone => + zone !== undefined && zone.levelId === currentLevelId + ); + + const handleAddZone = () => { + if (currentLevelId) { + setPhase("structure"); + setMode("build"); + setTool("zone"); + } + }; + + if (!currentLevelId) { + return ( +
+ Select a level to view and create zones +
+ ); + } + + return ( +
+ {levelZones.length === 0 ? ( +
+ No zones on this level.{" "} + +
+ ) : ( + levelZones.map((zone) => ) + )} +
+ ); +} diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index 4ab32e74..af2f1edc 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -15,3 +15,4 @@ export { AnyNode } from './types' // Zones export type { Zone, ZonePolygon } from './zone' +export { ZoneSchema } from './zone'