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:
co-authored by
Claude Opus 4.8
parent
8abfc94b99
commit
f7ff60561e
@@ -20,7 +20,15 @@ import { sfxEmitter } from '../../../lib/sfx-bus'
|
|||||||
import useEditor from '../../../store/use-editor'
|
import useEditor from '../../../store/use-editor'
|
||||||
import { CursorSphere } from '../shared/cursor-sphere'
|
import { CursorSphere } from '../shared/cursor-sphere'
|
||||||
|
|
||||||
const roundToHalf = (value: number) => Math.round(value * 2) / 2
|
/** Snap a world-plan coordinate to the editor's active grid step (0.5 / 0.25
|
||||||
|
* / 0.1 / 0.05), read live so changing the step mid-drag takes effect. */
|
||||||
|
const snapToGridStep = (value: number) => {
|
||||||
|
const step = useEditor.getState().gridSnapStep
|
||||||
|
return Math.round(value / step) * step
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 90° steps, matching the GLB item placement rotation. */
|
||||||
|
const ROTATION_STEP = Math.PI / 2
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generic move tool for any registry-backed kind.
|
* Generic move tool for any registry-backed kind.
|
||||||
@@ -104,6 +112,19 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
|||||||
* commit position consistent with the visible cursor.
|
* commit position consistent with the visible cursor.
|
||||||
*/
|
*/
|
||||||
const lastCursorRef = useRef<[number, number, number]>(originalPosition)
|
const lastCursorRef = useRef<[number, number, number]>(originalPosition)
|
||||||
|
/**
|
||||||
|
* Becomes true on the first `grid:move` after this move arms. Commits are
|
||||||
|
* ignored until then so a click that *armed* this move (e.g. the trailing
|
||||||
|
* `click` event of the click that just committed the previous copy, when a
|
||||||
|
* preset placement immediately re-arms the next one) can't auto-drop a
|
||||||
|
* second copy at the spot. Every real placement moves the cursor into
|
||||||
|
* position before the drop click, so this never blocks a legitimate commit.
|
||||||
|
*/
|
||||||
|
const hasMovedRef = useRef(false)
|
||||||
|
// Live Y-rotation during the drag, seeded from the node's current rotation
|
||||||
|
// and bumped by R/T. Applied imperatively + mirrored to `useLiveTransforms`,
|
||||||
|
// and committed to the scene on drop.
|
||||||
|
const rotationRef = useRef(originalRotationY)
|
||||||
|
|
||||||
const exitMoveMode = useCallback(() => {
|
const exitMoveMode = useCallback(() => {
|
||||||
useEditor.getState().setMovingNode(null)
|
useEditor.getState().setMovingNode(null)
|
||||||
@@ -112,8 +133,18 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
useScene.temporal.getState().pause()
|
useScene.temporal.getState().pause()
|
||||||
previousSnapRef.current = null
|
previousSnapRef.current = null
|
||||||
|
hasMovedRef.current = false
|
||||||
|
rotationRef.current = originalRotationY
|
||||||
let committed = false
|
let committed = false
|
||||||
|
|
||||||
|
// The node's rotation shape (tuple vs scalar) is preserved on commit;
|
||||||
|
// only the Y angle changes. Most registry kinds use a `[x, y, z]` tuple.
|
||||||
|
const baseRotation = (node as { rotation?: unknown }).rotation
|
||||||
|
const toCommitRotation = (y: number): number | [number, number, number] =>
|
||||||
|
Array.isArray(baseRotation)
|
||||||
|
? [(baseRotation[0] as number) ?? 0, y, (baseRotation[2] as number) ?? 0]
|
||||||
|
: y
|
||||||
|
|
||||||
// Disable raycast on the moved node's meshes for the duration of
|
// Disable raycast on the moved node's meshes for the duration of
|
||||||
// the drag. As the shelf follows the cursor, the cursor ray would
|
// the drag. As the shelf follows the cursor, the cursor ray would
|
||||||
// otherwise hit the moved mesh first → only `${kind}:move` fires →
|
// otherwise hit the moved mesh first → only `${kind}:move` fires →
|
||||||
@@ -135,8 +166,9 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onGridMove = (event: GridEvent) => {
|
const onGridMove = (event: GridEvent) => {
|
||||||
const x = roundToHalf(event.localPosition[0])
|
const x = snapToGridStep(event.localPosition[0])
|
||||||
const z = roundToHalf(event.localPosition[2])
|
const z = snapToGridStep(event.localPosition[2])
|
||||||
|
hasMovedRef.current = true
|
||||||
setCursorPosition([x, 0, z])
|
setCursorPosition([x, 0, z])
|
||||||
lastCursorRef.current = [x, 0, z]
|
lastCursorRef.current = [x, 0, z]
|
||||||
|
|
||||||
@@ -154,7 +186,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
|||||||
// their floor-plan move-targets handle the override themselves.
|
// their floor-plan move-targets handle the override themselves.
|
||||||
useLiveTransforms.getState().set(node.id, {
|
useLiveTransforms.getState().set(node.id, {
|
||||||
position: [x, 0, z],
|
position: [x, 0, z],
|
||||||
rotation: originalRotationY,
|
rotation: rotationRef.current,
|
||||||
})
|
})
|
||||||
|
|
||||||
const prev = previousSnapRef.current
|
const prev = previousSnapRef.current
|
||||||
@@ -180,11 +212,17 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
|||||||
* AND scene updated) — never the original.
|
* AND scene updated) — never the original.
|
||||||
*/
|
*/
|
||||||
const commitAtCursor = (event: ClickTriggerEvent) => {
|
const commitAtCursor = (event: ClickTriggerEvent) => {
|
||||||
|
// Ignore a commit that fires before the cursor has moved into place —
|
||||||
|
// it's the stray trailing click of whatever armed this move, not a
|
||||||
|
// deliberate drop. Prevents preset re-arm from double-placing.
|
||||||
|
if (!hasMovedRef.current) return
|
||||||
const position: [number, number, number] = [...lastCursorRef.current]
|
const position: [number, number, number] = [...lastCursorRef.current]
|
||||||
|
|
||||||
|
const rotation = toCommitRotation(rotationRef.current)
|
||||||
|
|
||||||
if (useScene.getState().nodes[node.id]) {
|
if (useScene.getState().nodes[node.id]) {
|
||||||
useScene.temporal.getState().resume()
|
useScene.temporal.getState().resume()
|
||||||
useScene.getState().updateNode(node.id, { position } as Partial<AnyNode>)
|
useScene.getState().updateNode(node.id, { position, rotation } as Partial<AnyNode>)
|
||||||
useScene.temporal.getState().pause()
|
useScene.temporal.getState().pause()
|
||||||
committed = true
|
committed = true
|
||||||
} else if (node.parentId) {
|
} else if (node.parentId) {
|
||||||
@@ -196,6 +234,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
|||||||
id: undefined,
|
id: undefined,
|
||||||
metadata: {},
|
metadata: {},
|
||||||
position,
|
position,
|
||||||
|
rotation,
|
||||||
})
|
})
|
||||||
useScene.temporal.getState().resume()
|
useScene.temporal.getState().resume()
|
||||||
useScene.getState().createNode(reparsed as AnyNode, node.parentId as AnyNodeId)
|
useScene.getState().createNode(reparsed as AnyNode, node.parentId as AnyNodeId)
|
||||||
@@ -204,11 +243,14 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep mesh.position aligned with the just-committed scene position
|
// Keep mesh.position/rotation aligned with the just-committed scene
|
||||||
// so the next R3F frame paints at the right spot even if React's
|
// values so the next R3F frame paints correctly even if React's
|
||||||
// reconciliation lags by a tick.
|
// reconciliation lags by a tick.
|
||||||
const mesh = sceneRegistry.nodes.get(node.id)
|
const mesh = sceneRegistry.nodes.get(node.id)
|
||||||
if (mesh) mesh.position.set(position[0], position[1], position[2])
|
if (mesh) {
|
||||||
|
mesh.position.set(position[0], position[1], position[2])
|
||||||
|
mesh.rotation.y = rotationRef.current
|
||||||
|
}
|
||||||
|
|
||||||
// Now safe to clear — node.position is already the new value, so
|
// Now safe to clear — node.position is already the new value, so
|
||||||
// `ParametricNodeRenderer`'s next render lands at `[x, 0, z]`.
|
// `ParametricNodeRenderer`'s next render lands at `[x, 0, z]`.
|
||||||
@@ -230,6 +272,26 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
|||||||
if (typeof direct === 'function') direct.call(event)
|
if (typeof direct === 'function') direct.call(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// R / T rotate the dragged node about Y in 90° steps — matching the GLB
|
||||||
|
// item placement keys (and the "Rotate" hints the move HUD shows). Applied
|
||||||
|
// imperatively + mirrored to the live transform; 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()
|
||||||
|
rotationRef.current += delta
|
||||||
|
const m = sceneRegistry.nodes.get(node.id)
|
||||||
|
if (m) m.rotation.y = rotationRef.current
|
||||||
|
useLiveTransforms.getState().set(node.id, {
|
||||||
|
position: lastCursorRef.current,
|
||||||
|
rotation: rotationRef.current,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', onKeyDown)
|
||||||
|
|
||||||
emitter.on('grid:move', onGridMove)
|
emitter.on('grid:move', onGridMove)
|
||||||
emitter.on('grid:click', commitAtCursor)
|
emitter.on('grid:click', commitAtCursor)
|
||||||
|
|
||||||
@@ -244,9 +306,11 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onCancel = () => {
|
const onCancel = () => {
|
||||||
sceneRegistry.nodes
|
const m = sceneRegistry.nodes.get(node.id)
|
||||||
.get(node.id)
|
if (m) {
|
||||||
?.position.set(originalPosition[0], originalPosition[1], originalPosition[2])
|
m.position.set(originalPosition[0], originalPosition[1], originalPosition[2])
|
||||||
|
m.rotation.y = originalRotationY
|
||||||
|
}
|
||||||
useLiveTransforms.getState().clear(node.id)
|
useLiveTransforms.getState().clear(node.id)
|
||||||
useScene.temporal.getState().resume()
|
useScene.temporal.getState().resume()
|
||||||
markToolCancelConsumed()
|
markToolCancelConsumed()
|
||||||
@@ -255,6 +319,7 @@ export function MoveRegistryNodeTool({ node }: { node: AnyNode }) {
|
|||||||
emitter.on('tool:cancel', onCancel)
|
emitter.on('tool:cancel', onCancel)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
window.removeEventListener('keydown', onKeyDown)
|
||||||
emitter.off('grid:move', onGridMove)
|
emitter.off('grid:move', onGridMove)
|
||||||
emitter.off('grid:click', commitAtCursor)
|
emitter.off('grid:click', commitAtCursor)
|
||||||
for (const kind of CLICK_TRIGGER_KINDS) {
|
for (const kind of CLICK_TRIGGER_KINDS) {
|
||||||
|
|||||||
@@ -34,6 +34,13 @@ const commitRoofPlacement = (
|
|||||||
): AnyNode['id'] => {
|
): AnyNode['id'] => {
|
||||||
const { createNode, createNodes, nodes } = useScene.getState()
|
const { createNode, createNodes, nodes } = useScene.getState()
|
||||||
|
|
||||||
|
// A placed roof preset seeds `toolDefaults.roof` with the flattened
|
||||||
|
// subtree params (roofType, pitch, wallHeight, overhang, materials, …)
|
||||||
|
// before the tool activates. The footprint (width/depth) and placement
|
||||||
|
// come from the drawn rectangle and always win; the segment carries the
|
||||||
|
// shape/material params, the roof container picks up the materials.
|
||||||
|
const defaults = useEditor.getState().toolDefaults.roof ?? {}
|
||||||
|
|
||||||
const centerX = (corner1[0] + corner2[0]) / 2
|
const centerX = (corner1[0] + corner2[0]) / 2
|
||||||
const centerZ = (corner1[2] + corner2[2]) / 2
|
const centerZ = (corner1[2] + corner2[2]) / 2
|
||||||
|
|
||||||
@@ -74,11 +81,12 @@ const commitRoofPlacement = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const segment = RoofSegmentNode.parse({
|
const segment = RoofSegmentNode.parse({
|
||||||
width,
|
|
||||||
depth,
|
|
||||||
wallHeight: DEFAULT_WALL_HEIGHT,
|
wallHeight: DEFAULT_WALL_HEIGHT,
|
||||||
pitch: DEFAULT_PITCH_DEG,
|
pitch: DEFAULT_PITCH_DEG,
|
||||||
roofType: 'gable',
|
roofType: 'gable',
|
||||||
|
...defaults,
|
||||||
|
width,
|
||||||
|
depth,
|
||||||
position: [localX, 0, localZ],
|
position: [localX, 0, localZ],
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -93,16 +101,19 @@ const commitRoofPlacement = (
|
|||||||
|
|
||||||
// Create the segment first (centered in its new parent)
|
// Create the segment first (centered in its new parent)
|
||||||
const segment = RoofSegmentNode.parse({
|
const segment = RoofSegmentNode.parse({
|
||||||
width,
|
|
||||||
depth,
|
|
||||||
wallHeight: DEFAULT_WALL_HEIGHT,
|
wallHeight: DEFAULT_WALL_HEIGHT,
|
||||||
pitch: DEFAULT_PITCH_DEG,
|
pitch: DEFAULT_PITCH_DEG,
|
||||||
roofType: 'gable',
|
roofType: 'gable',
|
||||||
|
...defaults,
|
||||||
|
width,
|
||||||
|
depth,
|
||||||
position: [0, 0, 0],
|
position: [0, 0, 0],
|
||||||
})
|
})
|
||||||
|
|
||||||
// Create the roof container
|
// Create the roof container. Segment-shaped params (roofType, pitch, …) are
|
||||||
|
// dropped by the RoofNode schema; surface materials in `defaults` carry over.
|
||||||
const roof = RoofNode.parse({
|
const roof = RoofNode.parse({
|
||||||
|
...defaults,
|
||||||
name,
|
name,
|
||||||
position: [centerX, 0, centerZ],
|
position: [centerX, 0, centerZ],
|
||||||
children: [segment.id],
|
children: [segment.id],
|
||||||
@@ -138,6 +149,10 @@ export const RoofTool: React.FC = () => {
|
|||||||
selectedIdsRef.current = selectedIds
|
selectedIdsRef.current = selectedIds
|
||||||
}, [selectedIds])
|
}, [selectedIds])
|
||||||
|
|
||||||
|
// Clear preset-seeded defaults on deactivation so a later manual roof draw
|
||||||
|
// isn't built with a stale preset's parameters. Unmount-only.
|
||||||
|
useEffect(() => () => useEditor.getState().setToolDefaults('roof', null), [])
|
||||||
|
|
||||||
const corner1Ref = useRef<[number, number, number] | null>(null)
|
const corner1Ref = useRef<[number, number, number] | null>(null)
|
||||||
const previousGridPosRef = useRef<[number, number] | null>(null)
|
const previousGridPosRef = useRef<[number, number] | null>(null)
|
||||||
const [preview, setPreview] = useState<PreviewState>({
|
const [preview, setPreview] = useState<PreviewState>({
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
emitter,
|
emitter,
|
||||||
type GridEvent,
|
type GridEvent,
|
||||||
type ItemNode,
|
type ItemNode,
|
||||||
|
isRegistrySelectable,
|
||||||
type LevelNode,
|
type LevelNode,
|
||||||
type SlabNode,
|
type SlabNode,
|
||||||
sceneRegistry,
|
sceneRegistry,
|
||||||
@@ -272,6 +273,13 @@ function collectNodeIdsInBounds(bounds: Bounds): string[] {
|
|||||||
if (xz && pointInBounds(xz[0], xz[1], bounds)) {
|
if (xz && pointInBounds(xz[0], xz[1], bounds)) {
|
||||||
result.push(item.id)
|
result.push(item.id)
|
||||||
}
|
}
|
||||||
|
} else if (isRegistrySelectable(node.type)) {
|
||||||
|
// Registry-driven selectable kinds (shelf + future furnish/structure
|
||||||
|
// kinds) aren't in the hardcoded list above; pick them up by their
|
||||||
|
// rendered bounding box, the same path column/stair use.
|
||||||
|
if (objectBoundsIntersectsBounds(node.id, bounds)) {
|
||||||
|
result.push(node.id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -507,7 +507,12 @@ export function createWallOnCurrentLevel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const wallCount = Object.values(nodes).filter((node) => node.type === 'wall').length
|
const wallCount = Object.values(nodes).filter((node) => node.type === 'wall').length
|
||||||
|
// A placed wall preset seeds `toolDefaults.wall` (thickness, height,
|
||||||
|
// materials, sides) before the tool activates; merge those first so the
|
||||||
|
// drawn wall reproduces the preset. Identity + endpoints always win.
|
||||||
|
const defaults = useEditor.getState().toolDefaults.wall ?? {}
|
||||||
const wall = WallSchema.parse({
|
const wall = WallSchema.parse({
|
||||||
|
...defaults,
|
||||||
name: `Wall ${wallCount + 1}`,
|
name: `Wall ${wallCount + 1}`,
|
||||||
start: resolvedStart,
|
start: resolvedStart,
|
||||||
end: resolvedEnd,
|
end: resolvedEnd,
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { emitter, type GridEvent, type LevelNode, useScene } from '@pascal-app/core'
|
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 { useViewer } from '@pascal-app/viewer'
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { BufferGeometry, DoubleSide, type Group, type Line, Shape, Vector3 } from 'three'
|
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 { createNode, nodes } = useScene.getState()
|
||||||
const ceilingCount = Object.values(nodes).filter((n) => n.type === 'ceiling').length
|
const ceilingCount = Object.values(nodes).filter((n) => n.type === 'ceiling').length
|
||||||
const name = `Ceiling ${ceilingCount + 1}`
|
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)
|
createNode(ceiling, levelId)
|
||||||
triggerSFX('sfx:structure-build')
|
triggerSFX('sfx:structure-build')
|
||||||
return ceiling.id
|
return ceiling.id
|
||||||
@@ -70,6 +79,10 @@ export const CeilingTool: React.FC = () => {
|
|||||||
const previousSnappedPointRef = useRef<[number, number] | null>(null)
|
const previousSnappedPointRef = useRef<[number, number] | null>(null)
|
||||||
const shiftPressed = useRef(false)
|
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(
|
const verticalGeo = useMemo(
|
||||||
() =>
|
() =>
|
||||||
new BufferGeometry().setFromPoints([
|
new BufferGeometry().setFromPoints([
|
||||||
|
|||||||
@@ -28,7 +28,14 @@ import { useCallback, useEffect, useState } from 'react'
|
|||||||
* dispatcher's `getRegistryAffordanceTool('column', 'move')` lookup
|
* dispatcher's `getRegistryAffordanceTool('column', 'move')` lookup
|
||||||
* picks this up before its legacy chain reaches `<MoveColumnTool>`.
|
* 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 }) {
|
function MoveColumnTool({ node }: { node: ColumnNode }) {
|
||||||
const [previewPosition, setPreviewPosition] = useState<[number, number, number]>(node.position)
|
const [previewPosition, setPreviewPosition] = useState<[number, number, number]>(node.position)
|
||||||
@@ -40,6 +47,14 @@ function MoveColumnTool({ node }: { node: ColumnNode }) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
useScene.temporal.getState().pause()
|
useScene.temporal.getState().pause()
|
||||||
let committed = false
|
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 =
|
const meta =
|
||||||
typeof node.metadata === 'object' && node.metadata !== null
|
typeof node.metadata === 'object' && node.metadata !== null
|
||||||
? (node.metadata as Record<string, unknown>)
|
? (node.metadata as Record<string, unknown>)
|
||||||
@@ -47,23 +62,47 @@ function MoveColumnTool({ node }: { node: ColumnNode }) {
|
|||||||
const isNew = !!meta.isNew
|
const isNew = !!meta.isNew
|
||||||
|
|
||||||
const applyPreview = (position: [number, number, number]) => {
|
const applyPreview = (position: [number, number, number]) => {
|
||||||
|
lastPosition = position
|
||||||
setPreviewPosition(position)
|
setPreviewPosition(position)
|
||||||
useLiveTransforms.getState().set(node.id, {
|
useLiveTransforms.getState().set(node.id, {
|
||||||
position,
|
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) => {
|
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) => {
|
const onGridClick = (event: GridEvent) => {
|
||||||
|
if (!hasMoved) return
|
||||||
const position: [number, number, number] = [
|
const position: [number, number, number] = [
|
||||||
roundToHalf(event.localPosition[0]),
|
snapToGridStep(event.localPosition[0]),
|
||||||
0,
|
0,
|
||||||
roundToHalf(event.localPosition[2]),
|
snapToGridStep(event.localPosition[2]),
|
||||||
]
|
]
|
||||||
const nodeId = (node as { id?: ColumnNode['id'] }).id
|
const nodeId = (node as { id?: ColumnNode['id'] }).id
|
||||||
|
|
||||||
@@ -71,13 +110,16 @@ function MoveColumnTool({ node }: { node: ColumnNode }) {
|
|||||||
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, ...(isNew ? { metadata: {} } : {}) })
|
useScene
|
||||||
|
.getState()
|
||||||
|
.updateNode(nodeId, { position, rotation: rotationY, ...(isNew ? { metadata: {} } : {}) })
|
||||||
} else if (node.parentId) {
|
} else if (node.parentId) {
|
||||||
const column = ColumnNodeSchema.parse({
|
const column = ColumnNodeSchema.parse({
|
||||||
...node,
|
...node,
|
||||||
id: undefined,
|
id: undefined,
|
||||||
metadata: {},
|
metadata: {},
|
||||||
position,
|
position,
|
||||||
|
rotation: rotationY,
|
||||||
})
|
})
|
||||||
committed = true
|
committed = true
|
||||||
useScene.temporal.getState().resume()
|
useScene.temporal.getState().resume()
|
||||||
@@ -92,27 +134,33 @@ function MoveColumnTool({ node }: { node: ColumnNode }) {
|
|||||||
|
|
||||||
const onCancel = () => {
|
const onCancel = () => {
|
||||||
useLiveTransforms.getState().clear(node.id)
|
useLiveTransforms.getState().clear(node.id)
|
||||||
sceneRegistry.nodes
|
const m = sceneRegistry.nodes.get(node.id)
|
||||||
.get(node.id)
|
if (m) {
|
||||||
?.position.set(node.position[0], node.position[1], node.position[2])
|
m.position.set(node.position[0], node.position[1], node.position[2])
|
||||||
|
m.rotation.y = node.rotation
|
||||||
|
}
|
||||||
useScene.temporal.getState().resume()
|
useScene.temporal.getState().resume()
|
||||||
markToolCancelConsumed()
|
markToolCancelConsumed()
|
||||||
exitMoveMode()
|
exitMoveMode()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
window.addEventListener('keydown', onKeyDown)
|
||||||
emitter.on('grid:move', onGridMove)
|
emitter.on('grid:move', onGridMove)
|
||||||
emitter.on('grid:click', onGridClick)
|
emitter.on('grid:click', onGridClick)
|
||||||
emitter.on('tool:cancel', onCancel)
|
emitter.on('tool:cancel', onCancel)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
window.removeEventListener('keydown', onKeyDown)
|
||||||
emitter.off('grid:move', onGridMove)
|
emitter.off('grid:move', onGridMove)
|
||||||
emitter.off('grid:click', onGridClick)
|
emitter.off('grid:click', onGridClick)
|
||||||
emitter.off('tool:cancel', onCancel)
|
emitter.off('tool:cancel', onCancel)
|
||||||
useLiveTransforms.getState().clear(node.id)
|
useLiveTransforms.getState().clear(node.id)
|
||||||
if (!committed) {
|
if (!committed) {
|
||||||
sceneRegistry.nodes
|
const m = sceneRegistry.nodes.get(node.id)
|
||||||
.get(node.id)
|
if (m) {
|
||||||
?.position.set(node.position[0], node.position[1], node.position[2])
|
m.position.set(node.position[0], node.position[1], node.position[2])
|
||||||
|
m.rotation.y = node.rotation
|
||||||
|
}
|
||||||
useScene.temporal.getState().resume()
|
useScene.temporal.getState().resume()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,6 +93,13 @@ export function buildShelfGeometry(
|
|||||||
break
|
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
|
return group
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { emitter, type GridEvent, type LevelNode, useScene } from '@pascal-app/core'
|
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 { useViewer } from '@pascal-app/viewer'
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { BufferGeometry, DoubleSide, type Group, type Line, Shape, Vector3 } from 'three'
|
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 { createNode, nodes } = useScene.getState()
|
||||||
const slabCount = Object.values(nodes).filter((n) => n.type === 'slab').length
|
const slabCount = Object.values(nodes).filter((n) => n.type === 'slab').length
|
||||||
const name = `Slab ${slabCount + 1}`
|
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)
|
createNode(slab, levelId)
|
||||||
triggerSFX('sfx:structure-build')
|
triggerSFX('sfx:structure-build')
|
||||||
return slab.id
|
return slab.id
|
||||||
@@ -67,6 +76,10 @@ export const SlabTool: React.FC = () => {
|
|||||||
const previousSnappedPointRef = useRef<[number, number] | null>(null)
|
const previousSnappedPointRef = useRef<[number, number] | null>(null)
|
||||||
const shiftPressed = useRef(false)
|
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(() => {
|
useEffect(() => {
|
||||||
if (!currentLevelId) return
|
if (!currentLevelId) return
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
type SegmentAngleReference,
|
type SegmentAngleReference,
|
||||||
snapWallDraftPoint,
|
snapWallDraftPoint,
|
||||||
triggerSFX,
|
triggerSFX,
|
||||||
|
useEditor,
|
||||||
WALL_FINE_GRID_STEP,
|
WALL_FINE_GRID_STEP,
|
||||||
type WallPlanPoint,
|
type WallPlanPoint,
|
||||||
} from '@pascal-app/editor'
|
} from '@pascal-app/editor'
|
||||||
@@ -44,9 +45,11 @@ import { BoxGeometry, BufferGeometry, DoubleSide, type Group, type Mesh, Vector3
|
|||||||
*/
|
*/
|
||||||
const WALL_HEIGHT = 2.5
|
const WALL_HEIGHT = 2.5
|
||||||
const DRAFT_WALL_THICKNESS = 0.1
|
const DRAFT_WALL_THICKNESS = 0.1
|
||||||
const DRAFT_LABEL_Y = WALL_HEIGHT + 0.22
|
// HUD label heights are measured from the top of the preview bar, so they
|
||||||
const DRAFT_ANGLE_LABEL_Y = WALL_HEIGHT + 0.08
|
// track whatever height a seeded preset draws at (`previewHeight`).
|
||||||
const DRAFT_ANGLE_ARC_Y = WALL_HEIGHT + 0.012
|
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_MIN_RADIUS = 0.32
|
||||||
const DRAFT_ANGLE_ARC_MAX_RADIUS = 0.72
|
const DRAFT_ANGLE_ARC_MAX_RADIUS = 0.72
|
||||||
const DRAFT_ANGLE_ARC_SEGMENTS = 24
|
const DRAFT_ANGLE_ARC_SEGMENTS = 24
|
||||||
@@ -259,6 +262,7 @@ function getDraftAngleLabels(
|
|||||||
end: WallPlanPoint,
|
end: WallPlanPoint,
|
||||||
walls: WallNode[],
|
walls: WallNode[],
|
||||||
baseY: number,
|
baseY: number,
|
||||||
|
previewHeight: number,
|
||||||
): DraftAngleLabel[] {
|
): DraftAngleLabel[] {
|
||||||
const draftFromStart: WallPlanPoint = [end[0] - start[0], end[1] - start[1]]
|
const draftFromStart: WallPlanPoint = [end[0] - start[0], end[1] - start[1]]
|
||||||
const draftFromEnd: WallPlanPoint = [start[0] - end[0], start[1] - end[1]]
|
const draftFromEnd: WallPlanPoint = [start[0] - end[0], start[1] - end[1]]
|
||||||
@@ -313,7 +317,7 @@ function getDraftAngleLabels(
|
|||||||
label: formatAngleRadians(angle),
|
label: formatAngleRadians(angle),
|
||||||
position: [
|
position: [
|
||||||
arcCenter[0] + Math.cos(arc.midAngle) * (radius + 0.16),
|
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),
|
arcCenter[1] + Math.sin(arc.midAngle) * (radius + 0.16),
|
||||||
],
|
],
|
||||||
arc: {
|
arc: {
|
||||||
@@ -321,7 +325,7 @@ function getDraftAngleLabels(
|
|||||||
radius,
|
radius,
|
||||||
startAngle: arc.startAngle,
|
startAngle: arc.startAngle,
|
||||||
endAngle: arc.endAngle,
|
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[],
|
walls: WallNode[],
|
||||||
unit: 'metric' | 'imperial',
|
unit: 'metric' | 'imperial',
|
||||||
baseY: number,
|
baseY: number,
|
||||||
|
previewHeight: number,
|
||||||
): DraftMeasurementState {
|
): DraftMeasurementState {
|
||||||
const dx = end[0] - start[0]
|
const dx = end[0] - start[0]
|
||||||
const dz = end[1] - start[1]
|
const dz = end[1] - start[1]
|
||||||
@@ -342,12 +347,22 @@ function getDraftMeasurementState(
|
|||||||
if (length < 0.01) return null
|
if (length < 0.01) return null
|
||||||
return {
|
return {
|
||||||
lengthLabel: formatMeasurement(length, unit),
|
lengthLabel: formatMeasurement(length, unit),
|
||||||
lengthPosition: [(start[0] + end[0]) / 2, baseY + DRAFT_LABEL_Y, (start[1] + end[1]) / 2],
|
lengthPosition: [
|
||||||
angleLabels: getDraftAngleLabels(start, end, walls, baseY),
|
(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 direction = new Vector3(end.x - start.x, 0, end.z - start.z)
|
||||||
const length = direction.length()
|
const length = direction.length()
|
||||||
if (length < 0.01) {
|
if (length < 0.01) {
|
||||||
@@ -357,10 +372,10 @@ function updateWallPreview(mesh: Mesh, start: Vector3, end: Vector3) {
|
|||||||
mesh.visible = true
|
mesh.visible = true
|
||||||
direction.normalize()
|
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)
|
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
|
mesh.rotation.y = -angle
|
||||||
|
|
||||||
if (mesh.geometry) {
|
if (mesh.geometry) {
|
||||||
@@ -383,6 +398,19 @@ function getCurrentLevelWalls(): WallNode[] {
|
|||||||
export const WallTool: React.FC = () => {
|
export const WallTool: React.FC = () => {
|
||||||
const unit = useViewer((state) => state.unit)
|
const unit = useViewer((state) => state.unit)
|
||||||
const isDark = useViewer((state) => getSceneTheme(state.sceneTheme).appearance === 'dark')
|
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 cursorRef = useRef<Group>(null)
|
||||||
const wallPreviewRef = useRef<Mesh>(null!)
|
const wallPreviewRef = useRef<Mesh>(null!)
|
||||||
const startingPoint = useRef(new Vector3(0, 0, 0))
|
const startingPoint = useRef(new Vector3(0, 0, 0))
|
||||||
@@ -393,6 +421,10 @@ export const WallTool: React.FC = () => {
|
|||||||
const measurementColor = isDark ? '#ffffff' : '#111111'
|
const measurementColor = isDark ? '#ffffff' : '#111111'
|
||||||
const measurementShadowColor = isDark ? '#111111' : '#ffffff'
|
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(() => {
|
useEffect(() => {
|
||||||
let gridPosition: WallPlanPoint = [0, 0]
|
let gridPosition: WallPlanPoint = [0, 0]
|
||||||
let previousWallEnd: [number, number] | null = null
|
let previousWallEnd: [number, number] | null = null
|
||||||
@@ -434,7 +466,13 @@ export const WallTool: React.FC = () => {
|
|||||||
}
|
}
|
||||||
previousWallEnd = currentWallEnd
|
previousWallEnd = currentWallEnd
|
||||||
|
|
||||||
updateWallPreview(wallPreviewRef.current, startingPoint.current, endingPoint.current)
|
updateWallPreview(
|
||||||
|
wallPreviewRef.current,
|
||||||
|
startingPoint.current,
|
||||||
|
endingPoint.current,
|
||||||
|
previewHeightRef.current,
|
||||||
|
previewThicknessRef.current,
|
||||||
|
)
|
||||||
setDraftMeasurement(
|
setDraftMeasurement(
|
||||||
getDraftMeasurementState(
|
getDraftMeasurementState(
|
||||||
[startingPoint.current.x, startingPoint.current.z],
|
[startingPoint.current.x, startingPoint.current.z],
|
||||||
@@ -442,6 +480,7 @@ export const WallTool: React.FC = () => {
|
|||||||
walls,
|
walls,
|
||||||
unit,
|
unit,
|
||||||
startingPoint.current.y,
|
startingPoint.current.y,
|
||||||
|
previewHeightRef.current,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
@@ -538,7 +577,7 @@ export const WallTool: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<group>
|
<group>
|
||||||
<CursorSphere ref={cursorRef} />
|
<CursorSphere height={previewHeight} ref={cursorRef} />
|
||||||
<mesh layers={EDITOR_LAYER} ref={wallPreviewRef} renderOrder={1} visible={false}>
|
<mesh layers={EDITOR_LAYER} ref={wallPreviewRef} renderOrder={1} visible={false}>
|
||||||
<shapeGeometry />
|
<shapeGeometry />
|
||||||
<meshBasicMaterial
|
<meshBasicMaterial
|
||||||
|
|||||||
@@ -37,7 +37,12 @@ export {
|
|||||||
SUBTRACTION,
|
SUBTRACTION,
|
||||||
} from './lib/csg-utils'
|
} from './lib/csg-utils'
|
||||||
export type { EdgeMode } from './lib/edge-style'
|
export type { EdgeMode } from './lib/edge-style'
|
||||||
export { applyIsolation, clearIsolation, collectIsolationSubtree } from './lib/isolation'
|
export {
|
||||||
|
applyIsolation,
|
||||||
|
clearIsolation,
|
||||||
|
collectIsolationSubtree,
|
||||||
|
isIsolationActive,
|
||||||
|
} from './lib/isolation'
|
||||||
export { GRID_LAYER, OVERLAY_LAYER, SCENE_LAYER, ZONE_LAYER } from './lib/layers'
|
export { GRID_LAYER, OVERLAY_LAYER, SCENE_LAYER, ZONE_LAYER } from './lib/layers'
|
||||||
export {
|
export {
|
||||||
applyMaterialPresetToMaterials,
|
applyMaterialPresetToMaterials,
|
||||||
|
|||||||
@@ -12,6 +12,17 @@ const ORIGINAL_LAYERS = Symbol('isolation:original-layers')
|
|||||||
|
|
||||||
type IsolationCarrier = Object3D & { [ORIGINAL_LAYERS]?: number }
|
type IsolationCarrier = Object3D & { [ORIGINAL_LAYERS]?: number }
|
||||||
|
|
||||||
|
// Whether a subtree is currently isolated (some objects have SCENE_LAYER
|
||||||
|
// disabled). Read by consumers that must not act on the partial view — e.g.
|
||||||
|
// the project-thumbnail autosave skips capturing while isolated so it never
|
||||||
|
// snapshots a single focused item as the whole project's thumbnail.
|
||||||
|
let isolationActive = false
|
||||||
|
|
||||||
|
/** True while an isolation filter is applied (see {@link applyIsolation}). */
|
||||||
|
export function isIsolationActive(): boolean {
|
||||||
|
return isolationActive
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compute the union of every isolated subtree's `Object3D` descendants.
|
* Compute the union of every isolated subtree's `Object3D` descendants.
|
||||||
*
|
*
|
||||||
@@ -71,6 +82,7 @@ export function applyIsolation(ids: ReadonlyArray<AnyNodeId> | null): void {
|
|||||||
if (keep.has(obj)) continue
|
if (keep.has(obj)) continue
|
||||||
hideRecursive(obj, keep)
|
hideRecursive(obj, keep)
|
||||||
}
|
}
|
||||||
|
isolationActive = true
|
||||||
}
|
}
|
||||||
|
|
||||||
function hideRecursive(obj: Object3D, keep: Set<Object3D>): void {
|
function hideRecursive(obj: Object3D, keep: Set<Object3D>): void {
|
||||||
@@ -98,4 +110,5 @@ export function clearIsolation(): void {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
isolationActive = false
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user