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 ( +
+ + {/* Outer ring */} + + + {/* Rotating group — north arrow */} + + {/* North half of needle — red */} + + {/* South half of needle — muted */} + + {/* Centre dot */} + + + + {/* "N" label — always screen-up, outside the rotating group */} + + N + + +
+ ) +} + +/** + * 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 . Reads the camera azimuth every frame, + * combines it with the scene's northDirection, and pushes the result to + * useViewer.northBearingDeg so NorthCompassWidget (outside the canvas) can render it. + */ +export function NorthCompassR3F() { + const { camera } = useThree() + const northDirection = useNorthDirection() + const setNorthBearingDeg = useViewer((s) => s.setNorthBearingDeg) + + const _euler = useRef(new THREE.Euler()) + const _quat = useRef(new THREE.Quaternion()) + const _prevDeg = useRef(0) + + useFrame(() => { + camera.getWorldQuaternion(_quat.current) + _euler.current.setFromQuaternion(_quat.current, 'YXZ') + const cameraYawRad = _euler.current.y + + const northFromScreen = -(northDirection - Math.PI / 2 - cameraYawRad) + const deg = ((northFromScreen * 180) / Math.PI + 360) % 360 + + if (Math.abs(_prevDeg.current - deg) > 0.5) { + _prevDeg.current = deg + setNorthBearingDeg(deg) + } + }) + + return null +} diff --git a/packages/viewer/src/store/use-viewer.ts b/packages/viewer/src/store/use-viewer.ts index 58e0d052c..de822350c 100644 --- a/packages/viewer/src/store/use-viewer.ts +++ b/packages/viewer/src/store/use-viewer.ts @@ -15,7 +15,7 @@ type SelectionPath = { buildingId: BuildingNode['id'] | null levelId: LevelNode['id'] | null zoneId: ZoneNode['id'] | null - selectedIds: BaseNode['id'][] // For items/assets (multi-select) + selectedIds: BaseNode['id'][] } type Outliner = { @@ -78,24 +78,21 @@ type ViewerState = { transparentBackground: boolean setTransparentBackground: (transparent: boolean) => void - // Embed-controlled ink-edge opacity override (null = use the per-mode default). inkOpacity: number | null setInkOpacity: (opacity: number | null) => void projectId: string | null setProjectId: (id: string | null) => void - projectPreferences: Record< + projectPreferences: Record string, { showScans?: boolean; showGuides?: boolean; showGrid?: boolean } > - // Smart selection update setSelection: (updates: Partial) => void resetSelection: () => void - outliner: Outliner // No setter as we will manipulate directly the arrays + outliner: Outliner - // Export functionality exportScene: ((format?: 'glb' | 'stl' | 'obj') => Promise) | null setExportScene: (fn: ((format?: 'glb' | 'stl' | 'obj') => Promise) | null) => void @@ -113,17 +110,22 @@ type ViewerState = { * height arrow, width arrow, etc.). Suppresses node pointer event * routing so the synthetic click on pointerup doesn't reroute * selection to whatever mesh the cursor lands on at release. - * Conceptually a sibling of `cameraDragging` — both mean "user is - * dragging; don't treat the next pointerup as a click on the - * scene." Set by the host (e.g. `NodeArrowHandles` in the editor); - * the viewer only reads it. */ inputDragging: boolean setInputDragging: (dragging: boolean) => void + + /** + * Current north-arrow bearing in degrees (clockwise from screen-up). + * Written every frame by NorthCompassR3F (inside the Canvas) and read + * by NorthCompassWidget (outside the Canvas) via this shared store. + * Not persisted — resets to 0 on mount. + */ + northBearingDeg: number + setNorthBearingDeg: (deg: number) => void } -type PersistedViewerState = Partial< - Pick< +type PersistedViewerState = Partial + Pick ViewerState, | 'cameraMode' | 'sceneTheme' @@ -314,7 +316,6 @@ const useViewer = create()( set((state) => { const newSelection = { ...state.selection, ...updates } - // Hierarchy Guard: If we change a high-level parent, reset the children unless explicitly provided if (updates.buildingId !== undefined) { if (updates.levelId === undefined) newSelection.levelId = null if (updates.zoneId === undefined) newSelection.zoneId = null @@ -355,8 +356,12 @@ const useViewer = create()( cameraDragging: false, setCameraDragging: (dragging) => set({ cameraDragging: dragging }), + inputDragging: false, setInputDragging: (dragging) => set({ inputDragging: dragging }), + + northBearingDeg: 0, + setNorthBearingDeg: (deg) => set({ northBearingDeg: deg }), }), { name: 'viewer-preferences',