Skip to content

fix(editor): anchor walkthrough crosshair to the canvas, not the viewport#243

Open
b9llach wants to merge 1 commit intopascalorg:mainfrom
b9llach:fix/walkthrough-crosshair-canvas-rect
Open

fix(editor): anchor walkthrough crosshair to the canvas, not the viewport#243
b9llach wants to merge 1 commit intopascalorg:mainfrom
b9llach:fix/walkthrough-crosshair-canvas-rect

Conversation

@b9llach
Copy link
Copy Markdown
Contributor

@b9llach b9llach commented Apr 16, 2026

Problem

`FirstPersonOverlay`'s crosshair is wrapped in a `fixed inset-0` div that covers the whole browser viewport. The inner `flex items-center justify-center` then centres the crosshair on the viewport midpoint — but the actual R3F Canvas occupies only the right-hand region of the page, because the editor's v2 layout puts the sidebar on the left. Result: when you enter first-person mode the crosshair shows up noticeably off-centre in your actual view, sitting over the sidebar instead of the scene you're aiming at.

Reproduces on any editor layout where the Canvas is not flush against the left edge of the window (v2 layout, any split view, any scene with a sidebar visible).

Fix

Switch the crosshair wrapper from `fixed inset-0` to a fixed container whose `left` / `top` / `width` / `height` match the canvas's `getBoundingClientRect()`. A `ResizeObserver` on the canvas plus `resize` and `scroll` listeners keep the rect synced so the crosshair follows:

  • Sidebar collapses / expands
  • Panel toggles (preview mode, split view)
  • Window drags / browser resizes
  • Zoom changes

If the canvas hasn't been measured yet (first render before the effect runs), the wrapper falls back to `inset: 0` so the crosshair still appears — just full-screen-centred until the next frame. This avoids a visible flash of "no crosshair" at the moment first-person mode is entered.

The exit button (top-right) and controls hint (bottom-centre) are unchanged on purpose — they're edge-anchored chrome that should stay put regardless of sidebar state.

Scope

  • `FirstPersonOverlay` only — `FirstPersonControls` (camera/pointer-lock), the scene camera, movement, and pitch/yaw handling are unchanged.
  • One new hook + one new effect — `useState` for `canvasRect`, `useEffect` to subscribe to resize signals. ~25 lines net.
  • Canvas selector is `document.querySelector('canvas')` — the editor has a single live canvas in the DOM at a time; the thumbnail generator's canvas is offscreen-only and doesn't appear in the DOM, so the query reliably resolves to the main viewer canvas. If a future layout ever grows a second DOM canvas this will need to become more specific (e.g. a ref passed down from ``), but for now the simple selector works.

How to test

  1. `bun dev`, open any scene in the v2 editor layout with the sidebar visible.
  2. Click the walkthrough / first-person button in the viewer toolbar.
  3. Observe where the crosshair lands:
    • On `main`: over the sidebar (approximately at the viewport's horizontal centre), not over the scene.
    • This PR: over the centre of the viewer canvas, where your aim actually is.
  4. Collapse or expand the sidebar while in first-person mode — the crosshair should re-centre on the canvas after each layout change.
  5. Resize the browser window — the crosshair should follow.
  6. `bun check`, `bun check-types`, `bun run build` all clean.

Checklist

  • I've tested this locally with `bun dev`
  • My code follows the existing code style (`bun check` passes on the touched file — verified via `biome check` at `@biomejs/biome@^2.4.6`)
  • I've updated relevant documentation (N/A — no docs affected)
  • This PR targets the `main` branch

…port

`FirstPersonOverlay`'s crosshair was wrapped in a `fixed inset-0` div
that covers the whole browser viewport. The inner `flex items-center
justify-center` then centered the crosshair on the viewport midpoint
— but the actual R3F Canvas occupies only the right-hand region of
the page, because the editor's v2 layout puts the sidebar on the
left. Result: the crosshair shows up noticeably off-centre in the
user's actual first-person view, sitting over the sidebar instead of
the scene they're aiming at.

This switches the crosshair wrapper from `fixed inset-0` to a fixed
container whose `left` / `top` / `width` / `height` match the
canvas's `getBoundingClientRect()`. A `ResizeObserver` on the
canvas plus `resize` and `scroll` listeners keep the rect synced
so the crosshair tracks sidebar collapses, panel toggles, window
drags, and zoom. If the canvas hasn't been measured yet (first
render before the effect runs) the wrapper falls back to `inset: 0`
so the crosshair still appears, just full-screen-centred until the
next frame.

Selector is `document.querySelector('canvas')` — the editor has one
live canvas in the DOM at a time; the thumbnail generator's canvas
is offscreen-only and doesn't appear in the DOM, so the query
reliably resolves to the main viewer canvas. If a future layout
grows a second DOM canvas this will need to become more specific.

The exit button (top-right) and controls hint (bottom-centre) still
use their original `fixed`-edge positioning on purpose — they're
edge-anchored chrome that should stay put regardless of sidebar
state.
hellozzm pushed a commit to hellozzm/editor that referenced this pull request Apr 16, 2026
…alorg#220)

Sync monorepo PRs pascalorg#243 and pascalorg#248 to the open-source editor.

Move/Rotate Building:
- New move-building-tool with grid snapping, R/T rotation, and Esc cancel
- Floating building action menu with move button
- Building helper with keyboard shortcut hints
- BuildingRenderer now applies position and rotation from node data

Building-Relative Coordinate System:
- GridEvent gains localPosition (building-local coords)
- use-grid-events computes building-local intersection from building mesh
- ToolManager wraps building-relative tools in a building-local <group>
- All tools (wall, slab, ceiling, stair, roof, zone, polygon-editor,
  placement coordinator) updated to use event.localPosition
- Placement coordinator adds worldToBuildingLocal helper for cursor
  position conversion

2D Floorplan Fix:
- SVG layers wrapped in <g transform=rotate(...)> for building rotation
- Pointer-to-plan conversion un-rotates when building is rotated
- Grid events from 2D include localPosition

Store Changes:
- useEditor movingNode union includes BuildingNode
- NodeActionMenu onDelete is now optional (buildings can move but not delete)
import { useCallback, useEffect, useRef } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Euler, Vector3 } from 'three'
import useEditor from '../../store/use-editor'
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Race condition: update() is called synchronously before observer.observe(canvas) is set up. If ResizeObserver fires its initial callback immediately on observe() (permitted by the spec), update runs before observer is assigned — so observer.disconnect() in cleanup silently no-ops. Move observer.observe(canvas) to the line before update().

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants