Fix item surface placement and remove item height annotations
This commit is contained in:
@@ -4,7 +4,6 @@ import {
|
||||
type AnyNodeId,
|
||||
calculateLevelMiters,
|
||||
DEFAULT_WALL_HEIGHT,
|
||||
getScaledDimensions,
|
||||
getWallCurveLength,
|
||||
getWallMiterBoundaryPoints,
|
||||
getWallPlanFootprint,
|
||||
@@ -428,30 +427,6 @@ function buildMeasurementGuide(
|
||||
}
|
||||
}
|
||||
|
||||
type HeightGuide = {
|
||||
start: Vec3
|
||||
end: Vec3
|
||||
labelPosition: Vec3
|
||||
}
|
||||
|
||||
function buildItemHeightGuide(item: ItemNode): { guide: HeightGuide; height: number } | null {
|
||||
const [width, height, depth] = getScaledDimensions(item)
|
||||
|
||||
if (!Number.isFinite(height) || height < 0.01) return null
|
||||
|
||||
const x = Number.isFinite(width) ? width / 2 + 0.18 : 0.18
|
||||
const z = Number.isFinite(depth) ? depth / 2 + 0.18 : 0.18
|
||||
|
||||
return {
|
||||
height,
|
||||
guide: {
|
||||
start: [x, 0, z],
|
||||
end: [x, height, z],
|
||||
labelPosition: [x, height / 2, z],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function MeasurementBar({ start, end, color }: { start: Vec3; end: Vec3; color: string }) {
|
||||
const segment = useMemo(() => {
|
||||
const startVector = new THREE.Vector3(...start)
|
||||
@@ -535,7 +510,7 @@ function SelectedMeasurementAnnotation({ node }: { node: WallNode | ItemNode })
|
||||
return <WallMeasurementAnnotation wall={node} />
|
||||
}
|
||||
|
||||
return <ItemHeightMeasurementAnnotation item={node} />
|
||||
return null
|
||||
}
|
||||
|
||||
function WallMeasurementAnnotation({ wall }: { wall: WallNode }) {
|
||||
@@ -600,27 +575,3 @@ function WallMeasurementAnnotation({ wall }: { wall: WallNode }) {
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
function ItemHeightMeasurementAnnotation({ item }: { item: ItemNode }) {
|
||||
const theme = useViewer((state) => state.theme)
|
||||
const unit = useViewer((state) => state.unit)
|
||||
const isNight = theme === 'dark'
|
||||
const color = isNight ? '#ffffff' : '#111111'
|
||||
const shadowColor = isNight ? '#111111' : '#ffffff'
|
||||
|
||||
const measurement = useMemo(() => buildItemHeightGuide(item), [item])
|
||||
|
||||
if (!measurement) return null
|
||||
|
||||
return (
|
||||
<group>
|
||||
<MeasurementBar color={color} end={measurement.guide.end} start={measurement.guide.start} />
|
||||
<MeasurementLabel
|
||||
color={color}
|
||||
label={`H ${formatMeasurement(measurement.height, unit)}`}
|
||||
position={measurement.guide.labelPosition}
|
||||
shadowColor={shadowColor}
|
||||
/>
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import type {
|
||||
WallNode,
|
||||
} from '@pascal-app/core'
|
||||
import { getScaledDimensions, sceneRegistry, useScene } from '@pascal-app/core'
|
||||
import { Euler, Quaternion, Vector3 } from 'three'
|
||||
import { Euler, Matrix3, Quaternion, Vector3 } from 'three'
|
||||
import {
|
||||
calculateCursorRotation,
|
||||
calculateItemRotation,
|
||||
@@ -31,6 +31,56 @@ import type {
|
||||
} from './placement-types'
|
||||
|
||||
const DEFAULT_DIMENSIONS: [number, number, number] = [1, 1, 1]
|
||||
const LOW_PROFILE_ITEM_SURFACE_MAX_HEIGHT = 0.1
|
||||
const UPWARD_SURFACE_NORMAL_MIN_Y = 0.75
|
||||
const AUTO_SURFACE_MIN_LOCAL_Y = 0.1
|
||||
|
||||
function isLowProfileItemSurface(item: ItemNode): boolean {
|
||||
if (item.asset.attachTo) return false
|
||||
const surfaceHeight = item.asset.surface
|
||||
? item.asset.surface.height * item.scale[1]
|
||||
: getScaledDimensions(item)[1]
|
||||
return surfaceHeight <= LOW_PROFILE_ITEM_SURFACE_MAX_HEIGHT
|
||||
}
|
||||
|
||||
function getWorldNormalY(event: ItemEvent): number | null {
|
||||
if (!event.normal) return null
|
||||
|
||||
const normal = new Vector3(event.normal[0], event.normal[1], event.normal[2])
|
||||
normal.applyNormalMatrix(new Matrix3().getNormalMatrix(event.object.matrixWorld)).normalize()
|
||||
return normal.y
|
||||
}
|
||||
|
||||
function isUpwardItemSurfaceHit(event: ItemEvent): boolean {
|
||||
const normalY = getWorldNormalY(event)
|
||||
return normalY !== null && normalY >= UPWARD_SURFACE_NORMAL_MIN_Y
|
||||
}
|
||||
|
||||
function getSurfacePlacementHeight(surfaceItem: ItemNode, event: ItemEvent, localPos: Vector3) {
|
||||
if (isLowProfileItemSurface(surfaceItem)) return null
|
||||
if (!isUpwardItemSurfaceHit(event)) return null
|
||||
|
||||
if (surfaceItem.asset.surface) {
|
||||
return surfaceItem.asset.surface.height * surfaceItem.scale[1]
|
||||
}
|
||||
|
||||
if (localPos.y < AUTO_SURFACE_MIN_LOCAL_Y) return null
|
||||
return localPos.y
|
||||
}
|
||||
|
||||
function isDescendantOfItem(
|
||||
candidate: ItemNode,
|
||||
ancestor: ItemNode,
|
||||
nodes: Record<string, AnyNode>,
|
||||
): boolean {
|
||||
let parentId = candidate.parentId
|
||||
while (parentId) {
|
||||
if (parentId === ancestor.id) return true
|
||||
const parent = nodes[parentId as AnyNodeId]
|
||||
parentId = parent?.parentId ?? null
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// FLOOR STRATEGY
|
||||
@@ -434,8 +484,11 @@ export const itemSurfaceStrategy = {
|
||||
const surfaceItem = event.node as ItemNode
|
||||
// Don't surface-place on the draft itself
|
||||
if (surfaceItem.id === ctx.draftItem?.id) return null
|
||||
// Surface item must declare a surface
|
||||
if (!surfaceItem.asset.surface) return null
|
||||
if (ctx.state.surface === 'item-surface' && ctx.state.surfaceItemId === surfaceItem.id) {
|
||||
return null
|
||||
}
|
||||
const nodes = useScene.getState().nodes
|
||||
if (ctx.draftItem && isDescendantOfItem(surfaceItem, ctx.draftItem, nodes)) return null
|
||||
|
||||
// Size check: our footprint must fit on surface item's footprint
|
||||
const ourDims = ctx.draftItem
|
||||
@@ -449,10 +502,12 @@ export const itemSurfaceStrategy = {
|
||||
|
||||
const worldPos = new Vector3(event.position[0], event.position[1], event.position[2])
|
||||
const localPos = surfaceMesh.worldToLocal(worldPos)
|
||||
const surfaceHeight = getSurfacePlacementHeight(surfaceItem, event, localPos)
|
||||
if (surfaceHeight === null) return null
|
||||
|
||||
const x = snapToGrid(localPos.x, ourDims[0])
|
||||
const z = snapToGrid(localPos.z, ourDims[2])
|
||||
const y = surfaceItem.asset.surface.height * surfaceItem.scale[1]
|
||||
const y = surfaceHeight
|
||||
|
||||
const worldSnapped = surfaceMesh.localToWorld(new Vector3(x, y, z))
|
||||
|
||||
@@ -485,10 +540,11 @@ export const itemSurfaceStrategy = {
|
||||
move(ctx: PlacementContext, event: ItemEvent): PlacementResult | null {
|
||||
if (ctx.state.surface !== 'item-surface') return null
|
||||
if (!(ctx.state.surfaceItemId && ctx.draftItem)) return null
|
||||
if (event.node.id !== ctx.state.surfaceItemId) return null
|
||||
|
||||
const nodes = useScene.getState().nodes
|
||||
const surfaceItem = nodes[ctx.state.surfaceItemId as AnyNodeId] as ItemNode | undefined
|
||||
if (!surfaceItem?.asset.surface) return null
|
||||
if (!surfaceItem) return null
|
||||
|
||||
const surfaceMesh = sceneRegistry.nodes.get(ctx.state.surfaceItemId)
|
||||
if (!surfaceMesh) return null
|
||||
@@ -496,10 +552,12 @@ export const itemSurfaceStrategy = {
|
||||
const ourDims = getScaledDimensions(ctx.draftItem)
|
||||
const worldPos = new Vector3(event.position[0], event.position[1], event.position[2])
|
||||
const localPos = surfaceMesh.worldToLocal(worldPos)
|
||||
const surfaceHeight = getSurfacePlacementHeight(surfaceItem, event, localPos)
|
||||
if (surfaceHeight === null) return null
|
||||
|
||||
const x = snapToGrid(localPos.x, ourDims[0])
|
||||
const z = snapToGrid(localPos.z, ourDims[2])
|
||||
const y = surfaceItem.asset.surface.height * surfaceItem.scale[1]
|
||||
const y = surfaceHeight
|
||||
|
||||
const worldSnapped = surfaceMesh.localToWorld(new Vector3(x, y, z))
|
||||
|
||||
@@ -519,6 +577,7 @@ export const itemSurfaceStrategy = {
|
||||
click(ctx: PlacementContext, _event: ItemEvent): CommitResult | null {
|
||||
if (ctx.state.surface !== 'item-surface') return null
|
||||
if (!(ctx.draftItem && ctx.state.surfaceItemId)) return null
|
||||
if (_event.node.id !== ctx.state.surfaceItemId) return null
|
||||
|
||||
return {
|
||||
nodeUpdate: {
|
||||
|
||||
@@ -17,13 +17,11 @@ import {
|
||||
} from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { Html } from '@react-three/drei'
|
||||
import { createPortal, useFrame } from '@react-three/fiber'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useFrame } from '@react-three/fiber'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
BoxGeometry,
|
||||
Box3,
|
||||
BufferGeometry,
|
||||
EdgesGeometry,
|
||||
Euler,
|
||||
Float32BufferAttribute,
|
||||
type Group,
|
||||
@@ -199,21 +197,121 @@ function getFallbackPreviewBounds(
|
||||
): PreviewBounds {
|
||||
const dims = item ? getScaledDimensions(item) : (asset.dimensions ?? DEFAULT_DIMENSIONS)
|
||||
return {
|
||||
min: [
|
||||
-dims[0] / 2,
|
||||
0,
|
||||
attachTo === 'wall-side' ? -dims[2] : -dims[2] / 2,
|
||||
],
|
||||
max: [
|
||||
dims[0] / 2,
|
||||
dims[1],
|
||||
attachTo === 'wall-side' ? 0 : dims[2] / 2,
|
||||
],
|
||||
min: [-dims[0] / 2, 0, attachTo === 'wall-side' ? -dims[2] : -dims[2] / 2],
|
||||
max: [dims[0] / 2, dims[1], attachTo === 'wall-side' ? 0 : dims[2] / 2],
|
||||
dimensions: dims,
|
||||
center: [0, dims[1] / 2, attachTo === 'wall-side' ? -dims[2] / 2 : 0],
|
||||
}
|
||||
}
|
||||
|
||||
function createLineGeometry(points: number[] = [0, 0, 0, 0, 0, 0]): BufferGeometry {
|
||||
const geometry = new BufferGeometry()
|
||||
geometry.setAttribute('position', new Float32BufferAttribute(points, 3))
|
||||
return geometry
|
||||
}
|
||||
|
||||
function getBoxEdgePoints(bounds: PreviewBounds): number[] {
|
||||
const [width, height, depth] = bounds.dimensions
|
||||
const [centerX, centerY, centerZ] = bounds.center
|
||||
const minX = centerX - width / 2
|
||||
const maxX = centerX + width / 2
|
||||
const minY = centerY - height / 2
|
||||
const maxY = centerY + height / 2
|
||||
const minZ = centerZ - depth / 2
|
||||
const maxZ = centerZ + depth / 2
|
||||
|
||||
return [
|
||||
minX,
|
||||
minY,
|
||||
minZ,
|
||||
maxX,
|
||||
minY,
|
||||
minZ,
|
||||
maxX,
|
||||
minY,
|
||||
minZ,
|
||||
maxX,
|
||||
minY,
|
||||
maxZ,
|
||||
maxX,
|
||||
minY,
|
||||
maxZ,
|
||||
minX,
|
||||
minY,
|
||||
maxZ,
|
||||
minX,
|
||||
minY,
|
||||
maxZ,
|
||||
minX,
|
||||
minY,
|
||||
minZ,
|
||||
|
||||
minX,
|
||||
maxY,
|
||||
minZ,
|
||||
maxX,
|
||||
maxY,
|
||||
minZ,
|
||||
maxX,
|
||||
maxY,
|
||||
minZ,
|
||||
maxX,
|
||||
maxY,
|
||||
maxZ,
|
||||
maxX,
|
||||
maxY,
|
||||
maxZ,
|
||||
minX,
|
||||
maxY,
|
||||
maxZ,
|
||||
minX,
|
||||
maxY,
|
||||
maxZ,
|
||||
minX,
|
||||
maxY,
|
||||
minZ,
|
||||
|
||||
minX,
|
||||
minY,
|
||||
minZ,
|
||||
minX,
|
||||
maxY,
|
||||
minZ,
|
||||
maxX,
|
||||
minY,
|
||||
minZ,
|
||||
maxX,
|
||||
maxY,
|
||||
minZ,
|
||||
maxX,
|
||||
minY,
|
||||
maxZ,
|
||||
maxX,
|
||||
maxY,
|
||||
maxZ,
|
||||
minX,
|
||||
minY,
|
||||
maxZ,
|
||||
minX,
|
||||
maxY,
|
||||
maxZ,
|
||||
]
|
||||
}
|
||||
|
||||
function updateLineGeometry(ref: React.RefObject<LineSegments>, points: number[]) {
|
||||
const geometry = ref.current?.geometry
|
||||
if (!geometry) return
|
||||
|
||||
const attribute = geometry.getAttribute('position') as Float32BufferAttribute | undefined
|
||||
if (!attribute || attribute.array.length !== points.length) {
|
||||
geometry.setAttribute('position', new Float32BufferAttribute(points, 3))
|
||||
} else {
|
||||
attribute.set(points)
|
||||
attribute.needsUpdate = true
|
||||
}
|
||||
geometry.computeBoundingSphere()
|
||||
}
|
||||
|
||||
// Shared materials for placement cursor - we just change colors, not swap materials
|
||||
// Note: EdgesGeometry doesn't work with dashed lines, so using solid lines
|
||||
const edgeMaterial = new LineBasicNodeMaterial({
|
||||
@@ -269,11 +367,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
const shiftFreeRef = useRef(false)
|
||||
const previewBoundsSignatureRef = useRef<string | null>(null)
|
||||
const meshPreviewAppliedRef = useRef(false)
|
||||
const dimensionBoundsRef = useRef<PreviewBounds | null>(null)
|
||||
const [measurementTargetState, setMeasurementTargetState] = useState<{
|
||||
id: string
|
||||
object: Object3D
|
||||
} | null>(null)
|
||||
const [dimensionBounds, setDimensionBounds] = useState<PreviewBounds | null>(null)
|
||||
|
||||
// Store config callbacks in refs to avoid re-running effect when they change
|
||||
const configRef = useRef(config)
|
||||
@@ -291,23 +385,33 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
if (previewBoundsSignatureRef.current === signature) return
|
||||
previewBoundsSignatureRef.current = signature
|
||||
|
||||
const nextBoxGeometry = new BoxGeometry(width, height, depth)
|
||||
nextBoxGeometry.translate(centerX, centerY, centerZ)
|
||||
const nextEdgesGeometry = new EdgesGeometry(nextBoxGeometry)
|
||||
|
||||
const nextBasePlaneGeometry = new PlaneGeometry(width, depth)
|
||||
nextBasePlaneGeometry.rotateX(-Math.PI / 2)
|
||||
nextBasePlaneGeometry.translate(centerX, 0.01, centerZ)
|
||||
|
||||
edgesRef.current.geometry.dispose()
|
||||
edgesRef.current.geometry = nextEdgesGeometry
|
||||
basePlaneRef.current.geometry.dispose()
|
||||
updateLineGeometry(edgesRef, getBoxEdgePoints(bounds))
|
||||
|
||||
const oldBasePlaneGeometry = basePlaneRef.current.geometry
|
||||
basePlaneRef.current.geometry = nextBasePlaneGeometry
|
||||
nextBoxGeometry.dispose()
|
||||
oldBasePlaneGeometry.dispose()
|
||||
}
|
||||
|
||||
const updateDimensionGuides = (bounds: PreviewBounds) => {
|
||||
dimensionBoundsRef.current = bounds
|
||||
setDimensionBounds((current) => {
|
||||
if (
|
||||
current &&
|
||||
current.dimensions[0] === bounds.dimensions[0] &&
|
||||
current.dimensions[1] === bounds.dimensions[1] &&
|
||||
current.dimensions[2] === bounds.dimensions[2] &&
|
||||
current.center[0] === bounds.center[0] &&
|
||||
current.center[1] === bounds.center[1] &&
|
||||
current.center[2] === bounds.center[2]
|
||||
) {
|
||||
return current
|
||||
}
|
||||
return bounds
|
||||
})
|
||||
|
||||
const [width, , depth] = bounds.dimensions
|
||||
const [centerX, , centerZ] = bounds.center
|
||||
const minX = centerX - width / 2
|
||||
@@ -388,10 +492,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
]
|
||||
|
||||
const applyPoints = (ref: React.RefObject<LineSegments>, points: number[]) => {
|
||||
const geometry = new BufferGeometry()
|
||||
geometry.setAttribute('position', new Float32BufferAttribute(points, 3))
|
||||
ref.current!.geometry.dispose()
|
||||
ref.current!.geometry = geometry
|
||||
updateLineGeometry(ref, points)
|
||||
}
|
||||
|
||||
applyPoints(measurementWidthRef, widthPoints)
|
||||
@@ -767,6 +868,32 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
|
||||
// ---- Item Surface Handlers ----
|
||||
|
||||
const detachItemSurfaceToFloor = (event: ItemEvent) => {
|
||||
const buildingLocalPoint = worldToBuildingLocal(
|
||||
event.position[0],
|
||||
event.position[1],
|
||||
event.position[2],
|
||||
)
|
||||
const wx = Math.round(buildingLocalPoint.x * 2) / 2
|
||||
const wz = Math.round(buildingLocalPoint.z * 2) / 2
|
||||
const floorPos: [number, number, number] = [wx, 0, wz]
|
||||
|
||||
Object.assign(placementState.current, { surface: 'floor', surfaceItemId: null })
|
||||
gridPosition.current.set(wx, 0, wz)
|
||||
cursorGroupRef.current.position.set(wx, 0, wz)
|
||||
|
||||
const draft = draftNode.current
|
||||
if (draft) {
|
||||
draft.position = floorPos
|
||||
useScene.getState().updateNode(draft.id, {
|
||||
parentId: useViewer.getState().selection.levelId as string,
|
||||
position: floorPos,
|
||||
})
|
||||
}
|
||||
|
||||
revalidate()
|
||||
}
|
||||
|
||||
const onItemEnter = (event: ItemEvent) => {
|
||||
if (event.node.id === draftNode.current?.id) return
|
||||
const result = itemSurfaceStrategy.enter(getContext(), event)
|
||||
@@ -800,6 +927,24 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
return
|
||||
}
|
||||
|
||||
if (ctx.state.surface === 'item-surface' && event.node.id !== ctx.state.surfaceItemId) {
|
||||
const enterResult = itemSurfaceStrategy.enter(
|
||||
{ ...ctx, state: { ...ctx.state, surface: 'floor', surfaceItemId: null } },
|
||||
event,
|
||||
)
|
||||
|
||||
event.stopPropagation()
|
||||
if (enterResult) {
|
||||
applyTransition(enterResult)
|
||||
if (draftNode.current && enterResult.nodeUpdate.parentId) {
|
||||
useScene.getState().updateNode(draftNode.current.id, enterResult.nodeUpdate)
|
||||
}
|
||||
} else {
|
||||
detachItemSurfaceToFloor(event)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (!draftNode.current) {
|
||||
const enterResult = itemSurfaceStrategy.enter(getContext(), event)
|
||||
if (!enterResult) return
|
||||
@@ -846,30 +991,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
// building-local. Convert from world via worldToBuildingLocal instead,
|
||||
// otherwise the wireframe jumps to a surface-local-coordinate ghost
|
||||
// position until the next mouse move.
|
||||
const buildingLocalLeave = worldToBuildingLocal(
|
||||
event.position[0],
|
||||
event.position[1],
|
||||
event.position[2],
|
||||
)
|
||||
const wx = Math.round(buildingLocalLeave.x * 2) / 2
|
||||
const wz = Math.round(buildingLocalLeave.z * 2) / 2
|
||||
const floorPos: [number, number, number] = [wx, 0, wz]
|
||||
|
||||
Object.assign(placementState.current, { surface: 'floor', surfaceItemId: null })
|
||||
gridPosition.current.set(wx, 0, wz)
|
||||
cursorGroupRef.current.position.x = wx
|
||||
cursorGroupRef.current.position.z = wz
|
||||
|
||||
const draft = draftNode.current
|
||||
if (draft) {
|
||||
draft.position = floorPos
|
||||
useScene.getState().updateNode(draft.id, {
|
||||
parentId: useViewer.getState().selection.levelId as string,
|
||||
position: floorPos,
|
||||
})
|
||||
}
|
||||
|
||||
revalidate()
|
||||
detachItemSurfaceToFloor(event)
|
||||
}
|
||||
|
||||
const onItemClick = (event: ItemEvent) => {
|
||||
@@ -927,11 +1049,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
return
|
||||
}
|
||||
|
||||
lastRawPos.current.set(
|
||||
event.localPosition[0],
|
||||
event.localPosition[1],
|
||||
event.localPosition[2],
|
||||
)
|
||||
lastRawPos.current.set(event.localPosition[0], event.localPosition[1], event.localPosition[2])
|
||||
const result = ceilingStrategy.move(getContext(), event)
|
||||
if (!result) return
|
||||
|
||||
@@ -1147,16 +1265,16 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
asset.attachTo,
|
||||
gridSnapStep,
|
||||
)
|
||||
updatePreviewGeometry(
|
||||
draft
|
||||
? (expandBoundsToGrid(
|
||||
getPreviewBoundsFromObject(sceneRegistry.nodes.get(draft.id) ?? null) ?? getFallbackPreviewBounds(draft, asset, asset.attachTo),
|
||||
asset.attachTo,
|
||||
gridSnapStep,
|
||||
))
|
||||
: fallbackBounds,
|
||||
)
|
||||
updateDimensionGuides(fallbackBounds)
|
||||
const previewBounds = draft
|
||||
? expandBoundsToGrid(
|
||||
getPreviewBoundsFromObject(sceneRegistry.nodes.get(draft.id) ?? null) ??
|
||||
getFallbackPreviewBounds(draft, asset, asset.attachTo),
|
||||
asset.attachTo,
|
||||
gridSnapStep,
|
||||
)
|
||||
: fallbackBounds
|
||||
updatePreviewGeometry(previewBounds)
|
||||
updateDimensionGuides(previewBounds)
|
||||
|
||||
// ---- Undo protection ----
|
||||
// Undo replaces the entire `nodes` object with a previous snapshot, which doesn't
|
||||
@@ -1242,10 +1360,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
const meshBounds = draft
|
||||
? getPreviewBoundsFromObject(sceneRegistry.nodes.get(draft.id) ?? null)
|
||||
: null
|
||||
updatePreviewGeometry(
|
||||
meshBounds ? expandBoundsToGrid(meshBounds, asset.attachTo, gridSnapStep) : fallbackBounds,
|
||||
)
|
||||
updateDimensionGuides(fallbackBounds)
|
||||
const previewBounds = meshBounds
|
||||
? expandBoundsToGrid(meshBounds, asset.attachTo, gridSnapStep)
|
||||
: fallbackBounds
|
||||
updatePreviewGeometry(previewBounds)
|
||||
updateDimensionGuides(previewBounds)
|
||||
}, [gridSnapStep, asset, draftNode])
|
||||
// Wall/ceiling items are managed by their own surface entry events (ensureDraft / reparent).
|
||||
const viewerLevelId = useViewer((s) => s.selection.levelId)
|
||||
@@ -1263,19 +1382,16 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
if (!draftNode.current) return
|
||||
const mesh = sceneRegistry.nodes.get(draftNode.current.id)
|
||||
if (!mesh) return
|
||||
if (
|
||||
measurementTargetState?.id !== draftNode.current.id ||
|
||||
measurementTargetState.object !== mesh
|
||||
) {
|
||||
setMeasurementTargetState({ id: draftNode.current.id, object: mesh })
|
||||
}
|
||||
|
||||
if (!meshPreviewAppliedRef.current) {
|
||||
const previewBounds = getPreviewBoundsFromObject(mesh)
|
||||
if (previewBounds) {
|
||||
updatePreviewGeometry(
|
||||
expandBoundsToGrid(previewBounds, asset.attachTo, useEditor.getState().gridSnapStep),
|
||||
)
|
||||
const expandedBounds = expandBoundsToGrid(
|
||||
previewBounds,
|
||||
asset.attachTo,
|
||||
useEditor.getState().gridSnapStep,
|
||||
)
|
||||
updatePreviewGeometry(expandedBounds)
|
||||
updateDimensionGuides(expandedBounds)
|
||||
meshPreviewAppliedRef.current = true
|
||||
}
|
||||
}
|
||||
@@ -1319,68 +1435,75 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
? getScaledDimensions(initialDraft)
|
||||
: (config.asset?.dimensions ?? DEFAULT_DIMENSIONS)
|
||||
const dims = getGridAlignedDimensions(rawDims, initialAttachTo, gridSnapStep)
|
||||
const initialBoxGeometry = new BoxGeometry(dims[0], dims[1], dims[2])
|
||||
const wallSideZOffset = initialAttachTo === 'wall-side' ? -dims[2] / 2 : 0
|
||||
initialBoxGeometry.translate(0, dims[1] / 2, wallSideZOffset)
|
||||
|
||||
// Base plane geometry (colored rectangle on the ground)
|
||||
const basePlaneGeometry = new PlaneGeometry(dims[0], dims[2])
|
||||
basePlaneGeometry.rotateX(-Math.PI / 2) // Make it horizontal
|
||||
basePlaneGeometry.translate(0, 0.01, wallSideZOffset) // Slightly above ground to avoid z-fighting
|
||||
const initialDimensionBounds = expandBoundsToGrid(
|
||||
getFallbackPreviewBounds(initialDraft, config.asset!, initialAttachTo),
|
||||
initialAttachTo,
|
||||
gridSnapStep,
|
||||
)
|
||||
const widthLabel = formatMeasurement(initialDimensionBounds.dimensions[0], unit)
|
||||
const depthLabel = formatMeasurement(initialDimensionBounds.dimensions[2], unit)
|
||||
const heightLabel = formatMeasurement(initialDimensionBounds.dimensions[1], unit)
|
||||
const initialEdgeGeometry = useMemo(
|
||||
() => createLineGeometry(getBoxEdgePoints(initialDimensionBounds)),
|
||||
[
|
||||
initialDimensionBounds.center[0],
|
||||
initialDimensionBounds.center[1],
|
||||
initialDimensionBounds.center[2],
|
||||
initialDimensionBounds.dimensions[0],
|
||||
initialDimensionBounds.dimensions[1],
|
||||
initialDimensionBounds.dimensions[2],
|
||||
],
|
||||
)
|
||||
const basePlaneGeometry = useMemo(() => {
|
||||
const geometry = new PlaneGeometry(dims[0], dims[2])
|
||||
geometry.rotateX(-Math.PI / 2)
|
||||
geometry.translate(0, 0.01, wallSideZOffset)
|
||||
return geometry
|
||||
}, [dims[0], dims[2], wallSideZOffset])
|
||||
const initialWidthGuideGeometry = useMemo(() => createLineGeometry(), [])
|
||||
const initialDepthGuideGeometry = useMemo(() => createLineGeometry(), [])
|
||||
const initialHeightGuideGeometry = useMemo(() => createLineGeometry(), [])
|
||||
const currentDimensionBounds = dimensionBounds ?? initialDimensionBounds
|
||||
const widthLabel = formatMeasurement(currentDimensionBounds.dimensions[0], unit)
|
||||
const depthLabel = formatMeasurement(currentDimensionBounds.dimensions[2], unit)
|
||||
const heightLabel = formatMeasurement(currentDimensionBounds.dimensions[1], unit)
|
||||
const widthLabelPosition: [number, number, number] = [
|
||||
initialDimensionBounds.center[0],
|
||||
currentDimensionBounds.center[0],
|
||||
0.04,
|
||||
initialDimensionBounds.center[2] + initialDimensionBounds.dimensions[2] / 2 + 0.24,
|
||||
currentDimensionBounds.center[2] + currentDimensionBounds.dimensions[2] / 2 + 0.24,
|
||||
]
|
||||
const depthLabelPosition: [number, number, number] = [
|
||||
initialDimensionBounds.center[0] + initialDimensionBounds.dimensions[0] / 2 + 0.24,
|
||||
currentDimensionBounds.center[0] + currentDimensionBounds.dimensions[0] / 2 + 0.24,
|
||||
0.04,
|
||||
initialDimensionBounds.center[2],
|
||||
currentDimensionBounds.center[2],
|
||||
]
|
||||
const heightLabelPosition: [number, number, number] = [
|
||||
initialDimensionBounds.center[0] - initialDimensionBounds.dimensions[0] / 2 - 0.24,
|
||||
initialDimensionBounds.dimensions[1] / 2,
|
||||
initialDimensionBounds.center[2] - initialDimensionBounds.dimensions[2] / 2,
|
||||
currentDimensionBounds.center[0] - currentDimensionBounds.dimensions[0] / 2 - 0.24,
|
||||
currentDimensionBounds.dimensions[1] / 2,
|
||||
currentDimensionBounds.center[2] - currentDimensionBounds.dimensions[2] / 2,
|
||||
]
|
||||
|
||||
const measurementTarget =
|
||||
draftNode.current && measurementTargetState?.id === draftNode.current.id
|
||||
? measurementTargetState.object
|
||||
: null
|
||||
const measurementContent = (
|
||||
<>
|
||||
<lineSegments
|
||||
layers={EDITOR_LAYER}
|
||||
geometry={initialWidthGuideGeometry}
|
||||
material={measurementMaterial}
|
||||
ref={measurementWidthRef}
|
||||
renderOrder={998}
|
||||
>
|
||||
<bufferGeometry />
|
||||
</lineSegments>
|
||||
/>
|
||||
<lineSegments
|
||||
layers={EDITOR_LAYER}
|
||||
geometry={initialDepthGuideGeometry}
|
||||
material={measurementMaterial}
|
||||
ref={measurementDepthRef}
|
||||
renderOrder={998}
|
||||
>
|
||||
<bufferGeometry />
|
||||
</lineSegments>
|
||||
/>
|
||||
<lineSegments
|
||||
layers={EDITOR_LAYER}
|
||||
geometry={initialHeightGuideGeometry}
|
||||
material={measurementMaterial}
|
||||
ref={measurementHeightRef}
|
||||
renderOrder={998}
|
||||
>
|
||||
<bufferGeometry />
|
||||
</lineSegments>
|
||||
/>
|
||||
<Html center position={widthLabelPosition} style={{ pointerEvents: 'none' }}>
|
||||
<div
|
||||
style={{
|
||||
@@ -1443,10 +1566,14 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
||||
|
||||
return (
|
||||
<group ref={cursorGroupRef}>
|
||||
<lineSegments layers={EDITOR_LAYER} material={edgeMaterial} ref={edgesRef} renderOrder={999}>
|
||||
<edgesGeometry args={[initialBoxGeometry]} />
|
||||
</lineSegments>
|
||||
{measurementTarget ? createPortal(measurementContent, measurementTarget) : measurementContent}
|
||||
<lineSegments
|
||||
geometry={initialEdgeGeometry}
|
||||
layers={EDITOR_LAYER}
|
||||
material={edgeMaterial}
|
||||
ref={edgesRef}
|
||||
renderOrder={999}
|
||||
/>
|
||||
{measurementContent}
|
||||
<mesh
|
||||
geometry={basePlaneGeometry}
|
||||
layers={EDITOR_LAYER}
|
||||
|
||||
Reference in New Issue
Block a user