From ff34d3c5f58b9b4ffdb4c9130cc86f7a69ac0dd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thiago=20L=C3=B6pes?= Date: Tue, 16 Jun 2026 13:53:05 -0300 Subject: [PATCH 01/19] Fix: Restore floor drag/delete functionality for imported legacy JSON files Summary Fixed an issue where floors became impossible to drag or delete after importing JSON configuration files created in versions prior to 0.9.1. Root Cause The migration process was executing elevator parent migration before all level nodes had their children fully normalized. As a result, imported scenes could end up with inconsistent parent-child relationships, causing floor management operations such as dragging and deletion to fail. Changes Made Refactored migrateNodes() into a two-pass migration process. Added normalization for level nodes: Ensures level values are valid finite numbers. Removes references to missing child nodes. Preserves only valid children during migration. Moved elevator migration logic to a dedicated second pass: Elevator parent migration now runs only after all level.children relationships have been stabilized. Prevents invalid hierarchy reconstruction when importing legacy JSON files. Result Imported layouts from versions prior to 0.9.1 now correctly preserve floor hierarchy, allowing floors to be dragged, reordered, and deleted as expected. --- packages/core/src/store/use-scene.ts | 68 ++++++++++++++++++++++++---- 1 file changed, 60 insertions(+), 8 deletions(-) diff --git a/packages/core/src/store/use-scene.ts b/packages/core/src/store/use-scene.ts index a3e7a009e..f1eda5474 100644 --- a/packages/core/src/store/use-scene.ts +++ b/packages/core/src/store/use-scene.ts @@ -419,6 +419,14 @@ function migrateNodes(nodes: Record): Record { // any per-type migration runs, so already-saved scenes load cleanly. const { nodes: healed } = healSceneNodes(nodes) const patchedNodes = { ...healed } as Record + + // Pass 1: all node types except elevator. + // Elevator migration (migrateElevatorParent) mutates level.children to remove + // the elevator ID. If the elevator is processed before its parent level in + // Object.entries order, the level migration in this same pass would then see + // a children array that still contains the elevator ID and filter it out as + // "missing" — corrupting the level. Running elevators in a second pass after + // all levels are stable avoids the race entirely. for (const [id, node] of Object.entries(patchedNodes)) { // 1. Item scale migration if (node.type === 'item' && !('scale' in node)) { @@ -525,14 +533,6 @@ function migrateNodes(nodes: Record): Record { } } - if (node.type === 'elevator') { - const parentMigrated = migrateElevatorParent(id, node, patchedNodes) - const normalized = normalizeElevatorNode(parentMigrated) - if (normalized) { - patchedNodes[id] = normalized - } - } - // Roof-segment hosting was added in this migration cycle (the same // pattern as shelf above). Older segments saved before the schema // gained `children` need the field initialised so @@ -621,7 +621,59 @@ function migrateNodes(nodes: Record): Record { patchedNodes[id] = { ...node, children: flattened } } } + + // Level children normalization. + // Pre-0.9.1 JSONs may carry child IDs that no longer exist in the node + // map (e.g. elevator IDs that lived under a level before the elevator + // parent migration moved them up to building). If those dangling IDs are + // left in place, collectReachableNodeIds marks the level as having + // reachable children that don't exist, which corrupts the scene graph + // traversal and leaves the LevelNode in a broken state — making floors + // impossible to drag or delete after import. + // We intentionally do NOT filter by type prefix here; being permissive + // about which types are allowed as children prevents data loss when new + // child types are added to the schema in the future. + if (node.type === 'level') { + const rawChildren = getStringArray(node.children) + const validChildren = rawChildren.filter((childId) => { + const exists = Boolean(patchedNodes[childId]) + if (!exists) { + console.warn( + '[migrateNodes] level', + id, + 'references missing child', + childId, + '— dropping', + ) + } + return exists + }) + const levelNumber = getFiniteNumber(node.level, 0) + patchedNodes[id] = { + ...node, + level: levelNumber, + children: validChildren, + } + } + } + + // Pass 2: elevator migration. + // migrateElevatorParent mutates the parent level's children array (removes + // the elevator ID from it). Running this after Pass 1 guarantees that the + // level normalization above has already seen a clean children list — if we + // ran elevator migration inside Pass 1, the order of Object.entries + // iteration would be non-deterministic: processing an elevator before its + // parent level would mutate the level's children mid-iteration, potentially + // causing the level branch above to see a stale node reference. + for (const [id, node] of Object.entries(patchedNodes)) { + if (node.type !== 'elevator') continue + const parentMigrated = migrateElevatorParent(id, node, patchedNodes) + const normalized = normalizeElevatorNode(parentMigrated) + if (normalized) { + patchedNodes[id] = normalized + } } + return patchedNodes as Record } From fd9b73678811bbe434c45670c320da94197fe4a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thiago=20L=C3=B6pes?= Date: Thu, 18 Jun 2026 15:38:26 -0300 Subject: [PATCH 02/19] Update use-scene.ts --- packages/core/src/store/use-scene.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/store/use-scene.ts b/packages/core/src/store/use-scene.ts index 48136a4d5..3d1bf03a2 100644 --- a/packages/core/src/store/use-scene.ts +++ b/packages/core/src/store/use-scene.ts @@ -1318,4 +1318,4 @@ useScene.temporal.subscribe((state) => { prevPastLength = currentPastLength prevFutureLength = currentFutureLength prevNodesSnapshot = useScene.getState().nodes -}) \ No newline at end of file +}) From 6d8234516ffe2bc5f4462005960cf63248a7d6cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thiago=20L=C3=B6pes?= Date: Fri, 26 Jun 2026 17:45:34 -0300 Subject: [PATCH 03/19] Add northDirection to SiteNode schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O default π/2 significa que CCW 90° a partir de +X aponta para +Z — portanto +Z é Norte. Isso é o inverso do que o two-bedroom.ts declarava ("positive z points south"). A limpeza do template abaixo corrige esse comentário para refletir a nova convenção canônica. --- packages/core/src/schema/nodes/site.ts | 32 +++++++++++++++++--------- 1 file changed, 21 insertions(+), 11 deletions(-) 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) `, ) From 64de9de71b94b2bc441af218f4067c2c54cdc75a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thiago=20L=C3=B6pes?= Date: Fri, 26 Jun 2026 17:48:01 -0300 Subject: [PATCH 04/19] Export NORTH_DIRECTION_DEFAULT along with SiteNode --- packages/core/src/schema/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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, From fee584598a67094427189384374b52e91504e403 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thiago=20L=C3=B6pes?= Date: Fri, 26 Jun 2026 17:48:51 -0300 Subject: [PATCH 05/19] Refine coordinate system explanation in two-bedroom.ts Updated coordinate system description for clarity. --- packages/mcp/src/templates/two-bedroom.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) 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. From 4b370aed9a73a22e252e557c1dcb724aa35004c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thiago=20L=C3=B6pes?= Date: Fri, 26 Jun 2026 17:49:31 -0300 Subject: [PATCH 06/19] Clarify garden zone description in comments --- packages/mcp/src/templates/garden-house.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) 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 From 32787f6ba5ac250f14afa9d104afd88e8c480e25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thiago=20L=C3=B6pes?= Date: Fri, 26 Jun 2026 17:51:46 -0300 Subject: [PATCH 07/19] Add NorthCompass component for directional display --- apps/editor/components/north-compass.tsx | 152 +++++++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 apps/editor/components/north-compass.tsx diff --git a/apps/editor/components/north-compass.tsx b/apps/editor/components/north-compass.tsx new file mode 100644 index 000000000..4f726ef78 --- /dev/null +++ b/apps/editor/components/north-compass.tsx @@ -0,0 +1,152 @@ +'use client' + +import useScene from '@pascal-app/core/store/use-scene' +import { NORTH_DIRECTION_DEFAULT } from '@pascal-app/core/schema' +import { useViewer } from '@pascal-app/viewer' +import { useFrame, useThree } from '@react-three/fiber' +import { useRef, useState } from 'react' +import * as THREE from 'three' + +/** + * Reads the northDirection from the first Site node in the scene. + * Falls back to NORTH_DIRECTION_DEFAULT (π/2) if no site or no field. + */ +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 +} + +/** + * A pure-SVG north-arrow compass widget rendered in a corner of the viewport. + * 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 + + +
+ ) +} + +/** + * NorthCompass reads the camera azimuth from R3F each frame, combines it with + * the scene's northDirection, and renders the SVG widget as a DOM overlay. + * + * Mount this inside the R3F so useFrame is available, but use a + * React portal / absolute-positioned div trick via useState to push the SVG + * outside the canvas into normal DOM flow. + */ +export function NorthCompassR3F() { + const { camera } = useThree() + const northDirection = useNorthDirection() + const [bearingDeg, setBearingDeg] = useState(0) + + // Scratch objects — allocated once, reused every frame. + const _euler = useRef(new THREE.Euler()) + const _quat = useRef(new THREE.Quaternion()) + + useFrame(() => { + // Extract the camera's world yaw (rotation around Y axis). + camera.getWorldQuaternion(_quat.current) + _euler.current.setFromQuaternion(_quat.current, 'YXZ') + const cameraYawRad = _euler.current.y // radians, CCW from +Z in Three.js + + // northDirection is CCW from +X in radians. + // The angle from camera-forward to north: + // northDirection offset from +X → convert to "from +Z": subtract π/2 + // then subtract camera yaw to get screen-relative bearing. + // Negate because screen rotation is clockwise. + const northFromScreen = -(northDirection - Math.PI / 2 - cameraYawRad) + const deg = ((northFromScreen * 180) / Math.PI + 360) % 360 + + // Only trigger re-render when bearing changes by more than 0.5°. + setBearingDeg((prev) => (Math.abs(prev - deg) > 0.5 ? deg : prev)) + }) + + // This component only drives state; the SVG is rendered by NorthCompass below. + // Return null here — the parent component renders the SVG in DOM overlay. + return null +} + +/** + * The full compass widget: a thin wrapper that places the SVG in the + * bottom-right corner of the viewer and mounts the R3F frame-reader inside + * the canvas via the viewer's existing . + * + * Usage (in viewer-toolbar or viewport wrapper): + * + * — but the R3F part must live inside the Canvas. See NorthCompassWidget. + */ +export function NorthCompassOverlay({ bearingDeg }: { bearingDeg: number }) { + return ( +
+ +
+ ) +} From 7e6526db8f011a5ef8ea5ad33345a4c8c7921d5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thiago=20L=C3=B6pes?= Date: Fri, 26 Jun 2026 17:54:16 -0300 Subject: [PATCH 08/19] Import NorthCompassOverlay in viewer-toolbar --- apps/editor/components/viewer-toolbar.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/editor/components/viewer-toolbar.tsx b/apps/editor/components/viewer-toolbar.tsx index ddd2c8217..cdf62ab3a 100644 --- a/apps/editor/components/viewer-toolbar.tsx +++ b/apps/editor/components/viewer-toolbar.tsx @@ -1,5 +1,6 @@ 'use client' +import { NorthCompassOverlay } from './north-compass' import { Icon as IconifyIcon } from '@iconify/react' import { DropdownMenu, From 37e0e3e9499bb67c18a1fafc01521d013816df15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thiago=20L=C3=B6pes?= Date: Fri, 26 Jun 2026 17:55:35 -0300 Subject: [PATCH 09/19] Add north compass bridge using Zustand for state management This file implements a Zustand store for managing the north bearing state, allowing external React components to access the current bearing without using the Zustand viewer store. --- apps/editor/components/north-compass-bridge.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 apps/editor/components/north-compass-bridge.ts diff --git a/apps/editor/components/north-compass-bridge.ts b/apps/editor/components/north-compass-bridge.ts new file mode 100644 index 000000000..cdd261bd8 --- /dev/null +++ b/apps/editor/components/north-compass-bridge.ts @@ -0,0 +1,16 @@ +/** + * Lightweight bridge so the R3F frame loop (inside Canvas) can push the + * current north bearing to a React component outside the Canvas without + * adding it to the Zustand viewer store. + */ +import { create } from 'zustand' + +type NorthBridgeState = { + bearingDeg: number + setBearingDeg: (deg: number) => void +} + +export const useNorthBridge = create()((set) => ({ + bearingDeg: 0, + setBearingDeg: (bearingDeg) => set({ bearingDeg }), +})) From 58fa0a3228a36f2da0e98c0f1dd1e2dda3e8ec7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thiago=20L=C3=B6pes?= Date: Fri, 26 Jun 2026 17:59:02 -0300 Subject: [PATCH 10/19] Refactor NorthCompass to use NorthBridge for state --- apps/editor/components/north-compass.tsx | 54 ++++++++++++------------ 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/apps/editor/components/north-compass.tsx b/apps/editor/components/north-compass.tsx index 4f726ef78..9a0279ea0 100644 --- a/apps/editor/components/north-compass.tsx +++ b/apps/editor/components/north-compass.tsx @@ -1,11 +1,11 @@ 'use client' -import useScene from '@pascal-app/core/store/use-scene' import { NORTH_DIRECTION_DEFAULT } from '@pascal-app/core/schema' -import { useViewer } from '@pascal-app/viewer' +import useScene from '@pascal-app/core/store/use-scene' import { useFrame, useThree } from '@react-three/fiber' -import { useRef, useState } from 'react' +import { useRef } from 'react' import * as THREE from 'three' +import { useNorthBridge } from './north-compass-bridge' /** * Reads the northDirection from the first Site node in the scene. @@ -95,53 +95,53 @@ function CompassSVG({ bearingDeg }: { bearingDeg: number }) { } /** - * NorthCompass reads the camera azimuth from R3F each frame, combines it with - * the scene's northDirection, and renders the SVG widget as a DOM overlay. - * - * Mount this inside the R3F so useFrame is available, but use a - * React portal / absolute-positioned div trick via useState to push the SVG - * outside the canvas into normal DOM flow. + * Mounts inside the R3F . Reads the camera azimuth every frame, + * combines it with the scene's northDirection, and pushes the result to + * the bridge store so NorthCompassWidget (outside the canvas) can render it. */ export function NorthCompassR3F() { const { camera } = useThree() const northDirection = useNorthDirection() - const [bearingDeg, setBearingDeg] = useState(0) + const setBearingDeg = useNorthBridge((s) => s.setBearingDeg) // Scratch objects — allocated once, reused every frame. const _euler = useRef(new THREE.Euler()) const _quat = useRef(new THREE.Quaternion()) + const _prevDeg = useRef(0) useFrame(() => { - // Extract the camera's world yaw (rotation around Y axis). camera.getWorldQuaternion(_quat.current) _euler.current.setFromQuaternion(_quat.current, 'YXZ') - const cameraYawRad = _euler.current.y // radians, CCW from +Z in Three.js + const cameraYawRad = _euler.current.y - // northDirection is CCW from +X in radians. - // The angle from camera-forward to north: - // northDirection offset from +X → convert to "from +Z": subtract π/2 - // then subtract camera yaw to get screen-relative bearing. - // Negate because screen rotation is clockwise. const northFromScreen = -(northDirection - Math.PI / 2 - cameraYawRad) const deg = ((northFromScreen * 180) / Math.PI + 360) % 360 // Only trigger re-render when bearing changes by more than 0.5°. - setBearingDeg((prev) => (Math.abs(prev - deg) > 0.5 ? deg : prev)) + if (Math.abs(_prevDeg.current - deg) > 0.5) { + _prevDeg.current = deg + setBearingDeg(deg) + } }) - // This component only drives state; the SVG is rendered by NorthCompass below. - // Return null here — the parent component renders the SVG in DOM overlay. return null } /** - * The full compass widget: a thin wrapper that places the SVG in the - * bottom-right corner of the viewer and mounts the R3F frame-reader inside - * the canvas via the viewer's existing . - * - * Usage (in viewer-toolbar or viewport wrapper): - * - * — but the R3F part must live inside the Canvas. See NorthCompassWidget. + * DOM overlay — place this outside the Canvas, over the viewport. + * Reads the bearing from the bridge store. + */ +export function NorthCompassWidget() { + const bearingDeg = useNorthBridge((s) => s.bearingDeg) + return ( +
+ +
+ ) +} + +/** + * Low-level overlay if you want to pass bearingDeg manually. */ export function NorthCompassOverlay({ bearingDeg }: { bearingDeg: number }) { return ( From 8b4cfc8c7dc68414e3774dc97b28d039296c114c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thiago=20L=C3=B6pes?= Date: Fri, 26 Jun 2026 18:08:30 -0300 Subject: [PATCH 11/19] Update index.tsx --- packages/viewer/src/components/viewer/index.tsx | 2 ++ 1 file changed, 2 insertions(+) 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( }} > + From 0c5c16b58a234f36cdc49a0d94b443aa94f75b8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thiago=20L=C3=B6pes?= Date: Fri, 26 Jun 2026 18:11:04 -0300 Subject: [PATCH 12/19] Update viewer-toolbar.tsx --- apps/editor/components/viewer-toolbar.tsx | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/apps/editor/components/viewer-toolbar.tsx b/apps/editor/components/viewer-toolbar.tsx index cdf62ab3a..560a26b04 100644 --- a/apps/editor/components/viewer-toolbar.tsx +++ b/apps/editor/components/viewer-toolbar.tsx @@ -1,6 +1,6 @@ 'use client' -import { NorthCompassOverlay } from './north-compass' +import { NorthCompassWidget } from './north-compass' import { Icon as IconifyIcon } from '@iconify/react' import { DropdownMenu, @@ -514,14 +514,17 @@ export function CommunityViewerToolbarLeft() { export function CommunityViewerToolbarRight() { return ( -
- - -
- -
- - -
+ <> + +
+ + +
+ +
+ + +
+ ) } From a8a5f142fabf4f5f02ee38fe10551b81fcdaa6fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thiago=20L=C3=B6pes?= Date: Fri, 26 Jun 2026 18:32:35 -0300 Subject: [PATCH 13/19] Update use-viewer.ts --- packages/viewer/src/store/use-viewer.ts | 31 ++++++++++++++----------- 1 file changed, 18 insertions(+), 13 deletions(-) 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', From d88115bc2e0bc3fd4186376f1dfd3e7314eebb4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thiago=20L=C3=B6pes?= Date: Fri, 26 Jun 2026 18:34:58 -0300 Subject: [PATCH 14/19] Update north-compass.tsx --- apps/editor/components/north-compass.tsx | 98 +++--------------------- 1 file changed, 11 insertions(+), 87 deletions(-) diff --git a/apps/editor/components/north-compass.tsx b/apps/editor/components/north-compass.tsx index 9a0279ea0..441faf417 100644 --- a/apps/editor/components/north-compass.tsx +++ b/apps/editor/components/north-compass.tsx @@ -1,29 +1,9 @@ '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 { useNorthBridge } from './north-compass-bridge' +import useViewer from '@pascal-app/viewer/store/use-viewer' /** - * Reads the northDirection from the first Site node in the scene. - * Falls back to NORTH_DIRECTION_DEFAULT (π/2) if no site or no field. - */ -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 -} - -/** - * A pure-SVG north-arrow compass widget rendered in a corner of the viewport. + * 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. @@ -44,14 +24,7 @@ function CompassSVG({ bearingDeg }: { bearingDeg: number }) { xmlns="http://www.w3.org/2000/svg" > {/* Outer ring */} - + {/* Rotating group — north arrow */} {/* North half of needle — red */} - + {/* South half of needle — muted */} - + {/* Centre dot */} @@ -95,55 +60,14 @@ function CompassSVG({ bearingDeg }: { bearingDeg: number }) { } /** - * Mounts inside the R3F . Reads the camera azimuth every frame, - * combines it with the scene's northDirection, and pushes the result to - * the bridge store so NorthCompassWidget (outside the canvas) can render it. - */ -export function NorthCompassR3F() { - const { camera } = useThree() - const northDirection = useNorthDirection() - const setBearingDeg = useNorthBridge((s) => s.setBearingDeg) - - // Scratch objects — allocated once, reused every frame. - 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 - - // Only trigger re-render when bearing changes by more than 0.5°. - if (Math.abs(_prevDeg.current - deg) > 0.5) { - _prevDeg.current = deg - setBearingDeg(deg) - } - }) - - return null -} - -/** - * DOM overlay — place this outside the Canvas, over the viewport. - * Reads the bearing from the bridge store. + * 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 = useNorthBridge((s) => s.bearingDeg) - return ( -
- -
- ) -} - -/** - * Low-level overlay if you want to pass bearingDeg manually. - */ -export function NorthCompassOverlay({ bearingDeg }: { bearingDeg: number }) { + const bearingDeg = useViewer((s) => s.northBearingDeg) return (
From 658baf9eb49ea2a8100874ba498423493d38ba83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thiago=20L=C3=B6pes?= Date: Fri, 26 Jun 2026 18:44:45 -0300 Subject: [PATCH 15/19] Update viewer-toolbar.tsx --- apps/editor/components/viewer-toolbar.tsx | 26 ++++++++--------------- 1 file changed, 9 insertions(+), 17 deletions(-) diff --git a/apps/editor/components/viewer-toolbar.tsx b/apps/editor/components/viewer-toolbar.tsx index 560a26b04..dc24263cc 100644 --- a/apps/editor/components/viewer-toolbar.tsx +++ b/apps/editor/components/viewer-toolbar.tsx @@ -1,6 +1,5 @@ 'use client' -import { NorthCompassWidget } from './north-compass' import { Icon as IconifyIcon } from '@iconify/react' import { DropdownMenu, @@ -266,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' }, @@ -300,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() @@ -514,17 +509,14 @@ export function CommunityViewerToolbarLeft() { export function CommunityViewerToolbarRight() { return ( - <> - -
- - -
- -
- - -
- +
+ + +
+ +
+ + +
) } From 6deb8ff159fb7cd93de557283fe28acf6ac99af9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thiago=20L=C3=B6pes?= Date: Fri, 26 Jun 2026 18:46:35 -0300 Subject: [PATCH 16/19] Update page.tsx --- apps/editor/app/page.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) 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={} />
) From 217712a3860e90cefdd0334c442f0b7393e75d2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thiago=20L=C3=B6pes?= Date: Fri, 26 Jun 2026 18:49:26 -0300 Subject: [PATCH 17/19] Create north-compass.tsx --- .../src/components/viewer/north-compass.tsx | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 packages/viewer/src/components/viewer/north-compass.tsx 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 +} From 344d60affbb9053c0fef0dd91938d59c04194720 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thiago=20L=C3=B6pes?= Date: Fri, 26 Jun 2026 18:50:15 -0300 Subject: [PATCH 18/19] Update north-compass-bridge.ts --- apps/editor/components/north-compass-bridge.ts | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/apps/editor/components/north-compass-bridge.ts b/apps/editor/components/north-compass-bridge.ts index cdd261bd8..8b1378917 100644 --- a/apps/editor/components/north-compass-bridge.ts +++ b/apps/editor/components/north-compass-bridge.ts @@ -1,16 +1 @@ -/** - * Lightweight bridge so the R3F frame loop (inside Canvas) can push the - * current north bearing to a React component outside the Canvas without - * adding it to the Zustand viewer store. - */ -import { create } from 'zustand' -type NorthBridgeState = { - bearingDeg: number - setBearingDeg: (deg: number) => void -} - -export const useNorthBridge = create()((set) => ({ - bearingDeg: 0, - setBearingDeg: (bearingDeg) => set({ bearingDeg }), -})) From af1beacff0af9bc873ee05573895d941a06828c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thiago=20L=C3=B6pes?= Date: Fri, 26 Jun 2026 18:50:30 -0300 Subject: [PATCH 19/19] Delete apps/editor/components/north-compass-bridge.ts --- apps/editor/components/north-compass-bridge.ts | 1 - 1 file changed, 1 deletion(-) delete mode 100644 apps/editor/components/north-compass-bridge.ts diff --git a/apps/editor/components/north-compass-bridge.ts b/apps/editor/components/north-compass-bridge.ts deleted file mode 100644 index 8b1378917..000000000 --- a/apps/editor/components/north-compass-bridge.ts +++ /dev/null @@ -1 +0,0 @@ -