diff --git a/apps/editor/app/page.tsx b/apps/editor/app/page.tsx
index e4ccf7ed3..4228403b2 100644
--- a/apps/editor/app/page.tsx
+++ b/apps/editor/app/page.tsx
@@ -1,18 +1,15 @@
'use client'
-
import { Editor, ItemsPanel } from '@pascal-app/editor'
import { Hammer, Layers, Package, Settings } from 'lucide-react'
import Image from 'next/image'
import Link from 'next/link'
import { BuildTab } from '@/components/build-tab'
+import { NorthCompassWidget } from '@/components/north-compass'
import {
CommunityViewerToolbarLeft,
CommunityViewerToolbarRight,
} from '@/components/viewer-toolbar'
-// The open-source editor only ships the built-in catalog (no uploaded items),
-// so the Library/Community/Mine source chips and tag filters add nothing —
-// drop them and keep the panel to plain categories.
function EditorItemsPanel() {
return
}
@@ -111,6 +108,7 @@ export default function Home() {
sidebarTabs={SIDEBAR_TABS}
viewerToolbarLeft={}
viewerToolbarRight={}
+ viewerBanner={}
/>
)
diff --git a/apps/editor/components/north-compass.tsx b/apps/editor/components/north-compass.tsx
new file mode 100644
index 000000000..441faf417
--- /dev/null
+++ b/apps/editor/components/north-compass.tsx
@@ -0,0 +1,76 @@
+'use client'
+
+import useViewer from '@pascal-app/viewer/store/use-viewer'
+
+/**
+ * A pure-SVG north-arrow compass widget.
+ * The `bearingDeg` prop is the angle (clockwise degrees from screen-up) that
+ * the north arrow should point — 0 means north faces straight up, 90 means
+ * north faces right, etc.
+ */
+function CompassSVG({ bearingDeg }: { bearingDeg: number }) {
+ return (
+
+
+
+ )
+}
+
+/**
+ * DOM overlay — renders the compass in the bottom-right of the nearest
+ * `relative` positioned ancestor. Must be placed inside the viewport wrapper,
+ * not inside the toolbar strip.
+ * Reads the bearing from useViewer, written each frame by NorthCompassR3F
+ * (which lives inside the Canvas in packages/viewer).
+ */
+export function NorthCompassWidget() {
+ const bearingDeg = useViewer((s) => s.northBearingDeg)
+ return (
+
+
+
+ )
+}
diff --git a/apps/editor/components/viewer-toolbar.tsx b/apps/editor/components/viewer-toolbar.tsx
index ddd2c8217..dc24263cc 100644
--- a/apps/editor/components/viewer-toolbar.tsx
+++ b/apps/editor/components/viewer-toolbar.tsx
@@ -265,9 +265,6 @@ function WallModeToggle() {
)
}
-// One dropdown that gathers every "how the scene looks" control: grid, shadows,
-// camera projection, units, render mode, edges and scene theme.
-
const EDGE_OPTIONS = [
{ id: 'off', name: 'Off', detail: 'No edge lines' },
{ id: 'soft', name: 'Soft', detail: 'Faint outline of major creases' },
@@ -299,7 +296,6 @@ function DisplayMenu() {
const activeEdges = EDGE_OPTIONS.find((option) => option.id === edges) ?? EDGE_OPTIONS[0]
const activeTheme = getSceneTheme(sceneTheme)
- // Keep the menu open when flipping a toggle.
const keepOpen = (event: Event, fn: () => void) => {
event.preventDefault()
fn()
diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts
index e9d270dcc..90ef81a83 100644
--- a/packages/core/src/schema/index.ts
+++ b/packages/core/src/schema/index.ts
@@ -130,7 +130,7 @@ export {
} from './nodes/roof-segment-walls'
export { ScanNode } from './nodes/scan'
export { ShelfNode } from './nodes/shelf'
-export { SiteNode } from './nodes/site'
+export { NORTH_DIRECTION_DEFAULT, SiteNode } from './nodes/site'
export {
SKYLIGHT_TYPE_ORDER,
SKYLIGHT_TYPE_PRESETS,
diff --git a/packages/core/src/schema/nodes/site.ts b/packages/core/src/schema/nodes/site.ts
index 6cb33d681..afe7e17ff 100644
--- a/packages/core/src/schema/nodes/site.ts
+++ b/packages/core/src/schema/nodes/site.ts
@@ -1,28 +1,32 @@
-// lib/scenegraph/schema/nodes/site.ts
-
import dedent from 'dedent'
import { z } from 'zod'
import { BaseNode, nodeType, objectId } from '../base'
-// 2D Polygon
const PropertyLineData = z.object({
type: z.literal('polygon'),
points: z.array(z.tuple([z.number(), z.number()])),
})
-// 3D Polygon/Mesh
-// const TerrainData = z.object({
-// type: z.literal('terrain'),
-// points: z.array(z.tuple([z.number(), z.number(), z.number()])),
-// })
+/**
+ * Angle in radians, counter-clockwise from world +X axis, that points toward
+ * True North. Default: Math.PI / 2 (i.e. +Z is south, matching the
+ * two-bedroom template convention where positive-Z points south and +X is east).
+ *
+ * Examples:
+ * 0 → north is world +X (east on a standard north-up plan)
+ * Math.PI/2 → north is world +Z (default; +Z points north... wait, no:
+ * CCW from +X by 90° lands on +Z — so +Z IS north here)
+ *
+ * Tip: to map a surveyor's "bearing from True North" (clockwise degrees) to
+ * this value: northDirection = Math.PI/2 - bearing * (Math.PI/180)
+ */
+export const NORTH_DIRECTION_DEFAULT = Math.PI / 2
export const SiteNode = BaseNode.extend({
id: objectId('site'),
type: nodeType('site'),
- // Specific props
polygon: PropertyLineData.optional().default({
type: 'polygon',
- // Default 30x30 square centered at origin
points: [
[-15, -15],
[15, -15],
@@ -30,12 +34,18 @@ export const SiteNode = BaseNode.extend({
[-15, 15],
],
}),
- // terrain: TerrainData,
+ /**
+ * True-North direction: radians, CCW from world +X axis.
+ * Default (π/2) means world +Z points north, world +X points east —
+ * consistent with the two-bedroom template and standard north-up site plans.
+ */
+ northDirection: z.number().default(NORTH_DIRECTION_DEFAULT),
children: z.array(z.string()).default([]),
}).describe(
dedent`
Site node - used to represent a site
- polygon: polygon data
+ - northDirection: True North angle in radians, CCW from world +X (default π/2 = +Z is north)
- children: array of child node ids (buildings, items)
`,
)
diff --git a/packages/mcp/src/templates/garden-house.ts b/packages/mcp/src/templates/garden-house.ts
index 5dd8dcd73..5c6783f02 100644
--- a/packages/mcp/src/templates/garden-house.ts
+++ b/packages/mcp/src/templates/garden-house.ts
@@ -6,12 +6,17 @@ import type { AnyNode, AnyNodeId } from '@pascal-app/core/schema'
* MCP research fixtures.
*
* Footprint: 12 m × 8 m house centered at the origin, with a 12 m × 6 m
- * back garden zone immediately to the north of the house, surrounded by a
- * privacy fence on three sides.
+ * back garden zone to the north of the house (negative-Z side), surrounded
+ * by a privacy fence on three sides.
+ *
+ * Coordinate system: [x, z] on the XZ plane.
+ * x runs west (negative) to east (positive).
+ * z runs south (positive) to north (negative).
+ * True North = world −Z direction (site.northDirection = π/2, the default).
*
* Contents:
* - 4 perimeter walls around the house
- * - 1 front door (south wall), 1 large garden door (north wall)
+ * - 1 front door (south wall, +Z side), 1 large garden door (north wall, −Z side)
* - 2 windows on the south wall, 1 window on each of east and west
* - 1 indoor "living" zone, 1 outdoor "garden" zone
* - 3 fence segments bounding the north/east/west of the garden
diff --git a/packages/mcp/src/templates/two-bedroom.ts b/packages/mcp/src/templates/two-bedroom.ts
index d41a14b1d..08922372b 100644
--- a/packages/mcp/src/templates/two-bedroom.ts
+++ b/packages/mcp/src/templates/two-bedroom.ts
@@ -10,8 +10,12 @@ import type { AnyNode, AnyNodeId } from '@pascal-app/core/schema'
* 5 windows (2 on the living/kitchen, 1 per bedroom, 1 on the bath).
* Interior partitions split the north half into two bedrooms and a bath.
*
- * Coordinate system: `[x, z]` on the XZ plane, with `x` running east/west
- * and `z` running north/south (positive z points south).
+ * Coordinate system: [x, z] on the XZ plane.
+ * x runs west (negative) to east (positive).
+ * z runs south (positive) to north (negative).
+ * True North = world −Z direction (site.northDirection = π/2, the default).
+ *
+ * As a result: Z_MIN (−4) is the north wall, Z_MAX (+4) is the south wall.
*/
// Perimeter extents: 10 m × 8 m.
diff --git a/packages/viewer/src/components/viewer/index.tsx b/packages/viewer/src/components/viewer/index.tsx
index 497f36e8a..41b95374a 100644
--- a/packages/viewer/src/components/viewer/index.tsx
+++ b/packages/viewer/src/components/viewer/index.tsx
@@ -30,6 +30,7 @@ import { ErrorBoundary } from '../error-boundary'
import { SceneRenderer } from '../renderers/scene-renderer'
import FrameLimiter from './frame-limiter'
import { Lights } from './lights'
+import { NorthCompassR3F } from './north-compass'
import { PerfMonitor } from './perf-monitor'
import PostProcessing, { DEFAULT_HOVER_STYLES, type HoverStyles } from './post-processing'
import { RegisteredSystems } from './registered-systems'
@@ -515,6 +516,7 @@ const Viewer = forwardRef(function Viewer(
}}
>
+
diff --git a/packages/viewer/src/components/viewer/north-compass.tsx b/packages/viewer/src/components/viewer/north-compass.tsx
new file mode 100644
index 000000000..b2f672da2
--- /dev/null
+++ b/packages/viewer/src/components/viewer/north-compass.tsx
@@ -0,0 +1,50 @@
+'use client'
+
+import { NORTH_DIRECTION_DEFAULT } from '@pascal-app/core/schema'
+import useScene from '@pascal-app/core/store/use-scene'
+import { useFrame, useThree } from '@react-three/fiber'
+import { useRef } from 'react'
+import * as THREE from 'three'
+import useViewer from '../../store/use-viewer'
+
+function useNorthDirection(): number {
+ const nodes = useScene((state) => state.nodes)
+ for (const node of Object.values(nodes)) {
+ if (node.type === 'site') {
+ const dir = (node as { northDirection?: unknown }).northDirection
+ return typeof dir === 'number' ? dir : NORTH_DIRECTION_DEFAULT
+ }
+ }
+ return NORTH_DIRECTION_DEFAULT
+}
+
+/**
+ * Mounts inside the R3F