feat: improve collaboration feedback and previews
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
emitter,
|
||||
type GridEvent,
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
useScene,
|
||||
type WallEvent,
|
||||
type WallNode,
|
||||
WallNode as WallNodeSchema,
|
||||
WindowNode,
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
@@ -24,6 +26,7 @@ import {
|
||||
useAlignmentGuides,
|
||||
useEditor,
|
||||
useFacingPose,
|
||||
usePlacementPreview,
|
||||
} from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
@@ -119,6 +122,34 @@ const WindowTool: React.FC = () => {
|
||||
useEffect(() => {
|
||||
useScene.temporal.getState().pause()
|
||||
|
||||
const ownedPreviewIds = new Set<string>()
|
||||
const fallbackPreview = WindowNode.parse({
|
||||
position: [0, 0, 0],
|
||||
rotation: [0, 0, 0],
|
||||
side: 'front',
|
||||
})
|
||||
const fallbackWallId = WallNodeSchema.parse({
|
||||
end: [1, 0],
|
||||
start: [0, 0],
|
||||
thickness: 0.1,
|
||||
}).id
|
||||
const publishPlacementPreview = (node: AnyNode, parentNode: AnyNode | null) => {
|
||||
ownedPreviewIds.add(node.id)
|
||||
usePlacementPreview.getState().set(node, parentNode)
|
||||
}
|
||||
const clearPlacementPreview = () => {
|
||||
const current = usePlacementPreview.getState().node
|
||||
if (current && ownedPreviewIds.has(current.id)) usePlacementPreview.getState().clear()
|
||||
}
|
||||
const publishDraftPreview = (parentNode: AnyNode) => {
|
||||
const draft = draftRef.current
|
||||
if (!draft) return
|
||||
const live = useScene.getState().nodes[draft.id as AnyNodeId]
|
||||
if (live?.type !== 'window') return
|
||||
draftRef.current = live
|
||||
publishPlacementPreview(live, parentNode)
|
||||
}
|
||||
|
||||
let hostKind: HostKind = null
|
||||
// timeStamp of the most recent wall/roof mesh event. A wall/roof hover and
|
||||
// the grid raycast from the SAME pointermove share the source DOM event's
|
||||
@@ -148,10 +179,15 @@ const WindowTool: React.FC = () => {
|
||||
}
|
||||
|
||||
const destroyDraft = () => {
|
||||
if (!draftRef.current) return
|
||||
const wallId = draftRef.current.parentId
|
||||
useScene.getState().deleteNode(draftRef.current.id)
|
||||
const draft = draftRef.current
|
||||
if (!draft) {
|
||||
clearPlacementPreview()
|
||||
return
|
||||
}
|
||||
const wallId = draft.parentId
|
||||
useScene.getState().deleteNode(draft.id)
|
||||
draftRef.current = null
|
||||
clearPlacementPreview()
|
||||
// Rebuild wall so it removes the cutout from the deleted draft
|
||||
if (wallId) markHostDirty(wallId)
|
||||
}
|
||||
@@ -162,6 +198,7 @@ const WindowTool: React.FC = () => {
|
||||
clearOpeningGuides3D()
|
||||
setFallbackPose(null)
|
||||
useFacingPose.getState().clear()
|
||||
clearPlacementPreview()
|
||||
}
|
||||
|
||||
// Alignment candidates — anchors of every alignable object; refreshed
|
||||
@@ -207,6 +244,23 @@ const WindowTool: React.FC = () => {
|
||||
floorY,
|
||||
side: sideFlip ? 'back' : 'front',
|
||||
})
|
||||
const halfWidth = fallbackPreview.width / 2 + 0.5
|
||||
const wall = WallNodeSchema.parse({
|
||||
end: [position[0] + halfWidth, position[2]],
|
||||
id: fallbackWallId,
|
||||
start: [position[0] - halfWidth, position[2]],
|
||||
thickness: 0.1,
|
||||
})
|
||||
const ghost = WindowNode.parse({
|
||||
...fallbackPreview,
|
||||
metadata: { isTransient: true },
|
||||
parentId: wall.id,
|
||||
position: [halfWidth, FALLBACK_SILL_LIFT + fallbackPreview.height / 2, 0],
|
||||
rotation: [0, sideFlip ? Math.PI : 0, 0],
|
||||
side: sideFlip ? 'back' : 'front',
|
||||
wallId: wall.id,
|
||||
})
|
||||
publishPlacementPreview(ghost, wall)
|
||||
useAlignmentGuides.getState().clear()
|
||||
clearOpeningGuides3D()
|
||||
// Off-host (invalid) floating ghost — no direction triangle.
|
||||
@@ -349,6 +403,7 @@ const WindowTool: React.FC = () => {
|
||||
roofFace: undefined,
|
||||
})
|
||||
}
|
||||
publishDraftPreview(wall)
|
||||
|
||||
updateCursor(
|
||||
wallLocalToWorld(
|
||||
@@ -390,6 +445,7 @@ const WindowTool: React.FC = () => {
|
||||
) => {
|
||||
const draft = draftRef.current
|
||||
if (!draft) return
|
||||
clearPlacementPreview()
|
||||
draftRef.current = null
|
||||
hostKind = null
|
||||
|
||||
@@ -592,6 +648,7 @@ const WindowTool: React.FC = () => {
|
||||
useScene.getState().createNode(node, segment.id as AnyNodeId)
|
||||
draftRef.current = node
|
||||
}
|
||||
publishDraftPreview(segment)
|
||||
// Opening guides are wall-specific; clear them while over a roof face.
|
||||
clearOpeningGuides3D()
|
||||
updateRoofCursor(target, event.node as RoofNode)
|
||||
@@ -607,6 +664,7 @@ const WindowTool: React.FC = () => {
|
||||
const { segment, face, position } = target
|
||||
|
||||
const draft = draftRef.current
|
||||
clearPlacementPreview()
|
||||
draftRef.current = null
|
||||
hostKind = null
|
||||
|
||||
@@ -707,6 +765,7 @@ const WindowTool: React.FC = () => {
|
||||
return () => {
|
||||
destroyDraft()
|
||||
hideCursor()
|
||||
clearPlacementPreview()
|
||||
useAlignmentGuides.getState().clear()
|
||||
clearOpeningGuides3D()
|
||||
useScene.temporal.getState().resume()
|
||||
|
||||
Reference in New Issue
Block a user