Merge pull request #267 from sudhir9297/fix/minor-bug-fixes
Fix/minor bug fixes
This commit is contained in:
@@ -54,6 +54,12 @@ export {
|
||||
type ItemInteractiveState,
|
||||
useInteractive,
|
||||
} from './store/use-interactive'
|
||||
export {
|
||||
getSceneHistoryPauseDepth,
|
||||
pauseSceneHistory,
|
||||
resetSceneHistoryPauseDepth,
|
||||
resumeSceneHistory,
|
||||
} from './store/history-control'
|
||||
export { default as useLiveTransforms, type LiveTransform } from './store/use-live-transforms'
|
||||
export { FenceSystem } from './systems/fence/fence-system'
|
||||
export { clearSceneHistory, default as useScene } from './store/use-scene'
|
||||
|
||||
@@ -4,6 +4,11 @@ import {
|
||||
isCurvedWall,
|
||||
} from '../systems/wall/wall-curve'
|
||||
import { CeilingNode, SlabNode, type CeilingNode as CeilingNodeType, type SlabNode as SlabNodeType, type WallNode } from '../schema'
|
||||
import {
|
||||
getSceneHistoryPauseDepth,
|
||||
pauseSceneHistory,
|
||||
resumeSceneHistory,
|
||||
} from '../store/history-control'
|
||||
import { simplifyClosedPolygon } from './polygon-geometry'
|
||||
|
||||
type Point2D = { x: number; y: number }
|
||||
@@ -855,6 +860,7 @@ export function initSpaceDetectionSync(sceneStore: any, editorStore: any): () =>
|
||||
|
||||
const unsubscribe = sceneStore.subscribe((state: any) => {
|
||||
if (isProcessing) return
|
||||
if (getSceneHistoryPauseDepth() > 0) return
|
||||
|
||||
const nodes = state.nodes
|
||||
const wallsByLevel = new Map<string, WallNode[]>()
|
||||
@@ -889,11 +895,11 @@ export function initSpaceDetectionSync(sceneStore: any, editorStore: any): () =>
|
||||
}
|
||||
|
||||
isProcessing = true
|
||||
sceneStore.temporal.getState().pause()
|
||||
pauseSceneHistory(sceneStore)
|
||||
try {
|
||||
runSpaceDetection([...levelsToUpdate], sceneStore, editorStore, nodes)
|
||||
} finally {
|
||||
sceneStore.temporal.getState().resume()
|
||||
resumeSceneHistory(sceneStore)
|
||||
previousSnapshots.clear()
|
||||
for (const [levelId, snapshot] of currentSnapshots.entries()) {
|
||||
previousSnapshots.set(levelId, snapshot)
|
||||
|
||||
@@ -13,6 +13,7 @@ export const FenceNode = BaseNode.extend({
|
||||
materialPreset: z.string().optional(),
|
||||
start: z.tuple([z.number(), z.number()]),
|
||||
end: z.tuple([z.number(), z.number()]),
|
||||
curveOffset: z.number().optional(),
|
||||
height: z.number().default(1.8),
|
||||
thickness: z.number().default(0.08),
|
||||
baseHeight: z.number().default(0.22),
|
||||
@@ -28,6 +29,7 @@ export const FenceNode = BaseNode.extend({
|
||||
dedent`
|
||||
Fence node - used to represent a fence segment in the building/site level coordinate system
|
||||
- start/end: fence endpoints in level coordinate system
|
||||
- curveOffset: midpoint sagitta offset used to bend the fence into an arc
|
||||
- height/thickness: overall fence dimensions in meters
|
||||
- baseHeight/postSpacing/postSize/topRailHeight: exact geometric controls from the plan3D fence model
|
||||
- groundClearance/edgeInset/baseStyle: fence support and inset configuration
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
let sceneHistoryPauseDepth = 0
|
||||
|
||||
type TemporalStoreLike = {
|
||||
temporal: {
|
||||
getState(): {
|
||||
pause(): void
|
||||
resume(): void
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function pauseSceneHistory(sceneStore: TemporalStoreLike): void {
|
||||
if (sceneHistoryPauseDepth === 0) {
|
||||
sceneStore.temporal.getState().pause()
|
||||
}
|
||||
sceneHistoryPauseDepth += 1
|
||||
}
|
||||
|
||||
export function resumeSceneHistory(sceneStore: TemporalStoreLike): void {
|
||||
if (sceneHistoryPauseDepth === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
sceneHistoryPauseDepth -= 1
|
||||
if (sceneHistoryPauseDepth === 0) {
|
||||
sceneStore.temporal.getState().resume()
|
||||
}
|
||||
}
|
||||
|
||||
export function getSceneHistoryPauseDepth(): number {
|
||||
return sceneHistoryPauseDepth
|
||||
}
|
||||
|
||||
export function resetSceneHistoryPauseDepth(): void {
|
||||
sceneHistoryPauseDepth = 0
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { SiteNode } from '../schema/nodes/site'
|
||||
import { StairNode as StairNodeSchema } from '../schema/nodes/stair'
|
||||
import { StairSegmentNode as StairSegmentNodeSchema } from '../schema/nodes/stair-segment'
|
||||
import type { AnyNode, AnyNodeId } from '../schema/types'
|
||||
import { resetSceneHistoryPauseDepth } from './history-control'
|
||||
import * as nodeActions from './actions/node-actions'
|
||||
|
||||
function getFiniteNumber(value: unknown, fallback: number) {
|
||||
@@ -628,6 +629,7 @@ let prevNodesSnapshot: Record<AnyNodeId, AnyNode> | null = null
|
||||
|
||||
export function clearSceneHistory() {
|
||||
useScene.temporal.getState().clear()
|
||||
resetSceneHistoryPauseDepth()
|
||||
prevPastLength = 0
|
||||
prevFutureLength = 0
|
||||
prevNodesSnapshot = null
|
||||
@@ -647,8 +649,9 @@ useScene.temporal.subscribe((state) => {
|
||||
// Capture the previous snapshot before RAF fires
|
||||
const snapshotBefore = prevNodesSnapshot
|
||||
|
||||
// Use RAF to ensure all middleware and store updates are complete
|
||||
requestAnimationFrame(() => {
|
||||
// Defer to a microtask so the scene store has settled before we diff,
|
||||
// but still mark walls/items dirty before the next paint.
|
||||
queueMicrotask(() => {
|
||||
const currentNodes = useScene.getState().nodes
|
||||
const { markDirty } = useScene.getState()
|
||||
|
||||
|
||||
@@ -4,12 +4,90 @@ import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js
|
||||
import { sceneRegistry } from '../../hooks/scene-registry/scene-registry'
|
||||
import type { AnyNodeId, FenceNode } from '../../schema'
|
||||
import useScene from '../../store/use-scene'
|
||||
import { getWallCurveFrameAt, getWallCurveLength } from '../wall/wall-curve'
|
||||
|
||||
type FencePart = {
|
||||
position: [number, number, number]
|
||||
rotationY?: number
|
||||
scale: [number, number, number]
|
||||
}
|
||||
|
||||
const MIN_CURVE_SEGMENT_LENGTH = 0.18
|
||||
|
||||
function createFencePartGeometry(part: FencePart) {
|
||||
const geometry = new THREE.BoxGeometry(1, 1, 1)
|
||||
geometry.scale(part.scale[0], part.scale[1], part.scale[2])
|
||||
if (part.rotationY) {
|
||||
geometry.rotateY(part.rotationY)
|
||||
}
|
||||
geometry.translate(part.position[0], part.position[1], part.position[2])
|
||||
applyFenceUVs(geometry)
|
||||
return geometry
|
||||
}
|
||||
|
||||
function getFencePointAt(fence: FenceNode, t: number) {
|
||||
const frame = getWallCurveFrameAt(fence, t)
|
||||
return {
|
||||
point: frame.point,
|
||||
tangentAngle: Math.atan2(frame.tangent.y, frame.tangent.x),
|
||||
}
|
||||
}
|
||||
|
||||
function createStraightFenceSpanPart(
|
||||
start: [number, number],
|
||||
end: [number, number],
|
||||
centerY: number,
|
||||
height: number,
|
||||
depth: number,
|
||||
): FencePart | null {
|
||||
const dx = end[0] - start[0]
|
||||
const dz = end[1] - start[1]
|
||||
const length = Math.hypot(dx, dz)
|
||||
if (length <= 1e-4) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
position: [(start[0] + end[0]) / 2, centerY, (start[1] + end[1]) / 2],
|
||||
rotationY: -Math.atan2(dz, dx),
|
||||
scale: [length, height, depth],
|
||||
}
|
||||
}
|
||||
|
||||
function createFenceCurveSpanParts(
|
||||
fence: FenceNode,
|
||||
startT: number,
|
||||
endT: number,
|
||||
centerY: number,
|
||||
height: number,
|
||||
depth: number,
|
||||
): FencePart[] {
|
||||
const parts: FencePart[] = []
|
||||
const frameCount = Math.max(
|
||||
1,
|
||||
Math.ceil((getWallCurveLength(fence) * Math.max(1e-4, endT - startT)) / MIN_CURVE_SEGMENT_LENGTH),
|
||||
)
|
||||
|
||||
let previous = getFencePointAt(fence, startT)
|
||||
for (let index = 1; index <= frameCount; index += 1) {
|
||||
const t = startT + (endT - startT) * (index / frameCount)
|
||||
const current = getFencePointAt(fence, t)
|
||||
const segment = createStraightFenceSpanPart(
|
||||
[previous.point.x, previous.point.y],
|
||||
[current.point.x, current.point.y],
|
||||
centerY,
|
||||
height,
|
||||
depth,
|
||||
)
|
||||
if (segment) {
|
||||
parts.push(segment)
|
||||
}
|
||||
previous = current
|
||||
}
|
||||
|
||||
return parts
|
||||
}
|
||||
|
||||
function applyFenceUVs(geometry: THREE.BufferGeometry) {
|
||||
const position = geometry.getAttribute('position')
|
||||
const normal = geometry.getAttribute('normal')
|
||||
@@ -71,10 +149,7 @@ function getStyleDefaults(style: FenceNode['style']) {
|
||||
|
||||
function createFenceParts(fence: FenceNode): FencePart[] {
|
||||
const parts: FencePart[] = []
|
||||
const length = Math.max(
|
||||
Math.hypot(fence.end[0] - fence.start[0], fence.end[1] - fence.start[1]),
|
||||
0.01,
|
||||
)
|
||||
const length = Math.max(getWallCurveLength(fence), 0.01)
|
||||
const panelDepth = Math.max(fence.thickness, 0.03)
|
||||
const clearance = Math.max(fence.groundClearance, 0)
|
||||
const styleDefaults = getStyleDefaults(fence.style)
|
||||
@@ -87,31 +162,39 @@ function createFenceParts(fence: FenceNode): FencePart[] {
|
||||
const isFloating = fence.baseStyle === 'floating'
|
||||
const baseY = isFloating ? clearance : 0
|
||||
const effectiveBaseHeight = baseHeight
|
||||
const startInsetT = Math.min(0.499, edgeInset / length)
|
||||
const endInsetT = Math.max(0.501, 1 - edgeInset / length)
|
||||
|
||||
if (!isFloating) {
|
||||
parts.push({
|
||||
position: [0, baseY + effectiveBaseHeight / 2, 0],
|
||||
scale: [length, effectiveBaseHeight, panelDepth * 1.05],
|
||||
})
|
||||
parts.push({
|
||||
position: [0, baseY + effectiveBaseHeight + verticalHeight * 0.15, 0],
|
||||
scale: [length, topRailHeight * 0.8, panelDepth * 0.35],
|
||||
})
|
||||
parts.push(
|
||||
...createFenceCurveSpanParts(
|
||||
fence,
|
||||
0,
|
||||
1,
|
||||
baseY + effectiveBaseHeight / 2,
|
||||
effectiveBaseHeight,
|
||||
panelDepth * 1.05,
|
||||
),
|
||||
)
|
||||
parts.push(
|
||||
...createFenceCurveSpanParts(
|
||||
fence,
|
||||
0,
|
||||
1,
|
||||
baseY + effectiveBaseHeight + verticalHeight * 0.15,
|
||||
topRailHeight * 0.8,
|
||||
panelDepth * 0.35,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
const count = Math.max(2, Math.floor((length - edgeInset * 2) / spacing) + 1)
|
||||
const step = count > 1 ? (length - edgeInset * 2) / (count - 1) : 0
|
||||
const startX = -length / 2 + edgeInset
|
||||
const verticalY = baseY + effectiveBaseHeight + verticalHeight / 2
|
||||
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const x = count === 1 ? 0 : startX + step * index
|
||||
let posX = x
|
||||
const t = count === 1 ? 0.5 : startInsetT + (endInsetT - startInsetT) * (index / (count - 1))
|
||||
const frame = getFencePointAt(fence, t)
|
||||
const isEdgePost = index === 0 || index === count - 1
|
||||
if (count > 1) {
|
||||
if (index === 0) posX = -length / 2 + edgeInset + postWidth / 2
|
||||
else if (index === count - 1) posX = length / 2 - edgeInset - postWidth / 2
|
||||
}
|
||||
const postHeight =
|
||||
isFloating && isEdgePost
|
||||
? effectiveBaseHeight + verticalHeight + topRailHeight + clearance
|
||||
@@ -119,21 +202,34 @@ function createFenceParts(fence: FenceNode): FencePart[] {
|
||||
const postY = isFloating && isEdgePost ? postHeight / 2 : verticalY
|
||||
|
||||
parts.push({
|
||||
position: [posX, postY, 0],
|
||||
position: [frame.point.x, postY, frame.point.y],
|
||||
rotationY: -frame.tangentAngle,
|
||||
scale: [postWidth, postHeight, Math.max(panelDepth * 0.35, 0.012)],
|
||||
})
|
||||
}
|
||||
|
||||
parts.push({
|
||||
position: [0, baseY + effectiveBaseHeight + verticalHeight + topRailHeight / 2, 0],
|
||||
scale: [length, topRailHeight, Math.max(panelDepth * 0.55, 0.018)],
|
||||
})
|
||||
parts.push(
|
||||
...createFenceCurveSpanParts(
|
||||
fence,
|
||||
0,
|
||||
1,
|
||||
baseY + effectiveBaseHeight + verticalHeight + topRailHeight / 2,
|
||||
topRailHeight,
|
||||
Math.max(panelDepth * 0.55, 0.018),
|
||||
),
|
||||
)
|
||||
|
||||
if (isFloating) {
|
||||
parts.push({
|
||||
position: [0, baseY + effectiveBaseHeight + topRailHeight / 2, 0],
|
||||
scale: [length, topRailHeight, Math.max(panelDepth * 0.55, 0.018)],
|
||||
})
|
||||
parts.push(
|
||||
...createFenceCurveSpanParts(
|
||||
fence,
|
||||
0,
|
||||
1,
|
||||
baseY + effectiveBaseHeight + topRailHeight / 2,
|
||||
topRailHeight,
|
||||
Math.max(panelDepth * 0.55, 0.018),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
return parts
|
||||
@@ -141,13 +237,7 @@ function createFenceParts(fence: FenceNode): FencePart[] {
|
||||
|
||||
function generateFenceGeometry(fence: FenceNode) {
|
||||
const parts = createFenceParts(fence)
|
||||
const geometries = parts.map((part) => {
|
||||
const geometry = new THREE.BoxGeometry(1, 1, 1)
|
||||
geometry.scale(part.scale[0], part.scale[1], part.scale[2])
|
||||
applyFenceUVs(geometry)
|
||||
geometry.translate(part.position[0], part.position[1], part.position[2])
|
||||
return geometry
|
||||
})
|
||||
const geometries = parts.map(createFencePartGeometry)
|
||||
|
||||
const merged = mergeGeometries(geometries, false) ?? new THREE.BufferGeometry()
|
||||
geometries.forEach((geometry) => geometry.dispose())
|
||||
@@ -169,12 +259,8 @@ function updateFenceGeometry(fenceId: FenceNode['id']) {
|
||||
const newGeometry = generateFenceGeometry(node)
|
||||
mesh.geometry.dispose()
|
||||
mesh.geometry = newGeometry
|
||||
|
||||
const centerX = (node.start[0] + node.end[0]) / 2
|
||||
const centerZ = (node.start[1] + node.end[1]) / 2
|
||||
const angle = Math.atan2(node.end[1] - node.start[1], node.end[0] - node.start[0])
|
||||
mesh.position.set(centerX, 0, centerZ)
|
||||
mesh.rotation.set(0, -angle, 0)
|
||||
mesh.position.set(0, 0, 0)
|
||||
mesh.rotation.set(0, 0, 0)
|
||||
}
|
||||
|
||||
export const FenceSystem = () => {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { Point2D } from './wall-mitering'
|
||||
import type { WallNode } from '../../schema'
|
||||
import type { FenceNode, WallNode } from '../../schema'
|
||||
|
||||
const CURVE_EPSILON = 1e-6
|
||||
const DEFAULT_SAMPLE_SEGMENTS = 24
|
||||
|
||||
type WallCurveLike = Pick<WallNode, 'start' | 'end' | 'curveOffset'>
|
||||
type WallCurveLike = Pick<WallNode | FenceNode, 'start' | 'end' | 'curveOffset'>
|
||||
|
||||
type CurveFrame = {
|
||||
point: Point2D
|
||||
@@ -198,7 +198,7 @@ export function getWallCurveLength(wall: WallCurveLike, segments = DEFAULT_SAMPL
|
||||
}
|
||||
|
||||
export function getWallSurfacePolygon(
|
||||
wall: Pick<WallNode, 'start' | 'end' | 'curveOffset' | 'thickness'>,
|
||||
wall: Pick<WallNode | FenceNode, 'start' | 'end' | 'curveOffset' | 'thickness'>,
|
||||
segments = DEFAULT_SAMPLE_SEGMENTS,
|
||||
miterOverrides?: WallSurfaceMiterOverrides,
|
||||
) {
|
||||
|
||||
@@ -49,9 +49,13 @@ export function FloatingActionMenu() {
|
||||
const mode = useEditor((s) => s.mode)
|
||||
const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered)
|
||||
const movingWallEndpoint = useEditor((s) => s.movingWallEndpoint)
|
||||
const movingFenceEndpoint = useEditor((s) => s.movingFenceEndpoint)
|
||||
const curvingFence = useEditor((s) => s.curvingFence)
|
||||
const setMovingNode = useEditor((s) => s.setMovingNode)
|
||||
const setMovingWallEndpoint = useEditor((s) => s.setMovingWallEndpoint)
|
||||
const setMovingFenceEndpoint = useEditor((s) => s.setMovingFenceEndpoint)
|
||||
const setCurvingWall = useEditor((s) => s.setCurvingWall)
|
||||
const setCurvingFence = useEditor((s) => s.setCurvingFence)
|
||||
const setSelection = useViewer((s) => s.setSelection)
|
||||
const setEditingHole = useEditor((s) => s.setEditingHole)
|
||||
|
||||
@@ -128,12 +132,26 @@ export function FloatingActionMenu() {
|
||||
groupRef.current.position.set(center.x, box.max.y + yOffset, center.z)
|
||||
}
|
||||
|
||||
if (node?.type === 'wall') {
|
||||
const wall = node as WallNode
|
||||
const wallLength = Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1])
|
||||
if (node?.type === 'wall' || node?.type === 'fence') {
|
||||
const segment = node as WallNode | FenceNode
|
||||
const endpointYOffset = 0.35
|
||||
const startWorld = obj.localToWorld(new THREE.Vector3(0, 0, 0))
|
||||
const endWorld = obj.localToWorld(new THREE.Vector3(wallLength, 0, 0))
|
||||
const startWorld =
|
||||
node.type === 'wall'
|
||||
? obj.localToWorld(new THREE.Vector3(0, 0, 0))
|
||||
: obj.localToWorld(new THREE.Vector3(segment.start[0], 0, segment.start[1]))
|
||||
const endWorld =
|
||||
node.type === 'wall'
|
||||
? obj.localToWorld(
|
||||
new THREE.Vector3(
|
||||
Math.hypot(
|
||||
segment.end[0] - segment.start[0],
|
||||
segment.end[1] - segment.start[1],
|
||||
),
|
||||
0,
|
||||
0,
|
||||
),
|
||||
)
|
||||
: obj.localToWorld(new THREE.Vector3(segment.end[0], 0, segment.end[1]))
|
||||
|
||||
if (startEndpointGroupRef.current) {
|
||||
startEndpointGroupRef.current.position.set(
|
||||
@@ -180,22 +198,35 @@ export function FloatingActionMenu() {
|
||||
const handleCurve = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (!canCurveSelectedWall || !node || node.type !== 'wall') return
|
||||
if (!node) return
|
||||
sfxEmitter.emit('sfx:item-pick')
|
||||
if (node.type === 'wall') {
|
||||
if (!canCurveSelectedWall) return
|
||||
setCurvingWall(node)
|
||||
} else if (node.type === 'fence') {
|
||||
setCurvingFence(node)
|
||||
} else {
|
||||
return
|
||||
}
|
||||
setSelection({ selectedIds: [] })
|
||||
},
|
||||
[canCurveSelectedWall, node, setCurvingWall, setSelection],
|
||||
[canCurveSelectedWall, node, setCurvingFence, setCurvingWall, setSelection],
|
||||
)
|
||||
const handleEndpointMove = useCallback(
|
||||
(endpoint: 'start' | 'end', e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (!(node && node.type === 'wall')) return
|
||||
if (!node) return
|
||||
sfxEmitter.emit('sfx:item-pick')
|
||||
if (node.type === 'wall') {
|
||||
setMovingWallEndpoint({ wall: node, endpoint })
|
||||
} else if (node.type === 'fence') {
|
||||
setMovingFenceEndpoint({ fence: node, endpoint })
|
||||
} else {
|
||||
return
|
||||
}
|
||||
setSelection({ selectedIds: [] })
|
||||
},
|
||||
[node, setMovingWallEndpoint, setSelection],
|
||||
[node, setMovingFenceEndpoint, setMovingWallEndpoint, setSelection],
|
||||
)
|
||||
|
||||
const handleDuplicate = useCallback(
|
||||
@@ -396,7 +427,9 @@ export function FloatingActionMenu() {
|
||||
|
||||
if (
|
||||
!(selectedId && node && isValidType && !isFloorplanHovered && mode !== 'delete') ||
|
||||
movingWallEndpoint
|
||||
movingWallEndpoint ||
|
||||
movingFenceEndpoint ||
|
||||
curvingFence
|
||||
)
|
||||
return null
|
||||
|
||||
@@ -413,7 +446,11 @@ export function FloatingActionMenu() {
|
||||
>
|
||||
<NodeActionMenu
|
||||
onAddHole={node && HOLE_TYPES.includes(node.type) ? handleAddHole : undefined}
|
||||
onCurve={canCurveSelectedWall ? handleCurve : undefined}
|
||||
onCurve={
|
||||
node?.type === 'fence' || (node?.type === 'wall' && canCurveSelectedWall)
|
||||
? handleCurve
|
||||
: undefined
|
||||
}
|
||||
onDelete={handleDelete}
|
||||
onDuplicate={
|
||||
node && !DELETE_ONLY_TYPES.includes(node.type) && !HOLE_TYPES.includes(node.type)
|
||||
@@ -426,7 +463,7 @@ export function FloatingActionMenu() {
|
||||
/>
|
||||
</Html>
|
||||
</group>
|
||||
{node?.type === 'wall' && (
|
||||
{(node?.type === 'wall' || node?.type === 'fence') && (
|
||||
<>
|
||||
<group ref={startEndpointGroupRef}>
|
||||
<Html
|
||||
@@ -435,7 +472,7 @@ export function FloatingActionMenu() {
|
||||
zIndexRange={[100, 0]}
|
||||
>
|
||||
<button
|
||||
aria-label="Move wall start"
|
||||
aria-label={node.type === 'wall' ? 'Move wall start' : 'Move fence start'}
|
||||
className={`pointer-events-auto flex h-8 w-8 items-center justify-center rounded-full border bg-background/95 shadow-lg backdrop-blur-md transition-colors ${
|
||||
altPressed
|
||||
? 'border-amber-500/80 bg-amber-500/15 text-amber-100 hover:bg-amber-500/20 hover:text-white'
|
||||
@@ -443,7 +480,11 @@ export function FloatingActionMenu() {
|
||||
}`}
|
||||
onClick={(e) => handleEndpointMove('start', e)}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
title="Move wall start (Alt to detach)"
|
||||
title={
|
||||
node.type === 'wall'
|
||||
? 'Move wall start (Alt to detach)'
|
||||
: 'Move fence start (Alt to detach)'
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<Move className="h-4 w-4" />
|
||||
@@ -457,7 +498,7 @@ export function FloatingActionMenu() {
|
||||
zIndexRange={[100, 0]}
|
||||
>
|
||||
<button
|
||||
aria-label="Move wall end"
|
||||
aria-label={node.type === 'wall' ? 'Move wall end' : 'Move fence end'}
|
||||
className={`pointer-events-auto flex h-8 w-8 items-center justify-center rounded-full border bg-background/95 shadow-lg backdrop-blur-md transition-colors ${
|
||||
altPressed
|
||||
? 'border-amber-500/80 bg-amber-500/15 text-amber-100 hover:bg-amber-500/20 hover:text-white'
|
||||
@@ -465,7 +506,11 @@ export function FloatingActionMenu() {
|
||||
}`}
|
||||
onClick={(e) => handleEndpointMove('end', e)}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
title="Move wall end (Alt to detach)"
|
||||
title={
|
||||
node.type === 'wall'
|
||||
? 'Move wall end (Alt to detach)'
|
||||
: 'Move fence end (Alt to detach)'
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<Move className="h-4 w-4" />
|
||||
|
||||
@@ -159,6 +159,12 @@ type FloorplanViewport = {
|
||||
width: number
|
||||
}
|
||||
|
||||
function floorplanViewportEquals(a: FloorplanViewport | null, b: FloorplanViewport | null) {
|
||||
if (a === b) return true
|
||||
if (!(a && b)) return false
|
||||
return a.centerX === b.centerX && a.centerY === b.centerY && a.width === b.width
|
||||
}
|
||||
|
||||
type SvgPoint = {
|
||||
x: number
|
||||
y: number
|
||||
@@ -4772,8 +4778,9 @@ const FloorplanActionMenuLayer = memo(function FloorplanActionMenuLayer({
|
||||
const isFloorplanHovered = useEditor((state) => state.isFloorplanHovered)
|
||||
const movingNode = useEditor((state) => state.movingNode)
|
||||
const curvingWall = useEditor((state) => state.curvingWall)
|
||||
const curvingFence = useEditor((state) => state.curvingFence)
|
||||
|
||||
if (!isFloorplanHovered || movingNode || curvingWall) {
|
||||
if (!isFloorplanHovered || movingNode || curvingWall || curvingFence) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -4976,6 +4983,7 @@ export function FloorplanPanel() {
|
||||
const setMode = useEditor((state) => state.setMode)
|
||||
const movingNode = useEditor((state) => state.movingNode)
|
||||
const curvingWall = useEditor((state) => state.curvingWall)
|
||||
const curvingFence = useEditor((state) => state.curvingFence)
|
||||
const phase = useEditor((state) => state.phase)
|
||||
const mode = useEditor((state) => state.mode)
|
||||
const setPhase = useEditor((state) => state.setPhase)
|
||||
@@ -5650,6 +5658,7 @@ export function FloorplanPanel() {
|
||||
const isCeilingMoveActive = movingNode?.type === 'ceiling'
|
||||
const isWallMoveActive = movingNode?.type === 'wall'
|
||||
const isWallCurveActive = curvingWall?.type === 'wall'
|
||||
const isFenceCurveActive = curvingFence?.type === 'fence'
|
||||
const isItemPlacementPreviewActive =
|
||||
(mode === 'build' && tool === 'item') || movingNode?.type === 'item'
|
||||
const isFloorItemBuildActive = mode === 'build' && tool === 'item' && !selectedItem?.attachTo
|
||||
@@ -5661,6 +5670,7 @@ export function FloorplanPanel() {
|
||||
isCeilingMoveActive ||
|
||||
isWallMoveActive ||
|
||||
isWallCurveActive ||
|
||||
isFenceCurveActive ||
|
||||
isFloorItemBuildActive ||
|
||||
isFloorItemMoveActive
|
||||
const floorplanPreviewStairSegment = useMemo(
|
||||
@@ -6155,12 +6165,12 @@ export function FloorplanPanel() {
|
||||
if (levelChanged) {
|
||||
previousLevelIdRef.current = levelId ?? null
|
||||
hasUserAdjustedViewportRef.current = false
|
||||
setViewport(fittedViewport)
|
||||
setViewport((current) => (floorplanViewportEquals(current, fittedViewport) ? current : fittedViewport))
|
||||
return
|
||||
}
|
||||
|
||||
if (!hasUserAdjustedViewportRef.current) {
|
||||
setViewport(fittedViewport)
|
||||
setViewport((current) => (floorplanViewportEquals(current, fittedViewport) ? current : fittedViewport))
|
||||
}
|
||||
}, [fittedViewport, levelId])
|
||||
|
||||
|
||||
@@ -472,6 +472,7 @@ export const SelectionManager = () => {
|
||||
|
||||
const movingNode = useEditor((s) => s.movingNode)
|
||||
const curvingWall = useEditor((s) => s.curvingWall)
|
||||
const curvingFence = useEditor((s) => s.curvingFence)
|
||||
|
||||
useEffect(() => {
|
||||
setHoverHighlightMode(mode === 'delete' ? 'delete' : 'default')
|
||||
@@ -510,7 +511,7 @@ export const SelectionManager = () => {
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== 'select') return
|
||||
if (movingNode || curvingWall) return
|
||||
if (movingNode || curvingWall || curvingFence) return
|
||||
|
||||
const onClick = (event: NodeEvent) => {
|
||||
// Skip if box-select just completed (drag ended over a node)
|
||||
@@ -648,12 +649,12 @@ export const SelectionManager = () => {
|
||||
})
|
||||
emitter.off('grid:click', onGridClick)
|
||||
}
|
||||
}, [curvingWall, mode, movingNode])
|
||||
}, [curvingFence, curvingWall, mode, movingNode])
|
||||
|
||||
// Global double-click handler for auto-switching phases and cross-phase hover
|
||||
useEffect(() => {
|
||||
if (mode !== 'select') return
|
||||
if (movingNode || curvingWall) return
|
||||
if (movingNode || curvingWall || curvingFence) return
|
||||
|
||||
const onEnter = (event: NodeEvent) => {
|
||||
const node = event.node
|
||||
@@ -782,7 +783,7 @@ export const SelectionManager = () => {
|
||||
emitter.off(`${type}:double-click` as any, onDoubleClick as any)
|
||||
})
|
||||
}
|
||||
}, [curvingWall, mode, movingNode])
|
||||
}, [curvingFence, curvingWall, mode, movingNode])
|
||||
|
||||
// Delete mode: click-to-delete (sledgehammer tool)
|
||||
useEffect(() => {
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
type AnyNodeId,
|
||||
emitter,
|
||||
type FenceNode,
|
||||
type GridEvent,
|
||||
getClampedWallCurveOffset,
|
||||
getMaxWallCurveOffset,
|
||||
getWallChordFrame,
|
||||
getWallMidpointHandlePoint,
|
||||
normalizeWallCurveOffset,
|
||||
pauseSceneHistory,
|
||||
resumeSceneHistory,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
import { CursorSphere } from '../shared/cursor-sphere'
|
||||
import { getWallGridStep, snapScalarToGrid } from '../wall/wall-drafting'
|
||||
|
||||
export const CurveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
|
||||
const activatedAtRef = useRef<number>(Date.now())
|
||||
const originalCurveOffsetRef = useRef(getClampedWallCurveOffset(node))
|
||||
const previousCurveOffsetRef = useRef<number | null>(null)
|
||||
const shiftPressedRef = useRef(false)
|
||||
const previewOffsetRef = useRef<number>(originalCurveOffsetRef.current)
|
||||
|
||||
const initialHandle = getWallMidpointHandlePoint(node)
|
||||
const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>([
|
||||
initialHandle.x,
|
||||
0,
|
||||
initialHandle.y,
|
||||
])
|
||||
|
||||
const exitCurveMode = useCallback(() => {
|
||||
useEditor.getState().setCurvingFence(null)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const nodeId = node.id
|
||||
const originalCurveOffset = originalCurveOffsetRef.current
|
||||
const chord = getWallChordFrame(node)
|
||||
const maxCurveOffset = getMaxWallCurveOffset(node)
|
||||
|
||||
pauseSceneHistory(useScene)
|
||||
let wasCommitted = false
|
||||
|
||||
const applyPreview = (curveOffset: number) => {
|
||||
if (previewOffsetRef.current === curveOffset) {
|
||||
return
|
||||
}
|
||||
previewOffsetRef.current = curveOffset
|
||||
|
||||
const nextNode = {
|
||||
...node,
|
||||
curveOffset,
|
||||
}
|
||||
const handlePoint = getWallMidpointHandlePoint(nextNode)
|
||||
setCursorLocalPos([handlePoint.x, 0, handlePoint.y])
|
||||
useScene.getState().updateNode(nodeId, { curveOffset })
|
||||
useScene.getState().markDirty(nodeId as AnyNodeId)
|
||||
}
|
||||
|
||||
const restoreOriginal = () => {
|
||||
if (previewOffsetRef.current === originalCurveOffset) {
|
||||
return
|
||||
}
|
||||
previewOffsetRef.current = originalCurveOffset
|
||||
useScene.getState().updateNode(nodeId, { curveOffset: originalCurveOffset })
|
||||
useScene.getState().markDirty(nodeId as AnyNodeId)
|
||||
}
|
||||
|
||||
const onGridMove = (event: GridEvent) => {
|
||||
const snapStep = getWallGridStep()
|
||||
const localX = shiftPressedRef.current
|
||||
? event.localPosition[0]
|
||||
: snapScalarToGrid(event.localPosition[0], snapStep)
|
||||
const localZ = shiftPressedRef.current
|
||||
? event.localPosition[2]
|
||||
: snapScalarToGrid(event.localPosition[2], snapStep)
|
||||
|
||||
const offsetFromMidpoint =
|
||||
-(
|
||||
(localX - chord.midpoint.x) * chord.normal.x +
|
||||
(localZ - chord.midpoint.y) * chord.normal.y
|
||||
)
|
||||
const snappedOffset = shiftPressedRef.current
|
||||
? offsetFromMidpoint
|
||||
: snapScalarToGrid(offsetFromMidpoint, snapStep)
|
||||
const nextCurveOffset = normalizeWallCurveOffset(
|
||||
node,
|
||||
Math.max(-maxCurveOffset, Math.min(maxCurveOffset, snappedOffset)),
|
||||
)
|
||||
|
||||
if (
|
||||
previousCurveOffsetRef.current !== null &&
|
||||
nextCurveOffset !== previousCurveOffsetRef.current
|
||||
) {
|
||||
sfxEmitter.emit('sfx:grid-snap')
|
||||
}
|
||||
previousCurveOffsetRef.current = nextCurveOffset
|
||||
|
||||
applyPreview(nextCurveOffset)
|
||||
}
|
||||
|
||||
const onGridClick = (event: GridEvent) => {
|
||||
if (Date.now() - activatedAtRef.current < 150) {
|
||||
event.nativeEvent?.stopPropagation?.()
|
||||
return
|
||||
}
|
||||
|
||||
const curveOffset = previewOffsetRef.current
|
||||
wasCommitted = true
|
||||
|
||||
if (curveOffset !== originalCurveOffset) {
|
||||
useScene.getState().updateNode(nodeId, { curveOffset: originalCurveOffset })
|
||||
useScene.getState().markDirty(nodeId as AnyNodeId)
|
||||
|
||||
resumeSceneHistory(useScene)
|
||||
useScene.getState().updateNode(nodeId, { curveOffset })
|
||||
useScene.getState().markDirty(nodeId as AnyNodeId)
|
||||
pauseSceneHistory(useScene)
|
||||
}
|
||||
|
||||
sfxEmitter.emit('sfx:item-place')
|
||||
useViewer.getState().setSelection({ selectedIds: [nodeId] })
|
||||
exitCurveMode()
|
||||
event.nativeEvent?.stopPropagation?.()
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
restoreOriginal()
|
||||
useViewer.getState().setSelection({ selectedIds: [nodeId] })
|
||||
resumeSceneHistory(useScene)
|
||||
markToolCancelConsumed()
|
||||
exitCurveMode()
|
||||
}
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Shift') {
|
||||
shiftPressedRef.current = true
|
||||
}
|
||||
}
|
||||
|
||||
const onKeyUp = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Shift') {
|
||||
shiftPressedRef.current = false
|
||||
}
|
||||
}
|
||||
|
||||
emitter.on('grid:move', onGridMove)
|
||||
emitter.on('grid:click', onGridClick)
|
||||
emitter.on('tool:cancel', onCancel)
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
window.addEventListener('keyup', onKeyUp)
|
||||
|
||||
return () => {
|
||||
if (!wasCommitted) {
|
||||
restoreOriginal()
|
||||
}
|
||||
resumeSceneHistory(useScene)
|
||||
emitter.off('grid:move', onGridMove)
|
||||
emitter.off('grid:click', onGridClick)
|
||||
emitter.off('tool:cancel', onCancel)
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
window.removeEventListener('keyup', onKeyUp)
|
||||
}
|
||||
}, [exitCurveMode, node])
|
||||
|
||||
return (
|
||||
<group>
|
||||
<CursorSphere position={cursorLocalPos} showTooltip={false} />
|
||||
</group>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { FenceNode, useScene, type WallNode } from '@pascal-app/core'
|
||||
import { FenceNode, getWallCurveFrameAt, getWallCurveLength, isCurvedWall, useScene, type WallNode } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import {
|
||||
getWallAngleSnapStep,
|
||||
getWallGridStep,
|
||||
type WallPlanPoint,
|
||||
findWallSnapTarget,
|
||||
isWallLongEnough,
|
||||
@@ -58,11 +60,16 @@ function findFenceSnapTarget(
|
||||
continue
|
||||
}
|
||||
|
||||
const candidates: Array<FencePlanPoint | null> = [
|
||||
fence.start,
|
||||
fence.end,
|
||||
projectPointOntoSegment(point, fence),
|
||||
]
|
||||
const candidates: Array<FencePlanPoint | null> = [fence.start, fence.end]
|
||||
if (isCurvedWall(fence)) {
|
||||
const sampleCount = Math.max(8, Math.ceil(getWallCurveLength(fence) / 0.3))
|
||||
for (let index = 0; index <= sampleCount; index += 1) {
|
||||
const frame = getWallCurveFrameAt(fence, index / sampleCount)
|
||||
candidates.push([frame.point.x, frame.point.y])
|
||||
}
|
||||
} else {
|
||||
candidates.push(projectPointOntoSegment(point, fence))
|
||||
}
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate) {
|
||||
@@ -94,7 +101,12 @@ export function snapFenceDraftPoint(args: {
|
||||
ignoreFenceIds?: string[]
|
||||
}): FencePlanPoint {
|
||||
const { point, walls, fences, start, angleSnap = false, ignoreFenceIds } = args
|
||||
const basePoint = start && angleSnap ? snapPointTo45Degrees(start, point) : snapPointToGrid(point)
|
||||
const gridStep = getWallGridStep()
|
||||
const angleStep = getWallAngleSnapStep(gridStep)
|
||||
const basePoint =
|
||||
start && angleSnap
|
||||
? snapPointTo45Degrees(start, point, gridStep, angleStep)
|
||||
: snapPointToGrid(point, gridStep)
|
||||
const fenceSnapTarget = findFenceSnapTarget(basePoint, fences, ignoreFenceIds)
|
||||
|
||||
return fenceSnapTarget ?? findWallSnapTarget(basePoint, walls) ?? basePoint
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
type AnyNodeId,
|
||||
type FenceNode,
|
||||
type WallNode,
|
||||
emitter,
|
||||
type GridEvent,
|
||||
pauseSceneHistory,
|
||||
resumeSceneHistory,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { Html } from '@react-three/drei'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import useEditor, { type MovingFenceEndpoint } from '../../../store/use-editor'
|
||||
import { CursorSphere } from '../shared/cursor-sphere'
|
||||
import { snapFenceDraftPoint, type FencePlanPoint } from './fence-drafting'
|
||||
import { isWallLongEnough } from '../wall/wall-drafting'
|
||||
|
||||
function samePoint(a: FencePlanPoint, b: FencePlanPoint) {
|
||||
return a[0] === b[0] && a[1] === b[1]
|
||||
}
|
||||
|
||||
type LinkedFenceSnapshot = {
|
||||
id: FenceNode['id']
|
||||
start: FencePlanPoint
|
||||
end: FencePlanPoint
|
||||
}
|
||||
|
||||
function getLinkedFenceSnapshots(args: {
|
||||
fenceId: FenceNode['id']
|
||||
fenceParentId: string | null
|
||||
originalStart: FencePlanPoint
|
||||
originalEnd: FencePlanPoint
|
||||
}) {
|
||||
const { fenceId, fenceParentId, originalStart, originalEnd } = args
|
||||
const { nodes } = useScene.getState()
|
||||
const snapshots: LinkedFenceSnapshot[] = []
|
||||
|
||||
for (const node of Object.values(nodes)) {
|
||||
if (!(node?.type === 'fence' && node.id !== fenceId)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if ((node.parentId ?? null) !== fenceParentId) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (
|
||||
!samePoint(node.start, originalStart) &&
|
||||
!samePoint(node.start, originalEnd) &&
|
||||
!samePoint(node.end, originalStart) &&
|
||||
!samePoint(node.end, originalEnd)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
snapshots.push({
|
||||
id: node.id,
|
||||
start: [...node.start] as FencePlanPoint,
|
||||
end: [...node.end] as FencePlanPoint,
|
||||
})
|
||||
}
|
||||
|
||||
return snapshots
|
||||
}
|
||||
|
||||
function getLinkedFenceUpdates(
|
||||
linkedFences: LinkedFenceSnapshot[],
|
||||
originalStart: FencePlanPoint,
|
||||
originalEnd: FencePlanPoint,
|
||||
nextStart: FencePlanPoint,
|
||||
nextEnd: FencePlanPoint,
|
||||
) {
|
||||
return linkedFences.map((fence) => ({
|
||||
id: fence.id,
|
||||
start: samePoint(fence.start, originalStart)
|
||||
? nextStart
|
||||
: samePoint(fence.start, originalEnd)
|
||||
? nextEnd
|
||||
: fence.start,
|
||||
end: samePoint(fence.end, originalStart)
|
||||
? nextStart
|
||||
: samePoint(fence.end, originalEnd)
|
||||
? nextEnd
|
||||
: fence.end,
|
||||
}))
|
||||
}
|
||||
|
||||
export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> = ({ target }) => {
|
||||
const activatedAtRef = useRef<number>(Date.now())
|
||||
const previousGridPosRef = useRef<FencePlanPoint | null>(null)
|
||||
const shiftPressedRef = useRef(false)
|
||||
const altPressedRef = useRef(false)
|
||||
const nodeIdRef = useRef(target.fence.id)
|
||||
const originalStartRef = useRef<FencePlanPoint>([...target.fence.start] as FencePlanPoint)
|
||||
const originalEndRef = useRef<FencePlanPoint>([...target.fence.end] as FencePlanPoint)
|
||||
const fixedPointRef = useRef<FencePlanPoint>(
|
||||
target.endpoint === 'start'
|
||||
? ([...target.fence.end] as FencePlanPoint)
|
||||
: ([...target.fence.start] as FencePlanPoint),
|
||||
)
|
||||
const linkedOriginalsRef = useRef(
|
||||
getLinkedFenceSnapshots({
|
||||
fenceId: target.fence.id,
|
||||
fenceParentId: target.fence.parentId ?? null,
|
||||
originalStart: target.fence.start,
|
||||
originalEnd: target.fence.end,
|
||||
}),
|
||||
)
|
||||
const previewRef = useRef<{ start: FencePlanPoint; end: FencePlanPoint } | null>(null)
|
||||
|
||||
const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>(() => {
|
||||
const point = target.endpoint === 'start' ? target.fence.start : target.fence.end
|
||||
return [point[0], 0, point[1]]
|
||||
})
|
||||
const [altPressed, setAltPressed] = useState(false)
|
||||
|
||||
const exitMoveMode = useCallback(() => {
|
||||
useEditor.getState().setMovingFenceEndpoint(null)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const nodeId = nodeIdRef.current
|
||||
const originalStart = originalStartRef.current
|
||||
const originalEnd = originalEndRef.current
|
||||
const fixedPoint = fixedPointRef.current
|
||||
const siblings = Object.values(useScene.getState().nodes)
|
||||
const levelWalls = siblings.filter(
|
||||
(node): node is WallNode =>
|
||||
node?.type === 'wall' && (node.parentId ?? null) === (target.fence.parentId ?? null),
|
||||
)
|
||||
const levelFences = siblings.filter(
|
||||
(node): node is FenceNode =>
|
||||
node?.type === 'fence' && (node.parentId ?? null) === (target.fence.parentId ?? null),
|
||||
)
|
||||
|
||||
pauseSceneHistory(useScene)
|
||||
let wasCommitted = false
|
||||
|
||||
const applyNodePreview = (
|
||||
updates: Array<{ id: FenceNode['id']; start: FencePlanPoint; end: FencePlanPoint }>,
|
||||
) => {
|
||||
useScene.getState().updateNodes(
|
||||
updates.map((entry) => ({
|
||||
id: entry.id as AnyNodeId,
|
||||
data: { start: entry.start, end: entry.end },
|
||||
})),
|
||||
)
|
||||
for (const entry of updates) {
|
||||
useScene.getState().markDirty(entry.id as AnyNodeId)
|
||||
}
|
||||
}
|
||||
|
||||
const applyPreview = (movingPoint: FencePlanPoint, detachLinkedFences = false) => {
|
||||
const nextStart = target.endpoint === 'start' ? movingPoint : fixedPoint
|
||||
const nextEnd = target.endpoint === 'end' ? movingPoint : fixedPoint
|
||||
previewRef.current = { start: nextStart, end: nextEnd }
|
||||
setCursorLocalPos([movingPoint[0], 0, movingPoint[1]])
|
||||
applyNodePreview([
|
||||
{ id: nodeId, start: nextStart, end: nextEnd },
|
||||
...(detachLinkedFences
|
||||
? []
|
||||
: getLinkedFenceUpdates(
|
||||
linkedOriginalsRef.current,
|
||||
originalStart,
|
||||
originalEnd,
|
||||
nextStart,
|
||||
nextEnd,
|
||||
)),
|
||||
])
|
||||
}
|
||||
|
||||
const restoreOriginal = () => {
|
||||
applyNodePreview([
|
||||
{ id: nodeId, start: originalStart, end: originalEnd },
|
||||
...linkedOriginalsRef.current,
|
||||
])
|
||||
}
|
||||
|
||||
const onGridMove = (event: GridEvent) => {
|
||||
const planPoint: FencePlanPoint = [event.localPosition[0], event.localPosition[2]]
|
||||
const snappedPoint = snapFenceDraftPoint({
|
||||
point: planPoint,
|
||||
walls: levelWalls,
|
||||
fences: levelFences,
|
||||
start: fixedPoint,
|
||||
angleSnap: !shiftPressedRef.current,
|
||||
ignoreFenceIds: [nodeId],
|
||||
})
|
||||
|
||||
if (
|
||||
previousGridPosRef.current &&
|
||||
(snappedPoint[0] !== previousGridPosRef.current[0] ||
|
||||
snappedPoint[1] !== previousGridPosRef.current[1])
|
||||
) {
|
||||
sfxEmitter.emit('sfx:grid-snap')
|
||||
}
|
||||
previousGridPosRef.current = snappedPoint
|
||||
|
||||
applyPreview(snappedPoint, event.nativeEvent.altKey)
|
||||
}
|
||||
|
||||
const onGridClick = (event: GridEvent) => {
|
||||
if (Date.now() - activatedAtRef.current < 150) {
|
||||
event.nativeEvent?.stopPropagation?.()
|
||||
return
|
||||
}
|
||||
|
||||
const preview = previewRef.current ?? { start: originalStart, end: originalEnd }
|
||||
const hasChanged =
|
||||
!samePoint(preview.start, originalStart) || !samePoint(preview.end, originalEnd)
|
||||
|
||||
if (hasChanged && isWallLongEnough(preview.start, preview.end)) {
|
||||
wasCommitted = true
|
||||
|
||||
applyNodePreview([
|
||||
{ id: nodeId, start: originalStart, end: originalEnd },
|
||||
...linkedOriginalsRef.current,
|
||||
])
|
||||
|
||||
resumeSceneHistory(useScene)
|
||||
applyNodePreview([
|
||||
{ id: nodeId, start: preview.start, end: preview.end },
|
||||
...(altPressedRef.current
|
||||
? []
|
||||
: getLinkedFenceUpdates(
|
||||
linkedOriginalsRef.current,
|
||||
originalStart,
|
||||
originalEnd,
|
||||
preview.start,
|
||||
preview.end,
|
||||
)),
|
||||
])
|
||||
pauseSceneHistory(useScene)
|
||||
sfxEmitter.emit('sfx:item-place')
|
||||
}
|
||||
|
||||
useViewer.getState().setSelection({ selectedIds: [nodeId] })
|
||||
exitMoveMode()
|
||||
event.nativeEvent?.stopPropagation?.()
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
restoreOriginal()
|
||||
useViewer.getState().setSelection({ selectedIds: [nodeId] })
|
||||
resumeSceneHistory(useScene)
|
||||
markToolCancelConsumed()
|
||||
exitMoveMode()
|
||||
}
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
|
||||
return
|
||||
}
|
||||
if (event.key === 'Shift') {
|
||||
shiftPressedRef.current = true
|
||||
}
|
||||
if (event.key === 'Alt') {
|
||||
altPressedRef.current = true
|
||||
setAltPressed(true)
|
||||
}
|
||||
}
|
||||
|
||||
const onKeyUp = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Shift') {
|
||||
shiftPressedRef.current = false
|
||||
}
|
||||
if (event.key === 'Alt') {
|
||||
altPressedRef.current = false
|
||||
setAltPressed(false)
|
||||
}
|
||||
}
|
||||
|
||||
const onWindowBlur = () => {
|
||||
shiftPressedRef.current = false
|
||||
altPressedRef.current = false
|
||||
setAltPressed(false)
|
||||
}
|
||||
|
||||
emitter.on('grid:move', onGridMove)
|
||||
emitter.on('grid:click', onGridClick)
|
||||
emitter.on('tool:cancel', onCancel)
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
window.addEventListener('keyup', onKeyUp)
|
||||
window.addEventListener('blur', onWindowBlur)
|
||||
|
||||
return () => {
|
||||
if (!wasCommitted) {
|
||||
restoreOriginal()
|
||||
}
|
||||
resumeSceneHistory(useScene)
|
||||
emitter.off('grid:move', onGridMove)
|
||||
emitter.off('grid:click', onGridClick)
|
||||
emitter.off('tool:cancel', onCancel)
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
window.removeEventListener('keyup', onKeyUp)
|
||||
window.removeEventListener('blur', onWindowBlur)
|
||||
}
|
||||
}, [exitMoveMode, target])
|
||||
|
||||
return (
|
||||
<group>
|
||||
<CursorSphere position={cursorLocalPos} showTooltip={false} />
|
||||
<Html
|
||||
position={[cursorLocalPos[0], 0, cursorLocalPos[2]]}
|
||||
style={{ pointerEvents: 'none', touchAction: 'none' }}
|
||||
zIndexRange={[100, 0]}
|
||||
>
|
||||
<div className="translate-y-10">
|
||||
<div
|
||||
className={`whitespace-nowrap rounded-full border px-2 py-1 text-[11px] font-medium shadow-lg backdrop-blur-md transition-colors ${
|
||||
altPressed
|
||||
? 'border-amber-500/70 bg-amber-500/15 text-amber-100'
|
||||
: 'border-border/70 bg-background/90 text-foreground/80'
|
||||
}`}
|
||||
>
|
||||
{altPressed ? 'Detach endpoint' : 'Drag endpoint'}
|
||||
</div>
|
||||
</div>
|
||||
</Html>
|
||||
</group>
|
||||
)
|
||||
}
|
||||
@@ -167,6 +167,14 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
|
||||
const preview = previewRef.current ?? { start: originalStart, end: originalEnd }
|
||||
|
||||
wasCommitted = true
|
||||
|
||||
// Restore original baseline while paused so the next resume+update
|
||||
// registers as a single tracked change (undo reverts to original).
|
||||
applyNodePreview([
|
||||
{ id: nodeId, start: originalStart, end: originalEnd },
|
||||
...linkedOriginalsRef.current,
|
||||
])
|
||||
|
||||
useScene.temporal.getState().resume()
|
||||
applyNodePreview([
|
||||
{ id: nodeId, start: preview.start, end: preview.end },
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import {
|
||||
type AnyNodeId,
|
||||
emitter,
|
||||
type FenceNode,
|
||||
type GridEvent,
|
||||
type LevelNode,
|
||||
type RoofNode,
|
||||
type RoofSegmentNode,
|
||||
type StairNode,
|
||||
@@ -9,13 +11,16 @@ import {
|
||||
sceneRegistry,
|
||||
useLiveTransforms,
|
||||
useScene,
|
||||
type WallNode,
|
||||
} from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
import { snapFenceDraftPoint } from '../fence/fence-drafting'
|
||||
import { CursorSphere } from '../shared/cursor-sphere'
|
||||
import type { WallPlanPoint } from '../wall/wall-drafting'
|
||||
|
||||
export const MoveRoofTool: React.FC<{
|
||||
node: RoofNode | RoofSegmentNode | StairNode | StairSegmentNode
|
||||
@@ -118,6 +123,46 @@ export const MoveRoofTool: React.FC<{
|
||||
}
|
||||
}
|
||||
|
||||
const resolveLevelId = () => {
|
||||
if (movingNode.type === 'roof' || movingNode.type === 'stair') {
|
||||
return movingNode.parentId ?? null
|
||||
}
|
||||
|
||||
if (
|
||||
(movingNode.type === 'roof-segment' || movingNode.type === 'stair-segment') &&
|
||||
movingNode.parentId
|
||||
) {
|
||||
const parentNode = useScene.getState().nodes[movingNode.parentId as AnyNodeId]
|
||||
return parentNode && 'parentId' in parentNode ? (parentNode.parentId ?? null) : null
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const levelId = resolveLevelId()
|
||||
const levelNode =
|
||||
levelId && useScene.getState().nodes[levelId as AnyNodeId]?.type === 'level'
|
||||
? (useScene.getState().nodes[levelId as AnyNodeId] as LevelNode)
|
||||
: null
|
||||
const levelChildren = levelNode?.children ?? []
|
||||
const levelWalls = levelChildren
|
||||
.map((childId) => useScene.getState().nodes[childId as AnyNodeId])
|
||||
.filter((node): node is WallNode => node?.type === 'wall')
|
||||
const levelFences = levelChildren
|
||||
.map((childId) => useScene.getState().nodes[childId as AnyNodeId])
|
||||
.filter((node): node is FenceNode => node?.type === 'fence')
|
||||
const buildingId = useViewer.getState().selection.buildingId
|
||||
const buildingObj = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : null
|
||||
|
||||
const localToWorldPoint = (localPoint: WallPlanPoint, y: number): [number, number, number] => {
|
||||
if (buildingObj) {
|
||||
const worldPoint = buildingObj.localToWorld(new THREE.Vector3(localPoint[0], y, localPoint[1]))
|
||||
return [worldPoint.x, worldPoint.y, worldPoint.z]
|
||||
}
|
||||
|
||||
return [localPoint[0], y, localPoint[1]]
|
||||
}
|
||||
|
||||
const computeLocal = (
|
||||
gridX: number,
|
||||
gridZ: number,
|
||||
@@ -155,21 +200,21 @@ export const MoveRoofTool: React.FC<{
|
||||
}
|
||||
|
||||
const onGridMove = (event: GridEvent) => {
|
||||
const gridX = Math.round(event.position[0] * 2) / 2
|
||||
const gridZ = Math.round(event.position[2] * 2) / 2
|
||||
const y = event.position[1]
|
||||
|
||||
if (
|
||||
previousGridPosRef.current &&
|
||||
(gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1])
|
||||
) {
|
||||
const snappedLocal = snapFenceDraftPoint({
|
||||
point: [event.localPosition[0], event.localPosition[2]],
|
||||
walls: levelWalls,
|
||||
fences: levelFences,
|
||||
})
|
||||
const [gridX, , gridZ] = localToWorldPoint(snappedLocal, y)
|
||||
|
||||
if (previousGridPosRef.current && (gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1])) {
|
||||
sfxEmitter.emit('sfx:grid-snap')
|
||||
}
|
||||
|
||||
previousGridPosRef.current = [gridX, gridZ]
|
||||
// Cursor is inside the building-local ToolManager group — use local position
|
||||
const lx = Math.round(event.localPosition[0] * 2) / 2
|
||||
const lz = Math.round(event.localPosition[2] * 2) / 2
|
||||
const [lx, lz] = snappedLocal
|
||||
setCursorWorldPos([lx, event.localPosition[1], lz])
|
||||
|
||||
const [localX, localZ] = computeLocal(gridX, gridZ, y, lx, lz)
|
||||
@@ -189,11 +234,14 @@ export const MoveRoofTool: React.FC<{
|
||||
}
|
||||
|
||||
const onGridClick = (event: GridEvent) => {
|
||||
const gridX = Math.round(event.position[0] * 2) / 2 // world, for computeLocal
|
||||
const gridZ = Math.round(event.position[2] * 2) / 2
|
||||
const y = event.position[1]
|
||||
const lx = Math.round(event.localPosition[0] * 2) / 2
|
||||
const lz = Math.round(event.localPosition[2] * 2) / 2
|
||||
const snappedLocal = snapFenceDraftPoint({
|
||||
point: [event.localPosition[0], event.localPosition[2]],
|
||||
walls: levelWalls,
|
||||
fences: levelFences,
|
||||
})
|
||||
const [gridX, , gridZ] = localToWorldPoint(snappedLocal, y)
|
||||
const [lx, lz] = snappedLocal
|
||||
|
||||
const [localX, localZ] = computeLocal(gridX, gridZ, y, lx, lz)
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { BufferGeometry, Float32BufferAttribute, type Line, type Object3D } from 'three'
|
||||
import { EDITOR_LAYER } from '../../../lib/constants'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import { snapToHalf } from '../item/placement-math'
|
||||
|
||||
const Y_OFFSET = 0.02
|
||||
|
||||
@@ -187,8 +188,8 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
||||
// Listen to grid:move events to track cursor position
|
||||
useEffect(() => {
|
||||
const onGridMove = (event: GridEvent) => {
|
||||
const gridX = Math.round(event.localPosition[0] * 2) / 2
|
||||
const gridZ = Math.round(event.localPosition[2] * 2) / 2
|
||||
const gridX = snapToHalf(event.localPosition[0])
|
||||
const gridZ = snapToHalf(event.localPosition[2])
|
||||
const newPosition: [number, number] = [gridX, gridZ]
|
||||
|
||||
// Play snap sound when cursor moves to a new grid cell during drag
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
'use client'
|
||||
|
||||
import { type AnyNodeId, emitter, type GridEvent, useScene, type SlabNode } from '@pascal-app/core'
|
||||
import {
|
||||
type AnyNodeId,
|
||||
emitter,
|
||||
type FenceNode,
|
||||
type GridEvent,
|
||||
type LevelNode,
|
||||
type SlabNode,
|
||||
useScene,
|
||||
type WallNode,
|
||||
} from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
import { snapFenceDraftPoint } from '../fence/fence-drafting'
|
||||
import { CursorSphere } from '../shared/cursor-sphere'
|
||||
|
||||
function snap(value: number) {
|
||||
return Math.round(value * 2) / 2
|
||||
}
|
||||
|
||||
function translatePolygon(
|
||||
polygon: Array<[number, number]>,
|
||||
deltaX: number,
|
||||
@@ -56,6 +62,17 @@ export const MoveSlabTool: React.FC<{ node: SlabNode }> = ({ node }) => {
|
||||
useEffect(() => {
|
||||
const originalPolygon = originalPolygonRef.current
|
||||
const originalHoles = originalHolesRef.current
|
||||
const levelNode =
|
||||
node.parentId && useScene.getState().nodes[node.parentId as AnyNodeId]?.type === 'level'
|
||||
? (useScene.getState().nodes[node.parentId as AnyNodeId] as LevelNode)
|
||||
: null
|
||||
const levelChildren = levelNode?.children ?? []
|
||||
const levelWalls = levelChildren
|
||||
.map((childId) => useScene.getState().nodes[childId as AnyNodeId])
|
||||
.filter((child): child is WallNode => child?.type === 'wall')
|
||||
const levelFences = levelChildren
|
||||
.map((childId) => useScene.getState().nodes[childId as AnyNodeId])
|
||||
.filter((child): child is FenceNode => child?.type === 'fence')
|
||||
|
||||
useScene.temporal.getState().pause()
|
||||
let wasCommitted = false
|
||||
@@ -80,8 +97,11 @@ export const MoveSlabTool: React.FC<{ node: SlabNode }> = ({ node }) => {
|
||||
}
|
||||
|
||||
const onGridMove = (event: GridEvent) => {
|
||||
const localX = snap(event.localPosition[0])
|
||||
const localZ = snap(event.localPosition[2])
|
||||
const [localX, localZ] = snapFenceDraftPoint({
|
||||
point: [event.localPosition[0], event.localPosition[2]],
|
||||
walls: levelWalls,
|
||||
fences: levelFences,
|
||||
})
|
||||
|
||||
if (
|
||||
previousGridPosRef.current &&
|
||||
|
||||
@@ -11,7 +11,9 @@ import { CeilingBoundaryEditor } from './ceiling/ceiling-boundary-editor'
|
||||
import { CeilingHoleEditor } from './ceiling/ceiling-hole-editor'
|
||||
import { CeilingTool } from './ceiling/ceiling-tool'
|
||||
import { DoorTool } from './door/door-tool'
|
||||
import { CurveFenceTool } from './fence/curve-fence-tool'
|
||||
import { FenceTool } from './fence/fence-tool'
|
||||
import { MoveFenceEndpointTool } from './fence/move-fence-endpoint-tool'
|
||||
import { ItemTool } from './item/item-tool'
|
||||
import { MoveTool } from './item/move-tool'
|
||||
import { RoofTool } from './roof/roof-tool'
|
||||
@@ -54,7 +56,9 @@ export const ToolManager: React.FC = () => {
|
||||
const tool = useEditor((state) => state.tool)
|
||||
const movingNode = useEditor((state) => state.movingNode)
|
||||
const movingWallEndpoint = useEditor((state) => state.movingWallEndpoint)
|
||||
const movingFenceEndpoint = useEditor((state) => state.movingFenceEndpoint)
|
||||
const curvingWall = useEditor((state) => state.curvingWall)
|
||||
const curvingFence = useEditor((state) => state.curvingFence)
|
||||
const editingHole = useEditor((state) => state.editingHole)
|
||||
const selectedZoneId = useViewer((state) => state.selection.zoneId)
|
||||
const buildingId = useViewer((state) => state.selection.buildingId)
|
||||
@@ -145,7 +149,9 @@ export const ToolManager: React.FC = () => {
|
||||
<CeilingHoleEditor ceilingId={selectedCeilingId} holeIndex={editingHole.holeIndex} />
|
||||
)}
|
||||
{movingWallEndpoint && <MoveWallEndpointTool target={movingWallEndpoint} />}
|
||||
{movingFenceEndpoint && <MoveFenceEndpointTool target={movingFenceEndpoint} />}
|
||||
{curvingWall && <CurveWallTool node={curvingWall} />}
|
||||
{curvingFence && <CurveFenceTool node={curvingFence} />}
|
||||
{movingNode && movingNode.type !== 'building' && <MoveTool />}
|
||||
{!movingNode && BuildToolComponent && <BuildToolComponent />}
|
||||
</group>
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
'use client'
|
||||
|
||||
import { type AnyNodeId, emitter, type GridEvent, useScene, type WallNode } from '@pascal-app/core'
|
||||
import {
|
||||
type AnyNodeId,
|
||||
emitter,
|
||||
type GridEvent,
|
||||
pauseSceneHistory,
|
||||
resumeSceneHistory,
|
||||
useScene,
|
||||
type WallNode,
|
||||
} from '@pascal-app/core'
|
||||
import { Html } from '@react-three/drei'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
@@ -127,7 +135,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
|
||||
node?.type === 'wall' && (node.parentId ?? null) === (target.wall.parentId ?? null),
|
||||
)
|
||||
|
||||
useScene.temporal.getState().pause()
|
||||
pauseSceneHistory(useScene)
|
||||
let wasCommitted = false
|
||||
|
||||
const applyNodePreview = (
|
||||
@@ -209,7 +217,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
|
||||
...linkedOriginalsRef.current,
|
||||
])
|
||||
|
||||
useScene.temporal.getState().resume()
|
||||
resumeSceneHistory(useScene)
|
||||
applyNodePreview([
|
||||
{ id: nodeId, start: preview.start, end: preview.end },
|
||||
...(altPressedRef.current
|
||||
@@ -222,7 +230,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
|
||||
preview.end,
|
||||
)),
|
||||
])
|
||||
useScene.temporal.getState().pause()
|
||||
pauseSceneHistory(useScene)
|
||||
sfxEmitter.emit('sfx:item-place')
|
||||
}
|
||||
|
||||
@@ -234,7 +242,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
|
||||
const onCancel = () => {
|
||||
restoreOriginal()
|
||||
useViewer.getState().setSelection({ selectedIds: [nodeId] })
|
||||
useScene.temporal.getState().resume()
|
||||
resumeSceneHistory(useScene)
|
||||
markToolCancelConsumed()
|
||||
exitMoveMode()
|
||||
}
|
||||
@@ -279,7 +287,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
|
||||
if (!wasCommitted) {
|
||||
restoreOriginal()
|
||||
}
|
||||
useScene.temporal.getState().resume()
|
||||
resumeSceneHistory(useScene)
|
||||
emitter.off('grid:move', onGridMove)
|
||||
emitter.off('grid:click', onGridClick)
|
||||
emitter.off('tool:cancel', onCancel)
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
'use client'
|
||||
|
||||
import { type AnyNodeId, emitter, type GridEvent, useScene, type WallNode } from '@pascal-app/core'
|
||||
import {
|
||||
type AnyNodeId,
|
||||
emitter,
|
||||
type GridEvent,
|
||||
pauseSceneHistory,
|
||||
resumeSceneHistory,
|
||||
useScene,
|
||||
type WallNode,
|
||||
} from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
|
||||
@@ -24,9 +32,9 @@ function stripWallIsNewMetadata(meta: WallNode['metadata']): WallNode['metadata'
|
||||
return meta
|
||||
}
|
||||
|
||||
const nextMeta = { ...(meta as Record<string, unknown>) }
|
||||
const nextMeta = { ...(meta as Record<string, unknown>) } as Record<string, unknown>
|
||||
delete nextMeta.isNew
|
||||
return nextMeta
|
||||
return nextMeta as WallNode['metadata']
|
||||
}
|
||||
|
||||
type LinkedWallSnapshot = {
|
||||
@@ -146,7 +154,7 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
|
||||
const originalCenter = originalCenterRef.current
|
||||
const originalHalfVector = originalHalfVectorRef.current
|
||||
|
||||
useScene.temporal.getState().pause()
|
||||
pauseSceneHistory(useScene)
|
||||
let wasCommitted = false
|
||||
|
||||
const applyNodePreview = (
|
||||
@@ -237,7 +245,7 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
|
||||
...linkedOriginalsRef.current,
|
||||
])
|
||||
|
||||
useScene.temporal.getState().resume()
|
||||
resumeSceneHistory(useScene)
|
||||
|
||||
const commitUpdates = [
|
||||
{
|
||||
@@ -266,7 +274,7 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
|
||||
useScene.getState().markDirty(id)
|
||||
}
|
||||
|
||||
useScene.temporal.getState().pause()
|
||||
pauseSceneHistory(useScene)
|
||||
|
||||
sfxEmitter.emit('sfx:item-place')
|
||||
useViewer.getState().setSelection({ selectedIds: [nodeId] })
|
||||
@@ -315,7 +323,7 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
|
||||
const onCancel = () => {
|
||||
restoreOriginal()
|
||||
useViewer.getState().setSelection({ selectedIds: [nodeId] })
|
||||
useScene.temporal.getState().resume()
|
||||
resumeSceneHistory(useScene)
|
||||
markToolCancelConsumed()
|
||||
exitMoveMode()
|
||||
}
|
||||
@@ -331,7 +339,7 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
|
||||
restoreOriginal()
|
||||
}
|
||||
shiftPressedRef.current = false
|
||||
useScene.temporal.getState().resume()
|
||||
resumeSceneHistory(useScene)
|
||||
emitter.off('grid:move', onGridMove)
|
||||
emitter.off('grid:click', onGridClick)
|
||||
emitter.off('tool:cancel', onCancel)
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
} from 'lucide-react'
|
||||
import { useEffect } from 'react'
|
||||
import { deleteLevelWithFallbackSelection } from '../../../lib/level-selection'
|
||||
import { runRedo, runUndo } from '../../../lib/history'
|
||||
import { useCommandRegistry } from '../../../store/use-command-registry'
|
||||
import type { StructureTool } from '../../../store/use-editor'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
@@ -313,7 +314,7 @@ export function EditorCommands() {
|
||||
group: 'History',
|
||||
icon: <Undo2 className="h-4 w-4" />,
|
||||
keywords: ['undo', 'revert', 'back'],
|
||||
execute: () => run(() => useScene.temporal.getState().undo()),
|
||||
execute: () => run(() => runUndo()),
|
||||
},
|
||||
{
|
||||
id: 'editor.history.redo',
|
||||
@@ -321,7 +322,7 @@ export function EditorCommands() {
|
||||
group: 'History',
|
||||
icon: <Redo2 className="h-4 w-4" />,
|
||||
keywords: ['redo', 'forward', 'repeat'],
|
||||
execute: () => run(() => useScene.temporal.getState().redo()),
|
||||
execute: () => run(() => runRedo()),
|
||||
},
|
||||
|
||||
// ── Export & Share ───────────────────────────────────────────────────
|
||||
|
||||
@@ -1,8 +1,22 @@
|
||||
'use client'
|
||||
|
||||
import { type AnyNode, type AnyNodeId, type FenceNode, type MaterialSchema, useScene } from '@pascal-app/core'
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
type FenceNode,
|
||||
getClampedWallCurveOffset,
|
||||
getMaxWallCurveOffset,
|
||||
getWallCurveLength,
|
||||
type MaterialSchema,
|
||||
normalizeWallCurveOffset,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { Move, Spline } from 'lucide-react'
|
||||
import { useCallback } from 'react'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
import { ActionButton, ActionGroup } from '../controls/action-button'
|
||||
import { MaterialPicker } from '../controls/material-picker'
|
||||
import { PanelSection } from '../controls/panel-section'
|
||||
import { SegmentedControl } from '../controls/segmented-control'
|
||||
@@ -28,6 +42,8 @@ export function FencePanel() {
|
||||
const selectedCount = useViewer((s) => s.selection.selectedIds.length)
|
||||
const setSelection = useViewer((s) => s.setSelection)
|
||||
const updateNode = useScene((s) => s.updateNode)
|
||||
const setMovingNode = useEditor((s) => s.setMovingNode)
|
||||
const setCurvingFence = useEditor((s) => s.setCurvingFence)
|
||||
|
||||
const node = useScene((s) =>
|
||||
selectedId ? (s.nodes[selectedId as AnyNode['id']] as FenceNode | undefined) : undefined,
|
||||
@@ -67,6 +83,20 @@ export function FencePanel() {
|
||||
setSelection({ selectedIds: [] })
|
||||
}, [setSelection])
|
||||
|
||||
const handleMove = useCallback(() => {
|
||||
if (!node) return
|
||||
sfxEmitter.emit('sfx:item-pick')
|
||||
setMovingNode(node)
|
||||
setSelection({ selectedIds: [] })
|
||||
}, [node, setMovingNode, setSelection])
|
||||
|
||||
const handleCurve = useCallback(() => {
|
||||
if (!node) return
|
||||
sfxEmitter.emit('sfx:item-pick')
|
||||
setCurvingFence(node)
|
||||
setSelection({ selectedIds: [] })
|
||||
}, [node, setCurvingFence, setSelection])
|
||||
|
||||
const handleMaterialPresetChange = useCallback(
|
||||
(materialPreset: string) => {
|
||||
handleUpdate({ materialPreset, material: undefined })
|
||||
@@ -83,9 +113,9 @@ export function FencePanel() {
|
||||
|
||||
if (!(node && node.type === 'fence' && selectedId && selectedCount === 1)) return null
|
||||
|
||||
const dx = node.end[0] - node.start[0]
|
||||
const dz = node.end[1] - node.start[1]
|
||||
const length = Math.sqrt(dx * dx + dz * dz)
|
||||
const length = getWallCurveLength(node)
|
||||
const curveOffset = getClampedWallCurveOffset(node)
|
||||
const maxCurveOffset = getMaxWallCurveOffset(node)
|
||||
|
||||
return (
|
||||
<PanelWrapper
|
||||
@@ -119,6 +149,16 @@ export function FencePanel() {
|
||||
unit="m"
|
||||
value={length}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Curve"
|
||||
max={Math.max(0.01, maxCurveOffset)}
|
||||
min={-Math.max(0.01, maxCurveOffset)}
|
||||
onChange={(value) => handleUpdate({ curveOffset: normalizeWallCurveOffset(node, value) })}
|
||||
precision={2}
|
||||
step={0.1}
|
||||
unit="m"
|
||||
value={Math.round(curveOffset * 100) / 100}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Height"
|
||||
max={4}
|
||||
@@ -213,6 +253,17 @@ export function FencePanel() {
|
||||
value={node.material}
|
||||
/>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Actions">
|
||||
<ActionGroup>
|
||||
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
|
||||
<ActionButton
|
||||
icon={<Spline className="h-3.5 w-3.5" />}
|
||||
label="Curve"
|
||||
onClick={handleCurve}
|
||||
/>
|
||||
</ActionGroup>
|
||||
</PanelSection>
|
||||
</PanelWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -266,9 +266,11 @@ export function SlabPanel() {
|
||||
value={node.material}
|
||||
/>
|
||||
</PanelSection>
|
||||
<PanelSection title="Actions">
|
||||
<ActionGroup>
|
||||
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
|
||||
</ActionGroup>
|
||||
</PanelSection>
|
||||
</PanelWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type AnyNodeId, emitter, useScene } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect } from 'react'
|
||||
import { runRedo, runUndo } from '../lib/history'
|
||||
import { sfxEmitter } from '../lib/sfx-bus'
|
||||
import useEditor from '../store/use-editor'
|
||||
|
||||
@@ -91,11 +92,11 @@ export const useKeyboard = ({ isVersionPreviewMode = false } = {}) => {
|
||||
} else if (e.key === 'z' && (e.metaKey || e.ctrlKey)) {
|
||||
if (isVersionPreviewMode) return
|
||||
e.preventDefault()
|
||||
useScene.temporal.getState().undo()
|
||||
runUndo()
|
||||
} else if (e.key === 'Z' && e.shiftKey && (e.metaKey || e.ctrlKey)) {
|
||||
if (isVersionPreviewMode) return
|
||||
e.preventDefault()
|
||||
useScene.temporal.getState().redo()
|
||||
runRedo()
|
||||
} else if (e.key === 'ArrowUp' && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault()
|
||||
const { buildingId, levelId } = useViewer.getState().selection
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { useLiveTransforms, useScene } from '@pascal-app/core'
|
||||
|
||||
function refreshSceneAfterHistoryJump() {
|
||||
useLiveTransforms.getState().clearAll()
|
||||
|
||||
const state = useScene.getState()
|
||||
for (const node of Object.values(state.nodes)) {
|
||||
state.markDirty(node.id)
|
||||
}
|
||||
}
|
||||
|
||||
export function runUndo() {
|
||||
useScene.temporal.getState().undo()
|
||||
refreshSceneAfterHistoryJump()
|
||||
}
|
||||
|
||||
export function runRedo() {
|
||||
useScene.temporal.getState().redo()
|
||||
refreshSceneAfterHistoryJump()
|
||||
}
|
||||
@@ -84,6 +84,11 @@ export type MovingWallEndpoint = {
|
||||
endpoint: 'start' | 'end'
|
||||
}
|
||||
|
||||
export type MovingFenceEndpoint = {
|
||||
fence: FenceNode
|
||||
endpoint: 'start' | 'end'
|
||||
}
|
||||
|
||||
export type MaterialTargetRole = WallSurfaceSide | StairSurfaceMaterialRole | RoofSurfaceMaterialRole
|
||||
|
||||
export type SelectedMaterialTarget = {
|
||||
@@ -136,8 +141,12 @@ type EditorState = {
|
||||
) => void
|
||||
movingWallEndpoint: MovingWallEndpoint | null
|
||||
setMovingWallEndpoint: (value: MovingWallEndpoint | null) => void
|
||||
movingFenceEndpoint: MovingFenceEndpoint | null
|
||||
setMovingFenceEndpoint: (value: MovingFenceEndpoint | null) => void
|
||||
curvingWall: WallNode | null
|
||||
setCurvingWall: (wall: WallNode | null) => void
|
||||
curvingFence: FenceNode | null
|
||||
setCurvingFence: (fence: FenceNode | null) => void
|
||||
selectedMaterialTarget: SelectedMaterialTarget | null
|
||||
setSelectedMaterialTarget: (target: SelectedMaterialTarget | null) => void
|
||||
selectedReferenceId: string | null
|
||||
@@ -513,8 +522,12 @@ const useEditor = create<EditorState>()(
|
||||
setMovingNode: (node) => set({ movingNode: node }),
|
||||
movingWallEndpoint: null,
|
||||
setMovingWallEndpoint: (value) => set({ movingWallEndpoint: value }),
|
||||
movingFenceEndpoint: null,
|
||||
setMovingFenceEndpoint: (value) => set({ movingFenceEndpoint: value }),
|
||||
curvingWall: null,
|
||||
setCurvingWall: (wall) => set({ curvingWall: wall }),
|
||||
curvingFence: null,
|
||||
setCurvingFence: (fence) => set({ curvingFence: fence }),
|
||||
selectedMaterialTarget: null,
|
||||
setSelectedMaterialTarget: (target) => set({ selectedMaterialTarget: target }),
|
||||
selectedReferenceId: null,
|
||||
|
||||
Reference in New Issue
Block a user