Merge branch 'main' into feat/upgrade-and-bug-fix
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
import type { AnyNode, CeilingNode, ItemNode, SlabNode, WallNode } from '../../schema'
|
import type { AnyNode, CeilingNode, ItemNode, SlabNode, WallNode } from '../../schema'
|
||||||
import { getScaledDimensions } from '../../schema'
|
import { getScaledDimensions } from '../../schema'
|
||||||
|
import useScene from '../../store/use-scene'
|
||||||
import { SpatialGrid } from './spatial-grid'
|
import { SpatialGrid } from './spatial-grid'
|
||||||
import { WallSpatialGrid } from './wall-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.
|
* Test if two line segments (a1->a2) and (b1->b2) intersect.
|
||||||
*/
|
*/
|
||||||
@@ -481,8 +597,39 @@ export class SpatialGridManager {
|
|||||||
rotation: [number, number, number],
|
rotation: [number, number, number],
|
||||||
ignoreIds?: string[],
|
ignoreIds?: string[],
|
||||||
) {
|
) {
|
||||||
const grid = this.getFloorGrid(levelId)
|
const nodes = useScene.getState().nodes
|
||||||
return grid.canPlace(position, dimensions, rotation, ignoreIds)
|
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)
|
// Convert local X position to parametric t (0-1)
|
||||||
const tCenter = localX / wallLength
|
const tCenter = localX / wallLength
|
||||||
const [itemWidth, itemHeight] = dimensions
|
const [itemWidth, itemHeight] = dimensions
|
||||||
return this.getWallGrid(levelId).canPlaceOnWall(
|
const baseResult = this.getWallGrid(levelId).canPlaceOnWall(
|
||||||
wallId,
|
wallId,
|
||||||
wallLength,
|
wallLength,
|
||||||
wallHeight,
|
wallHeight,
|
||||||
@@ -526,6 +673,44 @@ export class SpatialGridManager {
|
|||||||
side,
|
side,
|
||||||
ignoreIds,
|
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 {
|
getWallForItem(levelId: string, itemId: string): string | undefined {
|
||||||
@@ -692,8 +877,39 @@ export class SpatialGridManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for overlaps with other ceiling items
|
const nodes = useScene.getState().nodes
|
||||||
return this.getCeilingGrid(ceilingId).canPlace(position, dimensions, rotation, ignoreIds)
|
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) {
|
clearLevel(levelId: string) {
|
||||||
|
|||||||
@@ -80,6 +80,9 @@ const assetSchema = z.object({
|
|||||||
category: z.string(),
|
category: z.string(),
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
thumbnail: z.string(),
|
thumbnail: z.string(),
|
||||||
|
// Optional top-down 2D image shown inside the item's footprint on the
|
||||||
|
// floor plan. When present, replaces the default diagonal-cross marker.
|
||||||
|
floorPlanUrl: z.string().optional(),
|
||||||
src: AssetUrl,
|
src: AssetUrl,
|
||||||
dimensions: z.tuple([z.number(), z.number(), z.number()]).default([1, 1, 1]), // [w, h, d]
|
dimensions: z.tuple([z.number(), z.number(), z.number()]).default([1, 1, 1]), // [w, h, d]
|
||||||
attachTo: z.enum(['wall', 'wall-side', 'ceiling']).optional(),
|
attachTo: z.enum(['wall', 'wall-side', 'ceiling']).optional(),
|
||||||
|
|||||||
@@ -216,7 +216,7 @@ function generateStairSegmentGeometry(
|
|||||||
extrudedGeometry.applyMatrix4(matrix)
|
extrudedGeometry.applyMatrix4(matrix)
|
||||||
extrudedGeometry.computeVertexNormals()
|
extrudedGeometry.computeVertexNormals()
|
||||||
|
|
||||||
const geometry = extrudedGeometry.toNonIndexed() ?? extrudedGeometry
|
const geometry = extrudedGeometry.index ? extrudedGeometry.toNonIndexed() : extrudedGeometry
|
||||||
if (geometry !== extrudedGeometry) {
|
if (geometry !== extrudedGeometry) {
|
||||||
extrudedGeometry.dispose()
|
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 }
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { animate, motion, useMotionValue } from 'motion/react'
|
||||||
|
import {
|
||||||
|
forwardRef,
|
||||||
|
type ReactNode,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useImperativeHandle,
|
||||||
|
useRef,
|
||||||
|
} from 'react'
|
||||||
|
|
||||||
|
export type BottomSheetHandle = {
|
||||||
|
snapTo: (heightPx: number) => void
|
||||||
|
getHeight: () => number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BottomSheetProps {
|
||||||
|
initialHeightPx: number
|
||||||
|
snapPointsPx: number[]
|
||||||
|
onCommit: (heightPx: number) => void
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
const DRAG_THRESHOLD_PX = 6
|
||||||
|
|
||||||
|
export const BottomSheet = forwardRef<BottomSheetHandle, BottomSheetProps>(function BottomSheet(
|
||||||
|
{ initialHeightPx, snapPointsPx, onCommit, children },
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
const height = useMotionValue(initialHeightPx)
|
||||||
|
const dragStartY = useRef<number | null>(null)
|
||||||
|
const dragStartHeight = useRef(0)
|
||||||
|
const hasDragged = useRef(false)
|
||||||
|
const animationRef = useRef<ReturnType<typeof animate> | null>(null)
|
||||||
|
|
||||||
|
const clamp = useCallback(
|
||||||
|
(px: number) => {
|
||||||
|
const min = Math.min(...snapPointsPx)
|
||||||
|
const max = Math.max(...snapPointsPx)
|
||||||
|
return Math.max(min, Math.min(max, px))
|
||||||
|
},
|
||||||
|
[snapPointsPx],
|
||||||
|
)
|
||||||
|
|
||||||
|
const nearestSnap = useCallback(
|
||||||
|
(px: number) => {
|
||||||
|
let best = snapPointsPx[0] ?? 0
|
||||||
|
let bestDist = Number.POSITIVE_INFINITY
|
||||||
|
for (const p of snapPointsPx) {
|
||||||
|
const d = Math.abs(p - px)
|
||||||
|
if (d < bestDist) {
|
||||||
|
bestDist = d
|
||||||
|
best = p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
},
|
||||||
|
[snapPointsPx],
|
||||||
|
)
|
||||||
|
|
||||||
|
const animateTo = useCallback(
|
||||||
|
(targetPx: number) => {
|
||||||
|
animationRef.current?.stop()
|
||||||
|
const controls = animate(height, targetPx, {
|
||||||
|
type: 'spring',
|
||||||
|
stiffness: 320,
|
||||||
|
damping: 32,
|
||||||
|
mass: 0.8,
|
||||||
|
onComplete: () => {
|
||||||
|
onCommit(targetPx)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
animationRef.current = controls
|
||||||
|
},
|
||||||
|
[height, onCommit],
|
||||||
|
)
|
||||||
|
|
||||||
|
useImperativeHandle(
|
||||||
|
ref,
|
||||||
|
() => ({
|
||||||
|
snapTo: (px: number) => animateTo(clamp(px)),
|
||||||
|
getHeight: () => height.get(),
|
||||||
|
}),
|
||||||
|
[animateTo, clamp, height],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handlePointerDown = useCallback(
|
||||||
|
(e: React.PointerEvent) => {
|
||||||
|
if (e.button !== 0 && e.pointerType === 'mouse') return
|
||||||
|
e.currentTarget.setPointerCapture(e.pointerId)
|
||||||
|
animationRef.current?.stop()
|
||||||
|
dragStartY.current = e.clientY
|
||||||
|
dragStartHeight.current = height.get()
|
||||||
|
hasDragged.current = false
|
||||||
|
},
|
||||||
|
[height],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handlePointerMove = useCallback(
|
||||||
|
(e: React.PointerEvent) => {
|
||||||
|
if (dragStartY.current === null) return
|
||||||
|
const dy = e.clientY - dragStartY.current
|
||||||
|
if (!hasDragged.current && Math.abs(dy) < DRAG_THRESHOLD_PX) return
|
||||||
|
hasDragged.current = true
|
||||||
|
const next = clamp(dragStartHeight.current - dy)
|
||||||
|
height.set(next)
|
||||||
|
},
|
||||||
|
[clamp, height],
|
||||||
|
)
|
||||||
|
|
||||||
|
const endDrag = useCallback(
|
||||||
|
(e: React.PointerEvent) => {
|
||||||
|
if (dragStartY.current === null) return
|
||||||
|
dragStartY.current = null
|
||||||
|
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
|
||||||
|
e.currentTarget.releasePointerCapture(e.pointerId)
|
||||||
|
}
|
||||||
|
if (!hasDragged.current) return
|
||||||
|
const target = nearestSnap(height.get())
|
||||||
|
animateTo(target)
|
||||||
|
},
|
||||||
|
[animateTo, height, nearestSnap],
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
animationRef.current?.stop()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
className="absolute right-0 bottom-0 left-0 z-40 flex flex-col overflow-hidden rounded-t-2xl bg-sidebar text-sidebar-foreground shadow-[0_-4px_16px_rgba(0,0,0,0.12)]"
|
||||||
|
style={{ height }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex h-6 shrink-0 cursor-grab touch-none items-center justify-center active:cursor-grabbing"
|
||||||
|
onPointerCancel={endDrag}
|
||||||
|
onPointerDown={handlePointerDown}
|
||||||
|
onPointerMove={handlePointerMove}
|
||||||
|
onPointerUp={endDrag}
|
||||||
|
>
|
||||||
|
<div className="h-1 w-10 rounded-full bg-muted-foreground/40" />
|
||||||
|
</div>
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">{children}</div>
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -117,6 +117,49 @@ export const CustomCameraControls = () => {
|
|||||||
}
|
}
|
||||||
}, [cameraMode, isPreviewMode])
|
}, [cameraMode, isPreviewMode])
|
||||||
|
|
||||||
|
// Touch gestures (mobile / trackpad).
|
||||||
|
// - One finger drag → rotate by default (much easier on a phone), but
|
||||||
|
// falls back to NONE while the user is actively
|
||||||
|
// placing/moving something OR in box-select mode,
|
||||||
|
// so the editor's pointer handlers (place tool,
|
||||||
|
// drag-to-move endpoint, marquee selection drag)
|
||||||
|
// keep priority over the camera.
|
||||||
|
// In preview mode it's TOUCH_TRUCK (pan), matching
|
||||||
|
// preview's left = SCREEN_PAN.
|
||||||
|
// - Two finger pinch → zoom + pan together (TOUCH_DOLLY_TRUCK for
|
||||||
|
// perspective, TOUCH_ZOOM_TRUCK for orthographic).
|
||||||
|
// - Three finger drag → rotate, so the camera is always orbitable even
|
||||||
|
// when one-finger is suppressed by an active
|
||||||
|
// editor action.
|
||||||
|
const tool = useEditor((s) => s.tool)
|
||||||
|
const mode = useEditor((s) => s.mode)
|
||||||
|
const selectionTool = useEditor((s) => s.floorplanSelectionTool)
|
||||||
|
const movingNode = useEditor((s) => s.movingNode)
|
||||||
|
const movingWallEndpoint = useEditor((s) => s.movingWallEndpoint)
|
||||||
|
const movingFenceEndpoint = useEditor((s) => s.movingFenceEndpoint)
|
||||||
|
const isBoxSelectActive = mode === 'select' && selectionTool === 'marquee'
|
||||||
|
const isInteracting = Boolean(
|
||||||
|
tool || movingNode || movingWallEndpoint || movingFenceEndpoint || isBoxSelectActive,
|
||||||
|
)
|
||||||
|
const touches = useMemo(() => {
|
||||||
|
const twoFingerAction =
|
||||||
|
cameraMode === 'orthographic'
|
||||||
|
? CameraControlsImpl.ACTION.TOUCH_ZOOM_TRUCK
|
||||||
|
: CameraControlsImpl.ACTION.TOUCH_DOLLY_TRUCK
|
||||||
|
|
||||||
|
const oneFingerAction = isPreviewMode
|
||||||
|
? CameraControlsImpl.ACTION.TOUCH_TRUCK
|
||||||
|
: isInteracting
|
||||||
|
? CameraControlsImpl.ACTION.NONE
|
||||||
|
: CameraControlsImpl.ACTION.TOUCH_ROTATE
|
||||||
|
|
||||||
|
return {
|
||||||
|
one: oneFingerAction,
|
||||||
|
two: twoFingerAction,
|
||||||
|
three: CameraControlsImpl.ACTION.TOUCH_ROTATE,
|
||||||
|
}
|
||||||
|
}, [cameraMode, isPreviewMode, isInteracting])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const keyState = {
|
const keyState = {
|
||||||
shiftRight: false,
|
shiftRight: false,
|
||||||
@@ -406,6 +449,7 @@ export const CustomCameraControls = () => {
|
|||||||
onTransitionStart={onTransitionStart}
|
onTransitionStart={onTransitionStart}
|
||||||
ref={controls}
|
ref={controls}
|
||||||
restThreshold={0.01}
|
restThreshold={0.01}
|
||||||
|
touches={touches}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,264 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
|
import { type ReactNode, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||||
|
import useEditor from '../../store/use-editor'
|
||||||
|
import { MobileTabBar } from '../ui/sidebar/mobile-tab-bar'
|
||||||
|
import type { SidebarTab } from '../ui/sidebar/tab-bar'
|
||||||
|
import { BottomSheet, type BottomSheetHandle } from './bottom-sheet'
|
||||||
|
|
||||||
|
const MIN_SNAP = 0
|
||||||
|
const MAX_SNAP = 1
|
||||||
|
const DEFAULT_SNAP = 0.5
|
||||||
|
// Viewer extends this many pixels behind the sheet's rounded top corners
|
||||||
|
// so the curve reveals viewer content underneath.
|
||||||
|
const SHEET_OVERLAP_PX = 16
|
||||||
|
// Sheet never collapses below the drag handle so the user can always grab it.
|
||||||
|
const SHEET_HANDLE_PX = 24
|
||||||
|
|
||||||
|
// Match the viewer's scene background colors (packages/viewer/src/components/viewer/index.tsx)
|
||||||
|
const VIEWER_BG_DARK = '#1f2433'
|
||||||
|
const VIEWER_BG_LIGHT = '#ffffff'
|
||||||
|
|
||||||
|
// Fixed set of intermediate snap heights (handle + middleH are added on top).
|
||||||
|
// Per-tab `mobileDefaultSnap` decides the OPENING height; this list bounds
|
||||||
|
// what the user can drag to.
|
||||||
|
const SNAP_RATIOS = [0.5, 0.66] as const
|
||||||
|
|
||||||
|
function getDefaultSnap(tab: SidebarTab | undefined): number {
|
||||||
|
const s = tab?.mobileDefaultSnap
|
||||||
|
if (typeof s !== 'number') return DEFAULT_SNAP
|
||||||
|
return Math.max(MIN_SNAP, Math.min(MAX_SNAP, s))
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EditorLayoutMobileProps {
|
||||||
|
navbarSlot?: ReactNode
|
||||||
|
sidebarTabs?: SidebarTab[]
|
||||||
|
renderTabContent: (tabId: string) => ReactNode
|
||||||
|
sidebarOverlay?: ReactNode
|
||||||
|
viewerToolbarLeft?: ReactNode
|
||||||
|
viewerToolbarRight?: ReactNode
|
||||||
|
viewerContent: ReactNode
|
||||||
|
overlays?: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditorLayoutMobile({
|
||||||
|
navbarSlot,
|
||||||
|
sidebarTabs = [],
|
||||||
|
renderTabContent,
|
||||||
|
sidebarOverlay,
|
||||||
|
viewerToolbarLeft,
|
||||||
|
viewerToolbarRight,
|
||||||
|
viewerContent,
|
||||||
|
overlays,
|
||||||
|
}: EditorLayoutMobileProps) {
|
||||||
|
const isCaptureMode = useEditor((s) => s.isCaptureMode)
|
||||||
|
const activePanel = useEditor((s) => s.activeSidebarPanel)
|
||||||
|
const setActivePanel = useEditor((s) => s.setActiveSidebarPanel)
|
||||||
|
const panelSheetHeight = useEditor((s) => s.mobilePanelSheetHeight)
|
||||||
|
const theme = useViewer((s) => s.theme)
|
||||||
|
const viewerBg = theme === 'light' ? VIEWER_BG_LIGHT : VIEWER_BG_DARK
|
||||||
|
|
||||||
|
const middleRef = useRef<HTMLDivElement>(null)
|
||||||
|
const sheetRef = useRef<BottomSheetHandle>(null)
|
||||||
|
const [middleH, setMiddleH] = useState(0)
|
||||||
|
// Distance from the middle area's bottom edge to the viewport's bottom edge
|
||||||
|
// (i.e. the tab bar height incl. safe area). Needed to translate the panel
|
||||||
|
// sheet's viewport-relative height into middle-area coordinates.
|
||||||
|
const [middleBottomFromViewport, setMiddleBottomFromViewport] = useState(0)
|
||||||
|
const [committedSheetH, setCommittedSheetH] = useState(0)
|
||||||
|
|
||||||
|
const currentTab = sidebarTabs.find((t) => t.id === activePanel)
|
||||||
|
|
||||||
|
// Keep active panel valid
|
||||||
|
useEffect(() => {
|
||||||
|
if (sidebarTabs.length > 0 && !sidebarTabs.some((t) => t.id === activePanel)) {
|
||||||
|
setActivePanel(sidebarTabs[0]!.id)
|
||||||
|
}
|
||||||
|
}, [sidebarTabs, activePanel, setActivePanel])
|
||||||
|
|
||||||
|
// Sync editor phase / mode with the active tab:
|
||||||
|
// - Entering Chat always drops to Select (chat is a composing context).
|
||||||
|
// - Entering Items snaps the editor into furnish-build (matches the
|
||||||
|
// desktop "Furnish" action which itself opens the Items panel).
|
||||||
|
// - Leaving Items while still furnishing exits the build mode.
|
||||||
|
useEffect(() => {
|
||||||
|
const { phase, mode, setMode, setPhase } = useEditor.getState()
|
||||||
|
if (activePanel === 'ai' && mode === 'build') {
|
||||||
|
setMode('select')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (activePanel === 'items') {
|
||||||
|
if (phase !== 'furnish') setPhase('furnish')
|
||||||
|
if (mode !== 'build') setMode('build')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (phase === 'furnish' && mode === 'build') {
|
||||||
|
setMode('select')
|
||||||
|
}
|
||||||
|
}, [activePanel])
|
||||||
|
|
||||||
|
// Measure middle area height + its bottom offset from viewport bottom
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const el = middleRef.current
|
||||||
|
if (!el) return
|
||||||
|
const measure = () => {
|
||||||
|
const rect = el.getBoundingClientRect()
|
||||||
|
setMiddleH(rect.height)
|
||||||
|
setMiddleBottomFromViewport(Math.max(0, window.innerHeight - rect.bottom))
|
||||||
|
}
|
||||||
|
const ro = new ResizeObserver(measure)
|
||||||
|
ro.observe(el)
|
||||||
|
measure()
|
||||||
|
window.addEventListener('resize', measure)
|
||||||
|
return () => {
|
||||||
|
ro.disconnect()
|
||||||
|
window.removeEventListener('resize', measure)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Initialise sheet to current tab default once we know the middle height
|
||||||
|
const didInit = useRef(false)
|
||||||
|
useEffect(() => {
|
||||||
|
if (didInit.current || middleH <= 0) return
|
||||||
|
didInit.current = true
|
||||||
|
const targetPx = getDefaultSnap(currentTab) * middleH
|
||||||
|
setCommittedSheetH(targetPx)
|
||||||
|
sheetRef.current?.snapTo(targetPx)
|
||||||
|
}, [middleH, currentTab])
|
||||||
|
|
||||||
|
// When middle height changes (rotation / resize), keep sheet in proportion
|
||||||
|
const prevMiddleH = useRef(0)
|
||||||
|
useEffect(() => {
|
||||||
|
if (middleH <= 0) return
|
||||||
|
if (prevMiddleH.current === 0) {
|
||||||
|
prevMiddleH.current = middleH
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (prevMiddleH.current === middleH) return
|
||||||
|
const ratio = committedSheetH / prevMiddleH.current
|
||||||
|
const nextPx = Math.max(SHEET_HANDLE_PX, Math.min(middleH, ratio * middleH))
|
||||||
|
prevMiddleH.current = middleH
|
||||||
|
setCommittedSheetH(nextPx)
|
||||||
|
sheetRef.current?.snapTo(nextPx)
|
||||||
|
}, [middleH, committedSheetH])
|
||||||
|
|
||||||
|
const handleTabPress = useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
if (middleH <= 0) return
|
||||||
|
const tab = sidebarTabs.find((t) => t.id === id)
|
||||||
|
if (!tab) return
|
||||||
|
const defaultPx = getDefaultSnap(tab) * middleH
|
||||||
|
if (id !== activePanel) {
|
||||||
|
setActivePanel(id)
|
||||||
|
sheetRef.current?.snapTo(defaultPx)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Same tab tapped — toggle
|
||||||
|
const current = sheetRef.current?.getHeight() ?? committedSheetH
|
||||||
|
const expandedThreshold = Math.max(SHEET_HANDLE_PX, defaultPx * 0.5)
|
||||||
|
if (current > expandedThreshold) {
|
||||||
|
sheetRef.current?.snapTo(SHEET_HANDLE_PX)
|
||||||
|
} else {
|
||||||
|
sheetRef.current?.snapTo(defaultPx)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[sidebarTabs, activePanel, setActivePanel, middleH, committedSheetH],
|
||||||
|
)
|
||||||
|
|
||||||
|
const snapPointsPx = (() => {
|
||||||
|
if (middleH <= 0) return [SHEET_HANDLE_PX]
|
||||||
|
const intermediate = SNAP_RATIOS.map((r) => r * middleH)
|
||||||
|
return Array.from(new Set([SHEET_HANDLE_PX, ...intermediate, middleH])).sort((a, b) => a - b)
|
||||||
|
})()
|
||||||
|
|
||||||
|
// When the secondary panel sheet is open, it covers the tab bar + part of
|
||||||
|
// the middle area; translate its viewport height into middle-area units.
|
||||||
|
const panelPenetrationInMiddle = Math.max(0, panelSheetHeight - middleBottomFromViewport)
|
||||||
|
// The effective "sheet height" that the viewer sits above is the larger of
|
||||||
|
// the primary sidebar sheet and the secondary panel sheet's penetration.
|
||||||
|
const effectiveSheetH = Math.max(committedSheetH, panelPenetrationInMiddle)
|
||||||
|
|
||||||
|
// In capture mode the sheet and tab bar are hidden — the viewer should fill
|
||||||
|
// the entire middle area regardless of the stored sheet height.
|
||||||
|
// Otherwise, the viewer extends SHEET_OVERLAP_PX behind the sheet's rounded
|
||||||
|
// corners so the curve reveals viewer content underneath.
|
||||||
|
const baseViewerHeight = Math.max(0, middleH - effectiveSheetH)
|
||||||
|
const viewerHeight = isCaptureMode
|
||||||
|
? middleH
|
||||||
|
: baseViewerHeight === 0
|
||||||
|
? 0
|
||||||
|
: Math.min(middleH, baseViewerHeight + SHEET_OVERLAP_PX)
|
||||||
|
|
||||||
|
// While the panel sheet is open, collapse the primary sheet to its handle so
|
||||||
|
// it doesn't peek above. Remember the previous height and restore it on close.
|
||||||
|
const sheetHeightBeforePanel = useRef<number | null>(null)
|
||||||
|
useEffect(() => {
|
||||||
|
if (panelSheetHeight > 0) {
|
||||||
|
if (sheetHeightBeforePanel.current === null && committedSheetH > SHEET_HANDLE_PX) {
|
||||||
|
sheetHeightBeforePanel.current = committedSheetH
|
||||||
|
sheetRef.current?.snapTo(SHEET_HANDLE_PX)
|
||||||
|
}
|
||||||
|
} else if (sheetHeightBeforePanel.current !== null) {
|
||||||
|
const target = sheetHeightBeforePanel.current
|
||||||
|
sheetHeightBeforePanel.current = null
|
||||||
|
sheetRef.current?.snapTo(target)
|
||||||
|
}
|
||||||
|
}, [panelSheetHeight, committedSheetH])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="dark flex h-full w-full flex-col bg-sidebar text-foreground">
|
||||||
|
{navbarSlot}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="relative flex min-h-0 flex-1"
|
||||||
|
ref={middleRef}
|
||||||
|
style={{ backgroundColor: viewerBg }}
|
||||||
|
>
|
||||||
|
{/* Viewer column: sized by committed sheet height */}
|
||||||
|
<div className="absolute inset-x-0 top-0 overflow-hidden" style={{ height: viewerHeight }}>
|
||||||
|
<div className="relative h-full w-full">
|
||||||
|
{(viewerToolbarLeft || viewerToolbarRight) && !isCaptureMode && (
|
||||||
|
<div className="pointer-events-none absolute top-3 right-3 left-3 z-20 flex items-center justify-between gap-2">
|
||||||
|
<div className="pointer-events-auto flex items-center gap-2">
|
||||||
|
{viewerToolbarLeft}
|
||||||
|
</div>
|
||||||
|
<div className="pointer-events-auto flex items-center gap-2">
|
||||||
|
{viewerToolbarRight}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="relative h-full w-full overflow-hidden">{viewerContent}</div>
|
||||||
|
{overlays && (
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute inset-0 z-30"
|
||||||
|
style={{ transform: 'translateZ(0)' }}
|
||||||
|
>
|
||||||
|
{overlays}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom sheet: overlays the lower part of the middle area */}
|
||||||
|
{!isCaptureMode && sidebarTabs.length > 0 && (
|
||||||
|
<BottomSheet
|
||||||
|
initialHeightPx={SHEET_HANDLE_PX}
|
||||||
|
onCommit={setCommittedSheetH}
|
||||||
|
ref={sheetRef}
|
||||||
|
snapPointsPx={snapPointsPx}
|
||||||
|
>
|
||||||
|
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||||
|
{renderTabContent(activePanel)}
|
||||||
|
{sidebarOverlay && <div className="absolute inset-0 z-50">{sidebarOverlay}</div>}
|
||||||
|
</div>
|
||||||
|
</BottomSheet>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isCaptureMode && sidebarTabs.length > 0 && (
|
||||||
|
<MobileTabBar activeTab={activePanel} onTabPress={handleTabPress} tabs={sidebarTabs} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { type ReactNode, useCallback, useEffect, useRef } from 'react'
|
import { type ReactNode, useCallback, useEffect, useRef } from 'react'
|
||||||
|
import { useIsMobile } from '../../hooks/use-mobile'
|
||||||
import useEditor from '../../store/use-editor'
|
import useEditor from '../../store/use-editor'
|
||||||
|
|
||||||
import { useSidebarStore } from '../ui/primitives/sidebar'
|
import { useSidebarStore } from '../ui/primitives/sidebar'
|
||||||
import { type SidebarTab, TabBar } from '../ui/sidebar/tab-bar'
|
import { type SidebarTab, TabBar } from '../ui/sidebar/tab-bar'
|
||||||
|
import { EditorLayoutMobile } from './editor-layout-mobile'
|
||||||
|
|
||||||
const SIDEBAR_MIN_WIDTH = 300
|
const SIDEBAR_MIN_WIDTH = 300
|
||||||
const SIDEBAR_MAX_WIDTH = 800
|
const SIDEBAR_MAX_WIDTH = 800
|
||||||
@@ -202,6 +205,23 @@ export function EditorLayoutV2({
|
|||||||
viewerContent,
|
viewerContent,
|
||||||
overlays,
|
overlays,
|
||||||
}: EditorLayoutV2Props) {
|
}: EditorLayoutV2Props) {
|
||||||
|
const isMobile = useIsMobile()
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
return (
|
||||||
|
<EditorLayoutMobile
|
||||||
|
navbarSlot={navbarSlot}
|
||||||
|
overlays={overlays}
|
||||||
|
renderTabContent={renderTabContent}
|
||||||
|
sidebarOverlay={sidebarOverlay}
|
||||||
|
sidebarTabs={sidebarTabs}
|
||||||
|
viewerContent={viewerContent}
|
||||||
|
viewerToolbarLeft={viewerToolbarLeft}
|
||||||
|
viewerToolbarRight={viewerToolbarRight}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="dark flex h-full w-full flex-col bg-sidebar text-foreground">
|
<div className="dark flex h-full w-full flex-col bg-sidebar text-foreground">
|
||||||
{/* Top navbar */}
|
{/* Top navbar */}
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import {
|
|||||||
type CeilingNode,
|
type CeilingNode,
|
||||||
DoorNode,
|
DoorNode,
|
||||||
FenceNode,
|
FenceNode,
|
||||||
|
generateId,
|
||||||
ItemNode,
|
ItemNode,
|
||||||
RoofNode,
|
|
||||||
RoofSegmentNode,
|
RoofSegmentNode,
|
||||||
type SlabNode,
|
type SlabNode,
|
||||||
SpawnNode,
|
SpawnNode,
|
||||||
@@ -24,6 +24,8 @@ import { useFrame } from '@react-three/fiber'
|
|||||||
import { Move } from 'lucide-react'
|
import { Move } from 'lucide-react'
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import * as THREE from 'three'
|
import * as THREE from 'three'
|
||||||
|
import { duplicateRoofSubtree } from '../../lib/roof-duplication'
|
||||||
|
import { duplicateStairSubtree } from '../../lib/stair-duplication'
|
||||||
import { sfxEmitter } from '../../lib/sfx-bus'
|
import { sfxEmitter } from '../../lib/sfx-bus'
|
||||||
import useEditor from '../../store/use-editor'
|
import useEditor from '../../store/use-editor'
|
||||||
import { NodeActionMenu } from './node-action-menu'
|
import { NodeActionMenu } from './node-action-menu'
|
||||||
@@ -237,6 +239,16 @@ export function FloatingActionMenu() {
|
|||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
if (!node?.parentId) return
|
if (!node?.parentId) return
|
||||||
sfxEmitter.emit('sfx:item-pick')
|
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()
|
useScene.temporal.getState().pause()
|
||||||
|
|
||||||
let duplicateInfo = structuredClone(node) as any
|
let duplicateInfo = structuredClone(node) as any
|
||||||
@@ -257,10 +269,8 @@ export function FloatingActionMenu() {
|
|||||||
duplicate = FenceNode.parse(duplicateInfo)
|
duplicate = FenceNode.parse(duplicateInfo)
|
||||||
duplicate.start = [duplicate.start[0] + 1, duplicate.start[1] + 1]
|
duplicate.start = [duplicate.start[0] + 1, duplicate.start[1] + 1]
|
||||||
duplicate.end = [duplicate.end[0] + 1, duplicate.end[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') {
|
} else if (node.type === 'roof-segment') {
|
||||||
|
duplicateInfo.id = generateId('rseg')
|
||||||
duplicate = RoofSegmentNode.parse(duplicateInfo)
|
duplicate = RoofSegmentNode.parse(duplicateInfo)
|
||||||
} else if (node.type === 'stair') {
|
} else if (node.type === 'stair') {
|
||||||
duplicateInfo.children = []
|
duplicateInfo.children = []
|
||||||
@@ -291,7 +301,6 @@ export function FloatingActionMenu() {
|
|||||||
} else if (duplicate.type === 'fence') {
|
} else if (duplicate.type === 'fence') {
|
||||||
useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
|
useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
|
||||||
} else if (
|
} else if (
|
||||||
duplicate.type === 'roof' ||
|
|
||||||
duplicate.type === 'roof-segment' ||
|
duplicate.type === 'roof-segment' ||
|
||||||
duplicate.type === 'stair' ||
|
duplicate.type === 'stair' ||
|
||||||
duplicate.type === 'stair-segment'
|
duplicate.type === 'stair-segment'
|
||||||
@@ -305,54 +314,11 @@ export function FloatingActionMenu() {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
if (node.type === 'stair' && duplicate.type === 'stair') {
|
if (node.type === 'stair' && duplicate.type === 'stair') {
|
||||||
const nodesState = useScene.getState().nodes
|
duplicateStairSubtree(node.id as AnyNodeId, { mode: 'move' })
|
||||||
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)
|
|
||||||
} else {
|
} else {
|
||||||
useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
|
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
|
// Duplicate children for stair nodes
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
@@ -361,7 +327,6 @@ export function FloatingActionMenu() {
|
|||||||
duplicate.type === 'fence' ||
|
duplicate.type === 'fence' ||
|
||||||
duplicate.type === 'window' ||
|
duplicate.type === 'window' ||
|
||||||
duplicate.type === 'door' ||
|
duplicate.type === 'door' ||
|
||||||
duplicate.type === 'roof' ||
|
|
||||||
duplicate.type === 'roof-segment' ||
|
duplicate.type === 'roof-segment' ||
|
||||||
duplicate.type === 'spawn' ||
|
duplicate.type === 'spawn' ||
|
||||||
duplicate.type === 'stair-segment'
|
duplicate.type === 'stair-segment'
|
||||||
@@ -370,7 +335,7 @@ export function FloatingActionMenu() {
|
|||||||
} else if (duplicate.type === 'stair') {
|
} else if (duplicate.type === 'stair') {
|
||||||
setSelection({ selectedIds: [duplicate.id as AnyNodeId] })
|
setSelection({ selectedIds: [duplicate.id as AnyNodeId] })
|
||||||
}
|
}
|
||||||
if (duplicate.type !== 'stair') {
|
if (duplicate.type !== 'stair' && duplicate.type !== 'roof') {
|
||||||
setSelection({ selectedIds: [] })
|
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
@@ -1108,7 +1108,13 @@ export default function Editor({
|
|||||||
return <Component />
|
return <Component />
|
||||||
}
|
}
|
||||||
|
|
||||||
const tabBarTabs = sidebarTabs?.map(({ id, label }) => ({ id, label })) ?? []
|
const tabBarTabs =
|
||||||
|
sidebarTabs?.map(({ id, label, mobileDefaultSnap, mobileIcon }) => ({
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
mobileDefaultSnap,
|
||||||
|
mobileIcon,
|
||||||
|
})) ?? []
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PresetsProvider adapter={presetsAdapter}>
|
<PresetsProvider adapter={presetsAdapter}>
|
||||||
|
|||||||
@@ -265,12 +265,32 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
|
|||||||
)) as Uint8Array
|
)) as Uint8Array
|
||||||
|
|
||||||
const actualBytesPerRow = width * 4
|
const actualBytesPerRow = width * 4
|
||||||
|
const tightTotal = actualBytesPerRow * height
|
||||||
const paddedBytesPerRow = Math.ceil(actualBytesPerRow / 256) * 256
|
const paddedBytesPerRow = Math.ceil(actualBytesPerRow / 256) * 256
|
||||||
|
// Two readback shapes to handle:
|
||||||
|
// - WebGPU (`copyTextureToBuffer`): top-down + 256-byte row padding
|
||||||
|
// when width*4 isn't already a multiple of 256.
|
||||||
|
// - WebGL2 fallback (iOS Chrome, etc.): tightly-packed but bottom-up
|
||||||
|
// (OpenGL framebuffer convention).
|
||||||
|
// `isWebGPURenderer` lies — it stays true even when the renderer
|
||||||
|
// falls back to the WebGL backend. Inspect the actual backend
|
||||||
|
// instead (presence of a GPU device, or backend constructor name).
|
||||||
|
const backend = (renderer as any).backend
|
||||||
|
const isWebGPU =
|
||||||
|
!!backend?.device ||
|
||||||
|
backend?.isWebGPUBackend === true ||
|
||||||
|
backend?.constructor?.name === 'WebGPUBackend'
|
||||||
let tightPixels: Uint8ClampedArray
|
let tightPixels: Uint8ClampedArray
|
||||||
|
if (isWebGPU) {
|
||||||
|
// WebGPU: depad rows if needed; orientation is already top-down.
|
||||||
if (paddedBytesPerRow === actualBytesPerRow) {
|
if (paddedBytesPerRow === actualBytesPerRow) {
|
||||||
tightPixels = new Uint8ClampedArray(pixels.buffer, pixels.byteOffset, pixels.byteLength)
|
tightPixels = new Uint8ClampedArray(
|
||||||
|
pixels.buffer,
|
||||||
|
pixels.byteOffset,
|
||||||
|
Math.min(pixels.byteLength, tightTotal),
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
tightPixels = new Uint8ClampedArray(width * height * 4)
|
tightPixels = new Uint8ClampedArray(tightTotal)
|
||||||
for (let row = 0; row < height; row++) {
|
for (let row = 0; row < height; row++) {
|
||||||
tightPixels.set(
|
tightPixels.set(
|
||||||
pixels.subarray(
|
pixels.subarray(
|
||||||
@@ -281,6 +301,17 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// WebGL2: tight buffer in bottom-up order — flip rows.
|
||||||
|
tightPixels = new Uint8ClampedArray(tightTotal)
|
||||||
|
for (let row = 0; row < height; row++) {
|
||||||
|
const srcStart = (height - 1 - row) * actualBytesPerRow
|
||||||
|
tightPixels.set(
|
||||||
|
pixels.subarray(srcStart, srcStart + actualBytesPerRow),
|
||||||
|
row * actualBytesPerRow,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const imageData = new ImageData(
|
const imageData = new ImageData(
|
||||||
tightPixels as unknown as Uint8ClampedArray<ArrayBuffer>,
|
tightPixels as unknown as Uint8ClampedArray<ArrayBuffer>,
|
||||||
|
|||||||
@@ -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) => {
|
const onGridMove = (event: GridEvent) => {
|
||||||
if (!(cursorRef.current && gridCursorRef.current)) return
|
if (!(cursorRef.current && gridCursorRef.current)) return
|
||||||
|
|
||||||
const gridX = Math.round(event.position[0] * 2) / 2
|
const gridX = Math.round(event.localPosition[0] * 2) / 2
|
||||||
const gridZ = Math.round(event.position[2] * 2) / 2
|
const gridZ = Math.round(event.localPosition[2] * 2) / 2
|
||||||
const gridPosition: [number, number] = [gridX, gridZ]
|
const gridPosition: [number, number] = [gridX, gridZ]
|
||||||
|
|
||||||
setCursorPosition(gridPosition)
|
setCursorPosition(gridPosition)
|
||||||
setLevelY(event.position[1])
|
setLevelY(event.localPosition[1])
|
||||||
|
|
||||||
const ceilingY = event.position[1] + CEILING_HEIGHT
|
const ceilingY = event.localPosition[1] + CEILING_HEIGHT
|
||||||
const gridY = event.position[1] + GRID_OFFSET
|
const gridY = event.localPosition[1] + GRID_OFFSET
|
||||||
|
|
||||||
// Calculate snapped display position (bypass snap when Shift is held)
|
// Calculate snapped display position (bypass snap when Shift is held)
|
||||||
const lastPoint = points[points.length - 1]
|
const lastPoint = points[points.length - 1]
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
isCurvedWall,
|
isCurvedWall,
|
||||||
sceneRegistry,
|
sceneRegistry,
|
||||||
spatialGridManager,
|
spatialGridManager,
|
||||||
|
useLiveTransforms,
|
||||||
useScene,
|
useScene,
|
||||||
type WallEvent,
|
type WallEvent,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
@@ -127,6 +128,10 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
|
|||||||
parentId: event.node.id,
|
parentId: event.node.id,
|
||||||
wallId: 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)
|
if (prevWallId && prevWallId !== event.node.id) markWallDirty(prevWallId)
|
||||||
markWallDirty(event.node.id)
|
markWallDirty(event.node.id)
|
||||||
@@ -195,6 +200,10 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
|
|||||||
doorMesh.updateMatrixWorld(true)
|
doorMesh.updateMatrixWorld(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
useLiveTransforms.getState().set(movingDoorNode.id, {
|
||||||
|
position: [clampedX, clampedY, 0],
|
||||||
|
rotation: itemRotation,
|
||||||
|
})
|
||||||
markWallDirty(event.node.id)
|
markWallDirty(event.node.id)
|
||||||
|
|
||||||
const valid = !hasWallChildOverlap(
|
const valid = !hasWallChildOverlap(
|
||||||
@@ -291,6 +300,7 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
|
|||||||
}
|
}
|
||||||
|
|
||||||
markWallDirty(event.node.id)
|
markWallDirty(event.node.id)
|
||||||
|
useLiveTransforms.getState().clear(movingDoorNode.id)
|
||||||
useScene.temporal.getState().pause()
|
useScene.temporal.getState().pause()
|
||||||
|
|
||||||
sfxEmitter.emit('sfx:item-place')
|
sfxEmitter.emit('sfx:item-place')
|
||||||
@@ -302,6 +312,7 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
|
|||||||
|
|
||||||
const onWallLeave = () => {
|
const onWallLeave = () => {
|
||||||
hideCursor()
|
hideCursor()
|
||||||
|
useLiveTransforms.getState().clear(movingDoorNode.id)
|
||||||
if (isNew) return
|
if (isNew) return
|
||||||
if (currentWallId && currentWallId !== original.parentId) {
|
if (currentWallId && currentWallId !== original.parentId) {
|
||||||
markWallDirty(currentWallId)
|
markWallDirty(currentWallId)
|
||||||
@@ -318,6 +329,7 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onCancel = () => {
|
const onCancel = () => {
|
||||||
|
useLiveTransforms.getState().clear(movingDoorNode.id)
|
||||||
if (isNew) {
|
if (isNew) {
|
||||||
useScene.getState().deleteNode(movingDoorNode.id)
|
useScene.getState().deleteNode(movingDoorNode.id)
|
||||||
if (currentWallId) markWallDirty(currentWallId)
|
if (currentWallId) markWallDirty(currentWallId)
|
||||||
@@ -364,6 +376,7 @@ export const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNod
|
|||||||
if (original.parentId) markWallDirty(original.parentId)
|
if (original.parentId) markWallDirty(original.parentId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
useLiveTransforms.getState().clear(movingDoorNode.id)
|
||||||
useScene.temporal.getState().resume()
|
useScene.temporal.getState().resume()
|
||||||
emitter.off('wall:enter', onWallEnter)
|
emitter.off('wall:enter', onWallEnter)
|
||||||
emitter.off('wall:move', onWallMove)
|
emitter.off('wall:move', onWallMove)
|
||||||
|
|||||||
@@ -1,17 +1,25 @@
|
|||||||
'use client'
|
'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 { useViewer } from '@pascal-app/viewer'
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import type * as THREE from 'three'
|
||||||
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
|
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
|
||||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||||
import useEditor from '../../../store/use-editor'
|
import useEditor from '../../../store/use-editor'
|
||||||
|
import { snapFenceDraftPoint } from './fence-drafting'
|
||||||
import { CursorSphere } from '../shared/cursor-sphere'
|
import { CursorSphere } from '../shared/cursor-sphere'
|
||||||
|
|
||||||
function snap(value: number) {
|
|
||||||
return Math.round(value * 2) / 2
|
|
||||||
}
|
|
||||||
|
|
||||||
function samePoint(a: [number, number], b: [number, number]) {
|
function samePoint(a: [number, number], b: [number, number]) {
|
||||||
return a[0] === b[0] && a[1] === b[1]
|
return a[0] === b[0] && a[1] === b[1]
|
||||||
}
|
}
|
||||||
@@ -24,10 +32,11 @@ type LinkedFenceSnapshot = {
|
|||||||
|
|
||||||
function getLinkedFenceSnapshots(args: {
|
function getLinkedFenceSnapshots(args: {
|
||||||
fenceId: FenceNode['id']
|
fenceId: FenceNode['id']
|
||||||
|
fenceParentId: string | null
|
||||||
originalStart: [number, number]
|
originalStart: [number, number]
|
||||||
originalEnd: [number, number]
|
originalEnd: [number, number]
|
||||||
}) {
|
}) {
|
||||||
const { fenceId, originalStart, originalEnd } = args
|
const { fenceId, fenceParentId, originalStart, originalEnd } = args
|
||||||
const { nodes } = useScene.getState()
|
const { nodes } = useScene.getState()
|
||||||
const snapshots: LinkedFenceSnapshot[] = []
|
const snapshots: LinkedFenceSnapshot[] = []
|
||||||
|
|
||||||
@@ -36,6 +45,10 @@ function getLinkedFenceSnapshots(args: {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ((node.parentId ?? null) !== fenceParentId) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!samePoint(node.start, originalStart) &&
|
!samePoint(node.start, originalStart) &&
|
||||||
!samePoint(node.start, originalEnd) &&
|
!samePoint(node.start, originalEnd) &&
|
||||||
@@ -78,12 +91,14 @@ function getLinkedFenceUpdates(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
|
export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
|
||||||
|
const activatedAtRef = useRef<number>(Date.now())
|
||||||
const previousGridPosRef = useRef<[number, number] | null>(null)
|
const previousGridPosRef = useRef<[number, number] | null>(null)
|
||||||
const originalStartRef = useRef<[number, number]>([...node.start] as [number, number])
|
const originalStartRef = useRef<[number, number]>([...node.start] as [number, number])
|
||||||
const originalEndRef = useRef<[number, number]>([...node.end] as [number, number])
|
const originalEndRef = useRef<[number, number]>([...node.end] as [number, number])
|
||||||
const linkedOriginalsRef = useRef(
|
const linkedOriginalsRef = useRef(
|
||||||
getLinkedFenceSnapshots({
|
getLinkedFenceSnapshots({
|
||||||
fenceId: node.id,
|
fenceId: node.id,
|
||||||
|
fenceParentId: node.parentId ?? null,
|
||||||
originalStart: node.start,
|
originalStart: node.start,
|
||||||
originalEnd: node.end,
|
originalEnd: node.end,
|
||||||
}),
|
}),
|
||||||
@@ -106,10 +121,49 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
|
|||||||
const nodeId = nodeIdRef.current
|
const nodeId = nodeIdRef.current
|
||||||
const originalStart = originalStartRef.current
|
const originalStart = originalStartRef.current
|
||||||
const originalEnd = originalEndRef.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()
|
useScene.temporal.getState().pause()
|
||||||
let wasCommitted = false
|
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] }>) => {
|
const applyNodePreview = (updates: Array<{ id: FenceNode['id']; start: [number, number]; end: [number, number] }>) => {
|
||||||
useScene.getState().updateNodes(
|
useScene.getState().updateNodes(
|
||||||
updates.map((entry) => ({
|
updates.map((entry) => ({
|
||||||
@@ -127,21 +181,33 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
|
|||||||
const centerX = (nextStart[0] + nextEnd[0]) / 2
|
const centerX = (nextStart[0] + nextEnd[0]) / 2
|
||||||
const centerZ = (nextStart[1] + nextEnd[1]) / 2
|
const centerZ = (nextStart[1] + nextEnd[1]) / 2
|
||||||
setCursorLocalPos([centerX, 0, centerZ])
|
setCursorLocalPos([centerX, 0, centerZ])
|
||||||
applyNodePreview([
|
const deltaX = nextStart[0] - originalStart[0]
|
||||||
{ id: nodeId, start: nextStart, end: nextEnd },
|
const deltaZ = nextStart[1] - originalStart[1]
|
||||||
...getLinkedFenceUpdates(
|
setMeshOffset(nodeId, deltaX, deltaZ)
|
||||||
linkedOriginalsRef.current,
|
setFenceLiveTransform(node, deltaX, deltaZ)
|
||||||
originalStart,
|
|
||||||
originalEnd,
|
for (const linkedFence of linkedOriginalsRef.current) {
|
||||||
nextStart,
|
setMeshOffset(linkedFence.id, deltaX, deltaZ)
|
||||||
nextEnd,
|
setFenceLiveTransform(
|
||||||
),
|
{
|
||||||
])
|
...node,
|
||||||
|
id: linkedFence.id,
|
||||||
|
start: linkedFence.start,
|
||||||
|
end: linkedFence.end,
|
||||||
|
},
|
||||||
|
deltaX,
|
||||||
|
deltaZ,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const onGridMove = (event: GridEvent) => {
|
const onGridMove = (event: GridEvent) => {
|
||||||
const localX = snap(event.localPosition[0])
|
const [localX, localZ] = snapFenceDraftPoint({
|
||||||
const localZ = snap(event.localPosition[2])
|
point: [event.localPosition[0], event.localPosition[2]],
|
||||||
|
walls: levelWalls,
|
||||||
|
fences: levelFences,
|
||||||
|
ignoreFenceIds: [nodeId],
|
||||||
|
})
|
||||||
|
|
||||||
if (
|
if (
|
||||||
previousGridPosRef.current &&
|
previousGridPosRef.current &&
|
||||||
@@ -164,17 +230,15 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onGridClick = (event: GridEvent) => {
|
const onGridClick = (event: GridEvent) => {
|
||||||
|
if (Date.now() - activatedAtRef.current < 150) {
|
||||||
|
event.nativeEvent?.stopPropagation?.()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const preview = previewRef.current ?? { start: originalStart, end: originalEnd }
|
const preview = previewRef.current ?? { start: originalStart, end: originalEnd }
|
||||||
|
|
||||||
wasCommitted = true
|
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()
|
useScene.temporal.getState().resume()
|
||||||
applyNodePreview([
|
applyNodePreview([
|
||||||
{ id: nodeId, start: preview.start, end: preview.end },
|
{ id: nodeId, start: preview.start, end: preview.end },
|
||||||
@@ -186,6 +250,10 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
|
|||||||
preview.end,
|
preview.end,
|
||||||
),
|
),
|
||||||
])
|
])
|
||||||
|
useLiveTransforms.getState().clear(nodeId)
|
||||||
|
for (const linkedFence of linkedOriginalsRef.current) {
|
||||||
|
useLiveTransforms.getState().clear(linkedFence.id)
|
||||||
|
}
|
||||||
useScene.temporal.getState().pause()
|
useScene.temporal.getState().pause()
|
||||||
|
|
||||||
sfxEmitter.emit('sfx:item-place')
|
sfxEmitter.emit('sfx:item-place')
|
||||||
@@ -195,10 +263,7 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onCancel = () => {
|
const onCancel = () => {
|
||||||
applyNodePreview([
|
clearPreviewState()
|
||||||
{ id: nodeId, start: originalStart, end: originalEnd },
|
|
||||||
...linkedOriginalsRef.current,
|
|
||||||
])
|
|
||||||
useViewer.getState().setSelection({ selectedIds: [nodeId] })
|
useViewer.getState().setSelection({ selectedIds: [nodeId] })
|
||||||
useScene.temporal.getState().resume()
|
useScene.temporal.getState().resume()
|
||||||
markToolCancelConsumed()
|
markToolCancelConsumed()
|
||||||
@@ -211,17 +276,19 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (!wasCommitted) {
|
if (!wasCommitted) {
|
||||||
applyNodePreview([
|
clearPreviewState()
|
||||||
{ id: nodeId, start: originalStart, end: originalEnd },
|
} else {
|
||||||
...linkedOriginalsRef.current,
|
useLiveTransforms.getState().clear(nodeId)
|
||||||
])
|
for (const linkedFence of linkedOriginalsRef.current) {
|
||||||
|
useLiveTransforms.getState().clear(linkedFence.id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
useScene.temporal.getState().resume()
|
useScene.temporal.getState().resume()
|
||||||
emitter.off('grid:move', onGridMove)
|
emitter.off('grid:move', onGridMove)
|
||||||
emitter.off('grid:click', onGridClick)
|
emitter.off('grid:click', onGridClick)
|
||||||
emitter.off('tool:cancel', onCancel)
|
emitter.off('tool:cancel', onCancel)
|
||||||
}
|
}
|
||||||
}, [exitMoveMode])
|
}, [exitMoveMode, node])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<group>
|
<group>
|
||||||
|
|||||||
@@ -130,6 +130,7 @@ export function useDraftNode(): DraftNodeHandle {
|
|||||||
useScene.getState().updateNode(draft.id, {
|
useScene.getState().updateNode(draft.id, {
|
||||||
position: updateProps.position ?? draft.position,
|
position: updateProps.position ?? draft.position,
|
||||||
rotation: updateProps.rotation ?? draft.rotation,
|
rotation: updateProps.rotation ?? draft.rotation,
|
||||||
|
scale: updateProps.scale ?? draft.scale,
|
||||||
side: updateProps.side ?? draft.side,
|
side: updateProps.side ?? draft.side,
|
||||||
metadata: updateProps.metadata ?? stripTransient(draft.metadata),
|
metadata: updateProps.metadata ?? stripTransient(draft.metadata),
|
||||||
parentId: parentId as string,
|
parentId: parentId as string,
|
||||||
@@ -161,6 +162,7 @@ export function useDraftNode(): DraftNodeHandle {
|
|||||||
asset: draft.asset,
|
asset: draft.asset,
|
||||||
position: updateProps.position ?? draft.position,
|
position: updateProps.position ?? draft.position,
|
||||||
rotation: updateProps.rotation ?? draft.rotation,
|
rotation: updateProps.rotation ?? draft.rotation,
|
||||||
|
scale: updateProps.scale ?? draft.scale,
|
||||||
side: updateProps.side ?? draft.side,
|
side: updateProps.side ?? draft.side,
|
||||||
metadata: updateProps.metadata ?? stripTransient(draft.metadata),
|
metadata: updateProps.metadata ?? stripTransient(draft.metadata),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -16,15 +16,21 @@ import {
|
|||||||
type WallNode,
|
type WallNode,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { useFrame } from '@react-three/fiber'
|
import { Html } from '@react-three/drei'
|
||||||
import { useEffect, useRef } from 'react'
|
import { createPortal, useFrame } from '@react-three/fiber'
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
BoxGeometry,
|
BoxGeometry,
|
||||||
|
Box3,
|
||||||
|
BufferGeometry,
|
||||||
EdgesGeometry,
|
EdgesGeometry,
|
||||||
Euler,
|
Euler,
|
||||||
|
Float32BufferAttribute,
|
||||||
type Group,
|
type Group,
|
||||||
type LineSegments,
|
type LineSegments,
|
||||||
|
Matrix4,
|
||||||
type Mesh,
|
type Mesh,
|
||||||
|
type Object3D,
|
||||||
PlaneGeometry,
|
PlaneGeometry,
|
||||||
Quaternion,
|
Quaternion,
|
||||||
Vector3,
|
Vector3,
|
||||||
@@ -46,6 +52,119 @@ import type { DraftNodeHandle } from './use-draft-node'
|
|||||||
|
|
||||||
const DEFAULT_DIMENSIONS: [number, number, number] = [1, 1, 1]
|
const DEFAULT_DIMENSIONS: [number, number, number] = [1, 1, 1]
|
||||||
|
|
||||||
|
function formatMeasurement(value: number, unit: 'metric' | 'imperial') {
|
||||||
|
if (unit === 'imperial') {
|
||||||
|
const feet = value * 3.280_84
|
||||||
|
const wholeFeet = Math.floor(feet)
|
||||||
|
const inches = Math.round((feet - wholeFeet) * 12)
|
||||||
|
if (inches === 12) return `${wholeFeet + 1}'0"`
|
||||||
|
return `${wholeFeet}'${inches}"`
|
||||||
|
}
|
||||||
|
return `${Number.parseFloat(value.toFixed(2))}m`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PreviewBounds = {
|
||||||
|
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
|
// Shared materials for placement cursor - we just change colors, not swap materials
|
||||||
// Note: EdgesGeometry doesn't work with dashed lines, so using solid lines
|
// Note: EdgesGeometry doesn't work with dashed lines, so using solid lines
|
||||||
const edgeMaterial = new LineBasicNodeMaterial({
|
const edgeMaterial = new LineBasicNodeMaterial({
|
||||||
@@ -55,6 +174,13 @@ const edgeMaterial = new LineBasicNodeMaterial({
|
|||||||
depthWrite: false,
|
depthWrite: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const measurementMaterial = new LineBasicNodeMaterial({
|
||||||
|
color: 0x0f_17_2a,
|
||||||
|
linewidth: 2,
|
||||||
|
depthTest: false,
|
||||||
|
depthWrite: false,
|
||||||
|
})
|
||||||
|
|
||||||
const basePlaneMaterial = new MeshBasicNodeMaterial({
|
const basePlaneMaterial = new MeshBasicNodeMaterial({
|
||||||
color: 0xef_44_44, // red-500 (invalid)
|
color: 0xef_44_44, // red-500 (invalid)
|
||||||
transparent: true,
|
transparent: true,
|
||||||
@@ -82,6 +208,9 @@ export interface PlacementCoordinatorConfig {
|
|||||||
export function usePlacementCoordinator(config: PlacementCoordinatorConfig): React.ReactNode {
|
export function usePlacementCoordinator(config: PlacementCoordinatorConfig): React.ReactNode {
|
||||||
const cursorGroupRef = useRef<Group>(null!)
|
const cursorGroupRef = useRef<Group>(null!)
|
||||||
const edgesRef = useRef<LineSegments>(null!)
|
const edgesRef = useRef<LineSegments>(null!)
|
||||||
|
const measurementWidthRef = useRef<LineSegments>(null!)
|
||||||
|
const measurementDepthRef = useRef<LineSegments>(null!)
|
||||||
|
const measurementHeightRef = useRef<LineSegments>(null!)
|
||||||
const basePlaneRef = useRef<Mesh>(null!)
|
const basePlaneRef = useRef<Mesh>(null!)
|
||||||
const gridPosition = useRef(new Vector3(0, 0, 0))
|
const gridPosition = useRef(new Vector3(0, 0, 0))
|
||||||
const lastRawPos = useRef(new Vector3(0, 0, 0))
|
const lastRawPos = useRef(new Vector3(0, 0, 0))
|
||||||
@@ -89,6 +218,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
config.initialState ?? { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null },
|
config.initialState ?? { surface: 'floor', wallId: null, ceilingId: null, surfaceItemId: null },
|
||||||
)
|
)
|
||||||
const shiftFreeRef = useRef(false)
|
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
|
// Store config callbacks in refs to avoid re-running effect when they change
|
||||||
const configRef = useRef(config)
|
const configRef = useRef(config)
|
||||||
@@ -96,10 +232,128 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
|
|
||||||
const { canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling } = useSpatialQuery()
|
const { canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling } = useSpatialQuery()
|
||||||
const { asset, draftNode } = config
|
const { asset, draftNode } = config
|
||||||
|
const unit = useViewer((state) => state.unit)
|
||||||
|
|
||||||
|
const updatePreviewGeometry = (bounds: PreviewBounds) => {
|
||||||
|
const [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(() => {
|
useEffect(() => {
|
||||||
if (!asset) return
|
if (!asset) return
|
||||||
useScene.temporal.getState().pause()
|
useScene.temporal.getState().pause()
|
||||||
|
meshPreviewAppliedRef.current = false
|
||||||
|
|
||||||
const validators = { canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling }
|
const validators = { canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling }
|
||||||
|
|
||||||
@@ -829,12 +1083,14 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
// ---- Bounding box geometry ----
|
// ---- Bounding box geometry ----
|
||||||
|
|
||||||
const draft = draftNode.current
|
const draft = draftNode.current
|
||||||
const dims = draft ? getScaledDimensions(draft) : (asset.dimensions ?? DEFAULT_DIMENSIONS)
|
const fallbackBounds = getFallbackPreviewBounds(draft, asset, asset.attachTo)
|
||||||
const boxGeometry = new BoxGeometry(dims[0], dims[1], dims[2])
|
updatePreviewGeometry(
|
||||||
const wallSideZOffset = asset.attachTo === 'wall-side' ? -dims[2] / 2 : 0
|
draft
|
||||||
boxGeometry.translate(0, dims[1] / 2, wallSideZOffset)
|
? (getPreviewBoundsFromObject(sceneRegistry.nodes.get(draft.id) ?? null) ??
|
||||||
const edgesGeometry = new EdgesGeometry(boxGeometry)
|
fallbackBounds)
|
||||||
edgesRef.current.geometry = edgesGeometry
|
: fallbackBounds,
|
||||||
|
)
|
||||||
|
updateDimensionGuides(fallbackBounds)
|
||||||
|
|
||||||
// ---- Undo protection ----
|
// ---- Undo protection ----
|
||||||
// Undo replaces the entire `nodes` object with a previous snapshot, which doesn't
|
// Undo replaces the entire `nodes` object with a previous snapshot, which doesn't
|
||||||
@@ -878,6 +1134,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
tearingDown = true
|
tearingDown = true
|
||||||
|
meshPreviewAppliedRef.current = false
|
||||||
unsubDraftWatch()
|
unsubDraftWatch()
|
||||||
// Clear live transform for any remaining draft
|
// Clear live transform for any remaining draft
|
||||||
if (draftNode.current) {
|
if (draftNode.current) {
|
||||||
@@ -923,6 +1180,20 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
if (!draftNode.current) return
|
if (!draftNode.current) return
|
||||||
const mesh = sceneRegistry.nodes.get(draftNode.current.id)
|
const mesh = sceneRegistry.nodes.get(draftNode.current.id)
|
||||||
if (!mesh) return
|
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)
|
// Hide wall/ceiling-attached items when between surfaces (only cursor visible)
|
||||||
if (asset.attachTo && placementState.current.surface === 'floor') {
|
if (asset.attachTo && placementState.current.surface === 'floor') {
|
||||||
@@ -950,10 +1221,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
draftNode.current.rotation,
|
draftNode.current.rotation,
|
||||||
)
|
)
|
||||||
mesh.position.y = slabElevation
|
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -970,12 +1237,119 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
const basePlaneGeometry = new PlaneGeometry(dims[0], dims[2])
|
const basePlaneGeometry = new PlaneGeometry(dims[0], dims[2])
|
||||||
basePlaneGeometry.rotateX(-Math.PI / 2) // Make it horizontal
|
basePlaneGeometry.rotateX(-Math.PI / 2) // Make it horizontal
|
||||||
basePlaneGeometry.translate(0, 0.01, wallSideZOffset) // Slightly above ground to avoid z-fighting
|
basePlaneGeometry.translate(0, 0.01, wallSideZOffset) // Slightly above ground to avoid z-fighting
|
||||||
|
const initialDimensionBounds = getFallbackPreviewBounds(initialDraft, config.asset!, config.asset?.attachTo)
|
||||||
|
const widthLabel = formatMeasurement(initialDimensionBounds.dimensions[0], unit)
|
||||||
|
const depthLabel = formatMeasurement(initialDimensionBounds.dimensions[2], unit)
|
||||||
|
const heightLabel = formatMeasurement(initialDimensionBounds.dimensions[1], unit)
|
||||||
|
const widthLabelPosition: [number, number, number] = [
|
||||||
|
initialDimensionBounds.center[0],
|
||||||
|
0.04,
|
||||||
|
initialDimensionBounds.center[2] + initialDimensionBounds.dimensions[2] / 2 + 0.24,
|
||||||
|
]
|
||||||
|
const depthLabelPosition: [number, number, number] = [
|
||||||
|
initialDimensionBounds.center[0] + initialDimensionBounds.dimensions[0] / 2 + 0.24,
|
||||||
|
0.04,
|
||||||
|
initialDimensionBounds.center[2],
|
||||||
|
]
|
||||||
|
const heightLabelPosition: [number, number, number] = [
|
||||||
|
initialDimensionBounds.center[0] - initialDimensionBounds.dimensions[0] / 2 - 0.24,
|
||||||
|
initialDimensionBounds.dimensions[1] / 2,
|
||||||
|
initialDimensionBounds.center[2] - initialDimensionBounds.dimensions[2] / 2,
|
||||||
|
]
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<group ref={cursorGroupRef}>
|
<group ref={cursorGroupRef}>
|
||||||
<lineSegments layers={EDITOR_LAYER} material={edgeMaterial} ref={edgesRef} renderOrder={999}>
|
<lineSegments layers={EDITOR_LAYER} material={edgeMaterial} ref={edgesRef} renderOrder={999}>
|
||||||
<edgesGeometry args={[initialBoxGeometry]} />
|
<edgesGeometry args={[initialBoxGeometry]} />
|
||||||
</lineSegments>
|
</lineSegments>
|
||||||
|
{measurementTarget ? createPortal(measurementContent, measurementTarget) : measurementContent}
|
||||||
<mesh
|
<mesh
|
||||||
geometry={basePlaneGeometry}
|
geometry={basePlaneGeometry}
|
||||||
layers={EDITOR_LAYER}
|
layers={EDITOR_LAYER}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import * as THREE from 'three'
|
import * as THREE from 'three'
|
||||||
|
import { clearRoofDuplicateMetadata } from '../../../lib/roof-duplication'
|
||||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||||
import useEditor from '../../../store/use-editor'
|
import useEditor from '../../../store/use-editor'
|
||||||
import { snapFenceDraftPoint } from '../fence/fence-drafting'
|
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
|
// resetting the mesh position (it resets on dirty) and from triggering
|
||||||
// expensive merged-mesh CSG rebuilds on every frame.
|
// expensive merged-mesh CSG rebuilds on every frame.
|
||||||
let wasCommitted = false
|
let wasCommitted = false
|
||||||
|
let wasCancelled = false
|
||||||
|
|
||||||
// Track pending rotation — no store updates during drag
|
// Track pending rotation — no store updates during drag
|
||||||
let pendingRotation: number = movingNode.rotation as number
|
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.
|
// Resume temporal and apply the final state as a single undoable step.
|
||||||
useScene.temporal.getState().resume()
|
useScene.temporal.getState().resume()
|
||||||
|
|
||||||
|
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, {
|
useScene.getState().updateNode(movingNode.id, {
|
||||||
position: [localX, movingNode.position[1], localZ],
|
position: [localX, movingNode.position[1], localZ],
|
||||||
rotation: pendingRotation,
|
rotation: pendingRotation,
|
||||||
metadata: committedMeta,
|
metadata: committedMeta,
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
useScene.temporal.getState().pause()
|
useScene.temporal.getState().pause()
|
||||||
|
|
||||||
@@ -267,6 +277,7 @@ export const MoveRoofTool: React.FC<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onCancel = () => {
|
const onCancel = () => {
|
||||||
|
wasCancelled = true
|
||||||
useLiveTransforms.getState().clear(movingNode.id)
|
useLiveTransforms.getState().clear(movingNode.id)
|
||||||
if (isNew) {
|
if (isNew) {
|
||||||
useScene.getState().deleteNode(movingNode.id)
|
useScene.getState().deleteNode(movingNode.id)
|
||||||
@@ -325,17 +336,13 @@ export const MoveRoofTool: React.FC<{
|
|||||||
// Clear ephemeral live transform
|
// Clear ephemeral live transform
|
||||||
useLiveTransforms.getState().clear(movingNode.id)
|
useLiveTransforms.getState().clear(movingNode.id)
|
||||||
|
|
||||||
if (!wasCommitted) {
|
if (!(wasCommitted || wasCancelled || isNew)) {
|
||||||
if (isNew) {
|
|
||||||
useScene.getState().deleteNode(movingNode.id)
|
|
||||||
} else {
|
|
||||||
useScene.getState().updateNode(movingNode.id, {
|
useScene.getState().updateNode(movingNode.id, {
|
||||||
position: original.position,
|
position: original.position,
|
||||||
rotation: original.rotation,
|
rotation: original.rotation,
|
||||||
metadata: original.metadata,
|
metadata: original.metadata,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
|
||||||
useScene.temporal.getState().resume()
|
useScene.temporal.getState().resume()
|
||||||
emitter.off('grid:move', onGridMove)
|
emitter.off('grid:move', onGridMove)
|
||||||
emitter.off('grid:click', onGridClick)
|
emitter.off('grid:click', onGridClick)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
isCurvedWall,
|
isCurvedWall,
|
||||||
sceneRegistry,
|
sceneRegistry,
|
||||||
spatialGridManager,
|
spatialGridManager,
|
||||||
|
useLiveTransforms,
|
||||||
useScene,
|
useScene,
|
||||||
type WallEvent,
|
type WallEvent,
|
||||||
WindowNode,
|
WindowNode,
|
||||||
@@ -144,6 +145,10 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin
|
|||||||
parentId: event.node.id,
|
parentId: event.node.id,
|
||||||
wallId: 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)
|
if (prevWallId && prevWallId !== event.node.id) markWallDirty(prevWallId)
|
||||||
markWallDirty(event.node.id)
|
markWallDirty(event.node.id)
|
||||||
@@ -215,6 +220,10 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin
|
|||||||
windowMesh.updateMatrixWorld(true)
|
windowMesh.updateMatrixWorld(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
useLiveTransforms.getState().set(movingWindowNode.id, {
|
||||||
|
position: [clampedX, clampedY, 0],
|
||||||
|
rotation: itemRotation,
|
||||||
|
})
|
||||||
markWallDirty(event.node.id)
|
markWallDirty(event.node.id)
|
||||||
|
|
||||||
const valid = !hasWallChildOverlap(
|
const valid = !hasWallChildOverlap(
|
||||||
@@ -326,6 +335,7 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin
|
|||||||
}
|
}
|
||||||
|
|
||||||
markWallDirty(event.node.id)
|
markWallDirty(event.node.id)
|
||||||
|
useLiveTransforms.getState().clear(movingWindowNode.id)
|
||||||
useScene.temporal.getState().pause()
|
useScene.temporal.getState().pause()
|
||||||
|
|
||||||
sfxEmitter.emit('sfx:item-place')
|
sfxEmitter.emit('sfx:item-place')
|
||||||
@@ -337,6 +347,7 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin
|
|||||||
|
|
||||||
const onWallLeave = () => {
|
const onWallLeave = () => {
|
||||||
hideCursor()
|
hideCursor()
|
||||||
|
useLiveTransforms.getState().clear(movingWindowNode.id)
|
||||||
if (isNew) return // No original to restore for duplicates
|
if (isNew) return // No original to restore for duplicates
|
||||||
// Move mode: restore to original position while off-wall
|
// Move mode: restore to original position while off-wall
|
||||||
if (currentWallId && currentWallId !== original.parentId) {
|
if (currentWallId && currentWallId !== original.parentId) {
|
||||||
@@ -354,6 +365,7 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onCancel = () => {
|
const onCancel = () => {
|
||||||
|
useLiveTransforms.getState().clear(movingWindowNode.id)
|
||||||
if (isNew) {
|
if (isNew) {
|
||||||
useScene.getState().deleteNode(movingWindowNode.id)
|
useScene.getState().deleteNode(movingWindowNode.id)
|
||||||
if (currentWallId) markWallDirty(currentWallId)
|
if (currentWallId) markWallDirty(currentWallId)
|
||||||
@@ -401,6 +413,7 @@ export const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWin
|
|||||||
if (original.parentId) markWallDirty(original.parentId)
|
if (original.parentId) markWallDirty(original.parentId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
useLiveTransforms.getState().clear(movingWindowNode.id)
|
||||||
useScene.temporal.getState().resume()
|
useScene.temporal.getState().resume()
|
||||||
emitter.off('wall:enter', onWallEnter)
|
emitter.off('wall:enter', onWallEnter)
|
||||||
emitter.off('wall:move', onWallMove)
|
emitter.off('wall:move', onWallMove)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { emitter } from '@pascal-app/core'
|
|||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
import { ActionButton } from './action-button'
|
import { ActionButton } from './action-button'
|
||||||
|
|
||||||
export function CameraActions() {
|
export function CameraActions({ hideOrbit = false }: { hideOrbit?: boolean }) {
|
||||||
const goToTopView = () => {
|
const goToTopView = () => {
|
||||||
emitter.emit('camera-controls:top-view')
|
emitter.emit('camera-controls:top-view')
|
||||||
}
|
}
|
||||||
@@ -19,6 +19,8 @@ export function CameraActions() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
|
{!hideOrbit && (
|
||||||
|
<>
|
||||||
{/* Orbit CCW */}
|
{/* Orbit CCW */}
|
||||||
<ActionButton
|
<ActionButton
|
||||||
className="group hover:bg-white/5"
|
className="group hover:bg-white/5"
|
||||||
@@ -52,6 +54,8 @@ export function CameraActions() {
|
|||||||
width={28}
|
width={28}
|
||||||
/>
|
/>
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Top View */}
|
{/* Top View */}
|
||||||
<ActionButton
|
<ActionButton
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ import { useScene } from '@pascal-app/core'
|
|||||||
import { AnimatePresence, motion } from 'motion/react'
|
import { AnimatePresence, motion } from 'motion/react'
|
||||||
import { useEffect, useMemo } from 'react'
|
import { useEffect, useMemo } from 'react'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
|
import { useReducedMotion } from './../../../hooks/use-reduced-motion'
|
||||||
|
import { useIsMobile } from './../../../hooks/use-mobile'
|
||||||
import { TooltipProvider } from './../../../components/ui/primitives/tooltip'
|
import { TooltipProvider } from './../../../components/ui/primitives/tooltip'
|
||||||
import { MaterialPicker } from './../../../components/ui/controls/material-picker'
|
import { MaterialPicker } from './../../../components/ui/controls/material-picker'
|
||||||
import { useReducedMotion } from './../../../hooks/use-reduced-motion'
|
|
||||||
import { resolvePaintTargetFromSelection } from './../../../lib/material-paint'
|
import { resolvePaintTargetFromSelection } from './../../../lib/material-paint'
|
||||||
import { cn } from './../../../lib/utils'
|
import { cn } from './../../../lib/utils'
|
||||||
import useEditor from './../../../store/use-editor'
|
import useEditor from './../../../store/use-editor'
|
||||||
@@ -58,8 +59,23 @@ export function ActionMenu({ className }: { className?: string }) {
|
|||||||
const mode = useEditor((state) => state.mode)
|
const mode = useEditor((state) => state.mode)
|
||||||
const tool = useEditor((state) => state.tool)
|
const tool = useEditor((state) => state.tool)
|
||||||
const catalogCategory = useEditor((state) => state.catalogCategory)
|
const catalogCategory = useEditor((state) => state.catalogCategory)
|
||||||
|
const isMobile = useIsMobile()
|
||||||
|
const hasSelectionOnMobile = useViewer((s) => isMobile && s.selection.selectedIds.length > 0)
|
||||||
|
const hasReferenceOnMobile = useEditor((s) => isMobile && Boolean(s.selectedReferenceId))
|
||||||
|
const CONTEXTUAL_TABS = new Set(['ai', 'items', 'studio'])
|
||||||
|
const isContextualPanelOnMobile = useEditor(
|
||||||
|
(s) => isMobile && CONTEXTUAL_TABS.has(s.activeSidebarPanel),
|
||||||
|
)
|
||||||
const reducedMotion = useReducedMotion()
|
const reducedMotion = useReducedMotion()
|
||||||
const showPaintTray = useMemo(() => mode === 'material-paint', [mode])
|
const showPaintTray = useMemo(() => mode === 'material-paint', [mode])
|
||||||
|
|
||||||
|
// On mobile, defer the bottom rail to the selection bar when something
|
||||||
|
// is selected — the contextual actions take priority over mode controls.
|
||||||
|
// Also hide on Chat / Items / Studio tabs; those are contextual workflows
|
||||||
|
// (composing / picking furniture / generating renders) where the build
|
||||||
|
// menu is irrelevant.
|
||||||
|
if (hasSelectionOnMobile || hasReferenceOnMobile || isContextualPanelOnMobile) return null
|
||||||
|
|
||||||
const transition = reducedMotion
|
const transition = reducedMotion
|
||||||
? { duration: 0 }
|
? { duration: 0 }
|
||||||
: { type: 'spring' as const, bounce: 0.2, duration: 0.4 }
|
: { type: 'spring' as const, bounce: 0.2, duration: 0.4 }
|
||||||
|
|||||||
@@ -8,17 +8,24 @@ import {
|
|||||||
useScene,
|
useScene,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { ChevronDown, Plus, Trash2 } from 'lucide-react'
|
import { Check, ChevronDown, Plus, Trash2 } from 'lucide-react'
|
||||||
import { useCallback, useRef, useState } from 'react'
|
import { useCallback, useRef, useState } from 'react'
|
||||||
import { useShallow } from 'zustand/react/shallow'
|
import { useShallow } from 'zustand/react/shallow'
|
||||||
import { cn } from '../../../lib/utils'
|
import { cn } from '../../../lib/utils'
|
||||||
|
import useEditor, { type GridSnapStep } from '../../../store/use-editor'
|
||||||
import { useUploadStore } from '../../../store/use-upload'
|
import { useUploadStore } from '../../../store/use-upload'
|
||||||
import { SliderControl } from '../controls/slider-control'
|
import { SliderControl } from '../controls/slider-control'
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '../primitives/popover'
|
import { Popover, PopoverContent, PopoverTrigger } from '../primitives/popover'
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from '../primitives/tooltip'
|
||||||
import { ActionButton } from './action-button'
|
import { ActionButton } from './action-button'
|
||||||
|
|
||||||
const MAX_FILE_SIZE = 200 * 1024 * 1024 // 200MB
|
const MAX_FILE_SIZE = 200 * 1024 * 1024 // 200MB
|
||||||
const ACCEPTED_FILE_TYPES = '.glb,.gltf,image/jpeg,image/png,image/webp,image/gif'
|
const ACCEPTED_FILE_TYPES = '.glb,.gltf,image/jpeg,image/png,image/webp,image/gif'
|
||||||
|
const GRID_SNAP_STEPS: GridSnapStep[] = [0.5, 0.25, 0.1, 0.05]
|
||||||
|
|
||||||
|
function formatGridSnapStep(step: GridSnapStep) {
|
||||||
|
return step.toFixed(2)
|
||||||
|
}
|
||||||
|
|
||||||
// ── Helper: get guide images for the current level ──────────────────────────
|
// ── Helper: get guide images for the current level ──────────────────────────
|
||||||
|
|
||||||
@@ -246,6 +253,83 @@ function GuidesControl() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Grid snap ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function GridSnapControl() {
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
const gridSnapStep = useEditor((state) => state.gridSnapStep)
|
||||||
|
const setGridSnapStep = useEditor((state) => state.setGridSnapStep)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover onOpenChange={setIsOpen} open={isOpen}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<button
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
aria-label={`Grid snap: ${formatGridSnapStep(gridSnapStep)}`}
|
||||||
|
className={cn(
|
||||||
|
'flex h-11 w-11 flex-col items-center justify-center rounded-lg text-muted-foreground transition-all hover:bg-white/5 hover:text-foreground',
|
||||||
|
isOpen && 'bg-white/10 text-foreground',
|
||||||
|
)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M3 3h7v7H3V3zm11 0h7v7h-7V3zm0 11h7v7h-7v-7zm-11 0h7v7H3v-7z"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span className="mt-1 font-medium text-[9px] leading-none">
|
||||||
|
{formatGridSnapStep(gridSnapStep)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top">Grid snap: {formatGridSnapStep(gridSnapStep)}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<PopoverContent
|
||||||
|
align="center"
|
||||||
|
className="w-36 rounded-xl border-border/45 bg-background/96 p-2 shadow-elevation-3 backdrop-blur-xl"
|
||||||
|
side="top"
|
||||||
|
sideOffset={14}
|
||||||
|
>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{GRID_SNAP_STEPS.map((step) => {
|
||||||
|
const isActive = step === gridSnapStep
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
'flex w-full items-center justify-between rounded-lg px-2.5 py-2 text-left text-sm transition-colors hover:bg-white/8',
|
||||||
|
isActive && 'bg-white/10 text-foreground',
|
||||||
|
)}
|
||||||
|
key={step}
|
||||||
|
onClick={() => {
|
||||||
|
setGridSnapStep(step)
|
||||||
|
setIsOpen(false)
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span>{formatGridSnapStep(step)}</span>
|
||||||
|
{isActive ? <Check className="h-3.5 w-3.5" /> : <span className="h-3.5 w-3.5" />}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ── Scans toggle + dropdown ─────────────────────────────────────────────────
|
// ── Scans toggle + dropdown ─────────────────────────────────────────────────
|
||||||
|
|
||||||
function ScansControl() {
|
function ScansControl() {
|
||||||
@@ -395,3 +479,14 @@ export function ViewToggles() {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Secondary toggles for mobile (grid snap + scans + guides)
|
||||||
|
export function SecondaryToggles() {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<GridSnapControl />
|
||||||
|
<ScansControl />
|
||||||
|
<GuidesControl />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,6 +22,17 @@ function stepPrecision(s: number): number {
|
|||||||
return Math.max(0, Math.ceil(-Math.log10(s)))
|
return Math.max(0, Math.ceil(-Math.log10(s)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getStepMultiplier(modifiers: {
|
||||||
|
shiftKey?: boolean
|
||||||
|
metaKey?: boolean
|
||||||
|
ctrlKey?: boolean
|
||||||
|
altKey?: boolean
|
||||||
|
}): number {
|
||||||
|
if (modifiers.shiftKey) return 10
|
||||||
|
if (modifiers.metaKey || modifiers.ctrlKey || modifiers.altKey) return 0.1
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
function getAdjustedStep(
|
function getAdjustedStep(
|
||||||
baseStep: number,
|
baseStep: number,
|
||||||
modifiers: {
|
modifiers: {
|
||||||
@@ -31,9 +42,7 @@ function getAdjustedStep(
|
|||||||
altKey?: boolean
|
altKey?: boolean
|
||||||
},
|
},
|
||||||
): number {
|
): number {
|
||||||
if (modifiers.shiftKey) return baseStep * 10
|
return baseStep * getStepMultiplier(modifiers)
|
||||||
if (modifiers.metaKey || modifiers.ctrlKey || modifiers.altKey) return baseStep * 0.1
|
|
||||||
return baseStep
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SliderControl({
|
export function SliderControl({
|
||||||
@@ -53,7 +62,17 @@ export function SliderControl({
|
|||||||
const [isHovered, setIsHovered] = useState(false)
|
const [isHovered, setIsHovered] = useState(false)
|
||||||
const [inputValue, setInputValue] = useState(value.toFixed(precision))
|
const [inputValue, setInputValue] = useState(value.toFixed(precision))
|
||||||
|
|
||||||
const dragRef = useRef<{ startX: number; startValue: number } | null>(null)
|
const dragRef = useRef<{
|
||||||
|
// Original value at drag start — preserved across modifier re-anchors so
|
||||||
|
// undo/redo rolls back to the pre-drag state, not to a mid-drag anchor.
|
||||||
|
originValue: number
|
||||||
|
// Anchor pointer position and value — updated whenever modifier keys
|
||||||
|
// change so the delta calculation continues smoothly from the current
|
||||||
|
// position at the new step size.
|
||||||
|
anchorX: number
|
||||||
|
anchorValue: number
|
||||||
|
stepMultiplier: number
|
||||||
|
} | null>(null)
|
||||||
const labelRef = useRef<HTMLDivElement>(null)
|
const labelRef = useRef<HTMLDivElement>(null)
|
||||||
const valueRef = useRef(value)
|
const valueRef = useRef(value)
|
||||||
valueRef.current = value
|
valueRef.current = value
|
||||||
@@ -109,7 +128,12 @@ export function SliderControl({
|
|||||||
if (isEditing) return
|
if (isEditing) return
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.currentTarget.setPointerCapture(e.pointerId)
|
e.currentTarget.setPointerCapture(e.pointerId)
|
||||||
dragRef.current = { startX: e.clientX, startValue: valueRef.current }
|
dragRef.current = {
|
||||||
|
originValue: valueRef.current,
|
||||||
|
anchorX: e.clientX,
|
||||||
|
anchorValue: valueRef.current,
|
||||||
|
stepMultiplier: getStepMultiplier(e),
|
||||||
|
}
|
||||||
setIsDragging(true)
|
setIsDragging(true)
|
||||||
useScene.temporal.getState().pause()
|
useScene.temporal.getState().pause()
|
||||||
},
|
},
|
||||||
@@ -119,12 +143,23 @@ export function SliderControl({
|
|||||||
const handleLabelPointerMove = useCallback(
|
const handleLabelPointerMove = useCallback(
|
||||||
(e: React.PointerEvent<HTMLDivElement>) => {
|
(e: React.PointerEvent<HTMLDivElement>) => {
|
||||||
if (!dragRef.current) return
|
if (!dragRef.current) return
|
||||||
const { startX, startValue } = dragRef.current
|
const multiplier = getStepMultiplier(e)
|
||||||
const dx = e.clientX - startX
|
// If modifier keys changed mid-drag, re-anchor from the current pointer
|
||||||
const s = getAdjustedStep(step, e)
|
// position and value — otherwise the accumulated dx would be applied
|
||||||
|
// with a new step size and jump the value (e.g. pressing Cmd while
|
||||||
|
// already far from the starting point would snap back toward it).
|
||||||
|
if (multiplier !== dragRef.current.stepMultiplier) {
|
||||||
|
dragRef.current.anchorX = e.clientX
|
||||||
|
dragRef.current.anchorValue = valueRef.current
|
||||||
|
dragRef.current.stepMultiplier = multiplier
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const { anchorX, anchorValue } = dragRef.current
|
||||||
|
const dx = e.clientX - anchorX
|
||||||
|
const s = step * multiplier
|
||||||
// 4 px per step at default sensitivity
|
// 4 px per step at default sensitivity
|
||||||
const newValue = clamp(
|
const newValue = clamp(
|
||||||
Number.parseFloat((startValue + (dx / 4) * s).toFixed(stepPrecision(s))),
|
Number.parseFloat((anchorValue + (dx / 4) * s).toFixed(stepPrecision(s))),
|
||||||
)
|
)
|
||||||
onChange(newValue)
|
onChange(newValue)
|
||||||
},
|
},
|
||||||
@@ -134,14 +169,14 @@ export function SliderControl({
|
|||||||
const handleLabelPointerUp = useCallback(
|
const handleLabelPointerUp = useCallback(
|
||||||
(e: React.PointerEvent<HTMLDivElement>) => {
|
(e: React.PointerEvent<HTMLDivElement>) => {
|
||||||
if (!dragRef.current) return
|
if (!dragRef.current) return
|
||||||
const { startValue } = dragRef.current
|
const { originValue } = dragRef.current
|
||||||
const finalVal = valueRef.current
|
const finalVal = valueRef.current
|
||||||
dragRef.current = null
|
dragRef.current = null
|
||||||
setIsDragging(false)
|
setIsDragging(false)
|
||||||
e.currentTarget.releasePointerCapture(e.pointerId)
|
e.currentTarget.releasePointerCapture(e.pointerId)
|
||||||
|
|
||||||
if (startValue !== finalVal) {
|
if (originValue !== finalVal) {
|
||||||
onChange(startValue)
|
onChange(originValue)
|
||||||
useScene.temporal.getState().resume()
|
useScene.temporal.getState().resume()
|
||||||
onChange(finalVal)
|
onChange(finalVal)
|
||||||
onCommit?.(finalVal)
|
onCommit?.(finalVal)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,7 @@
|
|||||||
import type { AssetInput } from '@pascal-app/core'
|
import type { AssetInput } from '@pascal-app/core'
|
||||||
import { resolveCdnUrl } from '@pascal-app/viewer'
|
import { resolveCdnUrl } from '@pascal-app/viewer'
|
||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect } from 'react'
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
@@ -13,54 +13,19 @@ import { cn } from './../../../lib/utils'
|
|||||||
import useEditor, { type CatalogCategory } from './../../../store/use-editor'
|
import useEditor, { type CatalogCategory } from './../../../store/use-editor'
|
||||||
import { CATALOG_ITEMS } from './catalog-items'
|
import { CATALOG_ITEMS } from './catalog-items'
|
||||||
|
|
||||||
const PLACEMENT_TAGS = new Set(['floor', 'wall', 'ceiling', 'countertop'])
|
|
||||||
|
|
||||||
export function ItemCatalog({ category }: { category: CatalogCategory }) {
|
export function ItemCatalog({ category }: { category: CatalogCategory }) {
|
||||||
const selectedItem = useEditor((state) => state.selectedItem)
|
const selectedItem = useEditor((state) => state.selectedItem)
|
||||||
const setSelectedItem = useEditor((state) => state.setSelectedItem)
|
const setSelectedItem = useEditor((state) => state.setSelectedItem)
|
||||||
const [activePlacementTag, setActivePlacementTag] = useState<string | null>(null)
|
|
||||||
const [activeFunctionalTag, setActiveFunctionalTag] = useState<string | null>(null)
|
|
||||||
|
|
||||||
const categoryItems = CATALOG_ITEMS.filter((item) => item.category === category)
|
const categoryItems = CATALOG_ITEMS.filter((item) => item.category === category)
|
||||||
|
|
||||||
// Collect tags available in this category
|
// Auto-select first item if current selection is not in this category
|
||||||
const allTags = Array.from(new Set(categoryItems.flatMap((item) => item.tags ?? [])))
|
|
||||||
const placementTags = allTags.filter((t) => PLACEMENT_TAGS.has(t))
|
|
||||||
const functionalTags = allTags.filter((t) => !PLACEMENT_TAGS.has(t))
|
|
||||||
const hasFilters = allTags.length > 1
|
|
||||||
|
|
||||||
// Count items for a placement tag given the current functional filter
|
|
||||||
const placementCount = (tag: string | null) =>
|
|
||||||
categoryItems.filter((item) => {
|
|
||||||
const tags = item.tags ?? []
|
|
||||||
if (tag !== null && !tags.includes(tag)) return false
|
|
||||||
if (activeFunctionalTag && !tags.includes(activeFunctionalTag)) return false
|
|
||||||
return true
|
|
||||||
}).length
|
|
||||||
|
|
||||||
// Count items for a functional tag given the current placement filter
|
|
||||||
const functionalCount = (tag: string) =>
|
|
||||||
categoryItems.filter((item) => {
|
|
||||||
const tags = item.tags ?? []
|
|
||||||
if (!tags.includes(tag)) return false
|
|
||||||
if (activePlacementTag && !tags.includes(activePlacementTag)) return false
|
|
||||||
return true
|
|
||||||
}).length
|
|
||||||
|
|
||||||
const filteredItems = categoryItems.filter((item) => {
|
|
||||||
const tags = item.tags ?? []
|
|
||||||
if (activePlacementTag && !tags.includes(activePlacementTag)) return false
|
|
||||||
if (activeFunctionalTag && !tags.includes(activeFunctionalTag)) return false
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
|
|
||||||
// Auto-select first item if current selection is not in the filtered list
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const isCurrentItemInCategory = filteredItems.some((item) => item.src === selectedItem?.src)
|
const isCurrentItemInCategory = categoryItems.some((item) => item.src === selectedItem?.src)
|
||||||
if (!isCurrentItemInCategory && filteredItems.length > 0) {
|
if (!isCurrentItemInCategory && categoryItems.length > 0) {
|
||||||
setSelectedItem(filteredItems[0] as AssetInput)
|
setSelectedItem(categoryItems[0] as AssetInput)
|
||||||
}
|
}
|
||||||
}, [filteredItems, selectedItem?.src, setSelectedItem])
|
}, [categoryItems, selectedItem?.src, setSelectedItem])
|
||||||
|
|
||||||
// Get attachment icon based on attachTo type
|
// Get attachment icon based on attachTo type
|
||||||
const getAttachmentIcon = (attachTo: AssetInput['attachTo']) => {
|
const getAttachmentIcon = (attachTo: AssetInput['attachTo']) => {
|
||||||
@@ -74,105 +39,8 @@ export function ItemCatalog({ category }: { category: CatalogCategory }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
{/* Filter chips */}
|
|
||||||
{hasFilters && (
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
{/* Placement row */}
|
|
||||||
{placementTags.length > 0 && (
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
<button
|
|
||||||
className={cn(
|
|
||||||
'cursor-pointer rounded-md px-2 py-0.5 font-medium text-xs transition-colors',
|
|
||||||
activePlacementTag === null
|
|
||||||
? 'bg-blue-500 text-white'
|
|
||||||
: 'bg-blue-950/50 text-blue-300 hover:bg-blue-900/60 hover:text-blue-200',
|
|
||||||
)}
|
|
||||||
onClick={() => setActivePlacementTag(null)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
All
|
|
||||||
</button>
|
|
||||||
{placementTags.map((tag) => {
|
|
||||||
const count = placementCount(tag)
|
|
||||||
const isActive = activePlacementTag === tag
|
|
||||||
const isEmpty = count === 0 && !isActive
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
className={cn(
|
|
||||||
'inline-flex cursor-pointer items-center gap-1 rounded-md py-0.5 pr-1.5 pl-2 font-medium text-xs capitalize transition-colors',
|
|
||||||
isActive
|
|
||||||
? 'bg-blue-500 text-white'
|
|
||||||
: isEmpty
|
|
||||||
? 'cursor-not-allowed bg-zinc-800 text-zinc-500'
|
|
||||||
: 'bg-blue-950/50 text-blue-300 hover:bg-blue-900/60 hover:text-blue-200',
|
|
||||||
)}
|
|
||||||
disabled={isEmpty}
|
|
||||||
key={tag}
|
|
||||||
onClick={() => setActivePlacementTag(isActive ? null : tag)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{tag}
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
'text-[10px]',
|
|
||||||
isActive ? 'text-blue-200' : isEmpty ? 'text-zinc-600' : 'text-blue-500/70',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{count}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Functional row */}
|
|
||||||
{functionalTags.length > 0 && (
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{functionalTags.map((tag) => {
|
|
||||||
const count = functionalCount(tag)
|
|
||||||
const isActive = activeFunctionalTag === tag
|
|
||||||
const isEmpty = count === 0 && !isActive
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
className={cn(
|
|
||||||
'inline-flex cursor-pointer items-center gap-1 rounded-md py-0.5 pr-1.5 pl-2 font-medium text-xs capitalize transition-colors',
|
|
||||||
isActive
|
|
||||||
? 'bg-violet-500 text-white'
|
|
||||||
: isEmpty
|
|
||||||
? 'cursor-not-allowed bg-zinc-800 text-zinc-500'
|
|
||||||
: 'bg-muted text-muted-foreground hover:bg-muted/80 hover:text-foreground',
|
|
||||||
)}
|
|
||||||
disabled={isEmpty}
|
|
||||||
key={tag}
|
|
||||||
onClick={() => setActiveFunctionalTag(isActive ? null : tag)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{tag}
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
'text-[10px]',
|
|
||||||
isActive
|
|
||||||
? 'text-violet-200'
|
|
||||||
: isEmpty
|
|
||||||
? 'text-zinc-600'
|
|
||||||
: 'text-zinc-500/70',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{count}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Items */}
|
|
||||||
<div className="-mx-2 -my-2 flex max-w-xl gap-2 overflow-x-auto p-2">
|
<div className="-mx-2 -my-2 flex max-w-xl gap-2 overflow-x-auto p-2">
|
||||||
{filteredItems.map((item, index) => {
|
{categoryItems.map((item, index) => {
|
||||||
const isSelected = selectedItem?.src === item?.src
|
const isSelected = selectedItem?.src === item?.src
|
||||||
const attachmentIcon = getAttachmentIcon(item?.attachTo)
|
const attachmentIcon = getAttachmentIcon(item?.attachTo)
|
||||||
return (
|
return (
|
||||||
@@ -214,6 +82,5 @@ export function ItemCatalog({ category }: { category: CatalogCategory }) {
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { X } from 'lucide-react'
|
||||||
|
import { AnimatePresence, motion } from 'motion/react'
|
||||||
|
import Image from 'next/image'
|
||||||
|
import { type ReactNode, useEffect, useState } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
|
import useEditor from '../../../store/use-editor'
|
||||||
|
|
||||||
|
interface MobilePanelSheetProps {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
icon?: string
|
||||||
|
title: string
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
const HEIGHT_VH = 50
|
||||||
|
const DRAG_CLOSE_THRESHOLD_PX = 120
|
||||||
|
|
||||||
|
export function MobilePanelSheet({ open, onClose, icon, title, children }: MobilePanelSheetProps) {
|
||||||
|
const [mounted, setMounted] = useState(false)
|
||||||
|
const setMobilePanelSheetHeight = useEditor((s) => s.setMobilePanelSheetHeight)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Publish the sheet's pixel height to the shared store so the mobile layout
|
||||||
|
// can shrink the viewer container and preview edits live. 0 means closed.
|
||||||
|
// Tracks visualViewport so the value follows the on-screen keyboard on iOS.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setMobilePanelSheetHeight(0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const compute = () => {
|
||||||
|
const vh = window.visualViewport?.height ?? window.innerHeight
|
||||||
|
setMobilePanelSheetHeight(Math.round((vh * HEIGHT_VH) / 100))
|
||||||
|
}
|
||||||
|
compute()
|
||||||
|
const vv = window.visualViewport
|
||||||
|
vv?.addEventListener('resize', compute)
|
||||||
|
window.addEventListener('resize', compute)
|
||||||
|
return () => {
|
||||||
|
vv?.removeEventListener('resize', compute)
|
||||||
|
window.removeEventListener('resize', compute)
|
||||||
|
setMobilePanelSheetHeight(0)
|
||||||
|
}
|
||||||
|
}, [open, setMobilePanelSheetHeight])
|
||||||
|
|
||||||
|
if (!mounted) return null
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<AnimatePresence>
|
||||||
|
{open && (
|
||||||
|
<motion.div
|
||||||
|
animate={{ y: 0 }}
|
||||||
|
className="dark fixed right-0 bottom-0 left-0 z-[60] flex flex-col overflow-hidden rounded-t-2xl bg-sidebar text-sidebar-foreground shadow-[0_-8px_24px_rgba(0,0,0,0.24)]"
|
||||||
|
drag="y"
|
||||||
|
dragConstraints={{ top: 0, bottom: 0 }}
|
||||||
|
dragElastic={{ top: 0, bottom: 0.4 }}
|
||||||
|
exit={{ y: '100%' }}
|
||||||
|
initial={{ y: '100%' }}
|
||||||
|
onDragEnd={(_, info) => {
|
||||||
|
if (info.offset.y > DRAG_CLOSE_THRESHOLD_PX) onClose()
|
||||||
|
}}
|
||||||
|
style={{ height: `${HEIGHT_VH}dvh` }}
|
||||||
|
transition={{ type: 'spring', stiffness: 320, damping: 32, mass: 0.8 }}
|
||||||
|
>
|
||||||
|
<div className="flex h-6 shrink-0 cursor-grab touch-none items-center justify-center active:cursor-grabbing">
|
||||||
|
<div className="h-1 w-10 rounded-full bg-muted-foreground/40" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex shrink-0 items-center justify-between border-border/50 border-b px-3 pt-1 pb-3">
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
{icon && (
|
||||||
|
<Image
|
||||||
|
alt=""
|
||||||
|
className="shrink-0 object-contain"
|
||||||
|
height={18}
|
||||||
|
src={icon}
|
||||||
|
width={18}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<h2 className="truncate font-semibold text-foreground text-sm tracking-tight">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
aria-label="Close"
|
||||||
|
className="flex h-8 w-8 items-center justify-center rounded-md bg-[#2C2C2E] text-muted-foreground transition-colors hover:bg-[#3e3e3e] hover:text-foreground"
|
||||||
|
onClick={onClose}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="no-scrollbar flex min-h-0 flex-1 flex-col overflow-y-auto">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>,
|
||||||
|
document.body,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import type { AnyNode } from '@pascal-app/core'
|
||||||
|
import { Copy, Move, SlidersHorizontal, Trash2 } from 'lucide-react'
|
||||||
|
import Image from 'next/image'
|
||||||
|
import type { MouseEventHandler } from 'react'
|
||||||
|
import { cn } from '../../../lib/utils'
|
||||||
|
import { getNodeDisplay } from './node-display'
|
||||||
|
|
||||||
|
interface MobileSelectionBarProps {
|
||||||
|
node: AnyNode
|
||||||
|
onMove: () => void
|
||||||
|
onDuplicate: () => void
|
||||||
|
onDelete: () => void
|
||||||
|
onEdit: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACTION_BTN =
|
||||||
|
'flex h-9 w-9 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-white/8 hover:text-foreground'
|
||||||
|
|
||||||
|
export function MobileSelectionBar({
|
||||||
|
node,
|
||||||
|
onMove,
|
||||||
|
onDuplicate,
|
||||||
|
onDelete,
|
||||||
|
onEdit,
|
||||||
|
}: MobileSelectionBarProps) {
|
||||||
|
const { icon, label } = getNodeDisplay(node)
|
||||||
|
|
||||||
|
const stop: MouseEventHandler<HTMLButtonElement> = (e) => e.stopPropagation()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pointer-events-auto absolute right-3 bottom-6 left-3 z-50 flex h-12 items-stretch gap-1 rounded-2xl border border-border/50 bg-background/95 px-2 shadow-2xl backdrop-blur-xl">
|
||||||
|
<button
|
||||||
|
aria-label={`Edit ${label}`}
|
||||||
|
className={cn(
|
||||||
|
'flex min-w-0 flex-1 items-center gap-2 rounded-lg px-2 text-left transition-colors hover:bg-white/8',
|
||||||
|
)}
|
||||||
|
onClick={onEdit}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
alt=""
|
||||||
|
className="shrink-0 rounded object-contain"
|
||||||
|
height={20}
|
||||||
|
src={icon}
|
||||||
|
width={20}
|
||||||
|
/>
|
||||||
|
<span className="truncate font-medium text-foreground text-sm">{label}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-0.5 border-border/40 border-l pl-1">
|
||||||
|
<button
|
||||||
|
aria-label="Move"
|
||||||
|
className={ACTION_BTN}
|
||||||
|
onClick={(e) => {
|
||||||
|
stop(e)
|
||||||
|
onMove()
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Move className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
aria-label="Duplicate"
|
||||||
|
className={ACTION_BTN}
|
||||||
|
onClick={(e) => {
|
||||||
|
stop(e)
|
||||||
|
onDuplicate()
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
aria-label="Delete"
|
||||||
|
className={cn(ACTION_BTN, 'hover:bg-red-500/15 hover:text-red-400')}
|
||||||
|
onClick={(e) => {
|
||||||
|
stop(e)
|
||||||
|
onDelete()
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
aria-label="Edit properties"
|
||||||
|
className={ACTION_BTN}
|
||||||
|
onClick={(e) => {
|
||||||
|
stop(e)
|
||||||
|
onEdit()
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<SlidersHorizontal className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import type { AnyNode } from '@pascal-app/core'
|
||||||
|
|
||||||
|
export type NodeDisplay = {
|
||||||
|
icon: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const TYPE_DEFAULTS: Record<string, NodeDisplay> = {
|
||||||
|
item: { icon: '/icons/furniture.png', label: 'Item' },
|
||||||
|
wall: { icon: '/icons/wall.png', label: 'Wall' },
|
||||||
|
door: { icon: '/icons/door.png', label: 'Door' },
|
||||||
|
window: { icon: '/icons/window.png', label: 'Window' },
|
||||||
|
slab: { icon: '/icons/floor.png', label: 'Slab' },
|
||||||
|
ceiling: { icon: '/icons/ceiling.png', label: 'Ceiling' },
|
||||||
|
fence: { icon: '/icons/fence.png', label: 'Fence' },
|
||||||
|
roof: { icon: '/icons/roof.png', label: 'Roof' },
|
||||||
|
'roof-segment': { icon: '/icons/roof.png', label: 'Roof segment' },
|
||||||
|
stair: { icon: '/icons/stair.png', label: 'Stair' },
|
||||||
|
'stair-segment': { icon: '/icons/stair.png', label: 'Stair segment' },
|
||||||
|
scan: { icon: '/icons/mesh.png', label: '3D Scan' },
|
||||||
|
guide: { icon: '/icons/floorplan.png', label: 'Guide image' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNodeDisplay(node: AnyNode | null | undefined): NodeDisplay {
|
||||||
|
if (!node) return { icon: '/icons/select.png', label: 'Selection' }
|
||||||
|
const fallback = TYPE_DEFAULTS[node.type] ?? { icon: '/icons/select.png', label: node.type }
|
||||||
|
// Item nodes carry an asset with its own thumbnail/name
|
||||||
|
if (node.type === 'item') {
|
||||||
|
return {
|
||||||
|
icon: node.asset?.thumbnail || fallback.icon,
|
||||||
|
label: node.name || node.asset?.name || fallback.label,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
icon: fallback.icon,
|
||||||
|
label: node.name || fallback.label,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,34 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { type AnyNodeId, useScene } from '@pascal-app/core'
|
import {
|
||||||
|
type AnyNode,
|
||||||
|
type AnyNodeId,
|
||||||
|
type BuildingNode,
|
||||||
|
type CeilingNode,
|
||||||
|
type DoorNode,
|
||||||
|
type FenceNode,
|
||||||
|
type ItemNode,
|
||||||
|
type RoofNode,
|
||||||
|
type RoofSegmentNode,
|
||||||
|
type SlabNode,
|
||||||
|
type StairNode,
|
||||||
|
type StairSegmentNode,
|
||||||
|
useScene,
|
||||||
|
type WallNode,
|
||||||
|
type WindowNode,
|
||||||
|
} from '@pascal-app/core'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { useIsMobile } from '../../../hooks/use-mobile'
|
||||||
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||||
import useEditor from '../../../store/use-editor'
|
import useEditor from '../../../store/use-editor'
|
||||||
import { CeilingPanel } from './ceiling-panel'
|
import { CeilingPanel } from './ceiling-panel'
|
||||||
import { DoorPanel } from './door-panel'
|
import { DoorPanel } from './door-panel'
|
||||||
import { FencePanel } from './fence-panel'
|
import { FencePanel } from './fence-panel'
|
||||||
import { ItemPanel } from './item-panel'
|
import { ItemPanel } from './item-panel'
|
||||||
|
import { MobilePanelSheet } from './mobile-panel-sheet'
|
||||||
|
import { MobileSelectionBar } from './mobile-selection-bar'
|
||||||
|
import { getNodeDisplay } from './node-display'
|
||||||
import { PaintPanel } from './paint-panel'
|
import { PaintPanel } from './paint-panel'
|
||||||
import { ReferencePanel } from './reference-panel'
|
import { ReferencePanel } from './reference-panel'
|
||||||
import { RoofPanel } from './roof-panel'
|
import { RoofPanel } from './roof-panel'
|
||||||
@@ -18,7 +40,152 @@ import { StairSegmentPanel } from './stair-segment-panel'
|
|||||||
import { WallPanel } from './wall-panel'
|
import { WallPanel } from './wall-panel'
|
||||||
import { WindowPanel } from './window-panel'
|
import { WindowPanel } from './window-panel'
|
||||||
|
|
||||||
|
type MovableNode =
|
||||||
|
| ItemNode
|
||||||
|
| WindowNode
|
||||||
|
| DoorNode
|
||||||
|
| CeilingNode
|
||||||
|
| SlabNode
|
||||||
|
| WallNode
|
||||||
|
| FenceNode
|
||||||
|
| RoofNode
|
||||||
|
| RoofSegmentNode
|
||||||
|
| StairNode
|
||||||
|
| StairSegmentNode
|
||||||
|
| BuildingNode
|
||||||
|
|
||||||
|
const MOVABLE_TYPES = new Set<string>([
|
||||||
|
'item',
|
||||||
|
'window',
|
||||||
|
'door',
|
||||||
|
'ceiling',
|
||||||
|
'slab',
|
||||||
|
'wall',
|
||||||
|
'fence',
|
||||||
|
'roof',
|
||||||
|
'roof-segment',
|
||||||
|
'stair',
|
||||||
|
'stair-segment',
|
||||||
|
'building',
|
||||||
|
])
|
||||||
|
|
||||||
|
function isMovableNode(node: AnyNode | null): node is MovableNode {
|
||||||
|
return !!node && MOVABLE_TYPES.has(node.type)
|
||||||
|
}
|
||||||
|
|
||||||
|
function panelForType(type: string | null) {
|
||||||
|
if (!type) return null
|
||||||
|
switch (type) {
|
||||||
|
case 'item':
|
||||||
|
return <ItemPanel />
|
||||||
|
case 'roof':
|
||||||
|
return <RoofPanel />
|
||||||
|
case 'roof-segment':
|
||||||
|
return <RoofSegmentPanel />
|
||||||
|
case 'stair':
|
||||||
|
return <StairPanel />
|
||||||
|
case 'stair-segment':
|
||||||
|
return <StairSegmentPanel />
|
||||||
|
case 'slab':
|
||||||
|
return <SlabPanel />
|
||||||
|
case 'ceiling':
|
||||||
|
return <CeilingPanel />
|
||||||
|
case 'wall':
|
||||||
|
return <WallPanel />
|
||||||
|
case 'fence':
|
||||||
|
return <FencePanel />
|
||||||
|
case 'door':
|
||||||
|
return <DoorPanel />
|
||||||
|
case 'window':
|
||||||
|
return <WindowPanel />
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function MobilePanelLayer({
|
||||||
|
node,
|
||||||
|
panel,
|
||||||
|
isReference,
|
||||||
|
}: {
|
||||||
|
node: AnyNode | null
|
||||||
|
panel: React.ReactNode
|
||||||
|
isReference: boolean
|
||||||
|
}) {
|
||||||
|
const setSelection = useViewer((s) => s.setSelection)
|
||||||
|
const setSelectedReferenceId = useEditor((s) => s.setSelectedReferenceId)
|
||||||
|
const setMovingNode = useEditor((s) => s.setMovingNode)
|
||||||
|
const deleteNode = useScene((s) => s.deleteNode)
|
||||||
|
const [isSheetOpen, setIsSheetOpen] = useState(false)
|
||||||
|
|
||||||
|
// Reset sheet open state when the selection changes / clears
|
||||||
|
const selectionKey = node?.id ?? (isReference ? 'reference' : null)
|
||||||
|
useEffect(() => {
|
||||||
|
setIsSheetOpen(false)
|
||||||
|
}, [selectionKey])
|
||||||
|
|
||||||
|
const clearSelection = useCallback(() => {
|
||||||
|
setSelection({ selectedIds: [] })
|
||||||
|
setSelectedReferenceId(null)
|
||||||
|
}, [setSelection, setSelectedReferenceId])
|
||||||
|
|
||||||
|
const handleMove = useCallback(() => {
|
||||||
|
if (!isMovableNode(node)) return
|
||||||
|
sfxEmitter.emit('sfx:item-pick')
|
||||||
|
setMovingNode(node)
|
||||||
|
clearSelection()
|
||||||
|
}, [node, setMovingNode, clearSelection])
|
||||||
|
|
||||||
|
const handleDuplicate = useCallback(() => {
|
||||||
|
if (!isMovableNode(node)) return
|
||||||
|
sfxEmitter.emit('sfx:item-pick')
|
||||||
|
const cloned = structuredClone(node) as MovableNode & { id?: AnyNodeId }
|
||||||
|
delete (cloned as { id?: AnyNodeId }).id
|
||||||
|
const prevMeta =
|
||||||
|
cloned.metadata && typeof cloned.metadata === 'object' && !Array.isArray(cloned.metadata)
|
||||||
|
? (cloned.metadata as Record<string, unknown>)
|
||||||
|
: {}
|
||||||
|
cloned.metadata = { ...prevMeta, isNew: true }
|
||||||
|
setMovingNode(cloned as MovableNode)
|
||||||
|
clearSelection()
|
||||||
|
}, [node, setMovingNode, clearSelection])
|
||||||
|
|
||||||
|
const handleDelete = useCallback(() => {
|
||||||
|
if (!node) return
|
||||||
|
sfxEmitter.emit('sfx:item-delete')
|
||||||
|
deleteNode(node.id)
|
||||||
|
clearSelection()
|
||||||
|
}, [node, deleteNode, clearSelection])
|
||||||
|
|
||||||
|
if (!(node || isReference)) return null
|
||||||
|
|
||||||
|
const display = getNodeDisplay(node)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{node && (
|
||||||
|
<MobileSelectionBar
|
||||||
|
node={node}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
onDuplicate={handleDuplicate}
|
||||||
|
onEdit={() => setIsSheetOpen((v) => !v)}
|
||||||
|
onMove={handleMove}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<MobilePanelSheet
|
||||||
|
icon={display.icon}
|
||||||
|
onClose={() => setIsSheetOpen(false)}
|
||||||
|
open={isSheetOpen}
|
||||||
|
title={display.label}
|
||||||
|
>
|
||||||
|
{panel}
|
||||||
|
</MobilePanelSheet>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function PanelManager() {
|
export function PanelManager() {
|
||||||
|
const isMobile = useIsMobile()
|
||||||
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
||||||
const selectedReferenceId = useEditor((s) => s.selectedReferenceId)
|
const selectedReferenceId = useEditor((s) => s.selectedReferenceId)
|
||||||
const isPaintPanelOpen = useEditor((s) => s.isPaintPanelOpen)
|
const isPaintPanelOpen = useEditor((s) => s.isPaintPanelOpen)
|
||||||
@@ -31,6 +198,24 @@ export function PanelManager() {
|
|||||||
const id = selectedIds[0]
|
const id = selectedIds[0]
|
||||||
return id ? (s.nodes[id as AnyNodeId]?.type ?? null) : null
|
return id ? (s.nodes[id as AnyNodeId]?.type ?? null) : null
|
||||||
})
|
})
|
||||||
|
const selectedNode = useScene((s) => {
|
||||||
|
if (selectedIds.length !== 1) return null
|
||||||
|
const id = selectedIds[0]
|
||||||
|
return id ? (s.nodes[id as AnyNodeId] ?? null) : null
|
||||||
|
})
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
if (selectedReferenceId) {
|
||||||
|
return <MobilePanelLayer isReference={true} node={null} panel={<ReferencePanel />} />
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<MobilePanelLayer
|
||||||
|
isReference={false}
|
||||||
|
node={selectedNode}
|
||||||
|
panel={panelForType(selectedNodeType)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Show reference panel if a reference is selected
|
// Show reference panel if a reference is selected
|
||||||
if (selectedReferenceId) {
|
if (selectedReferenceId) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { ChevronLeft, RotateCcw, X } from 'lucide-react'
|
import { ChevronLeft, RotateCcw, X } from 'lucide-react'
|
||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
|
import { useIsMobile } from '../../../hooks/use-mobile'
|
||||||
import { cn } from '../../../lib/utils'
|
import { cn } from '../../../lib/utils'
|
||||||
|
|
||||||
interface PanelWrapperProps {
|
interface PanelWrapperProps {
|
||||||
@@ -25,15 +26,20 @@ export function PanelWrapper({
|
|||||||
className,
|
className,
|
||||||
width = 320, // default width
|
width = 320, // default width
|
||||||
}: PanelWrapperProps) {
|
}: PanelWrapperProps) {
|
||||||
|
const isMobile = useIsMobile()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'pointer-events-auto fixed top-20 right-4 z-50 flex max-h-[calc(100dvh-100px)] flex-col overflow-hidden rounded-xl border border-border/50 bg-sidebar/95 shadow-2xl backdrop-blur-xl dark:text-foreground',
|
isMobile
|
||||||
|
? 'flex h-full w-full flex-col overflow-hidden bg-transparent dark:text-foreground'
|
||||||
|
: 'pointer-events-auto fixed top-20 right-4 z-50 flex max-h-[calc(100dvh-100px)] flex-col overflow-hidden rounded-xl border border-border/50 bg-sidebar/95 shadow-2xl backdrop-blur-xl dark:text-foreground',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
style={{ width }}
|
style={isMobile ? undefined : { width }}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header — desktop only; mobile sheet provides its own header */}
|
||||||
|
{!isMobile && (
|
||||||
<div className="flex items-center justify-between border-border/50 border-b px-3 py-3">
|
<div className="flex items-center justify-between border-border/50 border-b px-3 py-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{onBack && (
|
{onBack && (
|
||||||
@@ -48,7 +54,9 @@ export function PanelWrapper({
|
|||||||
{icon && (
|
{icon && (
|
||||||
<Image alt="" className="shrink-0 object-contain" height={16} src={icon} width={16} />
|
<Image alt="" className="shrink-0 object-contain" height={16} src={icon} width={16} />
|
||||||
)}
|
)}
|
||||||
<h2 className="truncate font-semibold text-foreground text-sm tracking-tight">{title}</h2>
|
<h2 className="truncate font-semibold text-foreground text-sm tracking-tight">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
@@ -72,6 +80,7 @@ export function PanelWrapper({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="no-scrollbar flex min-h-0 flex-1 flex-col overflow-y-auto">{children}</div>
|
<div className="no-scrollbar flex min-h-0 flex-1 flex-col overflow-y-auto">{children}</div>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
type AnyNode,
|
type AnyNode,
|
||||||
type AnyNodeId,
|
type AnyNodeId,
|
||||||
type RoofNode,
|
type RoofNode,
|
||||||
|
type RoofSurfaceMaterialRole,
|
||||||
RoofNode as RoofNodeSchema,
|
RoofNode as RoofNodeSchema,
|
||||||
type RoofSegmentNode,
|
type RoofSegmentNode,
|
||||||
RoofSegmentNode as RoofSegmentNodeSchema,
|
RoofSegmentNode as RoofSegmentNodeSchema,
|
||||||
@@ -14,6 +15,7 @@ import { Copy, Move, Plus, Trash2 } from 'lucide-react'
|
|||||||
import { useCallback } from 'react'
|
import { useCallback } from 'react'
|
||||||
import { useShallow } from 'zustand/react/shallow'
|
import { useShallow } from 'zustand/react/shallow'
|
||||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||||
|
import { duplicateRoofSubtree } from '../../../lib/roof-duplication'
|
||||||
import useEditor from '../../../store/use-editor'
|
import useEditor from '../../../store/use-editor'
|
||||||
import { ActionButton, ActionGroup } from '../controls/action-button'
|
import { ActionButton, ActionGroup } from '../controls/action-button'
|
||||||
import { PanelSection } from '../controls/panel-section'
|
import { PanelSection } from '../controls/panel-section'
|
||||||
@@ -73,44 +75,15 @@ export function RoofPanel() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const handleDuplicate = useCallback(() => {
|
const handleDuplicate = useCallback(() => {
|
||||||
if (!node?.parentId) return
|
if (!node) return
|
||||||
sfxEmitter.emit('sfx:item-pick')
|
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 {
|
try {
|
||||||
const duplicate = RoofNodeSchema.parse(duplicateInfo)
|
duplicateRoofSubtree(node.id as AnyNodeId, { mode: 'move' })
|
||||||
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)
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to duplicate roof', e)
|
console.error('Failed to duplicate roof', e)
|
||||||
}
|
}
|
||||||
}, [node, setSelection, setMovingNode])
|
}, [node])
|
||||||
|
|
||||||
const handleMove = useCallback(() => {
|
const handleMove = useCallback(() => {
|
||||||
if (node) {
|
if (node) {
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
type StairSlabOpeningMode,
|
type StairSlabOpeningMode,
|
||||||
type StairTopLandingMode,
|
type StairTopLandingMode,
|
||||||
type StairType,
|
type StairType,
|
||||||
StairNode as StairNodeSchema,
|
|
||||||
type StairSegmentNode,
|
type StairSegmentNode,
|
||||||
StairSegmentNode as StairSegmentNodeSchema,
|
StairSegmentNode as StairSegmentNodeSchema,
|
||||||
useScene,
|
useScene,
|
||||||
@@ -17,6 +16,7 @@ import {
|
|||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { Copy, Move, Plus, Trash2 } from 'lucide-react'
|
import { Copy, Move, Plus, Trash2 } from 'lucide-react'
|
||||||
import { useCallback } from 'react'
|
import { useCallback } from 'react'
|
||||||
|
import { duplicateStairSubtree } from '../../../lib/stair-duplication'
|
||||||
import { useShallow } from 'zustand/react/shallow'
|
import { useShallow } from 'zustand/react/shallow'
|
||||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||||
import useEditor from '../../../store/use-editor'
|
import useEditor from '../../../store/use-editor'
|
||||||
@@ -58,7 +58,6 @@ export function StairPanel() {
|
|||||||
const setSelection = useViewer((s) => s.setSelection)
|
const setSelection = useViewer((s) => s.setSelection)
|
||||||
const updateNode = useScene((s) => s.updateNode)
|
const updateNode = useScene((s) => s.updateNode)
|
||||||
const createNode = useScene((s) => s.createNode)
|
const createNode = useScene((s) => s.createNode)
|
||||||
const createNodes = useScene((s) => s.createNodes)
|
|
||||||
const setMovingNode = useEditor((s) => s.setMovingNode)
|
const setMovingNode = useEditor((s) => s.setMovingNode)
|
||||||
|
|
||||||
const node = useScene((s) =>
|
const node = useScene((s) =>
|
||||||
@@ -151,46 +150,15 @@ export function StairPanel() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const handleDuplicate = useCallback(() => {
|
const handleDuplicate = useCallback(() => {
|
||||||
if (!node?.parentId) return
|
if (!node) return
|
||||||
sfxEmitter.emit('sfx:item-pick')
|
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 {
|
try {
|
||||||
const duplicate = StairNodeSchema.parse(duplicateInfo)
|
duplicateStairSubtree(node.id as AnyNodeId, { mode: 'move' })
|
||||||
|
|
||||||
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']] })
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to duplicate stair', e)
|
console.error('Failed to duplicate stair', e)
|
||||||
}
|
}
|
||||||
}, [createNodes, node, setSelection])
|
}, [node])
|
||||||
|
|
||||||
const handleMove = useCallback(() => {
|
const handleMove = useCallback(() => {
|
||||||
if (node) {
|
if (node) {
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { cn } from './../../../lib/utils'
|
||||||
|
import type { SidebarTab } from './tab-bar'
|
||||||
|
|
||||||
|
interface MobileTabBarProps {
|
||||||
|
tabs: SidebarTab[]
|
||||||
|
activeTab: string
|
||||||
|
onTabPress: (id: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MobileTabBar({ tabs, activeTab, onTabPress }: MobileTabBarProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="z-50 flex h-14 shrink-0 border-border/50 border-t bg-sidebar text-sidebar-foreground"
|
||||||
|
style={{
|
||||||
|
// Cap the safe-area inset — iOS Chrome can report its bottom UI bar
|
||||||
|
// (50–100px) as part of the safe area which would balloon the tab bar.
|
||||||
|
// 34px matches the iPhone home-indicator height (the typical max).
|
||||||
|
paddingBottom: 'min(env(safe-area-inset-bottom, 0px), 34px)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tabs.map((tab) => {
|
||||||
|
const isActive = activeTab === tab.id
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
'flex flex-1 flex-col items-center justify-center gap-0.5 text-xs transition-colors',
|
||||||
|
isActive ? 'text-foreground' : 'text-muted-foreground',
|
||||||
|
)}
|
||||||
|
key={tab.id}
|
||||||
|
onClick={() => onTabPress(tab.id)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{tab.mobileIcon ? (
|
||||||
|
<span className={cn('flex h-5 w-5 items-center justify-center')}>
|
||||||
|
{tab.mobileIcon}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<span className="font-medium">{tab.label}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
import { cn } from './../../../lib/utils'
|
import { cn } from './../../../lib/utils'
|
||||||
|
|
||||||
export type SidebarTab = {
|
export type SidebarTab = {
|
||||||
id: string
|
id: string
|
||||||
label: string
|
label: string
|
||||||
|
mobileDefaultSnap?: number
|
||||||
|
mobileIcon?: ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TabBarProps {
|
interface TabBarProps {
|
||||||
|
|||||||
@@ -2,18 +2,18 @@ import * as React from 'react'
|
|||||||
|
|
||||||
const MOBILE_BREAKPOINT = 768
|
const MOBILE_BREAKPOINT = 768
|
||||||
|
|
||||||
export function useIsMobile() {
|
const subscribe = (callback: () => void): (() => void) => {
|
||||||
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
|
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
|
||||||
const onChange = () => {
|
mql.addEventListener('change', callback)
|
||||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
return () => mql.removeEventListener('change', callback)
|
||||||
}
|
}
|
||||||
mql.addEventListener('change', onChange)
|
|
||||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
|
||||||
return () => mql.removeEventListener('change', onChange)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return !!isMobile
|
const getClientSnapshot = (): boolean => window.innerWidth < MOBILE_BREAKPOINT
|
||||||
|
|
||||||
|
// Server can't know the viewport — assume desktop. React's useSyncExternalStore
|
||||||
|
// reconciles the SSR / client snapshots without a hydration mismatch warning.
|
||||||
|
const getServerSnapshot = (): boolean => false
|
||||||
|
|
||||||
|
export function useIsMobile(): boolean {
|
||||||
|
return React.useSyncExternalStore(subscribe, getClientSnapshot, getServerSnapshot)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'
|
||||||
@@ -0,0 +1,421 @@
|
|||||||
|
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)
|
||||||
|
const [width, , depth] = getScaledDimensions(item)
|
||||||
|
|
||||||
|
return {
|
||||||
|
dimensionPolygon,
|
||||||
|
item,
|
||||||
|
polygon: realMeshPolygon,
|
||||||
|
usesRealMesh: realMeshPolygon !== null,
|
||||||
|
center: transform.position,
|
||||||
|
rotation: transform.rotation,
|
||||||
|
width,
|
||||||
|
depth,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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,57 @@
|
|||||||
|
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
|
||||||
|
center: Point2D
|
||||||
|
rotation: number
|
||||||
|
width: number
|
||||||
|
depth: number
|
||||||
|
}
|
||||||
|
|
||||||
|
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),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -210,6 +210,10 @@ type EditorState = {
|
|||||||
setAllowUndergroundCamera: (enabled: boolean) => void
|
setAllowUndergroundCamera: (enabled: boolean) => void
|
||||||
activeSidebarPanel: string
|
activeSidebarPanel: string
|
||||||
setActiveSidebarPanel: (id: string) => void
|
setActiveSidebarPanel: (id: string) => void
|
||||||
|
mobilePanelSheetHeight: number
|
||||||
|
setMobilePanelSheetHeight: (height: number) => void
|
||||||
|
isCaptureMode: boolean
|
||||||
|
setIsCaptureMode: (enabled: boolean) => void
|
||||||
floorplanPaneRatio: number
|
floorplanPaneRatio: number
|
||||||
setFloorplanPaneRatio: (ratio: number) => void
|
setFloorplanPaneRatio: (ratio: number) => void
|
||||||
}
|
}
|
||||||
@@ -659,6 +663,10 @@ const useEditor = create<EditorState>()(
|
|||||||
},
|
},
|
||||||
activeSidebarPanel: DEFAULT_ACTIVE_SIDEBAR_PANEL,
|
activeSidebarPanel: DEFAULT_ACTIVE_SIDEBAR_PANEL,
|
||||||
setActiveSidebarPanel: (id) => set({ activeSidebarPanel: id }),
|
setActiveSidebarPanel: (id) => set({ activeSidebarPanel: id }),
|
||||||
|
mobilePanelSheetHeight: 0,
|
||||||
|
setMobilePanelSheetHeight: (height) => set({ mobilePanelSheetHeight: height }),
|
||||||
|
isCaptureMode: false,
|
||||||
|
setIsCaptureMode: (enabled) => set({ isCaptureMode: enabled }),
|
||||||
floorplanPaneRatio: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.floorplanPaneRatio,
|
floorplanPaneRatio: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.floorplanPaneRatio,
|
||||||
setFloorplanPaneRatio: (ratio) =>
|
setFloorplanPaneRatio: (ratio) =>
|
||||||
set({ floorplanPaneRatio: normalizeFloorplanPaneRatio(ratio) }),
|
set({ floorplanPaneRatio: normalizeFloorplanPaneRatio(ratio) }),
|
||||||
|
|||||||
@@ -1,15 +1,25 @@
|
|||||||
import type { ErrorInfo, ReactNode } from 'react'
|
import type { ErrorInfo, ReactNode } from 'react'
|
||||||
import { Component } from 'react'
|
import { Component } from 'react'
|
||||||
|
|
||||||
export class ErrorBoundary extends Component<
|
interface ErrorBoundaryProps {
|
||||||
{ children: ReactNode; fallback: ReactNode },
|
children: ReactNode
|
||||||
{ hasError: boolean }
|
fallback: ReactNode
|
||||||
> {
|
/** Tag for log lines so we can tell which boundary swallowed an error. */
|
||||||
|
scope?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ErrorBoundary extends Component<ErrorBoundaryProps, { hasError: boolean }> {
|
||||||
state = { hasError: false }
|
state = { hasError: false }
|
||||||
static getDerivedStateFromError() {
|
static getDerivedStateFromError() {
|
||||||
return { hasError: true }
|
return { hasError: true }
|
||||||
}
|
}
|
||||||
componentDidCatch(_e: Error, _i: ErrorInfo) {}
|
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||||
|
console.error(
|
||||||
|
`[viewer] ErrorBoundary caught${this.props.scope ? ` (${this.props.scope})` : ''}:`,
|
||||||
|
error,
|
||||||
|
info.componentStack,
|
||||||
|
)
|
||||||
|
}
|
||||||
render() {
|
render() {
|
||||||
return this.state.hasError ? this.props.fallback : this.props.children
|
return this.state.hasError ? this.props.fallback : this.props.children
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ import { MeshStandardNodeMaterial } from 'three/webgpu'
|
|||||||
import { useNodeEvents } from '../../../hooks/use-node-events'
|
import { useNodeEvents } from '../../../hooks/use-node-events'
|
||||||
import { resolveCdnUrl } from '../../../lib/asset-url'
|
import { resolveCdnUrl } from '../../../lib/asset-url'
|
||||||
import { useItemLightPool } from '../../../store/use-item-light-pool'
|
import { useItemLightPool } from '../../../store/use-item-light-pool'
|
||||||
|
import {
|
||||||
|
requestItemMeshMetadataSync,
|
||||||
|
setItemMeshMetadataSourceRoot,
|
||||||
|
} from '../../../systems/item-mesh-metadata/sync-request'
|
||||||
import { ErrorBoundary } from '../../error-boundary'
|
import { ErrorBoundary } from '../../error-boundary'
|
||||||
import { NodeRenderer } from '../node-renderer'
|
import { NodeRenderer } from '../node-renderer'
|
||||||
|
|
||||||
@@ -107,6 +111,19 @@ const ModelRenderer = ({ node }: { node: ItemNode }) => {
|
|||||||
useScene.getState().dirtyNodes.add(node.parentId as AnyNodeId)
|
useScene.getState().dirtyNodes.add(node.parentId as AnyNodeId)
|
||||||
}, [node.parentId])
|
}, [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(() => {
|
useEffect(() => {
|
||||||
const interactive = interactiveRef.current
|
const interactive = interactiveRef.current
|
||||||
if (!interactive) return
|
if (!interactive) return
|
||||||
|
|||||||
@@ -18,10 +18,12 @@ import * as THREE from 'three/webgpu'
|
|||||||
import useViewer from '../../store/use-viewer'
|
import useViewer from '../../store/use-viewer'
|
||||||
import { GuideSystem } from '../../systems/guide/guide-system'
|
import { GuideSystem } from '../../systems/guide/guide-system'
|
||||||
import { ItemLightSystem } from '../../systems/item-light/item-light-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 { LevelSystem } from '../../systems/level/level-system'
|
||||||
import { ScanSystem } from '../../systems/scan/scan-system'
|
import { ScanSystem } from '../../systems/scan/scan-system'
|
||||||
import { WallCutout } from '../../systems/wall/wall-cutout'
|
import { WallCutout } from '../../systems/wall/wall-cutout'
|
||||||
import { ZoneSystem } from '../../systems/zone/zone-system'
|
import { ZoneSystem } from '../../systems/zone/zone-system'
|
||||||
|
import { ErrorBoundary } from '../error-boundary'
|
||||||
import { SceneRenderer } from '../renderers/scene-renderer'
|
import { SceneRenderer } from '../renderers/scene-renderer'
|
||||||
import FrameLimiter from './frame-limiter'
|
import FrameLimiter from './frame-limiter'
|
||||||
import { Lights } from './lights'
|
import { Lights } from './lights'
|
||||||
@@ -63,6 +65,22 @@ declare module '@react-three/fiber' {
|
|||||||
|
|
||||||
extend(THREE as any)
|
extend(THREE as any)
|
||||||
|
|
||||||
|
// R3F's <Canvas> useLayoutEffect has no deps, so any re-render (theme switch,
|
||||||
|
// parent re-render, StrictMode double-mount) re-invokes `configure()`. With a
|
||||||
|
// sync `gl` factory that's harmless — the renderer is created once and reused.
|
||||||
|
// With an async factory (WebGPURenderer needs `await init()`), two configure
|
||||||
|
// calls can race: both see `state.gl == null` and both create a renderer. The
|
||||||
|
// first to resolve gets `setSize`/`setDpr` called on it; the second overwrites
|
||||||
|
// `state.gl` but R3F's store already holds the new size/dpr, so the new
|
||||||
|
// renderer is never resized and stays at the canvas's 300×150 default.
|
||||||
|
//
|
||||||
|
// Caching by canvas guarantees both branches return the same instance, so
|
||||||
|
// "duplicate" configure calls become no-ops on an already-sized renderer.
|
||||||
|
// We cache the in-flight Promise (not just the resolved renderer) so two
|
||||||
|
// concurrent configure() calls await the same init instead of creating two
|
||||||
|
// renderers in parallel and only caching the second.
|
||||||
|
const WEBGPU_RENDERER_CACHE = new WeakMap<HTMLCanvasElement, Promise<THREE.WebGPURenderer>>()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Monitors the WebGPU device for loss events and logs them.
|
* Monitors the WebGPU device for loss events and logs them.
|
||||||
* WebGPU device loss can happen when:
|
* WebGPU device loss can happen when:
|
||||||
@@ -77,6 +95,10 @@ type WebGPUDeviceLossInfo = {
|
|||||||
|
|
||||||
type WebGPUDeviceLike = {
|
type WebGPUDeviceLike = {
|
||||||
lost: Promise<WebGPUDeviceLossInfo>
|
lost: Promise<WebGPUDeviceLossInfo>
|
||||||
|
label?: string
|
||||||
|
features?: Set<string>
|
||||||
|
addEventListener?: (type: string, listener: EventListener) => void
|
||||||
|
removeEventListener?: (type: string, listener: EventListener) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function GPUDeviceWatcher() {
|
function GPUDeviceWatcher() {
|
||||||
@@ -86,7 +108,18 @@ function GPUDeviceWatcher() {
|
|||||||
const backend = (gl as any).backend
|
const backend = (gl as any).backend
|
||||||
const device = backend?.device as WebGPUDeviceLike | undefined
|
const device = backend?.device as WebGPUDeviceLike | undefined
|
||||||
|
|
||||||
if (!device) return
|
if (!device) {
|
||||||
|
console.warn('[viewer] No WebGPU device on backend — running on a fallback renderer.', {
|
||||||
|
backend: backend?.constructor?.name ?? 'unknown',
|
||||||
|
rendererType: (gl as any).constructor?.name ?? 'unknown',
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[viewer] WebGPU device ready', {
|
||||||
|
label: device.label,
|
||||||
|
features: device.features ? Array.from(device.features) : [],
|
||||||
|
})
|
||||||
|
|
||||||
device.lost.then((info: WebGPUDeviceLossInfo) => {
|
device.lost.then((info: WebGPUDeviceLossInfo) => {
|
||||||
console.error(
|
console.error(
|
||||||
@@ -94,6 +127,17 @@ function GPUDeviceWatcher() {
|
|||||||
'The page must be reloaded to recover the GPU context.',
|
'The page must be reloaded to recover the GPU context.',
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Uncaptured errors are normally silent (only console-warned by Chrome at
|
||||||
|
// best). Pipe them to console.error so silent mobile crashes show up.
|
||||||
|
const onUncapturedError = (event: any) => {
|
||||||
|
console.error('[viewer] WebGPU uncaptured error:', event?.error?.message, event?.error)
|
||||||
|
}
|
||||||
|
device.addEventListener?.('uncapturederror', onUncapturedError)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
device.removeEventListener?.('uncapturederror', onUncapturedError)
|
||||||
|
}
|
||||||
}, [gl])
|
}, [gl])
|
||||||
|
|
||||||
return null
|
return null
|
||||||
@@ -119,19 +163,43 @@ const Viewer: React.FC<ViewerProps> = ({
|
|||||||
className={`transition-colors duration-700 ${theme === 'dark' ? 'bg-[#1f2433]' : 'bg-[#fafafa]'}`}
|
className={`transition-colors duration-700 ${theme === 'dark' ? 'bg-[#1f2433]' : 'bg-[#fafafa]'}`}
|
||||||
dpr={[1, 1.5]}
|
dpr={[1, 1.5]}
|
||||||
frameloop="never"
|
frameloop="never"
|
||||||
gl={async (props) => {
|
gl={
|
||||||
|
((props: { canvas?: HTMLCanvasElement }) => {
|
||||||
|
const canvas = props.canvas
|
||||||
|
const cached = canvas ? WEBGPU_RENDERER_CACHE.get(canvas) : undefined
|
||||||
|
if (cached) return cached
|
||||||
|
// Surface the env we're about to ask WebGPU for — catches "no
|
||||||
|
// navigator.gpu" / "adapter request failed" silently failing in
|
||||||
|
// mobile WebViews where WebGPU is gated behind flags.
|
||||||
|
const hasGpu = typeof navigator !== 'undefined' && 'gpu' in navigator
|
||||||
|
console.log('[viewer] Creating WebGPURenderer', {
|
||||||
|
hasNavigatorGPU: hasGpu,
|
||||||
|
ua: typeof navigator !== 'undefined' ? navigator.userAgent : 'n/a',
|
||||||
|
})
|
||||||
|
const promise = (async () => {
|
||||||
|
try {
|
||||||
const renderer = new THREE.WebGPURenderer(props as any)
|
const renderer = new THREE.WebGPURenderer(props as any)
|
||||||
renderer.toneMapping = THREE.ACESFilmicToneMapping
|
renderer.toneMapping = THREE.ACESFilmicToneMapping
|
||||||
renderer.toneMappingExposure = 0.9
|
renderer.toneMappingExposure = 0.9
|
||||||
// Awaiting init() is required when the browser falls back to the
|
|
||||||
// WebGL2 backend (Safari without the WebGPU flag, older Chrome on
|
|
||||||
// machines without a WebGPU device). In native WebGPU mode the
|
|
||||||
// init resolves almost instantly. Without this await, the first
|
|
||||||
// render throws "Renderer: .render() called before the backend is
|
|
||||||
// initialized" from the post-processing fallback path.
|
|
||||||
await renderer.init()
|
await renderer.init()
|
||||||
|
console.log('[viewer] WebGPURenderer ready', {
|
||||||
|
backend: (renderer as any).backend?.constructor?.name,
|
||||||
|
isWebGPU: (renderer as any).isWebGPURenderer === true,
|
||||||
|
})
|
||||||
return renderer
|
return renderer
|
||||||
}}
|
} catch (err) {
|
||||||
|
// Drop the failed promise from the cache so a future Canvas
|
||||||
|
// mount on the same DOM can retry instead of inheriting the
|
||||||
|
// rejection forever.
|
||||||
|
if (canvas) WEBGPU_RENDERER_CACHE.delete(canvas)
|
||||||
|
console.error('[viewer] WebGPURenderer init failed', err)
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
if (canvas) WEBGPU_RENDERER_CACHE.set(canvas, promise)
|
||||||
|
return promise
|
||||||
|
}) as any
|
||||||
|
}
|
||||||
resize={{
|
resize={{
|
||||||
debounce: 100,
|
debounce: 100,
|
||||||
}}
|
}}
|
||||||
@@ -143,7 +211,9 @@ const Viewer: React.FC<ViewerProps> = ({
|
|||||||
<FrameLimiter fps={50} />
|
<FrameLimiter fps={50} />
|
||||||
{/* <AnimatedBackground isDark={theme === 'dark'} /> */}
|
{/* <AnimatedBackground isDark={theme === 'dark'} /> */}
|
||||||
<ViewerCamera />
|
<ViewerCamera />
|
||||||
|
<GPUDeviceWatcher />
|
||||||
|
|
||||||
|
<ErrorBoundary fallback={null} scope="viewer-scene">
|
||||||
{/* <directionalLight position={[10, 10, 5]} intensity={0.5} castShadow
|
{/* <directionalLight position={[10, 10, 5]} intensity={0.5} castShadow
|
||||||
/> */}
|
/> */}
|
||||||
<Lights />
|
<Lights />
|
||||||
@@ -169,12 +239,13 @@ const Viewer: React.FC<ViewerProps> = ({
|
|||||||
<ZoneSystem />
|
<ZoneSystem />
|
||||||
<PostProcessing hoverStyles={hoverStyles} />
|
<PostProcessing hoverStyles={hoverStyles} />
|
||||||
{/* <DebugRenderer /> */}
|
{/* <DebugRenderer /> */}
|
||||||
<GPUDeviceWatcher />
|
|
||||||
|
|
||||||
<ItemLightSystem />
|
<ItemLightSystem />
|
||||||
|
<ItemMeshMetadataSystem />
|
||||||
{selectionManager === 'default' && <SelectionManager />}
|
{selectionManager === 'default' && <SelectionManager />}
|
||||||
{perf && <PerfMonitor />}
|
{perf && <PerfMonitor />}
|
||||||
{children}
|
{children}
|
||||||
|
</ErrorBoundary>
|
||||||
</Canvas>
|
</Canvas>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -174,9 +174,22 @@ const PostProcessingPasses = ({
|
|||||||
void pipelineVersion
|
void pipelineVersion
|
||||||
|
|
||||||
if (!(renderer && scene && camera)) {
|
if (!(renderer && scene && camera)) {
|
||||||
|
console.warn('[viewer/post-processing] Skipping pipeline build — missing dependency.', {
|
||||||
|
hasRenderer: !!renderer,
|
||||||
|
hasScene: !!scene,
|
||||||
|
hasCamera: !!camera,
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log('[viewer/post-processing] Building pipeline', {
|
||||||
|
version: pipelineVersion,
|
||||||
|
ssgi: SSGI_PARAMS.enabled,
|
||||||
|
hoverHighlightMode,
|
||||||
|
projectId,
|
||||||
|
rendererCtor: (renderer as any).constructor?.name,
|
||||||
|
})
|
||||||
|
|
||||||
hasPipelineErrorRef.current = false
|
hasPipelineErrorRef.current = false
|
||||||
|
|
||||||
// WebGPU availability check: SSGI, denoise, and RenderPipeline are all
|
// WebGPU availability check: SSGI, denoise, and RenderPipeline are all
|
||||||
@@ -318,10 +331,16 @@ const PostProcessingPasses = ({
|
|||||||
renderPipeline.outputNode = finalOutput
|
renderPipeline.outputNode = finalOutput
|
||||||
renderPipelineRef.current = renderPipeline
|
renderPipelineRef.current = renderPipeline
|
||||||
retryCountRef.current = 0
|
retryCountRef.current = 0
|
||||||
|
console.log('[viewer/post-processing] Pipeline built OK', { version: pipelineVersion })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
hasPipelineErrorRef.current = true
|
hasPipelineErrorRef.current = true
|
||||||
console.error(
|
console.error(
|
||||||
'[viewer] Failed to set up post-processing pipeline. Rendering without post FX.',
|
'[viewer/post-processing] Failed to set up post-processing pipeline. Rendering without post FX.',
|
||||||
|
{
|
||||||
|
version: pipelineVersion,
|
||||||
|
ssgi: SSGI_PARAMS.enabled,
|
||||||
|
rendererCtor: (renderer as any).constructor?.name,
|
||||||
|
},
|
||||||
error,
|
error,
|
||||||
)
|
)
|
||||||
if (renderPipelineRef.current) {
|
if (renderPipelineRef.current) {
|
||||||
@@ -366,7 +385,7 @@ const PostProcessingPasses = ({
|
|||||||
}
|
}
|
||||||
;(renderer as any).render(scene, camera)
|
;(renderer as any).render(scene, camera)
|
||||||
} catch (fallbackError) {
|
} catch (fallbackError) {
|
||||||
console.error('[viewer] Fallback render failed.', fallbackError)
|
console.error('[viewer/post-processing] Fallback render failed.', fallbackError)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -378,7 +397,11 @@ const PostProcessingPasses = ({
|
|||||||
renderPipelineRef.current.render()
|
renderPipelineRef.current.render()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
hasPipelineErrorRef.current = true
|
hasPipelineErrorRef.current = true
|
||||||
console.error('[viewer] Post-processing render pass failed.', error)
|
console.error('[viewer/post-processing] Render pass failed.', {
|
||||||
|
retryCount: retryCountRef.current,
|
||||||
|
rendererCtor: (renderer as any).constructor?.name,
|
||||||
|
error,
|
||||||
|
})
|
||||||
if (renderPipelineRef.current) {
|
if (renderPipelineRef.current) {
|
||||||
renderPipelineRef.current.dispose()
|
renderPipelineRef.current.dispose()
|
||||||
}
|
}
|
||||||
@@ -388,7 +411,7 @@ const PostProcessingPasses = ({
|
|||||||
// Auto-retry: schedule a pipeline rebuild if we haven't exceeded the retry limit
|
// Auto-retry: schedule a pipeline rebuild if we haven't exceeded the retry limit
|
||||||
retryCountRef.current++
|
retryCountRef.current++
|
||||||
console.warn(
|
console.warn(
|
||||||
`[viewer] Scheduling post-processing rebuild (attempt ${retryCountRef.current}/${MAX_PIPELINE_RETRIES})`,
|
`[viewer/post-processing] Scheduling pipeline rebuild (attempt ${retryCountRef.current}/${MAX_PIPELINE_RETRIES})`,
|
||||||
)
|
)
|
||||||
if (rebuildTimeoutRef.current !== null) {
|
if (rebuildTimeoutRef.current !== null) {
|
||||||
clearTimeout(rebuildTimeoutRef.current)
|
clearTimeout(rebuildTimeoutRef.current)
|
||||||
@@ -396,7 +419,7 @@ const PostProcessingPasses = ({
|
|||||||
rebuildTimeoutRef.current = setTimeout(requestPipelineRebuild, RETRY_DELAY_MS)
|
rebuildTimeoutRef.current = setTimeout(requestPipelineRebuild, RETRY_DELAY_MS)
|
||||||
} else {
|
} else {
|
||||||
console.error(
|
console.error(
|
||||||
'[viewer] Post-processing retries exhausted. Rendering without post FX for this session.',
|
'[viewer/post-processing] Retries exhausted. Rendering without post FX for this session.',
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -166,6 +166,14 @@ export class MergedOutlineNode extends TempNode {
|
|||||||
private readonly _cacheA = new Set<Object3D>()
|
private readonly _cacheA = new Set<Object3D>()
|
||||||
private readonly _cacheB = new Set<Object3D>()
|
private readonly _cacheB = new Set<Object3D>()
|
||||||
|
|
||||||
|
// Tracks whether either group rendered last frame. We use this to decide
|
||||||
|
// when it's safe to skip renderer state manipulation entirely — touching
|
||||||
|
// the renderer (resetRendererAndSceneState + setRenderTarget + clearColor)
|
||||||
|
// corrupts the FBO state on the WebGL2 backend (iOS Chrome fallback) and
|
||||||
|
// the subsequent scene render comes out blank.
|
||||||
|
private _wroteGroupALastFrame = false
|
||||||
|
private _wroteGroupBLastFrame = false
|
||||||
|
|
||||||
private readonly _textureNodeA: any
|
private readonly _textureNodeA: any
|
||||||
private readonly _textureNodeB: any
|
private readonly _textureNodeB: any
|
||||||
|
|
||||||
@@ -294,6 +302,17 @@ export class MergedOutlineNode extends TempNode {
|
|||||||
updateBefore(frame: any) {
|
updateBefore(frame: any) {
|
||||||
const hasPrimary = this.primaryObjects.length > 0
|
const hasPrimary = this.primaryObjects.length > 0
|
||||||
const hasSecondary = this.secondaryObjects.length > 0
|
const hasSecondary = this.secondaryObjects.length > 0
|
||||||
|
const hasAny = hasPrimary || hasSecondary
|
||||||
|
|
||||||
|
// Fast-path: nothing to render and nothing was rendered last frame either,
|
||||||
|
// so there are no stale composites to clear. Touch nothing — on the WebGL2
|
||||||
|
// backend (iOS Chrome fallback) even an empty reset/setRenderTarget cycle
|
||||||
|
// corrupts the framebuffer state and the next scene render goes blank.
|
||||||
|
const needsCleanupA = !hasPrimary && this._wroteGroupALastFrame
|
||||||
|
const needsCleanupB = !hasSecondary && this._wroteGroupBLastFrame
|
||||||
|
if (!(hasAny || needsCleanupA || needsCleanupB)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const { renderer } = frame
|
const { renderer } = frame
|
||||||
const { camera, scene } = this
|
const { camera, scene } = this
|
||||||
@@ -303,24 +322,27 @@ export class MergedOutlineNode extends TempNode {
|
|||||||
const size = renderer.getDrawingBufferSize(_size)
|
const size = renderer.getDrawingBufferSize(_size)
|
||||||
this.setSize(size.width, size.height)
|
this.setSize(size.width, size.height)
|
||||||
|
|
||||||
// Clear composites for inactive groups so stale outlines don't persist on GPU.
|
// Clear composites for groups that just transitioned from "has content"
|
||||||
// Must happen inside resetRendererAndSceneState to avoid MSAA state corruption.
|
// to "empty" — without this, the previous outline lingers on the GPU.
|
||||||
if (!hasPrimary) {
|
if (needsCleanupA) {
|
||||||
renderer.setRenderTarget(this._groupA.composite)
|
renderer.setRenderTarget(this._groupA.composite)
|
||||||
renderer.clearColor()
|
renderer.clearColor()
|
||||||
|
this._wroteGroupALastFrame = false
|
||||||
}
|
}
|
||||||
if (!hasSecondary) {
|
if (needsCleanupB) {
|
||||||
renderer.setRenderTarget(this._groupB.composite)
|
renderer.setRenderTarget(this._groupB.composite)
|
||||||
renderer.clearColor()
|
renderer.clearColor()
|
||||||
|
this._wroteGroupBLastFrame = false
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasAny = hasPrimary || hasSecondary
|
|
||||||
if (!hasAny) {
|
if (!hasAny) {
|
||||||
RendererUtils.restoreRendererAndSceneState(renderer, scene, _rendererState)
|
RendererUtils.restoreRendererAndSceneState(renderer, scene, _rendererState)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
renderer.setClearColor(0xff_ff_ff, 1)
|
renderer.setClearColor(0xff_ff_ff, 1)
|
||||||
|
this._wroteGroupALastFrame = hasPrimary
|
||||||
|
this._wroteGroupBLastFrame = hasSecondary
|
||||||
|
|
||||||
if (hasPrimary) this._buildCache(this.primaryObjects, this._cacheA)
|
if (hasPrimary) this._buildCache(this.primaryObjects, this._cacheA)
|
||||||
if (hasSecondary) this._buildCache(this.secondaryObjects, this._cacheB)
|
if (hasSecondary) this._buildCache(this.secondaryObjects, this._cacheB)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user