feat(editor): preset placement polish — params, move tool, shadows, isolation (#357)

* feat(editor): preset placement polish — params, move tool, shadows

- wall/slab/ceiling/roof create paths consume `toolDefaults` so template
  presets build with their saved params; cleared on tool unmount
- wall draw preview reflects the preset's height/thickness (+ HUD labels)
- box-select picks up registry-selectable kinds (shelf) via bbox
- registry + column move tools: snap to the active grid step, R/T rotation,
  and ignore the stray trailing click that armed the move (no double-place)
- shelf geometry casts + receives shadows like fence/slab

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(viewer): expose isIsolationActive() for isolation-aware consumers

Tracks whether an isolation filter is currently applied and exposes it so
hosts can avoid acting on the partial view — e.g. skipping project-thumbnail
autosave while a single subtree is isolated (preset capture).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-06-02 10:17:15 -04:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 8abfc94b99
commit f7ff60561e
11 changed files with 277 additions and 46 deletions
+15 -2
View File
@@ -1,7 +1,13 @@
'use client'
import { emitter, type GridEvent, type LevelNode, useScene } from '@pascal-app/core'
import { CursorSphere, EDITOR_LAYER, markToolCancelConsumed, triggerSFX } from '@pascal-app/editor'
import {
CursorSphere,
EDITOR_LAYER,
markToolCancelConsumed,
triggerSFX,
useEditor,
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useRef, useState } from 'react'
import { BufferGeometry, DoubleSide, type Group, type Line, Shape, Vector3 } from 'three'
@@ -46,7 +52,10 @@ function commitCeilingDrawing(levelId: LevelNode['id'], points: Array<[number, n
const { createNode, nodes } = useScene.getState()
const ceilingCount = Object.values(nodes).filter((n) => n.type === 'ceiling').length
const name = `Ceiling ${ceilingCount + 1}`
const ceiling = CeilingNode.parse({ name, polygon: points })
// A placed ceiling preset seeds `toolDefaults.ceiling` (thickness, height,
// material, …) before the tool activates; the drawn polygon always wins.
const defaults = useEditor.getState().toolDefaults.ceiling ?? {}
const ceiling = CeilingNode.parse({ ...defaults, name, polygon: points })
createNode(ceiling, levelId)
triggerSFX('sfx:structure-build')
return ceiling.id
@@ -70,6 +79,10 @@ export const CeilingTool: React.FC = () => {
const previousSnappedPointRef = useRef<[number, number] | null>(null)
const shiftPressed = useRef(false)
// Clear preset-seeded defaults on deactivation so a later manual ceiling
// draw isn't built with a stale preset's parameters. Unmount-only.
useEffect(() => () => useEditor.getState().setToolDefaults('ceiling', null), [])
const verticalGeo = useMemo(
() =>
new BufferGeometry().setFromPoints([
+61 -13
View File
@@ -28,7 +28,14 @@ import { useCallback, useEffect, useState } from 'react'
* dispatcher's `getRegistryAffordanceTool('column', 'move')` lookup
* picks this up before its legacy chain reaches `<MoveColumnTool>`.
*/
const roundToHalf = (value: number) => Math.round(value * 2) / 2
/** Snap to the editor's active grid step (0.5 / 0.25 / 0.1 / 0.05), read live. */
const snapToGridStep = (value: number) => {
const step = useEditor.getState().gridSnapStep
return Math.round(value / step) * step
}
/** 90° steps, matching the GLB item / shelf placement rotation. */
const ROTATION_STEP = Math.PI / 2
function MoveColumnTool({ node }: { node: ColumnNode }) {
const [previewPosition, setPreviewPosition] = useState<[number, number, number]>(node.position)
@@ -40,6 +47,14 @@ function MoveColumnTool({ node }: { node: ColumnNode }) {
useEffect(() => {
useScene.temporal.getState().pause()
let committed = false
// Ignore a commit before the cursor has moved into place: it's the stray
// trailing click of whatever armed this move (e.g. a preset re-arming the
// next copy right after a placement click), not a deliberate drop.
let hasMoved = false
// Live Y-rotation, seeded from the column and bumped by R/T.
let rotationY = node.rotation
// Latest previewed position, so an R/T press can re-apply at the spot.
let lastPosition: [number, number, number] = node.position
const meta =
typeof node.metadata === 'object' && node.metadata !== null
? (node.metadata as Record<string, unknown>)
@@ -47,23 +62,47 @@ function MoveColumnTool({ node }: { node: ColumnNode }) {
const isNew = !!meta.isNew
const applyPreview = (position: [number, number, number]) => {
lastPosition = position
setPreviewPosition(position)
useLiveTransforms.getState().set(node.id, {
position,
rotation: node.rotation,
rotation: rotationY,
})
sceneRegistry.nodes.get(node.id)?.position.set(position[0], position[1], position[2])
const m = sceneRegistry.nodes.get(node.id)
if (m) {
m.position.set(position[0], position[1], position[2])
m.rotation.y = rotationY
}
}
const onGridMove = (event: GridEvent) => {
applyPreview([roundToHalf(event.localPosition[0]), 0, roundToHalf(event.localPosition[2])])
hasMoved = true
applyPreview([
snapToGridStep(event.localPosition[0]),
0,
snapToGridStep(event.localPosition[2]),
])
}
// R / T rotate the dragged column about Y in 90° steps (matches the move
// HUD's "Rotate" hints), committed on drop.
const onKeyDown = (e: KeyboardEvent) => {
if (e.metaKey || e.ctrlKey || e.altKey) return
let delta = 0
if (e.key === 'r' || e.key === 'R') delta = ROTATION_STEP
else if (e.key === 't' || e.key === 'T') delta = -ROTATION_STEP
else return
e.preventDefault()
rotationY += delta
applyPreview(lastPosition)
}
const onGridClick = (event: GridEvent) => {
if (!hasMoved) return
const position: [number, number, number] = [
roundToHalf(event.localPosition[0]),
snapToGridStep(event.localPosition[0]),
0,
roundToHalf(event.localPosition[2]),
snapToGridStep(event.localPosition[2]),
]
const nodeId = (node as { id?: ColumnNode['id'] }).id
@@ -71,13 +110,16 @@ function MoveColumnTool({ node }: { node: ColumnNode }) {
committed = true
useLiveTransforms.getState().clear(nodeId)
useScene.temporal.getState().resume()
useScene.getState().updateNode(nodeId, { position, ...(isNew ? { metadata: {} } : {}) })
useScene
.getState()
.updateNode(nodeId, { position, rotation: rotationY, ...(isNew ? { metadata: {} } : {}) })
} else if (node.parentId) {
const column = ColumnNodeSchema.parse({
...node,
id: undefined,
metadata: {},
position,
rotation: rotationY,
})
committed = true
useScene.temporal.getState().resume()
@@ -92,27 +134,33 @@ function MoveColumnTool({ node }: { node: ColumnNode }) {
const onCancel = () => {
useLiveTransforms.getState().clear(node.id)
sceneRegistry.nodes
.get(node.id)
?.position.set(node.position[0], node.position[1], node.position[2])
const m = sceneRegistry.nodes.get(node.id)
if (m) {
m.position.set(node.position[0], node.position[1], node.position[2])
m.rotation.y = node.rotation
}
useScene.temporal.getState().resume()
markToolCancelConsumed()
exitMoveMode()
}
window.addEventListener('keydown', onKeyDown)
emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick)
emitter.on('tool:cancel', onCancel)
return () => {
window.removeEventListener('keydown', onKeyDown)
emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick)
emitter.off('tool:cancel', onCancel)
useLiveTransforms.getState().clear(node.id)
if (!committed) {
sceneRegistry.nodes
.get(node.id)
?.position.set(node.position[0], node.position[1], node.position[2])
const m = sceneRegistry.nodes.get(node.id)
if (m) {
m.position.set(node.position[0], node.position[1], node.position[2])
m.rotation.y = node.rotation
}
useScene.temporal.getState().resume()
}
}
+7
View File
@@ -93,6 +93,13 @@ export function buildShelfGeometry(
break
}
// Boards/brackets cast + receive shadows like the other geometry-driven
// kinds (fence, slab). Set once here rather than on every `new Mesh` above.
for (const child of group.children) {
child.castShadow = true
child.receiveShadow = true
}
return group
}
+15 -2
View File
@@ -1,7 +1,13 @@
'use client'
import { emitter, type GridEvent, type LevelNode, useScene } from '@pascal-app/core'
import { CursorSphere, EDITOR_LAYER, markToolCancelConsumed, triggerSFX } from '@pascal-app/editor'
import {
CursorSphere,
EDITOR_LAYER,
markToolCancelConsumed,
triggerSFX,
useEditor,
} from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useRef, useState } from 'react'
import { BufferGeometry, DoubleSide, type Group, type Line, Shape, Vector3 } from 'three'
@@ -47,7 +53,10 @@ function commitSlabDrawing(levelId: LevelNode['id'], points: Array<[number, numb
const { createNode, nodes } = useScene.getState()
const slabCount = Object.values(nodes).filter((n) => n.type === 'slab').length
const name = `Slab ${slabCount + 1}`
const slab = SlabNode.parse({ name, polygon: points })
// A placed slab preset seeds `toolDefaults.slab` (thickness, material, …)
// before the tool activates; the drawn polygon always wins.
const defaults = useEditor.getState().toolDefaults.slab ?? {}
const slab = SlabNode.parse({ ...defaults, name, polygon: points })
createNode(slab, levelId)
triggerSFX('sfx:structure-build')
return slab.id
@@ -67,6 +76,10 @@ export const SlabTool: React.FC = () => {
const previousSnappedPointRef = useRef<[number, number] | null>(null)
const shiftPressed = useRef(false)
// Clear preset-seeded defaults on deactivation so a later manual slab draw
// isn't built with a stale preset's parameters. Unmount-only.
useEffect(() => () => useEditor.getState().setToolDefaults('slab', null), [])
useEffect(() => {
if (!currentLevelId) return
+51 -12
View File
@@ -21,6 +21,7 @@ import {
type SegmentAngleReference,
snapWallDraftPoint,
triggerSFX,
useEditor,
WALL_FINE_GRID_STEP,
type WallPlanPoint,
} from '@pascal-app/editor'
@@ -44,9 +45,11 @@ import { BoxGeometry, BufferGeometry, DoubleSide, type Group, type Mesh, Vector3
*/
const WALL_HEIGHT = 2.5
const DRAFT_WALL_THICKNESS = 0.1
const DRAFT_LABEL_Y = WALL_HEIGHT + 0.22
const DRAFT_ANGLE_LABEL_Y = WALL_HEIGHT + 0.08
const DRAFT_ANGLE_ARC_Y = WALL_HEIGHT + 0.012
// HUD label heights are measured from the top of the preview bar, so they
// track whatever height a seeded preset draws at (`previewHeight`).
const DRAFT_LABEL_Y_OFFSET = 0.22
const DRAFT_ANGLE_LABEL_Y_OFFSET = 0.08
const DRAFT_ANGLE_ARC_Y_OFFSET = 0.012
const DRAFT_ANGLE_ARC_MIN_RADIUS = 0.32
const DRAFT_ANGLE_ARC_MAX_RADIUS = 0.72
const DRAFT_ANGLE_ARC_SEGMENTS = 24
@@ -259,6 +262,7 @@ function getDraftAngleLabels(
end: WallPlanPoint,
walls: WallNode[],
baseY: number,
previewHeight: number,
): DraftAngleLabel[] {
const draftFromStart: WallPlanPoint = [end[0] - start[0], end[1] - start[1]]
const draftFromEnd: WallPlanPoint = [start[0] - end[0], start[1] - end[1]]
@@ -313,7 +317,7 @@ function getDraftAngleLabels(
label: formatAngleRadians(angle),
position: [
arcCenter[0] + Math.cos(arc.midAngle) * (radius + 0.16),
baseY + DRAFT_ANGLE_LABEL_Y,
baseY + previewHeight + DRAFT_ANGLE_LABEL_Y_OFFSET,
arcCenter[1] + Math.sin(arc.midAngle) * (radius + 0.16),
],
arc: {
@@ -321,7 +325,7 @@ function getDraftAngleLabels(
radius,
startAngle: arc.startAngle,
endAngle: arc.endAngle,
y: baseY + DRAFT_ANGLE_ARC_Y,
y: baseY + previewHeight + DRAFT_ANGLE_ARC_Y_OFFSET,
},
})
}
@@ -335,6 +339,7 @@ function getDraftMeasurementState(
walls: WallNode[],
unit: 'metric' | 'imperial',
baseY: number,
previewHeight: number,
): DraftMeasurementState {
const dx = end[0] - start[0]
const dz = end[1] - start[1]
@@ -342,12 +347,22 @@ function getDraftMeasurementState(
if (length < 0.01) return null
return {
lengthLabel: formatMeasurement(length, unit),
lengthPosition: [(start[0] + end[0]) / 2, baseY + DRAFT_LABEL_Y, (start[1] + end[1]) / 2],
angleLabels: getDraftAngleLabels(start, end, walls, baseY),
lengthPosition: [
(start[0] + end[0]) / 2,
baseY + previewHeight + DRAFT_LABEL_Y_OFFSET,
(start[1] + end[1]) / 2,
],
angleLabels: getDraftAngleLabels(start, end, walls, baseY, previewHeight),
}
}
function updateWallPreview(mesh: Mesh, start: Vector3, end: Vector3) {
function updateWallPreview(
mesh: Mesh,
start: Vector3,
end: Vector3,
previewHeight: number,
previewThickness: number,
) {
const direction = new Vector3(end.x - start.x, 0, end.z - start.z)
const length = direction.length()
if (length < 0.01) {
@@ -357,10 +372,10 @@ function updateWallPreview(mesh: Mesh, start: Vector3, end: Vector3) {
mesh.visible = true
direction.normalize()
const geometry = new BoxGeometry(length, WALL_HEIGHT, DRAFT_WALL_THICKNESS)
const geometry = new BoxGeometry(length, previewHeight, previewThickness)
const angle = Math.atan2(direction.z, direction.x)
mesh.position.set((start.x + end.x) / 2, start.y + WALL_HEIGHT / 2, (start.z + end.z) / 2)
mesh.position.set((start.x + end.x) / 2, start.y + previewHeight / 2, (start.z + end.z) / 2)
mesh.rotation.y = -angle
if (mesh.geometry) {
@@ -383,6 +398,19 @@ function getCurrentLevelWalls(): WallNode[] {
export const WallTool: React.FC = () => {
const unit = useViewer((state) => state.unit)
const isDark = useViewer((state) => getSceneTheme(state.sceneTheme).appearance === 'dark')
// A placed wall preset seeds `toolDefaults.wall` (height / thickness …)
// before the tool mounts, so the draft preview is drawn at the preset's
// dimensions rather than the generic fallbacks — matching the wall that
// will be created. Read through refs so the live event handlers below see
// the latest values without re-subscribing.
const wallDefaults = useEditor((s) => s.toolDefaults.wall)
const previewHeight = typeof wallDefaults?.height === 'number' ? wallDefaults.height : WALL_HEIGHT
const previewThickness =
typeof wallDefaults?.thickness === 'number' ? wallDefaults.thickness : DRAFT_WALL_THICKNESS
const previewHeightRef = useRef(previewHeight)
previewHeightRef.current = previewHeight
const previewThicknessRef = useRef(previewThickness)
previewThicknessRef.current = previewThickness
const cursorRef = useRef<Group>(null)
const wallPreviewRef = useRef<Mesh>(null!)
const startingPoint = useRef(new Vector3(0, 0, 0))
@@ -393,6 +421,10 @@ export const WallTool: React.FC = () => {
const measurementColor = isDark ? '#ffffff' : '#111111'
const measurementShadowColor = isDark ? '#111111' : '#ffffff'
// Clear preset-seeded defaults on deactivation so a later manual wall draw
// isn't built with a stale preset's parameters. Unmount-only.
useEffect(() => () => useEditor.getState().setToolDefaults('wall', null), [])
useEffect(() => {
let gridPosition: WallPlanPoint = [0, 0]
let previousWallEnd: [number, number] | null = null
@@ -434,7 +466,13 @@ export const WallTool: React.FC = () => {
}
previousWallEnd = currentWallEnd
updateWallPreview(wallPreviewRef.current, startingPoint.current, endingPoint.current)
updateWallPreview(
wallPreviewRef.current,
startingPoint.current,
endingPoint.current,
previewHeightRef.current,
previewThicknessRef.current,
)
setDraftMeasurement(
getDraftMeasurementState(
[startingPoint.current.x, startingPoint.current.z],
@@ -442,6 +480,7 @@ export const WallTool: React.FC = () => {
walls,
unit,
startingPoint.current.y,
previewHeightRef.current,
),
)
} else {
@@ -538,7 +577,7 @@ export const WallTool: React.FC = () => {
return (
<group>
<CursorSphere ref={cursorRef} />
<CursorSphere height={previewHeight} ref={cursorRef} />
<mesh layers={EDITOR_LAYER} ref={wallPreviewRef} renderOrder={1} visible={false}>
<shapeGeometry />
<meshBasicMaterial