diff --git a/apps/editor/components/editor/selection-manager.tsx b/apps/editor/components/editor/selection-manager.tsx index a0acf89a..b1362dec 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'; +type SelectableNodeType = "wall" | "item" | "building" | "zone" | 'slab' | 'ceiling'; interface SelectionStrategy { types: SelectableNodeType[]; @@ -44,7 +44,7 @@ const SELECTION_STRATEGIES: Record = { }, structure: { - types: ["wall", "item", "zone", "slab"], + types: ["wall", "item", "zone", "slab", "ceiling"], handleSelect: (node, isShift) => { const { selection, setSelection } = useViewer.getState(); if (node.type === 'zone') { @@ -73,7 +73,7 @@ const SELECTION_STRATEGIES: Record = { if (node.type === "zone") return true; return false; } else { - if (node.type === "wall" || node.type === "slab") return true; + if (node.type === "wall" || node.type === "slab" || node.type === "ceiling") return true; if (node.type === "item") { return ( (node as ItemNode).asset.category === "door" || diff --git a/apps/editor/components/tools/ceiling/ceiling-tool.tsx b/apps/editor/components/tools/ceiling/ceiling-tool.tsx new file mode 100644 index 00000000..b507825a --- /dev/null +++ b/apps/editor/components/tools/ceiling/ceiling-tool.tsx @@ -0,0 +1,385 @@ +import { emitter, type GridEvent, useScene, CeilingNode, type LevelNode } from "@pascal-app/core"; +import { useViewer } from "@pascal-app/viewer"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { BufferGeometry, DoubleSide, type Line, type Mesh, Shape, Vector3 } from "three"; +import useEditor from "@/store/use-editor"; + +const Y_OFFSET = 2.52; // Slightly above default ceiling height + +/** + * Snaps a point to the nearest axis-aligned or 45-degree diagonal from the last point + */ +const calculateSnapPoint = ( + lastPoint: [number, number], + currentPoint: [number, number] +): [number, number] => { + const [x1, y1] = lastPoint; + const [x, y] = currentPoint; + + const dx = x - x1; + const dy = y - y1; + const absDx = Math.abs(dx); + const absDy = Math.abs(dy); + + // Calculate distances to horizontal, vertical, and diagonal lines + const horizontalDist = absDy; + const verticalDist = absDx; + const diagonalDist = Math.abs(absDx - absDy); + + // Find the minimum distance to determine which axis to snap to + const minDist = Math.min(horizontalDist, verticalDist, diagonalDist); + + if (minDist === diagonalDist) { + // Snap to 45° diagonal + const diagonalLength = Math.min(absDx, absDy); + return [ + x1 + Math.sign(dx) * diagonalLength, + y1 + Math.sign(dy) * diagonalLength, + ]; + } else if (minDist === horizontalDist) { + // Snap to horizontal + return [x, y1]; + } else { + // Snap to vertical + return [x1, y]; + } +}; + +/** + * Creates a ceiling with the given polygon points + */ +const commitCeilingDrawing = ( + levelId: LevelNode["id"], + points: Array<[number, number]> +) => { + const { createNode, nodes } = useScene.getState(); + + // Count existing ceilings for naming + const ceilingCount = Object.values(nodes).filter((n) => n.type === "ceiling").length; + const name = `Ceiling ${ceilingCount + 1}`; + + const ceiling = CeilingNode.parse({ + name, + polygon: points, + }); + + createNode(ceiling, levelId); +}; + +type PreviewState = { + points: Array<[number, number]>; + cursorPoint: [number, number] | null; +}; + +// Helper to validate point values (no NaN or Infinity) +const isValidPoint = ( + pt: [number, number] | null | undefined +): pt is [number, number] => { + if (!pt) return false; + return Number.isFinite(pt[0]) && Number.isFinite(pt[1]); +}; + +const GRID_Y = 0.02; // Grid level indicator + +export const CeilingTool: React.FC = () => { + const cursorRef = useRef(null); + const gridCursorRef = useRef(null); + const mainLineRef = useRef(null!); + const closingLineRef = useRef(null!); + const pointsRef = useRef>([]); + const currentLevelId = useViewer((state) => state.selection.levelId); + const setTool = useEditor((state) => state.setTool); + + // Preview state for reactive rendering (for shape and point markers) + const [preview, setPreview] = useState({ + points: [], + cursorPoint: null, + }); + + useEffect(() => { + if (!currentLevelId) return; + + let cursorPosition: [number, number] = [0, 0]; + + // Initialize line geometries + mainLineRef.current.geometry = new BufferGeometry(); + closingLineRef.current.geometry = new BufferGeometry(); + + const updateLines = () => { + const points = pointsRef.current; + + if (points.length === 0) { + mainLineRef.current.visible = false; + closingLineRef.current.visible = false; + return; + } + + // Build main line points + const linePoints: Vector3[] = points.map( + ([x, z]) => new Vector3(x, Y_OFFSET, z) + ); + + // Add cursor point + const lastPoint = points[points.length - 1]; + if (lastPoint) { + const snapped = calculateSnapPoint(lastPoint, cursorPosition); + if (isValidPoint(snapped)) { + linePoints.push(new Vector3(snapped[0], Y_OFFSET, snapped[1])); + } + } + + // Update main line geometry + if (linePoints.length >= 2) { + mainLineRef.current.geometry.dispose(); + mainLineRef.current.geometry = new BufferGeometry().setFromPoints(linePoints); + mainLineRef.current.visible = true; + } else { + mainLineRef.current.visible = false; + } + + // Update closing line (from cursor back to first point) + const firstPoint = points[0]; + if (points.length >= 2 && lastPoint && isValidPoint(firstPoint)) { + const snapped = calculateSnapPoint(lastPoint, cursorPosition); + if (isValidPoint(snapped)) { + const closingPoints = [ + new Vector3(snapped[0], Y_OFFSET, snapped[1]), + new Vector3(firstPoint[0], Y_OFFSET, firstPoint[1]), + ]; + closingLineRef.current.geometry.dispose(); + closingLineRef.current.geometry = new BufferGeometry().setFromPoints(closingPoints); + closingLineRef.current.visible = true; + } + } else { + closingLineRef.current.visible = false; + } + }; + + const updatePreview = () => { + const points = pointsRef.current; + const lastPoint = points[points.length - 1]; + + let cursorPt: [number, number] | null = null; + if (lastPoint) { + cursorPt = calculateSnapPoint(lastPoint, cursorPosition); + } else if (points.length === 0) { + cursorPt = cursorPosition; + } + + setPreview({ points: [...points], cursorPoint: cursorPt }); + updateLines(); + }; + + const onGridMove = (event: GridEvent) => { + if (!cursorRef.current) return; + + // Snap to 0.5 grid + const gridX = Math.round(event.position[0] * 2) / 2; + const gridZ = Math.round(event.position[2] * 2) / 2; + cursorPosition = [gridX, gridZ]; + + // If we have points, snap to axis from last point + const lastPoint = pointsRef.current[pointsRef.current.length - 1]; + if (lastPoint) { + const snapped = calculateSnapPoint(lastPoint, cursorPosition); + cursorRef.current.position.set(snapped[0], Y_OFFSET, snapped[1]); + // Also update grid-level cursor + if (gridCursorRef.current) { + gridCursorRef.current.position.set(snapped[0], GRID_Y, snapped[1]); + } + } else { + cursorRef.current.position.set(gridX, Y_OFFSET, gridZ); + if (gridCursorRef.current) { + gridCursorRef.current.position.set(gridX, GRID_Y, gridZ); + } + } + + updatePreview(); + }; + + 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; + let clickPoint: [number, number] = [gridX, gridZ]; + + // Snap to axis from last point + const lastPoint = pointsRef.current[pointsRef.current.length - 1]; + if (lastPoint) { + clickPoint = calculateSnapPoint(lastPoint, clickPoint); + } + + // Check if clicking on the first point to close the shape + const firstPoint = pointsRef.current[0]; + if ( + pointsRef.current.length >= 3 && + firstPoint && + Math.abs(clickPoint[0] - firstPoint[0]) < 0.25 && + Math.abs(clickPoint[1] - firstPoint[1]) < 0.25 + ) { + // Create the ceiling + commitCeilingDrawing(currentLevelId, pointsRef.current); + + // Reset state + pointsRef.current = []; + setPreview({ points: [], cursorPoint: null }); + mainLineRef.current.visible = false; + closingLineRef.current.visible = false; + + // Deactivate tool + setTool(null); + } else { + // Add point to polygon + pointsRef.current = [...pointsRef.current, clickPoint]; + updatePreview(); + } + }; + + const onGridDoubleClick = (_event: GridEvent) => { + if (!currentLevelId) return; + + // Need at least 3 points to form a polygon + if (pointsRef.current.length >= 3) { + commitCeilingDrawing(currentLevelId, pointsRef.current); + + // Reset state + pointsRef.current = []; + setPreview({ points: [], cursorPoint: null }); + mainLineRef.current.visible = false; + closingLineRef.current.visible = false; + + // Deactivate tool + setTool(null); + } + }; + + // Subscribe to events + emitter.on("grid:move", onGridMove); + emitter.on("grid:click", onGridClick); + emitter.on("grid:double-click", onGridDoubleClick); + + return () => { + emitter.off("grid:move", onGridMove); + emitter.off("grid:click", onGridClick); + emitter.off("grid:double-click", onGridDoubleClick); + + // Reset state on unmount + pointsRef.current = []; + }; + }, [currentLevelId, setTool]); + + const { points, cursorPoint } = preview; + + // Create preview shape when we have 3+ points + const previewShape = useMemo(() => { + if (points.length < 3) return null; + + const allPoints = [...points]; + if (isValidPoint(cursorPoint)) { + allPoints.push(cursorPoint); + } + + // THREE.Shape is in X-Y plane. After rotation of -PI/2 around X: + // - Shape X -> World X + // - Shape Y -> World -Z (so we negate Z to get correct orientation) + const firstPt = allPoints[0]; + if (!isValidPoint(firstPt)) return null; + + const shape = new Shape(); + shape.moveTo(firstPt[0], -firstPt[1]); + + for (let i = 1; i < allPoints.length; i++) { + const pt = allPoints[i]; + if (isValidPoint(pt)) { + shape.lineTo(pt[0], -pt[1]); + } + } + shape.closePath(); + + return shape; + }, [points, cursorPoint]); + + return ( + + {/* Cursor at ceiling height */} + + + + + + {/* Grid-level cursor indicator */} + + + + + + {/* Preview fill */} + {previewShape && ( + + + + + )} + + {/* Main line - uses native line element with TSL-compatible material */} + {/* @ts-ignore */} + + + + + + {/* Closing line - uses native line element with TSL-compatible material */} + {/* @ts-ignore */} + + + + + + {/* Point markers */} + {points.map(([x, z], index) => + isValidPoint([x, z]) ? ( + + + + + ) : null + )} + + ); +}; diff --git a/apps/editor/components/tools/tool-manager.tsx b/apps/editor/components/tools/tool-manager.tsx index 5c023bf8..cb245217 100644 --- a/apps/editor/components/tools/tool-manager.tsx +++ b/apps/editor/components/tools/tool-manager.tsx @@ -1,5 +1,6 @@ import useEditor, { type Phase, type Tool } from "@/store/use-editor"; import { useViewer } from "@pascal-app/viewer"; +import { CeilingTool } from "./ceiling/ceiling-tool"; import { ItemTool } from "./item/item-tool"; import { SlabTool } from "./slab/slab-tool"; import { WallTool } from "./wall/wall-tool"; @@ -11,6 +12,7 @@ const tools: Record>> = { structure: { wall: WallTool, slab: SlabTool, + ceiling: CeilingTool, item: ItemTool, zone: ZoneTool, }, diff --git a/apps/editor/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx b/apps/editor/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx new file mode 100644 index 00000000..18d00311 --- /dev/null +++ b/apps/editor/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx @@ -0,0 +1,67 @@ +import { CeilingNode } from "@pascal-app/core"; +import { useViewer } from "@pascal-app/viewer"; +import { Square } from "lucide-react"; +import { TreeNodeWrapper } from "./tree-node"; +import { TreeNodeActions } from "./tree-node-actions"; + +interface CeilingTreeNodeProps { + node: CeilingNode; + depth: number; +} + +export function CeilingTreeNode({ node, depth }: CeilingTreeNodeProps) { + 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 approximate area from polygon + const area = calculatePolygonArea(node.polygon).toFixed(1); + + return ( + } + label={node.name || `Ceiling (${area}m²)`} + depth={depth} + hasChildren={false} + expanded={false} + onToggle={() => {}} + onClick={handleClick} + onMouseEnter={handleMouseEnter} + onMouseLeave={handleMouseLeave} + isSelected={isSelected} + isHovered={isHovered} + actions={} + /> + ); +} + +/** + * Calculate the area of a polygon using the shoelace formula + */ +function calculatePolygonArea(polygon: Array<[number, number]>): number { + if (polygon.length < 3) return 0; + + let area = 0; + const n = polygon.length; + + for (let i = 0; i < n; i++) { + const j = (i + 1) % n; + area += polygon[i]![0] * polygon[j]![1]; + area -= polygon[j]![0] * polygon[i]![1]; + } + + return Math.abs(area) / 2; +} 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 4f12223b..0169513f 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 @@ -2,6 +2,7 @@ import { AnyNodeId, useScene } from "@pascal-app/core"; import { ChevronDown, ChevronRight } from "lucide-react"; import { cn } from "@/lib/utils"; 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 { SlabTreeNode } from "./slab-tree-node"; @@ -20,6 +21,8 @@ export function TreeNode({ nodeId, depth = 0 }: TreeNodeProps) { switch (node.type) { case "building": return ; + case "ceiling": + return ; case "level": return ; case "slab": diff --git a/packages/core/src/events/bus.ts b/packages/core/src/events/bus.ts index 698de8bc..09a770cb 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, ItemNode, SlabNode, WallNode, ZoneNode } from '../schema' +import type { BuildingNode, CeilingNode, ItemNode, SlabNode, WallNode, ZoneNode } from '../schema' import type { AnyNode } from '../schema/types' // Base event interfaces @@ -23,6 +23,7 @@ export type ItemEvent = NodeEvent export type BuildingEvent = NodeEvent export type ZoneEvent = NodeEvent export type SlabEvent = NodeEvent +export type CeilingEvent = NodeEvent // Event suffixes - exported for use in hooks export const eventSuffixes = [ @@ -60,6 +61,7 @@ type EditorEvents = GridEvents & NodeEvents<'item', ItemEvent> & NodeEvents<'building', BuildingEvent> & NodeEvents<'zone', ZoneEvent> & + NodeEvents<'ceiling', CeilingEvent> & 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 14d137bb..202e6144 100644 --- a/packages/core/src/hooks/scene-registry/scene-registry.ts +++ b/packages/core/src/hooks/scene-registry/scene-registry.ts @@ -9,6 +9,7 @@ export const sceneRegistry = { // Using a Set is faster for adding/deleting than an Array byType: { building: new Set(), + ceiling: new Set(), level: new Set(), wall: new Set(), item: new Set(), diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 9783ec1c..7b2ef67a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -10,6 +10,7 @@ export type { SlabEvent, WallEvent, ZoneEvent, + CeilingEvent, } from './events/bus' // Events export { emitter, eventSuffixes } from './events/bus' @@ -27,6 +28,7 @@ export { useSpatialQuery } from './hooks/spatial-grid/use-spatial-query' export * from './schema' export { default as useScene } from './store/use-scene' // Systems +export { CeilingSystem } from './systems/ceiling/ceiling-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 a08a404e..c82c05a9 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -2,15 +2,16 @@ export { BaseNode, generateId, Material, nodeType, objectId } from './base' // Camera export { CameraSchema } from './camera' -export { BuildingNode } from './nodes/building' export type { AssetInput } from './nodes/item' export { ItemNode } from './nodes/item' export { LevelNode } from './nodes/level' // Nodes export { SiteNode } from './nodes/site' -export { SlabNode, SlabPolygon } from './nodes/slab' +export { SlabNode } from './nodes/slab' export { WallNode } from './nodes/wall' -export type { ZonePolygon } from './nodes/zone' +export { BuildingNode } from './nodes/building' +export { CeilingNode } from './nodes/ceiling' + export { ZoneNode } from './nodes/zone' export type { AnyNodeId, AnyNodeType } from './types' // Union types diff --git a/packages/core/src/schema/nodes/ceiling.ts b/packages/core/src/schema/nodes/ceiling.ts new file mode 100644 index 00000000..cd2adbc6 --- /dev/null +++ b/packages/core/src/schema/nodes/ceiling.ts @@ -0,0 +1,19 @@ +import dedent from 'dedent' +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' + +export const CeilingNode = BaseNode.extend({ + id: objectId('ceiling'), + type: nodeType('ceiling'), + // Specific props + // Polygon boundary - array of [x, z] coordinates defining the ceiling + polygon: z.array(z.tuple([z.number(), z.number()])), + height: z.number().default(2.5), // Height in meters +}).describe( + dedent` + Ceiling node - used to represent a ceiling in the building + - polygon: array of [x, z] points defining the ceiling boundary + `, +) + +export type CeilingNode = z.infer diff --git a/packages/core/src/schema/nodes/level.ts b/packages/core/src/schema/nodes/level.ts index 3cfdc0c7..c613fede 100644 --- a/packages/core/src/schema/nodes/level.ts +++ b/packages/core/src/schema/nodes/level.ts @@ -1,6 +1,7 @@ import dedent from 'dedent' import { z } from 'zod' import { BaseNode, nodeType, objectId } from '../base' +import { CeilingNode } from './ceiling' import { SlabNode } from './slab' import { WallNode } from './wall' import { ZoneNode } from './zone' @@ -8,7 +9,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])).default([]), + children: z.array(z.union([WallNode.shape.id, ZoneNode.shape.id, SlabNode.shape.id, CeilingNode.shape.id])).default([]), // Specific props level: z.number().default(0), }).describe( diff --git a/packages/core/src/schema/nodes/slab.ts b/packages/core/src/schema/nodes/slab.ts index 0a0f90a2..7e051552 100644 --- a/packages/core/src/schema/nodes/slab.ts +++ b/packages/core/src/schema/nodes/slab.ts @@ -2,15 +2,12 @@ import dedent from 'dedent' import { z } from 'zod' import { BaseNode, nodeType, objectId } from '../base' -// Polygon boundary for zone area - array of [x, z] coordinates -export const SlabPolygon = z.array(z.tuple([z.number(), z.number()])) - export const SlabNode = BaseNode.extend({ id: objectId('slab'), type: nodeType('slab'), // Specific props // Polygon boundary - array of [x, z] coordinates defining the slab - polygon: SlabPolygon, + polygon: z.array(z.tuple([z.number(), z.number()])), elevation: z.number().default(0.05), // Elevation in meters }).describe( dedent` @@ -21,4 +18,3 @@ export const SlabNode = BaseNode.extend({ ) export type SlabNode = z.infer -export type SlabPolygon = z.infer diff --git a/packages/core/src/schema/nodes/zone.ts b/packages/core/src/schema/nodes/zone.ts index cd9fdfd9..429d3348 100644 --- a/packages/core/src/schema/nodes/zone.ts +++ b/packages/core/src/schema/nodes/zone.ts @@ -2,15 +2,12 @@ import dedent from 'dedent' import { z } from 'zod' import { BaseNode, nodeType, objectId } from '../base' -// Polygon boundary for zone area - array of [x, z] coordinates -export const ZonePolygon = z.array(z.tuple([z.number(), z.number()])) - export const ZoneNode = BaseNode.extend({ id: objectId('zone'), type: nodeType('zone'), name: z.string(), // Polygon boundary - array of [x, z] coordinates defining the zone - polygon: ZonePolygon, + polygon: z.array(z.tuple([z.number(), z.number()])), // Visual styling color: z.string().default('#3b82f6'), // Default blue metadata: z.json().optional().default({}), @@ -28,4 +25,3 @@ export const ZoneNode = BaseNode.extend({ ) export type ZoneNode = z.infer -export type ZonePolygon = z.infer diff --git a/packages/core/src/schema/types.ts b/packages/core/src/schema/types.ts index 9b3da384..5f99dcfe 100644 --- a/packages/core/src/schema/types.ts +++ b/packages/core/src/schema/types.ts @@ -1,5 +1,6 @@ import z from 'zod' import { BuildingNode } from './nodes/building' +import { CeilingNode } from './nodes/ceiling' import { ItemNode } from './nodes/item' import { LevelNode } from './nodes/level' import { SiteNode } from './nodes/site' @@ -15,6 +16,7 @@ export const AnyNode = z.discriminatedUnion('type', [ ItemNode, ZoneNode, SlabNode, + CeilingNode, ]) export type AnyNode = z.infer diff --git a/packages/core/src/systems/ceiling/ceiling-system.tsx b/packages/core/src/systems/ceiling/ceiling-system.tsx new file mode 100644 index 00000000..0bd23b48 --- /dev/null +++ b/packages/core/src/systems/ceiling/ceiling-system.tsx @@ -0,0 +1,78 @@ +import { useFrame } from '@react-three/fiber' +import * as THREE from 'three' +import { sceneRegistry } from '../../hooks/scene-registry/scene-registry' +import type { AnyNodeId, CeilingNode } from '../../schema' +import useScene from '../../store/use-scene' + +// ============================================================================ +// CEILING SYSTEM +// ============================================================================ + +export const CeilingSystem = () => { + const { nodes, dirtyNodes, clearDirty } = useScene() + + useFrame(() => { + if (dirtyNodes.size === 0) return + + // Process dirty ceilings + dirtyNodes.forEach((id) => { + const node = nodes[id] + if (!node || node.type !== 'ceiling') return + + const mesh = sceneRegistry.nodes.get(id) as THREE.Mesh + if (mesh) { + updateCeilingGeometry(node as CeilingNode, mesh) + } + clearDirty(id as AnyNodeId) + }) + }) + + return null +} + +/** + * Updates the geometry for a single ceiling + */ +function updateCeilingGeometry(node: CeilingNode, mesh: THREE.Mesh) { + const newGeo = generateCeilingGeometry(node) + + mesh.geometry.dispose() + mesh.geometry = newGeo + + // Position at the ceiling height + mesh.position.y = node.height ?? 2.5 +} + +/** + * Generates flat ceiling geometry from polygon (no extrusion) + */ +export function generateCeilingGeometry(ceilingNode: CeilingNode): THREE.BufferGeometry { + const polygon = ceilingNode.polygon + + if (polygon.length < 3) { + return new THREE.BufferGeometry() + } + + // Create shape from polygon + // Shape is in X-Y plane, we'll rotate to X-Z plane + const shape = new THREE.Shape() + const firstPt = polygon[0]! + + // Negate Y (which becomes Z) to get correct orientation after rotation + shape.moveTo(firstPt[0], -firstPt[1]) + + for (let i = 1; i < polygon.length; i++) { + const pt = polygon[i]! + shape.lineTo(pt[0], -pt[1]) + } + shape.closePath() + + // Create flat shape geometry (no extrusion) + const geometry = new THREE.ShapeGeometry(shape) + + // Rotate so the shape lies flat in X-Z plane + geometry.rotateX(-Math.PI / 2) + geometry.computeVertexNormals() + + return geometry +} diff --git a/packages/viewer/src/components/renderers/ceiling/ceiling-renderer.tsx b/packages/viewer/src/components/renderers/ceiling/ceiling-renderer.tsx new file mode 100644 index 00000000..8c89beb3 --- /dev/null +++ b/packages/viewer/src/components/renderers/ceiling/ceiling-renderer.tsx @@ -0,0 +1,32 @@ +import { type CeilingNode, useRegistry } from '@pascal-app/core' +import { useRef } from 'react' +import { faceDirection, float, mix } from 'three/tsl' +import { DoubleSide, type Mesh, MeshStandardNodeMaterial } from 'three/webgpu' +import { useNodeEvents } from '../../../hooks/use-node-events' + +// TSL material that renders differently based on face direction: +// - Back face (looking up at ceiling from below): solid +// - Front face (looking down at ceiling from above): 30% opacity +const ceilingMaterial = new MeshStandardNodeMaterial({ + color: 0xffffff, + side: DoubleSide, + transparent: true, +}) + +// faceDirection is 1.0 for front face, -1.0 for back face +// We want: front face (top, looking down) = 0.3 opacity, back face (bottom, looking up) = 1.0 opacity +ceilingMaterial.opacityNode = mix(float(1.0), float(0.3), faceDirection.greaterThan(0.0)) + +export const CeilingRenderer = ({ node }: { node: CeilingNode }) => { + const ref = useRef(null!) + + useRegistry(node.id, 'ceiling', ref) + const handlers = useNodeEvents(node, 'ceiling') + + return ( + + {/* CeilingSystem will replace this geometry in the next frame */} + + + ) +} diff --git a/packages/viewer/src/components/renderers/node-renderer.tsx b/packages/viewer/src/components/renderers/node-renderer.tsx index 7ff8a7a6..8c4234ac 100644 --- a/packages/viewer/src/components/renderers/node-renderer.tsx +++ b/packages/viewer/src/components/renderers/node-renderer.tsx @@ -2,6 +2,7 @@ import { type AnyNode, useScene } from '@pascal-app/core' import { BuildingRenderer } from './building/building-renderer' +import { CeilingRenderer } from './ceiling/ceiling-renderer' import { ItemRenderer } from './item/item-renderer' import { LevelRenderer } from './level/level-renderer' import { SlabRenderer } from './slab/slab-renderer' @@ -16,6 +17,7 @@ export const NodeRenderer = ({ nodeId }: { nodeId: AnyNode['id'] }) => { return ( <> {node.type === 'building' && } + {node.type === 'ceiling' && } {node.type === 'level' && } {node.type === 'item' && } {node.type === 'slab' && } diff --git a/packages/viewer/src/components/viewer/index.tsx b/packages/viewer/src/components/viewer/index.tsx index 363f21ba..c69ebccb 100644 --- a/packages/viewer/src/components/viewer/index.tsx +++ b/packages/viewer/src/components/viewer/index.tsx @@ -1,6 +1,6 @@ 'use client' -import { SlabSystem, WallSystem } from '@pascal-app/core' +import { CeilingSystem, 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' @@ -40,6 +40,7 @@ const Viewer: React.FC = ({ children }) => { {/* Default Systems */} + diff --git a/packages/viewer/src/hooks/use-node-events.ts b/packages/viewer/src/hooks/use-node-events.ts index 999baf8b..3b6f6ade 100644 --- a/packages/viewer/src/hooks/use-node-events.ts +++ b/packages/viewer/src/hooks/use-node-events.ts @@ -1,6 +1,8 @@ import { type BuildingEvent, type BuildingNode, + type CeilingEvent, + type CeilingNode, type EventSuffix, emitter, type ItemEvent, @@ -20,6 +22,7 @@ type NodeConfig = { building: { node: BuildingNode; event: BuildingEvent } zone: { node: ZoneNode; event: ZoneEvent } slab: { node: SlabNode; event: SlabEvent } + ceiling: { node: CeilingNode; event: CeilingEvent } } type NodeType = keyof NodeConfig