Merge pull request #83 from pascalorg/feat/polish-v2-builder-experience
Feat/polish v2 builder experience
This commit is contained in:
@@ -13,8 +13,7 @@ import { useKeyboard } from '@/hooks/use-keyboard'
|
|||||||
import { ZoneSystem } from '../systems/zone/zone-system'
|
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 { PanelManager } from '../ui/panels/panel-manager'
|
||||||
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'
|
||||||
@@ -31,8 +30,7 @@ export default function Editor() {
|
|||||||
return (
|
return (
|
||||||
<div className="w-full h-full">
|
<div className="w-full h-full">
|
||||||
<ActionMenu />
|
<ActionMenu />
|
||||||
<ReferencePanel />
|
<PanelManager />
|
||||||
<RoofPanel />
|
|
||||||
|
|
||||||
<SidebarProvider className="fixed z-10">
|
<SidebarProvider className="fixed z-10">
|
||||||
<AppSidebar />
|
<AppSidebar />
|
||||||
|
|||||||
@@ -503,12 +503,14 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
if (!asset.attachTo) {
|
if (!asset.attachTo) {
|
||||||
const levelId = useViewer.getState().selection.levelId
|
const levelId = useViewer.getState().selection.levelId
|
||||||
if (levelId) {
|
if (levelId) {
|
||||||
mesh.position.y = spatialGridManager.getSlabElevationForItem(
|
const slabElevation = spatialGridManager.getSlabElevationForItem(
|
||||||
levelId,
|
levelId,
|
||||||
[gridPosition.current.x, gridPosition.current.y, gridPosition.current.z],
|
[gridPosition.current.x, gridPosition.current.y, gridPosition.current.z],
|
||||||
asset.dimensions ?? DEFAULT_DIMENSIONS,
|
asset.dimensions ?? DEFAULT_DIMENSIONS,
|
||||||
draftNode.current.rotation,
|
draftNode.current.rotation,
|
||||||
)
|
)
|
||||||
|
mesh.position.y = slabElevation
|
||||||
|
cursorRef.current.position.y = slabElevation
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,12 +46,12 @@ const calculateSnapPoint = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a slab with the given polygon points
|
* Creates a slab with the given polygon points and returns its ID
|
||||||
*/
|
*/
|
||||||
const commitSlabDrawing = (
|
const commitSlabDrawing = (
|
||||||
levelId: LevelNode["id"],
|
levelId: LevelNode["id"],
|
||||||
points: Array<[number, number]>
|
points: Array<[number, number]>
|
||||||
) => {
|
): string => {
|
||||||
const { createNode, nodes } = useScene.getState();
|
const { createNode, nodes } = useScene.getState();
|
||||||
|
|
||||||
// Count existing slabs for naming
|
// Count existing slabs for naming
|
||||||
@@ -64,6 +64,7 @@ const commitSlabDrawing = (
|
|||||||
});
|
});
|
||||||
|
|
||||||
createNode(slab, levelId);
|
createNode(slab, levelId);
|
||||||
|
return slab.id;
|
||||||
};
|
};
|
||||||
|
|
||||||
type PreviewState = {
|
type PreviewState = {
|
||||||
@@ -87,6 +88,7 @@ export const SlabTool: React.FC = () => {
|
|||||||
const pointsRef = useRef<Array<[number, number]>>([]);
|
const pointsRef = useRef<Array<[number, number]>>([]);
|
||||||
const levelYRef = useRef(0); // Track current level Y position
|
const levelYRef = useRef(0); // Track current level Y position
|
||||||
const currentLevelId = useViewer((state) => state.selection.levelId);
|
const currentLevelId = useViewer((state) => state.selection.levelId);
|
||||||
|
const setSelection = useViewer((state) => state.setSelection);
|
||||||
const setTool = useEditor((state) => state.setTool);
|
const setTool = useEditor((state) => state.setTool);
|
||||||
|
|
||||||
// Preview state for reactive rendering (for shape and point markers)
|
// Preview state for reactive rendering (for shape and point markers)
|
||||||
@@ -213,17 +215,15 @@ export const SlabTool: React.FC = () => {
|
|||||||
Math.abs(clickPoint[0] - firstPoint[0]) < 0.25 &&
|
Math.abs(clickPoint[0] - firstPoint[0]) < 0.25 &&
|
||||||
Math.abs(clickPoint[1] - firstPoint[1]) < 0.25
|
Math.abs(clickPoint[1] - firstPoint[1]) < 0.25
|
||||||
) {
|
) {
|
||||||
// Create the slab
|
// Create the slab and select it
|
||||||
commitSlabDrawing(currentLevelId, pointsRef.current);
|
const slabId = commitSlabDrawing(currentLevelId, pointsRef.current);
|
||||||
|
setSelection({ selectedIds: [slabId] });
|
||||||
|
|
||||||
// Reset state
|
// Reset state
|
||||||
pointsRef.current = [];
|
pointsRef.current = [];
|
||||||
setPreview({ points: [], cursorPoint: null, levelY: 0 });
|
setPreview({ points: [], cursorPoint: null, levelY: 0 });
|
||||||
mainLineRef.current.visible = false;
|
mainLineRef.current.visible = false;
|
||||||
closingLineRef.current.visible = false;
|
closingLineRef.current.visible = false;
|
||||||
|
|
||||||
// Deactivate tool
|
|
||||||
setTool(null);
|
|
||||||
} else {
|
} else {
|
||||||
// Add point to polygon
|
// Add point to polygon
|
||||||
pointsRef.current = [...pointsRef.current, clickPoint];
|
pointsRef.current = [...pointsRef.current, clickPoint];
|
||||||
@@ -236,16 +236,15 @@ export const SlabTool: React.FC = () => {
|
|||||||
|
|
||||||
// Need at least 3 points to form a polygon
|
// Need at least 3 points to form a polygon
|
||||||
if (pointsRef.current.length >= 3) {
|
if (pointsRef.current.length >= 3) {
|
||||||
commitSlabDrawing(currentLevelId, pointsRef.current);
|
// Create the slab and select it
|
||||||
|
const slabId = commitSlabDrawing(currentLevelId, pointsRef.current);
|
||||||
|
setSelection({ selectedIds: [slabId] });
|
||||||
|
|
||||||
// Reset state
|
// Reset state
|
||||||
pointsRef.current = [];
|
pointsRef.current = [];
|
||||||
setPreview({ points: [], cursorPoint: null, levelY: 0 });
|
setPreview({ points: [], cursorPoint: null, levelY: 0 });
|
||||||
mainLineRef.current.visible = false;
|
mainLineRef.current.visible = false;
|
||||||
closingLineRef.current.visible = false;
|
closingLineRef.current.visible = false;
|
||||||
|
|
||||||
// Deactivate tool
|
|
||||||
setTool(null);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -262,7 +261,7 @@ export const SlabTool: React.FC = () => {
|
|||||||
// Reset state on unmount
|
// Reset state on unmount
|
||||||
pointsRef.current = [];
|
pointsRef.current = [];
|
||||||
};
|
};
|
||||||
}, [currentLevelId, setTool]);
|
}, [currentLevelId, setSelection]);
|
||||||
|
|
||||||
const { points, cursorPoint, levelY } = preview;
|
const { points, cursorPoint, levelY } = preview;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { AnyNodeId, useScene } from '@pascal-app/core'
|
||||||
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
|
import useEditor from '@/store/use-editor'
|
||||||
|
import { ReferencePanel } from './reference-panel'
|
||||||
|
import { RoofPanel } from './roof-panel'
|
||||||
|
import { SlabPanel } from './slab-panel'
|
||||||
|
|
||||||
|
export function PanelManager() {
|
||||||
|
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
||||||
|
const selectedReferenceId = useEditor((s) => s.selectedReferenceId)
|
||||||
|
const nodes = useScene((s) => s.nodes)
|
||||||
|
|
||||||
|
// Show reference panel if a reference is selected
|
||||||
|
if (selectedReferenceId) {
|
||||||
|
return <ReferencePanel />
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show appropriate panel based on selected node type
|
||||||
|
if (selectedIds.length === 1) {
|
||||||
|
const selectedNode = selectedIds[0]
|
||||||
|
const node = nodes[selectedNode as AnyNodeId]
|
||||||
|
if (node) {
|
||||||
|
switch (node.type) {
|
||||||
|
case 'roof':
|
||||||
|
return <RoofPanel />
|
||||||
|
case 'slab':
|
||||||
|
return <SlabPanel />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { type AnyNode, type SlabNode, useScene } from '@pascal-app/core'
|
||||||
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
|
import { X } from 'lucide-react'
|
||||||
|
import Image from 'next/image'
|
||||||
|
import { useCallback } from 'react'
|
||||||
|
|
||||||
|
export function SlabPanel() {
|
||||||
|
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 slab
|
||||||
|
const selectedId = selectedIds[0]
|
||||||
|
const node = selectedId
|
||||||
|
? (nodes[selectedId as AnyNode['id']] as SlabNode | undefined)
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
const handleUpdate = useCallback(
|
||||||
|
(updates: Partial<SlabNode>) => {
|
||||||
|
if (!selectedId) return
|
||||||
|
updateNode(selectedId as AnyNode['id'], updates)
|
||||||
|
},
|
||||||
|
[selectedId, updateNode],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
setSelection({ selectedIds: [] })
|
||||||
|
}, [setSelection])
|
||||||
|
|
||||||
|
// Only show if exactly one slab is selected
|
||||||
|
if (!node || node.type !== 'slab' || selectedIds.length !== 1) return null
|
||||||
|
|
||||||
|
// Calculate approximate area from polygon
|
||||||
|
const calculateArea = (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
|
||||||
|
}
|
||||||
|
|
||||||
|
const area = calculateArea(node.polygon)
|
||||||
|
|
||||||
|
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">
|
||||||
|
<Image src="/icons/floor.png" alt="" width={16} height={16} className="shrink-0 object-contain" />
|
||||||
|
<h2 className="font-semibold text-foreground text-sm truncate">
|
||||||
|
{node.name || `Slab (${area.toFixed(1)}m²)`}
|
||||||
|
</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">
|
||||||
|
{/* Elevation */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
|
||||||
|
Elevation
|
||||||
|
</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"
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = Number.parseFloat(e.target.value)
|
||||||
|
if (!Number.isNaN(value)) {
|
||||||
|
handleUpdate({ elevation: value })
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
step="0.05"
|
||||||
|
type="number"
|
||||||
|
value={Math.round(node.elevation * 1000) / 1000}
|
||||||
|
/>
|
||||||
|
<span className="text-muted-foreground text-xs shrink-0">m</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Height offset from the level base (positive = raised, negative = sunken)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick preset buttons */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
|
||||||
|
Presets
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-4 gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded border border-border px-2 py-1.5 text-xs hover:bg-accent cursor-pointer"
|
||||||
|
onClick={() => handleUpdate({ elevation: -0.15 })}
|
||||||
|
>
|
||||||
|
Sunken (-15cm)
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded border border-border px-2 py-1.5 text-xs hover:bg-accent cursor-pointer"
|
||||||
|
onClick={() => handleUpdate({ elevation: 0 })}
|
||||||
|
>
|
||||||
|
Ground (0m)
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded border border-border px-2 py-1.5 text-xs hover:bg-accent cursor-pointer"
|
||||||
|
onClick={() => handleUpdate({ elevation: 0.05 })}
|
||||||
|
>
|
||||||
|
Raised (5cm)
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded border border-border px-2 py-1.5 text-xs hover:bg-accent cursor-pointer"
|
||||||
|
onClick={() => handleUpdate({ elevation: 0.15 })}
|
||||||
|
>
|
||||||
|
Step (15cm)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Area info */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
|
||||||
|
Area
|
||||||
|
</label>
|
||||||
|
<div className="rounded border border-border bg-muted/50 px-3 py-2 text-sm">
|
||||||
|
{area.toFixed(2)} m²
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -59,6 +59,7 @@ export function CeilingTreeNode({ node, depth }: CeilingTreeNodeProps) {
|
|||||||
onMouseLeave={handleMouseLeave}
|
onMouseLeave={handleMouseLeave}
|
||||||
isSelected={isSelected}
|
isSelected={isSelected}
|
||||||
isHovered={isHovered}
|
isHovered={isHovered}
|
||||||
|
isVisible={node.visible !== false}
|
||||||
actions={<TreeNodeActions node={node} />}
|
actions={<TreeNodeActions node={node} />}
|
||||||
>
|
>
|
||||||
{node.children.map((childId) => (
|
{node.children.map((childId) => (
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
type BuildingNode,
|
type BuildingNode,
|
||||||
|
emitter,
|
||||||
LevelNode,
|
LevelNode,
|
||||||
useScene,
|
useScene,
|
||||||
type ZoneNode,
|
type ZoneNode,
|
||||||
@@ -7,6 +8,7 @@ import {
|
|||||||
import { useViewer } from "@pascal-app/viewer";
|
import { useViewer } from "@pascal-app/viewer";
|
||||||
import {
|
import {
|
||||||
Building2,
|
Building2,
|
||||||
|
Camera,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Layers,
|
Layers,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
@@ -155,11 +157,13 @@ function BuildingSelector() {
|
|||||||
function LevelsSection() {
|
function LevelsSection() {
|
||||||
const nodes = useScene((state) => state.nodes);
|
const nodes = useScene((state) => state.nodes);
|
||||||
const createNode = useScene((state) => state.createNode);
|
const createNode = useScene((state) => state.createNode);
|
||||||
|
const updateNode = useScene((state) => state.updateNode);
|
||||||
const selectedBuildingId = useViewer((state) => state.selection.buildingId);
|
const selectedBuildingId = useViewer((state) => state.selection.buildingId);
|
||||||
const selectedLevelId = useViewer((state) => state.selection.levelId);
|
const selectedLevelId = useViewer((state) => state.selection.levelId);
|
||||||
const setSelection = useViewer((state) => state.setSelection);
|
const setSelection = useViewer((state) => state.setSelection);
|
||||||
|
|
||||||
const [referencesLevelId, setReferencesLevelId] = useState<string | null>(null);
|
const [referencesLevelId, setReferencesLevelId] = useState<string | null>(null);
|
||||||
|
const [cameraPopoverOpen, setCameraPopoverOpen] = useState<string | null>(null);
|
||||||
|
|
||||||
const building = selectedBuildingId
|
const building = selectedBuildingId
|
||||||
? (nodes[selectedBuildingId] as BuildingNode)
|
? (nodes[selectedBuildingId] as BuildingNode)
|
||||||
@@ -215,6 +219,72 @@ function LevelsSection() {
|
|||||||
<Layers className="w-3.5 h-3.5 shrink-0" />
|
<Layers className="w-3.5 h-3.5 shrink-0" />
|
||||||
<span className="truncate">{level.name || `Level ${level.level}`}</span>
|
<span className="truncate">{level.name || `Level ${level.level}`}</span>
|
||||||
</button>
|
</button>
|
||||||
|
{/* Camera snapshot button */}
|
||||||
|
<Popover open={cameraPopoverOpen === level.id} onOpenChange={(open) => setCameraPopoverOpen(open ? level.id : null)}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
"relative opacity-0 group-hover/level:opacity-100 w-6 h-6 mr-1 flex items-center justify-center rounded cursor-pointer shrink-0",
|
||||||
|
selectedLevelId === level.id
|
||||||
|
? "hover:bg-primary-foreground/20"
|
||||||
|
: "hover:bg-accent"
|
||||||
|
)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
title="Camera snapshot"
|
||||||
|
>
|
||||||
|
<Camera className="w-3.5 h-3.5" />
|
||||||
|
{level.camera && (
|
||||||
|
<span className="absolute top-0.5 right-0.5 w-1.5 h-1.5 rounded-full bg-primary" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
side="right"
|
||||||
|
align="start"
|
||||||
|
className="w-auto p-1"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
{level.camera && (
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded cursor-pointer text-popover-foreground hover:bg-accent text-left w-full"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
emitter.emit("camera-controls:view", { nodeId: level.id });
|
||||||
|
setCameraPopoverOpen(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Camera className="w-3.5 h-3.5" />
|
||||||
|
View snapshot
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded cursor-pointer text-popover-foreground hover:bg-accent text-left w-full"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
emitter.emit("camera-controls:capture", { nodeId: level.id });
|
||||||
|
setCameraPopoverOpen(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Camera className="w-3.5 h-3.5" />
|
||||||
|
{level.camera ? "Update snapshot" : "Take snapshot"}
|
||||||
|
</button>
|
||||||
|
{level.camera && (
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded cursor-pointer text-popover-foreground hover:bg-destructive hover:text-destructive-foreground text-left w-full"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
updateNode(level.id, { camera: undefined });
|
||||||
|
setCameraPopoverOpen(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
Clear snapshot
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
<Popover>
|
<Popover>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<button
|
<button
|
||||||
@@ -307,6 +377,7 @@ function calculatePolygonArea(polygon: Array<[number, number]>): number {
|
|||||||
|
|
||||||
function ZoneItem({ zone }: { zone: ZoneNode }) {
|
function ZoneItem({ zone }: { zone: ZoneNode }) {
|
||||||
const [renameOpen, setRenameOpen] = useState(false);
|
const [renameOpen, setRenameOpen] = useState(false);
|
||||||
|
const [cameraPopoverOpen, setCameraPopoverOpen] = useState(false);
|
||||||
const deleteNode = useScene((state) => state.deleteNode);
|
const deleteNode = useScene((state) => state.deleteNode);
|
||||||
const updateNode = useScene((state) => state.updateNode);
|
const updateNode = useScene((state) => state.updateNode);
|
||||||
const selectedZoneId = useViewer((state) => state.selection.zoneId);
|
const selectedZoneId = useViewer((state) => state.selection.zoneId);
|
||||||
@@ -394,6 +465,67 @@ function ZoneItem({ zone }: { zone: ZoneNode }) {
|
|||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
<span className="truncate flex-1">{zone.name || defaultName}</span>
|
<span className="truncate flex-1">{zone.name || defaultName}</span>
|
||||||
|
{/* Camera snapshot button */}
|
||||||
|
<Popover open={cameraPopoverOpen} onOpenChange={setCameraPopoverOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<button
|
||||||
|
className="relative opacity-0 group-hover/row:opacity-100 w-5 h-5 flex items-center justify-center rounded cursor-pointer hover:bg-primary-foreground/20"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
title="Camera snapshot"
|
||||||
|
>
|
||||||
|
<Camera className="w-3 h-3" />
|
||||||
|
{zone.camera && (
|
||||||
|
<span className="absolute top-0.5 right-0.5 w-1.5 h-1.5 rounded-full bg-primary" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
side="right"
|
||||||
|
align="start"
|
||||||
|
className="w-auto p-1"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
{zone.camera && (
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded cursor-pointer text-popover-foreground hover:bg-accent text-left w-full"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
emitter.emit("camera-controls:view", { nodeId: zone.id });
|
||||||
|
setCameraPopoverOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Camera className="w-3.5 h-3.5" />
|
||||||
|
View snapshot
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded cursor-pointer text-popover-foreground hover:bg-accent text-left w-full"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
emitter.emit("camera-controls:capture", { nodeId: zone.id });
|
||||||
|
setCameraPopoverOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Camera className="w-3.5 h-3.5" />
|
||||||
|
{zone.camera ? "Update snapshot" : "Take snapshot"}
|
||||||
|
</button>
|
||||||
|
{zone.camera && (
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded cursor-pointer text-popover-foreground hover:bg-destructive hover:text-destructive-foreground text-left w-full"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
updateNode(zone.id, { camera: undefined });
|
||||||
|
setCameraPopoverOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
Clear snapshot
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
<button
|
<button
|
||||||
className="opacity-0 group-hover/row:opacity-100 w-5 h-5 flex items-center justify-center rounded cursor-pointer hover:bg-primary-foreground/20"
|
className="opacity-0 group-hover/row:opacity-100 w-5 h-5 flex items-center justify-center rounded cursor-pointer hover:bg-primary-foreground/20"
|
||||||
onClick={handleDelete}
|
onClick={handleDelete}
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ export function ItemTreeNode({ node, depth }: ItemTreeNodeProps) {
|
|||||||
onMouseLeave={handleMouseLeave}
|
onMouseLeave={handleMouseLeave}
|
||||||
isSelected={isSelected}
|
isSelected={isSelected}
|
||||||
isHovered={isHovered}
|
isHovered={isHovered}
|
||||||
|
isVisible={node.visible !== false}
|
||||||
actions={<TreeNodeActions node={node} />}
|
actions={<TreeNodeActions node={node} />}
|
||||||
/>
|
/>
|
||||||
</RenamePopover>
|
</RenamePopover>
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ export function RoofTreeNode({ node, depth }: RoofTreeNodeProps) {
|
|||||||
onMouseLeave={handleMouseLeave}
|
onMouseLeave={handleMouseLeave}
|
||||||
isSelected={isSelected}
|
isSelected={isSelected}
|
||||||
isHovered={isHovered}
|
isHovered={isHovered}
|
||||||
|
isVisible={node.visible !== false}
|
||||||
actions={<TreeNodeActions node={node} />}
|
actions={<TreeNodeActions node={node} />}
|
||||||
/>
|
/>
|
||||||
</RenamePopover>
|
</RenamePopover>
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ export function SlabTreeNode({ node, depth }: SlabTreeNodeProps) {
|
|||||||
onMouseLeave={handleMouseLeave}
|
onMouseLeave={handleMouseLeave}
|
||||||
isSelected={isSelected}
|
isSelected={isSelected}
|
||||||
isHovered={isHovered}
|
isHovered={isHovered}
|
||||||
|
isVisible={node.visible !== false}
|
||||||
actions={<TreeNodeActions node={node} />}
|
actions={<TreeNodeActions node={node} />}
|
||||||
/>
|
/>
|
||||||
</RenamePopover>
|
</RenamePopover>
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ interface TreeNodeWrapperProps {
|
|||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
isSelected?: boolean;
|
isSelected?: boolean;
|
||||||
isHovered?: boolean;
|
isHovered?: boolean;
|
||||||
|
isVisible?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TreeNodeWrapper = forwardRef<HTMLDivElement, TreeNodeWrapperProps>(
|
export const TreeNodeWrapper = forwardRef<HTMLDivElement, TreeNodeWrapperProps>(
|
||||||
@@ -77,6 +78,7 @@ export const TreeNodeWrapper = forwardRef<HTMLDivElement, TreeNodeWrapperProps>(
|
|||||||
children,
|
children,
|
||||||
isSelected,
|
isSelected,
|
||||||
isHovered,
|
isHovered,
|
||||||
|
isVisible = true,
|
||||||
},
|
},
|
||||||
ref
|
ref
|
||||||
) {
|
) {
|
||||||
@@ -89,7 +91,8 @@ export const TreeNodeWrapper = forwardRef<HTMLDivElement, TreeNodeWrapperProps>(
|
|||||||
? "text-primary-foreground bg-primary/80 hover:bg-primary/90"
|
? "text-primary-foreground bg-primary/80 hover:bg-primary/90"
|
||||||
: isHovered
|
: isHovered
|
||||||
? "bg-accent/70 text-foreground"
|
? "bg-accent/70 text-foreground"
|
||||||
: "text-muted-foreground hover:bg-accent/50"
|
: "text-muted-foreground hover:bg-accent/50",
|
||||||
|
!isVisible && "opacity-50"
|
||||||
)}
|
)}
|
||||||
style={{ paddingLeft: depth * 12 + 4 }}
|
style={{ paddingLeft: depth * 12 + 4 }}
|
||||||
onMouseEnter={onMouseEnter}
|
onMouseEnter={onMouseEnter}
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ export function WallTreeNode({ node, depth }: WallTreeNodeProps) {
|
|||||||
onMouseLeave={handleMouseLeave}
|
onMouseLeave={handleMouseLeave}
|
||||||
isSelected={isSelected}
|
isSelected={isSelected}
|
||||||
isHovered={isHovered}
|
isHovered={isHovered}
|
||||||
|
isVisible={node.visible !== false}
|
||||||
actions={<TreeNodeActions node={node} />}
|
actions={<TreeNodeActions node={node} />}
|
||||||
>
|
>
|
||||||
{node.children.map((childId) => (
|
{node.children.map((childId) => (
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useScene, type ZoneNode } from "@pascal-app/core";
|
import { emitter, useScene, type ZoneNode } from "@pascal-app/core";
|
||||||
import { useViewer } from "@pascal-app/viewer";
|
import { useViewer } from "@pascal-app/viewer";
|
||||||
import { Hexagon, Trash2 } from "lucide-react";
|
import { Camera, Hexagon, Trash2 } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import useEditor from "@/store/use-editor";
|
import useEditor from "@/store/use-editor";
|
||||||
import {
|
import {
|
||||||
@@ -22,6 +23,7 @@ const PRESET_COLORS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
function ZoneItem({ zone }: { zone: ZoneNode }) {
|
function ZoneItem({ zone }: { zone: ZoneNode }) {
|
||||||
|
const [cameraPopoverOpen, setCameraPopoverOpen] = useState(false);
|
||||||
const deleteNode = useScene((state) => state.deleteNode);
|
const deleteNode = useScene((state) => state.deleteNode);
|
||||||
const updateNode = useScene((state) => state.updateNode);
|
const updateNode = useScene((state) => state.updateNode);
|
||||||
const selectedZoneId = useViewer((state) => state.selection.zoneId);
|
const selectedZoneId = useViewer((state) => state.selection.zoneId);
|
||||||
@@ -85,6 +87,67 @@ function ZoneItem({ zone }: { zone: ZoneNode }) {
|
|||||||
</Popover>
|
</Popover>
|
||||||
<Hexagon className="w-3.5 h-3.5 mr-1.5 shrink-0" />
|
<Hexagon className="w-3.5 h-3.5 mr-1.5 shrink-0" />
|
||||||
<span className="truncate flex-1">{zone.name}</span>
|
<span className="truncate flex-1">{zone.name}</span>
|
||||||
|
{/* Camera snapshot button */}
|
||||||
|
<Popover open={cameraPopoverOpen} onOpenChange={setCameraPopoverOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<button
|
||||||
|
className="relative opacity-0 group-hover/row:opacity-100 w-5 h-5 flex items-center justify-center rounded cursor-pointer hover:bg-primary-foreground/20"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
title="Camera snapshot"
|
||||||
|
>
|
||||||
|
<Camera className="w-3 h-3" />
|
||||||
|
{zone.camera && (
|
||||||
|
<span className="absolute top-0.5 right-0.5 w-1.5 h-1.5 rounded-full bg-primary" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
side="right"
|
||||||
|
align="start"
|
||||||
|
className="w-auto p-1"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
{zone.camera && (
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded cursor-pointer text-popover-foreground hover:bg-accent text-left w-full"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
emitter.emit("camera-controls:view", { nodeId: zone.id });
|
||||||
|
setCameraPopoverOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Camera className="w-3.5 h-3.5" />
|
||||||
|
View snapshot
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded cursor-pointer text-popover-foreground hover:bg-accent text-left w-full"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
emitter.emit("camera-controls:capture", { nodeId: zone.id });
|
||||||
|
setCameraPopoverOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Camera className="w-3.5 h-3.5" />
|
||||||
|
{zone.camera ? "Update snapshot" : "Take snapshot"}
|
||||||
|
</button>
|
||||||
|
{zone.camera && (
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded cursor-pointer text-popover-foreground hover:bg-destructive hover:text-destructive-foreground text-left w-full"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
updateNode(zone.id, { camera: undefined });
|
||||||
|
setCameraPopoverOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
Clear snapshot
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
<button
|
<button
|
||||||
className="opacity-0 group-hover/row:opacity-100 w-5 h-5 flex items-center justify-center rounded cursor-pointer hover:bg-primary-foreground/20"
|
className="opacity-0 group-hover/row:opacity-100 w-5 h-5 flex items-center justify-center rounded cursor-pointer hover:bg-primary-foreground/20"
|
||||||
onClick={handleDelete}
|
onClick={handleDelete}
|
||||||
|
|||||||
@@ -58,9 +58,34 @@
|
|||||||
"item_0e9paq67kdbm5ux0",
|
"item_0e9paq67kdbm5ux0",
|
||||||
"item_iyu7knyxqe82c3yg",
|
"item_iyu7knyxqe82c3yg",
|
||||||
"item_nwe34qk8vzs1vag7",
|
"item_nwe34qk8vzs1vag7",
|
||||||
"guide_vh96481lk2oqzf4d"
|
"item_oay55zmjjo76s1fs",
|
||||||
|
"item_g060bhthvcra992w",
|
||||||
|
"item_38kt7s45alt2vrjg",
|
||||||
|
"item_n10cp9ke7n9hxl96",
|
||||||
|
"item_nt5fxip4a03cmaoi",
|
||||||
|
"item_1quoil3ytsuuarni",
|
||||||
|
"item_e1w89kkg2pql1v45",
|
||||||
|
"item_3akotmiffzdr8ule",
|
||||||
|
"item_1b3tinfswueb6gr8",
|
||||||
|
"item_y164oe3lxfx9qefg",
|
||||||
|
"item_dsalxofuqf96h8t4",
|
||||||
|
"roof_ui8zhim41alg6lq4",
|
||||||
|
"guide_acs9nzz19rm4vl2c"
|
||||||
],
|
],
|
||||||
"level": 0
|
"level": 0,
|
||||||
|
"camera": {
|
||||||
|
"position": [
|
||||||
|
32.19770918094574,
|
||||||
|
13.355189178183336,
|
||||||
|
32.63027548275616
|
||||||
|
],
|
||||||
|
"target": [
|
||||||
|
4.8501257048479305,
|
||||||
|
-6.656412040461838e-16,
|
||||||
|
7.3634421472590255
|
||||||
|
],
|
||||||
|
"mode": "perspective"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"slab_gr6zxi4915gqwjbn": {
|
"slab_gr6zxi4915gqwjbn": {
|
||||||
"object": "node",
|
"object": "node",
|
||||||
@@ -100,7 +125,20 @@
|
|||||||
"children": [
|
"children": [
|
||||||
"roof_jxd8tc6rcuaujl25"
|
"roof_jxd8tc6rcuaujl25"
|
||||||
],
|
],
|
||||||
"level": 1
|
"level": 1,
|
||||||
|
"camera": {
|
||||||
|
"position": [
|
||||||
|
11.709072311989358,
|
||||||
|
25.635955613557638,
|
||||||
|
50.59653427090403
|
||||||
|
],
|
||||||
|
"target": [
|
||||||
|
6.9995635873796855,
|
||||||
|
2.4999999999999996,
|
||||||
|
0.6911898255966076
|
||||||
|
],
|
||||||
|
"mode": "perspective"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"roof_jxd8tc6rcuaujl25": {
|
"roof_jxd8tc6rcuaujl25": {
|
||||||
"object": "node",
|
"object": "node",
|
||||||
@@ -117,9 +155,9 @@
|
|||||||
],
|
],
|
||||||
"rotation": 0,
|
"rotation": 0,
|
||||||
"length": 5.5,
|
"length": 5.5,
|
||||||
"height": 1.5,
|
"height": 1.6,
|
||||||
"leftWidth": 2.5,
|
"leftWidth": 4.7,
|
||||||
"rightWidth": 2.5
|
"rightWidth": 2.7
|
||||||
},
|
},
|
||||||
"zone_iozx54yy1hmmoads": {
|
"zone_iozx54yy1hmmoads": {
|
||||||
"object": "node",
|
"object": "node",
|
||||||
@@ -147,7 +185,20 @@
|
|||||||
6
|
6
|
||||||
]
|
]
|
||||||
],
|
],
|
||||||
"color": "#3b82f6"
|
"color": "#3b82f6",
|
||||||
|
"camera": {
|
||||||
|
"position": [
|
||||||
|
18.715003971902778,
|
||||||
|
18.8254836683251,
|
||||||
|
12.086656976691291
|
||||||
|
],
|
||||||
|
"target": [
|
||||||
|
6.9995635873796855,
|
||||||
|
1.6971845387795204e-17,
|
||||||
|
0.6911898255966076
|
||||||
|
],
|
||||||
|
"mode": "perspective"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"item_137wje66gax2c6bc": {
|
"item_137wje66gax2c6bc": {
|
||||||
"object": "node",
|
"object": "node",
|
||||||
@@ -757,7 +808,7 @@
|
|||||||
"visible": true,
|
"visible": true,
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"position": [
|
"position": [
|
||||||
3.5,
|
4.5,
|
||||||
0,
|
0,
|
||||||
0
|
0
|
||||||
],
|
],
|
||||||
@@ -1185,9 +1236,9 @@
|
|||||||
"metadata": {},
|
"metadata": {},
|
||||||
"children": [
|
"children": [
|
||||||
"item_s9fs055u0ilri7pi",
|
"item_s9fs055u0ilri7pi",
|
||||||
"item_4dehdkm4vx6r4ghv",
|
|
||||||
"item_0173tdywhm704hah",
|
"item_0173tdywhm704hah",
|
||||||
"item_7ykyzy2yre3761dq"
|
"item_7ykyzy2yre3761dq",
|
||||||
|
"item_4dehdkm4vx6r4ghv"
|
||||||
],
|
],
|
||||||
"start": [
|
"start": [
|
||||||
1,
|
1,
|
||||||
@@ -1255,7 +1306,7 @@
|
|||||||
"visible": true,
|
"visible": true,
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"position": [
|
"position": [
|
||||||
9,
|
8.5,
|
||||||
0.5,
|
0.5,
|
||||||
0
|
0
|
||||||
],
|
],
|
||||||
@@ -1303,7 +1354,7 @@
|
|||||||
"visible": true,
|
"visible": true,
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"position": [
|
"position": [
|
||||||
6,
|
6.5,
|
||||||
0,
|
0,
|
||||||
0
|
0
|
||||||
],
|
],
|
||||||
@@ -2053,7 +2104,20 @@
|
|||||||
15.5
|
15.5
|
||||||
]
|
]
|
||||||
],
|
],
|
||||||
"color": "#22c55e"
|
"color": "#22c55e",
|
||||||
|
"camera": {
|
||||||
|
"position": [
|
||||||
|
-6.654332076100337,
|
||||||
|
17.996846152830106,
|
||||||
|
22.501743052051737
|
||||||
|
],
|
||||||
|
"target": [
|
||||||
|
5.317880378107912,
|
||||||
|
-2.779128022959309e-17,
|
||||||
|
10.080522989431769
|
||||||
|
],
|
||||||
|
"mode": "perspective"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"item_0e9paq67kdbm5ux0": {
|
"item_0e9paq67kdbm5ux0": {
|
||||||
"object": "node",
|
"object": "node",
|
||||||
@@ -2193,18 +2257,497 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"guide_vh96481lk2oqzf4d": {
|
"item_oay55zmjjo76s1fs": {
|
||||||
"object": "node",
|
"object": "node",
|
||||||
"id": "guide_vh96481lk2oqzf4d",
|
"id": "item_oay55zmjjo76s1fs",
|
||||||
"type": "guide",
|
"type": "item",
|
||||||
"name": "elestio-postgis-thumbnail.png",
|
"name": "High Fence",
|
||||||
"parentId": "level_pojp0mw3qssu110w",
|
"parentId": "level_pojp0mw3qssu110w",
|
||||||
"visible": true,
|
"visible": true,
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"url": "asset://129b9095-60c3-4239-92b6-3cef391721fb",
|
"position": [
|
||||||
|
23,
|
||||||
|
0,
|
||||||
|
4.25
|
||||||
|
],
|
||||||
|
"rotation": [
|
||||||
|
0,
|
||||||
|
1.5707963267948966,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"asset": {
|
||||||
|
"id": "high-fence",
|
||||||
|
"category": "outdoor",
|
||||||
|
"name": "High Fence",
|
||||||
|
"thumbnail": "/items/high-fence/thumbnail.webp",
|
||||||
|
"src": "/items/high-fence/model.glb",
|
||||||
|
"dimensions": [
|
||||||
|
4,
|
||||||
|
4.1,
|
||||||
|
0.5
|
||||||
|
],
|
||||||
|
"offset": [
|
||||||
|
0,
|
||||||
|
0.01,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"rotation": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"scale": [
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"item_g060bhthvcra992w": {
|
||||||
|
"object": "node",
|
||||||
|
"id": "item_g060bhthvcra992w",
|
||||||
|
"type": "item",
|
||||||
|
"name": "Medium Fence",
|
||||||
|
"parentId": "level_pojp0mw3qssu110w",
|
||||||
|
"visible": true,
|
||||||
|
"metadata": {},
|
||||||
|
"position": [
|
||||||
|
23,
|
||||||
|
0,
|
||||||
|
-1.25
|
||||||
|
],
|
||||||
|
"rotation": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"asset": {
|
||||||
|
"id": "medium-fence",
|
||||||
|
"category": "outdoor",
|
||||||
|
"name": "Medium Fence",
|
||||||
|
"thumbnail": "/items/medium-fence/thumbnail.webp",
|
||||||
|
"src": "/items/medium-fence/model.glb",
|
||||||
|
"dimensions": [
|
||||||
|
2,
|
||||||
|
2,
|
||||||
|
0.5
|
||||||
|
],
|
||||||
|
"offset": [
|
||||||
|
0,
|
||||||
|
0.01,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"rotation": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"scale": [
|
||||||
|
0.49,
|
||||||
|
0.49,
|
||||||
|
0.49
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"item_38kt7s45alt2vrjg": {
|
||||||
|
"object": "node",
|
||||||
|
"id": "item_38kt7s45alt2vrjg",
|
||||||
|
"type": "item",
|
||||||
|
"name": "Medium Fence",
|
||||||
|
"parentId": "level_pojp0mw3qssu110w",
|
||||||
|
"visible": true,
|
||||||
|
"metadata": {},
|
||||||
|
"position": [
|
||||||
|
22,
|
||||||
|
0,
|
||||||
|
-2.25
|
||||||
|
],
|
||||||
|
"rotation": [
|
||||||
|
0,
|
||||||
|
1.5707963267948966,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"asset": {
|
||||||
|
"id": "medium-fence",
|
||||||
|
"category": "outdoor",
|
||||||
|
"name": "Medium Fence",
|
||||||
|
"thumbnail": "/items/medium-fence/thumbnail.webp",
|
||||||
|
"src": "/items/medium-fence/model.glb",
|
||||||
|
"dimensions": [
|
||||||
|
2,
|
||||||
|
2,
|
||||||
|
0.5
|
||||||
|
],
|
||||||
|
"offset": [
|
||||||
|
0,
|
||||||
|
0.01,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"rotation": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"scale": [
|
||||||
|
0.49,
|
||||||
|
0.49,
|
||||||
|
0.49
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"item_n10cp9ke7n9hxl96": {
|
||||||
|
"object": "node",
|
||||||
|
"id": "item_n10cp9ke7n9hxl96",
|
||||||
|
"type": "item",
|
||||||
|
"name": "Low Fence",
|
||||||
|
"parentId": "level_pojp0mw3qssu110w",
|
||||||
|
"visible": true,
|
||||||
|
"metadata": {},
|
||||||
|
"position": [
|
||||||
|
23,
|
||||||
|
0,
|
||||||
|
-4.75
|
||||||
|
],
|
||||||
|
"rotation": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"asset": {
|
||||||
|
"id": "low-fence",
|
||||||
|
"category": "outdoor",
|
||||||
|
"name": "Low Fence",
|
||||||
|
"thumbnail": "/items/low-fence/thumbnail.webp",
|
||||||
|
"src": "/items/low-fence/model.glb",
|
||||||
|
"dimensions": [
|
||||||
|
2,
|
||||||
|
0.8,
|
||||||
|
0.5
|
||||||
|
],
|
||||||
|
"offset": [
|
||||||
|
0,
|
||||||
|
0.01,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"rotation": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"scale": [
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"item_nt5fxip4a03cmaoi": {
|
||||||
|
"object": "node",
|
||||||
|
"id": "item_nt5fxip4a03cmaoi",
|
||||||
|
"type": "item",
|
||||||
|
"name": "Low Fence",
|
||||||
|
"parentId": "level_pojp0mw3qssu110w",
|
||||||
|
"visible": true,
|
||||||
|
"metadata": {},
|
||||||
|
"position": [
|
||||||
|
22,
|
||||||
|
0,
|
||||||
|
-5.75
|
||||||
|
],
|
||||||
|
"rotation": [
|
||||||
|
0,
|
||||||
|
1.5707963267948966,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"asset": {
|
||||||
|
"id": "low-fence",
|
||||||
|
"category": "outdoor",
|
||||||
|
"name": "Low Fence",
|
||||||
|
"thumbnail": "/items/low-fence/thumbnail.webp",
|
||||||
|
"src": "/items/low-fence/model.glb",
|
||||||
|
"dimensions": [
|
||||||
|
2,
|
||||||
|
0.8,
|
||||||
|
0.5
|
||||||
|
],
|
||||||
|
"offset": [
|
||||||
|
0,
|
||||||
|
0.01,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"rotation": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"scale": [
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"item_1quoil3ytsuuarni": {
|
||||||
|
"object": "node",
|
||||||
|
"id": "item_1quoil3ytsuuarni",
|
||||||
|
"type": "item",
|
||||||
|
"name": "High Fence",
|
||||||
|
"parentId": "level_pojp0mw3qssu110w",
|
||||||
|
"visible": true,
|
||||||
|
"metadata": {},
|
||||||
|
"position": [
|
||||||
|
15,
|
||||||
|
0,
|
||||||
|
4.25
|
||||||
|
],
|
||||||
|
"rotation": [
|
||||||
|
0,
|
||||||
|
1.5707963267948966,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"asset": {
|
||||||
|
"id": "high-fence",
|
||||||
|
"category": "outdoor",
|
||||||
|
"name": "High Fence",
|
||||||
|
"thumbnail": "/items/high-fence/thumbnail.webp",
|
||||||
|
"src": "/items/high-fence/model.glb",
|
||||||
|
"dimensions": [
|
||||||
|
4,
|
||||||
|
4.1,
|
||||||
|
0.5
|
||||||
|
],
|
||||||
|
"offset": [
|
||||||
|
0,
|
||||||
|
0.01,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"rotation": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"scale": [
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"item_e1w89kkg2pql1v45": {
|
||||||
|
"object": "node",
|
||||||
|
"id": "item_e1w89kkg2pql1v45",
|
||||||
|
"type": "item",
|
||||||
|
"name": "Medium Fence",
|
||||||
|
"parentId": "level_pojp0mw3qssu110w",
|
||||||
|
"visible": true,
|
||||||
|
"metadata": {},
|
||||||
|
"position": [
|
||||||
|
17,
|
||||||
|
0,
|
||||||
|
0.25
|
||||||
|
],
|
||||||
|
"rotation": [
|
||||||
|
0,
|
||||||
|
1.5707963267948966,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"asset": {
|
||||||
|
"id": "medium-fence",
|
||||||
|
"category": "outdoor",
|
||||||
|
"name": "Medium Fence",
|
||||||
|
"thumbnail": "/items/medium-fence/thumbnail.webp",
|
||||||
|
"src": "/items/medium-fence/model.glb",
|
||||||
|
"dimensions": [
|
||||||
|
2,
|
||||||
|
2,
|
||||||
|
0.5
|
||||||
|
],
|
||||||
|
"offset": [
|
||||||
|
0,
|
||||||
|
0.01,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"rotation": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"scale": [
|
||||||
|
0.49,
|
||||||
|
0.49,
|
||||||
|
0.49
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"item_3akotmiffzdr8ule": {
|
||||||
|
"object": "node",
|
||||||
|
"id": "item_3akotmiffzdr8ule",
|
||||||
|
"type": "item",
|
||||||
|
"name": "Medium Fence",
|
||||||
|
"parentId": "level_pojp0mw3qssu110w",
|
||||||
|
"visible": true,
|
||||||
|
"metadata": {},
|
||||||
|
"position": [
|
||||||
|
17,
|
||||||
|
0,
|
||||||
|
-1.75
|
||||||
|
],
|
||||||
|
"rotation": [
|
||||||
|
0,
|
||||||
|
1.5707963267948966,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"asset": {
|
||||||
|
"id": "medium-fence",
|
||||||
|
"category": "outdoor",
|
||||||
|
"name": "Medium Fence",
|
||||||
|
"thumbnail": "/items/medium-fence/thumbnail.webp",
|
||||||
|
"src": "/items/medium-fence/model.glb",
|
||||||
|
"dimensions": [
|
||||||
|
2,
|
||||||
|
2,
|
||||||
|
0.5
|
||||||
|
],
|
||||||
|
"offset": [
|
||||||
|
0,
|
||||||
|
0.01,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"rotation": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"scale": [
|
||||||
|
0.49,
|
||||||
|
0.49,
|
||||||
|
0.49
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"item_1b3tinfswueb6gr8": {
|
||||||
|
"object": "node",
|
||||||
|
"id": "item_1b3tinfswueb6gr8",
|
||||||
|
"type": "item",
|
||||||
|
"name": "Medium Fence",
|
||||||
|
"parentId": "level_pojp0mw3qssu110w",
|
||||||
|
"visible": true,
|
||||||
|
"metadata": {},
|
||||||
|
"position": [
|
||||||
|
17,
|
||||||
|
0,
|
||||||
|
-4.75
|
||||||
|
],
|
||||||
|
"rotation": [
|
||||||
|
0,
|
||||||
|
4.71238898038469,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"asset": {
|
||||||
|
"id": "medium-fence",
|
||||||
|
"category": "outdoor",
|
||||||
|
"name": "Medium Fence",
|
||||||
|
"thumbnail": "/items/medium-fence/thumbnail.webp",
|
||||||
|
"src": "/items/medium-fence/model.glb",
|
||||||
|
"dimensions": [
|
||||||
|
2,
|
||||||
|
2,
|
||||||
|
0.5
|
||||||
|
],
|
||||||
|
"offset": [
|
||||||
|
0,
|
||||||
|
0.01,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"rotation": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"scale": [
|
||||||
|
0.49,
|
||||||
|
0.49,
|
||||||
|
0.49
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"item_y164oe3lxfx9qefg": {
|
||||||
|
"object": "node",
|
||||||
|
"id": "item_y164oe3lxfx9qefg",
|
||||||
|
"type": "item",
|
||||||
|
"name": "Medium Fence",
|
||||||
|
"parentId": "level_pojp0mw3qssu110w",
|
||||||
|
"visible": true,
|
||||||
|
"metadata": {},
|
||||||
|
"position": [
|
||||||
|
17,
|
||||||
|
0,
|
||||||
|
-6.75
|
||||||
|
],
|
||||||
|
"rotation": [
|
||||||
|
0,
|
||||||
|
4.71238898038469,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"asset": {
|
||||||
|
"id": "medium-fence",
|
||||||
|
"category": "outdoor",
|
||||||
|
"name": "Medium Fence",
|
||||||
|
"thumbnail": "/items/medium-fence/thumbnail.webp",
|
||||||
|
"src": "/items/medium-fence/model.glb",
|
||||||
|
"dimensions": [
|
||||||
|
2,
|
||||||
|
2,
|
||||||
|
0.5
|
||||||
|
],
|
||||||
|
"offset": [
|
||||||
|
0,
|
||||||
|
0.01,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"rotation": [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0
|
||||||
|
],
|
||||||
|
"scale": [
|
||||||
|
0.49,
|
||||||
|
0.49,
|
||||||
|
0.49
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"roof_ui8zhim41alg6lq4": {
|
||||||
|
"object": "node",
|
||||||
|
"id": "roof_ui8zhim41alg6lq4",
|
||||||
|
"type": "roof",
|
||||||
|
"name": "Roof 2",
|
||||||
|
"parentId": "level_pojp0mw3qssu110w",
|
||||||
|
"visible": true,
|
||||||
|
"metadata": {},
|
||||||
|
"position": [
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
-5.5
|
||||||
|
],
|
||||||
|
"rotation": 0,
|
||||||
|
"length": 0.5,
|
||||||
|
"height": 1.5,
|
||||||
|
"leftWidth": 12.1,
|
||||||
|
"rightWidth": 1
|
||||||
|
},
|
||||||
|
"guide_acs9nzz19rm4vl2c": {
|
||||||
|
"object": "node",
|
||||||
|
"id": "guide_acs9nzz19rm4vl2c",
|
||||||
|
"type": "guide",
|
||||||
|
"name": "FaceIt0703_FaceItProductPage.png",
|
||||||
|
"parentId": "level_pojp0mw3qssu110w",
|
||||||
|
"visible": true,
|
||||||
|
"metadata": {},
|
||||||
|
"url": "asset://1e66ba17-99d2-4c5c-ad2b-00dff438b6a7",
|
||||||
"position": [
|
"position": [
|
||||||
0,
|
0,
|
||||||
1.2,
|
0,
|
||||||
0
|
0
|
||||||
],
|
],
|
||||||
"rotation": [
|
"rotation": [
|
||||||
@@ -2213,7 +2756,7 @@
|
|||||||
0
|
0
|
||||||
],
|
],
|
||||||
"scale": 1,
|
"scale": 1,
|
||||||
"opacity": 100
|
"opacity": 51
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"rootNodeIds": [
|
"rootNodeIds": [
|
||||||
|
|||||||
@@ -137,20 +137,74 @@ export function itemOverlapsPolygon(
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if wall segment (a) is substantially on polygon edge segment (b).
|
||||||
|
* Returns true only if BOTH endpoints of the wall are on or very close to the edge.
|
||||||
|
* This prevents walls that just touch one point from being detected.
|
||||||
|
*/
|
||||||
|
function segmentsCollinearAndOverlap(
|
||||||
|
ax1: number, az1: number, ax2: number, az2: number,
|
||||||
|
bx1: number, bz1: number, bx2: number, bz2: number,
|
||||||
|
): boolean {
|
||||||
|
const EPSILON = 1e-6
|
||||||
|
|
||||||
|
// Cross product to check collinearity
|
||||||
|
const cross1 = (ax2 - ax1) * (bz1 - az1) - (az2 - az1) * (bx1 - ax1)
|
||||||
|
const cross2 = (ax2 - ax1) * (bz2 - az1) - (az2 - az1) * (bx2 - ax1)
|
||||||
|
|
||||||
|
if (Math.abs(cross1) > EPSILON || Math.abs(cross2) > EPSILON) {
|
||||||
|
return false // Not collinear
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if a point is on segment b
|
||||||
|
const onSegment = (px: number, pz: number, qx: number, qz: number, rx: number, rz: number) =>
|
||||||
|
Math.min(px, qx) - EPSILON <= rx && rx <= Math.max(px, qx) + EPSILON &&
|
||||||
|
Math.min(pz, qz) - EPSILON <= rz && rz <= Math.max(pz, qz) + EPSILON
|
||||||
|
|
||||||
|
// BOTH endpoints of wall (a) must be on edge (b) for substantial overlap
|
||||||
|
const a1OnB = onSegment(bx1, bz1, bx2, bz2, ax1, az1)
|
||||||
|
const a2OnB = onSegment(bx1, bz1, bx2, bz2, ax2, az2)
|
||||||
|
|
||||||
|
return a1OnB && a2OnB
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Test if a wall segment overlaps with a polygon.
|
* Test if a wall segment overlaps with a polygon.
|
||||||
|
* A wall is considered to overlap if:
|
||||||
|
* - Its midpoint is inside the polygon (wall crosses through)
|
||||||
|
* - At least one endpoint is inside (wall partially or fully in slab)
|
||||||
|
* - It's collinear with and overlaps a polygon edge (wall on slab boundary)
|
||||||
|
*
|
||||||
|
* Note: A wall with just one endpoint touching the edge but the rest outside
|
||||||
|
* is NOT considered overlapping (adjacent only).
|
||||||
*/
|
*/
|
||||||
export function wallOverlapsPolygon(
|
export function wallOverlapsPolygon(
|
||||||
start: [number, number],
|
start: [number, number],
|
||||||
end: [number, number],
|
end: [number, number],
|
||||||
polygon: Array<[number, number]>,
|
polygon: Array<[number, number]>,
|
||||||
): boolean {
|
): boolean {
|
||||||
// Either endpoint inside the polygon
|
const startInside = pointInPolygon(start[0], start[1], polygon)
|
||||||
if (pointInPolygon(start[0], start[1], polygon)) return true
|
const endInside = pointInPolygon(end[0], end[1], polygon)
|
||||||
if (pointInPolygon(end[0], end[1], polygon)) return true
|
|
||||||
|
|
||||||
// Wall segment intersects any polygon edge
|
// At least one endpoint strictly inside the polygon
|
||||||
if (segmentIntersectsPolygon(start[0], start[1], end[0], end[1], polygon)) return true
|
if (startInside || endInside) return true
|
||||||
|
|
||||||
|
// Check if midpoint is inside (catches walls crossing through)
|
||||||
|
const midX = (start[0] + end[0]) / 2
|
||||||
|
const midZ = (start[1] + end[1]) / 2
|
||||||
|
if (pointInPolygon(midX, midZ, polygon)) return true
|
||||||
|
|
||||||
|
// Check if the wall is collinear with and overlaps any polygon edge
|
||||||
|
const n = polygon.length
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const j = (i + 1) % n
|
||||||
|
const [p1x, p1z] = polygon[i]!
|
||||||
|
const [p2x, p2z] = polygon[j]!
|
||||||
|
|
||||||
|
if (segmentsCollinearAndOverlap(start[0], start[1], end[0], end[1], p1x, p1z, p2x, p2z)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -435,7 +489,7 @@ export class SpatialGridManager {
|
|||||||
const slabMap = this.slabsByLevel.get(levelId)
|
const slabMap = this.slabsByLevel.get(levelId)
|
||||||
if (!slabMap) return 0
|
if (!slabMap) return 0
|
||||||
|
|
||||||
let maxElevation = 0
|
let maxElevation = -Infinity
|
||||||
for (const slab of slabMap.values()) {
|
for (const slab of slabMap.values()) {
|
||||||
if (slab.polygon.length >= 3 && itemOverlapsPolygon(position, dimensions, rotation, slab.polygon, 0.01)) {
|
if (slab.polygon.length >= 3 && itemOverlapsPolygon(position, dimensions, rotation, slab.polygon, 0.01)) {
|
||||||
const elevation = slab.elevation ?? 0.05
|
const elevation = slab.elevation ?? 0.05
|
||||||
@@ -444,7 +498,7 @@ export class SpatialGridManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return maxElevation
|
return maxElevation === -Infinity ? 0 : maxElevation
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -460,7 +514,7 @@ export class SpatialGridManager {
|
|||||||
const slabMap = this.slabsByLevel.get(levelId)
|
const slabMap = this.slabsByLevel.get(levelId)
|
||||||
if (!slabMap) return 0
|
if (!slabMap) return 0
|
||||||
|
|
||||||
let maxElevation = 0
|
let maxElevation = -Infinity
|
||||||
for (const slab of slabMap.values()) {
|
for (const slab of slabMap.values()) {
|
||||||
if (slab.polygon.length < 3) continue
|
if (slab.polygon.length < 3) continue
|
||||||
if (wallOverlapsPolygon(start, end, slab.polygon)) {
|
if (wallOverlapsPolygon(start, end, slab.polygon)) {
|
||||||
@@ -470,7 +524,7 @@ export class SpatialGridManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return maxElevation
|
return maxElevation === -Infinity ? 0 : maxElevation
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -97,9 +97,15 @@ export const updateNodesAction = (
|
|||||||
return { nodes: nextNodes }
|
return { nodes: nextNodes }
|
||||||
})
|
})
|
||||||
|
|
||||||
// Mark dirty
|
// Mark dirty after the next frame to ensure React renders complete
|
||||||
updates.forEach((u) => get().markDirty(u.id))
|
requestAnimationFrame(() => {
|
||||||
parentsToUpdate.forEach((pId) => get().markDirty(pId))
|
updates.forEach((u) => {
|
||||||
|
get().markDirty(u.id)
|
||||||
|
})
|
||||||
|
parentsToUpdate.forEach((pId) => {
|
||||||
|
get().markDirty(pId)
|
||||||
|
})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const deleteNodesAction = (
|
export const deleteNodesAction = (
|
||||||
|
|||||||
@@ -170,14 +170,35 @@ const useScene: UseSceneStore = create<SceneState>()(
|
|||||||
|
|
||||||
export default useScene
|
export default useScene
|
||||||
|
|
||||||
|
// Track previous temporal state lengths
|
||||||
|
let prevPastLength = 0
|
||||||
|
let prevFutureLength = 0
|
||||||
|
|
||||||
// Subscribe to the temporal store (Undo/Redo events)
|
// Subscribe to the temporal store (Undo/Redo events)
|
||||||
useScene.temporal.subscribe((state, prevState) => {
|
useScene.temporal.subscribe((state) => {
|
||||||
// Check if we just jumped in time (Undo/Redo)
|
const currentPastLength = state.pastStates.length
|
||||||
// If the 'nodes' object changed but it wasn't a normal 'set'
|
const currentFutureLength = state.futureStates.length
|
||||||
|
|
||||||
|
|
||||||
|
// Undo: futureStates increases (state moved from past to future)
|
||||||
|
// Redo: pastStates increases while futureStates decreases (state moved from future to past)
|
||||||
|
const didUndo = currentFutureLength > prevFutureLength
|
||||||
|
const didRedo = currentPastLength > prevPastLength && currentFutureLength < prevFutureLength
|
||||||
|
|
||||||
|
|
||||||
|
if (didUndo || didRedo) {
|
||||||
|
// Use RAF to ensure all middleware and store updates are complete
|
||||||
|
requestAnimationFrame(() => {
|
||||||
const currentNodes = useScene.getState().nodes
|
const currentNodes = useScene.getState().nodes
|
||||||
|
|
||||||
// Trigger a full scene re-validation
|
// Trigger a full scene re-validation after undo/redo
|
||||||
Object.values(currentNodes).forEach((node) => {
|
Object.values(currentNodes).forEach((node) => {
|
||||||
useScene.getState().markDirty(node.id)
|
useScene.getState().markDirty(node.id)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update tracked lengths
|
||||||
|
prevPastLength = currentPastLength
|
||||||
|
prevFutureLength = currentFutureLength
|
||||||
|
})
|
||||||
|
|||||||
@@ -9,11 +9,13 @@ import useScene from '../../store/use-scene'
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export const CeilingSystem = () => {
|
export const CeilingSystem = () => {
|
||||||
const { nodes, dirtyNodes, clearDirty } = useScene()
|
const dirtyNodes = useScene((state) => state.dirtyNodes)
|
||||||
|
const clearDirty = useScene((state) => state.clearDirty)
|
||||||
|
|
||||||
useFrame(() => {
|
useFrame(() => {
|
||||||
if (dirtyNodes.size === 0) return
|
if (dirtyNodes.size === 0) return
|
||||||
|
|
||||||
|
const nodes = useScene.getState().nodes
|
||||||
// Process dirty ceilings
|
// Process dirty ceilings
|
||||||
dirtyNodes.forEach((id) => {
|
dirtyNodes.forEach((id) => {
|
||||||
const node = nodes[id]
|
const node = nodes[id]
|
||||||
|
|||||||
@@ -11,10 +11,12 @@ import useScene from '../../store/use-scene'
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export const ItemSystem = () => {
|
export const ItemSystem = () => {
|
||||||
const { nodes, dirtyNodes, clearDirty } = useScene()
|
const dirtyNodes = useScene((state) => state.dirtyNodes)
|
||||||
|
const clearDirty = useScene((state) => state.clearDirty)
|
||||||
|
|
||||||
useFrame(() => {
|
useFrame(() => {
|
||||||
if (dirtyNodes.size === 0) return
|
if (dirtyNodes.size === 0) return
|
||||||
|
const nodes = useScene.getState().nodes
|
||||||
|
|
||||||
dirtyNodes.forEach((id) => {
|
dirtyNodes.forEach((id) => {
|
||||||
const node = nodes[id]
|
const node = nodes[id]
|
||||||
|
|||||||
@@ -4,16 +4,31 @@ import { sceneRegistry } from '../../hooks/scene-registry/scene-registry'
|
|||||||
import type { AnyNodeId, RoofNode } from '../../schema'
|
import type { AnyNodeId, RoofNode } from '../../schema'
|
||||||
import useScene from '../../store/use-scene'
|
import useScene from '../../store/use-scene'
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// ROOF GEOMETRY CONSTANTS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const THICKNESS_A = 0.05 // Roof cover thickness (5cm)
|
||||||
|
const THICKNESS_B = 0.1 // Structure thickness (10cm)
|
||||||
|
const ROOF_COVER_OVERHANG = 0.05 // Extension of cover past structure (5cm)
|
||||||
|
const EAVE_OVERHANG = 0.4 // Horizontal eave overhang (40cm)
|
||||||
|
const RAKE_OVERHANG = 0.3 // Overhang at gable ends (30cm)
|
||||||
|
const WALL_THICKNESS = 0.2 // Gable wall thickness (20cm)
|
||||||
|
const BASE_HEIGHT = 0.5 // Base height / knee wall / truss heel (50cm)
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// ROOF SYSTEM
|
// ROOF SYSTEM
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export const RoofSystem = () => {
|
export const RoofSystem = () => {
|
||||||
const { nodes, dirtyNodes, clearDirty } = useScene()
|
const dirtyNodes = useScene((state) => state.dirtyNodes)
|
||||||
|
const clearDirty = useScene((state) => state.clearDirty)
|
||||||
|
|
||||||
useFrame(() => {
|
useFrame(() => {
|
||||||
if (dirtyNodes.size === 0) return
|
if (dirtyNodes.size === 0) return
|
||||||
|
|
||||||
|
const nodes = useScene.getState().nodes
|
||||||
|
|
||||||
// Process dirty roofs
|
// Process dirty roofs
|
||||||
dirtyNodes.forEach((id) => {
|
dirtyNodes.forEach((id) => {
|
||||||
const node = nodes[id]
|
const node = nodes[id]
|
||||||
@@ -46,81 +61,257 @@ function updateRoofGeometry(node: RoofNode, mesh: THREE.Mesh) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates gable roof geometry from length, height, leftWidth, rightWidth
|
* Helper to solve pitch angle analytically given rise, run and thicknesses
|
||||||
*
|
* Solves: run * tan(a) + (ThickA + ThickB)/cos(a) = rise
|
||||||
* The roof is centered at origin (position applied via mesh transform)
|
*/
|
||||||
* - Ridge runs along the X axis (length direction)
|
function solvePitch(rise: number, run: number, thickA: number, thickB: number): number {
|
||||||
* - Left slope goes down toward -Z with horizontal distance leftWidth
|
const T = thickA + thickB
|
||||||
* - Right slope goes down toward +Z with horizontal distance rightWidth
|
if (run < 0.01) return 0
|
||||||
* - Total width = leftWidth + rightWidth
|
|
||||||
* - Gable ends at -X/2 and +X/2
|
const R = Math.sqrt(run * run + rise * rise)
|
||||||
|
if (R <= T) {
|
||||||
|
return Math.atan2(rise, run) * 0.5 // Fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
const phi = Math.atan2(rise, run)
|
||||||
|
const shift = Math.asin(T / R)
|
||||||
|
|
||||||
|
return phi - shift
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to create a Three.js Shape from polygon points
|
||||||
|
*/
|
||||||
|
function createShape(points: { x: number; y: number }[]): THREE.Shape {
|
||||||
|
const shape = new THREE.Shape()
|
||||||
|
if (points.length === 0) return shape
|
||||||
|
const firstPoint = points[0]
|
||||||
|
if (!firstPoint) return shape
|
||||||
|
shape.moveTo(firstPoint.x, firstPoint.y)
|
||||||
|
for (let i = 1; i < points.length; i++) {
|
||||||
|
const point = points[i]
|
||||||
|
if (point) {
|
||||||
|
shape.lineTo(point.x, point.y)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
shape.closePath()
|
||||||
|
return shape
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate profile for one side of the roof (left or right)
|
||||||
|
*/
|
||||||
|
function getSideProfile(
|
||||||
|
dir: 1 | -1,
|
||||||
|
width: number,
|
||||||
|
roofHeight: number,
|
||||||
|
): {
|
||||||
|
pointsA: { x: number; y: number }[]
|
||||||
|
pointsB: { x: number; y: number }[]
|
||||||
|
pointsSide: { x: number; y: number }[]
|
||||||
|
pointsC1: { x: number; y: number }[]
|
||||||
|
pointsC2: { x: number; y: number }[]
|
||||||
|
} {
|
||||||
|
const halfWall = WALL_THICKNESS / 2
|
||||||
|
|
||||||
|
const rise = Math.max(0, roofHeight - BASE_HEIGHT)
|
||||||
|
const run = width - halfWall
|
||||||
|
|
||||||
|
const angle = solvePitch(rise, run, THICKNESS_A, THICKNESS_B)
|
||||||
|
const tanA = Math.tan(angle)
|
||||||
|
const cosA = Math.cos(angle)
|
||||||
|
const sinA = Math.sin(angle)
|
||||||
|
|
||||||
|
const ridgeUnderY = BASE_HEIGHT + run * tanA
|
||||||
|
const ridgeInterfaceY = ridgeUnderY + THICKNESS_B / cosA
|
||||||
|
const ridgeTopY = ridgeInterfaceY + THICKNESS_A / cosA
|
||||||
|
|
||||||
|
const wallOuterTopY = BASE_HEIGHT - WALL_THICKNESS * tanA
|
||||||
|
|
||||||
|
const overhangDx = EAVE_OVERHANG * cosA
|
||||||
|
|
||||||
|
const eaveTopZ = width + halfWall + overhangDx
|
||||||
|
const eaveTopY = ridgeTopY - eaveTopZ * tanA
|
||||||
|
|
||||||
|
const coverExtDx = ROOF_COVER_OVERHANG * cosA
|
||||||
|
const coverExtDy = ROOF_COVER_OVERHANG * sinA
|
||||||
|
|
||||||
|
const eaveTopExtZ = eaveTopZ + coverExtDx
|
||||||
|
const eaveTopExtY = eaveTopY - coverExtDy
|
||||||
|
|
||||||
|
const eaveInterfaceExtZ = eaveTopExtZ - THICKNESS_A * sinA
|
||||||
|
const eaveInterfaceExtY = eaveTopExtY - THICKNESS_A * cosA
|
||||||
|
|
||||||
|
const eaveInterfaceZ = eaveTopZ
|
||||||
|
|
||||||
|
const eaveBottomZ = eaveTopZ
|
||||||
|
const eaveBottomY = ridgeUnderY - eaveTopZ * tanA
|
||||||
|
|
||||||
|
// Layer A (Cover)
|
||||||
|
const pointsA = [
|
||||||
|
{ x: 0, y: ridgeTopY },
|
||||||
|
{ x: dir * eaveTopExtZ, y: eaveTopExtY },
|
||||||
|
{ x: dir * eaveInterfaceExtZ, y: eaveInterfaceExtY },
|
||||||
|
{ x: 0, y: ridgeInterfaceY },
|
||||||
|
]
|
||||||
|
|
||||||
|
// Layer B (Structure)
|
||||||
|
const pointsB = [
|
||||||
|
{ x: 0, y: ridgeInterfaceY },
|
||||||
|
{ x: dir * eaveInterfaceZ, y: ridgeInterfaceY - eaveTopZ * tanA },
|
||||||
|
{ x: dir * eaveBottomZ, y: eaveBottomY },
|
||||||
|
{ x: 0, y: ridgeUnderY },
|
||||||
|
]
|
||||||
|
|
||||||
|
// Side Wall
|
||||||
|
const zInner = width - halfWall
|
||||||
|
const zOuter = width + halfWall
|
||||||
|
|
||||||
|
const pointsSide = [
|
||||||
|
{ x: dir * zInner, y: 0 },
|
||||||
|
{ x: dir * zOuter, y: 0 },
|
||||||
|
{ x: dir * zOuter, y: Math.max(0, wallOuterTopY) },
|
||||||
|
{ x: dir * zInner, y: BASE_HEIGHT },
|
||||||
|
]
|
||||||
|
|
||||||
|
// Gable Top (C1)
|
||||||
|
const pointsC1 = [
|
||||||
|
{ x: 0, y: BASE_HEIGHT },
|
||||||
|
{ x: dir * zInner, y: BASE_HEIGHT },
|
||||||
|
{ x: dir * zInner, y: BASE_HEIGHT },
|
||||||
|
{ x: 0, y: ridgeUnderY },
|
||||||
|
]
|
||||||
|
|
||||||
|
// Gable Base (C2)
|
||||||
|
const pointsC2 = [
|
||||||
|
{ x: 0, y: 0 },
|
||||||
|
{ x: dir * zInner, y: 0 },
|
||||||
|
{ x: dir * zInner, y: BASE_HEIGHT },
|
||||||
|
{ x: 0, y: BASE_HEIGHT },
|
||||||
|
]
|
||||||
|
|
||||||
|
return { pointsA, pointsB, pointsSide, pointsC1, pointsC2 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates detailed gable roof geometry with layers, walls, and overhangs
|
||||||
*/
|
*/
|
||||||
export function generateRoofGeometry(roofNode: RoofNode): THREE.BufferGeometry {
|
export function generateRoofGeometry(roofNode: RoofNode): THREE.BufferGeometry {
|
||||||
const { length, height, leftWidth, rightWidth } = roofNode
|
const { length, height, leftWidth, rightWidth } = roofNode
|
||||||
|
|
||||||
// Half length for centering
|
const ridgeLength = length
|
||||||
const halfLength = length / 2
|
|
||||||
|
|
||||||
// Ridge is at Y = height, centered at Z = 0
|
// Get profiles for both sides
|
||||||
// Left eave is at Z = -leftWidth, Y = 0
|
const leftP = getSideProfile(1, leftWidth, height)
|
||||||
// Right eave is at Z = +rightWidth, Y = 0
|
const rightP = getSideProfile(-1, rightWidth, height)
|
||||||
|
|
||||||
|
// Create shapes from profiles
|
||||||
|
const shapes = {
|
||||||
|
ALeft: createShape(leftP.pointsA),
|
||||||
|
ARight: createShape(rightP.pointsA),
|
||||||
|
BLeft: createShape(leftP.pointsB),
|
||||||
|
BRight: createShape(rightP.pointsB),
|
||||||
|
SideLeft: createShape(leftP.pointsSide),
|
||||||
|
SideRight: createShape(rightP.pointsSide),
|
||||||
|
C1Left: createShape(leftP.pointsC1),
|
||||||
|
C1Right: createShape(rightP.pointsC1),
|
||||||
|
C2Left: createShape(leftP.pointsC2),
|
||||||
|
C2Right: createShape(rightP.pointsC2),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate extrusion lengths and offsets
|
||||||
|
const lengths = {
|
||||||
|
A: ridgeLength + 2 * RAKE_OVERHANG + 2 * ROOF_COVER_OVERHANG + WALL_THICKNESS,
|
||||||
|
B: ridgeLength + 2 * RAKE_OVERHANG + WALL_THICKNESS,
|
||||||
|
Side: ridgeLength + WALL_THICKNESS,
|
||||||
|
Gable: WALL_THICKNESS,
|
||||||
|
}
|
||||||
|
|
||||||
|
const offsets = {
|
||||||
|
A: -RAKE_OVERHANG - ROOF_COVER_OVERHANG - WALL_THICKNESS / 2,
|
||||||
|
B: -RAKE_OVERHANG - WALL_THICKNESS / 2,
|
||||||
|
Side: -WALL_THICKNESS / 2,
|
||||||
|
GableFront: -WALL_THICKNESS / 2,
|
||||||
|
GableBack: ridgeLength - WALL_THICKNESS / 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to create and position extruded geometry
|
||||||
|
const createPart = (shape: THREE.Shape, depth: number, xOffset: number) => {
|
||||||
|
const geo = new THREE.ExtrudeGeometry(shape, { depth, bevelEnabled: false })
|
||||||
|
// Rotate to align: extrusion goes along X axis
|
||||||
|
geo.rotateY(Math.PI / 2)
|
||||||
|
geo.translate(xOffset, 0, 0)
|
||||||
|
return geo
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create all parts
|
||||||
|
const geometries: THREE.BufferGeometry[] = []
|
||||||
|
|
||||||
|
// Layer A (Cover) - both sides
|
||||||
|
geometries.push(createPart(shapes.ALeft, lengths.A, offsets.A))
|
||||||
|
geometries.push(createPart(shapes.ARight, lengths.A, offsets.A))
|
||||||
|
|
||||||
|
// Layer B (Structure) - both sides
|
||||||
|
geometries.push(createPart(shapes.BLeft, lengths.B, offsets.B))
|
||||||
|
geometries.push(createPart(shapes.BRight, lengths.B, offsets.B))
|
||||||
|
|
||||||
|
// Side Walls - both sides
|
||||||
|
geometries.push(createPart(shapes.SideLeft, lengths.Side, offsets.Side))
|
||||||
|
geometries.push(createPart(shapes.SideRight, lengths.Side, offsets.Side))
|
||||||
|
|
||||||
|
// Gable Walls (Front)
|
||||||
|
geometries.push(createPart(shapes.C1Left, lengths.Gable, offsets.GableFront))
|
||||||
|
geometries.push(createPart(shapes.C1Right, lengths.Gable, offsets.GableFront))
|
||||||
|
geometries.push(createPart(shapes.C2Left, lengths.Gable, offsets.GableFront))
|
||||||
|
geometries.push(createPart(shapes.C2Right, lengths.Gable, offsets.GableFront))
|
||||||
|
|
||||||
|
// Gable Walls (Back)
|
||||||
|
geometries.push(createPart(shapes.C1Left, lengths.Gable, offsets.GableBack))
|
||||||
|
geometries.push(createPart(shapes.C1Right, lengths.Gable, offsets.GableBack))
|
||||||
|
geometries.push(createPart(shapes.C2Left, lengths.Gable, offsets.GableBack))
|
||||||
|
geometries.push(createPart(shapes.C2Right, lengths.Gable, offsets.GableBack))
|
||||||
|
|
||||||
|
// Merge all geometries
|
||||||
|
const mergedGeometry = new THREE.BufferGeometry()
|
||||||
const positions: number[] = []
|
const positions: number[] = []
|
||||||
const normals: number[] = []
|
const normals: number[] = []
|
||||||
const indices: number[] = []
|
const uvs: number[] = []
|
||||||
|
|
||||||
const addVertex = (x: number, y: number, z: number, nx: number, ny: number, nz: number) => {
|
for (const geo of geometries) {
|
||||||
const idx = positions.length / 3
|
const posAttr = geo.getAttribute('position')
|
||||||
positions.push(x, y, z)
|
const normAttr = geo.getAttribute('normal')
|
||||||
normals.push(nx, ny, nz)
|
const uvAttr = geo.getAttribute('uv')
|
||||||
return idx
|
|
||||||
|
if (posAttr) {
|
||||||
|
for (let i = 0; i < posAttr.count; i++) {
|
||||||
|
positions.push(posAttr.getX(i), posAttr.getY(i), posAttr.getZ(i))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (normAttr) {
|
||||||
|
for (let i = 0; i < normAttr.count; i++) {
|
||||||
|
normals.push(normAttr.getX(i), normAttr.getY(i), normAttr.getZ(i))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (uvAttr) {
|
||||||
|
for (let i = 0; i < uvAttr.count; i++) {
|
||||||
|
uvs.push(uvAttr.getX(i), uvAttr.getY(i))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate slope normals
|
geo.dispose()
|
||||||
// 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)
|
mergedGeometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3))
|
||||||
const rightSlopeLen = Math.sqrt(height * height + rightWidth * rightWidth)
|
mergedGeometry.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3))
|
||||||
const rightNormalY = rightWidth / rightSlopeLen
|
if (uvs.length > 0) {
|
||||||
const rightNormalZ = height / rightSlopeLen
|
mergedGeometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2))
|
||||||
|
}
|
||||||
|
|
||||||
// Left slope (negative Z side) - CCW winding for outward-facing
|
mergedGeometry.computeVertexNormals()
|
||||||
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
|
// Center the geometry at X=0 (translate by -ridgeLength/2)
|
||||||
const rightNormal = [0, rightNormalY, rightNormalZ] as const
|
// This matches the old geometry centering behavior
|
||||||
const v4 = addVertex(halfLength, 0, rightWidth, ...rightNormal) // front-right eave
|
mergedGeometry.translate(-ridgeLength / 2, 0, 0)
|
||||||
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
|
return mergedGeometry
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,11 +9,14 @@ import useScene from '../../store/use-scene'
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export const SlabSystem = () => {
|
export const SlabSystem = () => {
|
||||||
const { nodes, dirtyNodes, clearDirty } = useScene()
|
const dirtyNodes = useScene((state) => state.dirtyNodes)
|
||||||
|
const clearDirty = useScene((state) => state.clearDirty)
|
||||||
|
|
||||||
useFrame(() => {
|
useFrame(() => {
|
||||||
if (dirtyNodes.size === 0) return
|
if (dirtyNodes.size === 0) return
|
||||||
|
|
||||||
|
const nodes = useScene.getState().nodes
|
||||||
|
|
||||||
// Process dirty slabs
|
// Process dirty slabs
|
||||||
dirtyNodes.forEach((id) => {
|
dirtyNodes.forEach((id) => {
|
||||||
const node = nodes[id]
|
const node = nodes[id]
|
||||||
|
|||||||
@@ -22,14 +22,18 @@ const csgEvaluator = new Evaluator()
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export const WallSystem = () => {
|
export const WallSystem = () => {
|
||||||
const { nodes, dirtyNodes, clearDirty } = useScene()
|
const dirtyNodes = useScene((state) => state.dirtyNodes)
|
||||||
|
const clearDirty = useScene((state) => state.clearDirty)
|
||||||
|
|
||||||
useFrame(() => {
|
useFrame(() => {
|
||||||
if (dirtyNodes.size === 0) return
|
if (dirtyNodes.size === 0) return
|
||||||
|
|
||||||
|
const nodes = useScene.getState().nodes
|
||||||
|
|
||||||
// Collect dirty walls and their levels
|
// Collect dirty walls and their levels
|
||||||
const dirtyWallsByLevel = new Map<string, Set<string>>()
|
const dirtyWallsByLevel = new Map<string, Set<string>>()
|
||||||
|
|
||||||
|
|
||||||
dirtyNodes.forEach((id) => {
|
dirtyNodes.forEach((id) => {
|
||||||
const node = nodes[id]
|
const node = nodes[id]
|
||||||
if (!node || node.type !== 'wall') return
|
if (!node || node.type !== 'wall') return
|
||||||
@@ -146,7 +150,9 @@ export function generateExtrudedWall(
|
|||||||
|
|
||||||
const wallStart: Point2D = { x: wallNode.start[0], y: wallNode.start[1] }
|
const wallStart: Point2D = { x: wallNode.start[0], y: wallNode.start[1] }
|
||||||
const wallEnd: Point2D = { x: wallNode.end[0], y: wallNode.end[1] }
|
const wallEnd: Point2D = { x: wallNode.end[0], y: wallNode.end[1] }
|
||||||
|
// Wall height is adjusted by slab elevation (positive reduces, negative increases)
|
||||||
const height = (wallNode.height ?? 2.5) - slabElevation
|
const height = (wallNode.height ?? 2.5) - slabElevation
|
||||||
|
|
||||||
const thickness = wallNode.thickness ?? 0.1
|
const thickness = wallNode.thickness ?? 0.1
|
||||||
const halfT = thickness / 2
|
const halfT = thickness / 2
|
||||||
|
|
||||||
@@ -239,7 +245,8 @@ export function generateExtrudedWall(
|
|||||||
|
|
||||||
// Rotate so extrusion direction (Z) becomes height direction (Y)
|
// Rotate so extrusion direction (Z) becomes height direction (Y)
|
||||||
geometry.rotateX(-Math.PI / 2)
|
geometry.rotateX(-Math.PI / 2)
|
||||||
if (slabElevation > 0) {
|
// Translate by slab elevation (works for both positive and negative values)
|
||||||
|
if (slabElevation !== 0) {
|
||||||
geometry.translate(0, slabElevation, 0)
|
geometry.translate(0, slabElevation, 0)
|
||||||
}
|
}
|
||||||
geometry.computeVertexNormals()
|
geometry.computeVertexNormals()
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export const ItemRenderer = ({ node }: { node: ItemNode }) => {
|
|||||||
useRegistry(node.id, node.type, ref)
|
useRegistry(node.id, node.type, ref)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<group position={node.position} rotation={node.rotation} ref={ref}>
|
<group position={node.position} rotation={node.rotation} ref={ref} visible={node.visible}>
|
||||||
<Suspense>
|
<Suspense>
|
||||||
<ModelRenderer node={node} />
|
<ModelRenderer node={node} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
@@ -68,15 +68,18 @@ const ModelRenderer = ({ node }: { node: ItemNode }) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
mesh.castShadow = true
|
let hasGlass = false;
|
||||||
mesh.receiveShadow = true
|
|
||||||
|
|
||||||
// Handle both single material and material array cases
|
// Handle both single material and material array cases
|
||||||
if (Array.isArray(mesh.material)) {
|
if (Array.isArray(mesh.material)) {
|
||||||
mesh.material = mesh.material.map((mat) => getMaterialForOriginal(mat))
|
mesh.material = mesh.material.map((mat) => getMaterialForOriginal(mat))
|
||||||
|
hasGlass = mesh.material.some(mat => mat.name === 'glass');
|
||||||
} else {
|
} else {
|
||||||
mesh.material = getMaterialForOriginal(mesh.material)
|
mesh.material = getMaterialForOriginal(mesh.material)
|
||||||
|
hasGlass = mesh.material.name === 'glass';
|
||||||
}
|
}
|
||||||
|
mesh.castShadow = !hasGlass
|
||||||
|
mesh.receiveShadow = !hasGlass
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}, [scene])
|
}, [scene])
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export const RoofRenderer = ({ node }: { node: RoofNode }) => {
|
|||||||
receiveShadow
|
receiveShadow
|
||||||
position={node.position}
|
position={node.position}
|
||||||
rotation-y={node.rotation}
|
rotation-y={node.rotation}
|
||||||
|
visible={node.visible}
|
||||||
{...handlers}
|
{...handlers}
|
||||||
>
|
>
|
||||||
{/* RoofSystem will replace this geometry in the next frame */}
|
{/* RoofSystem will replace this geometry in the next frame */}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export const SlabRenderer = ({ node }: { node: SlabNode }) => {
|
|||||||
const handlers = useNodeEvents(node, 'slab')
|
const handlers = useNodeEvents(node, 'slab')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<mesh ref={ref} castShadow receiveShadow {...handlers}>
|
<mesh ref={ref} castShadow receiveShadow {...handlers} visible={node.visible}>
|
||||||
{/* SlabSystem will replace this geometry in the next frame */}
|
{/* SlabSystem will replace this geometry in the next frame */}
|
||||||
<boxGeometry args={[0, 0, 0]} />
|
<boxGeometry args={[0, 0, 0]} />
|
||||||
<meshStandardMaterial color="#e5e5e5" />
|
<meshStandardMaterial color="#e5e5e5" />
|
||||||
|
|||||||
@@ -12,10 +12,10 @@ export const WallRenderer = ({ node }: { node: WallNode }) => {
|
|||||||
const handlers = useNodeEvents(node, 'wall')
|
const handlers = useNodeEvents(node, 'wall')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<mesh ref={ref} castShadow receiveShadow>
|
<mesh ref={ref} castShadow receiveShadow visible={node.visible}>
|
||||||
{/* WallSystem will replace this geometry in the next frame */}
|
{/* WallSystem will replace this geometry in the next frame */}
|
||||||
<boxGeometry args={[0, 0, 0]} />
|
<boxGeometry args={[0, 0, 0]} />
|
||||||
<meshStandardMaterial color="lightgray" />
|
<meshStandardMaterial color="white" />
|
||||||
<mesh name="collision-mesh" {...handlers} visible={false}>
|
<mesh name="collision-mesh" {...handlers} visible={false}>
|
||||||
<boxGeometry args={[0, 0, 0]} />
|
<boxGeometry args={[0, 0, 0]} />
|
||||||
</mesh>
|
</mesh>
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { CeilingSystem, ItemSystem, RoofSystem, 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 } 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'
|
||||||
import { GuideSystem } from '../../systems/guide/guide-system'
|
import { GuideSystem } from '../../systems/guide/guide-system'
|
||||||
import { LevelSystem } from '../../systems/level/level-system'
|
import { LevelSystem } from '../../systems/level/level-system'
|
||||||
import { ScanSystem } from '../../systems/scan/scan-system'
|
import { ScanSystem } from '../../systems/scan/scan-system'
|
||||||
import { SceneRenderer } from '../renderers/scene-renderer'
|
import { SceneRenderer } from '../renderers/scene-renderer'
|
||||||
|
import { Lights } from './lights'
|
||||||
import PostProcessing from './post-processing'
|
import PostProcessing from './post-processing'
|
||||||
import { SelectionManager } from './selection-manager'
|
import { SelectionManager } from './selection-manager'
|
||||||
import { ViewerCamera } from './viewer-camera'
|
import { ViewerCamera } from './viewer-camera'
|
||||||
@@ -30,16 +31,22 @@ const Viewer: React.FC<ViewerProps> = ({ children, selectionManager = 'default'
|
|||||||
gl={async (props) => {
|
gl={async (props) => {
|
||||||
const renderer = new THREE.WebGPURenderer(props as any)
|
const renderer = new THREE.WebGPURenderer(props as any)
|
||||||
await renderer.init()
|
await renderer.init()
|
||||||
|
renderer.toneMapping = THREE.ACESFilmicToneMapping
|
||||||
|
renderer.toneMappingExposure = 1.2
|
||||||
return renderer
|
return renderer
|
||||||
}}
|
}}
|
||||||
shadows
|
shadows={{
|
||||||
|
type: THREE.PCFShadowMap,
|
||||||
|
enabled: true,
|
||||||
|
}}
|
||||||
camera={{ position: [50, 50, 50], fov: 50 }}
|
camera={{ position: [50, 50, 50], fov: 50 }}
|
||||||
>
|
>
|
||||||
<color attach="background" args={['#ececec']} />
|
<color attach="background" args={['#ececec']} />
|
||||||
<ViewerCamera />
|
<ViewerCamera />
|
||||||
|
|
||||||
<directionalLight position={[10, 10, 5]} intensity={0.5} castShadow />
|
{/* <directionalLight position={[10, 10, 5]} intensity={0.5} castShadow
|
||||||
<Environment preset="sunset" environmentIntensity={0.3} />
|
/> */}
|
||||||
|
<Lights />
|
||||||
<Bvh>
|
<Bvh>
|
||||||
<SceneRenderer />
|
<SceneRenderer />
|
||||||
</Bvh>
|
</Bvh>
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { Environment } from '@react-three/drei'
|
||||||
|
import { useRef } from 'react'
|
||||||
|
import type { DirectionalLight, OrthographicCamera } from 'three/webgpu'
|
||||||
|
|
||||||
|
export function Lights() {
|
||||||
|
const lightRef = useRef<DirectionalLight>(null)
|
||||||
|
const shadowCamera = useRef<OrthographicCamera>(null)
|
||||||
|
const shadowCameraSize = 50 // The "area" around the camera to shadow
|
||||||
|
|
||||||
|
// useHelper(lightRef, DirectionalLightHelper, 1, 'red')
|
||||||
|
// useHelper(shadowCamera, CameraHelper)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<directionalLight
|
||||||
|
ref={lightRef}
|
||||||
|
position={[10, 10, 10]}
|
||||||
|
castShadow
|
||||||
|
intensity={1}
|
||||||
|
shadow-bias={-0.002}
|
||||||
|
shadow-normalBias={0.3}
|
||||||
|
shadow-mapSize={[1024, 1024]}
|
||||||
|
shadow-radius={3}
|
||||||
|
>
|
||||||
|
<orthographicCamera
|
||||||
|
ref={shadowCamera}
|
||||||
|
attach="shadow-camera"
|
||||||
|
near={1}
|
||||||
|
far={100}
|
||||||
|
left={-shadowCameraSize}
|
||||||
|
right={shadowCameraSize}
|
||||||
|
top={shadowCameraSize}
|
||||||
|
bottom={-shadowCameraSize}
|
||||||
|
/>
|
||||||
|
</directionalLight>
|
||||||
|
|
||||||
|
<ambientLight intensity={0.2} />
|
||||||
|
<Environment preset="sunset" environmentIntensity={0.4} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user