roof
This commit is contained in:
@@ -14,6 +14,7 @@ import { ZoneSystem } from '../systems/zone/zone-system'
|
|||||||
import { ToolManager } from '../tools/tool-manager'
|
import { ToolManager } from '../tools/tool-manager'
|
||||||
import { ActionMenu } from '../ui/action-menu'
|
import { ActionMenu } from '../ui/action-menu'
|
||||||
import { ReferencePanel } from '../ui/panels/reference-panel'
|
import { ReferencePanel } from '../ui/panels/reference-panel'
|
||||||
|
import { RoofPanel } from '../ui/panels/roof-panel'
|
||||||
import { SidebarProvider } from '../ui/primitives/sidebar'
|
import { SidebarProvider } from '../ui/primitives/sidebar'
|
||||||
import { AppSidebar } from '../ui/sidebar/app-sidebar'
|
import { AppSidebar } from '../ui/sidebar/app-sidebar'
|
||||||
import { CustomCameraControls } from './custom-camera-controls'
|
import { CustomCameraControls } from './custom-camera-controls'
|
||||||
@@ -33,6 +34,7 @@ export default function Editor() {
|
|||||||
<TestUndo />
|
<TestUndo />
|
||||||
<ActionMenu />
|
<ActionMenu />
|
||||||
<ReferencePanel />
|
<ReferencePanel />
|
||||||
|
<RoofPanel />
|
||||||
|
|
||||||
<SidebarProvider className="fixed z-10">
|
<SidebarProvider className="fixed z-10">
|
||||||
<AppSidebar />
|
<AppSidebar />
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ const isNodeInCurrentLevel = (node: AnyNode): boolean => {
|
|||||||
return nodeLevelId === currentLevelId;
|
return nodeLevelId === currentLevelId;
|
||||||
};
|
};
|
||||||
|
|
||||||
type SelectableNodeType = "wall" | "item" | "building" | "zone" | 'slab' | 'ceiling';
|
type SelectableNodeType = "wall" | "item" | "building" | "zone" | 'slab' | 'ceiling' | 'roof';
|
||||||
|
|
||||||
interface SelectionStrategy {
|
interface SelectionStrategy {
|
||||||
types: SelectableNodeType[];
|
types: SelectableNodeType[];
|
||||||
@@ -44,7 +44,7 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
structure: {
|
structure: {
|
||||||
types: ["wall", "item", "zone", "slab", "ceiling"],
|
types: ["wall", "item", "zone", "slab", "ceiling", "roof"],
|
||||||
handleSelect: (node, isShift) => {
|
handleSelect: (node, isShift) => {
|
||||||
// Single click on item (door/window) → enter move mode
|
// Single click on item (door/window) → enter move mode
|
||||||
if (!isShift && node.type === 'item') {
|
if (!isShift && node.type === 'item') {
|
||||||
@@ -78,7 +78,7 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
|
|||||||
if (node.type === "zone") return true;
|
if (node.type === "zone") return true;
|
||||||
return false;
|
return false;
|
||||||
} else {
|
} else {
|
||||||
if (node.type === "wall" || node.type === "slab" || node.type === "ceiling") return true;
|
if (node.type === "wall" || node.type === "slab" || node.type === "ceiling" || node.type === "roof") return true;
|
||||||
if (node.type === "item") {
|
if (node.type === "item") {
|
||||||
return (
|
return (
|
||||||
(node as ItemNode).asset.category === "door" ||
|
(node as ItemNode).asset.category === "door" ||
|
||||||
|
|||||||
@@ -0,0 +1,223 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -3,6 +3,7 @@ import { useViewer } from "@pascal-app/viewer";
|
|||||||
import { CeilingTool } from "./ceiling/ceiling-tool";
|
import { CeilingTool } from "./ceiling/ceiling-tool";
|
||||||
import { ItemTool } from "./item/item-tool";
|
import { ItemTool } from "./item/item-tool";
|
||||||
import { MoveTool } from "./item/move-tool";
|
import { MoveTool } from "./item/move-tool";
|
||||||
|
import { RoofTool } from "./roof/roof-tool";
|
||||||
import { SlabTool } from "./slab/slab-tool";
|
import { SlabTool } from "./slab/slab-tool";
|
||||||
import { WallTool } from "./wall/wall-tool";
|
import { WallTool } from "./wall/wall-tool";
|
||||||
import { ZoneBoundaryEditor } from "./zone/zone-boundary-editor";
|
import { ZoneBoundaryEditor } from "./zone/zone-boundary-editor";
|
||||||
@@ -14,6 +15,7 @@ const tools: Record<Phase, Partial<Record<Tool, React.FC>>> = {
|
|||||||
wall: WallTool,
|
wall: WallTool,
|
||||||
slab: SlabTool,
|
slab: SlabTool,
|
||||||
ceiling: CeilingTool,
|
ceiling: CeilingTool,
|
||||||
|
roof: RoofTool,
|
||||||
item: ItemTool,
|
item: ItemTool,
|
||||||
zone: ZoneTool,
|
zone: ZoneTool,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,232 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { type AnyNode, type RoofNode, useScene } from '@pascal-app/core'
|
||||||
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
|
import { Home, X } from 'lucide-react'
|
||||||
|
import { useCallback } from 'react'
|
||||||
|
|
||||||
|
export function RoofPanel() {
|
||||||
|
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
||||||
|
const setSelection = useViewer((s) => s.setSelection)
|
||||||
|
const nodes = useScene((s) => s.nodes)
|
||||||
|
const updateNode = useScene((s) => s.updateNode)
|
||||||
|
|
||||||
|
// Get the first selected node if it's a roof
|
||||||
|
const selectedId = selectedIds[0]
|
||||||
|
const node = selectedId
|
||||||
|
? (nodes[selectedId as AnyNode['id']] as RoofNode | undefined)
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
const handleUpdate = useCallback(
|
||||||
|
(updates: Partial<RoofNode>) => {
|
||||||
|
if (!selectedId) return
|
||||||
|
updateNode(selectedId as AnyNode['id'], updates)
|
||||||
|
},
|
||||||
|
[selectedId, updateNode],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
setSelection({ selectedIds: [] })
|
||||||
|
}, [setSelection])
|
||||||
|
|
||||||
|
// Only show if exactly one roof is selected
|
||||||
|
if (!node || node.type !== 'roof' || selectedIds.length !== 1) return null
|
||||||
|
|
||||||
|
// Calculate total width for display
|
||||||
|
const totalWidth = node.leftWidth + node.rightWidth
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pointer-events-auto fixed top-20 right-4 z-50 flex w-72 flex-col overflow-hidden rounded-lg border border-border bg-background/95 shadow-xl backdrop-blur-md">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between gap-2 border-b p-3">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<Home className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
|
<h2 className="font-semibold text-foreground text-sm truncate">
|
||||||
|
{node.name || 'Gable Roof'}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="shrink-0 rounded p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground cursor-pointer"
|
||||||
|
onClick={handleClose}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-3">
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Length */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
|
||||||
|
Length
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
className="flex-1 rounded border border-input bg-background px-2 py-1 text-foreground text-sm outline-none focus:border-primary"
|
||||||
|
min="0.5"
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = Number.parseFloat(e.target.value)
|
||||||
|
if (!Number.isNaN(value) && value > 0) {
|
||||||
|
handleUpdate({ length: value })
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
step="0.5"
|
||||||
|
type="number"
|
||||||
|
value={Math.round(node.length * 100) / 100}
|
||||||
|
/>
|
||||||
|
<span className="text-muted-foreground text-xs shrink-0">m</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Height */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
|
||||||
|
Height
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
className="flex-1 rounded border border-input bg-background px-2 py-1 text-foreground text-sm outline-none focus:border-primary"
|
||||||
|
min="0.1"
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = Number.parseFloat(e.target.value)
|
||||||
|
if (!Number.isNaN(value) && value > 0) {
|
||||||
|
handleUpdate({ height: value })
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
step="0.1"
|
||||||
|
type="number"
|
||||||
|
value={Math.round(node.height * 100) / 100}
|
||||||
|
/>
|
||||||
|
<span className="text-muted-foreground text-xs shrink-0">m</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Slope Widths */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
|
||||||
|
Slope Widths
|
||||||
|
</label>
|
||||||
|
<span className="text-muted-foreground text-xs">
|
||||||
|
Total: {totalWidth.toFixed(1)}m
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-muted-foreground text-xs">Left</label>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<input
|
||||||
|
className="w-full rounded border border-input bg-background px-2 py-1 text-foreground text-sm outline-none focus:border-primary"
|
||||||
|
min="0.1"
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = Number.parseFloat(e.target.value)
|
||||||
|
if (!Number.isNaN(value) && value > 0) {
|
||||||
|
handleUpdate({ leftWidth: value })
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
step="0.1"
|
||||||
|
type="number"
|
||||||
|
value={Math.round(node.leftWidth * 100) / 100}
|
||||||
|
/>
|
||||||
|
<span className="text-muted-foreground text-xs shrink-0">m</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-muted-foreground text-xs">Right</label>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<input
|
||||||
|
className="w-full rounded border border-input bg-background px-2 py-1 text-foreground text-sm outline-none focus:border-primary"
|
||||||
|
min="0.1"
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = Number.parseFloat(e.target.value)
|
||||||
|
if (!Number.isNaN(value) && value > 0) {
|
||||||
|
handleUpdate({ rightWidth: value })
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
step="0.1"
|
||||||
|
type="number"
|
||||||
|
value={Math.round(node.rightWidth * 100) / 100}
|
||||||
|
/>
|
||||||
|
<span className="text-muted-foreground text-xs shrink-0">m</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Rotation */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
|
||||||
|
Rotation
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<input
|
||||||
|
className="min-w-0 flex-1 rounded border border-input bg-background px-2 py-1 text-foreground text-sm outline-none focus:border-primary"
|
||||||
|
onChange={(e) => {
|
||||||
|
const degrees = Number.parseFloat(e.target.value)
|
||||||
|
if (!Number.isNaN(degrees)) {
|
||||||
|
const radians = (degrees * Math.PI) / 180
|
||||||
|
handleUpdate({ rotation: radians })
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
step="1"
|
||||||
|
type="number"
|
||||||
|
value={Math.round((node.rotation * 180) / Math.PI)}
|
||||||
|
/>
|
||||||
|
<span className="text-muted-foreground text-xs shrink-0">°</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="shrink-0 rounded border border-border px-1.5 py-0.5 text-xs hover:bg-accent cursor-pointer"
|
||||||
|
onClick={() => {
|
||||||
|
const newRotation = node.rotation - Math.PI / 2
|
||||||
|
handleUpdate({ rotation: newRotation })
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
−90
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="shrink-0 rounded border border-border px-1.5 py-0.5 text-xs hover:bg-accent cursor-pointer"
|
||||||
|
onClick={() => {
|
||||||
|
const newRotation = node.rotation + Math.PI / 2
|
||||||
|
handleUpdate({ rotation: newRotation })
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
+90
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Position */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
|
||||||
|
Position
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{([0, 1, 2] as const).map((i) => (
|
||||||
|
<div key={i} className="space-y-1">
|
||||||
|
<label className="text-muted-foreground text-xs">{['X', 'Y', 'Z'][i]}</label>
|
||||||
|
<input
|
||||||
|
className="w-full rounded border border-input bg-background px-2 py-1 text-foreground text-sm outline-none focus:border-primary"
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = Number.parseFloat(e.target.value)
|
||||||
|
if (!Number.isNaN(value)) {
|
||||||
|
const pos = [...node.position] as [number, number, number]
|
||||||
|
pos[i] = value
|
||||||
|
handleUpdate({ position: pos })
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
step="0.5"
|
||||||
|
type="number"
|
||||||
|
value={Math.round(node.position[i] * 100) / 100}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { RoofNode } from "@pascal-app/core";
|
||||||
|
import { useViewer } from "@pascal-app/viewer";
|
||||||
|
import { Home } from "lucide-react";
|
||||||
|
import { TreeNodeWrapper } from "./tree-node";
|
||||||
|
import { TreeNodeActions } from "./tree-node-actions";
|
||||||
|
|
||||||
|
interface RoofTreeNodeProps {
|
||||||
|
node: RoofNode;
|
||||||
|
depth: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RoofTreeNode({ node, depth }: RoofTreeNodeProps) {
|
||||||
|
const isSelected = useViewer((state) => state.selection.selectedIds.includes(node.id));
|
||||||
|
const isHovered = useViewer((state) => state.hoveredId === node.id);
|
||||||
|
const setSelection = useViewer((state) => state.setSelection);
|
||||||
|
const setHoveredId = useViewer((state) => state.setHoveredId);
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
setSelection({ selectedIds: [node.id] });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseEnter = () => {
|
||||||
|
setHoveredId(node.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseLeave = () => {
|
||||||
|
setHoveredId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate dimensions: length × total width (leftWidth + rightWidth)
|
||||||
|
const totalWidth = node.leftWidth + node.rightWidth;
|
||||||
|
const sizeLabel = `${node.length.toFixed(1)}×${totalWidth.toFixed(1)}m`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TreeNodeWrapper
|
||||||
|
icon={<Home className="w-3.5 h-3.5" />}
|
||||||
|
label={node.name || `Roof (${sizeLabel})`}
|
||||||
|
depth={depth}
|
||||||
|
hasChildren={false}
|
||||||
|
expanded={false}
|
||||||
|
onToggle={() => {}}
|
||||||
|
onClick={handleClick}
|
||||||
|
onMouseEnter={handleMouseEnter}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
|
isSelected={isSelected}
|
||||||
|
isHovered={isHovered}
|
||||||
|
actions={<TreeNodeActions node={node} />}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { BuildingTreeNode } from "./building-tree-node";
|
|||||||
import { CeilingTreeNode } from "./ceiling-tree-node";
|
import { CeilingTreeNode } from "./ceiling-tree-node";
|
||||||
import { ItemTreeNode } from "./item-tree-node";
|
import { ItemTreeNode } from "./item-tree-node";
|
||||||
import { LevelTreeNode } from "./level-tree-node";
|
import { LevelTreeNode } from "./level-tree-node";
|
||||||
|
import { RoofTreeNode } from "./roof-tree-node";
|
||||||
import { SlabTreeNode } from "./slab-tree-node";
|
import { SlabTreeNode } from "./slab-tree-node";
|
||||||
import { WallTreeNode } from "./wall-tree-node";
|
import { WallTreeNode } from "./wall-tree-node";
|
||||||
|
|
||||||
@@ -29,6 +30,8 @@ export function TreeNode({ nodeId, depth = 0 }: TreeNodeProps) {
|
|||||||
return <SlabTreeNode node={node} depth={depth} />;
|
return <SlabTreeNode node={node} depth={depth} />;
|
||||||
case "wall":
|
case "wall":
|
||||||
return <WallTreeNode node={node} depth={depth} />;
|
return <WallTreeNode node={node} depth={depth} />;
|
||||||
|
case "roof":
|
||||||
|
return <RoofTreeNode node={node} depth={depth} />;
|
||||||
case "item":
|
case "item":
|
||||||
return <ItemTreeNode node={node} depth={depth} />;
|
return <ItemTreeNode node={node} depth={depth} />;
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { ThreeEvent } from '@react-three/fiber'
|
import type { ThreeEvent } from '@react-three/fiber'
|
||||||
import mitt from 'mitt'
|
import mitt from 'mitt'
|
||||||
import type { BuildingNode, CeilingNode, ItemNode, SlabNode, WallNode, ZoneNode } from '../schema'
|
import type { BuildingNode, CeilingNode, ItemNode, RoofNode, SlabNode, WallNode, ZoneNode } from '../schema'
|
||||||
import type { AnyNode } from '../schema/types'
|
import type { AnyNode } from '../schema/types'
|
||||||
|
|
||||||
// Base event interfaces
|
// Base event interfaces
|
||||||
@@ -24,6 +24,7 @@ export type BuildingEvent = NodeEvent<BuildingNode>
|
|||||||
export type ZoneEvent = NodeEvent<ZoneNode>
|
export type ZoneEvent = NodeEvent<ZoneNode>
|
||||||
export type SlabEvent = NodeEvent<SlabNode>
|
export type SlabEvent = NodeEvent<SlabNode>
|
||||||
export type CeilingEvent = NodeEvent<CeilingNode>
|
export type CeilingEvent = NodeEvent<CeilingNode>
|
||||||
|
export type RoofEvent = NodeEvent<RoofNode>
|
||||||
|
|
||||||
// Event suffixes - exported for use in hooks
|
// Event suffixes - exported for use in hooks
|
||||||
export const eventSuffixes = [
|
export const eventSuffixes = [
|
||||||
@@ -61,7 +62,9 @@ type EditorEvents = GridEvents &
|
|||||||
NodeEvents<'item', ItemEvent> &
|
NodeEvents<'item', ItemEvent> &
|
||||||
NodeEvents<'building', BuildingEvent> &
|
NodeEvents<'building', BuildingEvent> &
|
||||||
NodeEvents<'zone', ZoneEvent> &
|
NodeEvents<'zone', ZoneEvent> &
|
||||||
|
NodeEvents<'slab', SlabEvent> &
|
||||||
NodeEvents<'ceiling', CeilingEvent> &
|
NodeEvents<'ceiling', CeilingEvent> &
|
||||||
|
NodeEvents<'roof', RoofEvent> &
|
||||||
CameraControlEvents
|
CameraControlEvents
|
||||||
|
|
||||||
export const emitter = mitt<EditorEvents>()
|
export const emitter = mitt<EditorEvents>()
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export const sceneRegistry = {
|
|||||||
item: new Set<string>(),
|
item: new Set<string>(),
|
||||||
slab: new Set<string>(),
|
slab: new Set<string>(),
|
||||||
zone: new Set<string>(),
|
zone: new Set<string>(),
|
||||||
|
roof: new Set<string>(),
|
||||||
scan: new Set<string>(),
|
scan: new Set<string>(),
|
||||||
guide: new Set<string>(),
|
guide: new Set<string>(),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export type {
|
|||||||
WallEvent,
|
WallEvent,
|
||||||
ZoneEvent,
|
ZoneEvent,
|
||||||
CeilingEvent,
|
CeilingEvent,
|
||||||
|
RoofEvent,
|
||||||
} from './events/bus'
|
} from './events/bus'
|
||||||
// Events
|
// Events
|
||||||
export { emitter, eventSuffixes } from './events/bus'
|
export { emitter, eventSuffixes } from './events/bus'
|
||||||
@@ -30,6 +31,7 @@ export { default as useScene } from './store/use-scene'
|
|||||||
// Systems
|
// Systems
|
||||||
export { CeilingSystem } from './systems/ceiling/ceiling-system'
|
export { CeilingSystem } from './systems/ceiling/ceiling-system'
|
||||||
export { ItemSystem } from './systems/item/item-system'
|
export { ItemSystem } from './systems/item/item-system'
|
||||||
|
export { RoofSystem } from './systems/roof/roof-system'
|
||||||
export { SlabSystem } from './systems/slab/slab-system'
|
export { SlabSystem } from './systems/slab/slab-system'
|
||||||
export { WallSystem } from './systems/wall/wall-system'
|
export { WallSystem } from './systems/wall/wall-system'
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export { BuildingNode } from './nodes/building'
|
|||||||
export { CeilingNode } from './nodes/ceiling'
|
export { CeilingNode } from './nodes/ceiling'
|
||||||
|
|
||||||
export { ZoneNode } from './nodes/zone'
|
export { ZoneNode } from './nodes/zone'
|
||||||
|
export { RoofNode } from './nodes/roof'
|
||||||
export { ScanNode } from './nodes/scan'
|
export { ScanNode } from './nodes/scan'
|
||||||
export { GuideNode } from './nodes/guide'
|
export { GuideNode } from './nodes/guide'
|
||||||
export type { AnyNodeId, AnyNodeType } from './types'
|
export type { AnyNodeId, AnyNodeType } from './types'
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { z } from 'zod'
|
|||||||
import { BaseNode, nodeType, objectId } from '../base'
|
import { BaseNode, nodeType, objectId } from '../base'
|
||||||
import { CeilingNode } from './ceiling'
|
import { CeilingNode } from './ceiling'
|
||||||
import { GuideNode } from './guide'
|
import { GuideNode } from './guide'
|
||||||
|
import { RoofNode } from './roof'
|
||||||
import { ScanNode } from './scan'
|
import { ScanNode } from './scan'
|
||||||
import { SlabNode } from './slab'
|
import { SlabNode } from './slab'
|
||||||
import { WallNode } from './wall'
|
import { WallNode } from './wall'
|
||||||
@@ -11,7 +12,7 @@ import { ZoneNode } from './zone'
|
|||||||
export const LevelNode = BaseNode.extend({
|
export const LevelNode = BaseNode.extend({
|
||||||
id: objectId('level'),
|
id: objectId('level'),
|
||||||
type: nodeType('level'),
|
type: nodeType('level'),
|
||||||
children: z.array(z.union([WallNode.shape.id, ZoneNode.shape.id, SlabNode.shape.id, CeilingNode.shape.id, ScanNode.shape.id, GuideNode.shape.id])).default([]),
|
children: z.array(z.union([WallNode.shape.id, ZoneNode.shape.id, SlabNode.shape.id, CeilingNode.shape.id, RoofNode.shape.id, ScanNode.shape.id, GuideNode.shape.id])).default([]),
|
||||||
// Specific props
|
// Specific props
|
||||||
level: z.number().default(0),
|
level: z.number().default(0),
|
||||||
}).describe(
|
}).describe(
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import dedent from 'dedent'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { BaseNode, nodeType, objectId } from '../base'
|
||||||
|
|
||||||
|
export const RoofNode = BaseNode.extend({
|
||||||
|
id: objectId('roof'),
|
||||||
|
type: nodeType('roof'),
|
||||||
|
// Position of the roof center (Y should typically be 0)
|
||||||
|
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
||||||
|
// Rotation around Y axis in radians
|
||||||
|
rotation: z.number().default(0),
|
||||||
|
// Length of the roof along the ridge direction (in meters)
|
||||||
|
length: z.number().default(4),
|
||||||
|
// Height of the roof peak from the base
|
||||||
|
height: z.number().default(1.5),
|
||||||
|
// Width of the left slope (in meters, measured horizontally from ridge)
|
||||||
|
leftWidth: z.number().default(1.5),
|
||||||
|
// Width of the right slope (in meters, measured horizontally from ridge)
|
||||||
|
rightWidth: z.number().default(1.5),
|
||||||
|
}).describe(
|
||||||
|
dedent`
|
||||||
|
Roof node - used to represent a gable roof in the building
|
||||||
|
- position: center position of the roof (Y typically 0)
|
||||||
|
- rotation: rotation around Y axis
|
||||||
|
- length: length of the roof along the ridge
|
||||||
|
- height: height of the roof peak
|
||||||
|
- leftWidth: horizontal width of the left slope (from ridge to eave)
|
||||||
|
- rightWidth: horizontal width of the right slope (from ridge to eave)
|
||||||
|
Total width = leftWidth + rightWidth
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
|
||||||
|
export type RoofNode = z.infer<typeof RoofNode>
|
||||||
@@ -4,6 +4,7 @@ import { CeilingNode } from './nodes/ceiling'
|
|||||||
import { GuideNode } from './nodes/guide'
|
import { GuideNode } from './nodes/guide'
|
||||||
import { ItemNode } from './nodes/item'
|
import { ItemNode } from './nodes/item'
|
||||||
import { LevelNode } from './nodes/level'
|
import { LevelNode } from './nodes/level'
|
||||||
|
import { RoofNode } from './nodes/roof'
|
||||||
import { ScanNode } from './nodes/scan'
|
import { ScanNode } from './nodes/scan'
|
||||||
import { SiteNode } from './nodes/site'
|
import { SiteNode } from './nodes/site'
|
||||||
import { SlabNode } from './nodes/slab'
|
import { SlabNode } from './nodes/slab'
|
||||||
@@ -19,6 +20,7 @@ export const AnyNode = z.discriminatedUnion('type', [
|
|||||||
ZoneNode,
|
ZoneNode,
|
||||||
SlabNode,
|
SlabNode,
|
||||||
CeilingNode,
|
CeilingNode,
|
||||||
|
RoofNode,
|
||||||
ScanNode,
|
ScanNode,
|
||||||
GuideNode,
|
GuideNode,
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { useFrame } from '@react-three/fiber'
|
||||||
|
import * as THREE from 'three'
|
||||||
|
import { sceneRegistry } from '../../hooks/scene-registry/scene-registry'
|
||||||
|
import type { AnyNodeId, RoofNode } from '../../schema'
|
||||||
|
import useScene from '../../store/use-scene'
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// ROOF SYSTEM
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const RoofSystem = () => {
|
||||||
|
const { nodes, dirtyNodes, clearDirty } = useScene()
|
||||||
|
|
||||||
|
useFrame(() => {
|
||||||
|
if (dirtyNodes.size === 0) return
|
||||||
|
|
||||||
|
// Process dirty roofs
|
||||||
|
dirtyNodes.forEach((id) => {
|
||||||
|
const node = nodes[id]
|
||||||
|
if (!node || node.type !== 'roof') return
|
||||||
|
|
||||||
|
const mesh = sceneRegistry.nodes.get(id) as THREE.Mesh
|
||||||
|
if (mesh) {
|
||||||
|
updateRoofGeometry(node as RoofNode, mesh)
|
||||||
|
}
|
||||||
|
clearDirty(id as AnyNodeId)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the geometry and transform for a single roof
|
||||||
|
*/
|
||||||
|
function updateRoofGeometry(node: RoofNode, mesh: THREE.Mesh) {
|
||||||
|
const newGeo = generateRoofGeometry(node)
|
||||||
|
|
||||||
|
mesh.geometry.dispose()
|
||||||
|
mesh.geometry = newGeo
|
||||||
|
|
||||||
|
// Update position and rotation
|
||||||
|
mesh.position.set(node.position[0], node.position[1], node.position[2])
|
||||||
|
mesh.rotation.y = node.rotation
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates gable roof geometry from length, height, leftWidth, rightWidth
|
||||||
|
*
|
||||||
|
* The roof is centered at origin (position applied via mesh transform)
|
||||||
|
* - Ridge runs along the X axis (length direction)
|
||||||
|
* - Left slope goes down toward -Z with horizontal distance leftWidth
|
||||||
|
* - Right slope goes down toward +Z with horizontal distance rightWidth
|
||||||
|
* - Total width = leftWidth + rightWidth
|
||||||
|
* - Gable ends at -X/2 and +X/2
|
||||||
|
*/
|
||||||
|
export function generateRoofGeometry(roofNode: RoofNode): THREE.BufferGeometry {
|
||||||
|
const { length, height, leftWidth, rightWidth } = roofNode
|
||||||
|
|
||||||
|
// Half length for centering
|
||||||
|
const halfLength = length / 2
|
||||||
|
|
||||||
|
// Ridge is at Y = height, centered at Z = 0
|
||||||
|
// Left eave is at Z = -leftWidth, Y = 0
|
||||||
|
// Right eave is at Z = +rightWidth, Y = 0
|
||||||
|
|
||||||
|
const positions: number[] = []
|
||||||
|
const normals: number[] = []
|
||||||
|
const indices: number[] = []
|
||||||
|
|
||||||
|
const addVertex = (x: number, y: number, z: number, nx: number, ny: number, nz: number) => {
|
||||||
|
const idx = positions.length / 3
|
||||||
|
positions.push(x, y, z)
|
||||||
|
normals.push(nx, ny, nz)
|
||||||
|
return idx
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate slope normals
|
||||||
|
// Left slope: from (0, height, 0) to (0, 0, -leftWidth)
|
||||||
|
const leftSlopeLen = Math.sqrt(height * height + leftWidth * leftWidth)
|
||||||
|
const leftNormalY = leftWidth / leftSlopeLen
|
||||||
|
const leftNormalZ = height / leftSlopeLen
|
||||||
|
|
||||||
|
// Right slope: from (0, height, 0) to (0, 0, +rightWidth)
|
||||||
|
const rightSlopeLen = Math.sqrt(height * height + rightWidth * rightWidth)
|
||||||
|
const rightNormalY = rightWidth / rightSlopeLen
|
||||||
|
const rightNormalZ = height / rightSlopeLen
|
||||||
|
|
||||||
|
// Left slope (negative Z side) - CCW winding for outward-facing
|
||||||
|
const leftNormal = [0, leftNormalY, -leftNormalZ] as const
|
||||||
|
const v0 = addVertex(-halfLength, 0, -leftWidth, ...leftNormal) // back-left eave
|
||||||
|
const v1 = addVertex(halfLength, 0, -leftWidth, ...leftNormal) // front-left eave
|
||||||
|
const v2 = addVertex(halfLength, height, 0, ...leftNormal) // front ridge
|
||||||
|
const v3 = addVertex(-halfLength, height, 0, ...leftNormal) // back ridge
|
||||||
|
indices.push(v0, v2, v1, v0, v3, v2)
|
||||||
|
|
||||||
|
// Right slope (positive Z side) - CCW winding for outward-facing
|
||||||
|
const rightNormal = [0, rightNormalY, rightNormalZ] as const
|
||||||
|
const v4 = addVertex(halfLength, 0, rightWidth, ...rightNormal) // front-right eave
|
||||||
|
const v5 = addVertex(-halfLength, 0, rightWidth, ...rightNormal) // back-right eave
|
||||||
|
const v6 = addVertex(-halfLength, height, 0, ...rightNormal) // back ridge
|
||||||
|
const v7 = addVertex(halfLength, height, 0, ...rightNormal) // front ridge
|
||||||
|
indices.push(v4, v6, v5, v4, v7, v6)
|
||||||
|
|
||||||
|
// Front gable end (positive X) - CCW winding for outward-facing
|
||||||
|
const frontNormal = [1, 0, 0] as const
|
||||||
|
const v8 = addVertex(halfLength, 0, -leftWidth, ...frontNormal)
|
||||||
|
const v9 = addVertex(halfLength, 0, rightWidth, ...frontNormal)
|
||||||
|
const v10 = addVertex(halfLength, height, 0, ...frontNormal)
|
||||||
|
indices.push(v8, v10, v9)
|
||||||
|
|
||||||
|
// Back gable end (negative X) - CCW winding for outward-facing
|
||||||
|
const backNormal = [-1, 0, 0] as const
|
||||||
|
const v11 = addVertex(-halfLength, 0, rightWidth, ...backNormal)
|
||||||
|
const v12 = addVertex(-halfLength, 0, -leftWidth, ...backNormal)
|
||||||
|
const v13 = addVertex(-halfLength, height, 0, ...backNormal)
|
||||||
|
indices.push(v11, v13, v12)
|
||||||
|
|
||||||
|
const geometry = new THREE.BufferGeometry()
|
||||||
|
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3))
|
||||||
|
geometry.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3))
|
||||||
|
geometry.setIndex(indices)
|
||||||
|
|
||||||
|
return geometry
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { CeilingRenderer } from './ceiling/ceiling-renderer'
|
|||||||
import { GuideRenderer } from './guide/guide-renderer'
|
import { GuideRenderer } from './guide/guide-renderer'
|
||||||
import { ItemRenderer } from './item/item-renderer'
|
import { ItemRenderer } from './item/item-renderer'
|
||||||
import { LevelRenderer } from './level/level-renderer'
|
import { LevelRenderer } from './level/level-renderer'
|
||||||
|
import { RoofRenderer } from './roof/roof-renderer'
|
||||||
import { ScanRenderer } from './scan/scan-renderer'
|
import { ScanRenderer } from './scan/scan-renderer'
|
||||||
import { SlabRenderer } from './slab/slab-renderer'
|
import { SlabRenderer } from './slab/slab-renderer'
|
||||||
import { WallRenderer } from './wall/wall-renderer'
|
import { WallRenderer } from './wall/wall-renderer'
|
||||||
@@ -25,6 +26,7 @@ export const NodeRenderer = ({ nodeId }: { nodeId: AnyNode['id'] }) => {
|
|||||||
{node.type === 'slab' && <SlabRenderer node={node} />}
|
{node.type === 'slab' && <SlabRenderer node={node} />}
|
||||||
{node.type === 'wall' && <WallRenderer node={node} />}
|
{node.type === 'wall' && <WallRenderer node={node} />}
|
||||||
{node.type === 'zone' && <ZoneRenderer node={node} />}
|
{node.type === 'zone' && <ZoneRenderer node={node} />}
|
||||||
|
{node.type === 'roof' && <RoofRenderer node={node} />}
|
||||||
{node.type === 'scan' && <ScanRenderer node={node} />}
|
{node.type === 'scan' && <ScanRenderer node={node} />}
|
||||||
{node.type === 'guide' && <GuideRenderer node={node} />}
|
{node.type === 'guide' && <GuideRenderer node={node} />}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { type RoofNode, useRegistry } from '@pascal-app/core'
|
||||||
|
import { useRef } from 'react'
|
||||||
|
import type { Mesh } from 'three'
|
||||||
|
import { useNodeEvents } from '../../../hooks/use-node-events'
|
||||||
|
|
||||||
|
export const RoofRenderer = ({ node }: { node: RoofNode }) => {
|
||||||
|
const ref = useRef<Mesh>(null!)
|
||||||
|
|
||||||
|
useRegistry(node.id, 'roof', ref)
|
||||||
|
|
||||||
|
const handlers = useNodeEvents(node, 'roof')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<mesh
|
||||||
|
ref={ref}
|
||||||
|
castShadow
|
||||||
|
receiveShadow
|
||||||
|
position={node.position}
|
||||||
|
rotation-y={node.rotation}
|
||||||
|
{...handlers}
|
||||||
|
>
|
||||||
|
{/* RoofSystem will replace this geometry in the next frame */}
|
||||||
|
<boxGeometry args={[0, 0, 0]} />
|
||||||
|
<meshStandardMaterial color="#8b4513" />
|
||||||
|
</mesh>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { CeilingSystem, ItemSystem, SlabSystem, WallSystem } from '@pascal-app/core'
|
import { CeilingSystem, ItemSystem, RoofSystem, SlabSystem, WallSystem } from '@pascal-app/core'
|
||||||
import { Bvh, Environment } from '@react-three/drei'
|
import { Bvh, Environment } from '@react-three/drei'
|
||||||
import { Canvas, extend, type ThreeToJSXElements } from '@react-three/fiber'
|
import { Canvas, extend, type ThreeToJSXElements } from '@react-three/fiber'
|
||||||
import * as THREE from 'three/webgpu'
|
import * as THREE from 'three/webgpu'
|
||||||
@@ -49,6 +49,7 @@ const Viewer: React.FC<ViewerProps> = ({ children }) => {
|
|||||||
{/* Core systems */}
|
{/* Core systems */}
|
||||||
<CeilingSystem />
|
<CeilingSystem />
|
||||||
<ItemSystem />
|
<ItemSystem />
|
||||||
|
<RoofSystem />
|
||||||
<SlabSystem />
|
<SlabSystem />
|
||||||
<WallSystem />
|
<WallSystem />
|
||||||
<PostProcessing />
|
<PostProcessing />
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import {
|
|||||||
emitter,
|
emitter,
|
||||||
type ItemEvent,
|
type ItemEvent,
|
||||||
type ItemNode,
|
type ItemNode,
|
||||||
|
type RoofEvent,
|
||||||
|
type RoofNode,
|
||||||
type SlabEvent,
|
type SlabEvent,
|
||||||
type SlabNode,
|
type SlabNode,
|
||||||
type WallEvent,
|
type WallEvent,
|
||||||
@@ -23,6 +25,7 @@ type NodeConfig = {
|
|||||||
zone: { node: ZoneNode; event: ZoneEvent }
|
zone: { node: ZoneNode; event: ZoneEvent }
|
||||||
slab: { node: SlabNode; event: SlabEvent }
|
slab: { node: SlabNode; event: SlabEvent }
|
||||||
ceiling: { node: CeilingNode; event: CeilingEvent }
|
ceiling: { node: CeilingNode; event: CeilingEvent }
|
||||||
|
roof: { node: RoofNode; event: RoofEvent }
|
||||||
}
|
}
|
||||||
|
|
||||||
type NodeType = keyof NodeConfig
|
type NodeType = keyof NodeConfig
|
||||||
|
|||||||
Reference in New Issue
Block a user