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,
|
||||
type PluginDiscovery,
|
||||
registerNode,
|
||||
resolveFacingIndicator,
|
||||
setPluginDiscovery,
|
||||
} from './registry'
|
||||
export {
|
||||
|
||||
@@ -178,6 +178,18 @@ export function isPresettableKind(kind: string): boolean {
|
||||
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`,
|
||||
* `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
|
||||
category: NodeCategory
|
||||
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 /
|
||||
* 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),
|
||||
// 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
|
||||
// capturing presets; reset on exit so general editing keeps the looser
|
||||
// navigation guardrails.
|
||||
const isPresetCapture = captureMode.mode === 'preset'
|
||||
const minDistance = isPresetCapture ? 0.5 : 6
|
||||
const minDistance = isPresetCapture ? 0.5 : 2
|
||||
|
||||
if (isFirstPersonMode) {
|
||||
return null
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
DEFAULT_WALL_HEIGHT,
|
||||
DoorNode,
|
||||
ElevatorNode,
|
||||
emitter,
|
||||
FenceNode,
|
||||
generateId,
|
||||
getActiveRoofHeight,
|
||||
@@ -215,6 +216,7 @@ export function FloatingActionMenu() {
|
||||
const updateNode = useScene((s) => s.updateNode)
|
||||
const mode = useEditor((s) => s.mode)
|
||||
const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered)
|
||||
const canFindNode = useEditor((s) => s.canFindNode)
|
||||
const endpointReshape = useEndpointReshape()
|
||||
const isCurveReshape = useIsCurveReshape()
|
||||
const setMovingNode = useEditor((s) => s.setMovingNode)
|
||||
@@ -655,6 +657,16 @@ export function FloatingActionMenu() {
|
||||
[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 (
|
||||
!(selectedId && node && isValidType && !isFloorplanHovered && mode !== 'delete') ||
|
||||
endpointReshape ||
|
||||
@@ -676,6 +688,7 @@ export function FloatingActionMenu() {
|
||||
>
|
||||
<div className="relative" ref={menuScaleRef} style={{ transformOrigin: 'center center' }}>
|
||||
<NodeActionMenu
|
||||
onFind={node && canFindNode ? handleFind : undefined}
|
||||
onAddHole={node && HOLE_TYPES.includes(node.type) ? handleAddHole : undefined}
|
||||
onCurve={
|
||||
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.
|
||||
// Snapping is governed by the snapping mode (`'off'` is the bypass);
|
||||
// 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 fenceSnapped = snapFenceDraftPoint({
|
||||
point: planPoint,
|
||||
@@ -9361,7 +9361,7 @@ export function FloorplanPanel({
|
||||
: (publishedNextStart ?? point)
|
||||
|
||||
if (
|
||||
useEditor.getState().wallChainMode === 'single' ||
|
||||
useEditor.getState().getContinuation('wall') === 'single' ||
|
||||
(wallChainFirstVertex && isWithinWallJoinSnapRadius(nextStart, wallChainFirstVertex))
|
||||
) {
|
||||
clearWallPlacementDraft()
|
||||
|
||||
@@ -1,14 +1,28 @@
|
||||
'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 { useFrame } from '@react-three/fiber'
|
||||
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 { MeshBasicNodeMaterial } from 'three/webgpu'
|
||||
import { useCeilingEvents } from '../../hooks/use-ceiling-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 = ({
|
||||
cellSize = 0.5,
|
||||
@@ -38,6 +52,16 @@ export const Grid = ({
|
||||
const effectiveSectionColor = isDark ? '#666677' : sectionColor
|
||||
|
||||
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(() => {
|
||||
// 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
|
||||
// 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 fw = fwidth(r)
|
||||
// Distance to nearest grid line for each axis
|
||||
@@ -70,7 +94,7 @@ export const Grid = ({
|
||||
return lineX.max(lineY)
|
||||
}
|
||||
|
||||
const g1 = getGrid(cellSize, cellThickness)
|
||||
const g1 = getGrid(cellSizeUniform, cellThickness)
|
||||
const g2 = getGrid(sectionSize, sectionThickness)
|
||||
|
||||
// Distance fade from center
|
||||
@@ -79,7 +103,9 @@ export const Grid = ({
|
||||
|
||||
// Cursor reveal effect - distance from cursor
|
||||
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
|
||||
const gridColor = mix(
|
||||
@@ -88,12 +114,10 @@ export const Grid = ({
|
||||
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
|
||||
const alpha = g1.add(g2).mul(fade).mul(cursorFade.max(baseAlpha))
|
||||
const finalAlpha = mix(alpha.mul(0.75), alpha, g2)
|
||||
const alpha = g1.add(g2).mul(fade).mul(cursorFade.max(baseAlphaUniform))
|
||||
const boostedAlpha = alpha.mul(patchAlphaUniform).min(1)
|
||||
const finalAlpha = mix(boostedAlpha.mul(0.75), boostedAlpha, g2)
|
||||
|
||||
return new MeshBasicNodeMaterial({
|
||||
transparent: true,
|
||||
@@ -102,7 +126,6 @@ export const Grid = ({
|
||||
depthWrite: false,
|
||||
})
|
||||
}, [
|
||||
cellSize,
|
||||
cellThickness,
|
||||
effectiveCellColor,
|
||||
sectionSize,
|
||||
@@ -110,7 +133,10 @@ export const Grid = ({
|
||||
effectiveSectionColor,
|
||||
fadeDistance,
|
||||
fadeStrength,
|
||||
revealRadius,
|
||||
revealRadiusUniform,
|
||||
baseAlphaUniform,
|
||||
cellSizeUniform,
|
||||
patchAlphaUniform,
|
||||
])
|
||||
|
||||
const gridRef = useRef<Mesh>(null!)
|
||||
@@ -145,30 +171,80 @@ export const Grid = ({
|
||||
|
||||
useFrame((_, delta) => {
|
||||
const { levelId } = useViewer.getState().selection
|
||||
// Grid stays anchored to world XZ (0, 0) — never chases the active
|
||||
// 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
|
||||
let levelY = 0
|
||||
if (levelId) {
|
||||
const levelMesh = sceneRegistry.nodes.get(levelId)
|
||||
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
|
||||
// is just the world cursor (mirrored on Z to match the -π/2 X-rotation
|
||||
// of the plane).
|
||||
// Resolve the surface the active ghost is snapped to (contact point +
|
||||
// normal). A fresh GLB item / drawn kind publishes via the surface module; a
|
||||
// moving node is read straight off its mesh (treated as horizontal). Null
|
||||
// when nothing is being placed.
|
||||
const published = getPlacementSurface()
|
||||
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 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)
|
||||
}
|
||||
|
||||
const showGrid = useViewer((state) => state.showGrid)
|
||||
// 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
|
||||
// is never reconciled with R3F's empty placeholder `BufferGeometry`.
|
||||
@@ -183,13 +259,8 @@ export const Grid = ({
|
||||
useEffect(() => () => geometry.dispose(), [geometry])
|
||||
|
||||
return (
|
||||
<mesh
|
||||
geometry={geometry}
|
||||
layers={GRID_LAYER}
|
||||
material={material}
|
||||
ref={gridRef}
|
||||
rotation-x={-Math.PI / 2}
|
||||
visible={showGrid}
|
||||
/>
|
||||
// Orientation is driven imperatively in `useFrame` (horizontal by default,
|
||||
// tilted into the wall plane while placing on a wall), so no static rotation.
|
||||
<mesh geometry={geometry} layers={GRID_LAYER} material={material} ref={gridRef} />
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
import { Icon } from '@iconify/react'
|
||||
import {
|
||||
getCatalogMaterialById,
|
||||
getLibraryMaterialIdFromRef,
|
||||
getSceneMaterialIdFromRef,
|
||||
initSpaceDetectionSync,
|
||||
initSpatialGridSync,
|
||||
spatialGridManager,
|
||||
@@ -19,6 +22,7 @@ import { ViewerOverlay } from '../../components/viewer-overlay'
|
||||
import { ViewerZoneSystem } from '../../components/viewer-zone-system'
|
||||
import { type SaveStatus, useAutoSave } from '../../hooks/use-auto-save'
|
||||
import { useKeyboard } from '../../hooks/use-keyboard'
|
||||
import { type ActivePaintMaterial, hasActivePaintMaterial } from '../../lib/material-paint'
|
||||
import {
|
||||
applySceneGraphToEditor,
|
||||
loadSceneFromLocalStorage,
|
||||
@@ -81,6 +85,7 @@ const PAINT_CURSOR_BADGE_DISABLED_COLOR = '#94a3b8'
|
||||
const PAINT_CURSOR_BADGE_OFFSET_X = 14
|
||||
const PAINT_CURSOR_BADGE_OFFSET_Y = 14
|
||||
const SCENE_READY_FALLBACK_MS = 8000
|
||||
type PaintCursorBadgeState = 'empty' | 'ready' | 'blocked'
|
||||
const EDITOR_HOVER_STYLES: HoverStyles = {
|
||||
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 },
|
||||
@@ -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({
|
||||
position,
|
||||
disabled,
|
||||
state,
|
||||
swatchColor,
|
||||
swatchImageUrl,
|
||||
isEraser,
|
||||
}: {
|
||||
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
|
||||
|
||||
return (
|
||||
@@ -576,7 +637,48 @@ function PaintCursorBadge({
|
||||
aria-hidden="true"
|
||||
className="h-5 w-5 object-contain drop-shadow-[0_2px_4px_rgba(0,0,0,0.5)]"
|
||||
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>
|
||||
)
|
||||
@@ -730,6 +832,9 @@ function PaintCursorLayer({
|
||||
}) {
|
||||
const mode = useEditor((s) => s.mode)
|
||||
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 active = mode === 'material-paint' && !isVersionPreviewMode
|
||||
|
||||
@@ -779,11 +884,14 @@ function PaintCursorLayer({
|
||||
}
|
||||
}, [active, containerRef])
|
||||
|
||||
const hasMaterial = Boolean(
|
||||
activePaintMaterial &&
|
||||
(activePaintMaterial.material !== undefined ||
|
||||
activePaintMaterial.materialPreset !== undefined),
|
||||
)
|
||||
const hasPaint = paintEraser || hasActivePaintMaterial(activePaintMaterial)
|
||||
const badgeState: PaintCursorBadgeState = !hasPaint
|
||||
? 'empty'
|
||||
: paintHover != null
|
||||
? 'ready'
|
||||
: 'blocked'
|
||||
const swatchColor = getActivePaintMaterialSwatchColor(activePaintMaterial, sceneMaterials)
|
||||
const swatchImageUrl = getActivePaintMaterialSwatchImageUrl(activePaintMaterial, sceneMaterials)
|
||||
|
||||
if (!active || !position) return null
|
||||
|
||||
@@ -792,7 +900,13 @@ function PaintCursorLayer({
|
||||
className="pointer-events-none absolute z-40"
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
'use client'
|
||||
|
||||
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'
|
||||
|
||||
type NodeActionMenuProps = {
|
||||
onFind?: MouseEventHandler<HTMLButtonElement>
|
||||
onAddHole?: MouseEventHandler<HTMLButtonElement>
|
||||
onDelete?: MouseEventHandler<HTMLButtonElement>
|
||||
onDuplicate?: MouseEventHandler<HTMLButtonElement>
|
||||
@@ -17,6 +18,7 @@ type NodeActionMenuProps = {
|
||||
}
|
||||
|
||||
export function NodeActionMenu({
|
||||
onFind,
|
||||
onAddHole,
|
||||
onDelete,
|
||||
onDuplicate,
|
||||
@@ -35,6 +37,17 @@ export function NodeActionMenu({
|
||||
onPointerLeave={onPointerLeave}
|
||||
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 && (
|
||||
<button
|
||||
aria-label="Move"
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
sceneRegistry,
|
||||
snapScalar,
|
||||
type TapActionHandle,
|
||||
type TranslateHandle,
|
||||
useLiveNodeOverrides,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
@@ -45,7 +44,6 @@ import { MeshBasicNodeMaterial } from 'three/webgpu'
|
||||
import { EDITOR_LAYER } from '../../lib/constants'
|
||||
import { RESIZE_HANDLE_DRAG_LABEL, ROTATE_HANDLE_DRAG_LABEL } from '../../lib/contextual-help'
|
||||
import { createEditorApi } from '../../lib/editor-api'
|
||||
import { sfxEmitter } from '../../lib/sfx-bus'
|
||||
import useDirectManipulationFeedback from '../../store/use-direct-manipulation-feedback'
|
||||
import useEditor from '../../store/use-editor'
|
||||
import useInteractionScope, {
|
||||
@@ -54,7 +52,6 @@ import useInteractionScope, {
|
||||
useMovingNode,
|
||||
} from '../../store/use-interaction-scope'
|
||||
import useOpeningGuides from '../../store/use-opening-guides'
|
||||
import { suppressBoxSelectForPointer } from '../tools/select/box-select-state'
|
||||
import { formatAngleRadians } from '../tools/shared/segment-angle'
|
||||
import {
|
||||
ARROW_COLOR,
|
||||
@@ -209,9 +206,15 @@ export function NodeArrowHandles() {
|
||||
const def = node ? nodeRegistry.get(node.type) : null
|
||||
const descriptors = useMemo(() => {
|
||||
if (!(node && def?.handles)) return null
|
||||
return typeof def.handles === 'function'
|
||||
const all =
|
||||
typeof def.handles === 'function'
|
||||
? 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])
|
||||
|
||||
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') {
|
||||
// Tap-action handles (fence side-move arrows, corner pickers) aren't
|
||||
// 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
|
||||
// descriptor's `onActivate` receives sceneApi + editorApi so it can engage
|
||||
// move tools, endpoint drags, or any other editor-state transition without
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
getRoofMaterialArray,
|
||||
useViewer,
|
||||
} from '@pascal-app/viewer'
|
||||
import { useThree } from '@react-three/fiber'
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import { type BufferGeometry, Color, type Material, type Mesh, type Object3D, Vector3 } from 'three'
|
||||
import {
|
||||
@@ -701,6 +702,10 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
|
||||
export const SelectionManager = () => {
|
||||
const phase = useEditor((s) => s.phase)
|
||||
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 modifierKeysRef = useRef<SelectionModifierKeys>({
|
||||
meta: false,
|
||||
@@ -1252,6 +1257,52 @@ export const SelectionManager = () => {
|
||||
}
|
||||
}, [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 = ' | ||||