diff --git a/apps/editor/components/editor/index.tsx b/apps/editor/components/editor/index.tsx index f2d15187..33933f60 100644 --- a/apps/editor/components/editor/index.tsx +++ b/apps/editor/components/editor/index.tsx @@ -14,6 +14,7 @@ import { ZoneSystem } from '../systems/zone/zone-system' import { ToolManager } from '../tools/tool-manager' import { ActionMenu } from '../ui/action-menu' import { ReferencePanel } from '../ui/panels/reference-panel' +import { RoofPanel } from '../ui/panels/roof-panel' import { SidebarProvider } from '../ui/primitives/sidebar' import { AppSidebar } from '../ui/sidebar/app-sidebar' import { CustomCameraControls } from './custom-camera-controls' @@ -33,6 +34,7 @@ export default function Editor() { + diff --git a/apps/editor/components/editor/selection-manager.tsx b/apps/editor/components/editor/selection-manager.tsx index 1c263a46..cd272ded 100644 --- a/apps/editor/components/editor/selection-manager.tsx +++ b/apps/editor/components/editor/selection-manager.tsx @@ -20,7 +20,7 @@ const isNodeInCurrentLevel = (node: AnyNode): boolean => { return nodeLevelId === currentLevelId; }; -type SelectableNodeType = "wall" | "item" | "building" | "zone" | 'slab' | 'ceiling'; +type SelectableNodeType = "wall" | "item" | "building" | "zone" | 'slab' | 'ceiling' | 'roof'; interface SelectionStrategy { types: SelectableNodeType[]; @@ -44,7 +44,7 @@ const SELECTION_STRATEGIES: Record = { }, structure: { - types: ["wall", "item", "zone", "slab", "ceiling"], + types: ["wall", "item", "zone", "slab", "ceiling", "roof"], handleSelect: (node, isShift) => { // Single click on item (door/window) → enter move mode if (!isShift && node.type === 'item') { @@ -78,7 +78,7 @@ const SELECTION_STRATEGIES: Record = { if (node.type === "zone") return true; return false; } 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") { return ( (node as ItemNode).asset.category === "door" || diff --git a/apps/editor/components/tools/roof/roof-tool.tsx b/apps/editor/components/tools/roof/roof-tool.tsx new file mode 100644 index 00000000..3c7cfc1c --- /dev/null +++ b/apps/editor/components/tools/roof/roof-tool.tsx @@ -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(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({ + 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 ( + + {/* Outline showing rectangle being drawn */} + {/* @ts-ignore */} + + + + + + {/* First corner marker */} + {corner1 && ( + + + + + )} + + {/* Cursor marker on ground */} + + + + + + {/* Thin preview fill when drawing */} + {previewDimensions && previewDimensions.length > 0.1 && previewDimensions.width > 0.1 && ( + + + + + )} + + ); +}; diff --git a/apps/editor/components/tools/tool-manager.tsx b/apps/editor/components/tools/tool-manager.tsx index ac884c60..bd3b03eb 100644 --- a/apps/editor/components/tools/tool-manager.tsx +++ b/apps/editor/components/tools/tool-manager.tsx @@ -3,6 +3,7 @@ import { useViewer } from "@pascal-app/viewer"; import { CeilingTool } from "./ceiling/ceiling-tool"; import { ItemTool } from "./item/item-tool"; import { MoveTool } from "./item/move-tool"; +import { RoofTool } from "./roof/roof-tool"; import { SlabTool } from "./slab/slab-tool"; import { WallTool } from "./wall/wall-tool"; import { ZoneBoundaryEditor } from "./zone/zone-boundary-editor"; @@ -14,6 +15,7 @@ const tools: Record>> = { wall: WallTool, slab: SlabTool, ceiling: CeilingTool, + roof: RoofTool, item: ItemTool, zone: ZoneTool, }, diff --git a/apps/editor/components/ui/panels/roof-panel.tsx b/apps/editor/components/ui/panels/roof-panel.tsx new file mode 100644 index 00000000..b32a8bbc --- /dev/null +++ b/apps/editor/components/ui/panels/roof-panel.tsx @@ -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) => { + 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 ( +
+ {/* Header */} +
+
+ +

+ {node.name || 'Gable Roof'} +

+
+ +
+ + {/* Content */} +
+
+ {/* Length */} +
+ +
+ { + 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} + /> + m +
+
+ + {/* Height */} +
+ +
+ { + 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} + /> + m +
+
+ + {/* Slope Widths */} +
+
+ + + Total: {totalWidth.toFixed(1)}m + +
+
+
+ +
+ { + 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} + /> + m +
+
+
+ +
+ { + 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} + /> + m +
+
+
+
+ + {/* Rotation */} +
+ +
+ { + 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)} + /> + ° + + +
+
+ + {/* Position */} +
+ +
+ {([0, 1, 2] as const).map((i) => ( +
+ + { + 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} + /> +
+ ))} +
+
+
+
+
+ ) +} diff --git a/apps/editor/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx b/apps/editor/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx new file mode 100644 index 00000000..15b9cf26 --- /dev/null +++ b/apps/editor/components/ui/sidebar/panels/site-panel/roof-tree-node.tsx @@ -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 ( + } + label={node.name || `Roof (${sizeLabel})`} + depth={depth} + hasChildren={false} + expanded={false} + onToggle={() => {}} + onClick={handleClick} + onMouseEnter={handleMouseEnter} + onMouseLeave={handleMouseLeave} + isSelected={isSelected} + isHovered={isHovered} + actions={} + /> + ); +} diff --git a/apps/editor/components/ui/sidebar/panels/site-panel/tree-node.tsx b/apps/editor/components/ui/sidebar/panels/site-panel/tree-node.tsx index 0169513f..553f4354 100644 --- a/apps/editor/components/ui/sidebar/panels/site-panel/tree-node.tsx +++ b/apps/editor/components/ui/sidebar/panels/site-panel/tree-node.tsx @@ -5,6 +5,7 @@ import { BuildingTreeNode } from "./building-tree-node"; import { CeilingTreeNode } from "./ceiling-tree-node"; import { ItemTreeNode } from "./item-tree-node"; import { LevelTreeNode } from "./level-tree-node"; +import { RoofTreeNode } from "./roof-tree-node"; import { SlabTreeNode } from "./slab-tree-node"; import { WallTreeNode } from "./wall-tree-node"; @@ -29,6 +30,8 @@ export function TreeNode({ nodeId, depth = 0 }: TreeNodeProps) { return ; case "wall": return ; + case "roof": + return ; case "item": return ; default: diff --git a/packages/core/src/events/bus.ts b/packages/core/src/events/bus.ts index 09a770cb..df5cbb40 100644 --- a/packages/core/src/events/bus.ts +++ b/packages/core/src/events/bus.ts @@ -1,6 +1,6 @@ import type { ThreeEvent } from '@react-three/fiber' 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' // Base event interfaces @@ -24,6 +24,7 @@ export type BuildingEvent = NodeEvent export type ZoneEvent = NodeEvent export type SlabEvent = NodeEvent export type CeilingEvent = NodeEvent +export type RoofEvent = NodeEvent // Event suffixes - exported for use in hooks export const eventSuffixes = [ @@ -61,7 +62,9 @@ type EditorEvents = GridEvents & NodeEvents<'item', ItemEvent> & NodeEvents<'building', BuildingEvent> & NodeEvents<'zone', ZoneEvent> & + NodeEvents<'slab', SlabEvent> & NodeEvents<'ceiling', CeilingEvent> & + NodeEvents<'roof', RoofEvent> & CameraControlEvents export const emitter = mitt() diff --git a/packages/core/src/hooks/scene-registry/scene-registry.ts b/packages/core/src/hooks/scene-registry/scene-registry.ts index e831c91c..3f13c69d 100644 --- a/packages/core/src/hooks/scene-registry/scene-registry.ts +++ b/packages/core/src/hooks/scene-registry/scene-registry.ts @@ -15,6 +15,7 @@ export const sceneRegistry = { item: new Set(), slab: new Set(), zone: new Set(), + roof: new Set(), scan: new Set(), guide: new Set(), }, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index b15f97a8..3187a00d 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -11,6 +11,7 @@ export type { WallEvent, ZoneEvent, CeilingEvent, + RoofEvent, } from './events/bus' // Events export { emitter, eventSuffixes } from './events/bus' @@ -30,6 +31,7 @@ export { default as useScene } from './store/use-scene' // Systems export { CeilingSystem } from './systems/ceiling/ceiling-system' export { ItemSystem } from './systems/item/item-system' +export { RoofSystem } from './systems/roof/roof-system' export { SlabSystem } from './systems/slab/slab-system' export { WallSystem } from './systems/wall/wall-system' diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index 1e13f807..8c0d8222 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -13,6 +13,7 @@ export { BuildingNode } from './nodes/building' export { CeilingNode } from './nodes/ceiling' export { ZoneNode } from './nodes/zone' +export { RoofNode } from './nodes/roof' export { ScanNode } from './nodes/scan' export { GuideNode } from './nodes/guide' export type { AnyNodeId, AnyNodeType } from './types' diff --git a/packages/core/src/schema/nodes/level.ts b/packages/core/src/schema/nodes/level.ts index d30ff196..cd043f31 100644 --- a/packages/core/src/schema/nodes/level.ts +++ b/packages/core/src/schema/nodes/level.ts @@ -3,6 +3,7 @@ import { z } from 'zod' import { BaseNode, nodeType, objectId } from '../base' import { CeilingNode } from './ceiling' import { GuideNode } from './guide' +import { RoofNode } from './roof' import { ScanNode } from './scan' import { SlabNode } from './slab' import { WallNode } from './wall' @@ -11,7 +12,7 @@ import { ZoneNode } from './zone' export const LevelNode = BaseNode.extend({ id: objectId('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 level: z.number().default(0), }).describe( diff --git a/packages/core/src/schema/nodes/roof.ts b/packages/core/src/schema/nodes/roof.ts new file mode 100644 index 00000000..39e38080 --- /dev/null +++ b/packages/core/src/schema/nodes/roof.ts @@ -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 diff --git a/packages/core/src/schema/types.ts b/packages/core/src/schema/types.ts index 963d82ff..bd97e9f1 100644 --- a/packages/core/src/schema/types.ts +++ b/packages/core/src/schema/types.ts @@ -4,6 +4,7 @@ import { CeilingNode } from './nodes/ceiling' import { GuideNode } from './nodes/guide' import { ItemNode } from './nodes/item' import { LevelNode } from './nodes/level' +import { RoofNode } from './nodes/roof' import { ScanNode } from './nodes/scan' import { SiteNode } from './nodes/site' import { SlabNode } from './nodes/slab' @@ -19,6 +20,7 @@ export const AnyNode = z.discriminatedUnion('type', [ ZoneNode, SlabNode, CeilingNode, + RoofNode, ScanNode, GuideNode, ]) diff --git a/packages/core/src/systems/roof/roof-system.tsx b/packages/core/src/systems/roof/roof-system.tsx new file mode 100644 index 00000000..edac2763 --- /dev/null +++ b/packages/core/src/systems/roof/roof-system.tsx @@ -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 +} diff --git a/packages/viewer/src/components/renderers/node-renderer.tsx b/packages/viewer/src/components/renderers/node-renderer.tsx index 7d375430..8224bcc8 100644 --- a/packages/viewer/src/components/renderers/node-renderer.tsx +++ b/packages/viewer/src/components/renderers/node-renderer.tsx @@ -6,6 +6,7 @@ import { CeilingRenderer } from './ceiling/ceiling-renderer' import { GuideRenderer } from './guide/guide-renderer' import { ItemRenderer } from './item/item-renderer' import { LevelRenderer } from './level/level-renderer' +import { RoofRenderer } from './roof/roof-renderer' import { ScanRenderer } from './scan/scan-renderer' import { SlabRenderer } from './slab/slab-renderer' import { WallRenderer } from './wall/wall-renderer' @@ -25,6 +26,7 @@ export const NodeRenderer = ({ nodeId }: { nodeId: AnyNode['id'] }) => { {node.type === 'slab' && } {node.type === 'wall' && } {node.type === 'zone' && } + {node.type === 'roof' && } {node.type === 'scan' && } {node.type === 'guide' && } diff --git a/packages/viewer/src/components/renderers/roof/roof-renderer.tsx b/packages/viewer/src/components/renderers/roof/roof-renderer.tsx new file mode 100644 index 00000000..8c985b1f --- /dev/null +++ b/packages/viewer/src/components/renderers/roof/roof-renderer.tsx @@ -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(null!) + + useRegistry(node.id, 'roof', ref) + + const handlers = useNodeEvents(node, 'roof') + + return ( + + {/* RoofSystem will replace this geometry in the next frame */} + + + + ) +} diff --git a/packages/viewer/src/components/viewer/index.tsx b/packages/viewer/src/components/viewer/index.tsx index 75d5fcd1..a1617836 100644 --- a/packages/viewer/src/components/viewer/index.tsx +++ b/packages/viewer/src/components/viewer/index.tsx @@ -1,6 +1,6 @@ '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 { Canvas, extend, type ThreeToJSXElements } from '@react-three/fiber' import * as THREE from 'three/webgpu' @@ -49,6 +49,7 @@ const Viewer: React.FC = ({ children }) => { {/* Core systems */} + diff --git a/packages/viewer/src/hooks/use-node-events.ts b/packages/viewer/src/hooks/use-node-events.ts index 3b6f6ade..36e33893 100644 --- a/packages/viewer/src/hooks/use-node-events.ts +++ b/packages/viewer/src/hooks/use-node-events.ts @@ -7,6 +7,8 @@ import { emitter, type ItemEvent, type ItemNode, + type RoofEvent, + type RoofNode, type SlabEvent, type SlabNode, type WallEvent, @@ -23,6 +25,7 @@ type NodeConfig = { zone: { node: ZoneNode; event: ZoneEvent } slab: { node: SlabNode; event: SlabEvent } ceiling: { node: CeilingNode; event: CeilingEvent } + roof: { node: RoofNode; event: RoofEvent } } type NodeType = keyof NodeConfig