2026-01-29 12:52:35 +09:00

224 lines
6.9 KiB
TypeScript

import { emitter, type GridEvent, useScene, RoofNode, type LevelNode, type AnyNode } from "@pascal-app/core";
import { useViewer } from "@pascal-app/viewer";
import { useEffect, useMemo, useRef, useState } from "react";
import { BufferGeometry, DoubleSide, type Line, Vector3 } from "three";
import useEditor from "@/store/use-editor";
// Default roof dimensions
const DEFAULT_HEIGHT = 1.5;
const PREVIEW_LINE_HEIGHT = 0.03; // Very thin preview
/**
* Creates a roof with the given corners
*/
const commitRoofPlacement = (
levelId: LevelNode["id"],
corner1: [number, number, number],
corner2: [number, number, number]
): RoofNode["id"] => {
const { createNode, nodes } = useScene.getState();
// Calculate center position and dimensions from corners
const centerX = (corner1[0] + corner2[0]) / 2;
const centerZ = (corner1[2] + corner2[2]) / 2;
const length = Math.abs(corner2[0] - corner1[0]);
const width = Math.abs(corner2[2] - corner1[2]);
// Split width evenly between left and right slopes
const slopeWidth = Math.max(width / 2, 0.5);
// Count existing roofs for naming
const roofCount = Object.values(nodes).filter((n) => n.type === "roof").length;
const name = `Roof ${roofCount + 1}`;
const roof = RoofNode.parse({
name,
position: [centerX, 0, centerZ], // Y is always 0
length: Math.max(length, 0.5),
height: DEFAULT_HEIGHT,
leftWidth: slopeWidth,
rightWidth: slopeWidth,
});
createNode(roof, levelId);
return roof.id;
};
type PreviewState = {
corner1: [number, number, number] | null;
cursorPosition: [number, number, number];
levelY: number;
};
export const RoofTool: React.FC = () => {
const outlineRef = useRef<Line>(null!);
const currentLevelId = useViewer((state) => state.selection.levelId);
const setSelection = useViewer((state) => state.setSelection);
const setTool = useEditor((state) => state.setTool);
const setMode = useEditor((state) => state.setMode);
const corner1Ref = useRef<[number, number, number] | null>(null);
const [preview, setPreview] = useState<PreviewState>({
corner1: null,
cursorPosition: [0, 0, 0],
levelY: 0,
});
useEffect(() => {
if (!currentLevelId) return;
// Initialize outline geometry
outlineRef.current.geometry = new BufferGeometry();
const updateOutline = (corner1: [number, number, number], corner2: [number, number, number]) => {
const y = corner1[1] + PREVIEW_LINE_HEIGHT;
const points = [
new Vector3(corner1[0], y, corner1[2]),
new Vector3(corner2[0], y, corner1[2]),
new Vector3(corner2[0], y, corner2[2]),
new Vector3(corner1[0], y, corner2[2]),
new Vector3(corner1[0], y, corner1[2]), // Close the loop
];
outlineRef.current.geometry.dispose();
outlineRef.current.geometry = new BufferGeometry().setFromPoints(points);
outlineRef.current.visible = true;
};
const onGridMove = (event: GridEvent) => {
// Snap to 0.5 grid
const gridX = Math.round(event.position[0] * 2) / 2;
const gridZ = Math.round(event.position[2] * 2) / 2;
const y = event.position[1];
const cursorPosition: [number, number, number] = [gridX, y, gridZ];
setPreview({
corner1: corner1Ref.current,
cursorPosition,
levelY: y,
});
// Update outline if we have first corner
if (corner1Ref.current) {
updateOutline(corner1Ref.current, cursorPosition);
}
};
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;
const y = event.position[1];
if (!corner1Ref.current) {
// First click - set corner 1
corner1Ref.current = [gridX, y, gridZ];
setPreview((prev) => ({
...prev,
corner1: corner1Ref.current,
}));
} else {
// Second click - create the roof
const roofId = commitRoofPlacement(
currentLevelId,
corner1Ref.current,
[gridX, y, gridZ]
);
// Auto-select the newly created roof
setSelection({ selectedIds: [roofId as AnyNode["id"]] });
// Reset state
corner1Ref.current = null;
outlineRef.current.visible = false;
// Switch to select mode and deactivate tool
setMode('select');
setTool(null);
}
};
// Subscribe to events
emitter.on("grid:move", onGridMove);
emitter.on("grid:click", onGridClick);
return () => {
emitter.off("grid:move", onGridMove);
emitter.off("grid:click", onGridClick);
// Reset state on unmount
corner1Ref.current = null;
};
}, [currentLevelId, setTool, setSelection, setMode]);
const { corner1, cursorPosition, levelY } = preview;
// Calculate preview dimensions for display
const previewDimensions = useMemo(() => {
if (!corner1) return null;
const length = Math.abs(cursorPosition[0] - corner1[0]);
const width = Math.abs(cursorPosition[2] - corner1[2]);
const centerX = (corner1[0] + cursorPosition[0]) / 2;
const centerZ = (corner1[2] + cursorPosition[2]) / 2;
return { length, width, centerX, centerZ };
}, [corner1, cursorPosition]);
return (
<group>
{/* Outline showing rectangle being drawn */}
{/* @ts-ignore */}
<line ref={outlineRef} frustumCulled={false} renderOrder={1} visible={false}>
<bufferGeometry />
<lineBasicNodeMaterial
color="#8b4513"
linewidth={2}
depthTest={false}
depthWrite={false}
/>
</line>
{/* First corner marker */}
{corner1 && (
<mesh position={[corner1[0], levelY + 0.02, corner1[2]]} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[0.1, 0.15, 32]} />
<meshBasicMaterial
color="#22c55e"
depthTest={false}
depthWrite={false}
/>
</mesh>
)}
{/* Cursor marker on ground */}
<mesh position={[cursorPosition[0], cursorPosition[1] + 0.02, cursorPosition[2]]} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[0.1, 0.15, 32]} />
<meshBasicMaterial
color="#8b4513"
depthTest={false}
depthWrite={false}
/>
</mesh>
{/* Thin preview fill when drawing */}
{previewDimensions && previewDimensions.length > 0.1 && previewDimensions.width > 0.1 && (
<mesh
position={[previewDimensions.centerX, levelY + 0.01, previewDimensions.centerZ]}
rotation={[-Math.PI / 2, 0, 0]}
>
<planeGeometry args={[previewDimensions.length, previewDimensions.width]} />
<meshBasicMaterial
color="#8b4513"
opacity={0.2}
transparent
side={DoubleSide}
depthTest={false}
depthWrite={false}
/>
</mesh>
)}
</group>
);
};