drafting wall attach system

This commit is contained in:
wass08
2026-01-19 07:59:06 +09:00
parent 159b61d22a
commit a217d0d3a6
8 changed files with 163 additions and 37 deletions
+124 -22
View File
@@ -7,6 +7,7 @@ import {
useRegistry, useRegistry,
useScene, useScene,
useSpatialQuery, useSpatialQuery,
WallEvent,
WallNode, WallNode,
} from "@pascal-app/core"; } from "@pascal-app/core";
import { useViewer } from "@pascal-app/viewer"; import { useViewer } from "@pascal-app/viewer";
@@ -23,9 +24,33 @@ export const ItemTool: React.FC = () => {
const { canPlace } = useSpatialQuery(); const { canPlace } = useSpatialQuery();
useEffect(() => { useEffect(() => {
console.log("item-tool-reloaded");
if (!selectedItem) { if (!selectedItem) {
return; return;
} }
let isOnWall = false;
let wallLocked = false;
const checkCanPlace = () => {
const currentLevelId = useViewer.getState().currentLevelId;
if (currentLevelId && draftItem.current) {
const placeable = canPlace(
currentLevelId,
[gridPosition.current.x, 0, gridPosition.current.z],
selectedItem.dimensions,
[0, 0, 0],
[draftItem.current.id],
);
if (placeable.valid) {
cursorRef.current.material.color.set("green");
return true;
} else {
cursorRef.current.material.color.set("red");
return false;
}
}
};
const createDraftItem = () => { const createDraftItem = () => {
const { currentLevelId } = useViewer.getState(); const { currentLevelId } = useViewer.getState();
if (!currentLevelId) { if (!currentLevelId) {
@@ -42,11 +67,13 @@ export const ItemTool: React.FC = () => {
asset: selectedItem, asset: selectedItem,
}); });
useScene.getState().createNode(draftItem.current, currentLevelId); useScene.getState().createNode(draftItem.current, currentLevelId);
checkCanPlace();
}; };
createDraftItem(); createDraftItem();
const onGridMove = (event: GridEvent) => { const onGridMove = (event: GridEvent) => {
if (!cursorRef.current) return; if (!cursorRef.current) return;
if (isOnWall) return;
gridPosition.current.set( gridPosition.current.set(
Math.round(event.position[0] * 2) / 2, Math.round(event.position[0] * 2) / 2,
@@ -64,36 +91,19 @@ export const ItemTool: React.FC = () => {
0, 0,
gridPosition.current.z, gridPosition.current.z,
]; ];
checkCanPlace();
const currentLevelId = useViewer.getState().currentLevelId;
if (currentLevelId) {
const placeable = canPlace(
currentLevelId,
[gridPosition.current.x, 0, gridPosition.current.z],
selectedItem.dimensions,
[0, 0, 0],
[draftItem.current.id],
);
if (placeable.valid) {
cursorRef.current.material.color.set("green");
} else {
cursorRef.current.material.color.set("red");
}
}
} }
}; };
const onGridClick = (event: GridEvent) => { const onGridClick = (event: GridEvent) => {
const { currentLevelId } = useViewer.getState(); const { currentLevelId } = useViewer.getState();
if (isOnWall) return;
if (!currentLevelId || !draftItem.current) return; if (!currentLevelId || !draftItem.current || !checkCanPlace()) return;
console.log("ongridclick action");
useScene.temporal.getState().resume(); useScene.temporal.getState().resume();
useScene.getState().updateNode(draftItem.current.id, { useScene.getState().updateNode(draftItem.current.id, {
position: [ position: [gridPosition.current.x, 0, gridPosition.current.z],
gridPosition.current.x,
gridPosition.current.y,
gridPosition.current.z,
],
}); });
draftItem.current = null; draftItem.current = null;
@@ -101,8 +111,95 @@ export const ItemTool: React.FC = () => {
createDraftItem(); createDraftItem();
}; };
const onWallEnter = (event: WallEvent) => {
console.log("Wall enter", event);
event.stopPropagation();
if (
draftItem.current?.asset.attachTo === "wall" ||
draftItem.current?.asset.attachTo === "wall-side"
) {
isOnWall = true;
gridPosition.current.set(
Math.round(event.localPosition[0] * 2) / 2,
Math.round(event.localPosition[1] * 2) / 2,
Math.round(event.localPosition[2] * 2) / 2,
);
draftItem.current.parentId = event.node.id;
useScene.getState().updateNode(draftItem.current.id, {
position: [
gridPosition.current.x,
gridPosition.current.y,
gridPosition.current.z,
],
parentId: event.node.id,
});
}
};
const onWallLeave = (event: WallEvent) => {
console.log("Wall leave", event);
isOnWall = false;
event.stopPropagation();
};
const onWallClick = (event: WallEvent) => {
event.stopPropagation();
if (wallLocked) {
return;
}
const { currentLevelId } = useViewer.getState();
if (!currentLevelId || !draftItem.current || !checkCanPlace()) return;
wallLocked = true;
console.log("onwallclick action");
useScene.temporal.getState().resume();
useScene.getState().updateNode(draftItem.current.id, {
position: [
gridPosition.current.x,
gridPosition.current.y,
gridPosition.current.z,
],
parentId: event.node.id,
});
useScene.getState().dirtyNodes.add(event.node.id);
draftItem.current = null;
useScene.temporal.getState().pause();
createDraftItem();
};
const onWallMove = (event: WallEvent) => {
event.stopPropagation();
console.log("Wall move", event);
wallLocked = false;
if (!draftItem.current) return;
gridPosition.current.set(
Math.round(event.localPosition[0] * 2) / 2,
Math.round(event.localPosition[1] * 2) / 2,
Math.round(event.localPosition[2] * 2) / 2,
);
cursorRef.current.position.set(
event.position[0],
event.position[1],
event.position[2],
);
if (draftItem.current) {
draftItem.current.position = [
gridPosition.current.x,
gridPosition.current.y,
gridPosition.current.z,
];
useScene.getState().dirtyNodes.add(event.node.id);
checkCanPlace();
}
};
emitter.on("grid:move", onGridMove); emitter.on("grid:move", onGridMove);
emitter.on("grid:click", onGridClick); emitter.on("grid:click", onGridClick);
emitter.on("wall:enter", onWallEnter);
emitter.on("wall:move", onWallMove);
emitter.on("wall:click", onWallClick);
emitter.on("wall:leave", onWallLeave);
const setupBoundingBox = () => { const setupBoundingBox = () => {
const boxGeometry = new BoxGeometry( const boxGeometry = new BoxGeometry(
@@ -119,8 +216,13 @@ export const ItemTool: React.FC = () => {
if (draftItem.current) { if (draftItem.current) {
useScene.getState().deleteNode(draftItem.current.id); useScene.getState().deleteNode(draftItem.current.id);
} }
useScene.temporal.getState().resume();
emitter.off("grid:move", onGridMove); emitter.off("grid:move", onGridMove);
emitter.off("grid:click", onGridClick); emitter.off("grid:click", onGridClick);
emitter.off("wall:enter", onWallEnter);
emitter.off("wall:leave", onWallLeave);
emitter.off("wall:click", onWallClick);
emitter.off("wall:move", onWallMove);
}; };
}, [selectedItem]); }, [selectedItem]);
@@ -325,10 +325,10 @@ export const CATALOG_ITEMS: AssetInput[] = [
name: "Window", name: "Window",
thumbnail: "/items/window-small/thumbnail.webp", thumbnail: "/items/window-small/thumbnail.webp",
src: "/items/window-small/model.glb", src: "/items/window-small/model.glb",
offset: [0, 0.5, 0], offset: [0, 0, 0],
scale: [1, 1, 1], scale: [1, 1, 1],
rotation: [0, Math.PI, 0], rotation: [0, Math.PI, 0],
dimensions: [2, 1, 1], dimensions: [1, 1.5, 0.1],
attachTo: "wall", attachTo: "wall",
}, },
{ {
@@ -336,10 +336,10 @@ export const CATALOG_ITEMS: AssetInput[] = [
name: "Window", name: "Window",
thumbnail: "/items/window-small-2/thumbnail.webp", thumbnail: "/items/window-small-2/thumbnail.webp",
src: "/items/window-small-2/model.glb", src: "/items/window-small-2/model.glb",
offset: [0, 0.5, 0], offset: [0, 0, 0],
scale: [1, 1, 1], scale: [1, 1, 1],
rotation: [0, Math.PI, 0], rotation: [0, Math.PI, 0],
dimensions: [2, 1, 1], dimensions: [1, 1, 0.1],
attachTo: "wall", attachTo: "wall",
}, },
{ {
+1
View File
@@ -10,6 +10,7 @@ export interface GridEvent {
export interface NodeEvent<T extends AnyNode = AnyNode> { export interface NodeEvent<T extends AnyNode = AnyNode> {
node: T; node: T;
position: [number, number, number]; position: [number, number, number];
localPosition: [number, number, number];
normal?: [number, number, number]; normal?: [number, number, number];
stopPropagation: () => void; stopPropagation: () => void;
} }
+1
View File
@@ -93,6 +93,7 @@ const useScene = create<SceneState>()(
type: "item", type: "item",
name: "Window", name: "Window",
position: [2.5, 0.5, 0], position: [2.5, 0.5, 0],
parentId: wall0.id,
asset: { asset: {
name: "Round Window", name: "Round Window",
thumbnail: "/items/window-round/thumbnail.png", thumbnail: "/items/window-round/thumbnail.png",
@@ -47,12 +47,20 @@ function updateWallGeometry(wallId: string) {
.map((childId) => useScene.getState().nodes[childId]) .map((childId) => useScene.getState().nodes[childId])
.filter((n): n is AnyNode => n !== undefined); .filter((n): n is AnyNode => n !== undefined);
// Perform the Extrusion with Holes logic we discussed // Generate visual geometry with holes
const newGeo = generateExtrudedWall(node, childrenNodes); const newGeo = generateExtrudedWall(node, childrenNodes);
mesh.geometry.dispose(); mesh.geometry.dispose();
mesh.geometry = newGeo; mesh.geometry = newGeo;
// Update collision mesh with solid geometry (no holes)
const collisionMesh = mesh.getObjectByName("collision-mesh") as THREE.Mesh;
if (collisionMesh) {
const collisionGeo = generateExtrudedWall(node, []); // No children = no holes
collisionMesh.geometry.dispose();
collisionMesh.geometry = collisionGeo;
}
mesh.position.set(node.start[0], 0, node.start[1]); mesh.position.set(node.start[0], 0, node.start[1]);
// Rotate mesh to look at 'end' point // Rotate mesh to look at 'end' point
@@ -1,8 +1,14 @@
import { emitter, ItemNode, useRegistry } from "@pascal-app/core"; import {
AnyNodeId,
emitter,
ItemNode,
useRegistry,
useScene,
} from "@pascal-app/core";
import { Clone } from "@react-three/drei/core/Clone"; import { Clone } from "@react-three/drei/core/Clone";
import { useGLTF } from "@react-three/drei/core/Gltf"; import { useGLTF } from "@react-three/drei/core/Gltf";
import { ThreeEvent } from "@react-three/fiber/dist/declarations/src/core/events"; import { ThreeEvent } from "@react-three/fiber/dist/declarations/src/core/events";
import { Suspense, useCallback, useRef } from "react"; import { Suspense, useCallback, useEffect, useRef } from "react";
import { Group } from "three"; import { Group } from "three";
import { useNodeEvents } from "../../../hooks/use-node-events"; import { useNodeEvents } from "../../../hooks/use-node-events";
@@ -29,6 +35,11 @@ const ModelRenderer = ({ node }: { node: ItemNode }) => {
const handlers = useNodeEvents(node, "item"); const handlers = useNodeEvents(node, "item");
useEffect(() => {
if (!node.parentId) return;
useScene.getState().dirtyNodes.add(node.parentId as AnyNodeId);
}, []);
return ( return (
<Clone <Clone
object={scene} object={scene}
@@ -12,15 +12,13 @@ export const WallRenderer = ({ node }: { node: WallNode }) => {
const handlers = useNodeEvents(node, "wall"); const handlers = useNodeEvents(node, "wall");
return ( return (
<mesh <mesh ref={ref} castShadow receiveShadow>
ref={ref}
castShadow
receiveShadow
{...handlers}
>
{/* WallSystem will replace this geometry in the next frame */} {/* WallSystem will replace this geometry in the next frame */}
<boxGeometry args={[0, 0, 0]} /> <boxGeometry args={[0, 0, 0]} />
<meshStandardMaterial color="lightgray" /> <meshStandardMaterial color="lightgray" />
<mesh name="collision-mesh" {...handlers} visible={false}>
<boxGeometry args={[0, 0, 0]} />
</mesh>
{node.children.map((childId) => ( {node.children.map((childId) => (
<NodeRenderer key={childId} nodeId={childId} /> <NodeRenderer key={childId} nodeId={childId} />
+7 -2
View File
@@ -18,12 +18,17 @@ type NodeConfig = {
type NodeType = keyof NodeConfig; type NodeType = keyof NodeConfig;
export function useNodeEvents<T extends NodeType>(node: NodeConfig[T]["node"], type: T) { export function useNodeEvents<T extends NodeType>(
node: NodeConfig[T]["node"],
type: T,
) {
const emit = (suffix: EventSuffix, e: ThreeEvent<PointerEvent>) => { const emit = (suffix: EventSuffix, e: ThreeEvent<PointerEvent>) => {
const eventKey = `${type}:${suffix}` as `${T}:${EventSuffix}`; const eventKey = `${type}:${suffix}` as `${T}:${EventSuffix}`;
const localPoint = e.object.worldToLocal(e.point.clone());
const payload = { const payload = {
node, node,
position: [e.point.x, e.point.y, e.point.z], position: [e.point.x, e.point.y, e.point.z],
localPosition: [localPoint.x, localPoint.y, localPoint.z],
normal: e.face normal: e.face
? [e.face.normal.x, e.face.normal.y, e.face.normal.z] ? [e.face.normal.x, e.face.normal.y, e.face.normal.z]
: undefined, : undefined,
@@ -52,4 +57,4 @@ export function useNodeEvents<T extends NodeType>(node: NodeConfig[T]["node"], t
onDoubleClick: (e: ThreeEvent<PointerEvent>) => emit("double-click", e), onDoubleClick: (e: ThreeEvent<PointerEvent>) => emit("double-click", e),
onContextMenu: (e: ThreeEvent<PointerEvent>) => emit("context-menu", e), onContextMenu: (e: ThreeEvent<PointerEvent>) => emit("context-menu", e),
}; };
} }