Add preview slot to NodeDefinition; show translucent shape during move
User feedback: the move tool's CursorSphere is just a vertical line,
hard to tell what you're moving. Placement gets a translucent shape
that follows the cursor; move should too.
NodeDefinition.preview?: () => Promise<{ default: ComponentType<{ node }> }>
opt-in lazy component that renders a translucent ghost of the node.
Used by:
- The placement tool (ShelfTool) — renders the preview at the cursor
position so the user sees the shape they're placing.
- The move tool (MoveRegistryNodeTool) — renders the preview at the
drag target alongside the CursorSphere. Plus the original node is
also dragged via live transforms, so the user sees both: the actual
node moving + a translucent ghost at the same spot.
Implementation:
- New nodes/shelf/preview.tsx: ShelfPreview component. Renders the
same shape as ShelfRenderer but `transparent: true, opacity: 0.5`.
- shelfDefinition.preview = () => import('./preview').
- ShelfTool's placement preview now uses <ShelfPreview node={defaults} />
instead of an inline copy of the box geometry.
- MoveRegistryNodeTool lazy-loads `def.preview` (cached by loader,
Suspense-wrapped). If a kind doesn't define `preview`, only the
CursorSphere shows — matches today's behavior.
Phase 4 may merge `preview` with `renderer` behind an `opacity` prop
so kinds don't duplicate JSX between the solid and translucent
versions; until then defining `preview` is opt-in and one extra file.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
a090c22f42
commit
e84b1ec8bc
@@ -32,6 +32,15 @@ export type NodeDefinition<S extends ZodObject<any>> = {
|
|||||||
tool?: LazyComponent
|
tool?: LazyComponent
|
||||||
affordances?: Affordance<z.infer<S>>[]
|
affordances?: Affordance<z.infer<S>>[]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional translucent preview of the node — used by the move tool to
|
||||||
|
* show where the node will land, and by the placement tool's cursor.
|
||||||
|
* Receives the partially-resolved node (or a default-shaped stub during
|
||||||
|
* placement before any commit has happened). Phase 4 may merge this with
|
||||||
|
* the renderer behind an `opacity` prop.
|
||||||
|
*/
|
||||||
|
preview?: () => Promise<{ default: ComponentType<{ node: z.infer<S> }> }>
|
||||||
|
|
||||||
presentation?: Presentation
|
presentation?: Presentation
|
||||||
mcp?: McpOverrides
|
mcp?: McpOverrides
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,15 @@ import {
|
|||||||
useLiveTransforms,
|
useLiveTransforms,
|
||||||
useScene,
|
useScene,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import {
|
||||||
|
type ComponentType,
|
||||||
|
lazy,
|
||||||
|
Suspense,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from 'react'
|
||||||
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
|
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
|
||||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||||
import useEditor from '../../../store/use-editor'
|
import useEditor from '../../../store/use-editor'
|
||||||
@@ -20,21 +28,44 @@ import { CursorSphere } from '../shared/cursor-sphere'
|
|||||||
|
|
||||||
const roundToHalf = (value: number) => Math.round(value * 2) / 2
|
const roundToHalf = (value: number) => Math.round(value * 2) / 2
|
||||||
|
|
||||||
|
// Cache lazy preview components keyed by their module loader so React.lazy
|
||||||
|
// isn't re-invoked across renders.
|
||||||
|
const previewCache = new WeakMap<() => Promise<unknown>, ComponentType<{ node: AnyNode }>>()
|
||||||
|
|
||||||
|
function loadPreview(node: AnyNode): ComponentType<{ node: AnyNode }> | null {
|
||||||
|
const def = nodeRegistry.get(node.type)
|
||||||
|
if (!def?.preview) return null
|
||||||
|
const cached = previewCache.get(def.preview)
|
||||||
|
if (cached) return cached
|
||||||
|
const Comp = lazy(def.preview as () => Promise<{ default: ComponentType<{ node: AnyNode }> }>)
|
||||||
|
previewCache.set(def.preview, Comp)
|
||||||
|
return Comp
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generic move tool for any registry-backed kind. Mirrors MoveColumnTool's
|
* Generic move tool for any registry-backed kind.
|
||||||
* shape but parses re-creation through `nodeRegistry.get(kind).schema`
|
|
||||||
* instead of a hardcoded schema reference. Used as the fallback in
|
|
||||||
* `<MoveTool>` for kinds without a bespoke mover.
|
|
||||||
*
|
*
|
||||||
* Phase 4 may consolidate this with the per-kind movers if they all
|
* Behavior mirrors MoveColumnTool's shape:
|
||||||
* collapse to the same position+rotation shape — until then they live
|
* - Pauses scene history on activation, resumes on commit / cancel / unmount.
|
||||||
* side by side.
|
* - On each `grid:move`, applies a live transform to the original node so it
|
||||||
|
* visibly follows the cursor (no second copy of the node).
|
||||||
|
* - On `grid:click`, commits the position to the scene store.
|
||||||
|
* - If the kind exposes a `preview` component on its NodeDefinition, render
|
||||||
|
* it as a translucent ghost at the cursor too — the user sees the shape
|
||||||
|
* they're moving (better UX than just CursorSphere's line).
|
||||||
|
*
|
||||||
|
* Phase 4 may merge the preview slot with the renderer behind an `opacity`
|
||||||
|
* prop. Until then, defining `preview` on a NodeDefinition gives nice move
|
||||||
|
* + placement UX for free.
|
||||||
*/
|
*/
|
||||||
export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
||||||
const initialPosition: [number, number, number] =
|
const initialPosition: [number, number, number] = useMemo(
|
||||||
'position' in node && Array.isArray((node as { position?: unknown }).position)
|
() =>
|
||||||
? ((node as { position: [number, number, number] }).position ?? [0, 0, 0])
|
'position' in node && Array.isArray((node as { position?: unknown }).position)
|
||||||
: [0, 0, 0]
|
? ((node as { position: [number, number, number] }).position ?? [0, 0, 0])
|
||||||
|
: [0, 0, 0],
|
||||||
|
[node],
|
||||||
|
)
|
||||||
const [previewPosition, setPreviewPosition] = useState<[number, number, number]>(initialPosition)
|
const [previewPosition, setPreviewPosition] = useState<[number, number, number]>(initialPosition)
|
||||||
|
|
||||||
const exitMoveMode = useCallback(() => {
|
const exitMoveMode = useCallback(() => {
|
||||||
@@ -67,15 +98,11 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
|||||||
const nodeId = node.id
|
const nodeId = node.id
|
||||||
|
|
||||||
if (nodeId && useScene.getState().nodes[nodeId]) {
|
if (nodeId && useScene.getState().nodes[nodeId]) {
|
||||||
// Existing node — just update its position.
|
|
||||||
committed = true
|
committed = true
|
||||||
useLiveTransforms.getState().clear(nodeId)
|
useLiveTransforms.getState().clear(nodeId)
|
||||||
useScene.temporal.getState().resume()
|
useScene.temporal.getState().resume()
|
||||||
useScene.getState().updateNode(nodeId, { position } as Partial<AnyNode>)
|
useScene.getState().updateNode(nodeId, { position } as Partial<AnyNode>)
|
||||||
} else if (node.parentId) {
|
} else if (node.parentId) {
|
||||||
// Orphan re-create path — re-parse the node fresh via the kind's
|
|
||||||
// schema in the registry. Mirrors MoveColumnTool's behavior for
|
|
||||||
// registry-supplied kinds.
|
|
||||||
const def = nodeRegistry.get(node.type)
|
const def = nodeRegistry.get(node.type)
|
||||||
if (def) {
|
if (def) {
|
||||||
const reparsed = def.schema.parse({
|
const reparsed = def.schema.parse({
|
||||||
@@ -124,9 +151,18 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
|||||||
}
|
}
|
||||||
}, [exitMoveMode, initialPosition, node])
|
}, [exitMoveMode, initialPosition, node])
|
||||||
|
|
||||||
// Cursor color from the def's presentation if available, else a neutral fallback.
|
const Preview = loadPreview(node)
|
||||||
const def = nodeRegistry.get(node.type)
|
|
||||||
const cursorColor = def?.presentation?.icon.kind === 'iconify' ? '#a78bfa' : '#a78bfa'
|
|
||||||
|
|
||||||
return <CursorSphere color={cursorColor} height={2.5} position={previewPosition} />
|
return (
|
||||||
|
<>
|
||||||
|
<CursorSphere color="#a78bfa" height={2.5} position={previewPosition} />
|
||||||
|
{Preview && (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<group position={previewPosition}>
|
||||||
|
<Preview node={node} />
|
||||||
|
</group>
|
||||||
|
</Suspense>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export const shelfDefinition: NodeDefinition<typeof ShelfNode> = {
|
|||||||
kind: 'parametric',
|
kind: 'parametric',
|
||||||
module: () => import('./renderer'),
|
module: () => import('./renderer'),
|
||||||
},
|
},
|
||||||
|
preview: () => import('./preview'),
|
||||||
tool: () => import('./tool'),
|
tool: () => import('./tool'),
|
||||||
|
|
||||||
presentation: {
|
presentation: {
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useMemo } from 'react'
|
||||||
|
import { Color } from 'three'
|
||||||
|
import type { ShelfNode } from './schema'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Translucent preview of a shelf. Used by:
|
||||||
|
* - The placement tool's cursor (ShelfTool) — at the cursor position
|
||||||
|
* - The move tool (MoveRegistryNodeTool) — at the drag target position
|
||||||
|
*
|
||||||
|
* Renders the same primitives as the actual ShelfRenderer, but with
|
||||||
|
* `transparent: true, opacity: 0.5` so the user can see what they're
|
||||||
|
* placing/moving without it being a hard solid.
|
||||||
|
*/
|
||||||
|
const ShelfPreview = ({ node }: { node: ShelfNode }) => {
|
||||||
|
const color = useMemo(() => new Color(node.color), [node.color])
|
||||||
|
const topY = node.height + node.thickness / 2
|
||||||
|
|
||||||
|
const inset = Math.min(0.12, node.width / 6)
|
||||||
|
const bracketHeight = Math.max(0.01, node.height)
|
||||||
|
const bracketWidth =
|
||||||
|
node.bracketStyle === 'industrial'
|
||||||
|
? Math.max(0.04, node.depth * 0.2)
|
||||||
|
: Math.max(0.02, node.depth * 0.12)
|
||||||
|
const bracketDepth = node.bracketStyle === 'industrial' ? node.depth * 0.95 : node.depth * 0.7
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
<mesh position={[0, topY, 0]}>
|
||||||
|
<boxGeometry args={[node.width, node.thickness, node.depth]} />
|
||||||
|
<meshStandardMaterial color={color} transparent opacity={0.5} />
|
||||||
|
</mesh>
|
||||||
|
{node.bracketStyle !== 'hidden' && (
|
||||||
|
<>
|
||||||
|
<mesh position={[-(node.width / 2 - inset), bracketHeight / 2, 0]}>
|
||||||
|
<boxGeometry args={[bracketWidth, bracketHeight, bracketDepth]} />
|
||||||
|
<meshStandardMaterial color={color} transparent opacity={0.5} />
|
||||||
|
</mesh>
|
||||||
|
<mesh position={[node.width / 2 - inset, bracketHeight / 2, 0]}>
|
||||||
|
<boxGeometry args={[bracketWidth, bracketHeight, bracketDepth]} />
|
||||||
|
<meshStandardMaterial color={color} transparent opacity={0.5} />
|
||||||
|
</mesh>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</group>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ShelfPreview
|
||||||
@@ -10,8 +10,9 @@ import {
|
|||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import { triggerSFX } from '@pascal-app/editor'
|
import { triggerSFX } from '@pascal-app/editor'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useMemo, useRef } from 'react'
|
||||||
import { type Group, Vector3 } from 'three'
|
import { type Group, Vector3 } from 'three'
|
||||||
|
import ShelfPreview from './preview'
|
||||||
|
|
||||||
const worldVector = new Vector3()
|
const worldVector = new Vector3()
|
||||||
const GRID_STEP = 0.5
|
const GRID_STEP = 0.5
|
||||||
@@ -36,22 +37,19 @@ function getLevelLocalPosition(levelId: string, event: GridEvent): [number, numb
|
|||||||
return [sx, worldVector.y, sz]
|
return [sx, worldVector.y, sz]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cursor preview dimensions — match the shelf's default schema dimensions.
|
|
||||||
// Once the shelf has user-tunable defaults in the inspector, this can pull
|
|
||||||
// from the active draft.
|
|
||||||
const PREVIEW_WIDTH = 1.2
|
|
||||||
const PREVIEW_DEPTH = 0.3
|
|
||||||
const PREVIEW_THICKNESS = 0.04
|
|
||||||
const PREVIEW_HEIGHT = 0.9
|
|
||||||
const PREVIEW_INSET = Math.min(0.12, PREVIEW_WIDTH / 6)
|
|
||||||
const PREVIEW_BRACKET_WIDTH = Math.max(0.02, PREVIEW_DEPTH * 0.12)
|
|
||||||
const PREVIEW_BRACKET_DEPTH = PREVIEW_DEPTH * 0.7
|
|
||||||
|
|
||||||
const ShelfTool = () => {
|
const ShelfTool = () => {
|
||||||
const activeLevelId = useViewer((state) => state.selection.levelId)
|
const activeLevelId = useViewer((state) => state.selection.levelId)
|
||||||
const cursorRef = useRef<Group>(null)
|
const cursorRef = useRef<Group>(null)
|
||||||
const previousSnapRef = useRef<[number, number] | null>(null)
|
const previousSnapRef = useRef<[number, number] | null>(null)
|
||||||
|
|
||||||
|
// Default-shaped shelf for the placement preview. Same shape the move tool
|
||||||
|
// uses (both reach for `shelfDefinition.preview`) so placement and move
|
||||||
|
// look identical.
|
||||||
|
const previewNode = useMemo(
|
||||||
|
() => ShelfNode.parse({ name: 'Shelf', position: [0, 0, 0], rotation: [0, 0, 0] }),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activeLevelId) return
|
if (!activeLevelId) return
|
||||||
previousSnapRef.current = null
|
previousSnapRef.current = null
|
||||||
@@ -60,8 +58,6 @@ const ShelfTool = () => {
|
|||||||
const [sx, sz] = snapPointToGrid([event.localPosition[0], event.localPosition[2]], GRID_STEP)
|
const [sx, sz] = snapPointToGrid([event.localPosition[0], event.localPosition[2]], GRID_STEP)
|
||||||
cursorRef.current?.position.set(sx, event.localPosition[1], sz)
|
cursorRef.current?.position.set(sx, event.localPosition[1], sz)
|
||||||
|
|
||||||
// Fire grid-snap SFX only when the snapped position crosses a cell,
|
|
||||||
// matching the wall / slab / curve tools.
|
|
||||||
const prev = previousSnapRef.current
|
const prev = previousSnapRef.current
|
||||||
if (!prev || prev[0] !== sx || prev[1] !== sz) {
|
if (!prev || prev[0] !== sx || prev[1] !== sz) {
|
||||||
triggerSFX('sfx:grid-snap')
|
triggerSFX('sfx:grid-snap')
|
||||||
@@ -94,26 +90,12 @@ const ShelfTool = () => {
|
|||||||
|
|
||||||
if (!activeLevelId) return null
|
if (!activeLevelId) return null
|
||||||
|
|
||||||
// Cursor preview: ghostly version of the full shelf (top board + brackets)
|
// Cursor preview: defers to the shared ShelfPreview component used by the
|
||||||
// so the user sees the same shape they're placing. Position is updated
|
// move tool too. Position is updated imperatively via the ref; no React
|
||||||
// imperatively via the ref; no React state, no re-render cycles.
|
// state, no re-render cycles.
|
||||||
return (
|
return (
|
||||||
<group ref={cursorRef}>
|
<group ref={cursorRef}>
|
||||||
{/* Top board */}
|
<ShelfPreview node={previewNode} />
|
||||||
<mesh position={[0, PREVIEW_HEIGHT + PREVIEW_THICKNESS / 2, 0]}>
|
|
||||||
<boxGeometry args={[PREVIEW_WIDTH, PREVIEW_THICKNESS, PREVIEW_DEPTH]} />
|
|
||||||
<meshStandardMaterial color="#a07050" transparent opacity={0.5} />
|
|
||||||
</mesh>
|
|
||||||
{/* Left bracket */}
|
|
||||||
<mesh position={[-(PREVIEW_WIDTH / 2 - PREVIEW_INSET), PREVIEW_HEIGHT / 2, 0]}>
|
|
||||||
<boxGeometry args={[PREVIEW_BRACKET_WIDTH, PREVIEW_HEIGHT, PREVIEW_BRACKET_DEPTH]} />
|
|
||||||
<meshStandardMaterial color="#a07050" transparent opacity={0.5} />
|
|
||||||
</mesh>
|
|
||||||
{/* Right bracket */}
|
|
||||||
<mesh position={[PREVIEW_WIDTH / 2 - PREVIEW_INSET, PREVIEW_HEIGHT / 2, 0]}>
|
|
||||||
<boxGeometry args={[PREVIEW_BRACKET_WIDTH, PREVIEW_HEIGHT, PREVIEW_BRACKET_DEPTH]} />
|
|
||||||
<meshStandardMaterial color="#a07050" transparent opacity={0.5} />
|
|
||||||
</mesh>
|
|
||||||
</group>
|
</group>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user