Use bbox anchors for item dimension measurements

This commit is contained in:
sudhir
2026-04-27 10:49:20 +05:30
parent 59779c5bf2
commit e4f42e0ee5
7 changed files with 534 additions and 118 deletions
@@ -1,8 +1,7 @@
import type { AnyNode, CeilingNode, ItemNode, SlabNode, WallNode } from '../../schema' import type { AnyNode, CeilingNode, ItemNode, SlabNode, WallNode } from '../../schema'
import { getScaledDimensions } from '../../schema' import { getScaledDimensions } from '../../schema'
import { sceneRegistry } from '../scene-registry/scene-registry'
import useScene from '../../store/use-scene' import useScene from '../../store/use-scene'
import { Box3, Matrix4, Vector3, type Object3D } from 'three' import { Vector3 } from 'three'
import { SpatialGrid } from './spatial-grid' import { SpatialGrid } from './spatial-grid'
import { WallSpatialGrid } from './wall-spatial-grid' import { WallSpatialGrid } from './wall-spatial-grid'
@@ -78,84 +77,43 @@ function getFallbackItemLocalBounds(item: ItemNode): ItemLocalBounds {
} }
} }
function getItemLocalBoundsFromObject(object: Object3D | null): ItemLocalBounds | null {
if (!object) return null
object.updateWorldMatrix(true, true)
const inverseRootMatrix = new Matrix4().copy(object.matrixWorld).invert()
const localMatrix = new Matrix4()
const localBounds = new Box3()
const scratchBounds = new Box3()
let hasBounds = false
const registeredNodeObjects = new Set(sceneRegistry.nodes.values())
const expandBounds = (child: Object3D) => {
if (child !== object && registeredNodeObjects.has(child)) return
const mesh = child as Object3D & {
isMesh?: boolean
name?: string
geometry?: {
boundingBox: Box3 | null
computeBoundingBox?: () => void
}
}
if (mesh.isMesh && mesh.name !== 'cutout' && mesh.geometry) {
if (!mesh.geometry.boundingBox && mesh.geometry.computeBoundingBox) {
mesh.geometry.computeBoundingBox()
}
if (mesh.geometry.boundingBox) {
localMatrix.copy(inverseRootMatrix).multiply(mesh.matrixWorld)
scratchBounds.copy(mesh.geometry.boundingBox).applyMatrix4(localMatrix)
if (!hasBounds) {
localBounds.copy(scratchBounds)
hasBounds = true
} else {
localBounds.union(scratchBounds)
}
}
}
for (const grandchild of child.children) {
expandBounds(grandchild)
}
}
for (const child of object.children) {
expandBounds(child)
}
if (!hasBounds) return null
return {
min: [localBounds.min.x, localBounds.min.y, localBounds.min.z],
max: [localBounds.max.x, localBounds.max.y, localBounds.max.z],
}
}
function getItemLocalBounds(item: ItemNode): ItemLocalBounds { function getItemLocalBounds(item: ItemNode): ItemLocalBounds {
return getItemLocalBoundsFromObject(sceneRegistry.nodes.get(item.id) ?? null) ?? getFallbackItemLocalBounds(item) const metadata =
typeof item.metadata === 'object' && item.metadata !== null && !Array.isArray(item.metadata)
? (item.metadata as Record<string, unknown>)
: null
const rawBounds =
typeof metadata?.meshLocalBounds === 'object' &&
metadata.meshLocalBounds !== null &&
!Array.isArray(metadata.meshLocalBounds)
? (metadata.meshLocalBounds as Record<string, unknown>)
: null
const min = rawBounds?.min
const max = rawBounds?.max
if (
Array.isArray(min) &&
min.length >= 3 &&
Array.isArray(max) &&
max.length >= 3 &&
typeof min[0] === 'number' &&
typeof min[1] === 'number' &&
typeof min[2] === 'number' &&
typeof max[0] === 'number' &&
typeof max[1] === 'number' &&
typeof max[2] === 'number'
) {
return {
min: [min[0], min[1], min[2]],
max: [max[0], max[1], max[2]],
}
}
return getFallbackItemLocalBounds(item)
} }
function getItemParentAabb(item: ItemNode): ItemParentAabb { function getItemParentAabb(item: ItemNode): ItemParentAabb {
const object = sceneRegistry.nodes.get(item.id)
const bounds = getItemLocalBounds(item) const bounds = getItemLocalBounds(item)
if (!object) {
return {
minX: bounds.min[0] + item.position[0],
maxX: bounds.max[0] + item.position[0],
minY: bounds.min[1] + item.position[1],
maxY: bounds.max[1] + item.position[1],
minZ: bounds.min[2] + item.position[2],
maxZ: bounds.max[2] + item.position[2],
}
}
object.updateMatrix()
const corners = [ const corners = [
new Vector3(bounds.min[0], bounds.min[1], bounds.min[2]), new Vector3(bounds.min[0], bounds.min[1], bounds.min[2]),
new Vector3(bounds.min[0], bounds.min[1], bounds.max[2]), new Vector3(bounds.min[0], bounds.min[1], bounds.max[2]),
@@ -166,6 +124,9 @@ function getItemParentAabb(item: ItemNode): ItemParentAabb {
new Vector3(bounds.max[0], bounds.max[1], bounds.min[2]), new Vector3(bounds.max[0], bounds.max[1], bounds.min[2]),
new Vector3(bounds.max[0], bounds.max[1], bounds.max[2]), new Vector3(bounds.max[0], bounds.max[1], bounds.max[2]),
] ]
const yRot = item.rotation[1] ?? 0
const cos = Math.cos(yRot)
const sin = Math.sin(yRot)
let minX = Number.POSITIVE_INFINITY let minX = Number.POSITIVE_INFINITY
let minY = Number.POSITIVE_INFINITY let minY = Number.POSITIVE_INFINITY
@@ -175,13 +136,17 @@ function getItemParentAabb(item: ItemNode): ItemParentAabb {
let maxZ = Number.NEGATIVE_INFINITY let maxZ = Number.NEGATIVE_INFINITY
for (const corner of corners) { for (const corner of corners) {
corner.applyMatrix4(object.matrix) const rotatedX = corner.x * cos + corner.z * sin
minX = Math.min(minX, corner.x) const rotatedZ = -corner.x * sin + corner.z * cos
minY = Math.min(minY, corner.y) const worldX = rotatedX + item.position[0]
minZ = Math.min(minZ, corner.z) const worldY = corner.y + item.position[1]
maxX = Math.max(maxX, corner.x) const worldZ = rotatedZ + item.position[2]
maxY = Math.max(maxY, corner.y) minX = Math.min(minX, worldX)
maxZ = Math.max(maxZ, corner.z) minY = Math.min(minY, worldY)
minZ = Math.min(minZ, worldZ)
maxX = Math.max(maxX, worldX)
maxY = Math.max(maxY, worldY)
maxZ = Math.max(maxZ, worldZ)
} }
return { minX, maxX, minY, maxY, minZ, maxZ } return { minX, maxX, minY, maxY, minZ, maxZ }
@@ -10,6 +10,7 @@ const FLOORPLAN_MEASUREMENT_EXTENSION_DASH = '0.08 0.12'
const FLOORPLAN_MEASUREMENT_END_TICK = 0.18 const FLOORPLAN_MEASUREMENT_END_TICK = 0.18
export type LinearMeasurementOverlay = { export type LinearMeasurementOverlay = {
dashedExtensions?: boolean
id: string id: string
dimensionLineEnd: { x1: number; y1: number; x2: number; y2: number } dimensionLineEnd: { x1: number; y1: number; x2: number; y2: number }
dimensionLineStart: { x1: number; y1: number; x2: number; y2: number } dimensionLineStart: { x1: number; y1: number; x2: number; y2: number }
@@ -22,6 +23,7 @@ export type LinearMeasurementOverlay = {
extensionStroke?: string extensionStroke?: string
isSelected?: boolean isSelected?: boolean
labelFill?: string labelFill?: string
showTicks?: boolean
stroke?: string stroke?: string
} }
@@ -125,7 +127,7 @@ export const FloorplanMeasurementsLayer = memo(function FloorplanMeasurementsLay
{measurements.map((measurement) => ( {measurements.map((measurement) => (
<g className={className} key={measurement.id} pointerEvents="none" style={{ userSelect: 'none' }}> <g className={className} key={measurement.id} pointerEvents="none" style={{ userSelect: 'none' }}>
<FloorplanMeasurementLine <FloorplanMeasurementLine
dashed dashed={measurement.dashedExtensions ?? true}
isSelected={measurement.isSelected} isSelected={measurement.isSelected}
palette={palette} palette={palette}
segment={measurement.extensionStart} segment={measurement.extensionStart}
@@ -144,28 +146,32 @@ export const FloorplanMeasurementsLayer = memo(function FloorplanMeasurementsLay
stroke={measurement.stroke} stroke={measurement.stroke}
/> />
<FloorplanMeasurementLine <FloorplanMeasurementLine
dashed dashed={measurement.dashedExtensions ?? true}
isSelected={measurement.isSelected} isSelected={measurement.isSelected}
palette={palette} palette={palette}
segment={measurement.extensionEnd} segment={measurement.extensionEnd}
stroke={measurement.extensionStroke} stroke={measurement.extensionStroke}
/> />
<FloorplanMeasurementTick {measurement.showTicks !== false ? (
angleDeg={measurement.labelAngleDeg} <>
isSelected={measurement.isSelected} <FloorplanMeasurementTick
palette={palette} angleDeg={measurement.labelAngleDeg}
stroke={measurement.stroke} isSelected={measurement.isSelected}
x={measurement.dimensionLineStart.x1} palette={palette}
y={measurement.dimensionLineStart.y1} stroke={measurement.stroke}
/> x={measurement.dimensionLineStart.x1}
<FloorplanMeasurementTick y={measurement.dimensionLineStart.y1}
angleDeg={measurement.labelAngleDeg} />
isSelected={measurement.isSelected} <FloorplanMeasurementTick
palette={palette} angleDeg={measurement.labelAngleDeg}
stroke={measurement.stroke} isSelected={measurement.isSelected}
x={measurement.dimensionLineEnd.x2} palette={palette}
y={measurement.dimensionLineEnd.y2} stroke={measurement.stroke}
/> x={measurement.dimensionLineEnd.x2}
y={measurement.dimensionLineEnd.y2}
/>
</>
) : null}
<text <text
dominantBaseline="central" dominantBaseline="central"
fill={measurement.labelFill ?? palette.measurementStroke} fill={measurement.labelFill ?? palette.measurementStroke}
@@ -169,6 +169,7 @@ const FLOORPLAN_WALL_INNER_MEASUREMENT_EXTENSION = 'rgba(147, 197, 253, 0.9)'
const FLOORPLAN_OPENING_MEASUREMENT_STROKE = 'rgba(249, 115, 22, 0.98)' const FLOORPLAN_OPENING_MEASUREMENT_STROKE = 'rgba(249, 115, 22, 0.98)'
const FLOORPLAN_OPENING_MEASUREMENT_TEXT = 'rgba(234, 88, 12, 0.98)' const FLOORPLAN_OPENING_MEASUREMENT_TEXT = 'rgba(234, 88, 12, 0.98)'
const FLOORPLAN_OPENING_MEASUREMENT_EXTENSION = 'rgba(251, 146, 60, 0.9)' const FLOORPLAN_OPENING_MEASUREMENT_EXTENSION = 'rgba(251, 146, 60, 0.9)'
const FLOORPLAN_ITEM_DIMENSION_OFFSET = 0.24
const FLOORPLAN_ITEM_CLEARANCE_MAX_DISTANCE = 12 const FLOORPLAN_ITEM_CLEARANCE_MAX_DISTANCE = 12
const FLOORPLAN_ITEM_CLEARANCE_MIN_DISTANCE = 0.05 const FLOORPLAN_ITEM_CLEARANCE_MIN_DISTANCE = 0.05
const FLOORPLAN_ITEM_CLEARANCE_EDGE_PARALLEL_THRESHOLD = 0.65 const FLOORPLAN_ITEM_CLEARANCE_EDGE_PARALLEL_THRESHOLD = 0.65
@@ -446,6 +447,7 @@ type FloorplanPolygonEntry = {
} }
type FloorplanItemEntry = { type FloorplanItemEntry = {
dimensionPolygon: Point2D[]
item: ItemNode item: ItemNode
points: string points: string
polygon: Point2D[] polygon: Point2D[]
@@ -1963,10 +1965,13 @@ function getLinearMeasurementOverlay(
end: Point2D, end: Point2D,
label: string, label: string,
options?: { options?: {
extensionOvershoot?: number
offsetDistance?: number offsetDistance?: number
offsetVector?: Point2D offsetVector?: Point2D
}, },
): LinearMeasurementOverlay | null { ): LinearMeasurementOverlay | null {
const extensionOvershoot =
options?.extensionOvershoot ?? FLOORPLAN_MEASUREMENT_EXTENSION_OVERSHOOT
const offsetDistance = options?.offsetDistance ?? 0 const offsetDistance = options?.offsetDistance ?? 0
const offsetVector = options?.offsetVector const offsetVector = options?.offsetVector
const offsetStart = const offsetStart =
@@ -2035,14 +2040,14 @@ function getLinearMeasurementOverlay(
offsetVector offsetVector
? start.x + ? start.x +
offsetVector.x * offsetVector.x *
(offsetDistance + FLOORPLAN_MEASUREMENT_EXTENSION_OVERSHOOT) (offsetDistance + extensionOvershoot)
: start.x, : start.x,
), ),
y2: toSvgY( y2: toSvgY(
offsetVector offsetVector
? start.y + ? start.y +
offsetVector.y * offsetVector.y *
(offsetDistance + FLOORPLAN_MEASUREMENT_EXTENSION_OVERSHOOT) (offsetDistance + extensionOvershoot)
: start.y, : start.y,
), ),
}, },
@@ -2051,14 +2056,12 @@ function getLinearMeasurementOverlay(
y1: toSvgY(end.y), y1: toSvgY(end.y),
x2: toSvgX( x2: toSvgX(
offsetVector offsetVector
? end.x + ? end.x + offsetVector.x * (offsetDistance + extensionOvershoot)
offsetVector.x * (offsetDistance + FLOORPLAN_MEASUREMENT_EXTENSION_OVERSHOOT)
: end.x, : end.x,
), ),
y2: toSvgY( y2: toSvgY(
offsetVector offsetVector
? end.y + ? end.y + offsetVector.y * (offsetDistance + extensionOvershoot)
offsetVector.y * (offsetDistance + FLOORPLAN_MEASUREMENT_EXTENSION_OVERSHOOT)
: end.y, : end.y,
), ),
}, },
@@ -2319,6 +2322,113 @@ function getSelectedWallMeasurementOverlays(
return overlays return overlays
} }
function getItemDimensionMeasurementOverlays(
itemEntry: FloorplanItemEntry,
unit: 'metric' | 'imperial',
): LinearMeasurementOverlay[] {
const itemMetadata =
typeof itemEntry.item.metadata === 'object' &&
itemEntry.item.metadata !== null &&
!Array.isArray(itemEntry.item.metadata)
? (itemEntry.item.metadata as Record<string, unknown>)
: null
if (itemMetadata?.isTransient !== true) {
return []
}
const polygon = itemEntry.polygon
if (polygon.length < 4) {
return []
}
const centroid = polygonCentroid(polygon)
const configuredWidth = formatMeasurement(itemEntry.item.scale[0] * itemEntry.item.asset.dimensions[0], unit)
const configuredDepth = formatMeasurement(itemEntry.item.scale[2] * itemEntry.item.asset.dimensions[2], unit)
const buildSideOverlay = (id: string, start: Point2D, end: Point2D) => {
const edgeVector = {
x: end.x - start.x,
y: end.y - start.y,
}
const tangent = normalizePlanVector(edgeVector)
if (!tangent) {
return null
}
let outwardNormal: Point2D = {
x: -tangent.y,
y: tangent.x,
}
const midpoint = {
x: (start.x + end.x) / 2,
y: (start.y + end.y) / 2,
}
const centroidVector = {
x: midpoint.x - centroid.x,
y: midpoint.y - centroid.y,
}
if (dotPlanVectors(outwardNormal, centroidVector) < 0) {
outwardNormal = {
x: -outwardNormal.x,
y: -outwardNormal.y,
}
}
const overlay = getLinearMeasurementOverlay(
id,
start,
end,
id.includes(':width') ? configuredWidth : configuredDepth,
{
extensionOvershoot: 0,
offsetDistance: FLOORPLAN_ITEM_DIMENSION_OFFSET,
offsetVector: outwardNormal,
},
)
return overlay
? {
dashedExtensions: false,
...overlay,
isSelected: true,
showTicks: false,
}
: null
}
const widthCandidates = [
polygon[0] && polygon[1]
? buildSideOverlay(`${itemEntry.item.id}:width-a`, polygon[0], polygon[1])
: null,
polygon[2] && polygon[3]
? buildSideOverlay(`${itemEntry.item.id}:width-b`, polygon[3], polygon[2])
: null,
].filter((overlay): overlay is LinearMeasurementOverlay => overlay !== null)
const depthCandidates = [
polygon[1] && polygon[2]
? buildSideOverlay(`${itemEntry.item.id}:depth-a`, polygon[1], polygon[2])
: null,
polygon[0] && polygon[3]
? buildSideOverlay(`${itemEntry.item.id}:depth-b`, polygon[0], polygon[3])
: null,
].filter((overlay): overlay is LinearMeasurementOverlay => overlay !== null)
const widthOverlay =
widthCandidates.length > 0
? widthCandidates.reduce((best, current) => (current.labelY > best.labelY ? current : best))
: null
const depthOverlay =
depthCandidates.length > 0
? depthCandidates.reduce((best, current) => (current.labelX < best.labelX ? current : best))
: null
return [widthOverlay, depthOverlay].filter(
(overlay): overlay is LinearMeasurementOverlay => overlay !== null,
)
}
function getOpeningFootprint(wall: WallNode, node: WindowNode | DoorNode): Point2D[] { function getOpeningFootprint(wall: WallNode, node: WindowNode | DoorNode): Point2D[] {
const [x1, z1] = wall.start const [x1, z1] = wall.start
const [x2, z2] = wall.end const [x2, z2] = wall.end
@@ -6163,6 +6273,7 @@ export function FloorplanPanel() {
return [ return [
{ {
dimensionPolygon: entry.dimensionPolygon,
item: entry.item, item: entry.item,
points: formatPolygonPoints(entry.polygon), points: formatPolygonPoints(entry.polygon),
polygon: entry.polygon, polygon: entry.polygon,
@@ -6665,6 +6776,15 @@ export function FloorplanPanel() {
isFenceEndpointMoveActive || isFenceEndpointMoveActive ||
isFloorItemBuildActive || isFloorItemBuildActive ||
isFloorItemMoveActive isFloorItemMoveActive
const itemPlacementDimensionMeasurements = useMemo(() => {
if (!isItemPlacementPreviewActive) {
return [] as LinearMeasurementOverlay[]
}
return floorplanItemEntries.flatMap((itemEntry) =>
getItemDimensionMeasurementOverlays(itemEntry, unit),
)
}, [floorplanItemEntries, isItemPlacementPreviewActive, unit])
const floorplanPreviewStairSegment = useMemo( const floorplanPreviewStairSegment = useMemo(
() => () =>
StairSegmentNodeSchema.parse({ StairSegmentNodeSchema.parse({
@@ -11876,6 +11996,12 @@ export function FloorplanPanel() {
selectedIdSet={selectedIdSet} selectedIdSet={selectedIdSet}
/> />
<FloorplanMeasurementsLayer
className="item-dimension-measurement"
measurements={itemPlacementDimensionMeasurements}
palette={palette}
/>
<FloorplanMeasurementsLayer <FloorplanMeasurementsLayer
className="opening-placement-dimension" className="opening-placement-dimension"
measurements={movingOpeningPlacementMeasurements} measurements={movingOpeningPlacementMeasurements}
@@ -16,13 +16,16 @@ import {
type WallNode, type WallNode,
} from '@pascal-app/core' } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { Html } from '@react-three/drei'
import { useFrame } from '@react-three/fiber' import { useFrame } from '@react-three/fiber'
import { useEffect, useRef } from 'react' import { useEffect, useRef } from 'react'
import { import {
BoxGeometry, BoxGeometry,
Box3, Box3,
BufferGeometry,
EdgesGeometry, EdgesGeometry,
Euler, Euler,
Float32BufferAttribute,
type Group, type Group,
type LineSegments, type LineSegments,
Matrix4, Matrix4,
@@ -49,6 +52,17 @@ import type { DraftNodeHandle } from './use-draft-node'
const DEFAULT_DIMENSIONS: [number, number, number] = [1, 1, 1] const DEFAULT_DIMENSIONS: [number, number, number] = [1, 1, 1]
function formatMeasurement(value: number, unit: 'metric' | 'imperial') {
if (unit === 'imperial') {
const feet = value * 3.280_84
const wholeFeet = Math.floor(feet)
const inches = Math.round((feet - wholeFeet) * 12)
if (inches === 12) return `${wholeFeet + 1}'0"`
return `${wholeFeet}'${inches}"`
}
return `${Number.parseFloat(value.toFixed(2))}m`
}
type PreviewBounds = { type PreviewBounds = {
min: [number, number, number] min: [number, number, number]
max: [number, number, number] max: [number, number, number]
@@ -160,6 +174,13 @@ const edgeMaterial = new LineBasicNodeMaterial({
depthWrite: false, depthWrite: false,
}) })
const measurementMaterial = new LineBasicNodeMaterial({
color: 0x0f_17_2a,
linewidth: 2,
depthTest: false,
depthWrite: false,
})
const basePlaneMaterial = new MeshBasicNodeMaterial({ const basePlaneMaterial = new MeshBasicNodeMaterial({
color: 0xef_44_44, // red-500 (invalid) color: 0xef_44_44, // red-500 (invalid)
transparent: true, transparent: true,
@@ -187,6 +208,9 @@ export interface PlacementCoordinatorConfig {
export function usePlacementCoordinator(config: PlacementCoordinatorConfig): React.ReactNode { export function usePlacementCoordinator(config: PlacementCoordinatorConfig): React.ReactNode {
const cursorGroupRef = useRef<Group>(null!) const cursorGroupRef = useRef<Group>(null!)
const edgesRef = useRef<LineSegments>(null!) const edgesRef = useRef<LineSegments>(null!)
const measurementWidthRef = useRef<LineSegments>(null!)
const measurementDepthRef = useRef<LineSegments>(null!)
const measurementHeightRef = useRef<LineSegments>(null!)
const basePlaneRef = useRef<Mesh>(null!) const basePlaneRef = useRef<Mesh>(null!)
const gridPosition = useRef(new Vector3(0, 0, 0)) const gridPosition = useRef(new Vector3(0, 0, 0))
const lastRawPos = useRef(new Vector3(0, 0, 0)) const lastRawPos = useRef(new Vector3(0, 0, 0))
@@ -196,6 +220,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
const shiftFreeRef = useRef(false) const shiftFreeRef = useRef(false)
const previewBoundsSignatureRef = useRef<string | null>(null) const previewBoundsSignatureRef = useRef<string | null>(null)
const meshPreviewAppliedRef = useRef(false) const meshPreviewAppliedRef = useRef(false)
const dimensionBoundsRef = useRef<PreviewBounds | null>(null)
// Store config callbacks in refs to avoid re-running effect when they change // Store config callbacks in refs to avoid re-running effect when they change
const configRef = useRef(config) const configRef = useRef(config)
@@ -203,6 +228,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
const { canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling } = useSpatialQuery() const { canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling } = useSpatialQuery()
const { asset, draftNode } = config const { asset, draftNode } = config
const unit = useViewer((state) => state.unit)
const updatePreviewGeometry = (bounds: PreviewBounds) => { const updatePreviewGeometry = (bounds: PreviewBounds) => {
const [width, height, depth] = bounds.dimensions const [width, height, depth] = bounds.dimensions
@@ -227,6 +253,99 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
nextBoxGeometry.dispose() nextBoxGeometry.dispose()
} }
const updateDimensionGuides = (bounds: PreviewBounds) => {
dimensionBoundsRef.current = bounds
const [width, , depth] = bounds.dimensions
const [centerX, , centerZ] = bounds.center
const minX = centerX - width / 2
const maxX = centerX + width / 2
const minZ = centerZ - depth / 2
const maxZ = centerZ + depth / 2
const guideOffset = 0.18
const tick = 0.08
const y = 0.02
const widthPoints = [
minX,
y,
maxZ + guideOffset,
maxX,
y,
maxZ + guideOffset,
minX,
y,
maxZ + guideOffset - tick,
minX,
y,
maxZ + guideOffset + tick,
maxX,
y,
maxZ + guideOffset - tick,
maxX,
y,
maxZ + guideOffset + tick,
]
const depthPoints = [
maxX + guideOffset,
y,
minZ,
maxX + guideOffset,
y,
maxZ,
maxX + guideOffset - tick,
y,
minZ,
maxX + guideOffset + tick,
y,
minZ,
maxX + guideOffset - tick,
y,
maxZ,
maxX + guideOffset + tick,
y,
maxZ,
]
const heightPoints = [
minX - guideOffset,
0,
minZ,
minX - guideOffset,
bounds.dimensions[1],
minZ,
minX - guideOffset - tick,
0,
minZ,
minX - guideOffset + tick,
0,
minZ,
minX - guideOffset - tick,
bounds.dimensions[1],
minZ,
minX - guideOffset + tick,
bounds.dimensions[1],
minZ,
]
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
}
applyPoints(measurementWidthRef, widthPoints)
applyPoints(measurementDepthRef, depthPoints)
applyPoints(measurementHeightRef, heightPoints)
}
useEffect(() => { useEffect(() => {
if (!asset) return if (!asset) return
useScene.temporal.getState().pause() useScene.temporal.getState().pause()
@@ -963,6 +1082,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
fallbackBounds) fallbackBounds)
: fallbackBounds, : fallbackBounds,
) )
updateDimensionGuides(fallbackBounds)
// ---- Undo protection ---- // ---- Undo protection ----
// Undo replaces the entire `nodes` object with a previous snapshot, which doesn't // Undo replaces the entire `nodes` object with a previous snapshot, which doesn't
@@ -1107,12 +1227,109 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
const basePlaneGeometry = new PlaneGeometry(dims[0], dims[2]) const basePlaneGeometry = new PlaneGeometry(dims[0], dims[2])
basePlaneGeometry.rotateX(-Math.PI / 2) // Make it horizontal basePlaneGeometry.rotateX(-Math.PI / 2) // Make it horizontal
basePlaneGeometry.translate(0, 0.01, wallSideZOffset) // Slightly above ground to avoid z-fighting basePlaneGeometry.translate(0, 0.01, wallSideZOffset) // Slightly above ground to avoid z-fighting
const initialDimensionBounds = getFallbackPreviewBounds(initialDraft, config.asset!, config.asset?.attachTo)
const widthLabel = formatMeasurement(initialDimensionBounds.dimensions[0], unit)
const depthLabel = formatMeasurement(initialDimensionBounds.dimensions[2], unit)
const heightLabel = formatMeasurement(initialDimensionBounds.dimensions[1], unit)
const widthLabelPosition: [number, number, number] = [
initialDimensionBounds.center[0],
0.04,
initialDimensionBounds.center[2] + initialDimensionBounds.dimensions[2] / 2 + 0.24,
]
const depthLabelPosition: [number, number, number] = [
initialDimensionBounds.center[0] + initialDimensionBounds.dimensions[0] / 2 + 0.24,
0.04,
initialDimensionBounds.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,
]
return ( return (
<group ref={cursorGroupRef}> <group ref={cursorGroupRef}>
<lineSegments layers={EDITOR_LAYER} material={edgeMaterial} ref={edgesRef} renderOrder={999}> <lineSegments layers={EDITOR_LAYER} material={edgeMaterial} ref={edgesRef} renderOrder={999}>
<edgesGeometry args={[initialBoxGeometry]} /> <edgesGeometry args={[initialBoxGeometry]} />
</lineSegments> </lineSegments>
<lineSegments
layers={EDITOR_LAYER}
material={measurementMaterial}
ref={measurementWidthRef}
renderOrder={998}
>
<bufferGeometry />
</lineSegments>
<lineSegments
layers={EDITOR_LAYER}
material={measurementMaterial}
ref={measurementDepthRef}
renderOrder={998}
>
<bufferGeometry />
</lineSegments>
<lineSegments
layers={EDITOR_LAYER}
material={measurementMaterial}
ref={measurementHeightRef}
renderOrder={998}
>
<bufferGeometry />
</lineSegments>
<Html center position={widthLabelPosition}>
<div
style={{
background: 'rgba(15, 23, 42, 0.86)',
border: '1px solid rgba(15, 23, 42, 0.65)',
borderRadius: '999px',
color: '#f8fafc',
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
fontSize: '11px',
fontWeight: 600,
lineHeight: 1,
padding: '4px 8px',
whiteSpace: 'nowrap',
}}
>
{widthLabel}
</div>
</Html>
<Html center position={depthLabelPosition}>
<div
style={{
background: 'rgba(15, 23, 42, 0.86)',
border: '1px solid rgba(15, 23, 42, 0.65)',
borderRadius: '999px',
color: '#f8fafc',
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
fontSize: '11px',
fontWeight: 600,
lineHeight: 1,
padding: '4px 8px',
whiteSpace: 'nowrap',
}}
>
{depthLabel}
</div>
</Html>
<Html center position={heightLabelPosition}>
<div
style={{
background: 'rgba(15, 23, 42, 0.86)',
border: '1px solid rgba(15, 23, 42, 0.65)',
borderRadius: '999px',
color: '#f8fafc',
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
fontSize: '11px',
fontWeight: 600,
lineHeight: 1,
padding: '4px 8px',
whiteSpace: 'nowrap',
}}
>
{heightLabel}
</div>
</Html>
<mesh <mesh
geometry={basePlaneGeometry} geometry={basePlaneGeometry}
layers={EDITOR_LAYER} layers={EDITOR_LAYER}
+21 -1
View File
@@ -1,6 +1,7 @@
import { import {
type AnyNode, type AnyNode,
type AnyNodeId, type AnyNodeId,
getScaledDimensions,
type ItemNode, type ItemNode,
type LevelNode, type LevelNode,
sceneRegistry, sceneRegistry,
@@ -8,7 +9,7 @@ import {
} from '@pascal-app/core' } from '@pascal-app/core'
import type { Object3D } from 'three' import type { Object3D } from 'three'
import { Box3, Matrix4, Vector3 } from 'three' import { Box3, Matrix4, Vector3 } from 'three'
import { rotatePlanVector } from './geometry' import { getRotatedRectanglePolygon, rotatePlanVector } from './geometry'
import type { FloorplanItemEntry, FloorplanNodeTransform, LevelDescendantMap } from './types' import type { FloorplanItemEntry, FloorplanNodeTransform, LevelDescendantMap } from './types'
export function collectLevelDescendants( export function collectLevelDescendants(
@@ -146,7 +147,10 @@ export function buildFloorplanItemEntry(
return null return null
} }
const dimensionPolygon = getItemDimensionPolygon(item, transform)
return { return {
dimensionPolygon,
item, item,
polygon: realMeshPolygon, polygon: realMeshPolygon,
usesRealMesh: realMeshPolygon !== null, usesRealMesh: realMeshPolygon !== null,
@@ -158,6 +162,22 @@ type Point = {
y: number y: number
} }
function getItemDimensionPolygon(item: ItemNode, transform: FloorplanNodeTransform): Point[] {
const [width, , depth] = getScaledDimensions(item)
const centerLocalZ = item.asset.attachTo === 'wall-side' ? -depth / 2 : 0
const [offsetX, offsetY] = rotatePlanVector(0, centerLocalZ, transform.rotation)
return getRotatedRectanglePolygon(
{
x: transform.position.x + offsetX,
y: transform.position.y + offsetY,
},
width,
depth,
transform.rotation,
)
}
function getCachedLocalMeshPolygon(item: ItemNode): Point[] | null { function getCachedLocalMeshPolygon(item: ItemNode): Point[] | null {
const metadata = const metadata =
typeof item.metadata === 'object' && item.metadata !== null && !Array.isArray(item.metadata) typeof item.metadata === 'object' && item.metadata !== null && !Array.isArray(item.metadata)
@@ -11,6 +11,7 @@ export type FloorplanLineSegment = {
} }
export type FloorplanItemEntry = { export type FloorplanItemEntry = {
dimensionPolygon: Point2D[]
item: ItemNode item: ItemNode
polygon: Point2D[] polygon: Point2D[]
usesRealMesh: boolean usesRealMesh: boolean
@@ -94,6 +94,11 @@ type Point = {
y: number y: number
} }
type LocalBounds = {
min: [number, number, number]
max: [number, number, number]
}
function getLocalMeshFloorplanPolygon(object: Object3D): Point[] { function getLocalMeshFloorplanPolygon(object: Object3D): Point[] {
object.updateWorldMatrix(true, true) object.updateWorldMatrix(true, true)
@@ -170,6 +175,59 @@ function getLocalMeshFloorplanPolygon(object: Object3D): Point[] {
return getMinimumAreaBoundingRect(footprintPoints) ?? [] return getMinimumAreaBoundingRect(footprintPoints) ?? []
} }
function getLocalMeshBounds(object: Object3D): LocalBounds | null {
object.updateWorldMatrix(true, true)
const inverseRootMatrix = new Matrix4().copy(object.matrixWorld).invert()
const localMatrix = new Matrix4()
const localBounds = new Box3()
const scratchBounds = new Box3()
let hasBounds = false
const expandBounds = (child: Object3D) => {
const mesh = child as Object3D & {
isMesh?: boolean
name?: string
geometry?: {
boundingBox: Box3 | null
computeBoundingBox?: () => void
}
}
if (mesh.isMesh && mesh.name !== 'cutout' && mesh.geometry) {
if (!mesh.geometry.boundingBox && mesh.geometry.computeBoundingBox) {
mesh.geometry.computeBoundingBox()
}
if (mesh.geometry.boundingBox) {
localMatrix.copy(inverseRootMatrix).multiply(mesh.matrixWorld)
scratchBounds.copy(mesh.geometry.boundingBox).applyMatrix4(localMatrix)
if (!hasBounds) {
localBounds.copy(scratchBounds)
hasBounds = true
} else {
localBounds.union(scratchBounds)
}
}
}
for (const grandchild of child.children) {
expandBounds(grandchild)
}
}
for (const child of object.children) {
expandBounds(child)
}
if (!hasBounds) return null
return {
min: [localBounds.min.x, localBounds.min.y, localBounds.min.z],
max: [localBounds.max.x, localBounds.max.y, localBounds.max.z],
}
}
function getMinimumAreaBoundingRect(points: Point[]) { function getMinimumAreaBoundingRect(points: Point[]) {
if (points.length === 0) return null if (points.length === 0) return null
if (points.length < 3) return points if (points.length < 3) return points
@@ -273,30 +331,53 @@ const ModelRenderer = ({ node }: { node: ItemNode }) => {
if (!cloneRoot) return if (!cloneRoot) return
const polygon = getLocalMeshFloorplanPolygon(cloneRoot) const polygon = getLocalMeshFloorplanPolygon(cloneRoot)
if (polygon.length < 3) return const bounds = getLocalMeshBounds(cloneRoot)
if (polygon.length < 3 && !bounds) return
const nextPolygon = polygon.map(({ x, y }) => [x, y] as [number, number]) const nextPolygon = polygon.length >= 3 ? polygon.map(({ x, y }) => [x, y] as [number, number]) : null
const nextBounds = bounds ? { min: bounds.min, max: bounds.max } : null
const metadata = const metadata =
typeof node.metadata === 'object' && node.metadata !== null && !Array.isArray(node.metadata) typeof node.metadata === 'object' && node.metadata !== null && !Array.isArray(node.metadata)
? (node.metadata as Record<string, unknown>) ? (node.metadata as Record<string, unknown>)
: {} : {}
const currentPolygon = metadata.floorplanLocalPolygon const currentPolygon = metadata.floorplanLocalPolygon
const currentBounds =
typeof metadata.meshLocalBounds === 'object' &&
metadata.meshLocalBounds !== null &&
!Array.isArray(metadata.meshLocalBounds)
? (metadata.meshLocalBounds as { min?: unknown; max?: unknown })
: null
const unchanged = const unchanged =
Array.isArray(currentPolygon) && ((nextPolygon === null &&
currentPolygon.length === nextPolygon.length && (currentPolygon === undefined || currentPolygon === null || currentPolygon === false)) ||
currentPolygon.every( (Array.isArray(currentPolygon) &&
(point, index) => nextPolygon !== null &&
Array.isArray(point) && currentPolygon.length === nextPolygon.length &&
point[0] === nextPolygon[index]?.[0] && currentPolygon.every(
point[1] === nextPolygon[index]?.[1], (point, index) =>
) Array.isArray(point) &&
point[0] === nextPolygon[index]?.[0] &&
point[1] === nextPolygon[index]?.[1],
))) &&
((nextBounds === null &&
(currentBounds === undefined || currentBounds === null || currentBounds === false)) ||
(nextBounds !== null &&
Array.isArray(currentBounds?.min) &&
Array.isArray(currentBounds?.max) &&
currentBounds.min[0] === nextBounds.min[0] &&
currentBounds.min[1] === nextBounds.min[1] &&
currentBounds.min[2] === nextBounds.min[2] &&
currentBounds.max[0] === nextBounds.max[0] &&
currentBounds.max[1] === nextBounds.max[1] &&
currentBounds.max[2] === nextBounds.max[2]))
if (unchanged) return if (unchanged) return
useScene.getState().updateNode(node.id, { useScene.getState().updateNode(node.id, {
metadata: { metadata: {
...metadata, ...metadata,
floorplanLocalPolygon: nextPolygon, ...(nextPolygon ? { floorplanLocalPolygon: nextPolygon } : {}),
...(nextBounds ? { meshLocalBounds: nextBounds } : {}),
}, },
}) })
}, [node.id, node.metadata, scene]) }, [node.id, node.metadata, scene])