Merge pull request #277 from sudhir9297/feat/2d-editor

Feat/2d editor
This commit is contained in:
Wassim SAMAD
2026-04-28 07:36:05 -04:00
committed by GitHub
39 changed files with 8362 additions and 2152 deletions
@@ -1,5 +1,6 @@
import type { AnyNode, CeilingNode, ItemNode, SlabNode, WallNode } from '../../schema'
import { getScaledDimensions } from '../../schema'
import useScene from '../../store/use-scene'
import { SpatialGrid } from './spatial-grid'
import { WallSpatialGrid } from './wall-spatial-grid'
@@ -51,6 +52,121 @@ function getItemFootprint(
]
}
type ItemLocalBounds = {
min: [number, number, number]
max: [number, number, number]
}
type ItemParentAabb = {
minX: number
maxX: number
minY: number
maxY: number
minZ: number
maxZ: number
}
function getFallbackItemLocalBounds(item: ItemNode): ItemLocalBounds {
const [width, height, depth] = getScaledDimensions(item)
const minZ = item.asset.attachTo === 'wall-side' ? -depth : -depth / 2
const maxZ = item.asset.attachTo === 'wall-side' ? 0 : depth / 2
return {
min: [-width / 2, 0, minZ],
max: [width / 2, height, maxZ],
}
}
function getItemLocalBounds(item: ItemNode): ItemLocalBounds {
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 bounds = getItemLocalBounds(item)
const corners: Array<[number, number, number]> = [
[bounds.min[0], bounds.min[1], bounds.min[2]],
[bounds.min[0], bounds.min[1], bounds.max[2]],
[bounds.min[0], bounds.max[1], bounds.min[2]],
[bounds.min[0], bounds.max[1], bounds.max[2]],
[bounds.max[0], bounds.min[1], bounds.min[2]],
[bounds.max[0], bounds.min[1], bounds.max[2]],
[bounds.max[0], bounds.max[1], bounds.min[2]],
[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
let minZ = Number.POSITIVE_INFINITY
let maxX = Number.NEGATIVE_INFINITY
let maxY = Number.NEGATIVE_INFINITY
let maxZ = Number.NEGATIVE_INFINITY
for (const [cx, cy, cz] of corners) {
const rotatedX = cx * cos + cz * sin
const rotatedZ = -cx * sin + cz * cos
const worldX = rotatedX + item.position[0]
const worldY = cy + 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 }
}
function intervalsOverlap(minA: number, maxA: number, minB: number, maxB: number, epsilon = 1e-4) {
return minA < maxB - epsilon && maxA > minB + epsilon
}
function resolveNodeLevelId(node: AnyNode, nodes: Record<string, AnyNode>): string {
if (node.type === 'level') return node.id
let current: AnyNode | undefined = node
while (current) {
if (current.type === 'level') return current.id
current = current.parentId ? nodes[current.parentId] : undefined
}
return 'default'
}
/**
* Test if two line segments (a1->a2) and (b1->b2) intersect.
*/
@@ -481,8 +597,39 @@ export class SpatialGridManager {
rotation: [number, number, number],
ignoreIds?: string[],
) {
const grid = this.getFloorGrid(levelId)
return grid.canPlace(position, dimensions, rotation, ignoreIds)
const nodes = useScene.getState().nodes
const ignoreSet = new Set(ignoreIds ?? [])
const [width, , depth] = dimensions
const yRot = rotation[1]
const cos = Math.abs(Math.cos(yRot))
const sin = Math.abs(Math.sin(yRot))
const rotatedW = width * cos + depth * sin
const rotatedD = width * sin + depth * cos
const draftBounds = {
minX: position[0] - rotatedW / 2,
maxX: position[0] + rotatedW / 2,
minZ: position[2] - rotatedD / 2,
maxZ: position[2] + rotatedD / 2,
}
const conflicts: string[] = []
for (const node of Object.values(nodes)) {
if (node.type !== 'item') continue
const item = node as ItemNode
if (item.asset.attachTo) continue
if (ignoreSet.has(item.id)) continue
if (resolveNodeLevelId(item, nodes) !== levelId) continue
const bounds = getItemParentAabb(item)
if (
intervalsOverlap(draftBounds.minX, draftBounds.maxX, bounds.minX, bounds.maxX) &&
intervalsOverlap(draftBounds.minZ, draftBounds.maxZ, bounds.minZ, bounds.maxZ)
) {
conflicts.push(item.id)
}
}
return { valid: conflicts.length === 0, conflictIds: conflicts }
}
/**
@@ -514,7 +661,7 @@ export class SpatialGridManager {
// Convert local X position to parametric t (0-1)
const tCenter = localX / wallLength
const [itemWidth, itemHeight] = dimensions
return this.getWallGrid(levelId).canPlaceOnWall(
const baseResult = this.getWallGrid(levelId).canPlaceOnWall(
wallId,
wallLength,
wallHeight,
@@ -526,6 +673,44 @@ export class SpatialGridManager {
side,
ignoreIds,
)
if (!baseResult.valid) return baseResult
const nodes = useScene.getState().nodes
const ignoreSet = new Set(ignoreIds ?? [])
const draftBounds = {
minX: localX - itemWidth / 2,
maxX: localX + itemWidth / 2,
minY: baseResult.adjustedY,
maxY: baseResult.adjustedY + itemHeight,
}
const conflicts: string[] = []
for (const node of Object.values(nodes)) {
if (node.type !== 'item') continue
const item = node as ItemNode
if (!(item.asset.attachTo === 'wall' || item.asset.attachTo === 'wall-side')) continue
if (ignoreSet.has(item.id)) continue
if (item.parentId !== wallId) continue
if (attachType === 'wall-side' && item.asset.attachTo === 'wall-side' && side && item.side) {
if (side !== item.side) continue
}
const bounds = getItemParentAabb(item)
if (
intervalsOverlap(draftBounds.minX, draftBounds.maxX, bounds.minX, bounds.maxX) &&
intervalsOverlap(draftBounds.minY, draftBounds.maxY, bounds.minY, bounds.maxY)
) {
conflicts.push(item.id)
}
}
return {
...baseResult,
valid: conflicts.length === 0,
conflictIds: conflicts,
}
}
getWallForItem(levelId: string, itemId: string): string | undefined {
@@ -692,8 +877,39 @@ export class SpatialGridManager {
}
}
// Check for overlaps with other ceiling items
return this.getCeilingGrid(ceilingId).canPlace(position, dimensions, rotation, ignoreIds)
const nodes = useScene.getState().nodes
const ignoreSet = new Set(ignoreIds ?? [])
const [width, , depth] = dimensions
const yRot = rotation[1]
const cos = Math.abs(Math.cos(yRot))
const sin = Math.abs(Math.sin(yRot))
const rotatedW = width * cos + depth * sin
const rotatedD = width * sin + depth * cos
const draftBounds = {
minX: position[0] - rotatedW / 2,
maxX: position[0] + rotatedW / 2,
minZ: position[2] - rotatedD / 2,
maxZ: position[2] + rotatedD / 2,
}
const conflicts: string[] = []
for (const node of Object.values(nodes)) {
if (node.type !== 'item') continue
const item = node as ItemNode
if (item.asset.attachTo !== 'ceiling') continue
if (ignoreSet.has(item.id)) continue
if (item.parentId !== ceilingId) continue
const bounds = getItemParentAabb(item)
if (
intervalsOverlap(draftBounds.minX, draftBounds.maxX, bounds.minX, bounds.maxX) &&
intervalsOverlap(draftBounds.minZ, draftBounds.maxZ, bounds.minZ, bounds.maxZ)
) {
conflicts.push(item.id)
}
}
return { valid: conflicts.length === 0, conflictIds: conflicts }
}
clearLevel(levelId: string) {
@@ -216,7 +216,7 @@ function generateStairSegmentGeometry(
extrudedGeometry.applyMatrix4(matrix)
extrudedGeometry.computeVertexNormals()
const geometry = extrudedGeometry.toNonIndexed() ?? extrudedGeometry
const geometry = extrudedGeometry.index ? extrudedGeometry.toNonIndexed() : extrudedGeometry
if (geometry !== extrudedGeometry) {
extrudedGeometry.dispose()
}
@@ -0,0 +1,90 @@
'use client'
import { memo, type MouseEvent as ReactMouseEvent } from 'react'
import useEditor from '../../store/use-editor'
import { NodeActionMenu } from '../editor/node-action-menu'
type SvgPoint = {
x: number
y: number
}
export type FloorplanActionMenuHandler = (event: ReactMouseEvent<HTMLButtonElement>) => void
export type FloorplanActionMenuEntry = {
position: SvgPoint | null
onDelete: FloorplanActionMenuHandler
onMove: FloorplanActionMenuHandler
onDuplicate?: FloorplanActionMenuHandler
}
type FloorplanActionMenuLayerProps = {
item: FloorplanActionMenuEntry
wall: FloorplanActionMenuEntry
fence: FloorplanActionMenuEntry
slab: FloorplanActionMenuEntry
ceiling: FloorplanActionMenuEntry
opening: FloorplanActionMenuEntry
stair: FloorplanActionMenuEntry
roof: FloorplanActionMenuEntry
offsetY?: number
}
export const FloorplanActionMenuLayer = memo(function FloorplanActionMenuLayer({
item,
wall,
fence,
slab,
ceiling,
opening,
stair,
roof,
offsetY = 10,
}: FloorplanActionMenuLayerProps) {
const isFloorplanHovered = useEditor((state) => state.isFloorplanHovered)
const movingNode = useEditor((state) => state.movingNode)
const movingFenceEndpoint = useEditor((state) => state.movingFenceEndpoint)
const curvingWall = useEditor((state) => state.curvingWall)
const curvingFence = useEditor((state) => state.curvingFence)
if (!isFloorplanHovered || movingNode || movingFenceEndpoint || curvingWall || curvingFence) {
return null
}
const entries: FloorplanActionMenuEntry[] = [
item,
wall,
fence,
slab,
ceiling,
opening,
stair,
roof,
]
return (
<>
{entries.map((entry, index) =>
entry.position ? (
<div
className="absolute z-30"
key={index}
style={{
left: entry.position.x,
top: entry.position.y,
transform: `translate(-50%, calc(-100% - ${offsetY}px))`,
}}
>
<NodeActionMenu
onDelete={entry.onDelete}
onDuplicate={entry.onDuplicate}
onMove={entry.onMove}
onPointerDown={(event) => event.stopPropagation()}
onPointerUp={(event) => event.stopPropagation()}
/>
</div>
) : null,
)}
</>
)
})
@@ -0,0 +1,160 @@
'use client'
import { Icon } from '@iconify/react'
import { memo, useMemo } from 'react'
import useEditor, { type FloorplanSelectionTool } from '../../store/use-editor'
import { furnishTools } from '../ui/action-menu/furnish-tools'
import { tools as structureTools } from '../ui/action-menu/structure-tools'
type SvgPoint = {
x: number
y: number
}
type FloorplanCursorIndicator =
| {
kind: 'asset'
iconSrc: string
}
| {
kind: 'icon'
icon: string
}
type FloorplanCursorIndicatorOverlayProps = {
cursorPosition: SvgPoint | null
cursorAnchorPosition: SvgPoint | null
floorplanSelectionTool: FloorplanSelectionTool
movingOpeningType: 'door' | 'window' | null
isPanning: boolean
cursorColor: string
indicatorLineHeight?: number
indicatorBadgeOffsetX?: number
indicatorBadgeOffsetY?: number
}
export const FloorplanCursorIndicatorOverlay = memo(function FloorplanCursorIndicatorOverlay({
cursorPosition,
cursorAnchorPosition,
floorplanSelectionTool,
movingOpeningType,
isPanning,
cursorColor,
indicatorLineHeight = 18,
indicatorBadgeOffsetX = 14,
indicatorBadgeOffsetY = 14,
}: FloorplanCursorIndicatorOverlayProps) {
const mode = useEditor((state) => state.mode)
const tool = useEditor((state) => state.tool)
const structureLayer = useEditor((state) => state.structureLayer)
const catalogCategory = useEditor((state) => state.catalogCategory)
const activeFloorplanToolConfig = useMemo(() => {
if (movingOpeningType) {
return structureTools.find((entry) => entry.id === movingOpeningType) ?? null
}
if (mode !== 'build' || !tool) {
return null
}
if (tool === 'item' && catalogCategory) {
return furnishTools.find((entry) => entry.catalogCategory === catalogCategory) ?? null
}
return structureTools.find((entry) => entry.id === tool) ?? null
}, [catalogCategory, mode, movingOpeningType, tool])
const indicator = useMemo<FloorplanCursorIndicator | null>(() => {
if (activeFloorplanToolConfig) {
return { kind: 'asset', iconSrc: activeFloorplanToolConfig.iconSrc }
}
if (mode === 'select' && floorplanSelectionTool === 'marquee' && structureLayer !== 'zones') {
return { kind: 'icon', icon: 'mdi:select-drag' }
}
if (mode === 'delete') {
return { kind: 'icon', icon: 'mdi:trash-can-outline' }
}
return null
}, [activeFloorplanToolConfig, floorplanSelectionTool, mode, structureLayer])
const position = mode === 'delete' ? cursorPosition : cursorAnchorPosition
if (!(indicator && position) || isPanning) {
return null
}
return (
<div
aria-hidden="true"
className="pointer-events-none absolute z-20"
style={{ left: position.x, top: position.y }}
>
{mode === 'delete' ? (
<div
className="flex h-8 w-8 items-center justify-center rounded-xl border border-white/5 bg-zinc-900/95 shadow-[0_8px_16px_-4px_rgba(0,0,0,0.3),0_4px_8px_-4px_rgba(0,0,0,0.2)]"
style={{
boxShadow: `0 8px 16px -4px rgba(0,0,0,0.3), 0 4px 8px -4px rgba(0,0,0,0.2), 0 0 18px ${cursorColor}22`,
transform: `translate(${indicatorBadgeOffsetX}px, ${indicatorBadgeOffsetY}px)`,
}}
>
{indicator.kind === 'asset' ? (
<img
alt=""
aria-hidden="true"
className="h-5 w-5 object-contain drop-shadow-[0_2px_4px_rgba(0,0,0,0.5)]"
src={indicator.iconSrc}
/>
) : (
<Icon
aria-hidden="true"
className="drop-shadow-[0_2px_4px_rgba(0,0,0,0.5)]"
color={cursorColor}
height={18}
icon={indicator.icon}
width={18}
/>
)}
</div>
) : (
<>
<div
className="absolute top-0 left-1/2 w-px -translate-x-1/2 -translate-y-full"
style={{
backgroundColor: cursorColor,
boxShadow: `0 0 12px ${cursorColor}55`,
height: indicatorLineHeight,
}}
/>
<div
className="absolute top-0 left-1/2 flex h-8 w-8 items-center justify-center rounded-xl border border-white/5 bg-zinc-900/95 shadow-[0_8px_16px_-4px_rgba(0,0,0,0.3),0_4px_8px_-4px_rgba(0,0,0,0.2)]"
style={{
transform: `translate(-50%, calc(-100% - ${indicatorLineHeight}px))`,
}}
>
{indicator.kind === 'asset' ? (
<img
alt=""
aria-hidden="true"
className="h-5 w-5 object-contain drop-shadow-[0_2px_4px_rgba(0,0,0,0.5)]"
src={indicator.iconSrc}
/>
) : (
<Icon
aria-hidden="true"
className="drop-shadow-[0_2px_4px_rgba(0,0,0,0.5)]"
color="white"
height={18}
icon={indicator.icon}
width={18}
/>
)}
</div>
</>
)}
</div>
)
})
@@ -0,0 +1,92 @@
'use client'
import { memo, useEffect } from 'react'
import useEditor from '../../store/use-editor'
type FloorplanSiteKeyHandlerProps = {
onRestoreGroundLevel: () => void
}
export const FloorplanSiteKeyHandler = memo(function FloorplanSiteKeyHandler({
onRestoreGroundLevel,
}: FloorplanSiteKeyHandlerProps) {
const isFloorplanHovered = useEditor((state) => state.isFloorplanHovered)
const phase = useEditor((state) => state.phase)
const setFloorplanSelectionTool = useEditor((state) => state.setFloorplanSelectionTool)
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
const target = event.target as HTMLElement | null
const isEditableTarget =
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
Boolean(target?.isContentEditable)
if (
isEditableTarget ||
!isFloorplanHovered ||
phase !== 'site' ||
event.metaKey ||
event.ctrlKey ||
event.altKey ||
event.key.toLowerCase() !== 'v'
) {
return
}
setFloorplanSelectionTool('click')
onRestoreGroundLevel()
}
window.addEventListener('keydown', handleKeyDown, true)
return () => {
window.removeEventListener('keydown', handleKeyDown, true)
}
}, [isFloorplanHovered, onRestoreGroundLevel, phase, setFloorplanSelectionTool])
return null
})
type FloorplanDuplicateHotkeyProps = {
hasDuplicatable: boolean
onDuplicateSelected: () => void
}
export const FloorplanDuplicateHotkey = memo(function FloorplanDuplicateHotkey({
hasDuplicatable,
onDuplicateSelected,
}: FloorplanDuplicateHotkeyProps) {
const isFloorplanHovered = useEditor((state) => state.isFloorplanHovered)
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== 'c') {
return
}
if (!(isFloorplanHovered && hasDuplicatable)) {
return
}
const target = event.target as HTMLElement | null
const isEditableTarget =
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
Boolean(target?.isContentEditable)
if (isEditableTarget) {
return
}
event.preventDefault()
onDuplicateSelected()
}
window.addEventListener('keydown', handleKeyDown, true)
return () => {
window.removeEventListener('keydown', handleKeyDown, true)
}
}, [hasDuplicatable, isFloorplanHovered, onDuplicateSelected])
return null
})
@@ -0,0 +1,109 @@
'use client'
import { memo } from 'react'
type SvgLine = {
x1: number
y1: number
x2: number
y2: number
}
type FloorplanDraftLayerProps = {
draftPolygonPoints: string | null
linearDraftSegment: SvgLine | null
polygonDraftPolygonPoints: string | null
polygonDraftPolylinePoints: string | null
polygonDraftClosingSegment: SvgLine | null
draftAnchorPoints: Array<{ x: number; y: number; isPrimary: boolean }>
draftFill: string
draftStroke: string
anchorFill: string
}
export const FloorplanDraftLayer = memo(function FloorplanDraftLayer({
draftPolygonPoints,
linearDraftSegment,
polygonDraftPolygonPoints,
polygonDraftPolylinePoints,
polygonDraftClosingSegment,
draftAnchorPoints,
draftFill,
draftStroke,
anchorFill,
}: FloorplanDraftLayerProps) {
return (
<>
{draftPolygonPoints && (
<polygon
fill={draftFill}
fillOpacity={0.35}
points={draftPolygonPoints}
stroke={draftStroke}
strokeDasharray="0.24 0.12"
strokeWidth="0.07"
vectorEffect="non-scaling-stroke"
/>
)}
{linearDraftSegment && (
<line
stroke={draftStroke}
strokeDasharray="0.2 0.12"
strokeLinecap="round"
strokeOpacity={0.95}
strokeWidth="0.08"
vectorEffect="non-scaling-stroke"
x1={linearDraftSegment.x1}
x2={linearDraftSegment.x2}
y1={linearDraftSegment.y1}
y2={linearDraftSegment.y2}
/>
)}
{polygonDraftPolygonPoints && (
<polygon fill={draftFill} fillOpacity={0.2} points={polygonDraftPolygonPoints} stroke="none" />
)}
{polygonDraftPolylinePoints && (
<polyline
fill="none"
points={polygonDraftPolylinePoints}
stroke={draftStroke}
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="0.08"
vectorEffect="non-scaling-stroke"
/>
)}
{polygonDraftClosingSegment && (
<line
stroke={draftStroke}
strokeDasharray="0.16 0.1"
strokeLinecap="round"
strokeOpacity={0.75}
strokeWidth="0.05"
vectorEffect="non-scaling-stroke"
x1={polygonDraftClosingSegment.x1}
x2={polygonDraftClosingSegment.x2}
y1={polygonDraftClosingSegment.y1}
y2={polygonDraftClosingSegment.y2}
/>
)}
{draftAnchorPoints.map((point, index) => (
<circle
cx={point.x}
cy={point.y}
fill={point.isPrimary ? anchorFill : draftStroke}
fillOpacity={0.95}
key={`polygon-draft-${index}`}
pointerEvents="none"
r={point.isPrimary ? 0.12 : 0.1}
vectorEffect="non-scaling-stroke"
/>
))}
</>
)
})
@@ -0,0 +1,58 @@
'use client'
import { memo } from 'react'
type SvgSelectionBounds = {
x: number
y: number
width: number
height: number
}
type FloorplanMarqueeLayerProps = {
bounds: SvgSelectionBounds | null
cursorColor: string
outlineWidth: number
glowWidth: number
}
export const FloorplanMarqueeLayer = memo(function FloorplanMarqueeLayer({
bounds,
cursorColor,
outlineWidth,
glowWidth,
}: FloorplanMarqueeLayerProps) {
if (!bounds) {
return null
}
return (
<>
<rect
fill={cursorColor}
fillOpacity={0.12}
height={bounds.height}
pointerEvents="none"
stroke={cursorColor}
strokeOpacity={0.26}
strokeWidth={glowWidth}
vectorEffect="non-scaling-stroke"
width={bounds.width}
x={bounds.x}
y={bounds.y}
/>
<rect
fill="none"
height={bounds.height}
pointerEvents="none"
stroke={cursorColor}
strokeOpacity={0.96}
strokeWidth={outlineWidth}
vectorEffect="non-scaling-stroke"
width={bounds.width}
x={bounds.x}
y={bounds.y}
/>
</>
)
})
@@ -0,0 +1,197 @@
'use client'
import { memo } from 'react'
const FLOORPLAN_MEASUREMENT_LINE_WIDTH = 1.35
const FLOORPLAN_MEASUREMENT_LINE_OPACITY = 0.95
const FLOORPLAN_MEASUREMENT_LABEL_FONT_SIZE = 0.15
const FLOORPLAN_MEASUREMENT_LABEL_OPACITY = 0.98
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 }
extensionStart: { x1: number; y1: number; x2: number; y2: number }
extensionEnd: { x1: number; y1: number; x2: number; y2: number }
label: string
labelX: number
labelY: number
labelAngleDeg: number
extensionStroke?: string
isSelected?: boolean
labelFill?: string
showTicks?: boolean
stroke?: string
}
type FloorplanMeasurementPalette = {
measurementStroke: string
}
type FloorplanMeasurementLineProps = {
palette: FloorplanMeasurementPalette
segment: { x1: number; y1: number; x2: number; y2: number }
isSelected?: boolean
dashed?: boolean
stroke?: string
}
function FloorplanMeasurementLine({
palette,
segment,
isSelected,
dashed = false,
stroke,
}: FloorplanMeasurementLineProps) {
const lineOpacity = isSelected
? FLOORPLAN_MEASUREMENT_LINE_OPACITY
: FLOORPLAN_MEASUREMENT_LINE_OPACITY * 0.4
return (
<line
shapeRendering="geometricPrecision"
stroke={stroke ?? palette.measurementStroke}
strokeDasharray={dashed ? FLOORPLAN_MEASUREMENT_EXTENSION_DASH : undefined}
strokeLinecap="round"
strokeOpacity={lineOpacity}
strokeWidth={FLOORPLAN_MEASUREMENT_LINE_WIDTH}
vectorEffect="non-scaling-stroke"
x1={segment.x1}
x2={segment.x2}
y1={segment.y1}
y2={segment.y2}
/>
)
}
type FloorplanMeasurementTickProps = {
palette: FloorplanMeasurementPalette
x: number
y: number
angleDeg: number
isSelected?: boolean
stroke?: string
}
function FloorplanMeasurementTick({
palette,
x,
y,
angleDeg,
isSelected,
stroke,
}: FloorplanMeasurementTickProps) {
const radians = (angleDeg * Math.PI) / 180
const nx = -Math.sin(radians)
const ny = Math.cos(radians)
const half = FLOORPLAN_MEASUREMENT_END_TICK / 2
return (
<line
shapeRendering="geometricPrecision"
stroke={stroke ?? palette.measurementStroke}
strokeLinecap="round"
strokeOpacity={
isSelected ? FLOORPLAN_MEASUREMENT_LINE_OPACITY : FLOORPLAN_MEASUREMENT_LINE_OPACITY * 0.4
}
strokeWidth={FLOORPLAN_MEASUREMENT_LINE_WIDTH}
vectorEffect="non-scaling-stroke"
x1={x - nx * half}
x2={x + nx * half}
y1={y - ny * half}
y2={y + ny * half}
/>
)
}
type FloorplanMeasurementsLayerProps = {
className: string
measurements: LinearMeasurementOverlay[]
palette: FloorplanMeasurementPalette
}
export const FloorplanMeasurementsLayer = memo(function FloorplanMeasurementsLayer({
className,
measurements,
palette,
}: FloorplanMeasurementsLayerProps) {
if (measurements.length === 0) {
return null
}
return (
<>
{measurements.map((measurement) => (
<g className={className} key={measurement.id} pointerEvents="none" style={{ userSelect: 'none' }}>
<FloorplanMeasurementLine
dashed={measurement.dashedExtensions ?? true}
isSelected={measurement.isSelected}
palette={palette}
segment={measurement.extensionStart}
stroke={measurement.extensionStroke}
/>
<FloorplanMeasurementLine
isSelected={measurement.isSelected}
palette={palette}
segment={measurement.dimensionLineStart}
stroke={measurement.stroke}
/>
<FloorplanMeasurementLine
isSelected={measurement.isSelected}
palette={palette}
segment={measurement.dimensionLineEnd}
stroke={measurement.stroke}
/>
<FloorplanMeasurementLine
dashed={measurement.dashedExtensions ?? true}
isSelected={measurement.isSelected}
palette={palette}
segment={measurement.extensionEnd}
stroke={measurement.extensionStroke}
/>
{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}
fillOpacity={
measurement.isSelected
? FLOORPLAN_MEASUREMENT_LABEL_OPACITY
: FLOORPLAN_MEASUREMENT_LABEL_OPACITY * 0.4
}
fontFamily="ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace"
fontSize={FLOORPLAN_MEASUREMENT_LABEL_FONT_SIZE}
fontWeight="600"
textAnchor="middle"
transform={`rotate(${measurement.labelAngleDeg} ${measurement.labelX} ${measurement.labelY}) translate(0, -0.04)`}
x={measurement.labelX}
y={measurement.labelY}
>
{measurement.label}
</text>
</g>
))}
</>
)
})
@@ -0,0 +1,98 @@
'use client'
import { memo } from 'react'
import type { Point2D, RoofNode, RoofSegmentNode } from '@pascal-app/core'
import { toSvgX, toSvgY } from '../svg-paths'
type FloorplanLineSegment = {
start: Point2D
end: Point2D
}
type FloorplanRoofSegmentEntry = {
segment: RoofSegmentNode
points: string
ridgeLine: FloorplanLineSegment | null
}
type FloorplanRoofEntry = {
roof: RoofNode
segments: FloorplanRoofSegmentEntry[]
}
type FloorplanRoofLayerProps = {
highlightedIdSet: ReadonlySet<string>
roofEntries: FloorplanRoofEntry[]
selectedIdSet: ReadonlySet<string>
}
export const FloorplanRoofLayer = memo(function FloorplanRoofLayer({
highlightedIdSet,
roofEntries,
selectedIdSet,
}: FloorplanRoofLayerProps) {
if (roofEntries.length === 0) {
return null
}
return (
<>
{roofEntries.map(({ roof, segments }) => {
const roofSelected = selectedIdSet.has(roof.id)
const roofHighlighted = highlightedIdSet.has(roof.id)
const hasSelectedSegment = segments.some(({ segment }) => selectedIdSet.has(segment.id))
const hasHighlightedSegment = segments.some(({ segment }) =>
highlightedIdSet.has(segment.id),
)
const isRoofActive =
roofSelected || roofHighlighted || hasSelectedSegment || hasHighlightedSegment
return (
<g key={roof.id} pointerEvents="none">
{segments.map(({ points, ridgeLine, segment }) => {
const isSegmentSelected = selectedIdSet.has(segment.id)
const isSegmentHighlighted = highlightedIdSet.has(segment.id)
const isSegmentActive = isSegmentSelected || isSegmentHighlighted
return (
<g key={segment.id}>
<polygon
fill={
isSegmentActive
? 'rgba(14, 165, 233, 0.2)'
: isRoofActive
? 'rgba(14, 165, 233, 0.14)'
: 'rgba(14, 165, 233, 0.08)'
}
points={points}
stroke={
isSegmentActive
? '#0369a1'
: isRoofActive
? '#0ea5e9'
: 'rgba(14, 165, 233, 0.65)'
}
strokeWidth={isSegmentActive ? '2.25' : isRoofActive ? '1.75' : '1.1'}
vectorEffect="non-scaling-stroke"
/>
{ridgeLine ? (
<line
fill="none"
stroke={isSegmentActive ? '#0f172a' : 'rgba(3, 105, 161, 0.75)'}
strokeWidth={isSegmentActive ? '2' : '1.4'}
vectorEffect="non-scaling-stroke"
x1={toSvgX(ridgeLine.start.x)}
x2={toSvgX(ridgeLine.end.x)}
y1={toSvgY(ridgeLine.start.y)}
y2={toSvgY(ridgeLine.end.y)}
/>
) : null}
</g>
)
})}
</g>
)
})}
</>
)
})
@@ -0,0 +1,430 @@
'use client'
import type { Point2D, StairNode, StairSegmentNode } from '@pascal-app/core'
import { memo, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent } from 'react'
import {
buildSvgAnnularSectorPath,
buildSvgArcPath,
buildSvgArrowHeadPoints,
formatSvgPolygonPoints,
getArcPlanPoint,
toSvgX,
toSvgY,
} from '../svg-paths'
type FloorplanPolygonEntry = {
points: string
polygon: Point2D[]
}
type FloorplanStairSegmentEntry = {
segment: StairSegmentNode
points: string
treadBars: FloorplanPolygonEntry[]
}
type FloorplanStairArrowEntry = {
head: Point2D[]
polyline: Point2D[]
}
type FloorplanStairEntry = {
arrow: FloorplanStairArrowEntry | null
hitPolygons: Point2D[][]
stair: StairNode
segments: FloorplanStairSegmentEntry[]
}
type FloorplanPalette = {
deleteFill: string
deleteStroke: string
}
type FloorplanStairLayerProps = {
canFocusStairs: boolean
canSelectStairs: boolean
cursor: string
highlightedIdSet: ReadonlySet<string>
hitStrokeWidth: number
hoveredStairId: StairNode['id'] | null
isDeleteMode: boolean
onStairDoubleClick: (stair: StairNode, event: ReactMouseEvent<SVGElement>) => void
onStairHoverChange: (stairId: StairNode['id'] | null) => void
onStairHoverEnter: (stairId: StairNode['id']) => void
onStairPointerDown: (stairId: StairNode['id'], event: ReactPointerEvent<SVGElement>) => void
onStairSelect: (stairId: StairNode['id'], event: ReactMouseEvent<SVGElement>) => void
palette: FloorplanPalette
selectedIdSet: ReadonlySet<string>
stairEntries: FloorplanStairEntry[]
}
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value))
}
function getNormalizedFloorplanStairSweepAngle(stair: StairNode) {
const stairType = stair.stairType ?? 'straight'
const baseSweepAngle =
stair.sweepAngle ?? (stairType === 'spiral' ? Math.PI * 2 : Math.PI / 2)
if (Math.abs(baseSweepAngle) >= Math.PI * 2) {
return Math.sign(baseSweepAngle || 1) * (Math.PI * 2 - 0.001)
}
return baseSweepAngle
}
export const FloorplanStairLayer = memo(function FloorplanStairLayer({
canFocusStairs,
canSelectStairs,
cursor,
highlightedIdSet,
hitStrokeWidth,
hoveredStairId,
isDeleteMode,
onStairDoubleClick,
onStairHoverChange,
onStairHoverEnter,
onStairPointerDown,
onStairSelect,
palette,
selectedIdSet,
stairEntries,
}: FloorplanStairLayerProps) {
if (stairEntries.length === 0) {
return null
}
return (
<>
{stairEntries.map(({ arrow, hitPolygons, stair, segments }) => {
const stairSelected = selectedIdSet.has(stair.id)
const stairHighlighted = highlightedIdSet.has(stair.id)
const segmentSelected = segments.some(({ segment }) => selectedIdSet.has(segment.id))
const segmentHighlighted = segments.some(({ segment }) => highlightedIdSet.has(segment.id))
const isHovered = hoveredStairId === stair.id
const isDeleteHovered = isDeleteMode && isHovered
const isSelectionActive =
stairSelected || stairHighlighted || segmentSelected || segmentHighlighted
const stairType = stair.stairType ?? 'straight'
const normalizedSweepAngle = getNormalizedFloorplanStairSweepAngle(stair)
const sectorStartAngle = stair.rotation - normalizedSweepAngle / 2
const sectorEndAngle = sectorStartAngle + normalizedSweepAngle
const stairCenter = {
x: stair.position[0],
y: stair.position[2],
}
const innerRadius = Math.max(
stairType === 'spiral' ? 0.05 : 0.2,
stair.innerRadius ?? (stairType === 'spiral' ? 0.2 : 0.9),
)
const outerRadius = innerRadius + stair.width
const centerlineRadius = innerRadius + stair.width / 2
const curvedStroke = isDeleteHovered
? palette.deleteStroke
: isSelectionActive
? '#2563eb'
: 'rgba(31, 41, 55, 0.9)'
const curvedAccent = isDeleteHovered
? palette.deleteStroke
: isSelectionActive
? '#1d4ed8'
: 'rgba(23, 23, 23, 0.96)'
const curvedFill = isDeleteHovered
? palette.deleteFill
: isSelectionActive
? 'rgba(59, 130, 246, 0.16)'
: '#ffffff'
const straightAccent = isDeleteHovered
? palette.deleteStroke
: isSelectionActive
? '#1d4ed8'
: 'rgba(23, 23, 23, 0.96)'
const straightStroke = isDeleteHovered
? palette.deleteStroke
: isSelectionActive
? '#1d4ed8'
: 'rgba(23, 23, 23, 0.88)'
const straightTread = isDeleteHovered
? palette.deleteStroke
: isSelectionActive
? 'rgba(37, 99, 235, 0.78)'
: 'rgba(38, 38, 38, 0.62)'
const straightFill = isDeleteHovered
? palette.deleteFill
: isSelectionActive
? 'rgba(59, 130, 246, 0.08)'
: 'rgba(255, 255, 255, 0.02)'
const curvedOuterLineWidth = isSelectionActive ? '2' : '1.4'
const curvedInnerLineWidth = isSelectionActive ? '1.7' : '1.2'
const stairSymbol =
stairType === 'spiral' ? (
<>
<path
d={buildSvgAnnularSectorPath(
stairCenter,
innerRadius,
outerRadius,
sectorStartAngle,
sectorEndAngle,
)}
fill={curvedFill}
pointerEvents="none"
/>
<path
d={buildSvgArcPath(stairCenter, outerRadius, sectorStartAngle, sectorEndAngle)}
fill="none"
pointerEvents="none"
stroke={curvedStroke}
strokeWidth={curvedOuterLineWidth}
vectorEffect="non-scaling-stroke"
/>
<path
d={buildSvgArcPath(stairCenter, innerRadius, sectorStartAngle, sectorEndAngle)}
fill="none"
pointerEvents="none"
stroke={curvedStroke}
strokeWidth={curvedInnerLineWidth}
vectorEffect="non-scaling-stroke"
/>
{Array.from({ length: Math.max(6, stair.stepCount) }, (_, index) => {
const stepCount = Math.max(6, stair.stepCount)
const stepSweep = normalizedSweepAngle / stepCount
const angle = sectorStartAngle + stepSweep * index
const innerPoint = getArcPlanPoint(stairCenter, innerRadius, angle)
const outerPoint = getArcPlanPoint(stairCenter, outerRadius, angle)
const dashedFromIndex = Math.floor(stepCount * 0.68)
return (
<line
key={`${stair.id}:spiral-step:${index}`}
pointerEvents="none"
stroke={index === stepCount - 1 ? curvedAccent : curvedStroke}
strokeDasharray={index >= dashedFromIndex ? '0.1 0.08' : undefined}
strokeWidth={index === stepCount - 1 ? '1.8' : '1.15'}
vectorEffect="non-scaling-stroke"
x1={toSvgX(innerPoint.x)}
x2={toSvgX(outerPoint.x)}
y1={toSvgY(innerPoint.y)}
y2={toSvgY(outerPoint.y)}
/>
)
})}
<circle
cx={toSvgX(stairCenter.x)}
cy={toSvgY(stairCenter.y)}
fill="#ffffff"
pointerEvents="none"
r={Math.max(innerRadius * 0.18, 0.06)}
stroke={curvedAccent}
strokeWidth="1.2"
vectorEffect="non-scaling-stroke"
/>
{(() => {
const directionAngle = sectorStartAngle + normalizedSweepAngle * 0.86
const arrowPoint = getArcPlanPoint(stairCenter, centerlineRadius, directionAngle)
const tangentAngle =
directionAngle + (normalizedSweepAngle >= 0 ? Math.PI / 2 : -Math.PI / 2)
return (
<polygon
fill={curvedAccent}
key={`${stair.id}:spiral-arrow`}
pointerEvents="none"
points={buildSvgArrowHeadPoints(
arrowPoint,
tangentAngle,
clamp(stair.width * 0.18, 0.12, 0.18),
)}
/>
)
})()}
</>
) : stairType === 'curved' ? (
<>
<path
d={buildSvgAnnularSectorPath(
stairCenter,
innerRadius,
outerRadius,
sectorStartAngle,
sectorEndAngle,
)}
fill={curvedFill}
pointerEvents="none"
/>
<path
d={buildSvgArcPath(stairCenter, outerRadius, sectorStartAngle, sectorEndAngle)}
fill="none"
pointerEvents="none"
stroke={curvedStroke}
strokeWidth={curvedOuterLineWidth}
vectorEffect="non-scaling-stroke"
/>
<path
d={buildSvgArcPath(stairCenter, innerRadius, sectorStartAngle, sectorEndAngle)}
fill="none"
pointerEvents="none"
stroke={curvedStroke}
strokeWidth={curvedInnerLineWidth}
vectorEffect="non-scaling-stroke"
/>
{Array.from({ length: Math.max(4, stair.stepCount) + 1 }, (_, index) => {
const stepCount = Math.max(4, stair.stepCount)
const stepSweep = normalizedSweepAngle / stepCount
const angle = sectorStartAngle + stepSweep * index
const innerPoint = getArcPlanPoint(stairCenter, innerRadius, angle)
const outerPoint = getArcPlanPoint(stairCenter, outerRadius, angle)
return (
<line
key={`${stair.id}:curved-step:${index}`}
pointerEvents="none"
stroke={curvedStroke}
strokeWidth={index === 0 || index === stepCount ? '1.5' : '1.1'}
vectorEffect="non-scaling-stroke"
x1={toSvgX(innerPoint.x)}
x2={toSvgX(outerPoint.x)}
y1={toSvgY(innerPoint.y)}
y2={toSvgY(outerPoint.y)}
/>
)
})}
<path
d={buildSvgArcPath(
stairCenter,
centerlineRadius,
sectorStartAngle + (normalizedSweepAngle / Math.max(4, stair.stepCount)) * 0.55,
sectorEndAngle - (normalizedSweepAngle / Math.max(4, stair.stepCount)) * 0.55,
)}
fill="none"
pointerEvents="none"
stroke={curvedAccent}
strokeDasharray="0.08 0.11"
strokeWidth="1.1"
vectorEffect="non-scaling-stroke"
/>
{(() => {
const stepCount = Math.max(4, stair.stepCount)
const stepSweep = normalizedSweepAngle / stepCount
const arrowAngle = sectorEndAngle - stepSweep * 0.8
const arrowPoint = getArcPlanPoint(stairCenter, centerlineRadius, arrowAngle)
const tangentAngle =
arrowAngle + (normalizedSweepAngle >= 0 ? Math.PI / 2 : -Math.PI / 2)
return (
<polygon
fill={curvedAccent}
key={`${stair.id}:curved-arrow`}
pointerEvents="none"
points={buildSvgArrowHeadPoints(
arrowPoint,
tangentAngle,
clamp(stair.width * 0.16, 0.1, 0.16),
)}
/>
)
})()}
</>
) : (
<>
{segments.map(({ points, segment, treadBars }) => (
<g key={segment.id}>
<polygon
fill={straightFill}
pointerEvents="none"
points={points}
stroke={straightStroke}
strokeWidth={isSelectionActive ? '2' : '1.35'}
vectorEffect="non-scaling-stroke"
/>
{treadBars.map((treadBar, treadIndex) => (
<polygon
fill={straightTread}
key={`${segment.id}:tread:${treadIndex}`}
pointerEvents="none"
points={segment.segmentType === 'landing' ? '' : treadBar.points}
/>
))}
</g>
))}
{arrow?.polyline && arrow.polyline.length >= 2 ? (
<>
<polyline
fill="none"
points={formatSvgPolygonPoints(arrow.polyline)}
pointerEvents="none"
stroke={straightAccent}
strokeWidth="1.15"
vectorEffect="non-scaling-stroke"
/>
<circle
cx={toSvgX(arrow.polyline[0]!.x)}
cy={toSvgY(arrow.polyline[0]!.y)}
fill={straightAccent}
pointerEvents="none"
r="0.045"
/>
<polygon
fill={straightAccent}
points={formatSvgPolygonPoints(arrow.head)}
pointerEvents="none"
/>
</>
) : null}
</>
)
return (
<g
key={stair.id}
onClick={
canSelectStairs
? (event) => {
event.stopPropagation()
onStairSelect(stair.id, event)
}
: undefined
}
onDoubleClick={
canFocusStairs
? (event) => {
event.stopPropagation()
onStairDoubleClick(stair, event)
}
: undefined
}
onPointerEnter={canSelectStairs ? () => onStairHoverEnter(stair.id) : undefined}
onPointerLeave={canSelectStairs ? () => onStairHoverChange(null) : undefined}
onPointerDown={
canFocusStairs && stairSelected
? (event) => {
if (event.button === 0) {
onStairPointerDown(stair.id, event)
}
}
: undefined
}
pointerEvents={canSelectStairs ? undefined : 'none'}
style={canSelectStairs ? { cursor } : undefined}
>
{hitPolygons.map((polygon, polygonIndex) => (
<polygon
fill="transparent"
key={`${stair.id}:hit:${polygonIndex}`}
points={formatSvgPolygonPoints(polygon)}
pointerEvents={canSelectStairs ? 'all' : 'none'}
stroke="transparent"
strokeLinejoin="round"
strokeWidth={hitStrokeWidth}
vectorEffect="non-scaling-stroke"
/>
))}
<title>{stair.name || 'Staircase'}</title>
{stairSymbol}
</g>
)
})}
</>
)
})
@@ -0,0 +1,121 @@
'use client'
import type { Point2D } from '@pascal-app/core'
function toSvgX(value: number) {
return -value
}
function toSvgY(value: number) {
return -value
}
function toSvgPoint(point: Point2D) {
return {
x: toSvgX(point.x),
y: toSvgY(point.y),
}
}
export function formatPolygonPath(points: Point2D[], holes: Point2D[][] = []) {
const formatSubpath = (subpathPoints: Point2D[]) => {
const [firstPoint, ...restPoints] = subpathPoints
if (!firstPoint) {
return null
}
const firstSvgPoint = toSvgPoint(firstPoint)
return [
`M ${firstSvgPoint.x} ${firstSvgPoint.y}`,
...restPoints.map((point) => {
const svgPoint = toSvgPoint(point)
return `L ${svgPoint.x} ${svgPoint.y}`
}),
'Z',
].join(' ')
}
return [points, ...holes].map(formatSubpath).filter(Boolean).join(' ')
}
export function buildSvgPolylinePath(points: Point2D[]) {
if (points.length < 2) {
return null
}
return points
.map((point, index) => {
const svgPoint = toSvgPoint(point)
return `${index === 0 ? 'M' : 'L'} ${svgPoint.x} ${svgPoint.y}`
})
.join(' ')
}
export function getArcPlanPoint(center: Point2D, radius: number, angle: number): Point2D {
return {
x: center.x + Math.cos(angle) * radius,
y: center.y + Math.sin(angle) * radius,
}
}
export function buildSvgArcPath(
center: Point2D,
radius: number,
startAngle: number,
endAngle: number,
) {
const start = getArcPlanPoint(center, radius, startAngle)
const end = getArcPlanPoint(center, radius, endAngle)
const delta = endAngle - startAngle
const largeArcFlag = Math.abs(delta) > Math.PI ? 1 : 0
const sweepFlag = delta >= 0 ? 1 : 0
return `M ${toSvgX(start.x)} ${toSvgY(start.y)} A ${radius} ${radius} 0 ${largeArcFlag} ${sweepFlag} ${toSvgX(end.x)} ${toSvgY(end.y)}`
}
export function buildSvgAnnularSectorPath(
center: Point2D,
innerRadius: number,
outerRadius: number,
startAngle: number,
endAngle: number,
) {
const outerStart = getArcPlanPoint(center, outerRadius, startAngle)
const outerEnd = getArcPlanPoint(center, outerRadius, endAngle)
const innerEnd = getArcPlanPoint(center, innerRadius, endAngle)
const innerStart = getArcPlanPoint(center, innerRadius, startAngle)
const delta = endAngle - startAngle
const largeArcFlag = Math.abs(delta) > Math.PI ? 1 : 0
const sweepFlag = delta >= 0 ? 1 : 0
const reverseSweepFlag = sweepFlag ? 0 : 1
return [
`M ${toSvgX(outerStart.x)} ${toSvgY(outerStart.y)}`,
`A ${outerRadius} ${outerRadius} 0 ${largeArcFlag} ${sweepFlag} ${toSvgX(outerEnd.x)} ${toSvgY(outerEnd.y)}`,
`L ${toSvgX(innerEnd.x)} ${toSvgY(innerEnd.y)}`,
`A ${innerRadius} ${innerRadius} 0 ${largeArcFlag} ${reverseSweepFlag} ${toSvgX(innerStart.x)} ${toSvgY(innerStart.y)}`,
'Z',
].join(' ')
}
export function formatSvgPolygonPoints(points: Point2D[]) {
return points
.map((point) => `${toSvgX(point.x)},${toSvgY(point.y)}`)
.join(' ')
}
export function buildSvgArrowHeadPoints(point: Point2D, angle: number, size: number) {
const left = {
x: point.x - size * Math.cos(angle - Math.PI / 6),
y: point.y - size * Math.sin(angle - Math.PI / 6),
}
const right = {
x: point.x - size * Math.cos(angle + Math.PI / 6),
y: point.y - size * Math.sin(angle + Math.PI / 6),
}
return formatSvgPolygonPoints([point, left, right])
}
export { toSvgPoint, toSvgX, toSvgY }
@@ -6,8 +6,8 @@ import {
type CeilingNode,
DoorNode,
FenceNode,
generateId,
ItemNode,
RoofNode,
RoofSegmentNode,
type SlabNode,
StairNode,
@@ -23,6 +23,8 @@ import { useFrame } from '@react-three/fiber'
import { Move } from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react'
import * as THREE from 'three'
import { duplicateRoofSubtree } from '../../lib/roof-duplication'
import { duplicateStairSubtree } from '../../lib/stair-duplication'
import { sfxEmitter } from '../../lib/sfx-bus'
import useEditor from '../../store/use-editor'
import { NodeActionMenu } from './node-action-menu'
@@ -234,6 +236,16 @@ export function FloatingActionMenu() {
e.stopPropagation()
if (!node?.parentId) return
sfxEmitter.emit('sfx:item-pick')
if (node.type === 'roof') {
try {
duplicateRoofSubtree(node.id as AnyNodeId, { mode: 'move' })
} catch (error) {
console.error('Failed to duplicate roof', error)
}
return
}
useScene.temporal.getState().pause()
let duplicateInfo = structuredClone(node) as any
@@ -254,10 +266,8 @@ export function FloatingActionMenu() {
duplicate = FenceNode.parse(duplicateInfo)
duplicate.start = [duplicate.start[0] + 1, duplicate.start[1] + 1]
duplicate.end = [duplicate.end[0] + 1, duplicate.end[1] + 1]
} else if (node.type === 'roof') {
duplicateInfo.children = []
duplicate = RoofNode.parse(duplicateInfo)
} else if (node.type === 'roof-segment') {
duplicateInfo.id = generateId('rseg')
duplicate = RoofSegmentNode.parse(duplicateInfo)
} else if (node.type === 'stair') {
duplicateInfo.children = []
@@ -286,7 +296,6 @@ export function FloatingActionMenu() {
} else if (duplicate.type === 'fence') {
useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
} else if (
duplicate.type === 'roof' ||
duplicate.type === 'roof-segment' ||
duplicate.type === 'stair' ||
duplicate.type === 'stair-segment'
@@ -300,54 +309,11 @@ export function FloatingActionMenu() {
]
}
if (node.type === 'stair' && duplicate.type === 'stair') {
const nodesState = useScene.getState().nodes
const createOps: { node: AnyNode; parentId?: AnyNodeId }[] = [
{ node: duplicate, parentId: duplicate.parentId as AnyNodeId },
]
for (const childId of node.children ?? []) {
const childNode = nodesState[childId]
if (childNode?.type !== 'stair-segment') {
continue
}
let childDuplicateInfo = structuredClone(childNode) as any
delete childDuplicateInfo.id
childDuplicateInfo.metadata = { ...childDuplicateInfo.metadata }
delete childDuplicateInfo.metadata?.isNew
try {
const childDuplicate = StairSegmentNode.parse(childDuplicateInfo)
createOps.push({ node: childDuplicate, parentId: duplicate.id as AnyNodeId })
} catch (e) {
console.error('Failed to duplicate stair segment', e)
}
}
useScene.getState().createNodes(createOps)
duplicateStairSubtree(node.id as AnyNodeId, { mode: 'move' })
} else {
useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
}
// Duplicate children for roof nodes
if (node.type === 'roof' && node.children) {
const nodesState = useScene.getState().nodes
for (const childId of node.children) {
const childNode = nodesState[childId]
if (childNode && childNode.type === 'roof-segment') {
let childDuplicateInfo = structuredClone(childNode) as any
delete childDuplicateInfo.id
childDuplicateInfo.metadata = { ...childDuplicateInfo.metadata, isNew: true }
try {
const childDuplicate = RoofSegmentNode.parse(childDuplicateInfo)
useScene.getState().createNode(childDuplicate, duplicate.id as AnyNodeId)
} catch (e) {
console.error('Failed to duplicate roof segment', e)
}
}
}
}
// Duplicate children for stair nodes
}
if (
@@ -356,7 +322,6 @@ export function FloatingActionMenu() {
duplicate.type === 'fence' ||
duplicate.type === 'window' ||
duplicate.type === 'door' ||
duplicate.type === 'roof' ||
duplicate.type === 'roof-segment' ||
duplicate.type === 'stair-segment'
) {
@@ -364,7 +329,7 @@ export function FloatingActionMenu() {
} else if (duplicate.type === 'stair') {
setSelection({ selectedIds: [duplicate.id as AnyNodeId] })
}
if (duplicate.type !== 'stair') {
if (duplicate.type !== 'stair' && duplicate.type !== 'roof') {
setSelection({ selectedIds: [] })
}
}
@@ -0,0 +1,113 @@
'use client'
import type { Point2D, ZoneNode as ZoneNodeType } from '@pascal-app/core'
import { isPointInsidePolygon } from '../../lib/floorplan'
import type { WallPlanPoint } from '../tools/wall/wall-drafting'
type ModifierKeys = {
meta: boolean
ctrl: boolean
}
type ZoneHitEntry = {
zone: {
id: ZoneNodeType['id']
}
polygon: Point2D[]
}
type ResolveFloorplanBackgroundSelectionArgs = {
canSelectElementFloorplanGeometry: boolean
canSelectFloorplanZones: boolean
currentSelectedIds: string[]
getFloorplanHitIdAtPoint: (planPoint: WallPlanPoint) => string | null
isWallBuildActive: boolean
modifierKeys: ModifierKeys
planPoint: WallPlanPoint
structureLayer: string
toPoint2D: (point: WallPlanPoint) => Point2D
visibleZonePolygons: ZoneHitEntry[]
}
export type FloorplanBackgroundSelectionResult =
| {
handled: true
kind: 'select-zone'
zoneId: ZoneNodeType['id']
}
| {
handled: true
kind: 'select-elements'
selectedIds: string[]
}
| {
handled: true
kind: 'clear-zones'
}
| {
handled: true
kind: 'clear-elements'
preserveSelection: boolean
}
| {
handled: false
}
export function resolveFloorplanBackgroundSelection({
canSelectElementFloorplanGeometry,
canSelectFloorplanZones,
currentSelectedIds,
getFloorplanHitIdAtPoint,
isWallBuildActive,
modifierKeys,
planPoint,
structureLayer,
toPoint2D,
visibleZonePolygons,
}: ResolveFloorplanBackgroundSelectionArgs): FloorplanBackgroundSelectionResult {
if (canSelectFloorplanZones) {
const zoneHit = visibleZonePolygons.find(({ polygon }) =>
isPointInsidePolygon(toPoint2D(planPoint), polygon),
)
if (zoneHit) {
return {
handled: true,
kind: 'select-zone',
zoneId: zoneHit.zone.id,
}
}
}
if (canSelectElementFloorplanGeometry) {
const hitId = getFloorplanHitIdAtPoint(planPoint)
if (hitId) {
return {
handled: true,
kind: 'select-elements',
selectedIds:
modifierKeys.meta || modifierKeys.ctrl
? currentSelectedIds.includes(hitId)
? currentSelectedIds.filter((selectedId) => selectedId !== hitId)
: [...currentSelectedIds, hitId]
: [hitId],
}
}
}
if (!isWallBuildActive) {
if (structureLayer === 'zones') {
return {
handled: true,
kind: 'clear-zones',
}
}
return {
handled: true,
kind: 'clear-elements',
preserveSelection: modifierKeys.meta || modifierKeys.ctrl,
}
}
return { handled: false }
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,257 @@
'use client'
import { emitter, type FenceNode, isCurvedWall, type WallNode } from '@pascal-app/core'
import { type MouseEvent as ReactMouseEvent, useCallback } from 'react'
import { getPlanPointDistance } from '../../lib/floorplan'
import { snapFenceDraftPoint } from '../tools/fence/fence-drafting'
import type { WallPlanPoint } from '../tools/wall/wall-drafting'
type UseFloorplanBackgroundPlacementArgs = {
activePolygonDraftPoints: WallPlanPoint[]
ceilingDraftPoints: WallPlanPoint[]
clearFencePlacementDraft: () => void
clearRoofPlacementDraft: () => void
emitFloorplanGridEvent: (
type: 'click' | 'double-click' | 'move',
planPoint: WallPlanPoint,
event: ReactMouseEvent<SVGSVGElement>,
) => WallPlanPoint
fenceDraftStart: WallPlanPoint | null
fences: FenceNode[]
findClosestWallPoint: (
point: WallPlanPoint,
walls: WallNode[],
options?: { canUseWall?: (wall: WallNode) => boolean },
) => {
normal: [number, number, number]
point: WallPlanPoint
t: number
wall: WallNode
} | null
floorplanOpeningLocalY: number
getSnappedFloorplanPoint: (point: WallPlanPoint) => WallPlanPoint
handleCeilingPlacementPoint: (point: WallPlanPoint) => void
handleSlabPlacementPoint: (point: WallPlanPoint) => void
handleWallPlacementPoint: (point: WallPlanPoint) => void
handleZonePlacementPoint: (point: WallPlanPoint) => void
isCeilingBuildActive: boolean
isFenceBuildActive: boolean
isFloorplanGridInteractionActive: boolean
isOpeningPlacementActive: boolean
isPolygonBuildActive: boolean
isRoofBuildActive: boolean
isWallBuildActive: boolean
isZoneBuildActive: boolean
roofDraftStart: WallPlanPoint | null
setCursorPoint: React.Dispatch<React.SetStateAction<WallPlanPoint | null>>
setFenceDraftEnd: React.Dispatch<React.SetStateAction<WallPlanPoint | null>>
setFenceDraftStart: React.Dispatch<React.SetStateAction<WallPlanPoint | null>>
setRoofDraftEnd: React.Dispatch<React.SetStateAction<WallPlanPoint | null>>
setRoofDraftStart: React.Dispatch<React.SetStateAction<WallPlanPoint | null>>
shiftPressed: boolean
snapWallDraftPoint: (args: {
point: WallPlanPoint
walls: WallNode[]
start?: WallPlanPoint
angleSnap: boolean
}) => WallPlanPoint
snapPolygonDraftPoint: (args: {
point: WallPlanPoint
start?: WallPlanPoint
angleSnap: boolean
}) => WallPlanPoint
toPoint2D: (point: WallPlanPoint) => { x: number; y: number }
walls: WallNode[]
}
export function useFloorplanBackgroundPlacement({
activePolygonDraftPoints,
ceilingDraftPoints,
clearFencePlacementDraft,
clearRoofPlacementDraft,
emitFloorplanGridEvent,
fenceDraftStart,
fences,
findClosestWallPoint,
floorplanOpeningLocalY,
getSnappedFloorplanPoint,
handleCeilingPlacementPoint,
handleSlabPlacementPoint,
handleWallPlacementPoint,
handleZonePlacementPoint,
isCeilingBuildActive,
isFenceBuildActive,
isFloorplanGridInteractionActive,
isOpeningPlacementActive,
isPolygonBuildActive,
isRoofBuildActive,
isWallBuildActive,
isZoneBuildActive,
roofDraftStart,
setCursorPoint,
setFenceDraftEnd,
setFenceDraftStart,
setRoofDraftEnd,
setRoofDraftStart,
shiftPressed,
snapWallDraftPoint,
snapPolygonDraftPoint,
toPoint2D,
walls,
}: UseFloorplanBackgroundPlacementArgs) {
const handleBackgroundPlacementClick = useCallback(
(
planPoint: WallPlanPoint,
event: ReactMouseEvent<SVGSVGElement>,
draftStart: WallPlanPoint | null,
) => {
if (isOpeningPlacementActive) {
const closest = findClosestWallPoint(planPoint, walls, {
canUseWall: (wall) => !isCurvedWall(wall),
})
if (closest) {
const dx = closest.wall.end[0] - closest.wall.start[0]
const dz = closest.wall.end[1] - closest.wall.start[1]
const length = Math.sqrt(dx * dx + dz * dz)
const distance = closest.t * length
emitter.emit('wall:click', {
node: closest.wall,
point: { x: closest.point[0], y: 0, z: closest.point[1] },
localPosition: [distance, floorplanOpeningLocalY, 0],
normal: closest.normal,
stopPropagation: () => {},
} as any)
}
return true
}
if (isCeilingBuildActive) {
emitFloorplanGridEvent('click', planPoint, event)
const snappedPoint = snapPolygonDraftPoint({
point: planPoint,
start: ceilingDraftPoints[ceilingDraftPoints.length - 1],
angleSnap: ceilingDraftPoints.length > 0 && !shiftPressed,
})
handleCeilingPlacementPoint(snappedPoint)
return true
}
if (isRoofBuildActive) {
const snappedPoint = getSnappedFloorplanPoint(planPoint)
emitFloorplanGridEvent('click', snappedPoint, event)
setCursorPoint(snappedPoint)
if (!roofDraftStart) {
setRoofDraftStart(snappedPoint)
setRoofDraftEnd(snappedPoint)
} else {
clearRoofPlacementDraft()
}
return true
}
if (isFenceBuildActive) {
emitFloorplanGridEvent('click', planPoint, event)
const snappedPoint = snapFenceDraftPoint({
point: planPoint,
walls,
fences,
start: fenceDraftStart ?? undefined,
angleSnap: Boolean(fenceDraftStart) && !shiftPressed,
})
setCursorPoint(snappedPoint)
if (!fenceDraftStart) {
setFenceDraftStart(snappedPoint)
setFenceDraftEnd(snappedPoint)
} else if (
getPlanPointDistance(toPoint2D(fenceDraftStart), toPoint2D(snappedPoint)) >= 0.01
) {
clearFencePlacementDraft()
} else {
setFenceDraftEnd(snappedPoint)
}
return true
}
if (isFloorplanGridInteractionActive) {
const snappedPoint = emitFloorplanGridEvent('click', planPoint, event)
setCursorPoint(snappedPoint)
return true
}
if (isPolygonBuildActive) {
const snappedPoint = snapPolygonDraftPoint({
point: planPoint,
start: activePolygonDraftPoints[activePolygonDraftPoints.length - 1],
angleSnap: activePolygonDraftPoints.length > 0 && !shiftPressed,
})
if (isZoneBuildActive) {
handleZonePlacementPoint(snappedPoint)
} else {
handleSlabPlacementPoint(snappedPoint)
}
return true
}
if (!isWallBuildActive) {
return false
}
const snappedPoint = snapWallDraftPoint({
point: planPoint,
walls,
start: draftStart ?? undefined,
angleSnap: Boolean(draftStart) && !shiftPressed,
})
handleWallPlacementPoint(snappedPoint)
return true
},
[
activePolygonDraftPoints,
ceilingDraftPoints,
clearFencePlacementDraft,
clearRoofPlacementDraft,
emitFloorplanGridEvent,
fenceDraftStart,
fences,
findClosestWallPoint,
floorplanOpeningLocalY,
getSnappedFloorplanPoint,
handleCeilingPlacementPoint,
handleSlabPlacementPoint,
handleZonePlacementPoint,
isCeilingBuildActive,
isFenceBuildActive,
isFloorplanGridInteractionActive,
isOpeningPlacementActive,
isPolygonBuildActive,
isRoofBuildActive,
isWallBuildActive,
isZoneBuildActive,
roofDraftStart,
setCursorPoint,
setFenceDraftEnd,
setFenceDraftStart,
setRoofDraftEnd,
setRoofDraftStart,
shiftPressed,
snapWallDraftPoint,
snapPolygonDraftPoint,
toPoint2D,
walls,
handleWallPlacementPoint,
],
)
return {
handleBackgroundPlacementClick,
}
}
@@ -0,0 +1,171 @@
'use client'
import type {
AnyNode,
CeilingNode,
DoorNode,
ItemNode,
Point2D,
RoofNode,
RoofSegmentNode,
SlabNode,
StairNode,
StairSegmentNode,
WallNode,
WindowNode,
} from '@pascal-app/core'
import { useCallback } from 'react'
import {
getFloorplanHitNodeId,
getFloorplanSelectionIdsInBounds,
} from '../../lib/floorplan/selection-tool'
import type { FloorplanSelectionBounds } from '../../lib/floorplan/types'
import type { WallPlanPoint } from '../tools/wall/wall-drafting'
type OpeningNode = WindowNode | DoorNode
type WallPolygonEntry = {
wall: WallNode
polygon: Point2D[]
}
type OpeningPolygonEntry = {
opening: OpeningNode
polygon: Point2D[]
}
type SlabPolygonEntry = {
slab: SlabNode
polygon: Point2D[]
holes: Point2D[][]
}
type CeilingPolygonEntry = {
ceiling: CeilingNode
polygon: Point2D[]
holes: Point2D[][]
}
type FloorplanRoofEntry = {
roof: RoofNode
segments: Array<{
polygon: Point2D[]
segment: RoofSegmentNode
}>
}
type FloorplanItemEntry = {
item: ItemNode
polygon: Point2D[]
}
type FloorplanStairSegmentEntry = {
polygon: Point2D[]
segment: StairSegmentNode | AnyNode
}
type FloorplanStairEntry = {
hitPolygons: Point2D[][]
stair: StairNode
segments: FloorplanStairSegmentEntry[]
}
type UseFloorplanHitTestingArgs = {
ceilingPolygons: CeilingPolygonEntry[]
displaySlabPolygons: SlabPolygonEntry[]
displayWallPolygons: WallPolygonEntry[]
floorplanItemEntries: FloorplanItemEntry[]
floorplanOpeningHitTolerance: number
floorplanRoofEntries: FloorplanRoofEntry[]
floorplanStairEntries: FloorplanStairEntry[]
floorplanWallHitTolerance: number
getOpeningCenterLine: (polygon: Point2D[]) => { start: Point2D; end: Point2D } | null
isFloorplanItemContextActive: boolean
openingsPolygons: OpeningPolygonEntry[]
phase: 'site' | 'structure' | 'furnish'
toPoint2D: (point: WallPlanPoint) => Point2D
}
export function useFloorplanHitTesting({
ceilingPolygons,
displaySlabPolygons,
displayWallPolygons,
floorplanItemEntries,
floorplanOpeningHitTolerance,
floorplanRoofEntries,
floorplanStairEntries,
floorplanWallHitTolerance,
getOpeningCenterLine,
isFloorplanItemContextActive,
openingsPolygons,
phase,
toPoint2D,
}: UseFloorplanHitTestingArgs) {
const getFloorplanHitIdAtPoint = useCallback(
(planPoint: WallPlanPoint) => {
const point = toPoint2D(planPoint)
return getFloorplanHitNodeId({
point,
ceilings: ceilingPolygons,
phase,
isItemContextActive: isFloorplanItemContextActive,
items: floorplanItemEntries,
openings: openingsPolygons,
roofs: floorplanRoofEntries,
stairs: floorplanStairEntries,
walls: displayWallPolygons,
slabs: displaySlabPolygons,
openingHitTolerance: floorplanOpeningHitTolerance,
wallHitTolerance: floorplanWallHitTolerance,
getOpeningCenterLine,
})
},
[
ceilingPolygons,
displaySlabPolygons,
displayWallPolygons,
floorplanItemEntries,
floorplanOpeningHitTolerance,
floorplanRoofEntries,
floorplanStairEntries,
floorplanWallHitTolerance,
getOpeningCenterLine,
isFloorplanItemContextActive,
openingsPolygons,
phase,
toPoint2D,
],
)
const getFloorplanSelectionIdsInBoundsForArea = useCallback(
(bounds: FloorplanSelectionBounds) =>
getFloorplanSelectionIdsInBounds({
bounds,
ceilings: ceilingPolygons,
phase,
isItemContextActive: isFloorplanItemContextActive,
items: floorplanItemEntries,
walls: displayWallPolygons,
openings: openingsPolygons,
roofs: floorplanRoofEntries,
slabs: displaySlabPolygons,
stairs: floorplanStairEntries,
}),
[
ceilingPolygons,
displaySlabPolygons,
displayWallPolygons,
floorplanItemEntries,
floorplanRoofEntries,
floorplanStairEntries,
isFloorplanItemContextActive,
openingsPolygons,
phase,
],
)
return {
getFloorplanHitIdAtPoint,
getFloorplanSelectionIdsInBounds: getFloorplanSelectionIdsInBoundsForArea,
}
}
@@ -0,0 +1,186 @@
'use client'
import {
type AnyNode,
type BuildingNode,
type CeilingNode,
type DoorNode,
type FenceNode,
type GuideNode,
type LevelNode,
type RoofNode,
type SiteNode,
type SlabNode,
useScene,
type WallNode,
type WindowNode,
type ZoneNode as ZoneNodeType,
} from '@pascal-app/core'
import { useShallow } from 'zustand/react/shallow'
import { collectLevelDescendants } from '../../lib/floorplan'
type OpeningNode = WindowNode | DoorNode
const DEFAULT_BUILDING_POSITION = [0, 0, 0] as const satisfies [number, number, number]
function useLevelChildren<TNode extends AnyNode>(
levelId: LevelNode['id'] | null,
typeGuard: (node: AnyNode | undefined) => node is TNode,
) {
return useScene(
useShallow((state) => {
if (!levelId) {
return [] as TNode[]
}
const levelNode = state.nodes[levelId]
if (!levelNode || levelNode.type !== 'level') {
return [] as TNode[]
}
return levelNode.children.map((childId) => state.nodes[childId]).filter(typeGuard)
}),
)
}
export function useFloorplanSceneData({
buildingId,
levelId,
}: {
buildingId: BuildingNode['id'] | null
levelId: LevelNode['id'] | null
}) {
const levelNode = useScene((state) =>
levelId ? (state.nodes[levelId] as LevelNode | undefined) : undefined,
)
const currentBuildingId =
levelNode?.type === 'level' && levelNode.parentId
? (levelNode.parentId as BuildingNode['id'])
: buildingId
const buildingRotationY = useScene((state) => {
if (!currentBuildingId) return 0
const node = state.nodes[currentBuildingId]
return node?.type === 'building' ? (node.rotation[1] ?? 0) : 0
})
const buildingPosition = useScene((state) => {
if (!currentBuildingId) {
return DEFAULT_BUILDING_POSITION
}
const node = state.nodes[currentBuildingId]
return node?.type === 'building'
? (node.position as [number, number, number])
: DEFAULT_BUILDING_POSITION
})
const site = useScene((state) => {
for (const rootNodeId of state.rootNodeIds) {
const node = state.nodes[rootNodeId]
if (node?.type === 'site') {
return node as SiteNode
}
}
return null
})
const floorplanLevels = useScene(
useShallow((state) => {
if (!currentBuildingId) {
return [] as LevelNode[]
}
const buildingNode = state.nodes[currentBuildingId]
if (!buildingNode || buildingNode.type !== 'building') {
return [] as LevelNode[]
}
return buildingNode.children
.map((childId) => state.nodes[childId])
.filter((node): node is LevelNode => node?.type === 'level')
.sort((a, b) => a.level - b.level)
}),
)
const walls = useLevelChildren(levelId, (node): node is WallNode => node?.type === 'wall')
const fences = useLevelChildren(levelId, (node): node is FenceNode => node?.type === 'fence')
const slabs = useLevelChildren(levelId, (node): node is SlabNode => node?.type === 'slab')
const ceilings = useLevelChildren(
levelId,
(node): node is CeilingNode => node?.type === 'ceiling',
)
const levelGuides = useLevelChildren(levelId, (node): node is GuideNode => node?.type === 'guide')
const zones = useLevelChildren(levelId, (node): node is ZoneNodeType => node?.type === 'zone')
const roofs = useScene(
useShallow((state) => {
if (!levelId) {
return [] as RoofNode[]
}
const nextLevelNode = state.nodes[levelId]
if (!nextLevelNode || nextLevelNode.type !== 'level') {
return [] as RoofNode[]
}
return nextLevelNode.children
.map((childId) => state.nodes[childId])
.filter((node): node is RoofNode => node?.type === 'roof' && node.visible !== false)
}),
)
const openings = useScene(
useShallow((state) => {
if (!levelId) {
return [] as OpeningNode[]
}
const nextLevelNode = state.nodes[levelId]
if (!nextLevelNode || nextLevelNode.type !== 'level') {
return [] as OpeningNode[]
}
const nextWalls = nextLevelNode.children
.map((childId) => state.nodes[childId])
.filter((node): node is WallNode => node?.type === 'wall')
return nextWalls.flatMap((wall) =>
wall.children
.map((childId) => state.nodes[childId])
.filter((node): node is OpeningNode => node?.type === 'window' || node?.type === 'door'),
)
}),
)
const levelDescendantNodes = useScene(
useShallow((state) => {
if (!levelId) {
return [] as AnyNode[]
}
const nextLevelNode = state.nodes[levelId]
if (!nextLevelNode || nextLevelNode.type !== 'level') {
return [] as AnyNode[]
}
return collectLevelDescendants(nextLevelNode, state.nodes as Record<string, AnyNode>)
}),
)
return {
buildingPosition,
buildingRotationY,
currentBuildingId,
ceilings,
fences,
floorplanLevels,
levelDescendantNodes,
levelGuides,
levelNode,
openings,
roofs,
site,
slabs,
walls,
zones,
}
}
@@ -111,15 +111,15 @@ export const CeilingTool: React.FC = () => {
const onGridMove = (event: GridEvent) => {
if (!(cursorRef.current && gridCursorRef.current)) return
const gridX = Math.round(event.position[0] * 2) / 2
const gridZ = Math.round(event.position[2] * 2) / 2
const gridX = Math.round(event.localPosition[0] * 2) / 2
const gridZ = Math.round(event.localPosition[2] * 2) / 2
const gridPosition: [number, number] = [gridX, gridZ]
setCursorPosition(gridPosition)
setLevelY(event.position[1])
setLevelY(event.localPosition[1])
const ceilingY = event.position[1] + CEILING_HEIGHT
const gridY = event.position[1] + GRID_OFFSET
const ceilingY = event.localPosition[1] + CEILING_HEIGHT
const gridY = event.localPosition[1] + GRID_OFFSET
// Calculate snapped display position (bypass snap when Shift is held)
const lastPoint = points[points.length - 1]
@@ -5,6 +5,7 @@ import {
isCurvedWall,
sceneRegistry,
spatialGridManager,
useLiveTransforms,
useScene,
type WallEvent,
} from '@pascal-app/core'
@@ -127,6 +128,10 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
parentId: event.node.id,
wallId: event.node.id,
})
useLiveTransforms.getState().set(movingDoorNode.id, {
position: [clampedX, clampedY, 0],
rotation: itemRotation,
})
if (prevWallId && prevWallId !== event.node.id) markWallDirty(prevWallId)
markWallDirty(event.node.id)
@@ -195,6 +200,10 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
doorMesh.updateMatrixWorld(true)
}
}
useLiveTransforms.getState().set(movingDoorNode.id, {
position: [clampedX, clampedY, 0],
rotation: itemRotation,
})
markWallDirty(event.node.id)
const valid = !hasWallChildOverlap(
@@ -291,6 +300,7 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
}
markWallDirty(event.node.id)
useLiveTransforms.getState().clear(movingDoorNode.id)
useScene.temporal.getState().pause()
sfxEmitter.emit('sfx:item-place')
@@ -302,6 +312,7 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
const onWallLeave = () => {
hideCursor()
useLiveTransforms.getState().clear(movingDoorNode.id)
if (isNew) return
if (currentWallId && currentWallId !== original.parentId) {
markWallDirty(currentWallId)
@@ -318,6 +329,7 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
}
const onCancel = () => {
useLiveTransforms.getState().clear(movingDoorNode.id)
if (isNew) {
useScene.getState().deleteNode(movingDoorNode.id)
if (currentWallId) markWallDirty(currentWallId)
@@ -364,6 +376,7 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
if (original.parentId) markWallDirty(original.parentId)
}
}
useLiveTransforms.getState().clear(movingDoorNode.id)
useScene.temporal.getState().resume()
emitter.off('wall:enter', onWallEnter)
emitter.off('wall:move', onWallMove)
@@ -1,17 +1,25 @@
'use client'
import { type AnyNodeId, type FenceNode, emitter, type GridEvent, useScene } from '@pascal-app/core'
import {
type AnyNodeId,
emitter,
type FenceNode,
type GridEvent,
type LevelNode,
sceneRegistry,
useLiveTransforms,
useScene,
type WallNode,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useRef, useState } from 'react'
import type * as THREE from 'three'
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { snapFenceDraftPoint } from './fence-drafting'
import { CursorSphere } from '../shared/cursor-sphere'
function snap(value: number) {
return Math.round(value * 2) / 2
}
function samePoint(a: [number, number], b: [number, number]) {
return a[0] === b[0] && a[1] === b[1]
}
@@ -24,10 +32,11 @@ type LinkedFenceSnapshot = {
function getLinkedFenceSnapshots(args: {
fenceId: FenceNode['id']
fenceParentId: string | null
originalStart: [number, number]
originalEnd: [number, number]
}) {
const { fenceId, originalStart, originalEnd } = args
const { fenceId, fenceParentId, originalStart, originalEnd } = args
const { nodes } = useScene.getState()
const snapshots: LinkedFenceSnapshot[] = []
@@ -36,6 +45,10 @@ function getLinkedFenceSnapshots(args: {
continue
}
if ((node.parentId ?? null) !== fenceParentId) {
continue
}
if (
!samePoint(node.start, originalStart) &&
!samePoint(node.start, originalEnd) &&
@@ -78,12 +91,14 @@ function getLinkedFenceUpdates(
}
export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
const activatedAtRef = useRef<number>(Date.now())
const previousGridPosRef = useRef<[number, number] | null>(null)
const originalStartRef = useRef<[number, number]>([...node.start] as [number, number])
const originalEndRef = useRef<[number, number]>([...node.end] as [number, number])
const linkedOriginalsRef = useRef(
getLinkedFenceSnapshots({
fenceId: node.id,
fenceParentId: node.parentId ?? null,
originalStart: node.start,
originalEnd: node.end,
}),
@@ -106,10 +121,49 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
const nodeId = nodeIdRef.current
const originalStart = originalStartRef.current
const originalEnd = originalEndRef.current
const levelNode =
node.parentId && useScene.getState().nodes[node.parentId as AnyNodeId]?.type === 'level'
? (useScene.getState().nodes[node.parentId as AnyNodeId] as LevelNode)
: null
const levelChildren = levelNode?.children ?? []
const levelWalls = levelChildren
.map((childId) => useScene.getState().nodes[childId as AnyNodeId])
.filter((child): child is WallNode => child?.type === 'wall')
const levelFences = levelChildren
.map((childId) => useScene.getState().nodes[childId as AnyNodeId])
.filter((child): child is FenceNode => child?.type === 'fence')
useScene.temporal.getState().pause()
let wasCommitted = false
const setMeshOffset = (fenceId: FenceNode['id'], deltaX: number, deltaZ: number) => {
const mesh = sceneRegistry.nodes.get(fenceId) as THREE.Object3D | undefined
if (!mesh) {
return
}
mesh.position.set(deltaX, 0, deltaZ)
}
const setFenceLiveTransform = (fence: FenceNode, deltaX: number, deltaZ: number) => {
const originalCenterX = (fence.start[0] + fence.end[0]) / 2
const originalCenterZ = (fence.start[1] + fence.end[1]) / 2
useLiveTransforms.getState().set(fence.id, {
position: [originalCenterX + deltaX, 0, originalCenterZ + deltaZ],
rotation: 0,
})
}
const clearPreviewState = () => {
setMeshOffset(nodeId, 0, 0)
useLiveTransforms.getState().clear(nodeId)
for (const linkedFence of linkedOriginalsRef.current) {
setMeshOffset(linkedFence.id, 0, 0)
useLiveTransforms.getState().clear(linkedFence.id)
}
}
const applyNodePreview = (updates: Array<{ id: FenceNode['id']; start: [number, number]; end: [number, number] }>) => {
useScene.getState().updateNodes(
updates.map((entry) => ({
@@ -127,21 +181,33 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
const centerX = (nextStart[0] + nextEnd[0]) / 2
const centerZ = (nextStart[1] + nextEnd[1]) / 2
setCursorLocalPos([centerX, 0, centerZ])
applyNodePreview([
{ id: nodeId, start: nextStart, end: nextEnd },
...getLinkedFenceUpdates(
linkedOriginalsRef.current,
originalStart,
originalEnd,
nextStart,
nextEnd,
),
])
const deltaX = nextStart[0] - originalStart[0]
const deltaZ = nextStart[1] - originalStart[1]
setMeshOffset(nodeId, deltaX, deltaZ)
setFenceLiveTransform(node, deltaX, deltaZ)
for (const linkedFence of linkedOriginalsRef.current) {
setMeshOffset(linkedFence.id, deltaX, deltaZ)
setFenceLiveTransform(
{
...node,
id: linkedFence.id,
start: linkedFence.start,
end: linkedFence.end,
},
deltaX,
deltaZ,
)
}
}
const onGridMove = (event: GridEvent) => {
const localX = snap(event.localPosition[0])
const localZ = snap(event.localPosition[2])
const [localX, localZ] = snapFenceDraftPoint({
point: [event.localPosition[0], event.localPosition[2]],
walls: levelWalls,
fences: levelFences,
ignoreFenceIds: [nodeId],
})
if (
previousGridPosRef.current &&
@@ -164,17 +230,15 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
}
const onGridClick = (event: GridEvent) => {
if (Date.now() - activatedAtRef.current < 150) {
event.nativeEvent?.stopPropagation?.()
return
}
const preview = previewRef.current ?? { start: originalStart, end: originalEnd }
wasCommitted = true
// Restore original baseline while paused so the next resume+update
// registers as a single tracked change (undo reverts to original).
applyNodePreview([
{ id: nodeId, start: originalStart, end: originalEnd },
...linkedOriginalsRef.current,
])
useScene.temporal.getState().resume()
applyNodePreview([
{ id: nodeId, start: preview.start, end: preview.end },
@@ -186,6 +250,10 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
preview.end,
),
])
useLiveTransforms.getState().clear(nodeId)
for (const linkedFence of linkedOriginalsRef.current) {
useLiveTransforms.getState().clear(linkedFence.id)
}
useScene.temporal.getState().pause()
sfxEmitter.emit('sfx:item-place')
@@ -195,10 +263,7 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
}
const onCancel = () => {
applyNodePreview([
{ id: nodeId, start: originalStart, end: originalEnd },
...linkedOriginalsRef.current,
])
clearPreviewState()
useViewer.getState().setSelection({ selectedIds: [nodeId] })
useScene.temporal.getState().resume()
markToolCancelConsumed()
@@ -211,17 +276,19 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
return () => {
if (!wasCommitted) {
applyNodePreview([
{ id: nodeId, start: originalStart, end: originalEnd },
...linkedOriginalsRef.current,
])
clearPreviewState()
} else {
useLiveTransforms.getState().clear(nodeId)
for (const linkedFence of linkedOriginalsRef.current) {
useLiveTransforms.getState().clear(linkedFence.id)
}
}
useScene.temporal.getState().resume()
emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick)
emitter.off('tool:cancel', onCancel)
}
}, [exitMoveMode])
}, [exitMoveMode, node])
return (
<group>
@@ -16,15 +16,21 @@ import {
type WallNode,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useFrame } from '@react-three/fiber'
import { useEffect, useRef } from 'react'
import { Html } from '@react-three/drei'
import { createPortal, useFrame } from '@react-three/fiber'
import { useEffect, useRef, useState } from 'react'
import {
BoxGeometry,
Box3,
BufferGeometry,
EdgesGeometry,
Euler,
Float32BufferAttribute,
type Group,
type LineSegments,
Matrix4,
type Mesh,
type Object3D,
PlaneGeometry,
Quaternion,
Vector3,
@@ -46,6 +52,119 @@ 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]
dimensions: [number, number, number]
center: [number, number, number]
}
function getPreviewBoundsFromObject(object: Object3D | null): PreviewBounds | 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()
const hasBounds = { current: 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 (Number.isFinite(scratchBounds.min.x) && Number.isFinite(scratchBounds.max.x)) {
if (!hasBounds.current) {
localBounds.copy(scratchBounds)
hasBounds.current = true
} else {
localBounds.union(scratchBounds)
}
}
}
}
for (const grandchild of child.children) {
expandBounds(grandchild)
}
}
for (const child of object.children) {
expandBounds(child)
}
if (!hasBounds.current) return null
const size = new Vector3()
const center = new Vector3()
localBounds.getSize(size)
localBounds.getCenter(center)
if (size.x <= 0 || size.y <= 0 || size.z <= 0) {
return null
}
return {
min: [localBounds.min.x, localBounds.min.y, localBounds.min.z],
max: [localBounds.max.x, localBounds.max.y, localBounds.max.z],
dimensions: [size.x, size.y, size.z],
center: [center.x, center.y, center.z],
}
}
function getFallbackPreviewBounds(
item: import('@pascal-app/core').ItemNode | null,
asset: AssetInput,
attachTo: AssetInput['attachTo'],
): PreviewBounds {
const dims = item ? getScaledDimensions(item) : (asset.dimensions ?? DEFAULT_DIMENSIONS)
return {
min: [
-dims[0] / 2,
0,
attachTo === 'wall-side' ? -dims[2] : -dims[2] / 2,
],
max: [
dims[0] / 2,
dims[1],
attachTo === 'wall-side' ? 0 : dims[2] / 2,
],
dimensions: dims,
center: [0, dims[1] / 2, attachTo === 'wall-side' ? -dims[2] / 2 : 0],
}
}
// Shared materials for placement cursor - we just change colors, not swap materials
// Note: EdgesGeometry doesn't work with dashed lines, so using solid lines
const edgeMaterial = new LineBasicNodeMaterial({
@@ -55,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,
@@ -82,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))
@@ -89,6 +218,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
config.initialState ?? { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null },
)
const shiftFreeRef = useRef(false)
const previewBoundsSignatureRef = useRef<string | null>(null)
const meshPreviewAppliedRef = useRef(false)
const dimensionBoundsRef = useRef<PreviewBounds | null>(null)
const [measurementTargetState, setMeasurementTargetState] = useState<{
id: string
object: Object3D
} | null>(null)
// Store config callbacks in refs to avoid re-running effect when they change
const configRef = useRef(config)
@@ -96,10 +232,128 @@ 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
const [centerX, centerY, centerZ] = bounds.center
const signature = `${width.toFixed(4)}:${height.toFixed(4)}:${depth.toFixed(4)}:${centerX.toFixed(4)}:${centerY.toFixed(4)}:${centerZ.toFixed(4)}`
if (previewBoundsSignatureRef.current === signature) return
previewBoundsSignatureRef.current = signature
const nextBoxGeometry = new BoxGeometry(width, height, depth)
nextBoxGeometry.translate(centerX, centerY, centerZ)
const nextEdgesGeometry = new EdgesGeometry(nextBoxGeometry)
const nextBasePlaneGeometry = new PlaneGeometry(width, depth)
nextBasePlaneGeometry.rotateX(-Math.PI / 2)
nextBasePlaneGeometry.translate(centerX, 0.01, centerZ)
edgesRef.current.geometry.dispose()
edgesRef.current.geometry = nextEdgesGeometry
basePlaneRef.current.geometry.dispose()
basePlaneRef.current.geometry = nextBasePlaneGeometry
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()
meshPreviewAppliedRef.current = false
const validators = { canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling }
@@ -825,12 +1079,14 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
// ---- Bounding box geometry ----
const draft = draftNode.current
const dims = draft ? getScaledDimensions(draft) : (asset.dimensions ?? DEFAULT_DIMENSIONS)
const boxGeometry = new BoxGeometry(dims[0], dims[1], dims[2])
const wallSideZOffset = asset.attachTo === 'wall-side' ? -dims[2] / 2 : 0
boxGeometry.translate(0, dims[1] / 2, wallSideZOffset)
const edgesGeometry = new EdgesGeometry(boxGeometry)
edgesRef.current.geometry = edgesGeometry
const fallbackBounds = getFallbackPreviewBounds(draft, asset, asset.attachTo)
updatePreviewGeometry(
draft
? (getPreviewBoundsFromObject(sceneRegistry.nodes.get(draft.id) ?? null) ??
fallbackBounds)
: fallbackBounds,
)
updateDimensionGuides(fallbackBounds)
// ---- Undo protection ----
// Undo replaces the entire `nodes` object with a previous snapshot, which doesn't
@@ -874,6 +1130,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
return () => {
tearingDown = true
meshPreviewAppliedRef.current = false
unsubDraftWatch()
// Clear live transform for any remaining draft
if (draftNode.current) {
@@ -919,6 +1176,20 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
if (!draftNode.current) return
const mesh = sceneRegistry.nodes.get(draftNode.current.id)
if (!mesh) return
if (
measurementTargetState?.id !== draftNode.current.id ||
measurementTargetState.object !== mesh
) {
setMeasurementTargetState({ id: draftNode.current.id, object: mesh })
}
if (!meshPreviewAppliedRef.current) {
const previewBounds = getPreviewBoundsFromObject(mesh)
if (previewBounds) {
updatePreviewGeometry(previewBounds)
meshPreviewAppliedRef.current = true
}
}
// Hide wall/ceiling-attached items when between surfaces (only cursor visible)
if (asset.attachTo && placementState.current.surface === 'floor') {
@@ -946,10 +1217,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
draftNode.current.rotation,
)
mesh.position.y = slabElevation
// Cursor group is at the world root (not inside a level group), so add the
// level group's current world Y to convert from level-local to world space.
const levelGroup = sceneRegistry.nodes.get(levelId as AnyNodeId)
cursorGroupRef.current.position.y = slabElevation + (levelGroup?.position.y ?? 0)
}
}
})
@@ -966,12 +1233,119 @@ 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,
]
const measurementTarget =
draftNode.current && measurementTargetState?.id === draftNode.current.id
? measurementTargetState.object
: null
const measurementContent = (
<>
<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>
</>
)
return (
<group ref={cursorGroupRef}>
<lineSegments layers={EDITOR_LAYER} material={edgeMaterial} ref={edgesRef} renderOrder={999}>
<edgesGeometry args={[initialBoxGeometry]} />
</lineSegments>
{measurementTarget ? createPortal(measurementContent, measurementTarget) : measurementContent}
<mesh
geometry={basePlaneGeometry}
layers={EDITOR_LAYER}
@@ -16,6 +16,7 @@ import {
import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useRef, useState } from 'react'
import * as THREE from 'three'
import { clearRoofDuplicateMetadata } from '../../../lib/roof-duplication'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { snapFenceDraftPoint } from '../fence/fence-drafting'
@@ -100,6 +101,7 @@ export const MoveRoofTool: React.FC<{
// resetting the mesh position (it resets on dirty) and from triggering
// expensive merged-mesh CSG rebuilds on every frame.
let wasCommitted = false
let wasCancelled = false
// Track pending rotation — no store updates during drag
let pendingRotation: number = movingNode.rotation as number
@@ -251,11 +253,19 @@ export const MoveRoofTool: React.FC<{
// Resume temporal and apply the final state as a single undoable step.
useScene.temporal.getState().resume()
useScene.getState().updateNode(movingNode.id, {
position: [localX, movingNode.position[1], localZ],
rotation: pendingRotation,
metadata: committedMeta,
})
if (isNew && movingNode.type === 'roof') {
clearRoofDuplicateMetadata(movingNode.id as AnyNodeId, {
position: [localX, movingNode.position[1], localZ],
rotation: pendingRotation,
metadata: committedMeta,
})
} else {
useScene.getState().updateNode(movingNode.id, {
position: [localX, movingNode.position[1], localZ],
rotation: pendingRotation,
metadata: committedMeta,
})
}
useScene.temporal.getState().pause()
@@ -267,6 +277,7 @@ export const MoveRoofTool: React.FC<{
}
const onCancel = () => {
wasCancelled = true
useLiveTransforms.getState().clear(movingNode.id)
if (isNew) {
useScene.getState().deleteNode(movingNode.id)
@@ -325,16 +336,12 @@ export const MoveRoofTool: React.FC<{
// Clear ephemeral live transform
useLiveTransforms.getState().clear(movingNode.id)
if (!wasCommitted) {
if (isNew) {
useScene.getState().deleteNode(movingNode.id)
} else {
useScene.getState().updateNode(movingNode.id, {
position: original.position,
rotation: original.rotation,
metadata: original.metadata,
})
}
if (!(wasCommitted || wasCancelled || isNew)) {
useScene.getState().updateNode(movingNode.id, {
position: original.position,
rotation: original.rotation,
metadata: original.metadata,
})
}
useScene.temporal.getState().resume()
emitter.off('grid:move', onGridMove)
@@ -4,6 +4,7 @@ import {
isCurvedWall,
sceneRegistry,
spatialGridManager,
useLiveTransforms,
useScene,
type WallEvent,
WindowNode,
@@ -144,6 +145,10 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin
parentId: event.node.id,
wallId: event.node.id,
})
useLiveTransforms.getState().set(movingWindowNode.id, {
position: [clampedX, clampedY, 0],
rotation: itemRotation,
})
if (prevWallId && prevWallId !== event.node.id) markWallDirty(prevWallId)
markWallDirty(event.node.id)
@@ -215,6 +220,10 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin
windowMesh.updateMatrixWorld(true)
}
}
useLiveTransforms.getState().set(movingWindowNode.id, {
position: [clampedX, clampedY, 0],
rotation: itemRotation,
})
markWallDirty(event.node.id)
const valid = !hasWallChildOverlap(
@@ -326,6 +335,7 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin
}
markWallDirty(event.node.id)
useLiveTransforms.getState().clear(movingWindowNode.id)
useScene.temporal.getState().pause()
sfxEmitter.emit('sfx:item-place')
@@ -337,6 +347,7 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin
const onWallLeave = () => {
hideCursor()
useLiveTransforms.getState().clear(movingWindowNode.id)
if (isNew) return // No original to restore for duplicates
// Move mode: restore to original position while off-wall
if (currentWallId && currentWallId !== original.parentId) {
@@ -354,6 +365,7 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin
}
const onCancel = () => {
useLiveTransforms.getState().clear(movingWindowNode.id)
if (isNew) {
useScene.getState().deleteNode(movingWindowNode.id)
if (currentWallId) markWallDirty(currentWallId)
@@ -401,6 +413,7 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin
if (original.parentId) markWallDirty(original.parentId)
}
}
useLiveTransforms.getState().clear(movingWindowNode.id)
useScene.temporal.getState().resume()
emitter.off('wall:enter', onWallEnter)
emitter.off('wall:move', onWallMove)
@@ -4,6 +4,7 @@ import {
type AnyNode,
type AnyNodeId,
type RoofNode,
type RoofSurfaceMaterialRole,
RoofNode as RoofNodeSchema,
type RoofSegmentNode,
RoofSegmentNode as RoofSegmentNodeSchema,
@@ -14,6 +15,7 @@ import { Copy, Move, Plus, Trash2 } from 'lucide-react'
import { useCallback } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { sfxEmitter } from '../../../lib/sfx-bus'
import { duplicateRoofSubtree } from '../../../lib/roof-duplication'
import useEditor from '../../../store/use-editor'
import { ActionButton, ActionGroup } from '../controls/action-button'
import { PanelSection } from '../controls/panel-section'
@@ -73,44 +75,15 @@ export function RoofPanel() {
)
const handleDuplicate = useCallback(() => {
if (!node?.parentId) return
if (!node) return
sfxEmitter.emit('sfx:item-pick')
let duplicateInfo = structuredClone(node) as any
delete duplicateInfo.id
duplicateInfo.metadata = { ...duplicateInfo.metadata, isNew: true }
// Offset slightly so it's visible
duplicateInfo.position = [
duplicateInfo.position[0] + 1,
duplicateInfo.position[1],
duplicateInfo.position[2] + 1,
]
try {
const duplicate = RoofNodeSchema.parse(duplicateInfo)
useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
// Also duplicate all child segments
const nodesState = useScene.getState().nodes
const children = node.children || []
for (const childId of children) {
const childNode = nodesState[childId]
if (childNode && childNode.type === 'roof-segment') {
let childDuplicateInfo = structuredClone(childNode) as any
delete childDuplicateInfo.id
childDuplicateInfo.metadata = { ...childDuplicateInfo.metadata, isNew: true }
const childDuplicate = RoofSegmentNodeSchema.parse(childDuplicateInfo)
useScene.getState().createNode(childDuplicate, duplicate.id as AnyNodeId)
}
}
setSelection({ selectedIds: [] })
setMovingNode(duplicate)
duplicateRoofSubtree(node.id as AnyNodeId, { mode: 'move' })
} catch (e) {
console.error('Failed to duplicate roof', e)
}
}, [node, setSelection, setMovingNode])
}, [node])
const handleMove = useCallback(() => {
if (node) {
@@ -9,7 +9,6 @@ import {
type StairSlabOpeningMode,
type StairTopLandingMode,
type StairType,
StairNode as StairNodeSchema,
type StairSegmentNode,
StairSegmentNode as StairSegmentNodeSchema,
useScene,
@@ -17,6 +16,7 @@ import {
import { useViewer } from '@pascal-app/viewer'
import { Copy, Move, Plus, Trash2 } from 'lucide-react'
import { useCallback } from 'react'
import { duplicateStairSubtree } from '../../../lib/stair-duplication'
import { useShallow } from 'zustand/react/shallow'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
@@ -58,7 +58,6 @@ export function StairPanel() {
const setSelection = useViewer((s) => s.setSelection)
const updateNode = useScene((s) => s.updateNode)
const createNode = useScene((s) => s.createNode)
const createNodes = useScene((s) => s.createNodes)
const setMovingNode = useEditor((s) => s.setMovingNode)
const node = useScene((s) =>
@@ -151,46 +150,15 @@ export function StairPanel() {
)
const handleDuplicate = useCallback(() => {
if (!node?.parentId) return
if (!node) return
sfxEmitter.emit('sfx:item-pick')
let duplicateInfo = structuredClone(node) as any
delete duplicateInfo.id
duplicateInfo.metadata = { ...duplicateInfo.metadata }
duplicateInfo.children = []
duplicateInfo.position = [
duplicateInfo.position[0] + 1,
duplicateInfo.position[1],
duplicateInfo.position[2] + 1,
]
try {
const duplicate = StairNodeSchema.parse(duplicateInfo)
const nodesState = useScene.getState().nodes
const children = node.children || []
const createOps: { node: AnyNode; parentId?: AnyNodeId }[] = [
{ node: duplicate, parentId: duplicate.parentId as AnyNodeId },
]
for (const childId of children) {
const childNode = nodesState[childId]
if (childNode && childNode.type === 'stair-segment') {
let childDuplicateInfo = structuredClone(childNode) as any
delete childDuplicateInfo.id
childDuplicateInfo.metadata = { ...childDuplicateInfo.metadata }
const childDuplicate = StairSegmentNodeSchema.parse(childDuplicateInfo)
createOps.push({ node: childDuplicate, parentId: duplicate.id as AnyNodeId })
}
}
createNodes(createOps)
setSelection({ selectedIds: [duplicate.id as AnyNode['id']] })
duplicateStairSubtree(node.id as AnyNodeId, { mode: 'move' })
} catch (e) {
console.error('Failed to duplicate stair', e)
}
}, [createNodes, node, setSelection])
}, [node])
const handleMove = useCallback(() => {
if (node) {
@@ -0,0 +1,263 @@
import type { Point2D } from '@pascal-app/core'
import type { FloorplanLineSegment, FloorplanSelectionBounds } from './types'
export function clampPlanValue(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max)
}
export function rotatePlanVector(x: number, y: number, rotation: number): [number, number] {
const cos = Math.cos(rotation)
const sin = Math.sin(rotation)
return [x * cos + y * sin, -x * sin + y * cos]
}
export function getRotatedRectanglePolygon(
center: Point2D,
width: number,
depth: number,
rotation: number,
): Point2D[] {
const halfWidth = width / 2
const halfDepth = depth / 2
const corners: Array<[number, number]> = [
[-halfWidth, -halfDepth],
[halfWidth, -halfDepth],
[halfWidth, halfDepth],
[-halfWidth, halfDepth],
]
return corners.map(([localX, localY]) => {
const [offsetX, offsetY] = rotatePlanVector(localX, localY, rotation)
return {
x: center.x + offsetX,
y: center.y + offsetY,
}
})
}
export function interpolatePlanPoint(start: Point2D, end: Point2D, t: number): Point2D {
return {
x: start.x + (end.x - start.x) * t,
y: start.y + (end.y - start.y) * t,
}
}
export function getPlanPointDistance(start: Point2D, end: Point2D): number {
return Math.hypot(end.x - start.x, end.y - start.y)
}
export function movePlanPointTowards(start: Point2D, end: Point2D, distance: number): Point2D {
const totalDistance = getPlanPointDistance(start, end)
if (totalDistance <= Number.EPSILON || distance <= 0) {
return start
}
return interpolatePlanPoint(start, end, Math.min(1, distance / totalDistance))
}
export function getThickPlanLinePolygon(line: FloorplanLineSegment, thickness: number): Point2D[] {
const dx = line.end.x - line.start.x
const dy = line.end.y - line.start.y
const length = Math.hypot(dx, dy)
if (length <= Number.EPSILON || thickness <= 0) {
return [line.start, line.end, line.end, line.start]
}
const halfThickness = thickness / 2
const normalX = (-dy / length) * halfThickness
const normalY = (dx / length) * halfThickness
return [
{ x: line.start.x + normalX, y: line.start.y + normalY },
{ x: line.end.x + normalX, y: line.end.y + normalY },
{ x: line.end.x - normalX, y: line.end.y - normalY },
{ x: line.start.x - normalX, y: line.start.y - normalY },
]
}
export function getFloorplanSelectionBounds(
start: [number, number],
end: [number, number],
): FloorplanSelectionBounds {
return {
minX: Math.min(start[0], end[0]),
maxX: Math.max(start[0], end[0]),
minY: Math.min(start[1], end[1]),
maxY: Math.max(start[1], end[1]),
}
}
export function isPointInsideSelectionBounds(point: Point2D, bounds: FloorplanSelectionBounds) {
return (
point.x >= bounds.minX &&
point.x <= bounds.maxX &&
point.y >= bounds.minY &&
point.y <= bounds.maxY
)
}
export function isPointInsidePolygon(point: Point2D, polygon: Point2D[]) {
let isInside = false
for (
let currentIndex = 0, previousIndex = polygon.length - 1;
currentIndex < polygon.length;
previousIndex = currentIndex, currentIndex += 1
) {
const current = polygon[currentIndex]
const previous = polygon[previousIndex]
if (!(current && previous)) {
continue
}
const intersects =
current.y > point.y !== previous.y > point.y &&
point.x <
((previous.x - current.x) * (point.y - current.y)) / (previous.y - current.y) + current.x
if (intersects) {
isInside = !isInside
}
}
return isInside
}
export function isPointInsidePolygonWithHoles(
point: Point2D,
polygon: Point2D[],
holes: Point2D[][] = [],
) {
return (
isPointInsidePolygon(point, polygon) && !holes.some((hole) => isPointInsidePolygon(point, hole))
)
}
function getLineOrientation(start: Point2D, end: Point2D, point: Point2D) {
return (end.x - start.x) * (point.y - start.y) - (end.y - start.y) * (point.x - start.x)
}
function isPointOnSegment(point: Point2D, start: Point2D, end: Point2D) {
const epsilon = 1e-9
return (
Math.abs(getLineOrientation(start, end, point)) <= epsilon &&
point.x >= Math.min(start.x, end.x) - epsilon &&
point.x <= Math.max(start.x, end.x) + epsilon &&
point.y >= Math.min(start.y, end.y) - epsilon &&
point.y <= Math.max(start.y, end.y) + epsilon
)
}
function doSegmentsIntersect(
firstStart: Point2D,
firstEnd: Point2D,
secondStart: Point2D,
secondEnd: Point2D,
) {
const orientation1 = getLineOrientation(firstStart, firstEnd, secondStart)
const orientation2 = getLineOrientation(firstStart, firstEnd, secondEnd)
const orientation3 = getLineOrientation(secondStart, secondEnd, firstStart)
const orientation4 = getLineOrientation(secondStart, secondEnd, firstEnd)
const hasProperIntersection =
((orientation1 > 0 && orientation2 < 0) || (orientation1 < 0 && orientation2 > 0)) &&
((orientation3 > 0 && orientation4 < 0) || (orientation3 < 0 && orientation4 > 0))
if (hasProperIntersection) {
return true
}
return (
isPointOnSegment(secondStart, firstStart, firstEnd) ||
isPointOnSegment(secondEnd, firstStart, firstEnd) ||
isPointOnSegment(firstStart, secondStart, secondEnd) ||
isPointOnSegment(firstEnd, secondStart, secondEnd)
)
}
export function doesPolygonIntersectSelectionBounds(
polygon: Point2D[],
bounds: FloorplanSelectionBounds,
) {
if (polygon.length === 0) {
return false
}
if (polygon.some((point) => isPointInsideSelectionBounds(point, bounds))) {
return true
}
const boundsCorners: [Point2D, Point2D, Point2D, Point2D] = [
{ x: bounds.minX, y: bounds.minY },
{ x: bounds.maxX, y: bounds.minY },
{ x: bounds.maxX, y: bounds.maxY },
{ x: bounds.minX, y: bounds.maxY },
]
if (boundsCorners.some((corner) => isPointInsidePolygon(corner, polygon))) {
return true
}
const boundsEdges = [
[boundsCorners[0], boundsCorners[1]],
[boundsCorners[1], boundsCorners[2]],
[boundsCorners[2], boundsCorners[3]],
[boundsCorners[3], boundsCorners[0]],
] as const
for (let index = 0; index < polygon.length; index += 1) {
const start = polygon[index]
const end = polygon[(index + 1) % polygon.length]
if (!(start && end)) {
continue
}
for (const [edgeStart, edgeEnd] of boundsEdges) {
if (doSegmentsIntersect(start, end, edgeStart, edgeEnd)) {
return true
}
}
}
return false
}
export function getDistanceToWallSegment(
point: Point2D,
start: [number, number],
end: [number, number],
) {
const dx = end[0] - start[0]
const dy = end[1] - start[1]
const lengthSquared = dx * dx + dy * dy
if (lengthSquared <= Number.EPSILON) {
return Math.hypot(point.x - start[0], point.y - start[1])
}
const projection = clampPlanValue(
((point.x - start[0]) * dx + (point.y - start[1]) * dy) / lengthSquared,
0,
1,
)
const projectedX = start[0] + dx * projection
const projectedY = start[1] + dy * projection
return Math.hypot(point.x - projectedX, point.y - projectedY)
}
export function pointMatchesWallPlanPoint(
point: Point2D | undefined,
planPoint: [number, number],
epsilon = 1e-6,
): boolean {
if (!point) {
return false
}
return Math.abs(point.x - planPoint[0]) <= epsilon && Math.abs(point.y - planPoint[1]) <= epsilon
}
@@ -0,0 +1,38 @@
export {
clampPlanValue,
doesPolygonIntersectSelectionBounds,
getDistanceToWallSegment,
getFloorplanSelectionBounds,
getPlanPointDistance,
getRotatedRectanglePolygon,
getThickPlanLinePolygon,
interpolatePlanPoint,
isPointInsidePolygon,
isPointInsidePolygonWithHoles,
isPointInsideSelectionBounds,
movePlanPointTowards,
pointMatchesWallPlanPoint,
rotatePlanVector,
} from './geometry'
export {
buildFloorplanItemEntry,
collectLevelDescendants,
getItemFloorplanTransform,
} from './items'
export {
buildFloorplanStairEntry,
computeFloorplanStairSegmentTransforms,
getFloorplanStairSegmentPolygon,
} from './stairs'
export type {
FloorplanItemEntry,
FloorplanLineSegment,
FloorplanNodeTransform,
FloorplanSelectionBounds,
FloorplanStairArrowEntry,
FloorplanStairEntry,
FloorplanStairSegmentEntry,
LevelDescendantMap,
StairSegmentTransform,
} from './types'
export { getFloorplanWall, getFloorplanWallThickness } from './walls'
+410
View File
@@ -0,0 +1,410 @@
import {
type AnyNode,
type AnyNodeId,
getScaledDimensions,
type ItemNode,
type LevelNode,
sceneRegistry,
useLiveTransforms,
} from '@pascal-app/core'
import type { Object3D } from 'three'
import { Box3, Matrix4, Vector3 } from 'three'
import { getRotatedRectanglePolygon, rotatePlanVector } from './geometry'
import type { FloorplanItemEntry, FloorplanNodeTransform, LevelDescendantMap } from './types'
export function collectLevelDescendants(
levelNode: LevelNode,
nodes: Record<string, AnyNode>,
): AnyNode[] {
const descendants: AnyNode[] = []
const stack = [...levelNode.children].reverse() as AnyNodeId[]
while (stack.length > 0) {
const nodeId = stack.pop()
if (!nodeId) {
continue
}
const node = nodes[nodeId]
if (!node) {
continue
}
descendants.push(node)
if ('children' in node && Array.isArray(node.children) && node.children.length > 0) {
for (let index = node.children.length - 1; index >= 0; index -= 1) {
stack.push(node.children[index] as AnyNodeId)
}
}
}
return descendants
}
export function getItemFloorplanTransform(
item: ItemNode,
nodeById: LevelDescendantMap,
cache: Map<string, FloorplanNodeTransform | null>,
): FloorplanNodeTransform | null {
const cached = cache.get(item.id)
if (cached !== undefined) {
return cached
}
const localRotation = item.rotation[1] ?? 0
let result: FloorplanNodeTransform | null = null
const itemMetadata =
typeof item.metadata === 'object' && item.metadata !== null && !Array.isArray(item.metadata)
? (item.metadata as Record<string, unknown>)
: null
if (itemMetadata?.isTransient === true) {
const live = useLiveTransforms.getState().get(item.id)
if (live) {
result = {
position: {
x: live.position[0],
y: live.position[2],
},
rotation: live.rotation,
}
cache.set(item.id, result)
return result
}
}
if (item.parentId) {
const parentNode = nodeById.get(item.parentId as AnyNodeId)
if (parentNode?.type === 'wall') {
const wallRotation = -Math.atan2(
parentNode.end[1] - parentNode.start[1],
parentNode.end[0] - parentNode.start[0],
)
const wallLocalZ =
item.asset.attachTo === 'wall-side'
? ((parentNode.thickness ?? 0.1) / 2) * (item.side === 'back' ? -1 : 1)
: item.position[2]
const [offsetX, offsetY] = rotatePlanVector(item.position[0], wallLocalZ, wallRotation)
result = {
position: {
x: parentNode.start[0] + offsetX,
y: parentNode.start[1] + offsetY,
},
rotation: wallRotation + localRotation,
}
} else if (parentNode?.type === 'item') {
const parentTransform = getItemFloorplanTransform(parentNode, nodeById, cache)
if (parentTransform) {
const [offsetX, offsetY] = rotatePlanVector(
item.position[0],
item.position[2],
parentTransform.rotation,
)
result = {
position: {
x: parentTransform.position.x + offsetX,
y: parentTransform.position.y + offsetY,
},
rotation: parentTransform.rotation + localRotation,
}
}
} else {
result = {
position: { x: item.position[0], y: item.position[2] },
rotation: localRotation,
}
}
} else {
result = {
position: { x: item.position[0], y: item.position[2] },
rotation: localRotation,
}
}
cache.set(item.id, result)
return result
}
export function buildFloorplanItemEntry(
item: ItemNode,
nodeById: LevelDescendantMap,
cache: Map<string, FloorplanNodeTransform | null>,
): FloorplanItemEntry | null {
const transform = getItemFloorplanTransform(item, nodeById, cache)
if (!transform) {
return null
}
const object = sceneRegistry.nodes.get(item.id)
const realMeshPolygon = object
? getRealMeshFloorplanPolygon(transform, object)
: getCachedMeshFloorplanPolygon(item, transform)
if (!realMeshPolygon) {
return null
}
const dimensionPolygon = getItemDimensionPolygon(item, transform)
return {
dimensionPolygon,
item,
polygon: realMeshPolygon,
usesRealMesh: realMeshPolygon !== null,
}
}
type Point = {
x: 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 {
const metadata =
typeof item.metadata === 'object' && item.metadata !== null && !Array.isArray(item.metadata)
? (item.metadata as Record<string, unknown>)
: null
const rawPolygon = metadata?.meshLocalPlanPolygon
if (!Array.isArray(rawPolygon)) {
return null
}
const polygon = rawPolygon.flatMap((point) => {
if (!Array.isArray(point) || point.length < 2) {
return []
}
const x = point[0]
const y = point[1]
return typeof x === 'number' && typeof y === 'number' ? [{ x, y }] : []
})
return polygon.length >= 3 ? polygon : null
}
function getCachedMeshFloorplanPolygon(item: ItemNode, transform: FloorplanNodeTransform) {
const localPolygon = getCachedLocalMeshPolygon(item)
if (!localPolygon) {
return null
}
return localPolygon.map((corner) => {
const [offsetX, offsetY] = rotatePlanVector(corner.x, corner.y, transform.rotation)
return {
x: transform.position.x + offsetX,
y: transform.position.y + offsetY,
}
})
}
function getRealMeshFloorplanPolygon(transform: FloorplanNodeTransform, object: Object3D) {
const localPolygon = getLocalMeshFloorplanPolygon(object)
if (localPolygon.length === 0) {
return null
}
return localPolygon.map((corner) => {
const [offsetX, offsetY] = rotatePlanVector(corner.x, corner.y, transform.rotation)
return {
x: transform.position.x + offsetX,
y: transform.position.y + offsetY,
}
})
}
function getLocalMeshFloorplanPolygon(object: Object3D): Point[] {
object.updateWorldMatrix(true, true)
const inverseRootMatrix = new Matrix4().copy(object.matrixWorld).invert()
const localMatrix = new Matrix4()
const scratchBounds = new Box3()
const scratchPosition = new Vector3()
const registeredNodeObjects = new Set(sceneRegistry.nodes.values())
const footprintPoints: Point[] = []
const collectPoints = (child: Object3D) => {
if (child !== object && registeredNodeObjects.has(child)) {
return
}
const mesh = child as {
isMesh?: boolean
name?: string
geometry?: {
boundingBox: Box3 | null
computeBoundingBox?: () => void
attributes?: {
position?: {
count: number
getX: (index: number) => number
getY: (index: number) => number
getZ: (index: number) => number
}
}
}
matrixWorld: Matrix4
}
if (mesh.isMesh && mesh.name !== 'cutout' && mesh.geometry) {
if (!mesh.geometry.boundingBox && mesh.geometry.computeBoundingBox) {
mesh.geometry.computeBoundingBox()
}
localMatrix.copy(inverseRootMatrix).multiply(mesh.matrixWorld)
const vertexPositions = mesh.geometry.attributes?.position
if (vertexPositions && vertexPositions.count > 0) {
for (let index = 0; index < vertexPositions.count; index += 1) {
scratchPosition
.set(
vertexPositions.getX(index),
vertexPositions.getY(index),
vertexPositions.getZ(index),
)
.applyMatrix4(localMatrix)
if (Number.isFinite(scratchPosition.x) && Number.isFinite(scratchPosition.z)) {
footprintPoints.push({ x: scratchPosition.x, y: scratchPosition.z })
}
}
} else if (mesh.geometry.boundingBox) {
scratchBounds.copy(mesh.geometry.boundingBox)
scratchBounds.applyMatrix4(localMatrix)
if (Number.isFinite(scratchBounds.min.x) && Number.isFinite(scratchBounds.max.x)) {
footprintPoints.push(
{ x: scratchBounds.min.x, y: scratchBounds.min.z },
{ x: scratchBounds.max.x, y: scratchBounds.min.z },
{ x: scratchBounds.max.x, y: scratchBounds.max.z },
{ x: scratchBounds.min.x, y: scratchBounds.max.z },
)
}
}
}
for (const grandchild of child.children) {
collectPoints(grandchild)
}
}
for (const child of object.children) {
collectPoints(child)
}
return getMinimumAreaBoundingRect(footprintPoints) ?? []
}
function getMinimumAreaBoundingRect(points: Point[]) {
if (points.length === 0) {
return null
}
const hull = getConvexHull(points)
if (hull.length === 0) {
return null
}
if (hull.length === 1) {
const point = hull[0]!
return [point, point, point, point]
}
if (hull.length === 2) {
const [start, end] = hull
return [start!, end!, end!, start!]
}
let bestArea = Number.POSITIVE_INFINITY
let bestRect: Point[] | null = null
for (let index = 0; index < hull.length; index += 1) {
const start = hull[index]!
const end = hull[(index + 1) % hull.length]!
const angle = Math.atan2(end.y - start.y, end.x - start.x)
const cos = Math.cos(-angle)
const sin = Math.sin(-angle)
let minX = Number.POSITIVE_INFINITY
let maxX = Number.NEGATIVE_INFINITY
let minY = Number.POSITIVE_INFINITY
let maxY = Number.NEGATIVE_INFINITY
for (const point of hull) {
const rx = point.x * cos - point.y * sin
const ry = point.x * sin + point.y * cos
minX = Math.min(minX, rx)
maxX = Math.max(maxX, rx)
minY = Math.min(minY, ry)
maxY = Math.max(maxY, ry)
}
const area = (maxX - minX) * (maxY - minY)
if (area >= bestArea) {
continue
}
bestRect = [
{ x: minX, y: minY },
{ x: maxX, y: minY },
{ x: maxX, y: maxY },
{ x: minX, y: maxY },
].map((point) => ({
x: point.x * Math.cos(angle) - point.y * Math.sin(angle),
y: point.x * Math.sin(angle) + point.y * Math.cos(angle),
}))
bestArea = area
}
return bestRect
}
function getConvexHull(points: Point[]) {
const uniquePoints = Array.from(
new Map(points.map((point) => [`${point.x.toFixed(6)}:${point.y.toFixed(6)}`, point])).values(),
).sort((a, b) => (a.x === b.x ? a.y - b.y : a.x - b.x))
if (uniquePoints.length <= 1) {
return uniquePoints
}
const cross = (origin: Point, a: Point, b: Point) =>
(a.x - origin.x) * (b.y - origin.y) - (a.y - origin.y) * (b.x - origin.x)
const lower: Point[] = []
for (const point of uniquePoints) {
while (lower.length >= 2 && cross(lower[lower.length - 2]!, lower[lower.length - 1]!, point) <= 0) {
lower.pop()
}
lower.push(point)
}
const upper: Point[] = []
for (let index = uniquePoints.length - 1; index >= 0; index -= 1) {
const point = uniquePoints[index]!
while (upper.length >= 2 && cross(upper[upper.length - 2]!, upper[upper.length - 1]!, point) <= 0) {
upper.pop()
}
upper.push(point)
}
lower.pop()
upper.pop()
return [...lower, ...upper]
}
@@ -0,0 +1,231 @@
import type {
CeilingNode,
DoorNode,
ItemNode,
Point2D,
RoofNode,
RoofSegmentNode,
SlabNode,
StairNode,
WallNode,
WindowNode,
} from '@pascal-app/core'
import {
doesPolygonIntersectSelectionBounds,
getDistanceToWallSegment,
isPointInsidePolygon,
isPointInsidePolygonWithHoles,
} from './geometry'
import type { FloorplanSelectionBounds } from './types'
type OpeningNode = WindowNode | DoorNode
type OpeningPolygonEntry = {
opening: OpeningNode
polygon: Point2D[]
}
type ItemEntry = {
item: ItemNode
polygon: Point2D[]
}
type StairEntry = {
hitPolygons: Point2D[][]
stair: StairNode
segments: Array<{ polygon: Point2D[] }>
}
type WallEntry = {
wall: WallNode
polygon: Point2D[]
}
type SlabEntry = {
slab: SlabNode
polygon: Point2D[]
holes: Point2D[][]
}
type CeilingEntry = {
ceiling: CeilingNode
polygon: Point2D[]
holes: Point2D[][]
}
type RoofEntry = {
roof: RoofNode
segments: Array<{
polygon: Point2D[]
segment: RoofSegmentNode
}>
}
type FloorplanSelectionToolContext = {
point: Point2D
phase: 'site' | 'structure' | 'furnish'
isItemContextActive: boolean
items: ItemEntry[]
openings: OpeningPolygonEntry[]
stairs: StairEntry[]
walls: WallEntry[]
slabs: SlabEntry[]
ceilings: CeilingEntry[]
roofs: RoofEntry[]
openingHitTolerance: number
wallHitTolerance: number
getOpeningCenterLine: (polygon: Point2D[]) => { start: Point2D; end: Point2D } | null
}
function getItemHitId(context: FloorplanSelectionToolContext) {
if (!context.isItemContextActive) {
return null
}
const itemHit = context.items.find(({ polygon }) => isPointInsidePolygon(context.point, polygon))
return itemHit?.item.id ?? null
}
function getStairHitPolygons(stair: StairEntry) {
return stair.hitPolygons.length > 0
? stair.hitPolygons
: stair.segments.map(({ polygon }) => polygon)
}
export function getFloorplanHitNodeId(context: FloorplanSelectionToolContext) {
if (context.phase === 'structure') {
const openingHit = context.openings.find(({ polygon }) => {
if (isPointInsidePolygon(context.point, polygon)) {
return true
}
const centerLine = context.getOpeningCenterLine(polygon)
if (!centerLine) {
return false
}
return (
getDistanceToWallSegment(
context.point,
[centerLine.start.x, centerLine.start.y],
[centerLine.end.x, centerLine.end.y],
) <= context.openingHitTolerance
)
})
if (openingHit) {
return openingHit.opening.id
}
const stairHit = context.stairs.find((stair) =>
getStairHitPolygons(stair).some((polygon) => isPointInsidePolygon(context.point, polygon)),
)
if (stairHit) {
return stairHit.stair.id
}
const wallHit = context.walls.find(
({ wall, polygon }) =>
isPointInsidePolygon(context.point, polygon) ||
getDistanceToWallSegment(context.point, wall.start, wall.end) <= context.wallHitTolerance,
)
if (wallHit) {
return wallHit.wall.id
}
const roofHit = context.roofs.find(({ segments }) =>
segments.some(({ polygon }) => isPointInsidePolygon(context.point, polygon)),
)
if (roofHit) {
return roofHit.roof.id
}
const ceilingHit = context.ceilings.find(({ polygon, holes }) =>
isPointInsidePolygonWithHoles(context.point, polygon, holes),
)
if (ceilingHit) {
return ceilingHit.ceiling.id
}
const slabHit = context.slabs.find(({ polygon, holes }) =>
isPointInsidePolygonWithHoles(context.point, polygon, holes),
)
if (slabHit) {
return slabHit.slab.id
}
}
return getItemHitId(context)
}
type FloorplanSelectionBoundsContext = {
bounds: FloorplanSelectionBounds
phase: 'site' | 'structure' | 'furnish'
isItemContextActive: boolean
items: ItemEntry[]
walls: WallEntry[]
openings: OpeningPolygonEntry[]
slabs: SlabEntry[]
ceilings: CeilingEntry[]
stairs: StairEntry[]
roofs: RoofEntry[]
}
export function getFloorplanSelectionIdsInBounds({
bounds,
phase,
isItemContextActive,
items,
walls,
openings,
slabs,
ceilings,
stairs,
roofs,
}: FloorplanSelectionBoundsContext) {
const itemIds = isItemContextActive
? items
.filter(({ polygon }) => doesPolygonIntersectSelectionBounds(polygon, bounds))
.map(({ item }) => item.id)
: []
if (phase !== 'structure') {
return itemIds
}
const wallIds = walls
.filter(({ polygon }) => doesPolygonIntersectSelectionBounds(polygon, bounds))
.map(({ wall }) => wall.id)
const openingIds = openings
.filter(({ polygon }) => doesPolygonIntersectSelectionBounds(polygon, bounds))
.map(({ opening }) => opening.id)
const slabIds = slabs
.filter(({ polygon }) => doesPolygonIntersectSelectionBounds(polygon, bounds))
.map(({ slab }) => slab.id)
const ceilingIds = ceilings
.filter(({ polygon }) => doesPolygonIntersectSelectionBounds(polygon, bounds))
.map(({ ceiling }) => ceiling.id)
const stairIds = stairs
.filter((stair) =>
getStairHitPolygons(stair).some((polygon) =>
doesPolygonIntersectSelectionBounds(polygon, bounds),
),
)
.map(({ stair }) => stair.id)
const roofIds = roofs
.filter(({ segments }) =>
segments.some(({ polygon }) => doesPolygonIntersectSelectionBounds(polygon, bounds)),
)
.map(({ roof }) => roof.id)
return Array.from(
new Set([
...itemIds,
...wallIds,
...openingIds,
...slabIds,
...ceilingIds,
...stairIds,
...roofIds,
]),
)
}
+461
View File
@@ -0,0 +1,461 @@
import type { Point2D, StairNode, StairSegmentNode } from '@pascal-app/core'
import {
clampPlanValue,
getPlanPointDistance,
getThickPlanLinePolygon,
interpolatePlanPoint,
movePlanPointTowards,
rotatePlanVector,
} from './geometry'
import type {
FloorplanLineSegment,
FloorplanStairArrowEntry,
FloorplanStairEntry,
FloorplanStairSegmentEntry,
StairSegmentTransform,
} from './types'
const FLOORPLAN_STAIR_OUTLINE_BAND_THICKNESS = 0.05
const FLOORPLAN_STAIR_OUTLINE_MAX_FRACTION = 0.18
const FLOORPLAN_STAIR_TREAD_BAND_THICKNESS = 0.05 * 0.82
const FLOORPLAN_STAIR_TREAD_MIN_THICKNESS = 0.02 * 1.5
const FLOORPLAN_STAIR_ARROW_HEAD_MIN_SIZE = 0.14
const FLOORPLAN_STAIR_ARROW_HEAD_MAX_SIZE = 0.24
type FloorplanStairArrowSide = 'back' | 'front' | 'left' | 'right'
function getFloorplanStairSegmentCenterLine(polygon: Point2D[]): FloorplanLineSegment | null {
if (polygon.length < 4) {
return null
}
const [backLeft, backRight, frontRight, frontLeft] = polygon
return {
start: interpolatePlanPoint(backLeft!, backRight!, 0.5),
end: interpolatePlanPoint(frontLeft!, frontRight!, 0.5),
}
}
function getFloorplanStairInnerPolygon(polygon: Point2D[]): Point2D[] {
if (polygon.length < 4) {
return polygon
}
const [backLeft, backRight, frontRight, frontLeft] = polygon
const outerWidth = getPlanPointDistance(backLeft!, backRight!)
const outerLength = getPlanPointDistance(backLeft!, frontLeft!)
const widthInset = Math.min(
FLOORPLAN_STAIR_OUTLINE_BAND_THICKNESS,
outerWidth * FLOORPLAN_STAIR_OUTLINE_MAX_FRACTION,
)
const lengthInset = Math.min(
FLOORPLAN_STAIR_OUTLINE_BAND_THICKNESS,
outerLength * FLOORPLAN_STAIR_OUTLINE_MAX_FRACTION,
)
const insetBackLeft = movePlanPointTowards(backLeft!, frontLeft!, lengthInset)
const insetBackRight = movePlanPointTowards(backRight!, frontRight!, lengthInset)
const insetFrontLeft = movePlanPointTowards(frontLeft!, backLeft!, lengthInset)
const insetFrontRight = movePlanPointTowards(frontRight!, backRight!, lengthInset)
const innerPolygon = [
movePlanPointTowards(insetBackLeft, insetBackRight, widthInset),
movePlanPointTowards(insetBackRight, insetBackLeft, widthInset),
movePlanPointTowards(insetFrontRight, insetFrontLeft, widthInset),
movePlanPointTowards(insetFrontLeft, insetFrontRight, widthInset),
]
const innerWidth = getPlanPointDistance(innerPolygon[0]!, innerPolygon[1]!)
const innerLength = getPlanPointDistance(innerPolygon[0]!, innerPolygon[3]!)
return innerWidth > 0.06 && innerLength > 0.06 ? innerPolygon : polygon
}
function getFloorplanStairTreadLines(
segment: StairSegmentNode,
innerPolygon: Point2D[],
): FloorplanLineSegment[] {
if (segment.segmentType !== 'stair' || segment.stepCount <= 1 || innerPolygon.length < 4) {
return []
}
const [backLeft, backRight, frontRight, frontLeft] = innerPolygon
const treadLines: FloorplanLineSegment[] = []
for (let stepIndex = 1; stepIndex < segment.stepCount; stepIndex += 1) {
const t = stepIndex / segment.stepCount
treadLines.push({
start: interpolatePlanPoint(backLeft!, frontLeft!, t),
end: interpolatePlanPoint(backRight!, frontRight!, t),
})
}
return treadLines
}
function getFloorplanStairTreadThickness(segment: StairSegmentNode, innerPolygon: Point2D[]) {
if (segment.segmentType !== 'stair' || segment.stepCount <= 1 || innerPolygon.length < 4) {
return 0
}
const innerWidth = getPlanPointDistance(innerPolygon[0]!, innerPolygon[1]!)
const innerLength = getPlanPointDistance(innerPolygon[0]!, innerPolygon[3]!)
const treadRun = innerLength / Math.max(segment.stepCount, 1)
return clampPlanValue(
Math.min(FLOORPLAN_STAIR_TREAD_BAND_THICKNESS, innerWidth * 0.12, treadRun * 0.44),
FLOORPLAN_STAIR_TREAD_MIN_THICKNESS,
FLOORPLAN_STAIR_TREAD_BAND_THICKNESS,
)
}
function getFloorplanStairTreadBars(
segment: StairSegmentNode,
innerPolygon: Point2D[],
treadThickness = getFloorplanStairTreadThickness(segment, innerPolygon),
): Point2D[][] {
const treadLines = getFloorplanStairTreadLines(segment, innerPolygon)
if (treadLines.length === 0 || treadThickness <= 0) {
return []
}
return treadLines.map((line) => getThickPlanLinePolygon(line, treadThickness))
}
function getFloorplanStairSegmentCenterPoint(segment: FloorplanStairSegmentEntry): Point2D | null {
if (segment.centerLine) {
return interpolatePlanPoint(segment.centerLine.start, segment.centerLine.end, 0.5)
}
if (segment.polygon.length < 4) {
return null
}
const [backLeft, backRight, frontRight, frontLeft] = segment.polygon
return {
x: (backLeft!.x + backRight!.x + frontRight!.x + frontLeft!.x) / 4,
y: (backLeft!.y + backRight!.y + frontRight!.y + frontLeft!.y) / 4,
}
}
function getFloorplanStairSegmentSidePoint(
segment: FloorplanStairSegmentEntry,
side: FloorplanStairArrowSide,
): Point2D | null {
if (segment.polygon.length < 4) {
return null
}
const [backLeft, backRight, frontRight, frontLeft] = segment.polygon
switch (side) {
case 'back':
return interpolatePlanPoint(backLeft!, backRight!, 0.5)
case 'front':
return interpolatePlanPoint(frontLeft!, frontRight!, 0.5)
case 'left':
return interpolatePlanPoint(backLeft!, frontLeft!, 0.5)
case 'right':
return interpolatePlanPoint(backRight!, frontRight!, 0.5)
}
}
function getFloorplanStairExitSide(
nextSegment: StairSegmentNode | undefined,
): FloorplanStairArrowSide {
if (!nextSegment) {
return 'front'
}
if (nextSegment.attachmentSide === 'left') {
return 'right'
}
if (nextSegment.attachmentSide === 'right') {
return 'left'
}
return 'front'
}
function appendUniquePlanPoint(points: Point2D[], point: Point2D | null) {
if (!point) {
return
}
const lastPoint = points[points.length - 1]
if (lastPoint && getPlanPointDistance(lastPoint, point) <= 0.001) {
return
}
points.push(point)
}
function getFloorplanArcPoint(center: Point2D, radius: number, angle: number): Point2D {
return {
x: center.x + Math.cos(angle) * radius,
y: center.y + Math.sin(angle) * radius,
}
}
function getNormalizedFloorplanStairSweepAngle(stair: StairNode) {
const stairType = stair.stairType ?? 'straight'
const baseSweepAngle =
stair.sweepAngle ?? (stairType === 'spiral' ? Math.PI * 2 : Math.PI / 2)
if (Math.abs(baseSweepAngle) >= Math.PI * 2) {
return Math.sign(baseSweepAngle || 1) * (Math.PI * 2 - 0.001)
}
return baseSweepAngle
}
function getFloorplanCurvedStairHitPolygon(stair: StairNode): Point2D[] {
const stairType = stair.stairType ?? 'straight'
const sweepAngle = getNormalizedFloorplanStairSweepAngle(stair)
const startAngle = stair.rotation - sweepAngle / 2
const endAngle = startAngle + sweepAngle
const center = {
x: stair.position[0],
y: stair.position[2],
}
const innerRadius = Math.max(
stairType === 'spiral' ? 0.05 : 0.2,
stair.innerRadius ?? (stairType === 'spiral' ? 0.2 : 0.9),
)
const outerRadius = innerRadius + stair.width
const outerArcLength = Math.abs(sweepAngle) * outerRadius
const segmentCount = Math.max(
24,
Math.ceil(Math.abs(sweepAngle) / (Math.PI / 24)),
Math.ceil(outerArcLength / 0.14),
)
const outerPoints: Point2D[] = []
const innerPoints: Point2D[] = []
for (let index = 0; index <= segmentCount; index += 1) {
const t = index / segmentCount
const angle = startAngle + (endAngle - startAngle) * t
outerPoints.push(getFloorplanArcPoint(center, outerRadius, angle))
innerPoints.push(getFloorplanArcPoint(center, innerRadius, angle))
}
return [...outerPoints, ...innerPoints.reverse()]
}
function buildFloorplanStairArrow(
segments: FloorplanStairSegmentEntry[],
): FloorplanStairArrowEntry | null {
const rawPoints: Point2D[] = []
for (let segmentIndex = 0; segmentIndex < segments.length; segmentIndex += 1) {
const segment = segments[segmentIndex]!
const nextSegment = segments[segmentIndex + 1]?.segment
const entryPoint = getFloorplanStairSegmentSidePoint(segment, 'back')
const exitPoint = getFloorplanStairSegmentSidePoint(
segment,
getFloorplanStairExitSide(nextSegment),
)
if (!(entryPoint && exitPoint)) {
continue
}
appendUniquePlanPoint(rawPoints, entryPoint)
const isStraightSegment = getPlanPointDistance(entryPoint, exitPoint) <= 0.001
if (isStraightSegment) {
continue
}
const exitSide = getFloorplanStairExitSide(nextSegment)
if (exitSide === 'front') {
appendUniquePlanPoint(rawPoints, exitPoint)
continue
}
appendUniquePlanPoint(rawPoints, getFloorplanStairSegmentCenterPoint(segment))
appendUniquePlanPoint(rawPoints, exitPoint)
}
if (rawPoints.length < 2) {
return null
}
const firstPoint = rawPoints[0]!
const secondPoint = rawPoints[1]!
const beforeLastPoint = rawPoints[rawPoints.length - 2]!
const lastPoint = rawPoints[rawPoints.length - 1]!
const firstLength = getPlanPointDistance(firstPoint, secondPoint)
const lastLength = getPlanPointDistance(beforeLastPoint, lastPoint)
if (firstLength <= Number.EPSILON || lastLength <= Number.EPSILON) {
return null
}
const polyline = [
movePlanPointTowards(firstPoint, secondPoint, Math.min(0.24, firstLength * 0.18)),
...rawPoints.slice(1, -1),
movePlanPointTowards(lastPoint, beforeLastPoint, Math.min(0.3, lastLength * 0.22)),
]
const arrowTailPoint = polyline[polyline.length - 2]
const arrowTip = polyline[polyline.length - 1]
if (!(arrowTailPoint && arrowTip)) {
return null
}
const arrowBodyLength = getPlanPointDistance(arrowTailPoint, arrowTip)
if (arrowBodyLength <= Number.EPSILON) {
return null
}
const arrowHeadLength = clampPlanValue(
arrowBodyLength * 0.72,
FLOORPLAN_STAIR_ARROW_HEAD_MIN_SIZE,
FLOORPLAN_STAIR_ARROW_HEAD_MAX_SIZE,
)
const arrowHeadBase = movePlanPointTowards(arrowTip, arrowTailPoint, arrowHeadLength)
const directionX = arrowTip.x - arrowHeadBase.x
const directionY = arrowTip.y - arrowHeadBase.y
const directionLength = Math.hypot(directionX, directionY)
if (directionLength <= Number.EPSILON) {
return null
}
const normalX = -directionY / directionLength
const normalY = directionX / directionLength
const arrowHeadHalfWidth = arrowHeadLength * 0.34
return {
head: [
arrowTip,
{
x: arrowHeadBase.x + normalX * arrowHeadHalfWidth,
y: arrowHeadBase.y + normalY * arrowHeadHalfWidth,
},
{
x: arrowHeadBase.x - normalX * arrowHeadHalfWidth,
y: arrowHeadBase.y - normalY * arrowHeadHalfWidth,
},
],
polyline,
}
}
export function computeFloorplanStairSegmentTransforms(
segments: StairSegmentNode[],
): StairSegmentTransform[] {
const transforms: StairSegmentTransform[] = []
let currentX = 0
let currentY = 0
let currentZ = 0
let currentRotation = 0
for (let index = 0; index < segments.length; index += 1) {
const segment = segments[index]!
if (index === 0) {
transforms.push({
position: [currentX, currentY, currentZ],
rotation: currentRotation,
})
continue
}
const previousSegment = segments[index - 1]!
let attachX = 0
let attachY = previousSegment.height
let attachZ = previousSegment.length
let rotationDelta = 0
if (segment.attachmentSide === 'left') {
attachX = previousSegment.width / 2
attachZ = previousSegment.length / 2
rotationDelta = Math.PI / 2
} else if (segment.attachmentSide === 'right') {
attachX = -previousSegment.width / 2
attachZ = previousSegment.length / 2
rotationDelta = -Math.PI / 2
}
const [rotatedAttachX, rotatedAttachZ] = rotatePlanVector(attachX, attachZ, currentRotation)
currentX += rotatedAttachX
currentY += attachY
currentZ += rotatedAttachZ
currentRotation += rotationDelta
transforms.push({
position: [currentX, currentY, currentZ],
rotation: currentRotation,
})
}
return transforms
}
export function getFloorplanStairSegmentPolygon(
stair: StairNode,
segment: StairSegmentNode,
transform: StairSegmentTransform,
): Point2D[] {
const halfWidth = segment.width / 2
const localCorners: Array<[number, number]> = [
[-halfWidth, 0],
[halfWidth, 0],
[halfWidth, segment.length],
[-halfWidth, segment.length],
]
return localCorners.map(([localX, localY]) => {
const [segmentX, segmentY] = rotatePlanVector(localX, localY, transform.rotation)
const groupX = transform.position[0] + segmentX
const groupY = transform.position[2] + segmentY
const [worldOffsetX, worldOffsetY] = rotatePlanVector(groupX, groupY, stair.rotation)
return {
x: stair.position[0] + worldOffsetX,
y: stair.position[2] + worldOffsetY,
}
})
}
export function buildFloorplanStairEntry(
stair: StairNode,
segments: StairSegmentNode[],
): FloorplanStairEntry | null {
const stairType = stair.stairType ?? 'straight'
if (segments.length === 0 && stairType === 'straight') {
return null
}
const transforms = computeFloorplanStairSegmentTransforms(segments)
const segmentEntries = segments.map((segment, index) => {
const polygon = getFloorplanStairSegmentPolygon(stair, segment, transforms[index]!)
const centerLine = getFloorplanStairSegmentCenterLine(polygon)
const innerPolygon = getFloorplanStairInnerPolygon(polygon)
const treadThickness = getFloorplanStairTreadThickness(segment, innerPolygon)
return {
centerLine,
innerPolygon,
segment,
polygon,
treadBars: getFloorplanStairTreadBars(segment, innerPolygon, treadThickness),
treadThickness,
}
})
const hitPolygons =
stairType === 'straight'
? segmentEntries.map(({ polygon }) => polygon)
: [getFloorplanCurvedStairHitPolygon(stair)]
return {
arrow: buildFloorplanStairArrow(segmentEntries),
hitPolygons,
stair,
segments: segmentEntries,
}
}
@@ -0,0 +1,53 @@
import type { AnyNode, ItemNode, Point2D, StairNode, StairSegmentNode } from '@pascal-app/core'
export type FloorplanNodeTransform = {
position: Point2D
rotation: number
}
export type FloorplanLineSegment = {
start: Point2D
end: Point2D
}
export type FloorplanItemEntry = {
dimensionPolygon: Point2D[]
item: ItemNode
polygon: Point2D[]
usesRealMesh: boolean
}
export type FloorplanStairSegmentEntry = {
centerLine: FloorplanLineSegment | null
innerPolygon: Point2D[]
segment: StairSegmentNode
polygon: Point2D[]
treadBars: Point2D[][]
treadThickness: number
}
export type FloorplanStairArrowEntry = {
head: Point2D[]
polyline: Point2D[]
}
export type FloorplanStairEntry = {
arrow: FloorplanStairArrowEntry | null
hitPolygons: Point2D[][]
stair: StairNode
segments: FloorplanStairSegmentEntry[]
}
export type FloorplanSelectionBounds = {
minX: number
maxX: number
minY: number
maxY: number
}
export type StairSegmentTransform = {
position: [number, number, number]
rotation: number
}
export type LevelDescendantMap = ReadonlyMap<string, AnyNode>
@@ -0,0 +1,23 @@
import type { WallNode } from '@pascal-app/core'
const FLOORPLAN_WALL_THICKNESS_SCALE = 1.18
const FLOORPLAN_MIN_VISIBLE_WALL_THICKNESS = 0.13
const FLOORPLAN_MAX_EXTRA_THICKNESS = 0.035
export function getFloorplanWallThickness(wall: WallNode): number {
const baseThickness = wall.thickness ?? 0.1
const scaledThickness = baseThickness * FLOORPLAN_WALL_THICKNESS_SCALE
return Math.min(
baseThickness + FLOORPLAN_MAX_EXTRA_THICKNESS,
Math.max(baseThickness, scaledThickness, FLOORPLAN_MIN_VISIBLE_WALL_THICKNESS),
)
}
export function getFloorplanWall(wall: WallNode): WallNode {
return {
...wall,
// Slightly exaggerate thin walls so the 2D plan stays legible without drifting from BIM data.
thickness: getFloorplanWallThickness(wall),
}
}
+214
View File
@@ -0,0 +1,214 @@
'use client'
import {
type AnyNodeId,
generateId,
type RoofNode,
RoofNode as RoofNodeSchema,
type RoofSegmentNode,
RoofSegmentNode as RoofSegmentNodeSchema,
sceneRegistry,
useScene,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import useEditor from '../store/use-editor'
type DuplicateRoofMode = 'select' | 'move'
type DuplicateRoofOptions = {
mode?: DuplicateRoofMode
offset?: [number, number, number]
parentId?: AnyNodeId
}
type DuplicateRoofResult = {
roof: RoofNode
segmentIds: RoofSegmentNode['id'][]
}
const MOVE_REGISTRY_RETRY_LIMIT = 12
function stripDuplicateFlags(metadata: unknown) {
if (typeof metadata !== 'object' || metadata === null || Array.isArray(metadata)) {
return metadata
}
const nextMeta = { ...(metadata as Record<string, unknown>) }
delete nextMeta.isNew
delete nextMeta.isTransient
return nextMeta
}
function buildDuplicateMetadata(metadata: unknown) {
const cleaned = stripDuplicateFlags(metadata)
if (typeof cleaned !== 'object' || cleaned === null || Array.isArray(cleaned)) {
return { isNew: true }
}
return {
...cleaned,
isNew: true,
}
}
function moveRoofWhenRegistered(roofId: RoofNode['id'], attempt = 0) {
const latestRoof = useScene.getState().nodes[roofId as AnyNodeId]
if (latestRoof?.type !== 'roof') {
return
}
if (sceneRegistry.nodes.has(roofId)) {
useEditor.getState().setMovingNode(latestRoof)
useViewer.getState().setSelection({ selectedIds: [] })
return
}
if (attempt >= MOVE_REGISTRY_RETRY_LIMIT) {
console.warn(`Duplicated roof "${roofId}" did not register before move mode started`)
return
}
requestAnimationFrame(() => moveRoofWhenRegistered(roofId, attempt + 1))
}
export function duplicateRoofSubtree(
sourceRoofId: AnyNodeId,
options: DuplicateRoofOptions = {},
): DuplicateRoofResult {
const { mode = 'move', offset = [1, 0, 1], parentId: explicitParentId } = options
const scene = useScene.getState()
const sourceRoof = scene.nodes[sourceRoofId]
if (!sourceRoof || sourceRoof.type !== 'roof') {
throw new Error(`Node "${sourceRoofId}" is not a roof`)
}
const parentId = explicitParentId ?? (sourceRoof.parentId as AnyNodeId | null)
if (!parentId) {
throw new Error(`Roof "${sourceRoofId}" is missing a parent level`)
}
const roofClone = RoofNodeSchema.parse({
...structuredClone(sourceRoof),
id: generateId('roof'),
parentId,
children: [],
position: [
sourceRoof.position[0] + offset[0],
sourceRoof.position[1] + offset[1],
sourceRoof.position[2] + offset[2],
] as RoofNode['position'],
metadata: buildDuplicateMetadata(sourceRoof.metadata),
})
const segmentClones: RoofSegmentNode[] = []
for (const childId of sourceRoof.children ?? []) {
const childNode = scene.nodes[childId as AnyNodeId]
if (!childNode || childNode.type !== 'roof-segment') {
continue
}
const childClone = RoofSegmentNodeSchema.parse({
...structuredClone(childNode),
id: generateId('rseg'),
parentId: roofClone.id,
metadata: buildDuplicateMetadata(childNode.metadata),
})
segmentClones.push(childClone)
}
scene.createNodes([
{ node: roofClone, parentId },
...segmentClones.map((segment) => ({ node: segment, parentId: roofClone.id as AnyNodeId })),
])
const nextScene = useScene.getState()
const createdRoof = nextScene.nodes[roofClone.id as AnyNodeId]
if (!createdRoof || createdRoof.type !== 'roof') {
throw new Error(`Duplicated roof "${roofClone.id}" was not created`)
}
const createdParent = nextScene.nodes[parentId]
const parentChildIds =
createdParent && 'children' in createdParent && Array.isArray(createdParent.children)
? (createdParent.children as AnyNodeId[])
: null
if (!createdParent || !parentChildIds?.includes(createdRoof.id as AnyNodeId)) {
throw new Error(`Duplicated roof "${createdRoof.id}" was not linked to parent "${parentId}"`)
}
const segmentIds = segmentClones.map((segment) => segment.id)
const createdChildIds = (createdRoof.children ?? []) as AnyNodeId[]
const missingSegmentId = segmentIds.find(
(segmentId) => !createdChildIds.includes(segmentId as AnyNodeId),
)
if (missingSegmentId) {
throw new Error(
`Duplicated roof "${createdRoof.id}" is missing cloned segment "${missingSegmentId}"`,
)
}
const invalidSegment = segmentIds.find((segmentId) => {
const segment = nextScene.nodes[segmentId as AnyNodeId]
return !segment || segment.type !== 'roof-segment' || segment.parentId !== createdRoof.id
})
if (invalidSegment) {
throw new Error(
`Duplicated roof segment "${invalidSegment}" was not linked to roof "${createdRoof.id}"`,
)
}
const setSelection = useViewer.getState().setSelection
if (mode === 'select') {
setSelection({ selectedIds: [createdRoof.id] })
} else {
setSelection({ selectedIds: [createdRoof.id] })
requestAnimationFrame(() => moveRoofWhenRegistered(createdRoof.id))
}
return {
roof: createdRoof,
segmentIds,
}
}
export function clearRoofDuplicateMetadata(
roofId: AnyNodeId,
updates: Partial<Pick<RoofNode, 'position' | 'rotation' | 'metadata'>> = {},
) {
const scene = useScene.getState()
const roofNode = scene.nodes[roofId]
if (!roofNode || roofNode.type !== 'roof') {
return
}
const nodeUpdates: { id: AnyNodeId; data: Record<string, unknown> }[] = [
{
id: roofId,
data: {
...updates,
metadata:
updates.metadata !== undefined
? stripDuplicateFlags(updates.metadata)
: stripDuplicateFlags(roofNode.metadata),
},
},
]
for (const childId of roofNode.children ?? []) {
const childNode = scene.nodes[childId as AnyNodeId]
if (!childNode || childNode.type !== 'roof-segment') {
continue
}
nodeUpdates.push({
id: childNode.id as AnyNodeId,
data: {
metadata: stripDuplicateFlags(childNode.metadata),
},
})
}
scene.updateNodes(nodeUpdates as { id: AnyNodeId; data: Partial<RoofNode | RoofSegmentNode> }[])
}
@@ -0,0 +1,126 @@
import {
generateId,
type AnyNodeId,
sceneRegistry,
type StairNode,
StairNode as StairNodeSchema,
type StairSegmentNode,
StairSegmentNode as StairSegmentNodeSchema,
useScene,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import useEditor from '../store/use-editor'
type DuplicateStairOptions = {
mode?: 'select' | 'move'
offset?: [number, number, number]
parentId?: AnyNodeId
}
type DuplicateStairResult = {
stair: StairNode
segmentIds: StairSegmentNode['id'][]
}
const MOVE_REGISTRY_RETRY_LIMIT = 12
function stripDuplicateFlags(metadata: unknown) {
if (typeof metadata !== 'object' || metadata === null || Array.isArray(metadata)) {
return metadata
}
const nextMeta = { ...(metadata as Record<string, unknown>) }
delete nextMeta.isNew
delete nextMeta.isTransient
return nextMeta
}
function moveStairWhenRegistered(stairId: StairNode['id'], attempt = 0) {
const latestStair = useScene.getState().nodes[stairId as AnyNodeId]
if (!latestStair || latestStair.type !== 'stair') {
return
}
if (sceneRegistry.nodes.has(stairId)) {
useEditor.getState().setMovingNode(latestStair)
useViewer.getState().setSelection({ selectedIds: [] })
return
}
if (attempt >= MOVE_REGISTRY_RETRY_LIMIT) {
console.warn(`Duplicated stair "${stairId}" did not register before move mode started`)
return
}
requestAnimationFrame(() => moveStairWhenRegistered(stairId, attempt + 1))
}
export function duplicateStairSubtree(
sourceStairId: AnyNodeId,
options: DuplicateStairOptions = {},
): DuplicateStairResult {
const { mode = 'move', offset = [1, 0, 1], parentId: explicitParentId } = options
const scene = useScene.getState()
const sourceStair = scene.nodes[sourceStairId]
if (!sourceStair || sourceStair.type !== 'stair') {
throw new Error(`Node "${sourceStairId}" is not a stair`)
}
const parentId = explicitParentId ?? (sourceStair.parentId as AnyNodeId | null)
if (!parentId) {
throw new Error(`Stair "${sourceStairId}" is missing a parent level`)
}
const stairClone = StairNodeSchema.parse({
...structuredClone(sourceStair),
id: generateId('stair'),
parentId,
children: [],
position: [
sourceStair.position[0] + offset[0],
sourceStair.position[1] + offset[1],
sourceStair.position[2] + offset[2],
] as StairNode['position'],
metadata: stripDuplicateFlags(sourceStair.metadata),
})
const segmentClones: StairSegmentNode[] = []
for (const childId of sourceStair.children ?? []) {
const childNode = scene.nodes[childId as AnyNodeId]
if (!childNode || childNode.type !== 'stair-segment') {
continue
}
const childClone = StairSegmentNodeSchema.parse({
...structuredClone(childNode),
id: generateId('sseg'),
parentId: stairClone.id,
metadata: stripDuplicateFlags(childNode.metadata),
})
segmentClones.push(childClone)
}
scene.createNodes([
{ node: stairClone, parentId },
...segmentClones.map((segment) => ({ node: segment, parentId: stairClone.id as AnyNodeId })),
])
const createdStair = useScene.getState().nodes[stairClone.id as AnyNodeId]
if (!createdStair || createdStair.type !== 'stair') {
throw new Error(`Duplicated stair "${stairClone.id}" was not created`)
}
if (mode === 'select') {
useViewer.getState().setSelection({ selectedIds: [createdStair.id] })
} else {
useViewer.getState().setSelection({ selectedIds: [createdStair.id] })
requestAnimationFrame(() => moveStairWhenRegistered(createdStair.id))
}
return {
stair: createdStair,
segmentIds: segmentClones.map((segment) => segment.id),
}
}
@@ -22,6 +22,10 @@ import { MeshStandardNodeMaterial } from 'three/webgpu'
import { useNodeEvents } from '../../../hooks/use-node-events'
import { resolveCdnUrl } from '../../../lib/asset-url'
import { useItemLightPool } from '../../../store/use-item-light-pool'
import {
requestItemMeshMetadataSync,
setItemMeshMetadataSourceRoot,
} from '../../../systems/item-mesh-metadata/sync-request'
import { ErrorBoundary } from '../../error-boundary'
import { NodeRenderer } from '../node-renderer'
@@ -107,6 +111,19 @@ const ModelRenderer = ({ node }: { node: ItemNode }) => {
useScene.getState().dirtyNodes.add(node.parentId as AnyNodeId)
}, [node.parentId])
// Re-sync when GLTF `scene` or external `metadata` edits should invalidate cached footprint/bounds.
// biome-ignore lint/correctness/useExhaustiveDependencies: intentional — asset load and metadata drive mesh-metadata sync
useEffect(() => {
const cloneRoot = ref.current
if (!cloneRoot) return
setItemMeshMetadataSourceRoot(node.id, cloneRoot)
requestItemMeshMetadataSync(node.id)
return () => {
setItemMeshMetadataSourceRoot(node.id, null)
}
}, [node.id, node.metadata, scene])
useEffect(() => {
const interactive = interactiveRef.current
if (!interactive) return
@@ -18,6 +18,7 @@ import * as THREE from 'three/webgpu'
import useViewer from '../../store/use-viewer'
import { GuideSystem } from '../../systems/guide/guide-system'
import { ItemLightSystem } from '../../systems/item-light/item-light-system'
import { ItemMeshMetadataSystem } from '../../systems/item-mesh-metadata/item-mesh-metadata-system'
import { LevelSystem } from '../../systems/level/level-system'
import { ScanSystem } from '../../systems/scan/scan-system'
import { WallCutout } from '../../systems/wall/wall-cutout'
@@ -172,6 +173,7 @@ const Viewer: React.FC<ViewerProps> = ({
<GPUDeviceWatcher />
<ItemLightSystem />
<ItemMeshMetadataSystem />
{selectionManager === 'default' && <SelectionManager />}
{perf && <PerfMonitor />}
{children}
@@ -0,0 +1,226 @@
import type { Object3D } from 'three'
import { Box3, Matrix4, Vector3 } from 'three'
type Point = { x: number; y: number }
export type MeshLocalBounds = {
min: [number, number, number]
max: [number, number, number]
}
/** Plan footprint in the item root's horizontal (x, z) plane — stored as floorplan polygon. */
export function computePlanFootprintPolygonLocal(object: Object3D): Point[] {
object.updateWorldMatrix(true, true)
const inverseRootMatrix = new Matrix4().copy(object.matrixWorld).invert()
const localMatrix = new Matrix4()
const scratchBounds = new Box3()
const scratchPosition = new Vector3()
const footprintPoints: Point[] = []
const collectPoints = (child: Object3D) => {
const mesh = child as Object3D & {
isMesh?: boolean
name?: string
geometry?: {
boundingBox: Box3 | null
computeBoundingBox?: () => void
attributes?: {
position?: {
count: number
getX: (index: number) => number
getY: (index: number) => number
getZ: (index: number) => number
}
}
}
matrixWorld: Matrix4
}
if (mesh.isMesh && mesh.name !== 'cutout' && mesh.geometry) {
if (!mesh.geometry.boundingBox && mesh.geometry.computeBoundingBox) {
mesh.geometry.computeBoundingBox()
}
localMatrix.copy(inverseRootMatrix).multiply(mesh.matrixWorld)
const vertexPositions = mesh.geometry.attributes?.position
if (vertexPositions && vertexPositions.count > 0) {
for (let index = 0; index < vertexPositions.count; index += 1) {
scratchPosition
.set(
vertexPositions.getX(index),
vertexPositions.getY(index),
vertexPositions.getZ(index),
)
.applyMatrix4(localMatrix)
if (Number.isFinite(scratchPosition.x) && Number.isFinite(scratchPosition.z)) {
footprintPoints.push({ x: scratchPosition.x, y: scratchPosition.z })
}
}
} else if (mesh.geometry.boundingBox) {
scratchBounds.copy(mesh.geometry.boundingBox)
scratchBounds.applyMatrix4(localMatrix)
if (Number.isFinite(scratchBounds.min.x) && Number.isFinite(scratchBounds.max.x)) {
footprintPoints.push(
{ x: scratchBounds.min.x, y: scratchBounds.min.z },
{ x: scratchBounds.max.x, y: scratchBounds.min.z },
{ x: scratchBounds.max.x, y: scratchBounds.max.z },
{ x: scratchBounds.min.x, y: scratchBounds.max.z },
)
}
}
}
for (const grandchild of child.children) {
collectPoints(grandchild)
}
}
for (const child of object.children) {
collectPoints(child)
}
return getMinimumAreaBoundingRect(footprintPoints) ?? []
}
export function computeMeshLocalBoundsFromObject(object: Object3D): MeshLocalBounds | 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
const hull = getConvexHull(points)
if (hull.length < 3) return hull
let bestArea = Number.POSITIVE_INFINITY
let bestRect: Point[] | null = null
for (let index = 0; index < hull.length; index += 1) {
const nextIndex = (index + 1) % hull.length
const current = hull[index]!
const next = hull[nextIndex]!
const angle = Math.atan2(next.y - current.y, next.x - current.x)
const cos = Math.cos(-angle)
const sin = Math.sin(-angle)
let minX = Number.POSITIVE_INFINITY
let maxX = Number.NEGATIVE_INFINITY
let minY = Number.POSITIVE_INFINITY
let maxY = Number.NEGATIVE_INFINITY
for (const point of hull) {
const rx = point.x * cos - point.y * sin
const ry = point.x * sin + point.y * cos
minX = Math.min(minX, rx)
maxX = Math.max(maxX, rx)
minY = Math.min(minY, ry)
maxY = Math.max(maxY, ry)
}
const area = (maxX - minX) * (maxY - minY)
if (area >= bestArea) continue
bestArea = area
const unrotate = (x: number, y: number): Point => ({
x: x * Math.cos(angle) - y * Math.sin(angle),
y: x * Math.sin(angle) + y * Math.cos(angle),
})
bestRect = [
unrotate(minX, minY),
unrotate(maxX, minY),
unrotate(maxX, maxY),
unrotate(minX, maxY),
]
}
return bestRect
}
function getConvexHull(points: Point[]) {
if (points.length <= 1) return points
const sorted = [...points].sort((a, b) => (a.x === b.x ? a.y - b.y : a.x - b.x))
const cross = (o: Point, a: Point, b: Point) =>
(a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x)
const lower: Point[] = []
for (const point of sorted) {
while (
lower.length >= 2 &&
cross(lower[lower.length - 2]!, lower[lower.length - 1]!, point) <= 0
) {
lower.pop()
}
lower.push(point)
}
const upper: Point[] = []
for (let index = sorted.length - 1; index >= 0; index -= 1) {
const point = sorted[index]!
while (
upper.length >= 2 &&
cross(upper[upper.length - 2]!, upper[upper.length - 1]!, point) <= 0
) {
upper.pop()
}
upper.push(point)
}
lower.pop()
upper.pop()
return [...lower, ...upper]
}
@@ -0,0 +1,101 @@
'use client'
import { type AnyNode, type AnyNodeId, sceneRegistry, useScene } from '@pascal-app/core'
import { useFrame } from '@react-three/fiber'
import type { Object3D } from 'three'
import {
computeMeshLocalBoundsFromObject,
computePlanFootprintPolygonLocal,
} from './compute-item-mesh-metadata'
import { drainItemMeshMetadataSyncRequests, getItemMeshMetadataSourceRoot } from './sync-request'
function isMetadataUnchanged(
nextPolygon: [number, number][] | null,
nextBounds: { min: [number, number, number]; max: [number, number, number] } | null,
metadata: Record<string, unknown>,
): boolean {
const currentPolygon = metadata.meshLocalPlanPolygon
const currentBounds =
typeof metadata.meshLocalBounds === 'object' &&
metadata.meshLocalBounds !== null &&
!Array.isArray(metadata.meshLocalBounds)
? (metadata.meshLocalBounds as { min?: unknown; max?: unknown })
: null
const polygonUnchanged =
(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],
))
const boundsUnchanged =
(nextBounds === null && (currentBounds === undefined || currentBounds === null)) ||
(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])
return polygonUnchanged && boundsUnchanged
}
function trySyncItemMeshMetadata(itemId: string, nodes: Record<string, AnyNode | undefined>) {
const node = nodes[itemId]
if (!node || node.type !== 'item') return
const root =
getItemMeshMetadataSourceRoot(itemId) ??
(sceneRegistry.nodes.get(itemId) as Object3D | undefined)
if (!root) return
const polygon = computePlanFootprintPolygonLocal(root)
const bounds = computeMeshLocalBoundsFromObject(root)
if (polygon.length < 3 && !bounds) return
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>)
: {}
if (isMetadataUnchanged(nextPolygon, nextBounds, metadata)) return
useScene.getState().updateNode(itemId as AnyNodeId, {
metadata: {
...metadata,
...(nextPolygon ? { meshLocalPlanPolygon: nextPolygon } : {}),
...(nextBounds ? { meshLocalBounds: nextBounds } : {}),
},
})
}
/**
* Writes `meshLocalPlanPolygon` / `meshLocalBounds` from loaded item meshes.
* ModelRenderer requests sync via `requestItemMeshMetadataSync` when GLTF is ready.
*/
export function ItemMeshMetadataSystem() {
useFrame(() => {
const ids = drainItemMeshMetadataSyncRequests()
if (ids.length === 0) return
const nodes = useScene.getState().nodes
for (const id of ids) {
trySyncItemMeshMetadata(id, nodes)
}
})
return null
}
@@ -0,0 +1,29 @@
import type { Object3D } from 'three'
const pendingIds = new Set<string>()
/** Preferred root for footprint math (Clone root). Falls back to sceneRegistry item root. */
const sourceRoots = new Map<string, Object3D>()
/** Called when an item's loaded GLTF (or metadata driving footprint) may need re-syncing. */
export function requestItemMeshMetadataSync(itemId: string) {
pendingIds.add(itemId)
}
export function setItemMeshMetadataSourceRoot(itemId: string, root: Object3D | null) {
if (root) {
sourceRoots.set(itemId, root)
} else {
sourceRoots.delete(itemId)
}
}
export function getItemMeshMetadataSourceRoot(itemId: string): Object3D | undefined {
return sourceRoots.get(itemId)
}
export function drainItemMeshMetadataSyncRequests(): string[] {
if (pendingIds.size === 0) return []
const ids = [...pendingIds]
pendingIds.clear()
return ids
}