Fix floorplan player move and action menu anchoring
This commit is contained in:
@@ -26,6 +26,7 @@ type FloorplanActionMenuLayerProps = {
|
|||||||
slab: FloorplanActionMenuEntry
|
slab: FloorplanActionMenuEntry
|
||||||
ceiling: FloorplanActionMenuEntry
|
ceiling: FloorplanActionMenuEntry
|
||||||
opening: FloorplanActionMenuEntry
|
opening: FloorplanActionMenuEntry
|
||||||
|
spawn: FloorplanActionMenuEntry
|
||||||
stair: FloorplanActionMenuEntry
|
stair: FloorplanActionMenuEntry
|
||||||
roof: FloorplanActionMenuEntry
|
roof: FloorplanActionMenuEntry
|
||||||
offsetY?: number
|
offsetY?: number
|
||||||
@@ -38,6 +39,7 @@ export const FloorplanActionMenuLayer = memo(function FloorplanActionMenuLayer({
|
|||||||
slab,
|
slab,
|
||||||
ceiling,
|
ceiling,
|
||||||
opening,
|
opening,
|
||||||
|
spawn,
|
||||||
stair,
|
stair,
|
||||||
roof,
|
roof,
|
||||||
offsetY = 10,
|
offsetY = 10,
|
||||||
@@ -59,6 +61,7 @@ export const FloorplanActionMenuLayer = memo(function FloorplanActionMenuLayer({
|
|||||||
slab,
|
slab,
|
||||||
ceiling,
|
ceiling,
|
||||||
opening,
|
opening,
|
||||||
|
spawn,
|
||||||
stair,
|
stair,
|
||||||
roof,
|
roof,
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -3,11 +3,11 @@
|
|||||||
import type { Point2D } from '@pascal-app/core'
|
import type { Point2D } from '@pascal-app/core'
|
||||||
|
|
||||||
function toSvgX(value: number) {
|
function toSvgX(value: number) {
|
||||||
return -value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
function toSvgY(value: number) {
|
function toSvgY(value: number) {
|
||||||
return -value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
function toSvgPoint(point: Point2D) {
|
function toSvgPoint(point: Point2D) {
|
||||||
@@ -100,9 +100,7 @@ export function buildSvgAnnularSectorPath(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function formatSvgPolygonPoints(points: Point2D[]) {
|
export function formatSvgPolygonPoints(points: Point2D[]) {
|
||||||
return points
|
return points.map((point) => `${toSvgX(point.x)},${toSvgY(point.y)}`).join(' ')
|
||||||
.map((point) => `${toSvgX(point.x)},${toSvgY(point.y)}`)
|
|
||||||
.join(' ')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildSvgArrowHeadPoints(point: Point2D, angle: number, size: number) {
|
export function buildSvgArrowHeadPoints(point: Point2D, angle: number, size: number) {
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
type RoofSegmentNode,
|
type RoofSegmentNode,
|
||||||
type SiteNode,
|
type SiteNode,
|
||||||
SlabNode,
|
SlabNode,
|
||||||
|
type SpawnNode,
|
||||||
type StairNode,
|
type StairNode,
|
||||||
StairNode as StairNodeSchema,
|
StairNode as StairNodeSchema,
|
||||||
type StairSegmentNode,
|
type StairSegmentNode,
|
||||||
@@ -215,6 +216,13 @@ const FLOORPLAN_TRACE_STRUCTURE_SELECTED_FILL_OPACITY = 0.34
|
|||||||
const FLOORPLAN_SITE_COLOR = '#10b981'
|
const FLOORPLAN_SITE_COLOR = '#10b981'
|
||||||
const FLOORPLAN_NODE_FOOTPRINT_STROKE_WIDTH = FLOORPLAN_OPENING_STROKE_WIDTH / 2
|
const FLOORPLAN_NODE_FOOTPRINT_STROKE_WIDTH = FLOORPLAN_OPENING_STROKE_WIDTH / 2
|
||||||
const FLOORPLAN_NODE_FOOTPRINT_CROSS_STROKE_WIDTH = FLOORPLAN_NODE_FOOTPRINT_STROKE_WIDTH * 0.7
|
const FLOORPLAN_NODE_FOOTPRINT_CROSS_STROKE_WIDTH = FLOORPLAN_NODE_FOOTPRINT_STROKE_WIDTH * 0.7
|
||||||
|
const FLOORPLAN_SPAWN_RING_RADIUS = 0.34
|
||||||
|
const FLOORPLAN_SPAWN_RING_STROKE_WIDTH = 0.08
|
||||||
|
const FLOORPLAN_SPAWN_HIT_RADIUS = 0.62
|
||||||
|
const FLOORPLAN_SPAWN_ARROW_POINTS = '0,-0.62 -0.19,-0.2 0.19,-0.2'
|
||||||
|
const FLOORPLAN_SPAWN_BODY_WIDTH = 0.3
|
||||||
|
const FLOORPLAN_SPAWN_BODY_HEIGHT = 0.46
|
||||||
|
const FLOORPLAN_VIEW_ROTATION_DEG = 90
|
||||||
type FloorplanViewport = {
|
type FloorplanViewport = {
|
||||||
centerX: number
|
centerX: number
|
||||||
centerY: number
|
centerY: number
|
||||||
@@ -587,6 +595,12 @@ type FloorplanItemEntry = {
|
|||||||
depth: number
|
depth: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type FloorplanSpawnEntry = {
|
||||||
|
spawn: SpawnNode
|
||||||
|
position: Point2D
|
||||||
|
rotation: number
|
||||||
|
}
|
||||||
|
|
||||||
type ReferenceFloorData = {
|
type ReferenceFloorData = {
|
||||||
ceilingPolygons: CeilingPolygonEntry[]
|
ceilingPolygons: CeilingPolygonEntry[]
|
||||||
fenceEntries: FloorplanFenceEntry[]
|
fenceEntries: FloorplanFenceEntry[]
|
||||||
@@ -772,11 +786,11 @@ function toWallPlanPoint(point: Point2D): WallPlanPoint {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function toSvgX(value: number): number {
|
function toSvgX(value: number): number {
|
||||||
return -value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
function toSvgY(value: number): number {
|
function toSvgY(value: number): number {
|
||||||
return -value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
function toSvgPoint(point: Point2D): SvgPoint {
|
function toSvgPoint(point: Point2D): SvgPoint {
|
||||||
@@ -915,11 +929,11 @@ function getGuideRotateCursor(isDarkMode: boolean) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getGuideSvgRotation(rotationY: number) {
|
function getGuideSvgRotation(rotationY: number) {
|
||||||
return normalizeAngle(Math.PI - rotationY)
|
return normalizeAngle(-rotationY)
|
||||||
}
|
}
|
||||||
|
|
||||||
function getGuideSceneRotationFromSvgRotation(rotationSvg: number) {
|
function getGuideSceneRotationFromSvgRotation(rotationSvg: number) {
|
||||||
return normalizeAngle(Math.PI - rotationSvg)
|
return normalizeAngle(-rotationSvg)
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildGuideTranslateDraft(
|
function buildGuideTranslateDraft(
|
||||||
@@ -2938,6 +2952,37 @@ function buildGridPath(
|
|||||||
return commands.join(' ')
|
return commands.join(' ')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getRotatedViewBoxBounds(
|
||||||
|
viewBox: { minX: number; minY: number; width: number; height: number },
|
||||||
|
rotationDegrees: number,
|
||||||
|
) {
|
||||||
|
const radians = (-rotationDegrees * Math.PI) / 180
|
||||||
|
const cos = Math.cos(radians)
|
||||||
|
const sin = Math.sin(radians)
|
||||||
|
const corners = [
|
||||||
|
{ x: viewBox.minX, y: viewBox.minY },
|
||||||
|
{ x: viewBox.minX + viewBox.width, y: viewBox.minY },
|
||||||
|
{ x: viewBox.minX + viewBox.width, y: viewBox.minY + viewBox.height },
|
||||||
|
{ x: viewBox.minX, y: viewBox.minY + viewBox.height },
|
||||||
|
]
|
||||||
|
|
||||||
|
let minX = Number.POSITIVE_INFINITY
|
||||||
|
let maxX = Number.NEGATIVE_INFINITY
|
||||||
|
let minY = Number.POSITIVE_INFINITY
|
||||||
|
let maxY = Number.NEGATIVE_INFINITY
|
||||||
|
|
||||||
|
for (const corner of corners) {
|
||||||
|
const x = corner.x * cos - corner.y * sin
|
||||||
|
const y = corner.x * sin + corner.y * cos
|
||||||
|
minX = Math.min(minX, x)
|
||||||
|
maxX = Math.max(maxX, x)
|
||||||
|
minY = Math.min(minY, y)
|
||||||
|
maxY = Math.max(maxY, y)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { minX, maxX, minY, maxY }
|
||||||
|
}
|
||||||
|
|
||||||
function findClosestWallPoint(
|
function findClosestWallPoint(
|
||||||
point: WallPlanPoint,
|
point: WallPlanPoint,
|
||||||
walls: WallNode[],
|
walls: WallNode[],
|
||||||
@@ -3264,20 +3309,20 @@ const FloorplanGridLayer = memo(function FloorplanGridLayer({
|
|||||||
<path
|
<path
|
||||||
d={minorGridPath}
|
d={minorGridPath}
|
||||||
fill="none"
|
fill="none"
|
||||||
opacity={palette.minorGridOpacity}
|
opacity={palette.majorGridOpacity}
|
||||||
shapeRendering="crispEdges"
|
shapeRendering="crispEdges"
|
||||||
stroke={palette.minorGrid}
|
stroke={palette.majorGrid}
|
||||||
strokeWidth={FLOORPLAN_MINOR_GRID_STROKE_WIDTH}
|
strokeWidth={FLOORPLAN_MAJOR_GRID_STROKE_WIDTH}
|
||||||
vectorEffect="non-scaling-stroke"
|
vectorEffect="non-scaling-stroke"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<path
|
<path
|
||||||
d={majorGridPath}
|
d={majorGridPath}
|
||||||
fill="none"
|
fill="none"
|
||||||
opacity={palette.majorGridOpacity}
|
opacity={palette.minorGridOpacity}
|
||||||
shapeRendering="crispEdges"
|
shapeRendering="crispEdges"
|
||||||
stroke={palette.majorGrid}
|
stroke={palette.minorGrid}
|
||||||
strokeWidth={FLOORPLAN_MAJOR_GRID_STROKE_WIDTH}
|
strokeWidth={FLOORPLAN_MINOR_GRID_STROKE_WIDTH}
|
||||||
vectorEffect="non-scaling-stroke"
|
vectorEffect="non-scaling-stroke"
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
@@ -5193,11 +5238,14 @@ function FloorplanItemImage({
|
|||||||
|
|
||||||
const FloorplanNodeLayer = memo(function FloorplanNodeLayer({
|
const FloorplanNodeLayer = memo(function FloorplanNodeLayer({
|
||||||
canFocusItems,
|
canFocusItems,
|
||||||
|
canFocusSpawns,
|
||||||
canFocusStairs,
|
canFocusStairs,
|
||||||
canSelectItems,
|
canSelectItems,
|
||||||
|
canSelectSpawns,
|
||||||
canSelectStairs,
|
canSelectStairs,
|
||||||
highlightedIdSet,
|
highlightedIdSet,
|
||||||
hoveredItemId,
|
hoveredItemId,
|
||||||
|
hoveredSpawnId,
|
||||||
hoveredStairId,
|
hoveredStairId,
|
||||||
isDeleteMode,
|
isDeleteMode,
|
||||||
isFurnishContextActive,
|
isFurnishContextActive,
|
||||||
@@ -5207,6 +5255,11 @@ const FloorplanNodeLayer = memo(function FloorplanNodeLayer({
|
|||||||
onItemHoverEnter,
|
onItemHoverEnter,
|
||||||
onItemPointerDown,
|
onItemPointerDown,
|
||||||
onItemSelect,
|
onItemSelect,
|
||||||
|
onSpawnDoubleClick,
|
||||||
|
onSpawnHoverChange,
|
||||||
|
onSpawnHoverEnter,
|
||||||
|
onSpawnPointerDown,
|
||||||
|
onSpawnSelect,
|
||||||
onStairDoubleClick,
|
onStairDoubleClick,
|
||||||
onStairHoverChange,
|
onStairHoverChange,
|
||||||
onStairHoverEnter,
|
onStairHoverEnter,
|
||||||
@@ -5214,16 +5267,20 @@ const FloorplanNodeLayer = memo(function FloorplanNodeLayer({
|
|||||||
onStairSelect,
|
onStairSelect,
|
||||||
palette,
|
palette,
|
||||||
selectedIdSet,
|
selectedIdSet,
|
||||||
|
spawnEntries,
|
||||||
stairEntries,
|
stairEntries,
|
||||||
unit,
|
unit,
|
||||||
wallSelectionHatchId,
|
wallSelectionHatchId,
|
||||||
}: {
|
}: {
|
||||||
canFocusItems: boolean
|
canFocusItems: boolean
|
||||||
|
canFocusSpawns: boolean
|
||||||
canFocusStairs: boolean
|
canFocusStairs: boolean
|
||||||
canSelectItems: boolean
|
canSelectItems: boolean
|
||||||
|
canSelectSpawns: boolean
|
||||||
canSelectStairs: boolean
|
canSelectStairs: boolean
|
||||||
highlightedIdSet: ReadonlySet<string>
|
highlightedIdSet: ReadonlySet<string>
|
||||||
hoveredItemId: ItemNode['id'] | null
|
hoveredItemId: ItemNode['id'] | null
|
||||||
|
hoveredSpawnId: SpawnNode['id'] | null
|
||||||
hoveredStairId: StairNode['id'] | null
|
hoveredStairId: StairNode['id'] | null
|
||||||
isDeleteMode: boolean
|
isDeleteMode: boolean
|
||||||
isFurnishContextActive: boolean
|
isFurnishContextActive: boolean
|
||||||
@@ -5233,6 +5290,11 @@ const FloorplanNodeLayer = memo(function FloorplanNodeLayer({
|
|||||||
onItemHoverEnter: (itemId: ItemNode['id']) => void
|
onItemHoverEnter: (itemId: ItemNode['id']) => void
|
||||||
onItemPointerDown: (itemId: ItemNode['id'], event: ReactPointerEvent<SVGElement>) => void
|
onItemPointerDown: (itemId: ItemNode['id'], event: ReactPointerEvent<SVGElement>) => void
|
||||||
onItemSelect: (itemId: ItemNode['id'], event: ReactMouseEvent<SVGElement>) => void
|
onItemSelect: (itemId: ItemNode['id'], event: ReactMouseEvent<SVGElement>) => void
|
||||||
|
onSpawnDoubleClick: (spawn: SpawnNode, event: ReactMouseEvent<SVGElement>) => void
|
||||||
|
onSpawnHoverChange: (spawnId: SpawnNode['id'] | null) => void
|
||||||
|
onSpawnHoverEnter: (spawnId: SpawnNode['id']) => void
|
||||||
|
onSpawnPointerDown: (spawnId: SpawnNode['id'], event: ReactPointerEvent<SVGElement>) => void
|
||||||
|
onSpawnSelect: (spawnId: SpawnNode['id'], event: ReactMouseEvent<SVGElement>) => void
|
||||||
onStairDoubleClick: (stair: StairNode, event: ReactMouseEvent<SVGElement>) => void
|
onStairDoubleClick: (stair: StairNode, event: ReactMouseEvent<SVGElement>) => void
|
||||||
onStairHoverChange: (stairId: StairNode['id'] | null) => void
|
onStairHoverChange: (stairId: StairNode['id'] | null) => void
|
||||||
onStairHoverEnter: (stairId: StairNode['id']) => void
|
onStairHoverEnter: (stairId: StairNode['id']) => void
|
||||||
@@ -5240,11 +5302,12 @@ const FloorplanNodeLayer = memo(function FloorplanNodeLayer({
|
|||||||
onStairSelect: (stairId: StairNode['id'], event: ReactMouseEvent<SVGElement>) => void
|
onStairSelect: (stairId: StairNode['id'], event: ReactMouseEvent<SVGElement>) => void
|
||||||
palette: FloorplanPalette
|
palette: FloorplanPalette
|
||||||
selectedIdSet: ReadonlySet<string>
|
selectedIdSet: ReadonlySet<string>
|
||||||
|
spawnEntries: FloorplanSpawnEntry[]
|
||||||
stairEntries: FloorplanStairEntry[]
|
stairEntries: FloorplanStairEntry[]
|
||||||
unit: 'metric' | 'imperial'
|
unit: 'metric' | 'imperial'
|
||||||
wallSelectionHatchId: string
|
wallSelectionHatchId: string
|
||||||
}) {
|
}) {
|
||||||
if (itemEntries.length === 0 && stairEntries.length === 0) {
|
if (itemEntries.length === 0 && stairEntries.length === 0 && spawnEntries.length === 0) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5424,6 +5487,120 @@ const FloorplanNodeLayer = memo(function FloorplanNodeLayer({
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const spawnNodes = spawnEntries.map(({ spawn, position, rotation }) => {
|
||||||
|
const isSelected = selectedIdSet.has(spawn.id)
|
||||||
|
const isHighlighted = highlightedIdSet.has(spawn.id)
|
||||||
|
const isHovered = hoveredSpawnId === spawn.id
|
||||||
|
const isDeleteHovered = isDeleteMode && isHovered
|
||||||
|
const isSelectionActive = isSelected || isHighlighted
|
||||||
|
const showHighlight = isDeleteHovered || (isHovered && !isSelectionActive)
|
||||||
|
const stroke = isDeleteHovered
|
||||||
|
? palette.deleteStroke
|
||||||
|
: isSelectionActive
|
||||||
|
? palette.selectedStroke
|
||||||
|
: '#16a34a'
|
||||||
|
const fill = isDeleteHovered ? palette.deleteFill : '#22c55e'
|
||||||
|
const rotationDeg = (-rotation * 180) / Math.PI
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g
|
||||||
|
key={spawn.id}
|
||||||
|
onClick={
|
||||||
|
canSelectSpawns
|
||||||
|
? (event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
onSpawnSelect(spawn.id, event)
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onDoubleClick={
|
||||||
|
canFocusSpawns
|
||||||
|
? (event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
onSpawnDoubleClick(spawn, event)
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onPointerDown={
|
||||||
|
canFocusSpawns && isSelected
|
||||||
|
? (event) => {
|
||||||
|
if (event.button === 0) {
|
||||||
|
onSpawnPointerDown(spawn.id, event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onPointerEnter={canSelectSpawns ? () => onSpawnHoverEnter(spawn.id) : undefined}
|
||||||
|
onPointerLeave={canSelectSpawns ? () => onSpawnHoverChange(null) : undefined}
|
||||||
|
pointerEvents={canSelectSpawns ? undefined : 'none'}
|
||||||
|
style={canSelectSpawns ? { cursor: EDITOR_CURSOR } : undefined}
|
||||||
|
transform={`translate(${toSvgX(position.x)} ${toSvgY(position.y)}) rotate(${rotationDeg})`}
|
||||||
|
>
|
||||||
|
<title>{spawn.name || 'Spawn Point'}</title>
|
||||||
|
<circle
|
||||||
|
fill="none"
|
||||||
|
pointerEvents="none"
|
||||||
|
r={FLOORPLAN_SPAWN_HIT_RADIUS}
|
||||||
|
stroke={isDeleteHovered ? palette.deleteStroke : '#22c55e'}
|
||||||
|
strokeOpacity={isDeleteHovered || isSelectionActive ? 0.2 : 0.14}
|
||||||
|
strokeWidth={0.18}
|
||||||
|
style={{
|
||||||
|
opacity: showHighlight || isSelectionActive ? 1 : 0,
|
||||||
|
transition: FLOORPLAN_HOVER_TRANSITION,
|
||||||
|
}}
|
||||||
|
vectorEffect="non-scaling-stroke"
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
fill={fill}
|
||||||
|
fillOpacity={isDeleteHovered ? 0.18 : isSelectionActive ? 0.18 : 0.1}
|
||||||
|
pointerEvents="none"
|
||||||
|
r={FLOORPLAN_SPAWN_RING_RADIUS}
|
||||||
|
stroke={stroke}
|
||||||
|
strokeOpacity={isSelectionActive || isHovered ? 0.95 : 0.82}
|
||||||
|
strokeWidth={FLOORPLAN_SPAWN_RING_STROKE_WIDTH}
|
||||||
|
vectorEffect="non-scaling-stroke"
|
||||||
|
/>
|
||||||
|
<polygon
|
||||||
|
fill={fill}
|
||||||
|
fillOpacity={isDeleteHovered ? 0.82 : 0.92}
|
||||||
|
points={FLOORPLAN_SPAWN_ARROW_POINTS}
|
||||||
|
pointerEvents="none"
|
||||||
|
stroke={stroke}
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={0.055}
|
||||||
|
vectorEffect="non-scaling-stroke"
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
fill={fill}
|
||||||
|
fillOpacity={isDeleteHovered ? 0.78 : 0.88}
|
||||||
|
height={FLOORPLAN_SPAWN_BODY_HEIGHT}
|
||||||
|
pointerEvents="none"
|
||||||
|
rx={0.045}
|
||||||
|
stroke={stroke}
|
||||||
|
strokeWidth={0.045}
|
||||||
|
vectorEffect="non-scaling-stroke"
|
||||||
|
width={FLOORPLAN_SPAWN_BODY_WIDTH}
|
||||||
|
x={-FLOORPLAN_SPAWN_BODY_WIDTH / 2}
|
||||||
|
y={-FLOORPLAN_SPAWN_BODY_HEIGHT / 2}
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
fill={isDeleteHovered ? palette.deleteStroke : '#dcfce7'}
|
||||||
|
pointerEvents="none"
|
||||||
|
r={0.09}
|
||||||
|
stroke={stroke}
|
||||||
|
strokeWidth={0.035}
|
||||||
|
vectorEffect="non-scaling-stroke"
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
fill="transparent"
|
||||||
|
pointerEvents="all"
|
||||||
|
r={FLOORPLAN_SPAWN_HIT_RADIUS}
|
||||||
|
stroke="transparent"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{isFurnishContextActive ? (
|
{isFurnishContextActive ? (
|
||||||
@@ -5446,10 +5623,12 @@ const FloorplanNodeLayer = memo(function FloorplanNodeLayer({
|
|||||||
stairEntries={stairEntries}
|
stairEntries={stairEntries}
|
||||||
/>
|
/>
|
||||||
{itemNodes}
|
{itemNodes}
|
||||||
|
{spawnNodes}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{itemNodes}
|
{itemNodes}
|
||||||
|
{spawnNodes}
|
||||||
<FloorplanStairLayer
|
<FloorplanStairLayer
|
||||||
canFocusStairs={canFocusStairs}
|
canFocusStairs={canFocusStairs}
|
||||||
canSelectStairs={canSelectStairs}
|
canSelectStairs={canSelectStairs}
|
||||||
@@ -6501,10 +6680,12 @@ export function FloorplanPanel() {
|
|||||||
roofs,
|
roofs,
|
||||||
site,
|
site,
|
||||||
slabs,
|
slabs,
|
||||||
|
spawns,
|
||||||
walls,
|
walls,
|
||||||
zones,
|
zones,
|
||||||
} = useFloorplanSceneData({ buildingId, levelId })
|
} = useFloorplanSceneData({ buildingId, levelId })
|
||||||
const buildingRotationDeg = (buildingRotationY * 180) / Math.PI
|
const buildingRotationDeg = (buildingRotationY * 180) / Math.PI
|
||||||
|
const floorplanSceneRotationDeg = FLOORPLAN_VIEW_ROTATION_DEG - buildingRotationDeg
|
||||||
|
|
||||||
const [draftStart, setDraftStart] = useState<WallPlanPoint | null>(null)
|
const [draftStart, setDraftStart] = useState<WallPlanPoint | null>(null)
|
||||||
const [draftEnd, setDraftEnd] = useState<WallPlanPoint | null>(null)
|
const [draftEnd, setDraftEnd] = useState<WallPlanPoint | null>(null)
|
||||||
@@ -6558,6 +6739,7 @@ export function FloorplanPanel() {
|
|||||||
const [hoveredSlabId, setHoveredSlabId] = useState<SlabNode['id'] | null>(null)
|
const [hoveredSlabId, setHoveredSlabId] = useState<SlabNode['id'] | null>(null)
|
||||||
const [hoveredCeilingId, setHoveredCeilingId] = useState<CeilingNode['id'] | null>(null)
|
const [hoveredCeilingId, setHoveredCeilingId] = useState<CeilingNode['id'] | null>(null)
|
||||||
const [hoveredItemId, setHoveredItemId] = useState<ItemNode['id'] | null>(null)
|
const [hoveredItemId, setHoveredItemId] = useState<ItemNode['id'] | null>(null)
|
||||||
|
const [hoveredSpawnId, setHoveredSpawnId] = useState<SpawnNode['id'] | null>(null)
|
||||||
const [hoveredStairId, setHoveredStairId] = useState<StairNode['id'] | null>(null)
|
const [hoveredStairId, setHoveredStairId] = useState<StairNode['id'] | null>(null)
|
||||||
const [hoveredZoneId, setHoveredZoneId] = useState<ZoneNodeType['id'] | null>(null)
|
const [hoveredZoneId, setHoveredZoneId] = useState<ZoneNodeType['id'] | null>(null)
|
||||||
const [hoveredEndpointId, setHoveredEndpointId] = useState<string | null>(null)
|
const [hoveredEndpointId, setHoveredEndpointId] = useState<string | null>(null)
|
||||||
@@ -7145,6 +7327,24 @@ export function FloorplanPanel() {
|
|||||||
),
|
),
|
||||||
[levelDescendantNodes],
|
[levelDescendantNodes],
|
||||||
)
|
)
|
||||||
|
const floorplanSpawnEntries = useMemo<FloorplanSpawnEntry[]>(
|
||||||
|
() =>
|
||||||
|
spawns
|
||||||
|
.filter((spawn) => spawn.visible !== false)
|
||||||
|
.map((spawn) => {
|
||||||
|
const live = useLiveTransforms.getState().get(spawn.id)
|
||||||
|
|
||||||
|
return {
|
||||||
|
spawn,
|
||||||
|
position: {
|
||||||
|
x: live?.position[0] ?? spawn.position[0],
|
||||||
|
y: live?.position[2] ?? spawn.position[2],
|
||||||
|
},
|
||||||
|
rotation: live?.rotation ?? spawn.rotation,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
[movingFloorplanNodeRevision, spawns],
|
||||||
|
)
|
||||||
const floorplanItemEntries = useMemo(() => {
|
const floorplanItemEntries = useMemo(() => {
|
||||||
const transformCache = new Map<string, SharedFloorplanNodeTransform | null>()
|
const transformCache = new Map<string, SharedFloorplanNodeTransform | null>()
|
||||||
|
|
||||||
@@ -7504,6 +7704,13 @@ export function FloorplanPanel() {
|
|||||||
|
|
||||||
return floorplanItemEntries.find(({ item }) => item.id === selectedIds[0]) ?? null
|
return floorplanItemEntries.find(({ item }) => item.id === selectedIds[0]) ?? null
|
||||||
}, [floorplanItemEntries, selectedIds])
|
}, [floorplanItemEntries, selectedIds])
|
||||||
|
const selectedSpawnEntry = useMemo(() => {
|
||||||
|
if (selectedIds.length !== 1) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return floorplanSpawnEntries.find(({ spawn }) => spawn.id === selectedIds[0]) ?? null
|
||||||
|
}, [floorplanSpawnEntries, selectedIds])
|
||||||
const selectedItemClearanceMeasurements = useMemo(() => {
|
const selectedItemClearanceMeasurements = useMemo(() => {
|
||||||
if (!selectedItemEntry) {
|
if (!selectedItemEntry) {
|
||||||
return [] as LinearMeasurementOverlay[]
|
return [] as LinearMeasurementOverlay[]
|
||||||
@@ -7837,6 +8044,7 @@ export function FloorplanPanel() {
|
|||||||
const isCeilingMoveActive = movingNode?.type === 'ceiling'
|
const isCeilingMoveActive = movingNode?.type === 'ceiling'
|
||||||
const isFenceMoveActive = movingNode?.type === 'fence'
|
const isFenceMoveActive = movingNode?.type === 'fence'
|
||||||
const isWallMoveActive = movingNode?.type === 'wall'
|
const isWallMoveActive = movingNode?.type === 'wall'
|
||||||
|
const isSpawnMoveActive = movingNode?.type === 'spawn'
|
||||||
const isWallCurveActive = curvingWall?.type === 'wall'
|
const isWallCurveActive = curvingWall?.type === 'wall'
|
||||||
const isFenceCurveActive = curvingFence?.type === 'fence'
|
const isFenceCurveActive = curvingFence?.type === 'fence'
|
||||||
const isFenceEndpointMoveActive = movingFenceEndpoint !== null
|
const isFenceEndpointMoveActive = movingFenceEndpoint !== null
|
||||||
@@ -7855,6 +8063,7 @@ export function FloorplanPanel() {
|
|||||||
isCeilingMoveActive ||
|
isCeilingMoveActive ||
|
||||||
isFenceMoveActive ||
|
isFenceMoveActive ||
|
||||||
isWallMoveActive ||
|
isWallMoveActive ||
|
||||||
|
isSpawnMoveActive ||
|
||||||
isWallCurveActive ||
|
isWallCurveActive ||
|
||||||
isFenceCurveActive ||
|
isFenceCurveActive ||
|
||||||
isFenceEndpointMoveActive ||
|
isFenceEndpointMoveActive ||
|
||||||
@@ -7976,6 +8185,7 @@ export function FloorplanPanel() {
|
|||||||
!movingFenceEndpoint &&
|
!movingFenceEndpoint &&
|
||||||
isFloorplanStructureContextActive) ||
|
isFloorplanStructureContextActive) ||
|
||||||
isDeleteMode
|
isDeleteMode
|
||||||
|
const canSelectFloorplanSpawns = canSelectFloorplanStairs
|
||||||
const canSelectFloorplanItems =
|
const canSelectFloorplanItems =
|
||||||
(mode === 'select' &&
|
(mode === 'select' &&
|
||||||
floorplanSelectionTool === 'click' &&
|
floorplanSelectionTool === 'click' &&
|
||||||
@@ -7989,6 +8199,7 @@ export function FloorplanPanel() {
|
|||||||
!movingNode &&
|
!movingNode &&
|
||||||
!movingFenceEndpoint &&
|
!movingFenceEndpoint &&
|
||||||
isFloorplanStructureContextActive
|
isFloorplanStructureContextActive
|
||||||
|
const canFocusFloorplanSpawns = canFocusFloorplanStairs
|
||||||
const canFocusFloorplanItems =
|
const canFocusFloorplanItems =
|
||||||
mode === 'select' &&
|
mode === 'select' &&
|
||||||
floorplanSelectionTool === 'click' &&
|
floorplanSelectionTool === 'click' &&
|
||||||
@@ -8744,6 +8955,47 @@ export function FloorplanPanel() {
|
|||||||
: null,
|
: null,
|
||||||
[selectedItemEntry, surfaceSize, viewBox],
|
[selectedItemEntry, surfaceSize, viewBox],
|
||||||
)
|
)
|
||||||
|
const selectedSpawnActionMenuPosition = useMemo(() => {
|
||||||
|
if (!selectedSpawnEntry) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const { position } = selectedSpawnEntry
|
||||||
|
const svg = svgRef.current
|
||||||
|
const scene = floorplanSceneRef.current
|
||||||
|
const sceneCtm = scene?.getScreenCTM()
|
||||||
|
const hasResolvedSceneRotation = Number.isFinite(floorplanSceneRotationDeg)
|
||||||
|
|
||||||
|
if (svg && scene && sceneCtm && hasResolvedSceneRotation) {
|
||||||
|
const svgRect = svg.getBoundingClientRect()
|
||||||
|
const svgPoint = svg.createSVGPoint()
|
||||||
|
svgPoint.x = toSvgX(position.x)
|
||||||
|
svgPoint.y = toSvgY(position.y) - FLOORPLAN_SPAWN_HIT_RADIUS
|
||||||
|
|
||||||
|
const screenPoint = svgPoint.matrixTransform(sceneCtm)
|
||||||
|
const anchorX = screenPoint.x - svgRect.left
|
||||||
|
const anchorY = screenPoint.y - svgRect.top
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: Math.min(
|
||||||
|
Math.max(anchorX, FLOORPLAN_ACTION_MENU_HORIZONTAL_PADDING),
|
||||||
|
surfaceSize.width - FLOORPLAN_ACTION_MENU_HORIZONTAL_PADDING,
|
||||||
|
),
|
||||||
|
y: Math.max(anchorY, FLOORPLAN_ACTION_MENU_MIN_ANCHOR_Y),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return getFloorplanActionMenuPosition(
|
||||||
|
[
|
||||||
|
{ x: position.x - FLOORPLAN_SPAWN_HIT_RADIUS, y: position.y - FLOORPLAN_SPAWN_HIT_RADIUS },
|
||||||
|
{ x: position.x + FLOORPLAN_SPAWN_HIT_RADIUS, y: position.y - FLOORPLAN_SPAWN_HIT_RADIUS },
|
||||||
|
{ x: position.x + FLOORPLAN_SPAWN_HIT_RADIUS, y: position.y + FLOORPLAN_SPAWN_HIT_RADIUS },
|
||||||
|
{ x: position.x - FLOORPLAN_SPAWN_HIT_RADIUS, y: position.y + FLOORPLAN_SPAWN_HIT_RADIUS },
|
||||||
|
],
|
||||||
|
viewBox,
|
||||||
|
surfaceSize,
|
||||||
|
)
|
||||||
|
}, [floorplanSceneRotationDeg, selectedSpawnEntry, surfaceSize, viewBox])
|
||||||
const selectedSlabActionMenuPosition = useMemo(() => {
|
const selectedSlabActionMenuPosition = useMemo(() => {
|
||||||
if (slabHoleMoveDraft) {
|
if (slabHoleMoveDraft) {
|
||||||
return null
|
return null
|
||||||
@@ -9049,31 +9301,35 @@ export function FloorplanPanel() {
|
|||||||
() => getVisibleGridSteps(viewBox.width, surfaceSize.width),
|
() => getVisibleGridSteps(viewBox.width, surfaceSize.width),
|
||||||
[surfaceSize.width, viewBox.width],
|
[surfaceSize.width, viewBox.width],
|
||||||
)
|
)
|
||||||
|
const gridBounds = useMemo(
|
||||||
|
() => getRotatedViewBoxBounds(viewBox, floorplanSceneRotationDeg),
|
||||||
|
[floorplanSceneRotationDeg, viewBox],
|
||||||
|
)
|
||||||
|
|
||||||
const minorGridPath = useMemo(
|
const minorGridPath = useMemo(
|
||||||
() =>
|
() =>
|
||||||
buildGridPath(
|
buildGridPath(
|
||||||
viewBox.minX,
|
gridBounds.minX,
|
||||||
viewBox.minX + viewBox.width,
|
gridBounds.maxX,
|
||||||
viewBox.minY,
|
gridBounds.minY,
|
||||||
viewBox.minY + viewBox.height,
|
gridBounds.maxY,
|
||||||
gridSteps.minorStep,
|
gridSteps.minorStep,
|
||||||
{
|
{
|
||||||
excludeStep: gridSteps.majorStep,
|
excludeStep: gridSteps.majorStep,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
[gridSteps.majorStep, gridSteps.minorStep, viewBox],
|
[gridBounds, gridSteps.majorStep, gridSteps.minorStep],
|
||||||
)
|
)
|
||||||
const majorGridPath = useMemo(
|
const majorGridPath = useMemo(
|
||||||
() =>
|
() =>
|
||||||
buildGridPath(
|
buildGridPath(
|
||||||
viewBox.minX,
|
gridBounds.minX,
|
||||||
viewBox.minX + viewBox.width,
|
gridBounds.maxX,
|
||||||
viewBox.minY,
|
gridBounds.minY,
|
||||||
viewBox.minY + viewBox.height,
|
gridBounds.maxY,
|
||||||
gridSteps.majorStep,
|
gridSteps.majorStep,
|
||||||
),
|
),
|
||||||
[gridSteps.majorStep, viewBox],
|
[gridBounds, gridSteps.majorStep],
|
||||||
)
|
)
|
||||||
const floorplanUnitsPerPixel = viewBox.width / Math.max(surfaceSize.width, 1)
|
const floorplanUnitsPerPixel = viewBox.width / Math.max(surfaceSize.width, 1)
|
||||||
|
|
||||||
@@ -9808,6 +10064,30 @@ export function FloorplanPanel() {
|
|||||||
return unsubscribe
|
return unsubscribe
|
||||||
}, [movingNode, scheduleMovingFloorplanNodeRefresh])
|
}, [movingNode, scheduleMovingFloorplanNodeRefresh])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (movingNode?.type !== 'spawn') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const movingSpawnId = movingNode.id
|
||||||
|
const refreshSpawnPreview = () => {
|
||||||
|
scheduleMovingFloorplanNodeRefresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshSpawnPreview()
|
||||||
|
|
||||||
|
const unsubscribe = useLiveTransforms.subscribe((state, previousState) => {
|
||||||
|
const nextTransform = state.transforms.get(movingSpawnId)
|
||||||
|
const previousTransform = previousState.transforms.get(movingSpawnId)
|
||||||
|
|
||||||
|
if (nextTransform !== previousTransform) {
|
||||||
|
refreshSpawnPreview()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return unsubscribe
|
||||||
|
}, [movingNode, scheduleMovingFloorplanNodeRefresh])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
const target = event.target as HTMLElement | null
|
const target = event.target as HTMLElement | null
|
||||||
@@ -9831,7 +10111,9 @@ export function FloorplanPanel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
(movingNode?.type === 'stair' || movingNode?.type === 'item') &&
|
(movingNode?.type === 'stair' ||
|
||||||
|
movingNode?.type === 'item' ||
|
||||||
|
movingNode?.type === 'spawn') &&
|
||||||
(event.key === 'r' || event.key === 'R' || event.key === 't' || event.key === 'T')
|
(event.key === 'r' || event.key === 'R' || event.key === 't' || event.key === 'T')
|
||||||
) {
|
) {
|
||||||
setMovingFloorplanNodeRevision((current) => current + 1)
|
setMovingFloorplanNodeRevision((current) => current + 1)
|
||||||
@@ -11070,7 +11352,7 @@ export function FloorplanPanel() {
|
|||||||
|
|
||||||
const hoveredWallIdRef = useRef<string | null>(null)
|
const hoveredWallIdRef = useRef<string | null>(null)
|
||||||
const floorplanGridLocalY = useMemo(() => {
|
const floorplanGridLocalY = useMemo(() => {
|
||||||
if (movingNode?.type === 'item') {
|
if (movingNode?.type === 'item' || movingNode?.type === 'spawn') {
|
||||||
return movingNode.position[1]
|
return movingNode.position[1]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -11107,8 +11389,8 @@ export function FloorplanPanel() {
|
|||||||
const snappedPoint = getSnappedFloorplanPoint(planPoint)
|
const snappedPoint = getSnappedFloorplanPoint(planPoint)
|
||||||
const cos = Math.cos(buildingRotationY)
|
const cos = Math.cos(buildingRotationY)
|
||||||
const sin = Math.sin(buildingRotationY)
|
const sin = Math.sin(buildingRotationY)
|
||||||
const worldX = buildingPosition[0] + snappedPoint[0] * cos - snappedPoint[1] * sin
|
const worldX = buildingPosition[0] + snappedPoint[0] * cos + snappedPoint[1] * sin
|
||||||
const worldZ = buildingPosition[2] + snappedPoint[0] * sin + snappedPoint[1] * cos
|
const worldZ = buildingPosition[2] - snappedPoint[0] * sin + snappedPoint[1] * cos
|
||||||
|
|
||||||
emitter.emit(`grid:${eventType}` as any, {
|
emitter.emit(`grid:${eventType}` as any, {
|
||||||
nativeEvent: nativeEvent.nativeEvent as any,
|
nativeEvent: nativeEvent.nativeEvent as any,
|
||||||
@@ -11918,6 +12200,14 @@ export function FloorplanPanel() {
|
|||||||
[syncDeleteHoveredId],
|
[syncDeleteHoveredId],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const handleSpawnHoverChange = useCallback(
|
||||||
|
(spawnId: SpawnNode['id'] | null) => {
|
||||||
|
setHoveredSpawnId(spawnId)
|
||||||
|
syncDeleteHoveredId(spawnId)
|
||||||
|
},
|
||||||
|
[syncDeleteHoveredId],
|
||||||
|
)
|
||||||
|
|
||||||
const handleStairHoverChange = useCallback(
|
const handleStairHoverChange = useCallback(
|
||||||
(stairId: StairNode['id'] | null) => {
|
(stairId: StairNode['id'] | null) => {
|
||||||
setHoveredStairId(stairId)
|
setHoveredStairId(stairId)
|
||||||
@@ -11941,6 +12231,7 @@ export function FloorplanPanel() {
|
|||||||
handleSlabHoverChange(null)
|
handleSlabHoverChange(null)
|
||||||
handleCeilingHoverChange(null)
|
handleCeilingHoverChange(null)
|
||||||
handleStairHoverChange(null)
|
handleStairHoverChange(null)
|
||||||
|
handleSpawnHoverChange(null)
|
||||||
handleZoneHoverChange(null)
|
handleZoneHoverChange(null)
|
||||||
handleItemHoverChange(itemId)
|
handleItemHoverChange(itemId)
|
||||||
},
|
},
|
||||||
@@ -11950,6 +12241,7 @@ export function FloorplanPanel() {
|
|||||||
handleOpeningHoverChange,
|
handleOpeningHoverChange,
|
||||||
handleCeilingHoverChange,
|
handleCeilingHoverChange,
|
||||||
handleSlabHoverChange,
|
handleSlabHoverChange,
|
||||||
|
handleSpawnHoverChange,
|
||||||
handleStairHoverChange,
|
handleStairHoverChange,
|
||||||
handleWallHoverChange,
|
handleWallHoverChange,
|
||||||
handleZoneHoverChange,
|
handleZoneHoverChange,
|
||||||
@@ -11963,6 +12255,7 @@ export function FloorplanPanel() {
|
|||||||
handleSlabHoverChange(null)
|
handleSlabHoverChange(null)
|
||||||
handleCeilingHoverChange(null)
|
handleCeilingHoverChange(null)
|
||||||
handleStairHoverChange(null)
|
handleStairHoverChange(null)
|
||||||
|
handleSpawnHoverChange(null)
|
||||||
handleZoneHoverChange(null)
|
handleZoneHoverChange(null)
|
||||||
handleFenceHoverChange(fenceId)
|
handleFenceHoverChange(fenceId)
|
||||||
},
|
},
|
||||||
@@ -11972,6 +12265,7 @@ export function FloorplanPanel() {
|
|||||||
handleOpeningHoverChange,
|
handleOpeningHoverChange,
|
||||||
handleCeilingHoverChange,
|
handleCeilingHoverChange,
|
||||||
handleSlabHoverChange,
|
handleSlabHoverChange,
|
||||||
|
handleSpawnHoverChange,
|
||||||
handleStairHoverChange,
|
handleStairHoverChange,
|
||||||
handleWallHoverChange,
|
handleWallHoverChange,
|
||||||
handleZoneHoverChange,
|
handleZoneHoverChange,
|
||||||
@@ -11985,6 +12279,7 @@ export function FloorplanPanel() {
|
|||||||
handleSlabHoverChange(null)
|
handleSlabHoverChange(null)
|
||||||
handleCeilingHoverChange(null)
|
handleCeilingHoverChange(null)
|
||||||
handleWallHoverChange(null)
|
handleWallHoverChange(null)
|
||||||
|
handleSpawnHoverChange(null)
|
||||||
handleZoneHoverChange(null)
|
handleZoneHoverChange(null)
|
||||||
handleStairHoverChange(stairId)
|
handleStairHoverChange(stairId)
|
||||||
},
|
},
|
||||||
@@ -11994,6 +12289,31 @@ export function FloorplanPanel() {
|
|||||||
handleOpeningHoverChange,
|
handleOpeningHoverChange,
|
||||||
handleCeilingHoverChange,
|
handleCeilingHoverChange,
|
||||||
handleSlabHoverChange,
|
handleSlabHoverChange,
|
||||||
|
handleSpawnHoverChange,
|
||||||
|
handleStairHoverChange,
|
||||||
|
handleWallHoverChange,
|
||||||
|
handleZoneHoverChange,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
const handleFloorplanSpawnHoverEnter = useCallback(
|
||||||
|
(spawnId: SpawnNode['id']) => {
|
||||||
|
handleItemHoverChange(null)
|
||||||
|
handleFenceHoverChange(null)
|
||||||
|
handleOpeningHoverChange(null)
|
||||||
|
handleSlabHoverChange(null)
|
||||||
|
handleCeilingHoverChange(null)
|
||||||
|
handleWallHoverChange(null)
|
||||||
|
handleStairHoverChange(null)
|
||||||
|
handleZoneHoverChange(null)
|
||||||
|
handleSpawnHoverChange(spawnId)
|
||||||
|
},
|
||||||
|
[
|
||||||
|
handleCeilingHoverChange,
|
||||||
|
handleFenceHoverChange,
|
||||||
|
handleItemHoverChange,
|
||||||
|
handleOpeningHoverChange,
|
||||||
|
handleSlabHoverChange,
|
||||||
|
handleSpawnHoverChange,
|
||||||
handleStairHoverChange,
|
handleStairHoverChange,
|
||||||
handleWallHoverChange,
|
handleWallHoverChange,
|
||||||
handleZoneHoverChange,
|
handleZoneHoverChange,
|
||||||
@@ -12086,6 +12406,7 @@ export function FloorplanPanel() {
|
|||||||
| OpeningNode['id']
|
| OpeningNode['id']
|
||||||
| SlabNode['id']
|
| SlabNode['id']
|
||||||
| CeilingNode['id']
|
| CeilingNode['id']
|
||||||
|
| SpawnNode['id']
|
||||||
| StairNode['id']
|
| StairNode['id']
|
||||||
| ZoneNodeType['id'],
|
| ZoneNodeType['id'],
|
||||||
eventType: 'click' | 'double-click',
|
eventType: 'click' | 'double-click',
|
||||||
@@ -12100,6 +12421,7 @@ export function FloorplanPanel() {
|
|||||||
node.type === 'door' ||
|
node.type === 'door' ||
|
||||||
node.type === 'window' ||
|
node.type === 'window' ||
|
||||||
node.type === 'item' ||
|
node.type === 'item' ||
|
||||||
|
node.type === 'spawn' ||
|
||||||
node.type === 'stair' ||
|
node.type === 'stair' ||
|
||||||
node.type === 'zone')
|
node.type === 'zone')
|
||||||
)
|
)
|
||||||
@@ -12309,6 +12631,12 @@ export function FloorplanPanel() {
|
|||||||
},
|
},
|
||||||
[emitFloorplanNodeClick],
|
[emitFloorplanNodeClick],
|
||||||
)
|
)
|
||||||
|
const handleSpawnSelect = useCallback(
|
||||||
|
(spawnId: SpawnNode['id'], event: ReactMouseEvent<SVGElement>) => {
|
||||||
|
emitFloorplanNodeClick(spawnId, 'click', event)
|
||||||
|
},
|
||||||
|
[emitFloorplanNodeClick],
|
||||||
|
)
|
||||||
const handleStairSelect = useCallback(
|
const handleStairSelect = useCallback(
|
||||||
(stairId: StairNode['id'], event: ReactMouseEvent<SVGElement>) => {
|
(stairId: StairNode['id'], event: ReactMouseEvent<SVGElement>) => {
|
||||||
emitFloorplanNodeClick(stairId, 'click', event)
|
emitFloorplanNodeClick(stairId, 'click', event)
|
||||||
@@ -12347,6 +12675,73 @@ export function FloorplanPanel() {
|
|||||||
},
|
},
|
||||||
[emitFloorplanNodeClick],
|
[emitFloorplanNodeClick],
|
||||||
)
|
)
|
||||||
|
const handleSpawnDoubleClick = useCallback(
|
||||||
|
(spawn: SpawnNode, event: ReactMouseEvent<SVGElement>) => {
|
||||||
|
emitFloorplanNodeClick(spawn.id, 'double-click', event)
|
||||||
|
emitter.emit('camera-controls:focus', { nodeId: spawn.id })
|
||||||
|
},
|
||||||
|
[emitFloorplanNodeClick],
|
||||||
|
)
|
||||||
|
const handleSpawnPointerDown = useCallback(
|
||||||
|
(spawnId: SpawnNode['id'], event: ReactPointerEvent<SVGElement>) => {
|
||||||
|
if (event.button !== 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const spawn = selectedSpawnEntry?.spawn
|
||||||
|
if (!spawn || spawn.id !== spawnId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
|
||||||
|
const suppressClick = (clickEvent: MouseEvent) => {
|
||||||
|
clickEvent.stopImmediatePropagation()
|
||||||
|
clickEvent.preventDefault()
|
||||||
|
window.removeEventListener('click', suppressClick, true)
|
||||||
|
}
|
||||||
|
window.addEventListener('click', suppressClick, true)
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
window.removeEventListener('click', suppressClick, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
sfxEmitter.emit('sfx:item-pick')
|
||||||
|
setMovingNode(spawn)
|
||||||
|
setSelection({ selectedIds: [] })
|
||||||
|
},
|
||||||
|
[selectedSpawnEntry, setMovingNode, setSelection],
|
||||||
|
)
|
||||||
|
const handleSelectedSpawnMove = useCallback(
|
||||||
|
(event: ReactMouseEvent<HTMLButtonElement>) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
|
||||||
|
const spawn = selectedSpawnEntry?.spawn
|
||||||
|
if (!spawn) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sfxEmitter.emit('sfx:item-pick')
|
||||||
|
setMovingNode(spawn)
|
||||||
|
setSelection({ selectedIds: [] })
|
||||||
|
},
|
||||||
|
[selectedSpawnEntry, setMovingNode, setSelection],
|
||||||
|
)
|
||||||
|
const handleSelectedSpawnDelete = useCallback(
|
||||||
|
(event: ReactMouseEvent<HTMLButtonElement>) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
|
||||||
|
const spawn = selectedSpawnEntry?.spawn
|
||||||
|
if (!spawn) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sfxEmitter.emit('sfx:item-delete')
|
||||||
|
deleteNode(spawn.id as AnyNodeId)
|
||||||
|
setSelection({ selectedIds: [] })
|
||||||
|
},
|
||||||
|
[deleteNode, selectedSpawnEntry, setSelection],
|
||||||
|
)
|
||||||
const handleItemPointerDown = useCallback(
|
const handleItemPointerDown = useCallback(
|
||||||
(itemId: ItemNode['id'], event: ReactPointerEvent<SVGElement>) => {
|
(itemId: ItemNode['id'], event: ReactPointerEvent<SVGElement>) => {
|
||||||
if (event.button !== 0) {
|
if (event.button !== 0) {
|
||||||
@@ -13818,6 +14213,7 @@ export function FloorplanPanel() {
|
|||||||
handleWallHoverChange(null)
|
handleWallHoverChange(null)
|
||||||
handleSlabHoverChange(null)
|
handleSlabHoverChange(null)
|
||||||
handleCeilingHoverChange(null)
|
handleCeilingHoverChange(null)
|
||||||
|
handleSpawnHoverChange(null)
|
||||||
handleStairHoverChange(null)
|
handleStairHoverChange(null)
|
||||||
handleZoneHoverChange(null)
|
handleZoneHoverChange(null)
|
||||||
setHoveredEndpointId(null)
|
setHoveredEndpointId(null)
|
||||||
@@ -13835,6 +14231,7 @@ export function FloorplanPanel() {
|
|||||||
handleItemHoverChange,
|
handleItemHoverChange,
|
||||||
handleOpeningHoverChange,
|
handleOpeningHoverChange,
|
||||||
handleSlabHoverChange,
|
handleSlabHoverChange,
|
||||||
|
handleSpawnHoverChange,
|
||||||
handleStairHoverChange,
|
handleStairHoverChange,
|
||||||
handleWallHoverChange,
|
handleWallHoverChange,
|
||||||
handleZoneHoverChange,
|
handleZoneHoverChange,
|
||||||
@@ -13940,6 +14337,7 @@ export function FloorplanPanel() {
|
|||||||
handleOpeningHoverChange(null)
|
handleOpeningHoverChange(null)
|
||||||
handleWallHoverChange(null)
|
handleWallHoverChange(null)
|
||||||
handleSlabHoverChange(null)
|
handleSlabHoverChange(null)
|
||||||
|
handleSpawnHoverChange(null)
|
||||||
handleStairHoverChange(null)
|
handleStairHoverChange(null)
|
||||||
handleZoneHoverChange(null)
|
handleZoneHoverChange(null)
|
||||||
setHoveredEndpointId(null)
|
setHoveredEndpointId(null)
|
||||||
@@ -13960,6 +14358,7 @@ export function FloorplanPanel() {
|
|||||||
handleItemHoverChange,
|
handleItemHoverChange,
|
||||||
handleOpeningHoverChange,
|
handleOpeningHoverChange,
|
||||||
handleSlabHoverChange,
|
handleSlabHoverChange,
|
||||||
|
handleSpawnHoverChange,
|
||||||
handleStairHoverChange,
|
handleStairHoverChange,
|
||||||
handleWallHoverChange,
|
handleWallHoverChange,
|
||||||
handleZoneHoverChange,
|
handleZoneHoverChange,
|
||||||
@@ -14397,6 +14796,11 @@ export function FloorplanPanel() {
|
|||||||
onDuplicate: handleSelectedOpeningDuplicate,
|
onDuplicate: handleSelectedOpeningDuplicate,
|
||||||
onMove: handleSelectedOpeningMove,
|
onMove: handleSelectedOpeningMove,
|
||||||
}}
|
}}
|
||||||
|
spawn={{
|
||||||
|
position: selectedSpawnActionMenuPosition,
|
||||||
|
onDelete: handleSelectedSpawnDelete,
|
||||||
|
onMove: handleSelectedSpawnMove,
|
||||||
|
}}
|
||||||
roof={{
|
roof={{
|
||||||
position: selectedRoofActionMenuPosition,
|
position: selectedRoofActionMenuPosition,
|
||||||
onDelete: handleSelectedRoofDelete,
|
onDelete: handleSelectedRoofDelete,
|
||||||
@@ -14598,7 +15002,9 @@ export function FloorplanPanel() {
|
|||||||
|
|
||||||
<g
|
<g
|
||||||
ref={floorplanSceneRef}
|
ref={floorplanSceneRef}
|
||||||
transform={buildingRotationDeg !== 0 ? `rotate(${buildingRotationDeg})` : undefined}
|
transform={
|
||||||
|
floorplanSceneRotationDeg !== 0 ? `rotate(${floorplanSceneRotationDeg})` : undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<FloorplanGridLayer
|
<FloorplanGridLayer
|
||||||
majorGridPath={majorGridPath}
|
majorGridPath={majorGridPath}
|
||||||
@@ -14691,11 +15097,14 @@ export function FloorplanPanel() {
|
|||||||
|
|
||||||
<FloorplanNodeLayer
|
<FloorplanNodeLayer
|
||||||
canFocusItems={canFocusFloorplanItems}
|
canFocusItems={canFocusFloorplanItems}
|
||||||
|
canFocusSpawns={canFocusFloorplanSpawns}
|
||||||
canFocusStairs={canFocusFloorplanStairs}
|
canFocusStairs={canFocusFloorplanStairs}
|
||||||
canSelectItems={canSelectFloorplanItems}
|
canSelectItems={canSelectFloorplanItems}
|
||||||
|
canSelectSpawns={canSelectFloorplanSpawns}
|
||||||
canSelectStairs={canSelectFloorplanStairs}
|
canSelectStairs={canSelectFloorplanStairs}
|
||||||
highlightedIdSet={highlightedFloorplanIdSet}
|
highlightedIdSet={highlightedFloorplanIdSet}
|
||||||
hoveredItemId={hoveredItemId}
|
hoveredItemId={hoveredItemId}
|
||||||
|
hoveredSpawnId={hoveredSpawnId}
|
||||||
hoveredStairId={hoveredStairId}
|
hoveredStairId={hoveredStairId}
|
||||||
isDeleteMode={isDeleteMode}
|
isDeleteMode={isDeleteMode}
|
||||||
isFurnishContextActive={isFloorplanFurnishContextActive}
|
isFurnishContextActive={isFloorplanFurnishContextActive}
|
||||||
@@ -14705,6 +15114,11 @@ export function FloorplanPanel() {
|
|||||||
onItemHoverEnter={handleFloorplanItemHoverEnter}
|
onItemHoverEnter={handleFloorplanItemHoverEnter}
|
||||||
onItemPointerDown={handleItemPointerDown}
|
onItemPointerDown={handleItemPointerDown}
|
||||||
onItemSelect={handleItemSelect}
|
onItemSelect={handleItemSelect}
|
||||||
|
onSpawnDoubleClick={handleSpawnDoubleClick}
|
||||||
|
onSpawnHoverChange={handleSpawnHoverChange}
|
||||||
|
onSpawnHoverEnter={handleFloorplanSpawnHoverEnter}
|
||||||
|
onSpawnPointerDown={handleSpawnPointerDown}
|
||||||
|
onSpawnSelect={handleSpawnSelect}
|
||||||
onStairDoubleClick={handleStairDoubleClick}
|
onStairDoubleClick={handleStairDoubleClick}
|
||||||
onStairHoverChange={handleStairHoverChange}
|
onStairHoverChange={handleStairHoverChange}
|
||||||
onStairHoverEnter={handleFloorplanStairHoverEnter}
|
onStairHoverEnter={handleFloorplanStairHoverEnter}
|
||||||
@@ -14712,6 +15126,7 @@ export function FloorplanPanel() {
|
|||||||
onStairSelect={handleStairSelect}
|
onStairSelect={handleStairSelect}
|
||||||
palette={palette}
|
palette={palette}
|
||||||
selectedIdSet={selectedIdSet}
|
selectedIdSet={selectedIdSet}
|
||||||
|
spawnEntries={floorplanSpawnEntries}
|
||||||
stairEntries={renderedFloorplanStairEntries}
|
stairEntries={renderedFloorplanStairEntries}
|
||||||
unit={unit}
|
unit={unit}
|
||||||
wallSelectionHatchId={wallSelectionHatchId}
|
wallSelectionHatchId={wallSelectionHatchId}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
type RoofNode,
|
type RoofNode,
|
||||||
type SiteNode,
|
type SiteNode,
|
||||||
type SlabNode,
|
type SlabNode,
|
||||||
|
type SpawnNode,
|
||||||
useScene,
|
useScene,
|
||||||
type WallNode,
|
type WallNode,
|
||||||
type WindowNode,
|
type WindowNode,
|
||||||
@@ -113,6 +114,7 @@ export function useFloorplanSceneData({
|
|||||||
)
|
)
|
||||||
const levelGuides = useLevelChildren(levelId, (node): node is GuideNode => node?.type === 'guide')
|
const levelGuides = useLevelChildren(levelId, (node): node is GuideNode => node?.type === 'guide')
|
||||||
const zones = useLevelChildren(levelId, (node): node is ZoneNodeType => node?.type === 'zone')
|
const zones = useLevelChildren(levelId, (node): node is ZoneNodeType => node?.type === 'zone')
|
||||||
|
const spawns = useLevelChildren(levelId, (node): node is SpawnNode => node?.type === 'spawn')
|
||||||
const roofs = useScene(
|
const roofs = useScene(
|
||||||
useShallow((state) => {
|
useShallow((state) => {
|
||||||
if (!levelId) {
|
if (!levelId) {
|
||||||
@@ -180,6 +182,7 @@ export function useFloorplanSceneData({
|
|||||||
roofs,
|
roofs,
|
||||||
site,
|
site,
|
||||||
slabs,
|
slabs,
|
||||||
|
spawns,
|
||||||
walls,
|
walls,
|
||||||
zones,
|
zones,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,8 +10,10 @@ const Y_OFFSET = 0.02
|
|||||||
|
|
||||||
type DragState = {
|
type DragState = {
|
||||||
isDragging: boolean
|
isDragging: boolean
|
||||||
mode: 'vertex' | 'polygon'
|
mode: 'vertex' | 'polygon' | 'edge'
|
||||||
vertexIndex: number | null
|
vertexIndex: number | null
|
||||||
|
edgeIndex?: number
|
||||||
|
edgeNormal?: [number, number]
|
||||||
initialPosition: [number, number]
|
initialPosition: [number, number]
|
||||||
initialPolygon: Array<[number, number]>
|
initialPolygon: Array<[number, number]>
|
||||||
pointerId: number
|
pointerId: number
|
||||||
@@ -28,6 +30,8 @@ export interface PolygonEditorProps {
|
|||||||
surfaceHeight?: number
|
surfaceHeight?: number
|
||||||
/** Whether to show the center handle that moves the entire polygon. */
|
/** Whether to show the center handle that moves the entire polygon. */
|
||||||
allowPolygonMove?: boolean
|
allowPolygonMove?: boolean
|
||||||
|
/** Whether polygon edges can be dragged along their perpendicular normal. */
|
||||||
|
allowEdgeMove?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -35,6 +39,17 @@ export interface PolygonEditorProps {
|
|||||||
* Used by zone and site boundary editors
|
* Used by zone and site boundary editors
|
||||||
*/
|
*/
|
||||||
const MIN_HANDLE_HEIGHT = 0.15
|
const MIN_HANDLE_HEIGHT = 0.15
|
||||||
|
const EDGE_HANDLE_HEIGHT = 0.06
|
||||||
|
const EDGE_HANDLE_THICKNESS = 0.12
|
||||||
|
|
||||||
|
function getEdgeNormal(start: [number, number], end: [number, number]): [number, number] | null {
|
||||||
|
const dx = end[0] - start[0]
|
||||||
|
const dz = end[1] - start[1]
|
||||||
|
const length = Math.hypot(dx, dz)
|
||||||
|
if (length < 1e-6) return null
|
||||||
|
|
||||||
|
return [-dz / length, dx / length]
|
||||||
|
}
|
||||||
|
|
||||||
export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
||||||
polygon,
|
polygon,
|
||||||
@@ -44,6 +59,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|||||||
levelId,
|
levelId,
|
||||||
surfaceHeight = 0,
|
surfaceHeight = 0,
|
||||||
allowPolygonMove = false,
|
allowPolygonMove = false,
|
||||||
|
allowEdgeMove = false,
|
||||||
}) => {
|
}) => {
|
||||||
const [levelNode, setLevelNode] = useState<Object3D | null>(() =>
|
const [levelNode, setLevelNode] = useState<Object3D | null>(() =>
|
||||||
levelId ? (sceneRegistry.nodes.get(levelId) ?? null) : null,
|
levelId ? (sceneRegistry.nodes.get(levelId) ?? null) : null,
|
||||||
@@ -89,6 +105,11 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|||||||
const [previewPolygon, setPreviewPolygon] = useState<Array<[number, number]> | null>(null)
|
const [previewPolygon, setPreviewPolygon] = useState<Array<[number, number]> | null>(null)
|
||||||
const previewPolygonRef = useRef<Array<[number, number]> | null>(null)
|
const previewPolygonRef = useRef<Array<[number, number]> | null>(null)
|
||||||
|
|
||||||
|
const updatePreviewPolygon = useCallback((nextPolygon: Array<[number, number]> | null) => {
|
||||||
|
previewPolygonRef.current = nextPolygon
|
||||||
|
setPreviewPolygon(nextPolygon)
|
||||||
|
}, [])
|
||||||
|
|
||||||
// Keep ref in sync
|
// Keep ref in sync
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
previewPolygonRef.current = previewPolygon
|
previewPolygonRef.current = previewPolygon
|
||||||
@@ -96,6 +117,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|||||||
|
|
||||||
const [hoveredVertex, setHoveredVertex] = useState<number | null>(null)
|
const [hoveredVertex, setHoveredVertex] = useState<number | null>(null)
|
||||||
const [hoveredMidpoint, setHoveredMidpoint] = useState<number | null>(null)
|
const [hoveredMidpoint, setHoveredMidpoint] = useState<number | null>(null)
|
||||||
|
const [hoveredEdge, setHoveredEdge] = useState<number | null>(null)
|
||||||
const [cursorPosition, setCursorPosition] = useState<[number, number]>([0, 0])
|
const [cursorPosition, setCursorPosition] = useState<[number, number]>([0, 0])
|
||||||
|
|
||||||
const lineRef = useRef<Line>(null!)
|
const lineRef = useRef<Line>(null!)
|
||||||
@@ -106,7 +128,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|||||||
if (polygon !== lastPolygonRef.current) {
|
if (polygon !== lastPolygonRef.current) {
|
||||||
lastPolygonRef.current = polygon
|
lastPolygonRef.current = polygon
|
||||||
// External change (e.g. undo/redo) — clear any stale preview/drag state
|
// External change (e.g. undo/redo) — clear any stale preview/drag state
|
||||||
if (previewPolygon) setPreviewPolygon(null)
|
if (previewPolygon) updatePreviewPolygon(null)
|
||||||
if (dragState) setDragState(null)
|
if (dragState) setDragState(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,17 +156,37 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|||||||
})
|
})
|
||||||
}, [displayPolygon])
|
}, [displayPolygon])
|
||||||
|
|
||||||
|
const edgeHandles = useMemo(() => {
|
||||||
|
if (displayPolygon.length < 2) return []
|
||||||
|
|
||||||
|
return displayPolygon.flatMap(([x1, z1], index) => {
|
||||||
|
const nextIndex = (index + 1) % displayPolygon.length
|
||||||
|
const [x2, z2] = displayPolygon[nextIndex]!
|
||||||
|
const dx = x2 - x1
|
||||||
|
const dz = z2 - z1
|
||||||
|
const length = Math.hypot(dx, dz)
|
||||||
|
if (length < 1e-6) return []
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
index,
|
||||||
|
length,
|
||||||
|
midpoint: [(x1 + x2) / 2, (z1 + z2) / 2] as [number, number],
|
||||||
|
rotationY: -Math.atan2(dz, dx),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}, [displayPolygon])
|
||||||
|
|
||||||
// Update vertex position using grid cursor position
|
// Update vertex position using grid cursor position
|
||||||
const handleVertexDrag = useCallback(
|
const handleVertexDrag = useCallback(
|
||||||
(vertexIndex: number, position: [number, number]) => {
|
(vertexIndex: number, position: [number, number]) => {
|
||||||
setPreviewPolygon((prev) => {
|
const basePolygon = previewPolygonRef.current ?? polygon
|
||||||
const basePolygon = prev ?? polygon
|
|
||||||
const newPolygon = [...basePolygon]
|
const newPolygon = [...basePolygon]
|
||||||
newPolygon[vertexIndex] = position
|
newPolygon[vertexIndex] = position
|
||||||
return newPolygon
|
updatePreviewPolygon(newPolygon)
|
||||||
})
|
|
||||||
},
|
},
|
||||||
[polygon],
|
[polygon, updatePreviewPolygon],
|
||||||
)
|
)
|
||||||
|
|
||||||
// Commit polygon changes
|
// Commit polygon changes
|
||||||
@@ -152,9 +194,9 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|||||||
if (previewPolygonRef.current) {
|
if (previewPolygonRef.current) {
|
||||||
onPolygonChange(previewPolygonRef.current)
|
onPolygonChange(previewPolygonRef.current)
|
||||||
}
|
}
|
||||||
setPreviewPolygon(null)
|
updatePreviewPolygon(null)
|
||||||
setDragState(null)
|
setDragState(null)
|
||||||
}, [onPolygonChange])
|
}, [onPolygonChange, updatePreviewPolygon])
|
||||||
|
|
||||||
// Handle adding a new vertex at midpoint
|
// Handle adding a new vertex at midpoint
|
||||||
const handleAddVertex = useCallback(
|
const handleAddVertex = useCallback(
|
||||||
@@ -166,10 +208,13 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|||||||
...basePolygon.slice(afterIndex + 1),
|
...basePolygon.slice(afterIndex + 1),
|
||||||
]
|
]
|
||||||
|
|
||||||
setPreviewPolygon(newPolygon)
|
updatePreviewPolygon(newPolygon)
|
||||||
return afterIndex + 1 // Return new vertex index
|
return {
|
||||||
|
polygon: newPolygon,
|
||||||
|
vertexIndex: afterIndex + 1,
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[polygon, previewPolygon],
|
[polygon, previewPolygon, updatePreviewPolygon],
|
||||||
)
|
)
|
||||||
|
|
||||||
// Handle deleting a vertex
|
// Handle deleting a vertex
|
||||||
@@ -180,9 +225,9 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|||||||
|
|
||||||
const newPolygon = basePolygon.filter((_, i) => i !== index)
|
const newPolygon = basePolygon.filter((_, i) => i !== index)
|
||||||
onPolygonChange(newPolygon)
|
onPolygonChange(newPolygon)
|
||||||
setPreviewPolygon(null)
|
updatePreviewPolygon(null)
|
||||||
},
|
},
|
||||||
[polygon, previewPolygon, onPolygonChange, minVertices],
|
[polygon, previewPolygon, onPolygonChange, minVertices, updatePreviewPolygon],
|
||||||
)
|
)
|
||||||
|
|
||||||
// Listen to grid:move events to track cursor position
|
// Listen to grid:move events to track cursor position
|
||||||
@@ -212,9 +257,31 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|||||||
} else if (dragState.mode === 'polygon') {
|
} else if (dragState.mode === 'polygon') {
|
||||||
const deltaX = newPosition[0] - dragState.initialPosition[0]
|
const deltaX = newPosition[0] - dragState.initialPosition[0]
|
||||||
const deltaZ = newPosition[1] - dragState.initialPosition[1]
|
const deltaZ = newPosition[1] - dragState.initialPosition[1]
|
||||||
setPreviewPolygon(
|
updatePreviewPolygon(
|
||||||
dragState.initialPolygon.map(([x, z]) => [x + deltaX, z + deltaZ] as [number, number]),
|
dragState.initialPolygon.map(([x, z]) => [x + deltaX, z + deltaZ] as [number, number]),
|
||||||
)
|
)
|
||||||
|
} else if (
|
||||||
|
dragState.mode === 'edge' &&
|
||||||
|
dragState.edgeIndex !== undefined &&
|
||||||
|
dragState.edgeNormal
|
||||||
|
) {
|
||||||
|
const [normalX, normalZ] = dragState.edgeNormal
|
||||||
|
const pointerDeltaX = newPosition[0] - dragState.initialPosition[0]
|
||||||
|
const pointerDeltaZ = newPosition[1] - dragState.initialPosition[1]
|
||||||
|
const normalDistance = pointerDeltaX * normalX + pointerDeltaZ * normalZ
|
||||||
|
const edgeStartIndex = dragState.edgeIndex
|
||||||
|
const edgeEndIndex = (edgeStartIndex + 1) % dragState.initialPolygon.length
|
||||||
|
const nextPolygon = dragState.initialPolygon.map((point, index) => {
|
||||||
|
if (index !== edgeStartIndex && index !== edgeEndIndex) {
|
||||||
|
return point
|
||||||
|
}
|
||||||
|
|
||||||
|
return [point[0] + normalX * normalDistance, point[1] + normalZ * normalDistance] as [
|
||||||
|
number,
|
||||||
|
number,
|
||||||
|
]
|
||||||
|
})
|
||||||
|
updatePreviewPolygon(nextPolygon)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -223,7 +290,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|||||||
return () => {
|
return () => {
|
||||||
emitter.off('grid:move', onGridMove)
|
emitter.off('grid:move', onGridMove)
|
||||||
}
|
}
|
||||||
}, [dragState, handleVertexDrag])
|
}, [dragState, handleVertexDrag, updatePreviewPolygon])
|
||||||
|
|
||||||
// Set up pointer up listener for ending drag
|
// Set up pointer up listener for ending drag
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -337,6 +404,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|||||||
onPointerDown={(e) => {
|
onPointerDown={(e) => {
|
||||||
if (e.button !== 0) return
|
if (e.button !== 0) return
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
|
setHoveredEdge(null)
|
||||||
setDragState({
|
setDragState({
|
||||||
isDragging: true,
|
isDragging: true,
|
||||||
mode: 'vertex',
|
mode: 'vertex',
|
||||||
@@ -375,6 +443,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|||||||
onPointerDown={(e) => {
|
onPointerDown={(e) => {
|
||||||
if (e.button !== 0) return
|
if (e.button !== 0) return
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
|
setHoveredEdge(null)
|
||||||
setDragState({
|
setDragState({
|
||||||
isDragging: true,
|
isDragging: true,
|
||||||
mode: 'polygon',
|
mode: 'polygon',
|
||||||
@@ -395,6 +464,62 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|||||||
</mesh>
|
</mesh>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{allowEdgeMove &&
|
||||||
|
edgeHandles.map(({ index, length, midpoint, rotationY }) => {
|
||||||
|
const isHovered = hoveredEdge === index
|
||||||
|
const isDragging = dragState?.mode === 'edge' && dragState.edgeIndex === index
|
||||||
|
|
||||||
|
return (
|
||||||
|
<mesh
|
||||||
|
key={`edge-${index}`}
|
||||||
|
layers={EDITOR_LAYER}
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.button !== 0) return
|
||||||
|
e.stopPropagation()
|
||||||
|
}}
|
||||||
|
onPointerDown={(e) => {
|
||||||
|
if (e.button !== 0) return
|
||||||
|
e.stopPropagation()
|
||||||
|
const start = displayPolygon[index]
|
||||||
|
const end = displayPolygon[(index + 1) % displayPolygon.length]
|
||||||
|
if (!(start && end)) return
|
||||||
|
|
||||||
|
const edgeNormal = getEdgeNormal(start, end)
|
||||||
|
if (!edgeNormal) return
|
||||||
|
|
||||||
|
setHoveredEdge(null)
|
||||||
|
setDragState({
|
||||||
|
isDragging: true,
|
||||||
|
mode: 'edge',
|
||||||
|
vertexIndex: null,
|
||||||
|
edgeIndex: index,
|
||||||
|
edgeNormal,
|
||||||
|
initialPosition: cursorPosition,
|
||||||
|
initialPolygon: displayPolygon.map(([px, pz]) => [px, pz] as [number, number]),
|
||||||
|
pointerId: e.pointerId,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
onPointerEnter={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setHoveredEdge(index)
|
||||||
|
}}
|
||||||
|
onPointerLeave={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setHoveredEdge(null)
|
||||||
|
}}
|
||||||
|
position={[midpoint[0], editY + EDGE_HANDLE_HEIGHT / 2, midpoint[1]]}
|
||||||
|
rotation={[0, rotationY, 0]}
|
||||||
|
>
|
||||||
|
<boxGeometry args={[length, EDGE_HANDLE_HEIGHT, EDGE_HANDLE_THICKNESS]} />
|
||||||
|
<meshStandardMaterial
|
||||||
|
color={isDragging ? '#22c55e' : '#94a3b8'}
|
||||||
|
opacity={isDragging ? 0.5 : isHovered ? 0.38 : 0.14}
|
||||||
|
transparent
|
||||||
|
/>
|
||||||
|
</mesh>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
{/* Midpoint handles - smaller green cylinders for adding vertices (hidden while dragging) */}
|
{/* Midpoint handles - smaller green cylinders for adding vertices (hidden while dragging) */}
|
||||||
{!dragState &&
|
{!dragState &&
|
||||||
midpoints.map(([x, z], index) => {
|
midpoints.map(([x, z], index) => {
|
||||||
@@ -413,12 +538,14 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
|||||||
onPointerDown={(e) => {
|
onPointerDown={(e) => {
|
||||||
if (e.button !== 0) return
|
if (e.button !== 0) return
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
const newVertexIndex = handleAddVertex(index, [x!, z!])
|
const insertedVertex = handleAddVertex(index, [x!, z!])
|
||||||
if (newVertexIndex >= 0) {
|
if (insertedVertex.vertexIndex >= 0) {
|
||||||
setDragState({
|
setDragState({
|
||||||
isDragging: true,
|
isDragging: true,
|
||||||
vertexIndex: newVertexIndex,
|
mode: 'vertex',
|
||||||
|
vertexIndex: insertedVertex.vertexIndex,
|
||||||
initialPosition: [x!, z!],
|
initialPosition: [x!, z!],
|
||||||
|
initialPolygon: insertedVertex.polygon,
|
||||||
pointerId: e.pointerId,
|
pointerId: e.pointerId,
|
||||||
})
|
})
|
||||||
setHoveredMidpoint(null)
|
setHoveredMidpoint(null)
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export const SlabBoundaryEditor: React.FC<SlabBoundaryEditorProps> = ({ slabId }
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PolygonEditor
|
<PolygonEditor
|
||||||
|
allowEdgeMove
|
||||||
color="#a3a3a3"
|
color="#a3a3a3"
|
||||||
levelId={resolveLevelId(slab, useScene.getState().nodes)}
|
levelId={resolveLevelId(slab, useScene.getState().nodes)}
|
||||||
minVertices={3}
|
minVertices={3}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export const SlabHoleEditor: React.FC<SlabHoleEditorProps> = ({ slabId, holeInde
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PolygonEditor
|
<PolygonEditor
|
||||||
|
allowEdgeMove
|
||||||
allowPolygonMove
|
allowPolygonMove
|
||||||
color="#ef4444"
|
color="#ef4444"
|
||||||
levelId={resolveLevelId(slab, useScene.getState().nodes)} // red for holes
|
levelId={resolveLevelId(slab, useScene.getState().nodes)} // red for holes
|
||||||
|
|||||||
Reference in New Issue
Block a user