diff --git a/apps/editor/components/tools/site/site-boundary-editor.tsx b/apps/editor/components/tools/site/site-boundary-editor.tsx
index bf197fff..4783bd4d 100644
--- a/apps/editor/components/tools/site/site-boundary-editor.tsx
+++ b/apps/editor/components/tools/site/site-boundary-editor.tsx
@@ -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 { PolygonEditor } from '../shared/polygon-editor'
@@ -34,7 +34,7 @@ export const SiteBoundaryEditor: React.FC = () => {
return (
diff --git a/apps/editor/components/ui/sidebar/panels/site-panel/index.tsx b/apps/editor/components/ui/sidebar/panels/site-panel/index.tsx
index 494dd895..67aa1d74 100644
--- a/apps/editor/components/ui/sidebar/panels/site-panel/index.tsx
+++ b/apps/editor/components/ui/sidebar/panels/site-panel/index.tsx
@@ -2,6 +2,7 @@ import {
type BuildingNode,
emitter,
LevelNode,
+ type SiteNode,
useScene,
type ZoneNode,
} from "@pascal-app/core";
@@ -11,7 +12,9 @@ import {
Camera,
ChevronDown,
Layers,
+ MapPin,
MoreHorizontal,
+ Pencil,
Plus,
Trash2,
} 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 (
+
+ {/* Header */}
+
+
+
+ Property Line
+
+
+
+
+ {/* Measurements */}
+
+
+ Area: {area.toFixed(1)} m²
+
+
+ Perimeter:{" "}
+ {perimeter.toFixed(1)} m
+
+
+
+ {/* Vertex list (shown when editing) */}
+ {isEditing && (
+
+ )}
+
+ );
+}
+
+// ============================================================================
+// SITE PHASE VIEW - Property line + building buttons
// ============================================================================
function SitePhaseView() {
@@ -55,31 +237,32 @@ function SitePhaseView() {
.map((child) => typeof child === 'string' ? nodes[child] : child)
.filter((node): node is BuildingNode => node?.type === "building");
- if (buildings.length === 0) {
- return (
-
- No buildings yet
-
- );
- }
-
return (
-
- {buildings.map((building) => (
-
- ))}
+
+
+ {buildings.length === 0 ? (
+
+ No buildings yet
+
+ ) : (
+
+ {buildings.map((building) => (
+
+ ))}
+
+ )}
);
}
@@ -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 }) {
const [renameOpen, setRenameOpen] = useState(false);
const [cameraPopoverOpen, setCameraPopoverOpen] = useState(false);
diff --git a/packages/viewer/src/components/renderers/site/site-renderer.tsx b/packages/viewer/src/components/renderers/site/site-renderer.tsx
index 58e3d222..ce50a4bc 100644
--- a/packages/viewer/src/components/renderers/site/site-renderer.tsx
+++ b/packages/viewer/src/components/renderers/site/site-renderer.tsx
@@ -1,4 +1,5 @@
import { type SiteNode, useRegistry } from '@pascal-app/core'
+import { Html } from '@react-three/drei'
import { useMemo, useRef } from 'react'
import { BufferGeometry, DoubleSide, Float32BufferAttribute, type Group, Shape } from 'three'
import { useNodeEvents } from '../../../hooks/use-node-events'
@@ -60,6 +61,19 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => {
return createBoundaryLineGeometry(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')
if (!node || !floorShape || !lineGeometry) {
@@ -98,6 +112,21 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => {
opacity={0.6}
/>
+
+ {/* Edge distance labels */}
+ {edges.map((edge, i) => (
+
+
+ {edge.dist.toFixed(2)}m
+
+
+ ))}
)
}
diff --git a/packages/viewer/src/components/renderers/zone/zone-renderer.tsx b/packages/viewer/src/components/renderers/zone/zone-renderer.tsx
index a8d89bcd..7fe2496a 100644
--- a/packages/viewer/src/components/renderers/zone/zone-renderer.tsx
+++ b/packages/viewer/src/components/renderers/zone/zone-renderer.tsx
@@ -180,7 +180,8 @@ export const ZoneRenderer = ({ node }: { node: ZoneNode }) => {
+ }}
+ zIndexRange={[10, 0]}>