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
@@ -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