draft wall builder

This commit is contained in:
wass08
2026-01-16 13:58:01 +09:00
parent 1b954d3416
commit 536c2e94cc
11 changed files with 225 additions and 16 deletions
+20 -4
View File
@@ -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() {
<ActionMenu />
<Viewer>
<Selector />
<DraftSelector />
<Stats />
<Grid cellColor="#666" sectionColor="#999" fadeDistance={30} />
<Passes />
<WallEditor />
</Viewer>
</div>
);
@@ -187,17 +189,31 @@ const Grid = ({
fadeStrength,
]);
const handlers = useGridEvents();
return (
<mesh rotation-x={-Math.PI / 2} material={material}>
<mesh rotation-x={-Math.PI / 2} material={material} {...handlers}>
<planeGeometry args={[fadeDistance * 2, fadeDistance * 2]} />
</mesh>
);
};
const Selector = () => {
const DraftSelector = () => {
const selectedItemId = useRef<ItemNode["id"] | WallNode["id"]>(null);
const itemSelectedAt = useRef<number>(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) {
@@ -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<Mesh>(null);
const drawingLineRef = useRef<Line>(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 (
<group>
<mesh ref={cursorRef}>
<boxGeometry args={[0.2, 0.2, 0.2]} />
<meshStandardMaterial color="red" />
</mesh>
<group>
{/* @ts-ignore */}
<line ref={drawingLineRef} frustumCulled={false} renderOrder={1} visible={false}>
<bufferGeometry />
<lineDashedNodeMaterial color="blue" linewidth={4} linecap="round" depthTest={false} depthWrite={false} dashSize={2} gapSize={0.1} />
</line>
</group>
</group>
)
}
+45 -1
View File
@@ -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<EditorState>()((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,
+4 -2
View File
@@ -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<T extends AnyNode = AnyNode> {
export type WallEvent = NodeEvent<WallNode>;
export type ItemEvent = NodeEvent<ItemNode>;
export type BuildingEvent = NodeEvent<BuildingNode>;
// 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<EditorEvents>();
+23
View File
@@ -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<SceneState>()((set, get) => ({
@@ -123,6 +125,27 @@ const useScene = create<SceneState>()((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();
@@ -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<Group>(null!);
useRegistry(node.id, node.type, ref);
const handlers = useNodeEvents(node, "building");
return (
<group ref={ref}>
{/* <mesh receiveShadow>
<boxGeometry args={[10, 0.1, 10]} />
<meshStandardMaterial color="orange" />
</mesh> */}
<group ref={ref} {...handlers}>
{node.children.map((childId) => (
<NodeRenderer key={childId} nodeId={childId} />
))}
@@ -7,6 +7,8 @@ export const LevelRenderer = ({ node }: { node: LevelNode }) => {
const ref = useRef<Group>(null!);
useRegistry(node.id, node.type, ref);
console.log('rendering level:', node.id, node.children);
return (
<group ref={ref}>
{/* <mesh receiveShadow>
@@ -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";
@@ -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<PointerEvent>) => {
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<PointerEvent>) => {
if (e.button !== 0) return;
emit("pointerdown", e);
},
onPointerUp: (e: ThreeEvent<PointerEvent>) => {
if (e.button !== 0) return;
emit("pointerup", e);
},
onClick: (e: ThreeEvent<PointerEvent>) => {
if (e.button !== 0) return;
emit("click", e);
},
onPointerEnter: (e: ThreeEvent<PointerEvent>) => emit("enter", e),
onPointerLeave: (e: ThreeEvent<PointerEvent>) => emit("leave", e),
onPointerMove: (e: ThreeEvent<PointerEvent>) => emit("move", e),
onDoubleClick: (e: ThreeEvent<PointerEvent>) => emit("double-click", e),
onContextMenu: (e: ThreeEvent<PointerEvent>) => emit("context-menu", e),
};
}
@@ -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;
+4
View File
@@ -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";