slab panel
This commit is contained in:
@@ -13,8 +13,7 @@ import { useKeyboard } from '@/hooks/use-keyboard'
|
||||
import { ZoneSystem } from '../systems/zone/zone-system'
|
||||
import { ToolManager } from '../tools/tool-manager'
|
||||
import { ActionMenu } from '../ui/action-menu'
|
||||
import { ReferencePanel } from '../ui/panels/reference-panel'
|
||||
import { RoofPanel } from '../ui/panels/roof-panel'
|
||||
import { PanelManager } from '../ui/panels/panel-manager'
|
||||
import { SidebarProvider } from '../ui/primitives/sidebar'
|
||||
import { AppSidebar } from '../ui/sidebar/app-sidebar'
|
||||
import { CustomCameraControls } from './custom-camera-controls'
|
||||
@@ -31,8 +30,7 @@ export default function Editor() {
|
||||
return (
|
||||
<div className="w-full h-full">
|
||||
<ActionMenu />
|
||||
<ReferencePanel />
|
||||
<RoofPanel />
|
||||
<PanelManager />
|
||||
|
||||
<SidebarProvider className="fixed z-10">
|
||||
<AppSidebar />
|
||||
|
||||
@@ -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 = (
|
||||
levelId: LevelNode["id"],
|
||||
points: Array<[number, number]>
|
||||
) => {
|
||||
): string => {
|
||||
const { createNode, nodes } = useScene.getState();
|
||||
|
||||
// Count existing slabs for naming
|
||||
@@ -64,6 +64,7 @@ const commitSlabDrawing = (
|
||||
});
|
||||
|
||||
createNode(slab, levelId);
|
||||
return slab.id;
|
||||
};
|
||||
|
||||
type PreviewState = {
|
||||
@@ -87,6 +88,7 @@ export const SlabTool: React.FC = () => {
|
||||
const pointsRef = useRef<Array<[number, number]>>([]);
|
||||
const levelYRef = useRef(0); // Track current level Y position
|
||||
const currentLevelId = useViewer((state) => state.selection.levelId);
|
||||
const setSelection = useViewer((state) => state.setSelection);
|
||||
const setTool = useEditor((state) => state.setTool);
|
||||
|
||||
// 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[1] - firstPoint[1]) < 0.25
|
||||
) {
|
||||
// Create the slab
|
||||
commitSlabDrawing(currentLevelId, pointsRef.current);
|
||||
// Create the slab and select it
|
||||
const slabId = commitSlabDrawing(currentLevelId, pointsRef.current);
|
||||
setSelection({ selectedIds: [slabId] });
|
||||
|
||||
// Reset state
|
||||
pointsRef.current = [];
|
||||
setPreview({ points: [], cursorPoint: null, levelY: 0 });
|
||||
mainLineRef.current.visible = false;
|
||||
closingLineRef.current.visible = false;
|
||||
|
||||
// Deactivate tool
|
||||
setTool(null);
|
||||
} else {
|
||||
// Add point to polygon
|
||||
pointsRef.current = [...pointsRef.current, clickPoint];
|
||||
@@ -236,16 +236,15 @@ export const SlabTool: React.FC = () => {
|
||||
|
||||
// Need at least 3 points to form a polygon
|
||||
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
|
||||
pointsRef.current = [];
|
||||
setPreview({ points: [], cursorPoint: null, levelY: 0 });
|
||||
mainLineRef.current.visible = false;
|
||||
closingLineRef.current.visible = false;
|
||||
|
||||
// Deactivate tool
|
||||
setTool(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -262,7 +261,7 @@ export const SlabTool: React.FC = () => {
|
||||
// Reset state on unmount
|
||||
pointsRef.current = [];
|
||||
};
|
||||
}, [currentLevelId, setTool]);
|
||||
}, [currentLevelId, setSelection]);
|
||||
|
||||
const { points, cursorPoint, levelY } = preview;
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
'use client'
|
||||
|
||||
import { 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 node = nodes[selectedIds[0]!]
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -435,7 +435,7 @@ export class SpatialGridManager {
|
||||
const slabMap = this.slabsByLevel.get(levelId)
|
||||
if (!slabMap) return 0
|
||||
|
||||
let maxElevation = 0
|
||||
let maxElevation = -Infinity
|
||||
for (const slab of slabMap.values()) {
|
||||
if (slab.polygon.length >= 3 && itemOverlapsPolygon(position, dimensions, rotation, slab.polygon, 0.01)) {
|
||||
const elevation = slab.elevation ?? 0.05
|
||||
@@ -444,7 +444,7 @@ export class SpatialGridManager {
|
||||
}
|
||||
}
|
||||
}
|
||||
return maxElevation
|
||||
return maxElevation === -Infinity ? 0 : maxElevation
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -151,6 +151,7 @@ export function generateExtrudedWall(
|
||||
|
||||
const wallStart: Point2D = { x: wallNode.start[0], y: wallNode.start[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 thickness = wallNode.thickness ?? 0.1
|
||||
const halfT = thickness / 2
|
||||
@@ -244,7 +245,8 @@ export function generateExtrudedWall(
|
||||
|
||||
// Rotate so extrusion direction (Z) becomes height direction (Y)
|
||||
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.computeVertexNormals()
|
||||
|
||||
Reference in New Issue
Block a user