bring back site edition + zindex fix
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { useScene, type SiteNode } from '@pascal-app/core'
|
import { type SiteNode, useScene } from '@pascal-app/core'
|
||||||
import { useCallback } from 'react'
|
import { useCallback } from 'react'
|
||||||
import { PolygonEditor } from '../shared/polygon-editor'
|
import { PolygonEditor } from '../shared/polygon-editor'
|
||||||
|
|
||||||
@@ -34,7 +34,7 @@ export const SiteBoundaryEditor: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<PolygonEditor
|
<PolygonEditor
|
||||||
polygon={site.polygon.points}
|
polygon={site.polygon.points}
|
||||||
color="#f59e0b"
|
color="#10b981"
|
||||||
onPolygonChange={handlePolygonChange}
|
onPolygonChange={handlePolygonChange}
|
||||||
minVertices={3}
|
minVertices={3}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
type BuildingNode,
|
type BuildingNode,
|
||||||
emitter,
|
emitter,
|
||||||
LevelNode,
|
LevelNode,
|
||||||
|
type SiteNode,
|
||||||
useScene,
|
useScene,
|
||||||
type ZoneNode,
|
type ZoneNode,
|
||||||
} from "@pascal-app/core";
|
} from "@pascal-app/core";
|
||||||
@@ -11,7 +12,9 @@ import {
|
|||||||
Camera,
|
Camera,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Layers,
|
Layers,
|
||||||
|
MapPin,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
|
Pencil,
|
||||||
Plus,
|
Plus,
|
||||||
Trash2,
|
Trash2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
@@ -40,7 +43,186 @@ const PRESET_COLORS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// SITE PHASE VIEW - Simple building buttons
|
// PROPERTY LINE SECTION
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
function calculatePerimeter(points: Array<[number, number]>): number {
|
||||||
|
if (points.length < 2) return 0;
|
||||||
|
let perimeter = 0;
|
||||||
|
for (let i = 0; i < points.length; i++) {
|
||||||
|
const [x1, z1] = points[i]!;
|
||||||
|
const [x2, z2] = points[(i + 1) % points.length]!;
|
||||||
|
perimeter += Math.sqrt((x2 - x1) ** 2 + (z2 - z1) ** 2);
|
||||||
|
}
|
||||||
|
return perimeter;
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculatePolygonArea(polygon: Array<[number, number]>): number {
|
||||||
|
if (polygon.length < 3) return 0;
|
||||||
|
let area = 0;
|
||||||
|
const n = polygon.length;
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const j = (i + 1) % n;
|
||||||
|
area += polygon[i]![0] * polygon[j]![1];
|
||||||
|
area -= polygon[j]![0] * polygon[i]![1];
|
||||||
|
}
|
||||||
|
return Math.abs(area) / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
function useSiteNode(): SiteNode | null {
|
||||||
|
const siteId = useScene((state) => {
|
||||||
|
for (const id of state.rootNodeIds) {
|
||||||
|
if (state.nodes[id]?.type === "site") return id;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
return useScene((state) =>
|
||||||
|
siteId ? ((state.nodes[siteId] as SiteNode | undefined) ?? null) : null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PropertyLineSection() {
|
||||||
|
const siteNode = useSiteNode();
|
||||||
|
const updateNode = useScene((state) => state.updateNode);
|
||||||
|
const mode = useEditor((state) => state.mode);
|
||||||
|
const setMode = useEditor((state) => state.setMode);
|
||||||
|
|
||||||
|
if (!siteNode) return null;
|
||||||
|
|
||||||
|
const points = siteNode.polygon?.points ?? [];
|
||||||
|
const area = calculatePolygonArea(points);
|
||||||
|
const perimeter = calculatePerimeter(points);
|
||||||
|
const isEditing = mode === "edit";
|
||||||
|
|
||||||
|
const handleToggleEdit = () => {
|
||||||
|
setMode(isEditing ? "select" : "edit");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePointChange = (index: number, axis: 0 | 1, value: number) => {
|
||||||
|
const newPoints = [...points.map((p) => [...p] as [number, number])];
|
||||||
|
newPoints[index]![axis] = value;
|
||||||
|
updateNode(siteNode.id, {
|
||||||
|
polygon: { type: "polygon" as const, points: newPoints },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddPoint = () => {
|
||||||
|
const lastPoint = points[points.length - 1];
|
||||||
|
const firstPoint = points[0];
|
||||||
|
if (!lastPoint || !firstPoint) return;
|
||||||
|
|
||||||
|
const newPoint: [number, number] = [
|
||||||
|
(lastPoint[0] + firstPoint[0]) / 2,
|
||||||
|
(lastPoint[1] + firstPoint[1]) / 2,
|
||||||
|
];
|
||||||
|
const newPoints = [...points, newPoint];
|
||||||
|
updateNode(siteNode.id, {
|
||||||
|
polygon: { type: "polygon" as const, points: newPoints },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeletePoint = (index: number) => {
|
||||||
|
if (points.length <= 3) return;
|
||||||
|
const newPoints = points.filter((_, i) => i !== index);
|
||||||
|
updateNode(siteNode.id, {
|
||||||
|
polygon: { type: "polygon" as const, points: newPoints },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-b border-border/50">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-3 py-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<MapPin className="w-4 h-4 text-muted-foreground" />
|
||||||
|
<span className="text-sm font-medium">Property Line</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
"w-6 h-6 flex items-center justify-center rounded cursor-pointer transition-colors",
|
||||||
|
isEditing
|
||||||
|
? "bg-orange-500/20 text-orange-400"
|
||||||
|
: "hover:bg-accent text-muted-foreground"
|
||||||
|
)}
|
||||||
|
onClick={handleToggleEdit}
|
||||||
|
>
|
||||||
|
<Pencil className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Measurements */}
|
||||||
|
<div className="flex gap-3 px-3 pb-2">
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
Area: <span className="text-foreground">{area.toFixed(1)} m²</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
Perimeter:{" "}
|
||||||
|
<span className="text-foreground">{perimeter.toFixed(1)} m</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Vertex list (shown when editing) */}
|
||||||
|
{isEditing && (
|
||||||
|
<div className="px-3 pb-2">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{points.map((point, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="flex items-center gap-1.5 text-xs"
|
||||||
|
>
|
||||||
|
<span className="w-4 text-muted-foreground text-right shrink-0">
|
||||||
|
{index + 1}
|
||||||
|
</span>
|
||||||
|
<label className="text-muted-foreground shrink-0">X</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={point[0]}
|
||||||
|
onChange={(e) =>
|
||||||
|
handlePointChange(index, 0, parseFloat(e.target.value) || 0)
|
||||||
|
}
|
||||||
|
step={0.5}
|
||||||
|
className="w-16 bg-accent/50 rounded px-1.5 py-0.5 text-xs text-foreground border border-border/50 focus:outline-none focus:border-primary"
|
||||||
|
/>
|
||||||
|
<label className="text-muted-foreground shrink-0">Z</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={point[1]}
|
||||||
|
onChange={(e) =>
|
||||||
|
handlePointChange(index, 1, parseFloat(e.target.value) || 0)
|
||||||
|
}
|
||||||
|
step={0.5}
|
||||||
|
className="w-16 bg-accent/50 rounded px-1.5 py-0.5 text-xs text-foreground border border-border/50 focus:outline-none focus:border-primary"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
"w-5 h-5 flex items-center justify-center rounded cursor-pointer shrink-0",
|
||||||
|
points.length > 3
|
||||||
|
? "hover:bg-red-500/20 text-muted-foreground hover:text-red-400"
|
||||||
|
: "text-muted-foreground/30 cursor-not-allowed"
|
||||||
|
)}
|
||||||
|
onClick={() => handleDeletePoint(index)}
|
||||||
|
disabled={points.length <= 3}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-1 mt-1.5 px-2 py-1 text-xs text-muted-foreground hover:text-foreground hover:bg-accent/50 rounded cursor-pointer transition-colors"
|
||||||
|
onClick={handleAddPoint}
|
||||||
|
>
|
||||||
|
<Plus className="w-3 h-3" />
|
||||||
|
Add point
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// SITE PHASE VIEW - Property line + building buttons
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
function SitePhaseView() {
|
function SitePhaseView() {
|
||||||
@@ -55,15 +237,14 @@ function SitePhaseView() {
|
|||||||
.map((child) => typeof child === 'string' ? nodes[child] : child)
|
.map((child) => typeof child === 'string' ? nodes[child] : child)
|
||||||
.filter((node): node is BuildingNode => node?.type === "building");
|
.filter((node): node is BuildingNode => node?.type === "building");
|
||||||
|
|
||||||
if (buildings.length === 0) {
|
|
||||||
return (
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<PropertyLineSection />
|
||||||
|
{buildings.length === 0 ? (
|
||||||
<div className="px-3 py-4 text-sm text-muted-foreground">
|
<div className="px-3 py-4 text-sm text-muted-foreground">
|
||||||
No buildings yet
|
No buildings yet
|
||||||
</div>
|
</div>
|
||||||
);
|
) : (
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-1 p-2">
|
<div className="flex flex-col gap-1 p-2">
|
||||||
{buildings.map((building) => (
|
{buildings.map((building) => (
|
||||||
<button
|
<button
|
||||||
@@ -81,6 +262,8 @@ function SitePhaseView() {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -367,18 +550,6 @@ function LayerToggle() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function calculatePolygonArea(polygon: Array<[number, number]>): number {
|
|
||||||
if (polygon.length < 3) return 0;
|
|
||||||
let area = 0;
|
|
||||||
const n = polygon.length;
|
|
||||||
for (let i = 0; i < n; i++) {
|
|
||||||
const j = (i + 1) % n;
|
|
||||||
area += polygon[i]![0] * polygon[j]![1];
|
|
||||||
area -= polygon[j]![0] * polygon[i]![1];
|
|
||||||
}
|
|
||||||
return Math.abs(area) / 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 [cameraPopoverOpen, setCameraPopoverOpen] = useState(false);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { type SiteNode, useRegistry } from '@pascal-app/core'
|
import { type SiteNode, useRegistry } from '@pascal-app/core'
|
||||||
|
import { Html } from '@react-three/drei'
|
||||||
import { useMemo, useRef } from 'react'
|
import { useMemo, useRef } from 'react'
|
||||||
import { BufferGeometry, DoubleSide, Float32BufferAttribute, type Group, Shape } from 'three'
|
import { BufferGeometry, DoubleSide, Float32BufferAttribute, type Group, Shape } from 'three'
|
||||||
import { useNodeEvents } from '../../../hooks/use-node-events'
|
import { useNodeEvents } from '../../../hooks/use-node-events'
|
||||||
@@ -60,6 +61,19 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => {
|
|||||||
return createBoundaryLineGeometry(node.polygon.points)
|
return createBoundaryLineGeometry(node.polygon.points)
|
||||||
}, [node?.polygon?.points])
|
}, [node?.polygon?.points])
|
||||||
|
|
||||||
|
// Edge distances for labels
|
||||||
|
const edges = useMemo(() => {
|
||||||
|
const polygon = node?.polygon?.points ?? []
|
||||||
|
if (polygon.length < 2) return []
|
||||||
|
return polygon.map(([x1, z1], i) => {
|
||||||
|
const [x2, z2] = polygon[(i + 1) % polygon.length]!
|
||||||
|
const midX = (x1! + x2) / 2
|
||||||
|
const midZ = (z1! + z2) / 2
|
||||||
|
const dist = Math.sqrt((x2 - x1!) ** 2 + (z2 - z1!) ** 2)
|
||||||
|
return { midX, midZ, dist }
|
||||||
|
})
|
||||||
|
}, [node?.polygon?.points])
|
||||||
|
|
||||||
const handlers = useNodeEvents(node, 'site')
|
const handlers = useNodeEvents(node, 'site')
|
||||||
|
|
||||||
if (!node || !floorShape || !lineGeometry) {
|
if (!node || !floorShape || !lineGeometry) {
|
||||||
@@ -98,6 +112,21 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => {
|
|||||||
opacity={0.6}
|
opacity={0.6}
|
||||||
/>
|
/>
|
||||||
</line>
|
</line>
|
||||||
|
|
||||||
|
{/* Edge distance labels */}
|
||||||
|
{edges.map((edge, i) => (
|
||||||
|
<Html
|
||||||
|
center
|
||||||
|
key={`edge-${i}`}
|
||||||
|
position={[edge.midX, 0.5, edge.midZ]}
|
||||||
|
style={{ pointerEvents: 'none', userSelect: 'none' }}
|
||||||
|
zIndexRange={[10, 0]}
|
||||||
|
>
|
||||||
|
<div className="whitespace-nowrap rounded bg-black/75 px-1.5 py-0.5 font-mono text-white text-xs backdrop-blur-sm">
|
||||||
|
{edge.dist.toFixed(2)}m
|
||||||
|
</div>
|
||||||
|
</Html>
|
||||||
|
))}
|
||||||
</group>
|
</group>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -180,7 +180,8 @@ export const ZoneRenderer = ({ node }: { node: ZoneNode }) => {
|
|||||||
<group ref={ref} {...handlers}>
|
<group ref={ref} {...handlers}>
|
||||||
<Html name="label" position={[centroid[0], 1, centroid[1]]} style={{
|
<Html name="label" position={[centroid[0], 1, centroid[1]]} style={{
|
||||||
pointerEvents: 'none'
|
pointerEvents: 'none'
|
||||||
}}>
|
}}
|
||||||
|
zIndexRange={[10, 0]}>
|
||||||
<div style={{
|
<div style={{
|
||||||
transform: 'translate3d(-50%, -50%, 0)',
|
transform: 'translate3d(-50%, -50%, 0)',
|
||||||
width: 'max-content',
|
width: 'max-content',
|
||||||
|
|||||||
Reference in New Issue
Block a user