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 { getScaledDimensions } from '../../schema'
import { sceneRegistry } from '../scene-registry/scene-registry'
import useScene from '../../store/use-scene'
import { Box3, Matrix4, Vector3, type Object3D } from 'three'
import { Vector3 } from 'three'
import { SpatialGrid } from './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 {
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 {
const object = sceneRegistry.nodes.get(item.id)
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 = [
new Vector3(bounds.min[0], bounds.min[1], bounds.min[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.max[2]),
]
const yRot = item.rotation[1] ?? 0
const cos = Math.cos(yRot)
const sin = Math.sin(yRot)
let minX = Number.POSITIVE_INFINITY
let minY = Number.POSITIVE_INFINITY
@@ -175,13 +136,17 @@ function getItemParentAabb(item: ItemNode): ItemParentAabb {
let maxZ = Number.NEGATIVE_INFINITY
for (const corner of corners) {
corner.applyMatrix4(object.matrix)
minX = Math.min(minX, corner.x)
minY = Math.min(minY, corner.y)
minZ = Math.min(minZ, corner.z)
maxX = Math.max(maxX, corner.x)
maxY = Math.max(maxY, corner.y)
maxZ = Math.max(maxZ, corner.z)
const rotatedX = corner.x * cos + corner.z * sin
const rotatedZ = -corner.x * sin + corner.z * cos
const worldX = rotatedX + item.position[0]
const worldY = corner.y + item.position[1]
const worldZ = rotatedZ + item.position[2]
minX = Math.min(minX, worldX)
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 }
@@ -10,6 +10,7 @@ const FLOORPLAN_MEASUREMENT_EXTENSION_DASH = '0.08 0.12'
const FLOORPLAN_MEASUREMENT_END_TICK = 0.18
export type LinearMeasurementOverlay = {
dashedExtensions?: boolean
id: string
dimensionLineEnd: { 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
isSelected?: boolean
labelFill?: string
showTicks?: boolean
stroke?: string
}
@@ -125,7 +127,7 @@ export const FloorplanMeasurementsLayer = memo(function FloorplanMeasurementsLay
{measurements.map((measurement) => (
<g className={className} key={measurement.id} pointerEvents="none" style={{ userSelect: 'none' }}>
<FloorplanMeasurementLine
dashed
dashed={measurement.dashedExtensions ?? true}
isSelected={measurement.isSelected}
palette={palette}
segment={measurement.extensionStart}
@@ -144,28 +146,32 @@ export const FloorplanMeasurementsLayer = memo(function FloorplanMeasurementsLay
stroke={measurement.stroke}
/>
<FloorplanMeasurementLine
dashed
dashed={measurement.dashedExtensions ?? true}
isSelected={measurement.isSelected}
palette={palette}
segment={measurement.extensionEnd}
stroke={measurement.extensionStroke}
/>
<FloorplanMeasurementTick
angleDeg={measurement.labelAngleDeg}
isSelected={measurement.isSelected}
palette={palette}
stroke={measurement.stroke}
x={measurement.dimensionLineStart.x1}
y={measurement.dimensionLineStart.y1}
/>
<FloorplanMeasurementTick
angleDeg={measurement.labelAngleDeg}
isSelected={measurement.isSelected}
palette={palette}
stroke={measurement.stroke}
x={measurement.dimensionLineEnd.x2}
y={measurement.dimensionLineEnd.y2}
/>
{measurement.showTicks !== false ? (
<>
<FloorplanMeasurementTick
angleDeg={measurement.labelAngleDeg}
isSelected={measurement.isSelected}
palette={palette}
stroke={measurement.stroke}
x={measurement.dimensionLineStart.x1}
y={measurement.dimensionLineStart.y1}
/>
<FloorplanMeasurementTick
angleDeg={measurement.labelAngleDeg}
isSelected={measurement.isSelected}
palette={palette}
stroke={measurement.stroke}
x={measurement.dimensionLineEnd.x2}
y={measurement.dimensionLineEnd.y2}
/>
</>
) : null}
<text
dominantBaseline="central"
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_TEXT = 'rgba(234, 88, 12, 0.98)'
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_MIN_DISTANCE = 0.05
const FLOORPLAN_ITEM_CLEARANCE_EDGE_PARALLEL_THRESHOLD = 0.65
@@ -446,6 +447,7 @@ type FloorplanPolygonEntry = {
}
type FloorplanItemEntry = {
dimensionPolygon: Point2D[]
item: ItemNode
points: string
polygon: Point2D[]
@@ -1963,10 +1965,13 @@ function getLinearMeasurementOverlay(
end: Point2D,
label: string,
options?: {
extensionOvershoot?: number
offsetDistance?: number
offsetVector?: Point2D
},
): LinearMeasurementOverlay | null {
const extensionOvershoot =
options?.extensionOvershoot ?? FLOORPLAN_MEASUREMENT_EXTENSION_OVERSHOOT
const offsetDistance = options?.offsetDistance ?? 0
const offsetVector = options?.offsetVector
const offsetStart =
@@ -2035,14 +2040,14 @@ function getLinearMeasurementOverlay(
offsetVector
? start.x +
offsetVector.x *
(offsetDistance + FLOORPLAN_MEASUREMENT_EXTENSION_OVERSHOOT)
(offsetDistance + extensionOvershoot)
: start.x,
),
y2: toSvgY(
offsetVector
? start.y +
offsetVector.y *
(offsetDistance + FLOORPLAN_MEASUREMENT_EXTENSION_OVERSHOOT)
(offsetDistance + extensionOvershoot)
: start.y,
),
},
@@ -2051,14 +2056,12 @@ function getLinearMeasurementOverlay(
y1: toSvgY(end.y),
x2: toSvgX(
offsetVector
? end.x +
offsetVector.x * (offsetDistance + FLOORPLAN_MEASUREMENT_EXTENSION_OVERSHOOT)
? end.x + offsetVector.x * (offsetDistance + extensionOvershoot)
: end.x,
),
y2: toSvgY(
offsetVector
? end.y +
offsetVector.y * (offsetDistance + FLOORPLAN_MEASUREMENT_EXTENSION_OVERSHOOT)
? end.y + offsetVector.y * (offsetDistance + extensionOvershoot)
: end.y,
),
},
@@ -2319,6 +2322,113 @@ function getSelectedWallMeasurementOverlays(
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[] {
const [x1, z1] = wall.start
const [x2, z2] = wall.end
@@ -6163,6 +6273,7 @@ export function FloorplanPanel() {
return [
{
dimensionPolygon: entry.dimensionPolygon,
item: entry.item,
points: formatPolygonPoints(entry.polygon),
polygon: entry.polygon,
@@ -6665,6 +6776,15 @@ export function FloorplanPanel() {
isFenceEndpointMoveActive ||
isFloorItemBuildActive ||
isFloorItemMoveActive
const itemPlacementDimensionMeasurements = useMemo(() => {
if (!isItemPlacementPreviewActive) {
return [] as LinearMeasurementOverlay[]
}
return floorplanItemEntries.flatMap((itemEntry) =>
getItemDimensionMeasurementOverlays(itemEntry, unit),
)
}, [floorplanItemEntries, isItemPlacementPreviewActive, unit])
const floorplanPreviewStairSegment = useMemo(
() =>
StairSegmentNodeSchema.parse({
@@ -11876,6 +11996,12 @@ export function FloorplanPanel() {
selectedIdSet={selectedIdSet}
/>
<FloorplanMeasurementsLayer
className="item-dimension-measurement"
measurements={itemPlacementDimensionMeasurements}
palette={palette}
/>
<FloorplanMeasurementsLayer
className="opening-placement-dimension"
measurements={movingOpeningPlacementMeasurements}
@@ -16,13 +16,16 @@ import {
type WallNode,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { Html } from '@react-three/drei'
import { useFrame } from '@react-three/fiber'
import { useEffect, useRef } from 'react'
import {
BoxGeometry,
Box3,
BufferGeometry,
EdgesGeometry,
Euler,
Float32BufferAttribute,
type Group,
type LineSegments,
Matrix4,
@@ -49,6 +52,17 @@ import type { DraftNodeHandle } from './use-draft-node'
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 = {
min: [number, number, number]
max: [number, number, number]
@@ -160,6 +174,13 @@ const edgeMaterial = new LineBasicNodeMaterial({
depthWrite: false,
})
const measurementMaterial = new LineBasicNodeMaterial({
color: 0x0f_17_2a,
linewidth: 2,
depthTest: false,
depthWrite: false,
})
const basePlaneMaterial = new MeshBasicNodeMaterial({
color: 0xef_44_44, // red-500 (invalid)
transparent: true,
@@ -187,6 +208,9 @@ export interface PlacementCoordinatorConfig {
export function usePlacementCoordinator(config: PlacementCoordinatorConfig): React.ReactNode {
const cursorGroupRef = useRef<Group>(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 gridPosition = 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 previewBoundsSignatureRef = useRef<string | null>(null)
const meshPreviewAppliedRef = useRef(false)
const dimensionBoundsRef = useRef<PreviewBounds | null>(null)
// Store config callbacks in refs to avoid re-running effect when they change
const configRef = useRef(config)
@@ -203,6 +228,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
const { canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling } = useSpatialQuery()
const { asset, draftNode } = config
const unit = useViewer((state) => state.unit)
const updatePreviewGeometry = (bounds: PreviewBounds) => {
const [width, height, depth] = bounds.dimensions
@@ -227,6 +253,99 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
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(() => {
if (!asset) return
useScene.temporal.getState().pause()
@@ -963,6 +1082,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
fallbackBounds)
: fallbackBounds,
)
updateDimensionGuides(fallbackBounds)
// ---- Undo protection ----
// 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])
basePlaneGeometry.rotateX(-Math.PI / 2) // Make it horizontal
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 (
<group ref={cursorGroupRef}>
<lineSegments layers={EDITOR_LAYER} material={edgeMaterial} ref={edgesRef} renderOrder={999}>
<edgesGeometry args={[initialBoxGeometry]} />
</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
geometry={basePlaneGeometry}
layers={EDITOR_LAYER}
+21 -1
View File
@@ -1,6 +1,7 @@
import {
type AnyNode,
type AnyNodeId,
getScaledDimensions,
type ItemNode,
type LevelNode,
sceneRegistry,
@@ -8,7 +9,7 @@ import {
} from '@pascal-app/core'
import type { Object3D } from 'three'
import { Box3, Matrix4, Vector3 } from 'three'
import { rotatePlanVector } from './geometry'
import { getRotatedRectanglePolygon, rotatePlanVector } from './geometry'
import type { FloorplanItemEntry, FloorplanNodeTransform, LevelDescendantMap } from './types'
export function collectLevelDescendants(
@@ -146,7 +147,10 @@ export function buildFloorplanItemEntry(
return null
}
const dimensionPolygon = getItemDimensionPolygon(item, transform)
return {
dimensionPolygon,
item,
polygon: realMeshPolygon,
usesRealMesh: realMeshPolygon !== null,
@@ -158,6 +162,22 @@ type Point = {
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 {
const metadata =
typeof item.metadata === 'object' && item.metadata !== null && !Array.isArray(item.metadata)
@@ -11,6 +11,7 @@ export type FloorplanLineSegment = {
}
export type FloorplanItemEntry = {
dimensionPolygon: Point2D[]
item: ItemNode
polygon: Point2D[]
usesRealMesh: boolean
@@ -94,6 +94,11 @@ type Point = {
y: number
}
type LocalBounds = {
min: [number, number, number]
max: [number, number, number]
}
function getLocalMeshFloorplanPolygon(object: Object3D): Point[] {
object.updateWorldMatrix(true, true)
@@ -170,6 +175,59 @@ function getLocalMeshFloorplanPolygon(object: Object3D): Point[] {
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[]) {
if (points.length === 0) return null
if (points.length < 3) return points
@@ -273,30 +331,53 @@ const ModelRenderer = ({ node }: { node: ItemNode }) => {
if (!cloneRoot) return
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 =
typeof node.metadata === 'object' && node.metadata !== null && !Array.isArray(node.metadata)
? (node.metadata as Record<string, unknown>)
: {}
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 =
Array.isArray(currentPolygon) &&
currentPolygon.length === nextPolygon.length &&
currentPolygon.every(
(point, index) =>
Array.isArray(point) &&
point[0] === nextPolygon[index]?.[0] &&
point[1] === nextPolygon[index]?.[1],
)
((nextPolygon === null &&
(currentPolygon === undefined || currentPolygon === null || currentPolygon === false)) ||
(Array.isArray(currentPolygon) &&
nextPolygon !== null &&
currentPolygon.length === nextPolygon.length &&
currentPolygon.every(
(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
useScene.getState().updateNode(node.id, {
metadata: {
...metadata,
floorplanLocalPolygon: nextPolygon,
...(nextPolygon ? { floorplanLocalPolygon: nextPolygon } : {}),
...(nextBounds ? { meshLocalBounds: nextBounds } : {}),
},
})
}, [node.id, node.metadata, scene])