diff --git a/apps/editor/components/tools/item/item-tool.tsx b/apps/editor/components/tools/item/item-tool.tsx index c6675091..5a633618 100644 --- a/apps/editor/components/tools/item/item-tool.tsx +++ b/apps/editor/components/tools/item/item-tool.tsx @@ -7,6 +7,7 @@ import { useRegistry, useScene, useSpatialQuery, + WallEvent, WallNode, } from "@pascal-app/core"; import { useViewer } from "@pascal-app/viewer"; @@ -23,9 +24,33 @@ export const ItemTool: React.FC = () => { const { canPlace } = useSpatialQuery(); useEffect(() => { + console.log("item-tool-reloaded"); if (!selectedItem) { 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 { currentLevelId } = useViewer.getState(); if (!currentLevelId) { @@ -42,11 +67,13 @@ export const ItemTool: React.FC = () => { asset: selectedItem, }); useScene.getState().createNode(draftItem.current, currentLevelId); + checkCanPlace(); }; createDraftItem(); const onGridMove = (event: GridEvent) => { if (!cursorRef.current) return; + if (isOnWall) return; gridPosition.current.set( Math.round(event.position[0] * 2) / 2, @@ -64,36 +91,19 @@ export const ItemTool: React.FC = () => { 0, gridPosition.current.z, ]; - - 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"); - } - } + checkCanPlace(); } }; const onGridClick = (event: GridEvent) => { 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.getState().updateNode(draftItem.current.id, { - position: [ - gridPosition.current.x, - gridPosition.current.y, - gridPosition.current.z, - ], + position: [gridPosition.current.x, 0, gridPosition.current.z], }); draftItem.current = null; @@ -101,8 +111,95 @@ export const ItemTool: React.FC = () => { 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: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 boxGeometry = new BoxGeometry( @@ -119,8 +216,13 @@ export const ItemTool: React.FC = () => { if (draftItem.current) { useScene.getState().deleteNode(draftItem.current.id); } + useScene.temporal.getState().resume(); emitter.off("grid:move", onGridMove); 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]); diff --git a/apps/editor/components/ui/item-catalog/catalog-items.tsx b/apps/editor/components/ui/item-catalog/catalog-items.tsx index 12ba8dab..bb99eecd 100644 --- a/apps/editor/components/ui/item-catalog/catalog-items.tsx +++ b/apps/editor/components/ui/item-catalog/catalog-items.tsx @@ -325,10 +325,10 @@ export const CATALOG_ITEMS: AssetInput[] = [ name: "Window", thumbnail: "/items/window-small/thumbnail.webp", src: "/items/window-small/model.glb", - offset: [0, 0.5, 0], + offset: [0, 0, 0], scale: [1, 1, 1], rotation: [0, Math.PI, 0], - dimensions: [2, 1, 1], + dimensions: [1, 1.5, 0.1], attachTo: "wall", }, { @@ -336,10 +336,10 @@ export const CATALOG_ITEMS: AssetInput[] = [ name: "Window", thumbnail: "/items/window-small-2/thumbnail.webp", src: "/items/window-small-2/model.glb", - offset: [0, 0.5, 0], + offset: [0, 0, 0], scale: [1, 1, 1], rotation: [0, Math.PI, 0], - dimensions: [2, 1, 1], + dimensions: [1, 1, 0.1], attachTo: "wall", }, { diff --git a/packages/core/src/events/bus.ts b/packages/core/src/events/bus.ts index bb6943d5..574cf061 100644 --- a/packages/core/src/events/bus.ts +++ b/packages/core/src/events/bus.ts @@ -10,6 +10,7 @@ export interface GridEvent { export interface NodeEvent { node: T; position: [number, number, number]; + localPosition: [number, number, number]; normal?: [number, number, number]; stopPropagation: () => void; } diff --git a/packages/core/src/store/use-scene.ts b/packages/core/src/store/use-scene.ts index 66af6f4e..9f165c22 100644 --- a/packages/core/src/store/use-scene.ts +++ b/packages/core/src/store/use-scene.ts @@ -93,6 +93,7 @@ const useScene = create()( type: "item", name: "Window", position: [2.5, 0.5, 0], + parentId: wall0.id, asset: { name: "Round Window", thumbnail: "/items/window-round/thumbnail.png", diff --git a/packages/core/src/systems/wall/wall-system.tsx b/packages/core/src/systems/wall/wall-system.tsx index 174dfd51..965e5e03 100644 --- a/packages/core/src/systems/wall/wall-system.tsx +++ b/packages/core/src/systems/wall/wall-system.tsx @@ -47,12 +47,20 @@ function updateWallGeometry(wallId: string) { .map((childId) => useScene.getState().nodes[childId]) .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); mesh.geometry.dispose(); 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]); // Rotate mesh to look at 'end' point diff --git a/packages/viewer/src/components/renderers/item/item-renderer.tsx b/packages/viewer/src/components/renderers/item/item-renderer.tsx index f64717ff..7cf91d80 100644 --- a/packages/viewer/src/components/renderers/item/item-renderer.tsx +++ b/packages/viewer/src/components/renderers/item/item-renderer.tsx @@ -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 { useGLTF } from "@react-three/drei/core/Gltf"; 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 { useNodeEvents } from "../../../hooks/use-node-events"; @@ -29,6 +35,11 @@ const ModelRenderer = ({ node }: { node: ItemNode }) => { const handlers = useNodeEvents(node, "item"); + useEffect(() => { + if (!node.parentId) return; + useScene.getState().dirtyNodes.add(node.parentId as AnyNodeId); + }, []); + return ( { const handlers = useNodeEvents(node, "wall"); return ( - + {/* WallSystem will replace this geometry in the next frame */} + + + {node.children.map((childId) => ( diff --git a/packages/viewer/src/hooks/use-node-events.ts b/packages/viewer/src/hooks/use-node-events.ts index 5ffffcc0..a35e3aff 100644 --- a/packages/viewer/src/hooks/use-node-events.ts +++ b/packages/viewer/src/hooks/use-node-events.ts @@ -18,12 +18,17 @@ type NodeConfig = { type NodeType = keyof NodeConfig; -export function useNodeEvents(node: NodeConfig[T]["node"], type: T) { +export function useNodeEvents( + node: NodeConfig[T]["node"], + type: T, +) { const emit = (suffix: EventSuffix, e: ThreeEvent) => { const eventKey = `${type}:${suffix}` as `${T}:${EventSuffix}`; + const localPoint = e.object.worldToLocal(e.point.clone()); const payload = { node, position: [e.point.x, e.point.y, e.point.z], + localPosition: [localPoint.x, localPoint.y, localPoint.z], normal: e.face ? [e.face.normal.x, e.face.normal.y, e.face.normal.z] : undefined, @@ -52,4 +57,4 @@ export function useNodeEvents(node: NodeConfig[T]["node"], t onDoubleClick: (e: ThreeEvent) => emit("double-click", e), onContextMenu: (e: ThreeEvent) => emit("context-menu", e), }; -} \ No newline at end of file +}