Use bbox anchors for item dimension measurements
This commit is contained in:
@@ -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,12 +146,14 @@ 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}
|
||||||
/>
|
/>
|
||||||
|
{measurement.showTicks !== false ? (
|
||||||
|
<>
|
||||||
<FloorplanMeasurementTick
|
<FloorplanMeasurementTick
|
||||||
angleDeg={measurement.labelAngleDeg}
|
angleDeg={measurement.labelAngleDeg}
|
||||||
isSelected={measurement.isSelected}
|
isSelected={measurement.isSelected}
|
||||||
@@ -166,6 +170,8 @@ export const FloorplanMeasurementsLayer = memo(function FloorplanMeasurementsLay
|
|||||||
x={measurement.dimensionLineEnd.x2}
|
x={measurement.dimensionLineEnd.x2}
|
||||||
y={measurement.dimensionLineEnd.y2}
|
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}
|
||||||
|
|||||||
@@ -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 === undefined || currentPolygon === null || currentPolygon === false)) ||
|
||||||
|
(Array.isArray(currentPolygon) &&
|
||||||
|
nextPolygon !== null &&
|
||||||
currentPolygon.length === nextPolygon.length &&
|
currentPolygon.length === nextPolygon.length &&
|
||||||
currentPolygon.every(
|
currentPolygon.every(
|
||||||
(point, index) =>
|
(point, index) =>
|
||||||
Array.isArray(point) &&
|
Array.isArray(point) &&
|
||||||
point[0] === nextPolygon[index]?.[0] &&
|
point[0] === nextPolygon[index]?.[0] &&
|
||||||
point[1] === nextPolygon[index]?.[1],
|
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])
|
||||||
|
|||||||
Reference in New Issue
Block a user