feat(editor): unify placement/move facing triangle into one editor-side renderer
Every placement and move path now publishes its ghost pose to a single
`useFacingPose` store, drawn by one editor-side `<FacingPoseIndicator>` overlay,
instead of each path drawing its own triangle (which left the nodes-package and
PlacementBox paths invisible):
- column/shelf presets + all moves (PlacementBox via move-registry, and
DragBoundingBox) now publish the pose, so the triangle finally shows
- stair create + move use a declarative `facingIndicator: { reversed: true }`
(new registry resolver) so the triangle sits before the entry pointing out —
resolved in one place, so create and move match automatically
- stair placement defaults to single and respects the shared `point`
continuation (C) toggle, like the other placement tools
Checkpoint on the placement-interaction epic: also carries the in-flight
continuation-profile extraction (lib/continuation), grid surface (item #8), and
HUD work. Door/window still render their own legacy inline triangle and are
migrated to the overlay next.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
76089d85ea
commit
68e5c6ca67
@@ -30,6 +30,7 @@ export {
|
|||||||
nodeRegistry,
|
nodeRegistry,
|
||||||
type PluginDiscovery,
|
type PluginDiscovery,
|
||||||
registerNode,
|
registerNode,
|
||||||
|
resolveFacingIndicator,
|
||||||
setPluginDiscovery,
|
setPluginDiscovery,
|
||||||
} from './registry'
|
} from './registry'
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -178,6 +178,18 @@ export function isPresettableKind(kind: string): boolean {
|
|||||||
return def ? isPresettable(def) : false
|
return def ? isPresettable(def) : false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a kind's facing-triangle config, or `null` when it has none.
|
||||||
|
* `{ reversed }` says whether the triangle points along the node's local -Z
|
||||||
|
* (its front) instead of +Z. One reader (the editor-side `<FacingPoseIndicator>`
|
||||||
|
* publishers) so placement and move stay consistent.
|
||||||
|
*/
|
||||||
|
export function resolveFacingIndicator(kind: string): { reversed: boolean } | null {
|
||||||
|
const facing = nodeRegistry.get(kind)?.facingIndicator
|
||||||
|
if (!facing) return null
|
||||||
|
return { reversed: facing === true ? false : (facing.reversed ?? false) }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Names of schema fields on `def` that are host references (`wallId`,
|
* Names of schema fields on `def` that are host references (`wallId`,
|
||||||
* `wallT`, etc.). Read by host apps at preset-save time to strip these
|
* `wallT`, etc.). Read by host apps at preset-save time to strip these
|
||||||
|
|||||||
@@ -732,6 +732,14 @@ export type NodeDefinition<S extends ZodObject<any>> = {
|
|||||||
schema: S
|
schema: S
|
||||||
category: NodeCategory
|
category: NodeCategory
|
||||||
surfaceRole?: SurfaceRole
|
surfaceRole?: SurfaceRole
|
||||||
|
/**
|
||||||
|
* Show a floor direction-triangle while placing/moving — the kind has a
|
||||||
|
* meaningful front. `true` points along the node's local +Z (forward).
|
||||||
|
* `{ reversed: true }` points along local -Z, for kinds whose front is the
|
||||||
|
* -Z side (a stair faces *out* of its run: you approach from the low end,
|
||||||
|
* which sits on the -Z side of the footprint).
|
||||||
|
*/
|
||||||
|
facingIndicator?: boolean | { reversed?: boolean }
|
||||||
/**
|
/**
|
||||||
* Role this kind plays in a distribution system (HVAC duct / DWV pipe /
|
* Role this kind plays in a distribution system (HVAC duct / DWV pipe /
|
||||||
* refrigerant lineset). Lets the system-graph summary classify a
|
* refrigerant lineset). Lets the system-graph summary classify a
|
||||||
|
|||||||
@@ -1153,12 +1153,12 @@ export const CustomCameraControls = () => {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Preset capture mode frames a single subtree (often a 0.3–2m preset),
|
// Preset capture mode frames a single subtree (often a 0.3–2m preset),
|
||||||
// so the default 6m minDistance prevents the user from getting close
|
// so the default 2m minDistance prevents the user from getting close
|
||||||
// enough to compose a good thumbnail. Relax the clamp to 0.5m while
|
// enough to compose a good thumbnail. Relax the clamp to 0.5m while
|
||||||
// capturing presets; reset on exit so general editing keeps the looser
|
// capturing presets; reset on exit so general editing keeps the looser
|
||||||
// navigation guardrails.
|
// navigation guardrails.
|
||||||
const isPresetCapture = captureMode.mode === 'preset'
|
const isPresetCapture = captureMode.mode === 'preset'
|
||||||
const minDistance = isPresetCapture ? 0.5 : 6
|
const minDistance = isPresetCapture ? 0.5 : 2
|
||||||
|
|
||||||
if (isFirstPersonMode) {
|
if (isFirstPersonMode) {
|
||||||
return null
|
return null
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
DEFAULT_WALL_HEIGHT,
|
DEFAULT_WALL_HEIGHT,
|
||||||
DoorNode,
|
DoorNode,
|
||||||
ElevatorNode,
|
ElevatorNode,
|
||||||
|
emitter,
|
||||||
FenceNode,
|
FenceNode,
|
||||||
generateId,
|
generateId,
|
||||||
getActiveRoofHeight,
|
getActiveRoofHeight,
|
||||||
@@ -215,6 +216,7 @@ export function FloatingActionMenu() {
|
|||||||
const updateNode = useScene((s) => s.updateNode)
|
const updateNode = useScene((s) => s.updateNode)
|
||||||
const mode = useEditor((s) => s.mode)
|
const mode = useEditor((s) => s.mode)
|
||||||
const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered)
|
const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered)
|
||||||
|
const canFindNode = useEditor((s) => s.canFindNode)
|
||||||
const endpointReshape = useEndpointReshape()
|
const endpointReshape = useEndpointReshape()
|
||||||
const isCurveReshape = useIsCurveReshape()
|
const isCurveReshape = useIsCurveReshape()
|
||||||
const setMovingNode = useEditor((s) => s.setMovingNode)
|
const setMovingNode = useEditor((s) => s.setMovingNode)
|
||||||
@@ -655,6 +657,16 @@ export function FloatingActionMenu() {
|
|||||||
[node?.type, selectedId, setSelection],
|
[node?.type, selectedId, setSelection],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// "Find in catalog": the editor only signals intent — the host (community)
|
||||||
|
// listens for `selection:find-node` and reveals the node in its browser.
|
||||||
|
const handleFind = useCallback(
|
||||||
|
(e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
if (node) emitter.emit('selection:find-node' as never, node as never)
|
||||||
|
},
|
||||||
|
[node],
|
||||||
|
)
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!(selectedId && node && isValidType && !isFloorplanHovered && mode !== 'delete') ||
|
!(selectedId && node && isValidType && !isFloorplanHovered && mode !== 'delete') ||
|
||||||
endpointReshape ||
|
endpointReshape ||
|
||||||
@@ -676,6 +688,7 @@ export function FloatingActionMenu() {
|
|||||||
>
|
>
|
||||||
<div className="relative" ref={menuScaleRef} style={{ transformOrigin: 'center center' }}>
|
<div className="relative" ref={menuScaleRef} style={{ transformOrigin: 'center center' }}>
|
||||||
<NodeActionMenu
|
<NodeActionMenu
|
||||||
|
onFind={node && canFindNode ? handleFind : undefined}
|
||||||
onAddHole={node && HOLE_TYPES.includes(node.type) ? handleAddHole : undefined}
|
onAddHole={node && HOLE_TYPES.includes(node.type) ? handleAddHole : undefined}
|
||||||
onCurve={
|
onCurve={
|
||||||
node?.type === 'fence' || (node?.type === 'wall' && canCurveSelectedWall)
|
node?.type === 'fence' || (node?.type === 'wall' && canCurveSelectedWall)
|
||||||
|
|||||||
@@ -8871,7 +8871,7 @@ export function FloorplanPanel({
|
|||||||
// While a draft is open the segment locks to 15° rays from its start.
|
// While a draft is open the segment locks to 15° rays from its start.
|
||||||
// Snapping is governed by the snapping mode (`'off'` is the bypass);
|
// Snapping is governed by the snapping mode (`'off'` is the bypass);
|
||||||
// there is no Shift hold-to-bypass. Alignment follows the magnetic snap
|
// there is no Shift hold-to-bypass. Alignment follows the magnetic snap
|
||||||
// mode, not Alt (Alt-tap toggles continuous/single chaining).
|
// mode, not Alt (continuation is cycled through the HUD / C).
|
||||||
const fenceAngleSnap = fenceDraftStart !== null && isAngleSnapActive()
|
const fenceAngleSnap = fenceDraftStart !== null && isAngleSnapActive()
|
||||||
const fenceSnapped = snapFenceDraftPoint({
|
const fenceSnapped = snapFenceDraftPoint({
|
||||||
point: planPoint,
|
point: planPoint,
|
||||||
@@ -9361,7 +9361,7 @@ export function FloorplanPanel({
|
|||||||
: (publishedNextStart ?? point)
|
: (publishedNextStart ?? point)
|
||||||
|
|
||||||
if (
|
if (
|
||||||
useEditor.getState().wallChainMode === 'single' ||
|
useEditor.getState().getContinuation('wall') === 'single' ||
|
||||||
(wallChainFirstVertex && isWithinWallJoinSnapRadius(nextStart, wallChainFirstVertex))
|
(wallChainFirstVertex && isWithinWallJoinSnapRadius(nextStart, wallChainFirstVertex))
|
||||||
) {
|
) {
|
||||||
clearWallPlacementDraft()
|
clearWallPlacementDraft()
|
||||||
|
|||||||
@@ -1,14 +1,28 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { emitter, type GridEvent, sceneRegistry } from '@pascal-app/core'
|
import { type AnyNodeId, emitter, type GridEvent, sceneRegistry } from '@pascal-app/core'
|
||||||
import { GRID_LAYER, getSceneTheme, useViewer } from '@pascal-app/viewer'
|
import { GRID_LAYER, getSceneTheme, useViewer } from '@pascal-app/viewer'
|
||||||
import { useFrame } from '@react-three/fiber'
|
import { useFrame } from '@react-three/fiber'
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { MathUtils, type Mesh, PlaneGeometry, Vector2 } from 'three'
|
import { MathUtils, type Mesh, PlaneGeometry, Quaternion, Vector2, Vector3 } from 'three'
|
||||||
import { color, float, fract, fwidth, mix, positionLocal, uniform } from 'three/tsl'
|
import { color, float, fract, fwidth, mix, positionLocal, uniform } from 'three/tsl'
|
||||||
import { MeshBasicNodeMaterial } from 'three/webgpu'
|
import { MeshBasicNodeMaterial } from 'three/webgpu'
|
||||||
import { useCeilingEvents } from '../../hooks/use-ceiling-events'
|
import { useCeilingEvents } from '../../hooks/use-ceiling-events'
|
||||||
import { useGridEvents } from '../../hooks/use-grid-events'
|
import { useGridEvents } from '../../hooks/use-grid-events'
|
||||||
|
import { getPlacementSurface } from '../../lib/active-placement-surface'
|
||||||
|
import useEditor, { isGridSnapActive } from '../../store/use-editor'
|
||||||
|
import useInteractionScope, { getMovingNode } from '../../store/use-interaction-scope'
|
||||||
|
|
||||||
|
// Reveal radius (m) of the cursor-local grid patch shown while placing/moving in
|
||||||
|
// grid-snap mode — much tighter than the idle reveal so only the area you're
|
||||||
|
// about to snap into lights up.
|
||||||
|
const PLACEMENT_REVEAL_RADIUS = 5
|
||||||
|
|
||||||
|
const UP = new Vector3(0, 1, 0)
|
||||||
|
// PlaneGeometry faces +Z; this is the orientation that lays it flat (its normal
|
||||||
|
// → world +Y), equivalent to the old `rotation-x={-π/2}`.
|
||||||
|
const PLANE_LOCAL_NORMAL = new Vector3(0, 0, 1)
|
||||||
|
const HORIZONTAL_QUATERNION = new Quaternion().setFromUnitVectors(PLANE_LOCAL_NORMAL, UP)
|
||||||
|
|
||||||
export const Grid = ({
|
export const Grid = ({
|
||||||
cellSize = 0.5,
|
cellSize = 0.5,
|
||||||
@@ -38,6 +52,16 @@ export const Grid = ({
|
|||||||
const effectiveSectionColor = isDark ? '#666677' : sectionColor
|
const effectiveSectionColor = isDark ? '#666677' : sectionColor
|
||||||
|
|
||||||
const cursorPositionRef = useRef(new Vector2(0, 0))
|
const cursorPositionRef = useRef(new Vector2(0, 0))
|
||||||
|
// Scratch for reading a moving node's world Y (surface elevation) each frame.
|
||||||
|
const worldPosRef = useRef(new Vector3())
|
||||||
|
|
||||||
|
// Reveal radius + baseline alpha are uniforms so a placement/move can shrink
|
||||||
|
// the grid to a tight cursor patch (and drop the always-on baseline) without
|
||||||
|
// rebuilding the shader. Driven each frame in `useFrame`.
|
||||||
|
const revealRadiusUniform = useMemo(() => uniform(revealRadius), [revealRadius])
|
||||||
|
const baseAlphaUniform = useMemo(() => uniform(0.4), [])
|
||||||
|
const cellSizeUniform = useMemo(() => uniform(cellSize), [cellSize])
|
||||||
|
const patchAlphaUniform = useMemo(() => uniform(1), [])
|
||||||
|
|
||||||
const material = useMemo(() => {
|
const material = useMemo(() => {
|
||||||
// Use xy since plane geometry is in XY space (before rotation)
|
// Use xy since plane geometry is in XY space (before rotation)
|
||||||
@@ -48,7 +72,7 @@ export const Grid = ({
|
|||||||
|
|
||||||
// Grid line function using fwidth for anti-aliasing
|
// Grid line function using fwidth for anti-aliasing
|
||||||
// Returns 1 on grid lines, 0 elsewhere
|
// Returns 1 on grid lines, 0 elsewhere
|
||||||
const getGrid = (size: number, thickness: number) => {
|
const getGrid = (size: number | typeof cellSizeUniform, thickness: number) => {
|
||||||
const r = pos.div(size)
|
const r = pos.div(size)
|
||||||
const fw = fwidth(r)
|
const fw = fwidth(r)
|
||||||
// Distance to nearest grid line for each axis
|
// Distance to nearest grid line for each axis
|
||||||
@@ -70,7 +94,7 @@ export const Grid = ({
|
|||||||
return lineX.max(lineY)
|
return lineX.max(lineY)
|
||||||
}
|
}
|
||||||
|
|
||||||
const g1 = getGrid(cellSize, cellThickness)
|
const g1 = getGrid(cellSizeUniform, cellThickness)
|
||||||
const g2 = getGrid(sectionSize, sectionThickness)
|
const g2 = getGrid(sectionSize, sectionThickness)
|
||||||
|
|
||||||
// Distance fade from center
|
// Distance fade from center
|
||||||
@@ -79,7 +103,9 @@ export const Grid = ({
|
|||||||
|
|
||||||
// Cursor reveal effect - distance from cursor
|
// Cursor reveal effect - distance from cursor
|
||||||
const cursorDist = pos.sub(cursorPos).length()
|
const cursorDist = pos.sub(cursorPos).length()
|
||||||
const cursorFade = float(1).sub(cursorDist.div(revealRadius).clamp(0, 1)).smoothstep(0, 1)
|
const cursorFade = float(1)
|
||||||
|
.sub(cursorDist.div(revealRadiusUniform).clamp(0, 1))
|
||||||
|
.smoothstep(0, 1)
|
||||||
|
|
||||||
// Mix colors based on section grid
|
// Mix colors based on section grid
|
||||||
const gridColor = mix(
|
const gridColor = mix(
|
||||||
@@ -88,12 +114,10 @@ export const Grid = ({
|
|||||||
float(sectionThickness).mul(g2).min(1),
|
float(sectionThickness).mul(g2).min(1),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Baseline alpha: small amount of opacity everywhere the grid exists
|
|
||||||
const baseAlpha = float(0.4) // Subtle global visibility
|
|
||||||
|
|
||||||
// Combined alpha with cursor fade and baseline minimum
|
// Combined alpha with cursor fade and baseline minimum
|
||||||
const alpha = g1.add(g2).mul(fade).mul(cursorFade.max(baseAlpha))
|
const alpha = g1.add(g2).mul(fade).mul(cursorFade.max(baseAlphaUniform))
|
||||||
const finalAlpha = mix(alpha.mul(0.75), alpha, g2)
|
const boostedAlpha = alpha.mul(patchAlphaUniform).min(1)
|
||||||
|
const finalAlpha = mix(boostedAlpha.mul(0.75), boostedAlpha, g2)
|
||||||
|
|
||||||
return new MeshBasicNodeMaterial({
|
return new MeshBasicNodeMaterial({
|
||||||
transparent: true,
|
transparent: true,
|
||||||
@@ -102,7 +126,6 @@ export const Grid = ({
|
|||||||
depthWrite: false,
|
depthWrite: false,
|
||||||
})
|
})
|
||||||
}, [
|
}, [
|
||||||
cellSize,
|
|
||||||
cellThickness,
|
cellThickness,
|
||||||
effectiveCellColor,
|
effectiveCellColor,
|
||||||
sectionSize,
|
sectionSize,
|
||||||
@@ -110,7 +133,10 @@ export const Grid = ({
|
|||||||
effectiveSectionColor,
|
effectiveSectionColor,
|
||||||
fadeDistance,
|
fadeDistance,
|
||||||
fadeStrength,
|
fadeStrength,
|
||||||
revealRadius,
|
revealRadiusUniform,
|
||||||
|
baseAlphaUniform,
|
||||||
|
cellSizeUniform,
|
||||||
|
patchAlphaUniform,
|
||||||
])
|
])
|
||||||
|
|
||||||
const gridRef = useRef<Mesh>(null!)
|
const gridRef = useRef<Mesh>(null!)
|
||||||
@@ -145,30 +171,80 @@ export const Grid = ({
|
|||||||
|
|
||||||
useFrame((_, delta) => {
|
useFrame((_, delta) => {
|
||||||
const { levelId } = useViewer.getState().selection
|
const { levelId } = useViewer.getState().selection
|
||||||
// Grid stays anchored to world XZ (0, 0) — never chases the active
|
let levelY = 0
|
||||||
// building. The Y origin still lerps to the active level so the grid
|
|
||||||
// sits at floor height when a level is open.
|
|
||||||
let targetY = 0
|
|
||||||
if (levelId) {
|
if (levelId) {
|
||||||
const levelMesh = sceneRegistry.nodes.get(levelId)
|
const levelMesh = sceneRegistry.nodes.get(levelId)
|
||||||
if (levelMesh) {
|
if (levelMesh) {
|
||||||
targetY = levelMesh.position.y
|
levelY = levelMesh.position.y
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const newY = MathUtils.lerp(gridRef.current.position.y, targetY, 12 * delta)
|
|
||||||
gridRef.current.position.y = newY
|
|
||||||
setGridY(newY)
|
|
||||||
|
|
||||||
// Grid XZ is fixed at world origin, so the local-frame cursor uniform
|
// Resolve the surface the active ghost is snapped to (contact point +
|
||||||
// is just the world cursor (mirrored on Z to match the -π/2 X-rotation
|
// normal). A fresh GLB item / drawn kind publishes via the surface module; a
|
||||||
// of the plane).
|
// moving node is read straight off its mesh (treated as horizontal). Null
|
||||||
const world = lastWorldCursorRef.current
|
// when nothing is being placed.
|
||||||
if (world) {
|
const published = getPlacementSurface()
|
||||||
cursorPositionRef.current.set(world.x, -world.z)
|
const movingForGrid = getMovingNode()
|
||||||
|
let surfacePoint: Vector3 | null = null
|
||||||
|
let surfaceNormal = UP
|
||||||
|
if (published) {
|
||||||
|
surfacePoint = published.point
|
||||||
|
surfaceNormal = published.normal
|
||||||
|
} else if (movingForGrid) {
|
||||||
|
const ghostMesh = sceneRegistry.nodes.get(movingForGrid.id as AnyNodeId)
|
||||||
|
if (ghostMesh) surfacePoint = ghostMesh.getWorldPosition(worldPosRef.current)
|
||||||
}
|
}
|
||||||
})
|
|
||||||
|
|
||||||
const showGrid = useViewer((state) => state.showGrid)
|
const gridMesh = gridRef.current
|
||||||
|
const onWall = surfacePoint != null && Math.abs(surfaceNormal.y) < 0.5
|
||||||
|
if (onWall && surfacePoint) {
|
||||||
|
// Vertical surface: drop the plane onto the wall at the contact point and
|
||||||
|
// orient it into the wall plane. The patch reveals centred there — a wall
|
||||||
|
// has no world-anchored floor lattice to track.
|
||||||
|
gridMesh.position.copy(surfacePoint)
|
||||||
|
gridMesh.quaternion.setFromUnitVectors(PLANE_LOCAL_NORMAL, surfaceNormal)
|
||||||
|
cursorPositionRef.current.set(0, 0)
|
||||||
|
setGridY(surfacePoint.y)
|
||||||
|
} else {
|
||||||
|
// Horizontal: keep the lattice anchored to world XZ (0,0); only the Y
|
||||||
|
// origin follows the surface height (floor / shelf top), lerped. Cursor
|
||||||
|
// uniform tracks the world cursor (mirrored on Z for the laid-flat plane).
|
||||||
|
const targetY = surfacePoint ? surfacePoint.y : levelY
|
||||||
|
const newY = MathUtils.lerp(gridMesh.position.y, targetY, 12 * delta)
|
||||||
|
gridMesh.position.set(0, newY, 0)
|
||||||
|
gridMesh.quaternion.copy(HORIZONTAL_QUATERNION)
|
||||||
|
const world = lastWorldCursorRef.current
|
||||||
|
if (world) {
|
||||||
|
cursorPositionRef.current.set(world.x, -world.z)
|
||||||
|
}
|
||||||
|
setGridY(newY)
|
||||||
|
}
|
||||||
|
|
||||||
|
// While placing/moving: in grid-snap mode shrink to a tight cursor patch
|
||||||
|
// (drop the always-on baseline so only the snap area near the cursor shows);
|
||||||
|
// when NOT grid-snapping, hide the grid entirely. Idle keeps the full grid.
|
||||||
|
// "Actively placing/moving" means a ghost is being positioned: a movingNode
|
||||||
|
// (preset/node move), an in-progress draft (wall/fence), or an armed GLB item
|
||||||
|
// in build mode. A merely-armed build tool with no ghost is NOT placing —
|
||||||
|
// otherwise the patch would show while the user isn't positioning anything.
|
||||||
|
const ed = useEditor.getState()
|
||||||
|
const scopeKind = useInteractionScope.getState().scope.kind
|
||||||
|
const placingOrMoving =
|
||||||
|
getMovingNode() != null ||
|
||||||
|
scopeKind === 'drafting' ||
|
||||||
|
scopeKind === 'placing' ||
|
||||||
|
(ed.mode === 'build' && ed.selectedItem != null)
|
||||||
|
const gridSnap = isGridSnapActive()
|
||||||
|
// The grid is a placement aid, not always-on chrome: it shows ONLY while
|
||||||
|
// actively placing/moving in grid-snap mode, as a tight cursor patch. Idle,
|
||||||
|
// select, and non-grid placement all hide it entirely.
|
||||||
|
const snapPatchVisible = placingOrMoving && gridSnap
|
||||||
|
revealRadiusUniform.value = PLACEMENT_REVEAL_RADIUS
|
||||||
|
baseAlphaUniform.value = 0
|
||||||
|
cellSizeUniform.value = useEditor.getState().gridSnapStep
|
||||||
|
patchAlphaUniform.value = 1.5
|
||||||
|
gridRef.current.visible = useViewer.getState().showGrid && snapPatchVisible
|
||||||
|
})
|
||||||
|
|
||||||
// Pass the geometry as a prop instead of a JSX child so the mesh
|
// Pass the geometry as a prop instead of a JSX child so the mesh
|
||||||
// is never reconciled with R3F's empty placeholder `BufferGeometry`.
|
// is never reconciled with R3F's empty placeholder `BufferGeometry`.
|
||||||
@@ -183,13 +259,8 @@ export const Grid = ({
|
|||||||
useEffect(() => () => geometry.dispose(), [geometry])
|
useEffect(() => () => geometry.dispose(), [geometry])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<mesh
|
// Orientation is driven imperatively in `useFrame` (horizontal by default,
|
||||||
geometry={geometry}
|
// tilted into the wall plane while placing on a wall), so no static rotation.
|
||||||
layers={GRID_LAYER}
|
<mesh geometry={geometry} layers={GRID_LAYER} material={material} ref={gridRef} />
|
||||||
material={material}
|
|
||||||
ref={gridRef}
|
|
||||||
rotation-x={-Math.PI / 2}
|
|
||||||
visible={showGrid}
|
|
||||||
/>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
|
|
||||||
import { Icon } from '@iconify/react'
|
import { Icon } from '@iconify/react'
|
||||||
import {
|
import {
|
||||||
|
getCatalogMaterialById,
|
||||||
|
getLibraryMaterialIdFromRef,
|
||||||
|
getSceneMaterialIdFromRef,
|
||||||
initSpaceDetectionSync,
|
initSpaceDetectionSync,
|
||||||
initSpatialGridSync,
|
initSpatialGridSync,
|
||||||
spatialGridManager,
|
spatialGridManager,
|
||||||
@@ -19,6 +22,7 @@ import { ViewerOverlay } from '../../components/viewer-overlay'
|
|||||||
import { ViewerZoneSystem } from '../../components/viewer-zone-system'
|
import { ViewerZoneSystem } from '../../components/viewer-zone-system'
|
||||||
import { type SaveStatus, useAutoSave } from '../../hooks/use-auto-save'
|
import { type SaveStatus, useAutoSave } from '../../hooks/use-auto-save'
|
||||||
import { useKeyboard } from '../../hooks/use-keyboard'
|
import { useKeyboard } from '../../hooks/use-keyboard'
|
||||||
|
import { type ActivePaintMaterial, hasActivePaintMaterial } from '../../lib/material-paint'
|
||||||
import {
|
import {
|
||||||
applySceneGraphToEditor,
|
applySceneGraphToEditor,
|
||||||
loadSceneFromLocalStorage,
|
loadSceneFromLocalStorage,
|
||||||
@@ -81,6 +85,7 @@ const PAINT_CURSOR_BADGE_DISABLED_COLOR = '#94a3b8'
|
|||||||
const PAINT_CURSOR_BADGE_OFFSET_X = 14
|
const PAINT_CURSOR_BADGE_OFFSET_X = 14
|
||||||
const PAINT_CURSOR_BADGE_OFFSET_Y = 14
|
const PAINT_CURSOR_BADGE_OFFSET_Y = 14
|
||||||
const SCENE_READY_FALLBACK_MS = 8000
|
const SCENE_READY_FALLBACK_MS = 8000
|
||||||
|
type PaintCursorBadgeState = 'empty' | 'ready' | 'blocked'
|
||||||
const EDITOR_HOVER_STYLES: HoverStyles = {
|
const EDITOR_HOVER_STYLES: HoverStyles = {
|
||||||
default: { visibleColor: 0x00_aa_ff, hiddenColor: 0xf3_ff_47, strength: 5, pulse: true },
|
default: { visibleColor: 0x00_aa_ff, hiddenColor: 0xf3_ff_47, strength: 5, pulse: true },
|
||||||
delete: { visibleColor: 0xef_44_44, hiddenColor: 0x99_1b_1b, strength: 6, pulse: false },
|
delete: { visibleColor: 0xef_44_44, hiddenColor: 0x99_1b_1b, strength: 6, pulse: false },
|
||||||
@@ -535,14 +540,70 @@ function DeleteCursorBadge({ position }: { position: { x: number; y: number } })
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getActivePaintMaterialSwatchColor(
|
||||||
|
material: ActivePaintMaterial | null,
|
||||||
|
sceneMaterials: ReturnType<typeof useScene.getState>['materials'],
|
||||||
|
) {
|
||||||
|
const directColor = material?.material?.properties?.color
|
||||||
|
if (directColor) return directColor
|
||||||
|
|
||||||
|
const sceneMaterialId = getSceneMaterialIdFromRef(material?.materialPreset)
|
||||||
|
if (sceneMaterialId) {
|
||||||
|
const sceneMaterial = sceneMaterials[sceneMaterialId as keyof typeof sceneMaterials]
|
||||||
|
const sceneColor = sceneMaterial?.material.properties?.color
|
||||||
|
if (sceneColor) return sceneColor
|
||||||
|
}
|
||||||
|
|
||||||
|
const catalogId =
|
||||||
|
getLibraryMaterialIdFromRef(material?.materialPreset) ?? material?.material?.id ?? undefined
|
||||||
|
const catalogMaterial = getCatalogMaterialById(catalogId)
|
||||||
|
return (
|
||||||
|
catalogMaterial?.previewColor ??
|
||||||
|
catalogMaterial?.preset.mapProperties.color ??
|
||||||
|
PAINT_CURSOR_BADGE_COLOR
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActivePaintMaterialSwatchImageUrl(
|
||||||
|
material: ActivePaintMaterial | null,
|
||||||
|
sceneMaterials: ReturnType<typeof useScene.getState>['materials'],
|
||||||
|
) {
|
||||||
|
const directTextureUrl = material?.material?.texture?.url
|
||||||
|
if (directTextureUrl) return directTextureUrl
|
||||||
|
|
||||||
|
const sceneMaterialId = getSceneMaterialIdFromRef(material?.materialPreset)
|
||||||
|
if (sceneMaterialId) {
|
||||||
|
const sceneMaterial = sceneMaterials[sceneMaterialId as keyof typeof sceneMaterials]
|
||||||
|
const sceneTextureUrl = sceneMaterial?.material.texture?.url
|
||||||
|
if (sceneTextureUrl) return sceneTextureUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
const catalogId =
|
||||||
|
getLibraryMaterialIdFromRef(material?.materialPreset) ?? material?.material?.id ?? undefined
|
||||||
|
const catalogMaterial = getCatalogMaterialById(catalogId)
|
||||||
|
return catalogMaterial?.previewThumbnailUrl ?? catalogMaterial?.preset.maps.albedoMap
|
||||||
|
}
|
||||||
|
|
||||||
function PaintCursorBadge({
|
function PaintCursorBadge({
|
||||||
position,
|
position,
|
||||||
disabled,
|
state,
|
||||||
|
swatchColor,
|
||||||
|
swatchImageUrl,
|
||||||
|
isEraser,
|
||||||
}: {
|
}: {
|
||||||
position: { x: number; y: number }
|
position: { x: number; y: number }
|
||||||
disabled: boolean
|
state: PaintCursorBadgeState
|
||||||
|
swatchColor: string
|
||||||
|
swatchImageUrl?: string
|
||||||
|
isEraser: boolean
|
||||||
}) {
|
}) {
|
||||||
const accentColor = disabled ? PAINT_CURSOR_BADGE_DISABLED_COLOR : PAINT_CURSOR_BADGE_COLOR
|
const accentColor =
|
||||||
|
state === 'ready'
|
||||||
|
? isEraser
|
||||||
|
? PAINT_CURSOR_BADGE_COLOR
|
||||||
|
: swatchColor
|
||||||
|
: PAINT_CURSOR_BADGE_DISABLED_COLOR
|
||||||
|
const iconOpacity = state === 'ready' ? 1 : state === 'blocked' ? 0.62 : 0.42
|
||||||
const lineHeight = 18
|
const lineHeight = 18
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -576,7 +637,48 @@ function PaintCursorBadge({
|
|||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="h-5 w-5 object-contain drop-shadow-[0_2px_4px_rgba(0,0,0,0.5)]"
|
className="h-5 w-5 object-contain drop-shadow-[0_2px_4px_rgba(0,0,0,0.5)]"
|
||||||
src="/icons/paint.webp"
|
src="/icons/paint.webp"
|
||||||
|
style={{
|
||||||
|
filter: state === 'ready' ? undefined : 'grayscale(1)',
|
||||||
|
opacity: iconOpacity,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
{state === 'ready' ? (
|
||||||
|
isEraser ? (
|
||||||
|
<span className="-right-1 -bottom-1 absolute flex h-3.5 w-3.5 items-center justify-center rounded-full border border-white/35 bg-zinc-950 text-white shadow-[0_2px_6px_rgba(0,0,0,0.45)]">
|
||||||
|
<Icon
|
||||||
|
aria-hidden="true"
|
||||||
|
color="currentColor"
|
||||||
|
height={10}
|
||||||
|
icon="mdi:eraser-variant"
|
||||||
|
width={10}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className="-right-1 -bottom-1 absolute h-3.5 w-3.5 rounded-full border border-white/70 bg-cover bg-center shadow-[0_2px_6px_rgba(0,0,0,0.45)]"
|
||||||
|
style={{
|
||||||
|
backgroundColor: swatchColor,
|
||||||
|
backgroundImage: swatchImageUrl
|
||||||
|
? `url(${JSON.stringify(swatchImageUrl)})`
|
||||||
|
: undefined,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
) : state === 'blocked' ? (
|
||||||
|
<span className="-right-1 -bottom-1 absolute flex h-3.5 w-3.5 items-center justify-center rounded-full border border-white/30 bg-zinc-950 text-rose-300 shadow-[0_2px_6px_rgba(0,0,0,0.45)]">
|
||||||
|
<Icon
|
||||||
|
aria-hidden="true"
|
||||||
|
color="currentColor"
|
||||||
|
height={12}
|
||||||
|
icon="mdi:cancel"
|
||||||
|
width={12}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="-right-1 -bottom-1 absolute flex h-3.5 w-3.5 items-center justify-center rounded-full border border-white/30 bg-zinc-950 font-semibold text-[9px] text-slate-300 shadow-[0_2px_6px_rgba(0,0,0,0.45)]">
|
||||||
|
?
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -730,6 +832,9 @@ function PaintCursorLayer({
|
|||||||
}) {
|
}) {
|
||||||
const mode = useEditor((s) => s.mode)
|
const mode = useEditor((s) => s.mode)
|
||||||
const activePaintMaterial = useEditor((s) => s.activePaintMaterial)
|
const activePaintMaterial = useEditor((s) => s.activePaintMaterial)
|
||||||
|
const paintEraser = useEditor((s) => s.paintEraser)
|
||||||
|
const paintHover = useEditor((s) => s.paintHover)
|
||||||
|
const sceneMaterials = useScene((s) => s.materials)
|
||||||
const [position, setPosition] = useState<{ x: number; y: number } | null>(null)
|
const [position, setPosition] = useState<{ x: number; y: number } | null>(null)
|
||||||
const active = mode === 'material-paint' && !isVersionPreviewMode
|
const active = mode === 'material-paint' && !isVersionPreviewMode
|
||||||
|
|
||||||
@@ -779,11 +884,14 @@ function PaintCursorLayer({
|
|||||||
}
|
}
|
||||||
}, [active, containerRef])
|
}, [active, containerRef])
|
||||||
|
|
||||||
const hasMaterial = Boolean(
|
const hasPaint = paintEraser || hasActivePaintMaterial(activePaintMaterial)
|
||||||
activePaintMaterial &&
|
const badgeState: PaintCursorBadgeState = !hasPaint
|
||||||
(activePaintMaterial.material !== undefined ||
|
? 'empty'
|
||||||
activePaintMaterial.materialPreset !== undefined),
|
: paintHover != null
|
||||||
)
|
? 'ready'
|
||||||
|
: 'blocked'
|
||||||
|
const swatchColor = getActivePaintMaterialSwatchColor(activePaintMaterial, sceneMaterials)
|
||||||
|
const swatchImageUrl = getActivePaintMaterialSwatchImageUrl(activePaintMaterial, sceneMaterials)
|
||||||
|
|
||||||
if (!active || !position) return null
|
if (!active || !position) return null
|
||||||
|
|
||||||
@@ -792,7 +900,13 @@ function PaintCursorLayer({
|
|||||||
className="pointer-events-none absolute z-40"
|
className="pointer-events-none absolute z-40"
|
||||||
style={{ left: 0, top: 0, transform: `translate(${position.x}px, ${position.y}px)` }}
|
style={{ left: 0, top: 0, transform: `translate(${position.x}px, ${position.y}px)` }}
|
||||||
>
|
>
|
||||||
<PaintCursorBadge disabled={!hasMaterial} position={{ x: 0, y: 0 }} />
|
<PaintCursorBadge
|
||||||
|
isEraser={paintEraser}
|
||||||
|
position={{ x: 0, y: 0 }}
|
||||||
|
state={badgeState}
|
||||||
|
swatchColor={swatchColor}
|
||||||
|
swatchImageUrl={swatchImageUrl}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { Icon } from '@iconify/react'
|
import { Icon } from '@iconify/react'
|
||||||
import { Copy, Move, Spline, Trash2 } from 'lucide-react'
|
import { Copy, Move, Search, Spline, Trash2 } from 'lucide-react'
|
||||||
import type { MouseEventHandler, PointerEventHandler } from 'react'
|
import type { MouseEventHandler, PointerEventHandler } from 'react'
|
||||||
|
|
||||||
type NodeActionMenuProps = {
|
type NodeActionMenuProps = {
|
||||||
|
onFind?: MouseEventHandler<HTMLButtonElement>
|
||||||
onAddHole?: MouseEventHandler<HTMLButtonElement>
|
onAddHole?: MouseEventHandler<HTMLButtonElement>
|
||||||
onDelete?: MouseEventHandler<HTMLButtonElement>
|
onDelete?: MouseEventHandler<HTMLButtonElement>
|
||||||
onDuplicate?: MouseEventHandler<HTMLButtonElement>
|
onDuplicate?: MouseEventHandler<HTMLButtonElement>
|
||||||
@@ -17,6 +18,7 @@ type NodeActionMenuProps = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function NodeActionMenu({
|
export function NodeActionMenu({
|
||||||
|
onFind,
|
||||||
onAddHole,
|
onAddHole,
|
||||||
onDelete,
|
onDelete,
|
||||||
onDuplicate,
|
onDuplicate,
|
||||||
@@ -35,6 +37,17 @@ export function NodeActionMenu({
|
|||||||
onPointerLeave={onPointerLeave}
|
onPointerLeave={onPointerLeave}
|
||||||
onPointerUp={onPointerUp}
|
onPointerUp={onPointerUp}
|
||||||
>
|
>
|
||||||
|
{onFind && (
|
||||||
|
<button
|
||||||
|
aria-label="Find in catalog"
|
||||||
|
className="tooltip-trigger rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||||
|
onClick={onFind}
|
||||||
|
title="Find in catalog"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Search className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{onMove && (
|
{onMove && (
|
||||||
<button
|
<button
|
||||||
aria-label="Move"
|
aria-label="Move"
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import {
|
|||||||
sceneRegistry,
|
sceneRegistry,
|
||||||
snapScalar,
|
snapScalar,
|
||||||
type TapActionHandle,
|
type TapActionHandle,
|
||||||
type TranslateHandle,
|
|
||||||
useLiveNodeOverrides,
|
useLiveNodeOverrides,
|
||||||
useScene,
|
useScene,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
@@ -45,7 +44,6 @@ import { MeshBasicNodeMaterial } from 'three/webgpu'
|
|||||||
import { EDITOR_LAYER } from '../../lib/constants'
|
import { EDITOR_LAYER } from '../../lib/constants'
|
||||||
import { RESIZE_HANDLE_DRAG_LABEL, ROTATE_HANDLE_DRAG_LABEL } from '../../lib/contextual-help'
|
import { RESIZE_HANDLE_DRAG_LABEL, ROTATE_HANDLE_DRAG_LABEL } from '../../lib/contextual-help'
|
||||||
import { createEditorApi } from '../../lib/editor-api'
|
import { createEditorApi } from '../../lib/editor-api'
|
||||||
import { sfxEmitter } from '../../lib/sfx-bus'
|
|
||||||
import useDirectManipulationFeedback from '../../store/use-direct-manipulation-feedback'
|
import useDirectManipulationFeedback from '../../store/use-direct-manipulation-feedback'
|
||||||
import useEditor from '../../store/use-editor'
|
import useEditor from '../../store/use-editor'
|
||||||
import useInteractionScope, {
|
import useInteractionScope, {
|
||||||
@@ -54,7 +52,6 @@ import useInteractionScope, {
|
|||||||
useMovingNode,
|
useMovingNode,
|
||||||
} from '../../store/use-interaction-scope'
|
} from '../../store/use-interaction-scope'
|
||||||
import useOpeningGuides from '../../store/use-opening-guides'
|
import useOpeningGuides from '../../store/use-opening-guides'
|
||||||
import { suppressBoxSelectForPointer } from '../tools/select/box-select-state'
|
|
||||||
import { formatAngleRadians } from '../tools/shared/segment-angle'
|
import { formatAngleRadians } from '../tools/shared/segment-angle'
|
||||||
import {
|
import {
|
||||||
ARROW_COLOR,
|
ARROW_COLOR,
|
||||||
@@ -209,9 +206,15 @@ export function NodeArrowHandles() {
|
|||||||
const def = node ? nodeRegistry.get(node.type) : null
|
const def = node ? nodeRegistry.get(node.type) : null
|
||||||
const descriptors = useMemo(() => {
|
const descriptors = useMemo(() => {
|
||||||
if (!(node && def?.handles)) return null
|
if (!(node && def?.handles)) return null
|
||||||
return typeof def.handles === 'function'
|
const all =
|
||||||
? def.handles(node as never)
|
typeof def.handles === 'function'
|
||||||
: (def.handles as HandleDescriptor[])
|
? def.handles(node as never)
|
||||||
|
: (def.handles as HandleDescriptor[])
|
||||||
|
// The whole-node move-cross gizmo is gone: moving is now click-to-move on
|
||||||
|
// the selected node body (see selection-manager). Drop both flavours — the
|
||||||
|
// `translate` ground cross (column/roof/shelf/spawn) and the `tap-action`
|
||||||
|
// `move-cross` (item/door/window/elevator/stair) — keep rotate/resize.
|
||||||
|
return all.filter((d) => d.kind !== 'translate' && !('shape' in d && d.shape === 'move-cross'))
|
||||||
}, [node, def])
|
}, [node, def])
|
||||||
|
|
||||||
const shouldRender =
|
const shouldRender =
|
||||||
@@ -523,17 +526,6 @@ function ArrowHandle({
|
|||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (descriptor.kind === 'translate') {
|
|
||||||
return (
|
|
||||||
<TranslateArrow
|
|
||||||
descriptor={descriptor}
|
|
||||||
dragControls={dragControls}
|
|
||||||
handleIndex={handleIndex}
|
|
||||||
node={placementNode}
|
|
||||||
rideObject={rideObject}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (descriptor.kind === 'tap-action') {
|
if (descriptor.kind === 'tap-action') {
|
||||||
// Tap-action handles (fence side-move arrows, corner pickers) aren't
|
// Tap-action handles (fence side-move arrows, corner pickers) aren't
|
||||||
// resize handles, so the freeze-at-pre-drag mechanism — which only
|
// resize handles, so the freeze-at-pre-drag mechanism — which only
|
||||||
@@ -1226,65 +1218,6 @@ function ArcArrow({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Free ground-plane move gizmo (the 4-way cross). Press-drag-release: raycast
|
|
||||||
// the horizontal plane at the node's base, convert the hit into the node's
|
|
||||||
// parent-local frame, add the delta to the node's drag-start position, grid-
|
|
||||||
// snap via the descriptor's `snapExtents`, and publish to `useLiveNodeOverrides`
|
|
||||||
// each move — committing one write to the store on release. The override stays
|
|
||||||
// at base Y; `<FloorElevationSystem>` reads that effective node and owns the
|
|
||||||
// presentation-only slab lift so the handle path shares the menu-move stacking
|
|
||||||
// contract without storing lifted positions.
|
|
||||||
function TranslateArrow({
|
|
||||||
descriptor,
|
|
||||||
node,
|
|
||||||
}: {
|
|
||||||
descriptor: TranslateHandle<AnyNode>
|
|
||||||
node: AnyNode
|
|
||||||
handleIndex: number
|
|
||||||
dragControls: HandleDragControls
|
|
||||||
rideObject: Object3D
|
|
||||||
}) {
|
|
||||||
const [isHovered, setIsHovered] = useState(false)
|
|
||||||
const { camera } = useThree()
|
|
||||||
const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1
|
|
||||||
const baseScale = zoom * ARROW_SCALE
|
|
||||||
|
|
||||||
const placementSceneApi = useMemo(() => createSceneApi(useScene), [])
|
|
||||||
const position = descriptor.placement.position(node, placementSceneApi)
|
|
||||||
const cursor: Cursor = 'move'
|
|
||||||
// 'node-normal' constrains the drag to the wall face (plane ⟂ the node's
|
|
||||||
// local +Z). Its cross icon stands up into that plane (tilt about X).
|
|
||||||
const isWallPlane = descriptor.plane === 'node-normal'
|
|
||||||
|
|
||||||
// Same function as the floating action menu's Move button
|
|
||||||
// (`floating-action-menu.tsx` → `handleMove`): arm the registry move tool,
|
|
||||||
// which owns the cursor follow, grid + alignment snap, green guide overlay,
|
|
||||||
// and click-to-commit. Routes both entry points through one path so the
|
|
||||||
// 3D translate gizmo and the floating Move button behave identically.
|
|
||||||
const activate = (event: ThreeEvent<PointerEvent>) => {
|
|
||||||
event.stopPropagation()
|
|
||||||
suppressBoxSelectForPointer(event)
|
|
||||||
sfxEmitter.emit('sfx:item-pick')
|
|
||||||
useEditor.getState().setMovingNode(node as never)
|
|
||||||
useViewer.getState().setSelection({ selectedIds: [] })
|
|
||||||
}
|
|
||||||
|
|
||||||
// The cross is built flat in the XZ plane. On a wall, tilt it up about X so
|
|
||||||
// it lies in the item-local XY plane (= the wall face).
|
|
||||||
const iconRotation: [number, number, number] = isWallPlane ? NODE_NORMAL_TILT : [0, 0, 0]
|
|
||||||
|
|
||||||
return (
|
|
||||||
<HandleArrow
|
|
||||||
cursor={cursor}
|
|
||||||
hover={isHovered}
|
|
||||||
onHoverChange={setIsHovered}
|
|
||||||
onPointerDown={activate}
|
|
||||||
placement={{ position, rotation: iconRotation, baseScale }}
|
|
||||||
shape="cross"
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Click-to-engage affordance — no drag plumbing, just a click target. The
|
// Click-to-engage affordance — no drag plumbing, just a click target. The
|
||||||
// descriptor's `onActivate` receives sceneApi + editorApi so it can engage
|
// descriptor's `onActivate` receives sceneApi + editorApi so it can engage
|
||||||
// move tools, endpoint drags, or any other editor-state transition without
|
// move tools, endpoint drags, or any other editor-state transition without
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import {
|
|||||||
getRoofMaterialArray,
|
getRoofMaterialArray,
|
||||||
useViewer,
|
useViewer,
|
||||||
} from '@pascal-app/viewer'
|
} from '@pascal-app/viewer'
|
||||||
|
import { useThree } from '@react-three/fiber'
|
||||||
import { useCallback, useEffect, useRef } from 'react'
|
import { useCallback, useEffect, useRef } from 'react'
|
||||||
import { type BufferGeometry, Color, type Material, type Mesh, type Object3D, Vector3 } from 'three'
|
import { type BufferGeometry, Color, type Material, type Mesh, type Object3D, Vector3 } from 'three'
|
||||||
import {
|
import {
|
||||||
@@ -701,6 +702,10 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
|
|||||||
export const SelectionManager = () => {
|
export const SelectionManager = () => {
|
||||||
const phase = useEditor((s) => s.phase)
|
const phase = useEditor((s) => s.phase)
|
||||||
const mode = useEditor((s) => s.mode)
|
const mode = useEditor((s) => s.mode)
|
||||||
|
// The canvas element — cursor styling must land here, not on `document.body`:
|
||||||
|
// the editor wraps the canvas in a div with a custom `cursor: url(...)`, which
|
||||||
|
// (being a closer ancestor) overrides any body cursor over the canvas.
|
||||||
|
const glDomElement = useThree((s) => s.gl.domElement)
|
||||||
const setHoverHighlightMode = useViewer((s) => s.setHoverHighlightMode)
|
const setHoverHighlightMode = useViewer((s) => s.setHoverHighlightMode)
|
||||||
const modifierKeysRef = useRef<SelectionModifierKeys>({
|
const modifierKeysRef = useRef<SelectionModifierKeys>({
|
||||||
meta: false,
|
meta: false,
|
||||||
@@ -1252,6 +1257,52 @@ export const SelectionManager = () => {
|
|||||||
}
|
}
|
||||||
}, [isCurveReshape, mode, movingNode])
|
}, [isCurveReshape, mode, movingNode])
|
||||||
|
|
||||||
|
// Move cursor over the selected movable node: the visual cue that clicking it
|
||||||
|
// picks it up (replaces the removed move-cross gizmo). Reacts only when the
|
||||||
|
// hovered/selected node changes (not on every camera move) so it doesn't fight
|
||||||
|
// the rotate/resize gizmos' own hover cursors. Clears only the cursor it owns.
|
||||||
|
useEffect(() => {
|
||||||
|
if (mode !== 'select') return
|
||||||
|
let owns = false
|
||||||
|
let prevKey = ' | ||||||