From 536c2e94cc6671a18e0180c331de209667551a6a Mon Sep 17 00:00:00 2001 From: wass08 Date: Fri, 16 Jan 2026 13:58:01 +0900 Subject: [PATCH] draft wall builder --- apps/editor/components/editor.tsx | 24 +++++- .../components/editors/wall/wall-editor.tsx | 85 +++++++++++++++++++ apps/editor/store/use-editor.tsx | 46 +++++++++- packages/core/src/events/bus.ts | 6 +- packages/core/src/store/use-scene.ts | 23 +++++ .../renderers/building/building-renderer.tsx | 10 +-- .../renderers/level/level-renderer.tsx | 2 + .../renderers/wall/wall-renderer.tsx | 5 +- packages/viewer/src/hooks/use-grid-events.ts | 33 +++++++ packages/viewer/src/hooks/use-node-events.ts | 3 + packages/viewer/src/index.ts | 4 + 11 files changed, 225 insertions(+), 16 deletions(-) create mode 100644 apps/editor/components/editors/wall/wall-editor.tsx create mode 100644 packages/viewer/src/hooks/use-grid-events.ts diff --git a/apps/editor/components/editor.tsx b/apps/editor/components/editor.tsx index efc76076..acc718aa 100644 --- a/apps/editor/components/editor.tsx +++ b/apps/editor/components/editor.tsx @@ -7,7 +7,7 @@ import { useScene, WallNode, } from "@pascal-app/core"; -import { useViewer, Viewer } from "@pascal-app/viewer"; +import { useGridEvents, useNodeEvents, useViewer, Viewer } from "@pascal-app/viewer"; import { Stats } from "@react-three/drei"; import { useFrame, useThree } from "@react-three/fiber"; import { useEffect, useMemo, useRef } from "react"; @@ -27,6 +27,7 @@ import { } from "three/tsl"; import { MeshBasicNodeMaterial, PostProcessing } from "three/webgpu"; import { ActionMenu } from "./ui/action-menu"; +import { WallEditor } from "./editors/wall/wall-editor"; const selectedObjects: Object3D[] = []; @@ -37,10 +38,11 @@ export default function Editor() { - + + ); @@ -187,17 +189,31 @@ const Grid = ({ fadeStrength, ]); + const handlers = useGridEvents(); + return ( - + ); }; -const Selector = () => { +const DraftSelector = () => { const selectedItemId = useRef(null); const itemSelectedAt = useRef(0); useEffect(() => { + emitter.on('building:enter', (event) => { + console.log('Entered building:', event.node.id); + const itemMesh = sceneRegistry.nodes.get(event.node.id); + selectedObjects.length = 0; + selectedObjects.push(itemMesh); + }); + emitter.on('building:leave', (event) => { + console.log('Leaving building:', event.node.id); + selectedObjects.length = 0; + }); + + emitter.on("item:click", (event) => { event.stopPropagation(); if (Date.now() - itemSelectedAt.current < 50) { diff --git a/apps/editor/components/editors/wall/wall-editor.tsx b/apps/editor/components/editors/wall/wall-editor.tsx new file mode 100644 index 00000000..c19b75e0 --- /dev/null +++ b/apps/editor/components/editors/wall/wall-editor.tsx @@ -0,0 +1,85 @@ +import { emitter, GridEvent, useScene, WallNode } from "@pascal-app/core"; +import { useViewer } from "@pascal-app/viewer"; +import { useEffect, useRef } from "react" +import { Line, Mesh, Vector3 } from "three"; + +export const WallEditor: React.FC = () => { + + const cursorRef = useRef(null); + const drawingLineRef = useRef(null!); + + useEffect(() => { + + let buildingState = 0; + const startingPoint = new Vector3(0, 0, 0); + const endingPoint = new Vector3(0, 0, 0); + let gridPosition: [number, number] = [0, 0]; + + + drawingLineRef.current.geometry.setFromPoints([ + startingPoint, + endingPoint, + ]); + + const onGridMove = (event: GridEvent) => { + if (!cursorRef.current) return; + + gridPosition = [Math.round(event.position[0] * 2) / 2, Math.round(event.position[1] * 2) / 2]; + cursorRef.current.position.set(gridPosition[0], 0.1, gridPosition[1]); + if (buildingState === 1) { + endingPoint.set(gridPosition[0], 0.1, gridPosition[1]); + } + drawingLineRef.current.geometry.setFromPoints([ + startingPoint, + endingPoint, + ]); + }; + const onGridClick = (event: GridEvent) => { + if (buildingState === 0) { + startingPoint.set(gridPosition[0], 0.1, gridPosition[1]); + buildingState = 1; + console.log('starting building at:', startingPoint); + drawingLineRef.current.visible = true; + + } else if (buildingState === 1) { + const currentLevelId = useViewer.getState().currentLevelId; + console.log('currentLevelId:', currentLevelId); + if (currentLevelId) { + const wallNode = WallNode.parse({ + start: [startingPoint.x, startingPoint.z], + end: [gridPosition[0], gridPosition[1]], + }) + useScene.getState().createNode(wallNode, currentLevelId); + } + drawingLineRef.current.visible = false; + buildingState = 0; + } + + }; + + + emitter.on('grid:move', onGridMove); + emitter.on('grid:click', onGridClick); + + return () => { + emitter.off('grid:move', onGridMove); + emitter.off('grid:click', onGridClick); + }; + }, []); + + return ( + + + + + + + {/* @ts-ignore */} + + + + + + + ) +} \ No newline at end of file diff --git a/apps/editor/store/use-editor.tsx b/apps/editor/store/use-editor.tsx index ac13a872..ceb439f8 100644 --- a/apps/editor/store/use-editor.tsx +++ b/apps/editor/store/use-editor.tsx @@ -1,5 +1,7 @@ "use client"; +import { BuildingNode, LevelNode, useScene } from "@pascal-app/core"; +import { useViewer } from "@pascal-app/viewer"; import { create } from "zustand"; export type Phase = "site" | "structure" | "furnish"; @@ -45,7 +47,49 @@ type EditorState = { const useEditor = create()((set, get) => ({ phase: "site", - setPhase: (phase) => set({ phase }), + setPhase: (phase) => { + const currentPhase = get().phase; + if (currentPhase === phase) return; + + set({ phase }); + + const viewer = useViewer.getState(); + const scene = useScene.getState(); + + switch (phase) { + case "site": + // In Site mode, we zoom out and deselect specific levels/buildings + viewer.setCurrentBuildingId(null); + viewer.setCurrentLevelId(null); + viewer.setLevelMode("stacked"); + break; + + case "structure": + // In Structure mode, we often want to focus on a specific building/level + // Auto-select the first building if none is selected + if (!viewer.currentBuildingId) { + const firstBuildingId = scene.rootNodeIds.find((id) => { + const node = scene.nodes[id]; + return node?.type === "building" || null; + }); + if (firstBuildingId) { + viewer.setCurrentBuildingId(firstBuildingId as BuildingNode["id"]); + const buildingNode = scene.nodes[firstBuildingId] as BuildingNode; + const firstLevelId = buildingNode.children[0]; + if (firstLevelId) { + viewer.setCurrentLevelId(firstLevelId as LevelNode["id"]); + } + } + } + viewer.setLevelMode("exploded"); // Better for structure editing + break; + + case "furnish": + // Maybe in furnish mode we force "solo" level view to see inside rooms + viewer.setLevelMode("solo"); + break; + } + }, mode: "select", setMode: (mode) => set({ mode }), tool: null, diff --git a/packages/core/src/events/bus.ts b/packages/core/src/events/bus.ts index b2094e34..bb6943d5 100644 --- a/packages/core/src/events/bus.ts +++ b/packages/core/src/events/bus.ts @@ -1,5 +1,5 @@ import mitt from "mitt"; -import { ItemNode, WallNode } from "../schema"; +import { BuildingNode, ItemNode, WallNode } from "../schema"; import { AnyNode } from "../schema/types"; // Base event interfaces @@ -16,6 +16,7 @@ export interface NodeEvent { export type WallEvent = NodeEvent; export type ItemEvent = NodeEvent; +export type BuildingEvent = NodeEvent; // Event suffixes - exported for use in hooks export const eventSuffixes = [ @@ -41,6 +42,7 @@ type GridEvents = { type EditorEvents = GridEvents & NodeEvents<"wall", WallEvent> & - NodeEvents<"item", ItemEvent>; + NodeEvents<"item", ItemEvent> & + NodeEvents<"building", BuildingEvent>; export const emitter = mitt(); diff --git a/packages/core/src/store/use-scene.ts b/packages/core/src/store/use-scene.ts index 8e13ee38..3b5c7120 100644 --- a/packages/core/src/store/use-scene.ts +++ b/packages/core/src/store/use-scene.ts @@ -20,6 +20,8 @@ type SceneState = { loadScene: () => void; markDirty: (id: AnyNodeId) => void; clearDirty: (id: AnyNodeId) => void; + + createNode: (node: AnyNode, parentId?: AnyNodeId) => void; }; const useScene = create()((set, get) => ({ @@ -123,6 +125,27 @@ const useScene = create()((set, get) => ({ clearDirty: (id) => { get().dirtyNodes.delete(id); }, + + createNode: (node, parentId) => { + set((state) => { + const newNodes = { ...state.nodes, [node.id]: node }; + + if (parentId) { + const parent = state.nodes[parentId]; + if (parent) { + newNodes[parentId] = { + ...parent, + children: [...parent.children, node.id], + }; + } + } + + return { nodes: newNodes }; + }); + + get().markDirty(node.id); + if (parentId) get().markDirty(parentId); + } })); useScene.getState().loadScene(); diff --git a/packages/viewer/src/components/renderers/building/building-renderer.tsx b/packages/viewer/src/components/renderers/building/building-renderer.tsx index fdc9a0e3..52161f69 100644 --- a/packages/viewer/src/components/renderers/building/building-renderer.tsx +++ b/packages/viewer/src/components/renderers/building/building-renderer.tsx @@ -1,18 +1,16 @@ -import { BuildingNode, LevelNode, useRegistry } from "@pascal-app/core"; +import { BuildingNode, useRegistry } from "@pascal-app/core"; import { useRef } from "react"; import { Group } from "three"; import { NodeRenderer } from "../node-renderer"; +import { useNodeEvents } from "../../../hooks/use-node-events"; export const BuildingRenderer = ({ node }: { node: BuildingNode }) => { const ref = useRef(null!); useRegistry(node.id, node.type, ref); + const handlers = useNodeEvents(node, "building"); return ( - - {/* - - - */} + {node.children.map((childId) => ( ))} diff --git a/packages/viewer/src/components/renderers/level/level-renderer.tsx b/packages/viewer/src/components/renderers/level/level-renderer.tsx index d89e569c..f6659034 100644 --- a/packages/viewer/src/components/renderers/level/level-renderer.tsx +++ b/packages/viewer/src/components/renderers/level/level-renderer.tsx @@ -7,6 +7,8 @@ export const LevelRenderer = ({ node }: { node: LevelNode }) => { const ref = useRef(null!); useRegistry(node.id, node.type, ref); + + console.log('rendering level:', node.id, node.children); return ( {/* diff --git a/packages/viewer/src/components/renderers/wall/wall-renderer.tsx b/packages/viewer/src/components/renderers/wall/wall-renderer.tsx index d71df886..3abd8c53 100644 --- a/packages/viewer/src/components/renderers/wall/wall-renderer.tsx +++ b/packages/viewer/src/components/renderers/wall/wall-renderer.tsx @@ -1,6 +1,5 @@ -import { emitter, useRegistry, WallNode } from "@pascal-app/core"; -import { ThreeEvent } from "@react-three/fiber"; -import { useCallback, useRef } from "react"; +import { useRegistry, WallNode } from "@pascal-app/core"; +import { useRef } from "react"; import { Mesh } from "three"; import { NodeRenderer } from "../node-renderer"; import { useNodeEvents } from "../../../hooks/use-node-events"; diff --git a/packages/viewer/src/hooks/use-grid-events.ts b/packages/viewer/src/hooks/use-grid-events.ts new file mode 100644 index 00000000..fd4a2914 --- /dev/null +++ b/packages/viewer/src/hooks/use-grid-events.ts @@ -0,0 +1,33 @@ +import { emitter, EventSuffix, GridEvent } from "@pascal-app/core"; +import { ThreeEvent } from "@react-three/fiber"; + +export function useGridEvents() { + const emit = (suffix: EventSuffix, e: ThreeEvent) => { + const eventKey = `grid:${suffix}` as `grid:${EventSuffix}`; + const payload: GridEvent = { + position: [e.point.x, e.point.z], + }; + + emitter.emit(eventKey, payload); + }; + + return { + onPointerDown: (e: ThreeEvent) => { + if (e.button !== 0) return; + emit("pointerdown", e); + }, + onPointerUp: (e: ThreeEvent) => { + if (e.button !== 0) return; + emit("pointerup", e); + }, + onClick: (e: ThreeEvent) => { + if (e.button !== 0) return; + emit("click", e); + }, + onPointerEnter: (e: ThreeEvent) => emit("enter", e), + onPointerLeave: (e: ThreeEvent) => emit("leave", e), + onPointerMove: (e: ThreeEvent) => emit("move", e), + onDoubleClick: (e: ThreeEvent) => emit("double-click", e), + onContextMenu: (e: ThreeEvent) => emit("context-menu", e), + }; +} \ No newline at end of file diff --git a/packages/viewer/src/hooks/use-node-events.ts b/packages/viewer/src/hooks/use-node-events.ts index c41d83d6..5ffffcc0 100644 --- a/packages/viewer/src/hooks/use-node-events.ts +++ b/packages/viewer/src/hooks/use-node-events.ts @@ -1,4 +1,5 @@ import { + BuildingNode, emitter, EventSuffix, ItemEvent, @@ -7,10 +8,12 @@ import { WallNode, } from "@pascal-app/core"; import { ThreeEvent } from "@react-three/fiber"; +import { BuildingEvent } from "../../../core/src/events/bus"; type NodeConfig = { item: { node: ItemNode; event: ItemEvent }; wall: { node: WallNode; event: WallEvent }; + building: { node: BuildingNode; event: BuildingEvent }; }; type NodeType = keyof NodeConfig; diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index fdc2b577..969357f8 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -1,3 +1,7 @@ +import { useGridEvents } from "./hooks/use-grid-events"; + export { default as Viewer } from "./components/viewer/viewer"; export { default as useViewer } from "./store/use-viewer"; + +export { useGridEvents } from "./hooks/use-grid-events"; \ No newline at end of file