Skip to content
Closed

#000. #446

Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 2 additions & 4 deletions apps/editor/app/page.tsx
Original file line number Diff line number Diff line change
@@ -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 <ItemsPanel showSourceFilter={false} showTagFilters={false} />
}
Expand Down Expand Up @@ -111,6 +108,7 @@ export default function Home() {
sidebarTabs={SIDEBAR_TABS}
viewerToolbarLeft={<CommunityViewerToolbarLeft />}
viewerToolbarRight={<CommunityViewerToolbarRight />}
viewerBanner={<NorthCompassWidget />}
/>
</div>
)
Expand Down
76 changes: 76 additions & 0 deletions apps/editor/components/north-compass.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
aria-label={`North arrow, ${Math.round(bearingDeg)}° clockwise from screen top`}
className="pointer-events-none select-none"
role="img"
style={{ width: 44, height: 44 }}
>
<svg
fill="none"
height="44"
viewBox="0 0 44 44"
width="44"
xmlns="http://www.w3.org/2000/svg"
>
{/* Outer ring */}
<circle cx="22" cy="22" r="20" stroke="currentColor" strokeOpacity="0.15" strokeWidth="1" />

{/* Rotating group — north arrow */}
<g
style={{
transformOrigin: '22px 22px',
transform: `rotate(${bearingDeg}deg)`,
}}
>
{/* North half of needle — red */}
<path d="M22 6 L25.5 22 L22 20 L18.5 22 Z" fill="#ef4444" opacity="0.95" />
{/* South half of needle — muted */}
<path d="M22 38 L18.5 22 L22 24 L25.5 22 Z" fill="currentColor" opacity="0.30" />
{/* Centre dot */}
<circle cx="22" cy="22" fill="currentColor" opacity="0.5" r="1.5" />
</g>

{/* "N" label — always screen-up, outside the rotating group */}
<text
dominantBaseline="middle"
fill="currentColor"
fontSize="7"
fontWeight="600"
opacity="0.55"
textAnchor="middle"
x="22"
y="5"
>
N
</text>
</svg>
</div>
)
}

/**
* 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 (
<div className="pointer-events-none absolute bottom-3 right-3 z-10 text-foreground/70">
<CompassSVG bearingDeg={bearingDeg} />
</div>
)
}
4 changes: 0 additions & 4 deletions apps/editor/components/viewer-toolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down Expand Up @@ -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()
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/schema/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
32 changes: 21 additions & 11 deletions packages/core/src/schema/nodes/site.ts
Original file line number Diff line number Diff line change
@@ -1,41 +1,51 @@
// 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],
[15, 15],
[-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),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Default northDirection contradicts −Z north

High Severity

The northDirection property's default π/2 value has conflicting interpretations for "True North." Despite being defined as radians CCW from world +X, the documentation, templates, and surveyor conversion inconsistently describe π/2 as pointing to +Z north, +Z south, or -Z north.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 0c5c16b. Configure here.

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)
`,
)
Expand Down
11 changes: 8 additions & 3 deletions packages/mcp/src/templates/garden-house.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 6 additions & 2 deletions packages/mcp/src/templates/two-bedroom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions packages/viewer/src/components/viewer/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -515,6 +516,7 @@ const Viewer = forwardRef<ViewerHandle, ViewerProps>(function Viewer(
}}
>
<FrameLimiter fps={50} />
<NorthCompassR3F />
Comment thread
cursor[bot] marked this conversation as resolved.
<ViewerCamera />
<GPUDeviceWatcher />
<ToneMappingExposure />
Expand Down
50 changes: 50 additions & 0 deletions packages/viewer/src/components/viewer/north-compass.tsx
Original file line number Diff line number Diff line change
@@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Site north from wrong node

Medium Severity

useNorthDirection returns the first site from Object.values(nodes), while other editor code resolves the canonical site via rootNodeIds[0]. If more than one site exists or iteration order differs from the root, the compass can use the wrong northDirection.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit af1beac. Configure here.

}

/**
* Mounts inside the R3F <Canvas>. 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
}
31 changes: 18 additions & 13 deletions packages/viewer/src/store/use-viewer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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<SelectionPath>) => void
resetSelection: () => void

outliner: Outliner // No setter as we will manipulate directly the arrays
outliner: Outliner

// Export functionality
exportScene: ((format?: 'glb' | 'stl' | 'obj') => Promise<void>) | null
setExportScene: (fn: ((format?: 'glb' | 'stl' | 'obj') => Promise<void>) | null) => void

Expand All @@ -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'
Expand Down Expand Up @@ -314,7 +316,6 @@ const useViewer = create<ViewerState>()(
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
Expand Down Expand Up @@ -355,8 +356,12 @@ const useViewer = create<ViewerState>()(

cameraDragging: false,
setCameraDragging: (dragging) => set({ cameraDragging: dragging }),

inputDragging: false,
setInputDragging: (dragging) => set({ inputDragging: dragging }),

northBearingDeg: 0,
setNorthBearingDeg: (deg) => set({ northBearingDeg: deg }),
Comment thread
cursor[bot] marked this conversation as resolved.
}),
{
name: 'viewer-preferences',
Expand Down
Loading