Use bbox anchors for item dimension measurements
This commit is contained in:
+24
-18
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user