feat: improve collaboration feedback and previews
This commit is contained in:
@@ -399,6 +399,7 @@ export const chimneyDefinition: NodeDefinition<typeof ChimneyNode> = {
|
||||
kind: 'parametric',
|
||||
module: () => import('./renderer'),
|
||||
},
|
||||
preview: () => import('./preview'),
|
||||
|
||||
tool: () => import('./tool'),
|
||||
toolHints: [
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
sceneRegistry,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { triggerSFX } from '@pascal-app/editor'
|
||||
import { triggerSFX, usePlacementPreview } from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import * as THREE from 'three'
|
||||
@@ -107,6 +107,15 @@ const ChimneyTool = () => {
|
||||
setSegmentXform(xform)
|
||||
setHitLocal([hit.localX, hit.localY, hit.localZ])
|
||||
setPreviewSegment(hit.segment)
|
||||
usePlacementPreview.getState().set(
|
||||
ChimneyNode.parse({
|
||||
...previewNode,
|
||||
parentId: hit.segment.id,
|
||||
position: [hit.localX, hit.localY, hit.localZ],
|
||||
roofSegmentId: hit.segment.id,
|
||||
}),
|
||||
hit.segment,
|
||||
)
|
||||
publishRoofSurfacePlacementGuides({
|
||||
roof: event.node as RoofNode,
|
||||
segment: hit.segment,
|
||||
@@ -137,6 +146,8 @@ const ChimneyTool = () => {
|
||||
state.dirtyNodes.add(hit.segment.id as AnyNodeId)
|
||||
setSelection({ selectedIds: [chimney.id] })
|
||||
triggerSFX('sfx:item-place')
|
||||
const placementPreview = usePlacementPreview.getState()
|
||||
if (placementPreview.node?.id === previewNode.id) placementPreview.clear()
|
||||
clearRoofSurfacePlacementGuides()
|
||||
event.stopPropagation()
|
||||
}
|
||||
@@ -149,6 +160,8 @@ const ChimneyTool = () => {
|
||||
emitter.off('roof:move', updatePreview)
|
||||
emitter.off('roof:enter', updatePreview)
|
||||
emitter.off('roof:click', onClick)
|
||||
const placementPreview = usePlacementPreview.getState()
|
||||
if (placementPreview.node?.id === previewNode.id) placementPreview.clear()
|
||||
clearRoofSurfacePlacementGuides()
|
||||
}
|
||||
}, [activeBuildingId, setSelection, previewNode])
|
||||
@@ -162,6 +175,8 @@ const ChimneyTool = () => {
|
||||
setSegmentXform(null)
|
||||
setHitLocal(null)
|
||||
setPreviewSegment(null)
|
||||
const placementPreview = usePlacementPreview.getState()
|
||||
if (placementPreview.node?.id === previewNode.id) placementPreview.clear()
|
||||
clearRoofSurfacePlacementGuides()
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -217,6 +217,7 @@ export const doorDefinition: NodeDefinition<typeof DoorNode> = {
|
||||
kind: 'parametric',
|
||||
module: () => import('./renderer'),
|
||||
},
|
||||
preview: () => import('./preview'),
|
||||
system: {
|
||||
module: () => import('./system'),
|
||||
// Priority 3 mirrors the legacy DoorSystem (after animation at 2,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
DoorNode,
|
||||
emitter,
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
useScene,
|
||||
type WallEvent,
|
||||
type WallNode,
|
||||
WallNode as WallNodeSchema,
|
||||
} from '@pascal-app/core'
|
||||
import {
|
||||
calculateCursorRotation,
|
||||
@@ -23,6 +25,7 @@ import {
|
||||
useAlignmentGuides,
|
||||
useEditor,
|
||||
useFacingPose,
|
||||
usePlacementPreview,
|
||||
} from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
@@ -106,6 +109,34 @@ const DoorTool: React.FC = () => {
|
||||
useEffect(() => {
|
||||
useScene.temporal.getState().pause()
|
||||
|
||||
const ownedPreviewIds = new Set<string>()
|
||||
const fallbackPreview = DoorNode.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 !== 'door') 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
|
||||
@@ -136,10 +167,15 @@ const DoorTool: 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()
|
||||
if (wallId) markHostDirty(wallId)
|
||||
}
|
||||
|
||||
@@ -149,6 +185,7 @@ const DoorTool: React.FC = () => {
|
||||
clearOpeningGuides3D()
|
||||
setFallbackPose(null)
|
||||
useFacingPose.getState().clear()
|
||||
clearPlacementPreview()
|
||||
}
|
||||
|
||||
// Alignment candidates — anchors of every alignable object; refreshed
|
||||
@@ -192,6 +229,23 @@ const DoorTool: React.FC = () => {
|
||||
rotationY: sideFlip ? Math.PI : 0,
|
||||
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 = DoorNode.parse({
|
||||
...fallbackPreview,
|
||||
metadata: { isTransient: true },
|
||||
parentId: wall.id,
|
||||
position: [halfWidth, 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.
|
||||
@@ -290,6 +344,7 @@ const DoorTool: React.FC = () => {
|
||||
roofFace: undefined,
|
||||
})
|
||||
}
|
||||
publishDraftPreview(wall)
|
||||
|
||||
updateCursor(
|
||||
wallLocalToWorld(
|
||||
@@ -331,6 +386,7 @@ const DoorTool: React.FC = () => {
|
||||
) => {
|
||||
const draft = draftRef.current
|
||||
if (!draft) return
|
||||
clearPlacementPreview()
|
||||
draftRef.current = null
|
||||
hostKind = null
|
||||
|
||||
@@ -530,6 +586,7 @@ const DoorTool: 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)
|
||||
@@ -545,6 +602,7 @@ const DoorTool: React.FC = () => {
|
||||
const { segment, face, position } = target
|
||||
|
||||
const draft = draftRef.current
|
||||
clearPlacementPreview()
|
||||
draftRef.current = null
|
||||
hostKind = null
|
||||
|
||||
@@ -654,6 +712,7 @@ const DoorTool: React.FC = () => {
|
||||
return () => {
|
||||
destroyDraft()
|
||||
hideCursor()
|
||||
clearPlacementPreview()
|
||||
useAlignmentGuides.getState().clear()
|
||||
clearOpeningGuides3D()
|
||||
useScene.temporal.getState().resume()
|
||||
|
||||
@@ -498,6 +498,7 @@ export const dormerDefinition: NodeDefinition<typeof DormerNode> = {
|
||||
kind: 'parametric',
|
||||
module: () => import('./renderer'),
|
||||
},
|
||||
preview: () => import('./preview'),
|
||||
|
||||
tool: () => import('./tool'),
|
||||
toolHints: [
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
'use client'
|
||||
|
||||
import { type AnyNodeId, DormerNode, useScene } from '@pascal-app/core'
|
||||
import { usePlacementPreview } from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useMemo } from 'react'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { RoofAttachmentFallbackPreview } from '../shared/roof-attachment-fallback-preview'
|
||||
import { dormerDefinition } from './definition'
|
||||
import { DormerPlacementGuides } from './placement-guides'
|
||||
@@ -67,9 +68,36 @@ const DormerTool = () => {
|
||||
state.createNode(dormer, hit.segment.id as AnyNodeId)
|
||||
state.dirtyNodes.add(hit.segment.id as AnyNodeId)
|
||||
setSelection({ selectedIds: [dormer.id] })
|
||||
usePlacementPreview.getState().clear()
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const placementPreview = usePlacementPreview.getState()
|
||||
if (!(hitSegment && hitLocal)) {
|
||||
if (placementPreview.node?.id === previewNode.id) placementPreview.clear()
|
||||
return
|
||||
}
|
||||
placementPreview.set(
|
||||
DormerNode.parse({
|
||||
...previewNode,
|
||||
parentId: hitSegment.id,
|
||||
position: hitLocal,
|
||||
roofSegmentId: hitSegment.id,
|
||||
rotation: ghostRotation,
|
||||
}),
|
||||
hitSegment,
|
||||
)
|
||||
}, [ghostRotation, hitLocal, hitSegment, previewNode])
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
const placementPreview = usePlacementPreview.getState()
|
||||
if (placementPreview.node?.id === previewNode.id) placementPreview.clear()
|
||||
},
|
||||
[previewNode.id],
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<RoofAttachmentFallbackPreview
|
||||
|
||||
@@ -283,6 +283,7 @@ export const itemDefinition: NodeDefinition<typeof ItemNode> = {
|
||||
kind: 'parametric',
|
||||
module: () => import('./renderer'),
|
||||
},
|
||||
preview: () => import('./renderer').then(({ ItemPreview }) => ({ default: ItemPreview })),
|
||||
system: {
|
||||
module: () => import('./system'),
|
||||
// Same priority as the legacy ItemSystem.
|
||||
|
||||
@@ -429,6 +429,34 @@ const PreviewModel = ({ node }: { node: ItemNode }) => {
|
||||
)
|
||||
}
|
||||
|
||||
const LoadedItemPreview = ({ node }: { node: ItemNode }) => {
|
||||
const gltf = useItemGltf(resolveCdnUrl(node.asset.src) || '')
|
||||
if (getUnavailableItemAsset(gltf)) return <PreviewModel node={node} />
|
||||
return (
|
||||
<group rotation={node.rotation} scale={node.scale}>
|
||||
<Clone
|
||||
dispose={null}
|
||||
object={gltf.scene}
|
||||
position={node.asset.offset}
|
||||
rotation={node.asset.rotation}
|
||||
scale={node.asset.scale || [1, 1, 1]}
|
||||
/>
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
export const ItemPreview = ({ node }: { node: ItemNode }) => {
|
||||
const url = resolveCdnUrl(node.asset.src) || ''
|
||||
if (!url) return <PreviewModel node={node} />
|
||||
return (
|
||||
<Suspense fallback={<PreviewModel node={node} />}>
|
||||
<ErrorBoundary fallback={<PreviewModel node={node} />} scope="item-preview-model">
|
||||
<LoadedItemPreview node={node} />
|
||||
</ErrorBoundary>
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
const ClearPreviewModel = ({ node }: { node: ItemNode }) => {
|
||||
const shading = useViewer((s) => s.shading)
|
||||
const [w, h, d] = getScaledDimensions(node)
|
||||
|
||||
@@ -254,6 +254,7 @@ export const skylightDefinition: NodeDefinition<typeof SkylightNode> = {
|
||||
kind: 'parametric',
|
||||
module: () => import('./renderer'),
|
||||
},
|
||||
preview: () => import('./preview'),
|
||||
system: {
|
||||
module: () => import('./system'),
|
||||
priority: 3,
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
sceneRegistry,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { triggerSFX } from '@pascal-app/editor'
|
||||
import { triggerSFX, usePlacementPreview } from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import * as THREE from 'three'
|
||||
@@ -77,6 +77,15 @@ const SkylightTool = () => {
|
||||
setPreviewSurfaceQuat(surfaceQuatFromNormal(normal, new THREE.Quaternion()))
|
||||
setPreviewYaw((event.node.rotation ?? 0) + (hit.segment.rotation ?? 0))
|
||||
setPreviewPos(worldToBuildingLocal(wx, wy, wz))
|
||||
usePlacementPreview.getState().set(
|
||||
SkylightNode.parse({
|
||||
...previewNode,
|
||||
parentId: hit.segment.id,
|
||||
position: [hit.localX, hit.localY, hit.localZ],
|
||||
roofSegmentId: hit.segment.id,
|
||||
}),
|
||||
hit.segment,
|
||||
)
|
||||
publishRoofSurfacePlacementGuides({
|
||||
roof: event.node as RoofNode,
|
||||
segment: hit.segment,
|
||||
@@ -107,6 +116,8 @@ const SkylightTool = () => {
|
||||
state.dirtyNodes.add(hit.segment.id as AnyNodeId)
|
||||
setSelection({ selectedIds: [skylight.id] })
|
||||
triggerSFX('sfx:item-place')
|
||||
const placementPreview = usePlacementPreview.getState()
|
||||
if (placementPreview.node?.id === previewNode.id) placementPreview.clear()
|
||||
clearRoofSurfacePlacementGuides()
|
||||
event.stopPropagation()
|
||||
}
|
||||
@@ -119,6 +130,8 @@ const SkylightTool = () => {
|
||||
emitter.off('roof:move', updatePreview)
|
||||
emitter.off('roof:enter', updatePreview)
|
||||
emitter.off('roof:click', onClick)
|
||||
const placementPreview = usePlacementPreview.getState()
|
||||
if (placementPreview.node?.id === previewNode.id) placementPreview.clear()
|
||||
clearRoofSurfacePlacementGuides()
|
||||
}
|
||||
}, [activeBuildingId, setSelection, previewNode])
|
||||
@@ -131,6 +144,8 @@ const SkylightTool = () => {
|
||||
onInvalidTarget={() => {
|
||||
setPreviewPos(null)
|
||||
setPreviewSurfaceQuat(null)
|
||||
const placementPreview = usePlacementPreview.getState()
|
||||
if (placementPreview.node?.id === previewNode.id) placementPreview.clear()
|
||||
clearRoofSurfacePlacementGuides()
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -259,6 +259,7 @@ export const solarPanelDefinition: NodeDefinition<typeof SolarPanelNode> = {
|
||||
kind: 'parametric',
|
||||
module: () => import('./renderer'),
|
||||
},
|
||||
preview: () => import('./preview'),
|
||||
|
||||
tool: () => import('./tool'),
|
||||
affordanceTools: {
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
sceneRegistry,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { triggerSFX } from '@pascal-app/editor'
|
||||
import { triggerSFX, usePlacementPreview } from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import * as THREE from 'three'
|
||||
@@ -90,6 +90,16 @@ const SolarPanelTool = () => {
|
||||
setPreviewSurfaceQuat(surfaceQuatFromNormal(normal, new THREE.Quaternion()))
|
||||
setPreviewYaw((event.node.rotation ?? 0) + (hit.segment.rotation ?? 0))
|
||||
setPreviewPos(worldToBuildingLocal(wx, wy, wz))
|
||||
usePlacementPreview.getState().set(
|
||||
SolarPanelNode.parse({
|
||||
...previewNode,
|
||||
parentId: hit.segment.id,
|
||||
position: [hit.localX, hit.localY, hit.localZ],
|
||||
roofSegmentId: hit.segment.id,
|
||||
surfaceNormal: [normal.x, normal.y, normal.z],
|
||||
}),
|
||||
hit.segment,
|
||||
)
|
||||
publishRoofSurfacePlacementGuides({
|
||||
roof: event.node as RoofNode,
|
||||
segment: hit.segment,
|
||||
@@ -128,6 +138,8 @@ const SolarPanelTool = () => {
|
||||
state.dirtyNodes.add(hit.segment.id as AnyNodeId)
|
||||
setSelection({ selectedIds: [panel.id] })
|
||||
triggerSFX('sfx:item-place')
|
||||
const placementPreview = usePlacementPreview.getState()
|
||||
if (placementPreview.node?.id === previewNode.id) placementPreview.clear()
|
||||
clearRoofSurfacePlacementGuides()
|
||||
event.stopPropagation()
|
||||
}
|
||||
@@ -140,6 +152,8 @@ const SolarPanelTool = () => {
|
||||
emitter.off('roof:move', updatePreview)
|
||||
emitter.off('roof:enter', updatePreview)
|
||||
emitter.off('roof:click', onClick)
|
||||
const placementPreview = usePlacementPreview.getState()
|
||||
if (placementPreview.node?.id === previewNode.id) placementPreview.clear()
|
||||
clearRoofSurfacePlacementGuides()
|
||||
}
|
||||
}, [activeBuildingId, setSelection, previewNode])
|
||||
@@ -152,6 +166,8 @@ const SolarPanelTool = () => {
|
||||
onInvalidTarget={() => {
|
||||
setPreviewPos(null)
|
||||
setPreviewSurfaceQuat(null)
|
||||
const placementPreview = usePlacementPreview.getState()
|
||||
if (placementPreview.node?.id === previewNode.id) placementPreview.clear()
|
||||
clearRoofSurfacePlacementGuides()
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -91,6 +91,7 @@ export const wallDefinition: NodeDefinition<typeof WallNode> = {
|
||||
// auto-slab live preview, history dances). Placement is wired via
|
||||
// `def.tool`.
|
||||
tool: () => import('./tool'),
|
||||
preview: () => import('./preview'),
|
||||
affordanceTools: {
|
||||
curve: () => import('./curve-tool'),
|
||||
'move-endpoint': () => import('./move-endpoint-tool'),
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
// @ts-expect-error — bun:test is provided by the Bun runtime; nodes does not
|
||||
// include Bun ambient types in its production declaration build.
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { buildWallPreviewGeometry } from './preview'
|
||||
|
||||
describe('wall placement preview', () => {
|
||||
test('uses the wall segment footprint instead of a generic box', () => {
|
||||
const geometry = buildWallPreviewGeometry({
|
||||
start: [1, 2],
|
||||
end: [5, 2],
|
||||
height: 3,
|
||||
thickness: 0.2,
|
||||
})
|
||||
const bounds = geometry.boundingBox!
|
||||
|
||||
expect(bounds.max.x - bounds.min.x).toBeCloseTo(4)
|
||||
expect(bounds.max.y - bounds.min.y).toBeCloseTo(3)
|
||||
expect(bounds.max.z - bounds.min.z).toBeCloseTo(0.2)
|
||||
|
||||
geometry.dispose()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,58 @@
|
||||
'use client'
|
||||
|
||||
import { getWallSurfacePolygon, type WallNode } from '@pascal-app/core'
|
||||
import { EDITOR_LAYER } from '@pascal-app/editor'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { ExtrudeGeometry, Shape } from 'three'
|
||||
|
||||
const WALL_PREVIEW_HEIGHT = 2.5
|
||||
const WALL_PREVIEW_THICKNESS = 0.1
|
||||
|
||||
export function buildWallPreviewGeometry(
|
||||
node: Pick<WallNode, 'start' | 'end' | 'curveOffset' | 'height' | 'thickness'>,
|
||||
) {
|
||||
const polygon = getWallSurfacePolygon({
|
||||
start: node.start,
|
||||
end: node.end,
|
||||
curveOffset: node.curveOffset,
|
||||
thickness: node.thickness ?? WALL_PREVIEW_THICKNESS,
|
||||
})
|
||||
const shape = new Shape()
|
||||
polygon.forEach((point, index) => {
|
||||
if (index === 0) shape.moveTo(point.x, -point.y)
|
||||
else shape.lineTo(point.x, -point.y)
|
||||
})
|
||||
shape.closePath()
|
||||
const geometry = new ExtrudeGeometry(shape, {
|
||||
bevelEnabled: false,
|
||||
depth: node.height ?? WALL_PREVIEW_HEIGHT,
|
||||
steps: 1,
|
||||
})
|
||||
geometry.rotateX(-Math.PI / 2)
|
||||
geometry.computeBoundingBox()
|
||||
return geometry
|
||||
}
|
||||
|
||||
const WallPreview = ({ node }: { node: WallNode }) => {
|
||||
const { curveOffset, end, height, start, thickness } = node
|
||||
const geometry = useMemo(
|
||||
() => buildWallPreviewGeometry({ curveOffset, end, height, start, thickness }),
|
||||
[curveOffset, end, height, start, thickness],
|
||||
)
|
||||
|
||||
useEffect(() => () => geometry.dispose(), [geometry])
|
||||
|
||||
return (
|
||||
<mesh geometry={geometry} layers={EDITOR_LAYER} raycast={() => undefined} renderOrder={1}>
|
||||
<meshBasicMaterial
|
||||
color="#818cf8"
|
||||
depthTest={false}
|
||||
depthWrite={false}
|
||||
opacity={0.5}
|
||||
transparent
|
||||
/>
|
||||
</mesh>
|
||||
)
|
||||
}
|
||||
|
||||
export default WallPreview
|
||||
@@ -208,6 +208,7 @@ export const windowDefinition: NodeDefinition<typeof WindowNode> = {
|
||||
kind: 'parametric',
|
||||
module: () => import('./renderer'),
|
||||
},
|
||||
preview: () => import('./preview'),
|
||||
system: {
|
||||
module: () => import('./system'),
|
||||
priority: 3,
|
||||
|
||||
@@ -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