* Add roof surface placement support for items
Items (e.g. solar panels) can now be placed on sloped roof surfaces.
The placement system computes euler rotation from the roof surface
normal so items sit flush on the slope instead of going inside.
- Add roofStrategy to placement-strategies with enter/move/click/leave
- Wire roof:enter/move/click/leave events in the placement coordinator
- Add calculateRoofRotation in placement-math using surface normals
- Support full 3D cursor rotation for sloped surfaces
- Items on roofs are parented to the level with world-space rotation
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fixed conflict
* feat(editor): 3D alignment guides for item/wall/fence move + placement
Bring Figma-style alignment guides into the 3D editor, reusing the shared
pure resolver (`resolveAlignment`) and ephemeral guide store
(`useAlignmentGuides`) that previously only drove the 2D floor plan.
Core:
- `alignment-anchors.ts`: node→anchor adapters (footprint AABBs, corner
anchors, wall/fence segment anchors) + `refineGuidesToGap` so a guide's
line and distance read to the candidate's nearest edge, not the far side.
- `bboxCornerAnchors` + corner-only footprint anchors so alignment locks to
item edges, never centrelines.
- `resolvePointSnap` (point-coincidence variant; kept for future use).
Editor:
- `Alignment3DGuideLayer`: dashed ribbon + flat floor dots + distance pill,
in the project's indigo accent, mounted inside ToolManager's building-local
group so guides render in the cursor's frame.
- Producers wired in the item move tool, item placement coordinator, and the
wall + fence endpoint tools; walls and fences cross-align.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(editor): align guides snap to nearest real anchor, drop bbox re-span
The 3D alignment guide could place its end dot in empty space: a diagonal
wall (or any rotated / non-rectangular object) has bounding-box corners that
don't lie on the object, and `refineGuidesToGap` re-spanned the guide to
exactly those AABB edges — so the dot floated "along the coordinate" rather
than on the item.
- `resolveAlignment` now tie-breaks to the candidate anchor NEAREST on the
perpendicular axis (after the tightest axis match). Anchors are real points
(corners / endpoints / midpoints), so the guide always connects to the
closest actual point — which also yields the facing-edge gap distance.
- All four producers (item move, item placement, wall + fence endpoints) now
publish the raw resolver guides; the AABB nearest-edge re-span is gone.
- Removed the now-dead `refineGuidesToGap` and `resolvePointSnap` helpers
(and their tests / exports).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(editor): group move handle + shared group-transform core, move-tool polish
Add a group-move gizmo alongside the existing group-rotate handle, both
driven by a new shared `group-transform-shared` module (participant
classification, group-box + corner math, connected wall/fence component
expansion so attached structure transforms rigidly as one piece).
- core: refactor alignment-anchors collection + tests, extend handle registry
- editor: group-move-handle, group-transform-shared; rotate handle reuses them;
node-arrow-handles gains click-swallow guard; box-select + placement tweaks
- nodes: move-tool updates across ceiling/column/slab/roof/registry; door math
and panel adjustments; item definition cleanup
- nodes(fence): play `sfx:grid-snap` ticker on endpoint move, matching the
wall endpoint tool (fixes missing audio feedback on fence side drag)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(editor): keep autosave alive across page unload
The autosave debounces writes by 1s and relies on a `beforeunload` flush
for anything still pending. That flush fired a plain `fetch` PUT, which
the browser cancels the instant the page unloads — so refreshing right
after an edit (e.g. painting a roof material) silently dropped the change
and the reload showed the last persisted scene.
Thread a `{ keepalive }` option through the save callback and set it on
the unload flush so the request survives the unload. Also listen for
`pagehide` (fires where `beforeunload` does not, e.g. mobile Safari /
bfcache) and clear the dirty flag up front so the two listeners don't
double-send. Normal debounced saves omit `keepalive` (its 64KB body cap
only constrains the best-effort unload flush, not regular saves).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(editor): paint eraser + reset-all, drop roof cross-role bleed
Material paint gains an eraser (clear a surface back to its default) and a
"Reset all" action that defaults every painted surface on a node — for a
roof that includes each child segment — via a generic
`buildResetSurfaceMaterialUpdates` that nulls catch-all and role-specific
material fields without per-kind knowledge.
Also stop a single painted roof surface from bleeding onto the others:
`getEffectiveRoofSurfaceMaterial`, `getRoofMaterialArray`, and the segment
renderer no longer cross-fall-back between top/edge/wall. An unset role
resolves only to the legacy catch-all (back-compat) or the theme default,
so painting just the shingle, trim, or soffit stays on that surface.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(editor): alignment guides + drag bounding box across tools
Extend the Figma-style 3D alignment guides to the placement and move
tools for columns, elevators, roofs, stairs, ceilings, slabs, fences,
walls, doors, and windows: each collects alignment anchors from the
scene, resolves a snap within the shared threshold, and drives the
`useAlignmentGuides` overlay. Wall openings (doors/windows) only snap
along their host wall via the new `wall-opening-alignment` helper.
Add a shared `DragBoundingBox` overlay (exported from the editor barrel)
that renders the dragged object's bounds during a move, wired into the
column move tool alongside the alignment snap.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(editor): axis-stable resize-arrow drag plane + slimmer gizmo handles
Build the linear resize-arrow's drag plane so it always contains the
handle's axis (view direction minus its along-axis component) instead of
a plane that merely faces the camera. The old camera-facing normal
collapsed when the axis pointed toward the viewer — screen motion barely
changed the axis component, so the resize crawled or stopped tracking the
cursor. Also slim the extruded arrow/handle geometry (shared by the node
arrows, wall side handles, and polygon editor) for a lighter gizmo.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(editor): fill-block wall opening highlight + selected frameless openings
Draw the selected-wall opening highlight as a translucent block filling
the cutout volume (front-side culled) instead of a single vertical pane,
so it reads as an occupied slot from any angle — including a top-down
floorplan view where an edge-on pane was invisible. Also highlight a
directly-selected frameless opening (a `door` with openingKind
`'opening'`), which otherwise renders no geometry of its own, and reflect
live drag overrides via `useLiveNodeOverrides`.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(viewer): double-side slab hole side-walls
Build the slab in 3D rather than via ExtrudeGeometry so each hole-wall
quad is emitted twice with opposite winding. The slab material is forced
to FrontSide (DoubleSide poisons the MRT scene pass), under which
ExtrudeGeometry's single-sided hole walls get back-face culled and you
see straight through the cut. The doubled quads keep the cut's inner
thickness visible from any angle without z-fighting.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(editor): box-select building-scoped nodes like elevators
Building-scoped selectable nodes (e.g. elevators) are children of the
building, not the active level, so the level walk never reached them.
Also walk the level's building children and box-test any registry-
selectable kind by its rendered bounds, matching the column/stair/shelf
path.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(editor): shelf placement alignment + column placement ghost
Shelf placement now snaps to Figma-style alignment guides by its
footprint edges (layered on grid snap, Alt bypasses), matching the
existing 3D move tool. Guides refresh after each drop and clear on
teardown.
Column placement migrates to the registry `def.tool` path so it can
render a translucent column ghost at the cursor (like the shelf build
tool) instead of a bare cursor sphere — the editor package can't import
the column geometry, so the tool now lives in packages/nodes:
- extract `ColumnBody` from the renderer and add a `ColumnPreview`
(cloned translucent material, raycast disabled, origin-positioned)
- new `column/tool.tsx` registry placement tool with the same
footprint-edge alignment as shelf / column move
- wire `def.tool` + tool hints; drop the now-unreachable legacy
editor-side `ColumnTool` and its dead file
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(editor): floor-plan alignment, pivot moves & placement ghosts
Bring the 3D editor's Figma-style alignment experience to the 2D floor
plan across every node kind, fix pivot semantics on move, and add 2D
placement ghosts.
Alignment
- Wall anchors now include ±thickness/2 face corners so columns/items/etc.
snap flush to wall faces (fixes pillar↔wall); shared by 2D and 3D.
- Shared apply-alignment helper (applyFloorplanAlignment /
alignFloorplanDraftPoint, with excludeIds) used by move sessions,
structural drafting (wall/fence/slab/zone/ceiling/roof), and wall/fence
endpoint drags.
- Door/window/wall-item moves get along-wall edge-to-edge snapping.
- Generic free-translate move path aligns by edges (corner anchors).
Pivot moves (2D)
- Polygon kinds (slab/ceiling/zone) move by centroid→cursor via a shared
polygon-centroid mover; stair moves by origin→cursor; matching 3D.
- Shelf/column move targets write position directly (single source of
truth) so the 3D group no longer sticks on commit.
Placement ghosts (2D)
- usePlacementPreview store + FloorplanPlacementPreviewLayer render a
kind's def.floorplan footprint following the cursor; wired for column
and elevator.
Fixes
- Elevator placement no longer deselects the active floor plan
(preserve levelId through setSelection's hierarchy guard).
- Guides clear on every commit/cancel/unmount path.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(editor): address architecture review for floor-plan work
- Move usePlacementPreview store from core to editor: placement ghosts
are an editor/tool concern the read-only viewer never needs. Rewire the
column tool (via the @pascal-app/editor public surface) and the
editor-internal elevator tool + preview layer (relative imports).
- FloorplanPlacementPreviewLayer: read scene lazily in ctx.resolve instead
of bulk-reading the nodes map during render.
- wiki/architecture/tools.md: refresh the stale useLiveTransforms-per-kind
note to reflect item/shelf/column (world-plan) + slab/ceiling/zone (delta).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(nodes): recreate window draft on wall:move when null after placement
After a successful click-to-place, the click handler deletes the
transient draft and relies on the wall-rebuild → R3F pointer-enter
cascade to create a fresh draft for the next placement. If that cascade
doesn't fire synchronously (e.g. async geometry rebuild) the next
wall:move receives a null draftRef and bails — requiring leave/re-enter
to place again.
Fix: in onWallMove, when draftRef.current is null but we're hovering a
valid wall, recreate the draft immediately (same WindowNode.parse +
createNode path as onWallEnter). This is idempotent: if wall:enter does
fire first, destroyDraft() in onWallEnter cleans up cleanly.
Preserves parity with door multi-place behaviour, matching #367's intent.
* fix(nodes): recreate door draft on wall:move when null after placement
Mirror of the window fix one commit back: after click-to-place the
DoorTool deletes its transient draft and relies on the wall-rebuild \u2192
R3F pointer-enter cascade to spawn a fresh draft for the next placement.
When that cascade doesn't fire synchronously, the next wall:move sees a
null draftRef and bails \u2014 forcing a leave/re-enter to place again.
Recreate the draft in onWallMove when null and over a valid wall on the
current level. Idempotent with onWallEnter (destroyDraft cleans up if
both fire).
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Pascal <open@pascal.app>
701 lines
23 KiB
TypeScript
701 lines
23 KiB
TypeScript
import '../../../three-types'
|
||
|
||
import { Icon } from '@iconify/react'
|
||
import {
|
||
type AnyNodeId,
|
||
type CeilingNode,
|
||
type ColumnNode,
|
||
emitter,
|
||
type GridEvent,
|
||
type ItemNode,
|
||
isRegistrySelectable,
|
||
type LevelNode,
|
||
resolveBuildingForLevel,
|
||
type SlabNode,
|
||
sceneRegistry,
|
||
useScene,
|
||
type WallNode,
|
||
type ZoneNode,
|
||
} from '@pascal-app/core'
|
||
import { useViewer } from '@pascal-app/viewer'
|
||
import type { ThreeElements } from '@react-three/fiber'
|
||
import { useThree } from '@react-three/fiber'
|
||
import { useEffect, useRef } from 'react'
|
||
import {
|
||
Box3,
|
||
BufferAttribute,
|
||
BufferGeometry,
|
||
DoubleSide,
|
||
type Group,
|
||
LineBasicMaterial,
|
||
LineSegments,
|
||
type Mesh,
|
||
Plane,
|
||
Raycaster,
|
||
Vector2,
|
||
Vector3,
|
||
} from 'three'
|
||
import { EDITOR_LAYER } from '../../../lib/constants'
|
||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||
import useEditor from '../../../store/use-editor'
|
||
import { CursorSphere } from '../shared/cursor-sphere'
|
||
|
||
declare module 'react/jsx-runtime' {
|
||
namespace JSX {
|
||
interface IntrinsicElements extends ThreeElements {}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Module-level flag to prevent the SelectionManager from deselecting
|
||
* on the grid:click that fires right after a box-select drag completes.
|
||
*/
|
||
export let boxSelectHandled = false
|
||
|
||
// ── Geometry helpers ────────────────────────────────────────────────────────
|
||
|
||
type Bounds = { minX: number; maxX: number; minZ: number; maxZ: number }
|
||
|
||
function pointInBounds(x: number, z: number, b: Bounds): boolean {
|
||
return x >= b.minX && x <= b.maxX && z >= b.minZ && z <= b.maxZ
|
||
}
|
||
|
||
function segmentsIntersect(
|
||
ax1: number,
|
||
az1: number,
|
||
ax2: number,
|
||
az2: number,
|
||
bx1: number,
|
||
bz1: number,
|
||
bx2: number,
|
||
bz2: number,
|
||
): boolean {
|
||
const d1 = cross(bx1, bz1, bx2, bz2, ax1, az1)
|
||
const d2 = cross(bx1, bz1, bx2, bz2, ax2, az2)
|
||
const d3 = cross(ax1, az1, ax2, az2, bx1, bz1)
|
||
const d4 = cross(ax1, az1, ax2, az2, bx2, bz2)
|
||
|
||
if (((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) && ((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0))) {
|
||
return true
|
||
}
|
||
|
||
if (d1 === 0 && onSeg(bx1, bz1, bx2, bz2, ax1, az1)) return true
|
||
if (d2 === 0 && onSeg(bx1, bz1, bx2, bz2, ax2, az2)) return true
|
||
if (d3 === 0 && onSeg(ax1, az1, ax2, az2, bx1, bz1)) return true
|
||
if (d4 === 0 && onSeg(ax1, az1, ax2, az2, bx2, bz2)) return true
|
||
|
||
return false
|
||
}
|
||
|
||
function cross(ax: number, az: number, bx: number, bz: number, cx: number, cz: number): number {
|
||
return (bx - ax) * (cz - az) - (bz - az) * (cx - ax)
|
||
}
|
||
|
||
function onSeg(ax: number, az: number, bx: number, bz: number, cx: number, cz: number): boolean {
|
||
return (
|
||
Math.min(ax, bx) <= cx &&
|
||
cx <= Math.max(ax, bx) &&
|
||
Math.min(az, bz) <= cz &&
|
||
cz <= Math.max(az, bz)
|
||
)
|
||
}
|
||
|
||
function segmentIntersectsBounds(
|
||
x1: number,
|
||
z1: number,
|
||
x2: number,
|
||
z2: number,
|
||
b: Bounds,
|
||
): boolean {
|
||
if (pointInBounds(x1, z1, b) || pointInBounds(x2, z2, b)) return true
|
||
|
||
const edges: [number, number, number, number][] = [
|
||
[b.minX, b.minZ, b.maxX, b.minZ],
|
||
[b.maxX, b.minZ, b.maxX, b.maxZ],
|
||
[b.maxX, b.maxZ, b.minX, b.maxZ],
|
||
[b.minX, b.maxZ, b.minX, b.minZ],
|
||
]
|
||
for (const [ex1, ez1, ex2, ez2] of edges) {
|
||
if (segmentsIntersect(x1, z1, x2, z2, ex1, ez1, ex2, ez2)) return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
function polygonIntersectsBounds(polygon: [number, number][], b: Bounds): boolean {
|
||
if (polygon.some(([x, z]) => pointInBounds(x, z, b))) return true
|
||
|
||
const corners: [number, number][] = [
|
||
[b.minX, b.minZ],
|
||
[b.maxX, b.minZ],
|
||
[b.maxX, b.maxZ],
|
||
[b.minX, b.maxZ],
|
||
]
|
||
if (corners.some(([cx, cz]) => pointInPolygon(cx, cz, polygon))) return true
|
||
|
||
const edges: [number, number, number, number][] = [
|
||
[b.minX, b.minZ, b.maxX, b.minZ],
|
||
[b.maxX, b.minZ, b.maxX, b.maxZ],
|
||
[b.maxX, b.maxZ, b.minX, b.maxZ],
|
||
[b.minX, b.maxZ, b.minX, b.minZ],
|
||
]
|
||
for (let i = 0; i < polygon.length; i++) {
|
||
const [px1, pz1] = polygon[i]!
|
||
const [px2, pz2] = polygon[(i + 1) % polygon.length]!
|
||
for (const [ex1, ez1, ex2, ez2] of edges) {
|
||
if (segmentsIntersect(px1, pz1, px2, pz2, ex1, ez1, ex2, ez2)) return true
|
||
}
|
||
}
|
||
|
||
return false
|
||
}
|
||
|
||
function pointInPolygon(x: number, z: number, polygon: [number, number][]): boolean {
|
||
let inside = false
|
||
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
|
||
const [xi, zi] = polygon[i]!
|
||
const [xj, zj] = polygon[j]!
|
||
if (zi > z !== zj > z && x < ((xj - xi) * (z - zi)) / (zj - zi) + xi) {
|
||
inside = !inside
|
||
}
|
||
}
|
||
return inside
|
||
}
|
||
|
||
// ── Node-in-bounds checks ───────────────────────────────────────────────────
|
||
|
||
const _tempVec = new Vector3()
|
||
const _tempBox = new Box3()
|
||
|
||
function getNodeWorldXZ(nodeId: string): [number, number] | null {
|
||
const obj = sceneRegistry.nodes.get(nodeId)
|
||
if (!obj) return null
|
||
obj.getWorldPosition(_tempVec)
|
||
return [_tempVec.x, _tempVec.z]
|
||
}
|
||
|
||
function objectBoundsIntersectsBounds(nodeId: string, bounds: Bounds): boolean {
|
||
const obj = sceneRegistry.nodes.get(nodeId)
|
||
if (!obj) return false
|
||
|
||
obj.updateWorldMatrix(true, true)
|
||
_tempBox.setFromObject(obj)
|
||
|
||
if (_tempBox.isEmpty()) {
|
||
const xz = getNodeWorldXZ(nodeId)
|
||
return Boolean(xz && pointInBounds(xz[0], xz[1], bounds))
|
||
}
|
||
|
||
return !(
|
||
_tempBox.max.x < bounds.minX ||
|
||
_tempBox.min.x > bounds.maxX ||
|
||
_tempBox.max.z < bounds.minZ ||
|
||
_tempBox.min.z > bounds.maxZ
|
||
)
|
||
}
|
||
|
||
/**
|
||
* Collect the ids of selectable nodes on the active level. When `bounds` is a
|
||
* rectangle, only nodes intersecting it are returned (a box-select hit-test).
|
||
* When `bounds` is `null`, every selectable node on the level is returned —
|
||
* the full set the box-select could ever capture — which lets the caller tell
|
||
* whether a drag selected the entire level.
|
||
*/
|
||
function collectNodeIdsInBounds(bounds: Bounds | null): string[] {
|
||
const { levelId } = useViewer.getState().selection
|
||
const { nodes } = useScene.getState()
|
||
const { phase, structureLayer } = useEditor.getState()
|
||
|
||
if (!levelId) return []
|
||
const levelNode = nodes[levelId] as LevelNode | undefined
|
||
if (!levelNode || levelNode.type !== 'level') return []
|
||
|
||
const result: string[] = []
|
||
|
||
if (phase === 'structure' && structureLayer === 'zones') {
|
||
for (const childId of levelNode.children) {
|
||
const node = nodes[childId as AnyNodeId]
|
||
if (!node || node.type !== 'zone') continue
|
||
const zone = node as ZoneNode
|
||
if (!bounds || polygonIntersectsBounds(zone.polygon, bounds)) {
|
||
result.push(zone.id)
|
||
}
|
||
}
|
||
} else {
|
||
// structure (elements) and furnish: collect all node types
|
||
for (const childId of levelNode.children) {
|
||
const node = nodes[childId as AnyNodeId]
|
||
if (!node) continue
|
||
|
||
if (node.type === 'wall' || node.type === 'fence') {
|
||
const wall = node as WallNode
|
||
if (
|
||
!bounds ||
|
||
segmentIntersectsBounds(wall.start[0], wall.start[1], wall.end[0], wall.end[1], bounds)
|
||
) {
|
||
result.push(wall.id)
|
||
}
|
||
// Check wall children (doors/windows)
|
||
for (const itemId of Array.isArray(wall.children) ? wall.children : []) {
|
||
const child = nodes[itemId as AnyNodeId]
|
||
if (!child) continue
|
||
if (
|
||
child.type === 'window' ||
|
||
child.type === 'door' ||
|
||
(child.type === 'item' &&
|
||
((child as ItemNode).asset.category === 'door' ||
|
||
(child as ItemNode).asset.category === 'window'))
|
||
) {
|
||
const xz = getNodeWorldXZ(child.id)
|
||
if (!bounds || (xz && pointInBounds(xz[0], xz[1], bounds))) {
|
||
result.push(child.id)
|
||
}
|
||
}
|
||
}
|
||
} else if (node.type === 'slab') {
|
||
const slab = node as SlabNode
|
||
if (!bounds || polygonIntersectsBounds(slab.polygon, bounds)) {
|
||
result.push(slab.id)
|
||
}
|
||
} else if (node.type === 'ceiling') {
|
||
const ceiling = node as CeilingNode
|
||
if (!bounds || polygonIntersectsBounds(ceiling.polygon, bounds)) {
|
||
result.push(ceiling.id)
|
||
}
|
||
} else if (node.type === 'roof') {
|
||
const xz = getNodeWorldXZ(node.id)
|
||
if (!bounds || (xz && pointInBounds(xz[0], xz[1], bounds))) {
|
||
result.push(node.id)
|
||
}
|
||
} else if (node.type === 'stair') {
|
||
if (!bounds || objectBoundsIntersectsBounds(node.id, bounds)) {
|
||
result.push(node.id)
|
||
}
|
||
} else if (node.type === 'column') {
|
||
const column = node as ColumnNode
|
||
if (!bounds || objectBoundsIntersectsBounds(column.id, bounds)) {
|
||
result.push(column.id)
|
||
}
|
||
} else if (node.type === 'item') {
|
||
const item = node as ItemNode
|
||
if (item.asset.category === 'door' || item.asset.category === 'window') continue
|
||
const xz = getNodeWorldXZ(item.id)
|
||
if (!bounds || (xz && pointInBounds(xz[0], xz[1], bounds))) {
|
||
result.push(item.id)
|
||
}
|
||
} else if (isRegistrySelectable(node.type)) {
|
||
// Registry-driven selectable kinds (shelf + future furnish/structure
|
||
// kinds) aren't in the hardcoded list above; pick them up by their
|
||
// rendered bounding box, the same path column/stair use.
|
||
if (!bounds || objectBoundsIntersectsBounds(node.id, bounds)) {
|
||
result.push(node.id)
|
||
}
|
||
}
|
||
}
|
||
|
||
// Building-scoped selectable nodes (e.g. elevator) are siblings of the
|
||
// level — children of the building, not the level — so the loop above
|
||
// never reaches them. Walk the active level's building children and
|
||
// box-test any registry-selectable kind by its rendered bounds, the same
|
||
// path column/stair/shelf use.
|
||
const buildingId = resolveBuildingForLevel(levelId as AnyNodeId, nodes)
|
||
const buildingNode = buildingId ? nodes[buildingId] : undefined
|
||
const buildingChildren =
|
||
buildingNode && 'children' in buildingNode && Array.isArray(buildingNode.children)
|
||
? (buildingNode.children as AnyNodeId[])
|
||
: []
|
||
for (const childId of buildingChildren) {
|
||
const node = nodes[childId]
|
||
if (!node || node.type === 'level' || !isRegistrySelectable(node.type)) continue
|
||
if (!bounds || objectBoundsIntersectsBounds(node.id, bounds)) {
|
||
result.push(node.id)
|
||
}
|
||
}
|
||
}
|
||
|
||
return result
|
||
}
|
||
|
||
function haveSameIds(currentIds: string[], nextIds: string[]): boolean {
|
||
return (
|
||
currentIds.length === nextIds.length &&
|
||
currentIds.every((currentId, index) => currentId === nextIds[index])
|
||
)
|
||
}
|
||
|
||
// ── Visual helpers ──────────────────────────────────────────────────────────
|
||
|
||
function updateRectVisuals(
|
||
fillMesh: Mesh,
|
||
outline: LineSegments,
|
||
start: Vector3,
|
||
end: Vector3,
|
||
y: number,
|
||
) {
|
||
const cx = (start.x + end.x) / 2
|
||
const cz = (start.z + end.z) / 2
|
||
const w = Math.abs(end.x - start.x)
|
||
const h = Math.abs(end.z - start.z)
|
||
|
||
if (w < 0.01 && h < 0.01) {
|
||
fillMesh.visible = false
|
||
outline.visible = false
|
||
return
|
||
}
|
||
|
||
// Fill rect (unit plane scaled)
|
||
fillMesh.visible = true
|
||
fillMesh.position.set(cx, y + 0.02, cz)
|
||
fillMesh.scale.set(w, h, 1)
|
||
|
||
// Outline — 4 edges as line segment pairs (8 vertices)
|
||
outline.visible = true
|
||
const oy = y + 0.03
|
||
const x0 = cx - w / 2
|
||
const x1 = cx + w / 2
|
||
const z0 = cz - h / 2
|
||
const z1 = cz + h / 2
|
||
const pos = outline.geometry.attributes.position as BufferAttribute
|
||
// bottom: (x0,z0)→(x1,z0)
|
||
pos.setXYZ(0, x0, oy, z0)
|
||
pos.setXYZ(1, x1, oy, z0)
|
||
// right: (x1,z0)→(x1,z1)
|
||
pos.setXYZ(2, x1, oy, z0)
|
||
pos.setXYZ(3, x1, oy, z1)
|
||
// top: (x1,z1)→(x0,z1)
|
||
pos.setXYZ(4, x1, oy, z1)
|
||
pos.setXYZ(5, x0, oy, z1)
|
||
// left: (x0,z1)→(x0,z0)
|
||
pos.setXYZ(6, x0, oy, z1)
|
||
pos.setXYZ(7, x0, oy, z0)
|
||
pos.needsUpdate = true
|
||
}
|
||
|
||
// ── Outline geometry (allocated once, reused) ───────────────────────────────
|
||
|
||
function createOutlineSegments(): LineSegments {
|
||
const geo = new BufferGeometry()
|
||
// 4 edges × 2 vertices each = 8 vertices
|
||
const positions = new Float32Array(8 * 3)
|
||
geo.setAttribute('position', new BufferAttribute(positions, 3))
|
||
|
||
const mat = new LineBasicMaterial({
|
||
color: BOX_SELECT_ACCENT_COLOR,
|
||
depthTest: false,
|
||
depthWrite: false,
|
||
transparent: true,
|
||
opacity: 0.85,
|
||
})
|
||
|
||
const segments = new LineSegments(geo, mat)
|
||
segments.layers.set(EDITOR_LAYER)
|
||
segments.renderOrder = 2
|
||
segments.visible = false
|
||
segments.frustumCulled = false
|
||
|
||
return segments
|
||
}
|
||
|
||
// ── Drag threshold (pixels) ─────────────────────────────────────────────────
|
||
|
||
const BOX_SELECT_ACCENT_COLOR = '#818cf8'
|
||
const DRAG_THRESHOLD_PX = 4
|
||
|
||
function getSnappedGridPosition(x: number, z: number): [number, number] {
|
||
return [Math.round(x * 2) / 2, Math.round(z * 2) / 2]
|
||
}
|
||
|
||
function setSnappedPoint(target: Vector3, x: number, y: number, z: number) {
|
||
const [snappedX, snappedZ] = getSnappedGridPosition(x, z)
|
||
target.set(snappedX, y, snappedZ)
|
||
}
|
||
|
||
// ── Component ───────────────────────────────────────────────────────────────
|
||
|
||
export const BoxSelectTool: React.FC = () => {
|
||
const mode = useEditor((s) => s.mode)
|
||
const selectionTool = useEditor((s) => s.floorplanSelectionTool)
|
||
const isActive = mode === 'select' && selectionTool === 'marquee'
|
||
|
||
if (!isActive) return null
|
||
|
||
return <BoxSelectToolInner />
|
||
}
|
||
|
||
const BOX_SELECT_TOOLTIP = (
|
||
<Icon
|
||
color="currentColor"
|
||
height={24}
|
||
icon="mdi:select-drag"
|
||
style={{ filter: 'drop-shadow(0px 2px 4px rgba(0,0,0,0.5))' }}
|
||
width={24}
|
||
/>
|
||
)
|
||
|
||
const BoxSelectToolInner: React.FC = () => {
|
||
const { camera, gl } = useThree()
|
||
const setPreviewSelectedIds = useViewer((state) => state.setPreviewSelectedIds)
|
||
const cursorRef = useRef<Group>(null)
|
||
const rectFillRef = useRef<Mesh>(null!)
|
||
const outlineRef = useRef(createOutlineSegments())
|
||
const startPoint = useRef(new Vector3())
|
||
const currentPoint = useRef(new Vector3())
|
||
const pointerDown = useRef(false)
|
||
const isDragging = useRef(false)
|
||
const startClientX = useRef(0)
|
||
const startClientY = useRef(0)
|
||
const gridY = useRef(0)
|
||
const previousGridPosition = useRef<[number, number] | null>(null)
|
||
const previewSelectedIdsRef = useRef<string[]>([])
|
||
|
||
// Raycasting helpers (same technique as useGridEvents)
|
||
const raycasterRef = useRef(new Raycaster())
|
||
const pointerNDC = useRef(new Vector2())
|
||
const groundPlane = useRef(new Plane(new Vector3(0, 1, 0), 0))
|
||
const hitPoint = useRef(new Vector3())
|
||
|
||
// Cleanup outline geometry on unmount
|
||
useEffect(() => {
|
||
const outline = outlineRef.current
|
||
return () => {
|
||
previewSelectedIdsRef.current = []
|
||
setPreviewSelectedIds([])
|
||
outline.geometry.dispose()
|
||
;(outline.material as LineBasicMaterial).dispose()
|
||
}
|
||
}, [setPreviewSelectedIds])
|
||
|
||
const syncPreviewSelectedIds = (nextIds: string[]) => {
|
||
if (haveSameIds(previewSelectedIdsRef.current, nextIds)) {
|
||
return
|
||
}
|
||
|
||
previewSelectedIdsRef.current = nextIds
|
||
setPreviewSelectedIds(nextIds)
|
||
}
|
||
|
||
// Sync ground plane Y with the current level
|
||
useEffect(() => {
|
||
const unsubscribe = useViewer.subscribe((state) => {
|
||
const levelId = state.selection.levelId
|
||
if (!levelId) return
|
||
const obj = sceneRegistry.nodes.get(levelId)
|
||
if (obj) groundPlane.current.constant = -obj.position.y
|
||
})
|
||
// Set initial value
|
||
const levelId = useViewer.getState().selection.levelId
|
||
if (levelId) {
|
||
const obj = sceneRegistry.nodes.get(levelId)
|
||
if (obj) groundPlane.current.constant = -obj.position.y
|
||
}
|
||
return unsubscribe
|
||
}, [])
|
||
|
||
const raycastToGround = (e: PointerEvent): Vector3 | null => {
|
||
const rect = gl.domElement.getBoundingClientRect()
|
||
pointerNDC.current.x = ((e.clientX - rect.left) / rect.width) * 2 - 1
|
||
pointerNDC.current.y = -((e.clientY - rect.top) / rect.height) * 2 + 1
|
||
raycasterRef.current.setFromCamera(pointerNDC.current, camera)
|
||
if (raycasterRef.current.ray.intersectPlane(groundPlane.current, hitPoint.current)) {
|
||
return hitPoint.current
|
||
}
|
||
return null
|
||
}
|
||
|
||
useEffect(() => {
|
||
const canvas = gl.domElement
|
||
|
||
const onCanvasPointerDown = (e: PointerEvent) => {
|
||
if (e.button !== 0) return
|
||
if (useViewer.getState().cameraDragging) return
|
||
// A gizmo/handle drag is underway (group rotate/move, resize arrows, …).
|
||
// Those use raw window listeners too, so without this guard box-select
|
||
// would run in parallel and clobber the selection on release.
|
||
if (useViewer.getState().inputDragging) return
|
||
|
||
const point = raycastToGround(e)
|
||
if (!point) return
|
||
|
||
setSnappedPoint(startPoint.current, point.x, point.y, point.z)
|
||
setSnappedPoint(currentPoint.current, point.x, point.y, point.z)
|
||
gridY.current = point.y
|
||
pointerDown.current = true
|
||
isDragging.current = false
|
||
previousGridPosition.current = getSnappedGridPosition(point.x, point.z)
|
||
startClientX.current = e.clientX
|
||
startClientY.current = e.clientY
|
||
syncPreviewSelectedIds([])
|
||
}
|
||
|
||
const onCanvasPointerUp = (e: PointerEvent) => {
|
||
if (e.button !== 0) return
|
||
// If a gizmo/handle drag is in progress, don't let box-select replace the
|
||
// selection. Canvas listeners fire before the gizmo's window pointer-up
|
||
// (which clears `inputDragging`), so this still reads true here. Reset our
|
||
// own state and bail.
|
||
if (useViewer.getState().inputDragging) {
|
||
pointerDown.current = false
|
||
isDragging.current = false
|
||
if (rectFillRef.current) rectFillRef.current.visible = false
|
||
if (outlineRef.current) outlineRef.current.visible = false
|
||
syncPreviewSelectedIds([])
|
||
return
|
||
}
|
||
if (!pointerDown.current) return
|
||
|
||
if (isDragging.current) {
|
||
const point = raycastToGround(e)
|
||
if (point) setSnappedPoint(currentPoint.current, point.x, point.y, point.z)
|
||
|
||
const bounds: Bounds = {
|
||
minX: Math.min(startPoint.current.x, currentPoint.current.x),
|
||
maxX: Math.max(startPoint.current.x, currentPoint.current.x),
|
||
minZ: Math.min(startPoint.current.z, currentPoint.current.z),
|
||
maxZ: Math.max(startPoint.current.z, currentPoint.current.z),
|
||
}
|
||
|
||
const ids = collectNodeIdsInBounds(bounds)
|
||
|
||
const shouldAppend = e.metaKey || e.ctrlKey
|
||
const { phase, structureLayer } = useEditor.getState()
|
||
|
||
if (phase === 'structure' && structureLayer === 'zones') {
|
||
if (ids.length > 0) {
|
||
useViewer.getState().setSelection({ zoneId: ids[0] as ZoneNode['id'] })
|
||
} else if (!shouldAppend) {
|
||
useViewer.getState().setSelection({ zoneId: null })
|
||
}
|
||
} else if (shouldAppend) {
|
||
const currentIds = useViewer.getState().selection.selectedIds
|
||
const merged = Array.from(new Set([...currentIds, ...ids]))
|
||
useViewer.getState().setSelection({ selectedIds: merged })
|
||
} else {
|
||
// If the box captured every selectable node on the level, promote to
|
||
// selecting the parent building — same as clicking it in the side menu.
|
||
const allOnLevel = collectNodeIdsInBounds(null)
|
||
const { buildingId } = useViewer.getState().selection
|
||
const selectedEntireLevel = allOnLevel.length > 0 && ids.length === allOnLevel.length
|
||
|
||
if (selectedEntireLevel && buildingId) {
|
||
useViewer.getState().setSelection({ buildingId })
|
||
} else {
|
||
useViewer.getState().setSelection({ selectedIds: ids })
|
||
}
|
||
}
|
||
|
||
// Prevent the subsequent grid:click from deselecting
|
||
boxSelectHandled = true
|
||
setTimeout(() => {
|
||
boxSelectHandled = false
|
||
}, 50)
|
||
}
|
||
// NOTE: Short clicks (no drag) fall through to the SelectionManager's
|
||
// existing grid:click / node:click handlers — no extra logic needed here.
|
||
|
||
// Hide visuals
|
||
if (rectFillRef.current) rectFillRef.current.visible = false
|
||
if (outlineRef.current) outlineRef.current.visible = false
|
||
syncPreviewSelectedIds([])
|
||
|
||
// Reset
|
||
pointerDown.current = false
|
||
isDragging.current = false
|
||
}
|
||
|
||
canvas.addEventListener('pointerdown', onCanvasPointerDown)
|
||
canvas.addEventListener('pointerup', onCanvasPointerUp)
|
||
|
||
return () => {
|
||
canvas.removeEventListener('pointerdown', onCanvasPointerDown)
|
||
canvas.removeEventListener('pointerup', onCanvasPointerUp)
|
||
}
|
||
}, [gl, raycastToGround, syncPreviewSelectedIds])
|
||
|
||
// grid:move for cursor tracking + rectangle update during drag
|
||
useEffect(() => {
|
||
const onMove = (event: GridEvent) => {
|
||
const [snappedX, snappedZ] = getSnappedGridPosition(event.position[0], event.position[2])
|
||
|
||
// Always update cursor position
|
||
if (cursorRef.current) {
|
||
cursorRef.current.position.set(snappedX, event.position[1], snappedZ)
|
||
}
|
||
|
||
if (!pointerDown.current) return
|
||
// A gizmo/handle drag took over — don't draw a selection box underneath it.
|
||
if (useViewer.getState().inputDragging) return
|
||
|
||
currentPoint.current.set(snappedX, event.position[1], snappedZ)
|
||
|
||
// Check drag threshold (screen pixels)
|
||
const nativeEvent = event.nativeEvent as unknown as PointerEvent
|
||
const dx = nativeEvent.clientX - startClientX.current
|
||
const dy = nativeEvent.clientY - startClientY.current
|
||
if (!isDragging.current && Math.hypot(dx, dy) >= DRAG_THRESHOLD_PX) {
|
||
isDragging.current = true
|
||
}
|
||
|
||
if (isDragging.current && rectFillRef.current && outlineRef.current) {
|
||
updateRectVisuals(
|
||
rectFillRef.current,
|
||
outlineRef.current,
|
||
startPoint.current,
|
||
currentPoint.current,
|
||
gridY.current,
|
||
)
|
||
|
||
const nextGridPosition: [number, number] = [snappedX, snappedZ]
|
||
if (
|
||
previousGridPosition.current &&
|
||
(nextGridPosition[0] !== previousGridPosition.current[0] ||
|
||
nextGridPosition[1] !== previousGridPosition.current[1])
|
||
) {
|
||
sfxEmitter.emit('sfx:grid-snap')
|
||
}
|
||
previousGridPosition.current = nextGridPosition
|
||
|
||
const bounds: Bounds = {
|
||
minX: Math.min(startPoint.current.x, currentPoint.current.x),
|
||
maxX: Math.max(startPoint.current.x, currentPoint.current.x),
|
||
minZ: Math.min(startPoint.current.z, currentPoint.current.z),
|
||
maxZ: Math.max(startPoint.current.z, currentPoint.current.z),
|
||
}
|
||
syncPreviewSelectedIds(collectNodeIdsInBounds(bounds))
|
||
}
|
||
}
|
||
|
||
emitter.on('grid:move', onMove)
|
||
return () => {
|
||
emitter.off('grid:move', onMove)
|
||
}
|
||
}, [syncPreviewSelectedIds])
|
||
|
||
return (
|
||
<group>
|
||
{/* Cursor indicator */}
|
||
<CursorSphere ref={cursorRef} tooltipContent={BOX_SELECT_TOOLTIP} />
|
||
|
||
{/* Selection rectangle fill */}
|
||
<mesh
|
||
layers={EDITOR_LAYER}
|
||
ref={rectFillRef}
|
||
renderOrder={1}
|
||
rotation={[-Math.PI / 2, 0, 0]}
|
||
visible={false}
|
||
>
|
||
<planeGeometry args={[1, 1]} />
|
||
<meshBasicMaterial
|
||
color={BOX_SELECT_ACCENT_COLOR}
|
||
depthTest={false}
|
||
depthWrite={false}
|
||
opacity={0.14}
|
||
side={DoubleSide}
|
||
transparent
|
||
/>
|
||
</mesh>
|
||
|
||
{/* Outline (LineLoop added as primitive — allocated once in ref) */}
|
||
<primitive object={outlineRef.current} />
|
||
</group>
|
||
)
|
||
}
|