Files
editor/apps/editor/components/editor.tsx
T
2026-01-17 08:58:17 +09:00

311 lines
8.2 KiB
TypeScript

"use client";
import {
emitter,
ItemNode,
sceneRegistry,
useScene,
WallNode,
} from "@pascal-app/core";
import { useGridEvents, 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";
import { Color, Object3D } from "three";
import { outline } from "three/addons/tsl/display/OutlineNode.js";
import {
color,
float,
fract,
fwidth,
mix,
oscSine,
pass,
positionLocal,
time,
uniform,
} from "three/tsl";
import { MeshBasicNodeMaterial, PostProcessing } from "three/webgpu";
import { ActionMenu } from "./ui/action-menu";
import { WallTool } from "./tools/wall/wall-tool";
import { ToolManager } from "./tools/tool-manager";
const selectedObjects: Object3D[] = [];
export default function Editor() {
return (
<div className="w-full h-full bg-pink-50">
{/* <LevelModeSwitcher /> */}
<ActionMenu />
<Viewer>
<DraftSelector />
<Stats />
<Grid cellColor="#666" sectionColor="#999" fadeDistance={30} />
<Passes />
<ToolManager />
</Viewer>
</div>
);
}
export const Passes = ({}) => {
const { gl: renderer, scene, camera } = useThree();
const postProcessingRef = useRef(null);
useEffect(() => {
if (!renderer || !scene || !camera) {
return;
}
const scenePass = pass(scene, camera);
// Get texture nodes
const outputPass = scenePass.getTextureNode("output");
const edgeStrength = uniform(3.0);
const edgeGlow = uniform(0.0);
const edgeThickness = uniform(1.0);
const pulsePeriod = uniform(0);
const visibleEdgeColor = uniform(new Color(0xffffff));
const hiddenEdgeColor = uniform(new Color(0x4e3636));
const outlinePass = outline(scene, camera, {
selectedObjects,
edgeGlow,
edgeThickness,
});
const { visibleEdge, hiddenEdge } = outlinePass;
const period = time.div(pulsePeriod).mul(2);
const osc = oscSine(period).mul(0.5).add(0.5); // osc [ 0.5, 1.0 ]
const outlineColor = visibleEdge
.mul(visibleEdgeColor)
.add(hiddenEdge.mul(hiddenEdgeColor))
.mul(edgeStrength);
const outlinePulse = pulsePeriod
.greaterThan(0)
.select(outlineColor.mul(osc), outlineColor);
// Setup post-processing
const postProcessing = new PostProcessing(renderer);
postProcessing.outputNode = outlinePulse.add(scenePass);
postProcessingRef.current = postProcessing;
return () => {
postProcessingRef.current = null;
};
}, [renderer, scene, camera]);
useFrame(() => {
if (postProcessingRef.current) {
postProcessingRef.current.render();
}
}, 1);
return null;
};
const Grid = ({
cellSize = 0.5,
cellThickness = 0.5,
cellColor = "#888888",
sectionSize = 1,
sectionThickness = 1,
sectionColor = "#000000",
fadeDistance = 100,
fadeStrength = 1,
}: {
cellSize?: number;
cellThickness?: number;
cellColor?: string;
sectionSize?: number;
sectionThickness?: number;
sectionColor?: string;
fadeDistance?: number;
fadeStrength?: number;
}) => {
const material = useMemo(() => {
// Use xy since plane geometry is in XY space (before rotation)
const pos = positionLocal.xy;
// Grid line function using fwidth for anti-aliasing
// Returns 1 on grid lines, 0 elsewhere
const getGrid = (size: number, thickness: number) => {
const r = pos.div(size);
const fw = fwidth(r);
// Distance to nearest grid line for each axis
const grid = fract(r.sub(0.5)).sub(0.5).abs();
// Anti-aliased step: divide by fwidth and clamp
const lineX = float(1).sub(
grid.x
.div(fw.x)
.add(1 - thickness)
.min(1)
);
const lineY = float(1).sub(
grid.y
.div(fw.y)
.add(1 - thickness)
.min(1)
);
// Combine both axes - max gives us lines in both directions
return lineX.max(lineY);
};
const g1 = getGrid(cellSize, cellThickness);
const g2 = getGrid(sectionSize, sectionThickness);
// Distance fade from center
const dist = pos.length();
const fade = float(1).sub(dist.div(fadeDistance).min(1)).pow(fadeStrength);
// Mix colors based on section grid
const gridColor = mix(
color(cellColor),
color(sectionColor),
float(sectionThickness).mul(g2).min(1)
);
// Combined alpha
const alpha = g1.add(g2).mul(fade);
const finalAlpha = mix(alpha.mul(0.75), alpha, g2);
return new MeshBasicNodeMaterial({
transparent: true,
colorNode: gridColor,
opacityNode: finalAlpha,
depthWrite: false,
});
}, [
cellSize,
cellThickness,
cellColor,
sectionSize,
sectionThickness,
sectionColor,
fadeDistance,
fadeStrength,
]);
const handlers = useGridEvents();
return (
<mesh rotation-x={-Math.PI / 2} material={material} {...handlers}>
<planeGeometry args={[fadeDistance * 2, fadeDistance * 2]} />
</mesh>
);
};
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) {
return;
}
itemSelectedAt.current = Date.now();
if (selectedItemId.current === event.node.id) {
selectedItemId.current = null;
console.log("Deselected item:", event.node.id);
selectedObjects.length = 0;
return;
}
selectedItemId.current = event.node.id;
const itemMesh = sceneRegistry.nodes.get(event.node.id);
if (!itemMesh) return;
selectedObjects.push(itemMesh);
console.log("Selected item:", event.node.id);
});
emitter.on("wall:click", (event) => {
if (Date.now() - itemSelectedAt.current < 50) {
return;
}
itemSelectedAt.current = Date.now();
if (selectedItemId.current === event.node.id) {
selectedItemId.current = null;
console.log("Deselected item:", event.node.id);
selectedObjects.length = 0;
return;
}
selectedItemId.current = event.node.id;
const itemMesh = sceneRegistry.nodes.get(event.node.id);
if (!itemMesh) return;
selectedObjects.push(itemMesh);
console.log("Selected item:", event.node.id);
});
emitter.on("wall:move", (event) => {
const wallNode = event.node as WallNode;
if (wallNode.children.length === 0) return;
const itemId = wallNode.children[0];
if (!itemId) return;
if (selectedItemId.current !== itemId) return;
const itemNode = useScene.getState().nodes[itemId];
const itemMesh = sceneRegistry.nodes.get(itemId);
if (!itemNode || !itemMesh) return;
itemMesh.position.set(
event.position[0],
event.position[1],
event.position[2]
);
useScene.getState().dirtyNodes.add(wallNode.id);
console.log(
"Wall move event:",
wallNode.id,
"Point position:",
event.position
);
});
}, []);
return null;
};
const LevelModeSwitcher = () => {
const setLevelMode = useViewer((state) => state.setLevelMode);
const levelMode = useViewer((state) => state.levelMode);
return (
<div className="absolute top-4 left-4 z-10 flex gap-2">
<button
className={`px-4 py-2 rounded ${
levelMode === "exploded" ? "bg-blue-500 text-white" : "bg-white"
}`}
onClick={() => setLevelMode("exploded")}
>
Exploded
</button>
<button
className={`px-4 py-2 rounded ${
levelMode === "stacked" ? "bg-blue-500 text-white" : "bg-white"
}`}
onClick={() => setLevelMode("stacked")}
>
Stacked
</button>
</div>
);
};