Fix floorplan player move and action menu anchoring
This commit is contained in:
@@ -26,6 +26,7 @@ type FloorplanActionMenuLayerProps = {
|
||||
slab: FloorplanActionMenuEntry
|
||||
ceiling: FloorplanActionMenuEntry
|
||||
opening: FloorplanActionMenuEntry
|
||||
spawn: FloorplanActionMenuEntry
|
||||
stair: FloorplanActionMenuEntry
|
||||
roof: FloorplanActionMenuEntry
|
||||
offsetY?: number
|
||||
@@ -38,6 +39,7 @@ export const FloorplanActionMenuLayer = memo(function FloorplanActionMenuLayer({
|
||||
slab,
|
||||
ceiling,
|
||||
opening,
|
||||
spawn,
|
||||
stair,
|
||||
roof,
|
||||
offsetY = 10,
|
||||
@@ -59,6 +61,7 @@ export const FloorplanActionMenuLayer = memo(function FloorplanActionMenuLayer({
|
||||
slab,
|
||||
ceiling,
|
||||
opening,
|
||||
spawn,
|
||||
stair,
|
||||
roof,
|
||||
]
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
import type { Point2D } from '@pascal-app/core'
|
||||
|
||||
function toSvgX(value: number) {
|
||||
return -value
|
||||
return value
|
||||
}
|
||||
|
||||
function toSvgY(value: number) {
|
||||
return -value
|
||||
return value
|
||||
}
|
||||
|
||||
function toSvgPoint(point: Point2D) {
|
||||
@@ -100,9 +100,7 @@ export function buildSvgAnnularSectorPath(
|
||||
}
|
||||
|
||||
export function formatSvgPolygonPoints(points: Point2D[]) {
|
||||
return points
|
||||
.map((point) => `${toSvgX(point.x)},${toSvgY(point.y)}`)
|
||||
.join(' ')
|
||||
return points.map((point) => `${toSvgX(point.x)},${toSvgY(point.y)}`).join(' ')
|
||||
}
|
||||
|
||||
export function buildSvgArrowHeadPoints(point: Point2D, angle: number, size: number) {
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
type RoofSegmentNode,
|
||||
type SiteNode,
|
||||
SlabNode,
|
||||
type SpawnNode,
|
||||
type StairNode,
|
||||
StairNode as StairNodeSchema,
|
||||
type StairSegmentNode,
|
||||
@@ -215,6 +216,13 @@ const FLOORPLAN_TRACE_STRUCTURE_SELECTED_FILL_OPACITY = 0.34
|
||||
const FLOORPLAN_SITE_COLOR = '#10b981'
|
||||
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_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 = {
|
||||
centerX: number
|
||||
centerY: number
|
||||
@@ -587,6 +595,12 @@ type FloorplanItemEntry = {
|
||||
depth: number
|
||||
}
|
||||
|
||||
type FloorplanSpawnEntry = {
|
||||
spawn: SpawnNode
|
||||
position: Point2D
|
||||
rotation: number
|
||||
}
|
||||
|
||||
type ReferenceFloorData = {
|
||||
ceilingPolygons: CeilingPolygonEntry[]
|
||||
fenceEntries: FloorplanFenceEntry[]
|
||||
@@ -772,11 +786,11 @@ function toWallPlanPoint(point: Point2D): WallPlanPoint {
|
||||
}
|
||||
|
||||
function toSvgX(value: number): number {
|
||||
return -value
|
||||
return value
|
||||
}
|
||||
|
||||
function toSvgY(value: number): number {
|
||||
return -value
|
||||
return value
|
||||
}
|
||||
|
||||
function toSvgPoint(point: Point2D): SvgPoint {
|
||||
@@ -915,11 +929,11 @@ function getGuideRotateCursor(isDarkMode: boolean) {
|
||||
}
|
||||
|
||||
function getGuideSvgRotation(rotationY: number) {
|
||||
return normalizeAngle(Math.PI - rotationY)
|
||||
return normalizeAngle(-rotationY)
|
||||
}
|
||||
|
||||
function getGuideSceneRotationFromSvgRotation(rotationSvg: number) {
|
||||
return normalizeAngle(Math.PI - rotationSvg)
|
||||
return normalizeAngle(-rotationSvg)
|
||||
}
|
||||
|
||||
function buildGuideTranslateDraft(
|
||||
@@ -2938,6 +2952,37 @@ function buildGridPath(
|
||||
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(
|
||||
point: WallPlanPoint,
|
||||
walls: WallNode[],
|
||||
@@ -3264,20 +3309,20 @@ const FloorplanGridLayer = memo(function FloorplanGridLayer({
|
||||
<path
|
||||
d={minorGridPath}
|
||||
fill="none"
|
||||
opacity={palette.minorGridOpacity}
|
||||
opacity={palette.majorGridOpacity}
|
||||
shapeRendering="crispEdges"
|
||||
stroke={palette.minorGrid}
|
||||
strokeWidth={FLOORPLAN_MINOR_GRID_STROKE_WIDTH}
|
||||
stroke={palette.majorGrid}
|
||||
strokeWidth={FLOORPLAN_MAJOR_GRID_STROKE_WIDTH}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
|
||||
<path
|
||||
d={majorGridPath}
|
||||
fill="none"
|
||||
opacity={palette.majorGridOpacity}
|
||||
opacity={palette.minorGridOpacity}
|
||||
shapeRendering="crispEdges"
|
||||
stroke={palette.majorGrid}
|
||||
strokeWidth={FLOORPLAN_MAJOR_GRID_STROKE_WIDTH}
|
||||
stroke={palette.minorGrid}
|
||||
strokeWidth={FLOORPLAN_MINOR_GRID_STROKE_WIDTH}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
</>
|
||||
@@ -5193,11 +5238,14 @@ function FloorplanItemImage({
|
||||
|
||||
const FloorplanNodeLayer = memo(function FloorplanNodeLayer({
|
||||
canFocusItems,
|
||||
canFocusSpawns,
|
||||
canFocusStairs,
|
||||
canSelectItems,
|
||||
canSelectSpawns,
|
||||
canSelectStairs,
|
||||
highlightedIdSet,
|
||||
hoveredItemId,
|
||||
hoveredSpawnId,
|
||||
hoveredStairId,
|
||||
isDeleteMode,
|
||||
isFurnishContextActive,
|
||||
@@ -5207,6 +5255,11 @@ const FloorplanNodeLayer = memo(function FloorplanNodeLayer({
|
||||
onItemHoverEnter,
|
||||
onItemPointerDown,
|
||||
onItemSelect,
|
||||
onSpawnDoubleClick,
|
||||
onSpawnHoverChange,
|
||||
onSpawnHoverEnter,
|
||||
onSpawnPointerDown,
|
||||
onSpawnSelect,
|
||||
onStairDoubleClick,
|
||||
onStairHoverChange,
|
||||
onStairHoverEnter,
|
||||
@@ -5214,16 +5267,20 @@ const FloorplanNodeLayer = memo(function FloorplanNodeLayer({
|
||||
onStairSelect,
|
||||
palette,
|
||||
selectedIdSet,
|
||||
spawnEntries,
|
||||
stairEntries,
|
||||
unit,
|
||||
wallSelectionHatchId,
|
||||
}: {
|
||||
canFocusItems: boolean
|
||||
canFocusSpawns: boolean
|
||||
canFocusStairs: boolean
|
||||
canSelectItems: boolean
|
||||
canSelectSpawns: boolean
|
||||
canSelectStairs: boolean
|
||||
highlightedIdSet: ReadonlySet<string>
|
||||
hoveredItemId: ItemNode['id'] | null
|
||||
hoveredSpawnId: SpawnNode['id'] | null
|
||||
hoveredStairId: StairNode['id'] | null
|
||||
isDeleteMode: boolean
|
||||
isFurnishContextActive: boolean
|
||||
@@ -5233,6 +5290,11 @@ const FloorplanNodeLayer = memo(function FloorplanNodeLayer({
|
||||
onItemHoverEnter: (itemId: ItemNode['id']) => void
|
||||
onItemPointerDown: (itemId: ItemNode['id'], event: ReactPointerEvent<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
|
||||
onStairHoverChange: (stairId: StairNode['id'] | null) => void
|
||||
onStairHoverEnter: (stairId: StairNode['id']) => void
|
||||
@@ -5240,11 +5302,12 @@ const FloorplanNodeLayer = memo(function FloorplanNodeLayer({
|
||||
onStairSelect: (stairId: StairNode['id'], event: ReactMouseEvent<SVGElement>) => void
|
||||
palette: FloorplanPalette
|
||||
selectedIdSet: ReadonlySet<string>
|
||||
spawnEntries: FloorplanSpawnEntry[]
|
||||
stairEntries: FloorplanStairEntry[]
|
||||
unit: 'metric' | 'imperial'
|
||||
wallSelectionHatchId: string
|
||||
}) {
|
||||
if (itemEntries.length === 0 && stairEntries.length === 0) {
|
||||
if (itemEntries.length === 0 && stairEntries.length === 0 && spawnEntries.length === 0) {
|
||||
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 (
|
||||
<>
|
||||
{isFurnishContextActive ? (
|
||||
@@ -5446,10 +5623,12 @@ const FloorplanNodeLayer = memo(function FloorplanNodeLayer({
|
||||
stairEntries={stairEntries}
|
||||
/>
|
||||
{itemNodes}
|
||||
{spawnNodes}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{itemNodes}
|
||||
{spawnNodes}
|
||||
<FloorplanStairLayer
|
||||
canFocusStairs={canFocusStairs}
|
||||
canSelectStairs={canSelectStairs}
|
||||
@@ -6501,10 +6680,12 @@ export function FloorplanPanel() {
|
||||
roofs,
|
||||
site,
|
||||
slabs,
|
||||
spawns,
|
||||
walls,
|
||||
zones,
|
||||
} = useFloorplanSceneData({ buildingId, levelId })
|
||||
const buildingRotationDeg = (buildingRotationY * 180) / Math.PI
|
||||
const floorplanSceneRotationDeg = FLOORPLAN_VIEW_ROTATION_DEG - buildingRotationDeg
|
||||
|
||||
const [draftStart, setDraftStart] = 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 [hoveredCeilingId, setHoveredCeilingId] = useState<CeilingNode['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 [hoveredZoneId, setHoveredZoneId] = useState<ZoneNodeType['id'] | null>(null)
|
||||
const [hoveredEndpointId, setHoveredEndpointId] = useState<string | null>(null)
|
||||
@@ -7145,6 +7327,24 @@ export function FloorplanPanel() {
|
||||
),
|
||||
[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 transformCache = new Map<string, SharedFloorplanNodeTransform | null>()
|
||||
|
||||
@@ -7504,6 +7704,13 @@ export function FloorplanPanel() {
|
||||
|
||||
return floorplanItemEntries.find(({ item }) => item.id === selectedIds[0]) ?? null
|
||||
}, [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(() => {
|
||||
if (!selectedItemEntry) {
|
||||
return [] as LinearMeasurementOverlay[]
|
||||
@@ -7837,6 +8044,7 @@ export function FloorplanPanel() {
|
||||
const isCeilingMoveActive = movingNode?.type === 'ceiling'
|
||||
const isFenceMoveActive = movingNode?.type === 'fence'
|
||||
const isWallMoveActive = movingNode?.type === 'wall'
|
||||
const isSpawnMoveActive = movingNode?.type === 'spawn'
|
||||
const isWallCurveActive = curvingWall?.type === 'wall'
|
||||
const isFenceCurveActive = curvingFence?.type === 'fence'
|
||||
const isFenceEndpointMoveActive = movingFenceEndpoint !== null
|
||||
@@ -7855,6 +8063,7 @@ export function FloorplanPanel() {
|
||||
isCeilingMoveActive ||
|
||||
isFenceMoveActive ||
|
||||
isWallMoveActive ||
|
||||
isSpawnMoveActive ||
|
||||
isWallCurveActive ||
|
||||
isFenceCurveActive ||
|
||||
isFenceEndpointMoveActive ||
|
||||
@@ -7976,6 +8185,7 @@ export function FloorplanPanel() {
|
||||
!movingFenceEndpoint &&
|
||||
isFloorplanStructureContextActive) ||
|
||||
isDeleteMode
|
||||
const canSelectFloorplanSpawns = canSelectFloorplanStairs
|
||||
const canSelectFloorplanItems =
|
||||
(mode === 'select' &&
|
||||
floorplanSelectionTool === 'click' &&
|
||||
@@ -7989,6 +8199,7 @@ export function FloorplanPanel() {
|
||||
!movingNode &&
|
||||
!movingFenceEndpoint &&
|
||||
isFloorplanStructureContextActive
|
||||
const canFocusFloorplanSpawns = canFocusFloorplanStairs
|
||||
const canFocusFloorplanItems =
|
||||
mode === 'select' &&
|
||||
floorplanSelectionTool === 'click' &&
|
||||
@@ -8744,6 +8955,47 @@ export function FloorplanPanel() {
|
||||
: null,
|
||||
[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(() => {
|
||||
if (slabHoleMoveDraft) {
|
||||
return null
|
||||
@@ -9049,31 +9301,35 @@ export function FloorplanPanel() {
|
||||
() => getVisibleGridSteps(viewBox.width, surfaceSize.width),
|
||||
[surfaceSize.width, viewBox.width],
|
||||
)
|
||||
const gridBounds = useMemo(
|
||||
() => getRotatedViewBoxBounds(viewBox, floorplanSceneRotationDeg),
|
||||
[floorplanSceneRotationDeg, viewBox],
|
||||
)
|
||||
|
||||
const minorGridPath = useMemo(
|
||||
() =>
|
||||
buildGridPath(
|
||||
viewBox.minX,
|
||||
viewBox.minX + viewBox.width,
|
||||
viewBox.minY,
|
||||
viewBox.minY + viewBox.height,
|
||||
gridBounds.minX,
|
||||
gridBounds.maxX,
|
||||
gridBounds.minY,
|
||||
gridBounds.maxY,
|
||||
gridSteps.minorStep,
|
||||
{
|
||||
excludeStep: gridSteps.majorStep,
|
||||
},
|
||||
),
|
||||
[gridSteps.majorStep, gridSteps.minorStep, viewBox],
|
||||
[gridBounds, gridSteps.majorStep, gridSteps.minorStep],
|
||||
)
|
||||
const majorGridPath = useMemo(
|
||||
() =>
|
||||
buildGridPath(
|
||||
viewBox.minX,
|
||||
viewBox.minX + viewBox.width,
|
||||
viewBox.minY,
|
||||
viewBox.minY + viewBox.height,
|
||||
gridBounds.minX,
|
||||
gridBounds.maxX,
|
||||
gridBounds.minY,
|
||||
gridBounds.maxY,
|
||||
gridSteps.majorStep,
|
||||
),
|
||||
[gridSteps.majorStep, viewBox],
|
||||
[gridBounds, gridSteps.majorStep],
|
||||
)
|
||||
const floorplanUnitsPerPixel = viewBox.width / Math.max(surfaceSize.width, 1)
|
||||
|
||||
@@ -9808,6 +10064,30 @@ export function FloorplanPanel() {
|
||||
return unsubscribe
|
||||
}, [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(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
const target = event.target as HTMLElement | null
|
||||
@@ -9831,7 +10111,9 @@ export function FloorplanPanel() {
|
||||
}
|
||||
|
||||
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')
|
||||
) {
|
||||
setMovingFloorplanNodeRevision((current) => current + 1)
|
||||
@@ -11070,7 +11352,7 @@ export function FloorplanPanel() {
|
||||
|
||||
const hoveredWallIdRef = useRef<string | null>(null)
|
||||
const floorplanGridLocalY = useMemo(() => {
|
||||
if (movingNode?.type === 'item') {
|
||||
if (movingNode?.type === 'item' || movingNode?.type === 'spawn') {
|
||||
return movingNode.position[1]
|
||||
}
|
||||
|
||||
@@ -11107,8 +11389,8 @@ export function FloorplanPanel() {
|
||||
const snappedPoint = getSnappedFloorplanPoint(planPoint)
|
||||
const cos = Math.cos(buildingRotationY)
|
||||
const sin = Math.sin(buildingRotationY)
|
||||
const worldX = buildingPosition[0] + snappedPoint[0] * cos - snappedPoint[1] * sin
|
||||
const worldZ = buildingPosition[2] + snappedPoint[0] * sin + snappedPoint[1] * cos
|
||||
const worldX = buildingPosition[0] + snappedPoint[0] * cos + snappedPoint[1] * sin
|
||||
const worldZ = buildingPosition[2] - snappedPoint[0] * sin + snappedPoint[1] * cos
|
||||
|
||||
emitter.emit(`grid:${eventType}` as any, {
|
||||
nativeEvent: nativeEvent.nativeEvent as any,
|
||||
@@ -11918,6 +12200,14 @@ export function FloorplanPanel() {
|
||||
[syncDeleteHoveredId],
|
||||
)
|
||||
|
||||
const handleSpawnHoverChange = useCallback(
|
||||
(spawnId: SpawnNode['id'] | null) => {
|
||||
setHoveredSpawnId(spawnId)
|
||||
syncDeleteHoveredId(spawnId)
|
||||
},
|
||||
[syncDeleteHoveredId],
|
||||
)
|
||||
|
||||
const handleStairHoverChange = useCallback(
|
||||
(stairId: StairNode['id'] | null) => {
|
||||
setHoveredStairId(stairId)
|
||||
@@ -11941,6 +12231,7 @@ export function FloorplanPanel() {
|
||||
handleSlabHoverChange(null)
|
||||
handleCeilingHoverChange(null)
|
||||
handleStairHoverChange(null)
|
||||
handleSpawnHoverChange(null)
|
||||
handleZoneHoverChange(null)
|
||||
handleItemHoverChange(itemId)
|
||||
},
|
||||
@@ -11950,6 +12241,7 @@ export function FloorplanPanel() {
|
||||
handleOpeningHoverChange,
|
||||
handleCeilingHoverChange,
|
||||
handleSlabHoverChange,
|
||||
handleSpawnHoverChange,
|
||||
handleStairHoverChange,
|
||||
handleWallHoverChange,
|
||||
handleZoneHoverChange,
|
||||
@@ -11963,6 +12255,7 @@ export function FloorplanPanel() {
|
||||
handleSlabHoverChange(null)
|
||||
handleCeilingHoverChange(null)
|
||||
handleStairHoverChange(null)
|
||||
handleSpawnHoverChange(null)
|
||||
handleZoneHoverChange(null)
|
||||
handleFenceHoverChange(fenceId)
|
||||
},
|
||||
@@ -11972,6 +12265,7 @@ export function FloorplanPanel() {
|
||||
handleOpeningHoverChange,
|
||||
handleCeilingHoverChange,
|
||||
handleSlabHoverChange,
|
||||
handleSpawnHoverChange,
|
||||
handleStairHoverChange,
|
||||
handleWallHoverChange,
|
||||
handleZoneHoverChange,
|
||||
@@ -11985,6 +12279,7 @@ export function FloorplanPanel() {
|
||||
handleSlabHoverChange(null)
|
||||
handleCeilingHoverChange(null)
|
||||
handleWallHoverChange(null)
|
||||
handleSpawnHoverChange(null)
|
||||
handleZoneHoverChange(null)
|
||||
handleStairHoverChange(stairId)
|
||||
},
|
||||
@@ -11994,6 +12289,31 @@ export function FloorplanPanel() {
|
||||
handleOpeningHoverChange,
|
||||
handleCeilingHoverChange,
|
||||
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,
|
||||
handleWallHoverChange,
|
||||
handleZoneHoverChange,
|
||||
@@ -12086,6 +12406,7 @@ export function FloorplanPanel() {
|
||||
| OpeningNode['id']
|
||||
| SlabNode['id']
|
||||
| CeilingNode['id']
|
||||
| SpawnNode['id']
|
||||
| StairNode['id']
|
||||
| ZoneNodeType['id'],
|
||||
eventType: 'click' | 'double-click',
|
||||
@@ -12100,6 +12421,7 @@ export function FloorplanPanel() {
|
||||
node.type === 'door' ||
|
||||
node.type === 'window' ||
|
||||
node.type === 'item' ||
|
||||
node.type === 'spawn' ||
|
||||
node.type === 'stair' ||
|
||||
node.type === 'zone')
|
||||
)
|
||||
@@ -12309,6 +12631,12 @@ export function FloorplanPanel() {
|
||||
},
|
||||
[emitFloorplanNodeClick],
|
||||
)
|
||||
const handleSpawnSelect = useCallback(
|
||||
(spawnId: SpawnNode['id'], event: ReactMouseEvent<SVGElement>) => {
|
||||
emitFloorplanNodeClick(spawnId, 'click', event)
|
||||
},
|
||||
[emitFloorplanNodeClick],
|
||||
)
|
||||
const handleStairSelect = useCallback(
|
||||
(stairId: StairNode['id'], event: ReactMouseEvent<SVGElement>) => {
|
||||
emitFloorplanNodeClick(stairId, 'click', event)
|
||||
@@ -12347,6 +12675,73 @@ export function FloorplanPanel() {
|
||||
},
|
||||
[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(
|
||||
(itemId: ItemNode['id'], event: ReactPointerEvent<SVGElement>) => {
|
||||
if (event.button !== 0) {
|
||||
@@ -13818,6 +14213,7 @@ export function FloorplanPanel() {
|
||||
handleWallHoverChange(null)
|
||||
handleSlabHoverChange(null)
|
||||
handleCeilingHoverChange(null)
|
||||
handleSpawnHoverChange(null)
|
||||
handleStairHoverChange(null)
|
||||
handleZoneHoverChange(null)
|
||||
setHoveredEndpointId(null)
|
||||
@@ -13835,6 +14231,7 @@ export function FloorplanPanel() {
|
||||
handleItemHoverChange,
|
||||
handleOpeningHoverChange,
|
||||
handleSlabHoverChange,
|
||||
handleSpawnHoverChange,
|
||||
handleStairHoverChange,
|
||||
handleWallHoverChange,
|
||||
handleZoneHoverChange,
|
||||
@@ -13940,6 +14337,7 @@ export function FloorplanPanel() {
|
||||
handleOpeningHoverChange(null)
|
||||
handleWallHoverChange(null)
|
||||
handleSlabHoverChange(null)
|
||||
handleSpawnHoverChange(null)
|
||||
handleStairHoverChange(null)
|
||||
handleZoneHoverChange(null)
|
||||
setHoveredEndpointId(null)
|
||||
@@ -13960,6 +14358,7 @@ export function FloorplanPanel() {
|
||||
handleItemHoverChange,
|
||||
handleOpeningHoverChange,
|
||||
handleSlabHoverChange,
|
||||
handleSpawnHoverChange,
|
||||
handleStairHoverChange,
|
||||
handleWallHoverChange,
|
||||
handleZoneHoverChange,
|
||||
@@ -14397,6 +14796,11 @@ export function FloorplanPanel() {
|
||||
onDuplicate: handleSelectedOpeningDuplicate,
|
||||
onMove: handleSelectedOpeningMove,
|
||||
}}
|
||||
spawn={{
|
||||
position: selectedSpawnActionMenuPosition,
|
||||
onDelete: handleSelectedSpawnDelete,
|
||||
onMove: handleSelectedSpawnMove,
|
||||
}}
|
||||
roof={{
|
||||
position: selectedRoofActionMenuPosition,
|
||||
onDelete: handleSelectedRoofDelete,
|
||||
@@ -14598,7 +15002,9 @@ export function FloorplanPanel() {
|
||||
|
||||
<g
|
||||
ref={floorplanSceneRef}
|
||||
transform={buildingRotationDeg !== 0 ? `rotate(${buildingRotationDeg})` : undefined}
|
||||
transform={
|
||||
floorplanSceneRotationDeg !== 0 ? `rotate(${floorplanSceneRotationDeg})` : undefined
|
||||
}
|
||||
>
|
||||
<FloorplanGridLayer
|
||||
majorGridPath={majorGridPath}
|
||||
@@ -14691,11 +15097,14 @@ export function FloorplanPanel() {
|
||||
|
||||
<FloorplanNodeLayer
|
||||
canFocusItems={canFocusFloorplanItems}
|
||||
canFocusSpawns={canFocusFloorplanSpawns}
|
||||
canFocusStairs={canFocusFloorplanStairs}
|
||||
canSelectItems={canSelectFloorplanItems}
|
||||
canSelectSpawns={canSelectFloorplanSpawns}
|
||||
canSelectStairs={canSelectFloorplanStairs}
|
||||
highlightedIdSet={highlightedFloorplanIdSet}
|
||||
hoveredItemId={hoveredItemId}
|
||||
hoveredSpawnId={hoveredSpawnId}
|
||||
hoveredStairId={hoveredStairId}
|
||||
isDeleteMode={isDeleteMode}
|
||||
isFurnishContextActive={isFloorplanFurnishContextActive}
|
||||
@@ -14705,6 +15114,11 @@ export function FloorplanPanel() {
|
||||
onItemHoverEnter={handleFloorplanItemHoverEnter}
|
||||
onItemPointerDown={handleItemPointerDown}
|
||||
onItemSelect={handleItemSelect}
|
||||
onSpawnDoubleClick={handleSpawnDoubleClick}
|
||||
onSpawnHoverChange={handleSpawnHoverChange}
|
||||
onSpawnHoverEnter={handleFloorplanSpawnHoverEnter}
|
||||
onSpawnPointerDown={handleSpawnPointerDown}
|
||||
onSpawnSelect={handleSpawnSelect}
|
||||
onStairDoubleClick={handleStairDoubleClick}
|
||||
onStairHoverChange={handleStairHoverChange}
|
||||
onStairHoverEnter={handleFloorplanStairHoverEnter}
|
||||
@@ -14712,6 +15126,7 @@ export function FloorplanPanel() {
|
||||
onStairSelect={handleStairSelect}
|
||||
palette={palette}
|
||||
selectedIdSet={selectedIdSet}
|
||||
spawnEntries={floorplanSpawnEntries}
|
||||
stairEntries={renderedFloorplanStairEntries}
|
||||
unit={unit}
|
||||
wallSelectionHatchId={wallSelectionHatchId}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
type RoofNode,
|
||||
type SiteNode,
|
||||
type SlabNode,
|
||||
type SpawnNode,
|
||||
useScene,
|
||||
type WallNode,
|
||||
type WindowNode,
|
||||
@@ -113,6 +114,7 @@ export function useFloorplanSceneData({
|
||||
)
|
||||
const levelGuides = useLevelChildren(levelId, (node): node is GuideNode => node?.type === 'guide')
|
||||
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(
|
||||
useShallow((state) => {
|
||||
if (!levelId) {
|
||||
@@ -180,6 +182,7 @@ export function useFloorplanSceneData({
|
||||
roofs,
|
||||
site,
|
||||
slabs,
|
||||
spawns,
|
||||
walls,
|
||||
zones,
|
||||
}
|
||||
|
||||
@@ -10,8 +10,10 @@ const Y_OFFSET = 0.02
|
||||
|
||||
type DragState = {
|
||||
isDragging: boolean
|
||||
mode: 'vertex' | 'polygon'
|
||||
mode: 'vertex' | 'polygon' | 'edge'
|
||||
vertexIndex: number | null
|
||||
edgeIndex?: number
|
||||
edgeNormal?: [number, number]
|
||||
initialPosition: [number, number]
|
||||
initialPolygon: Array<[number, number]>
|
||||
pointerId: number
|
||||
@@ -28,6 +30,8 @@ export interface PolygonEditorProps {
|
||||
surfaceHeight?: number
|
||||
/** Whether to show the center handle that moves the entire polygon. */
|
||||
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
|
||||
*/
|
||||
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> = ({
|
||||
polygon,
|
||||
@@ -44,6 +59,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
||||
levelId,
|
||||
surfaceHeight = 0,
|
||||
allowPolygonMove = false,
|
||||
allowEdgeMove = false,
|
||||
}) => {
|
||||
const [levelNode, setLevelNode] = useState<Object3D | 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 previewPolygonRef = useRef<Array<[number, number]> | null>(null)
|
||||
|
||||
const updatePreviewPolygon = useCallback((nextPolygon: Array<[number, number]> | null) => {
|
||||
previewPolygonRef.current = nextPolygon
|
||||
setPreviewPolygon(nextPolygon)
|
||||
}, [])
|
||||
|
||||
// Keep ref in sync
|
||||
useEffect(() => {
|
||||
previewPolygonRef.current = previewPolygon
|
||||
@@ -96,6 +117,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
||||
|
||||
const [hoveredVertex, setHoveredVertex] = 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 lineRef = useRef<Line>(null!)
|
||||
@@ -106,7 +128,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
||||
if (polygon !== lastPolygonRef.current) {
|
||||
lastPolygonRef.current = polygon
|
||||
// External change (e.g. undo/redo) — clear any stale preview/drag state
|
||||
if (previewPolygon) setPreviewPolygon(null)
|
||||
if (previewPolygon) updatePreviewPolygon(null)
|
||||
if (dragState) setDragState(null)
|
||||
}
|
||||
|
||||
@@ -134,17 +156,37 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
||||
})
|
||||
}, [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
|
||||
const handleVertexDrag = useCallback(
|
||||
(vertexIndex: number, position: [number, number]) => {
|
||||
setPreviewPolygon((prev) => {
|
||||
const basePolygon = prev ?? polygon
|
||||
const basePolygon = previewPolygonRef.current ?? polygon
|
||||
const newPolygon = [...basePolygon]
|
||||
newPolygon[vertexIndex] = position
|
||||
return newPolygon
|
||||
})
|
||||
updatePreviewPolygon(newPolygon)
|
||||
},
|
||||
[polygon],
|
||||
[polygon, updatePreviewPolygon],
|
||||
)
|
||||
|
||||
// Commit polygon changes
|
||||
@@ -152,9 +194,9 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
||||
if (previewPolygonRef.current) {
|
||||
onPolygonChange(previewPolygonRef.current)
|
||||
}
|
||||
setPreviewPolygon(null)
|
||||
updatePreviewPolygon(null)
|
||||
setDragState(null)
|
||||
}, [onPolygonChange])
|
||||
}, [onPolygonChange, updatePreviewPolygon])
|
||||
|
||||
// Handle adding a new vertex at midpoint
|
||||
const handleAddVertex = useCallback(
|
||||
@@ -166,10 +208,13 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
||||
...basePolygon.slice(afterIndex + 1),
|
||||
]
|
||||
|
||||
setPreviewPolygon(newPolygon)
|
||||
return afterIndex + 1 // Return new vertex index
|
||||
updatePreviewPolygon(newPolygon)
|
||||
return {
|
||||
polygon: newPolygon,
|
||||
vertexIndex: afterIndex + 1,
|
||||
}
|
||||
},
|
||||
[polygon, previewPolygon],
|
||||
[polygon, previewPolygon, updatePreviewPolygon],
|
||||
)
|
||||
|
||||
// Handle deleting a vertex
|
||||
@@ -180,9 +225,9 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
||||
|
||||
const newPolygon = basePolygon.filter((_, i) => i !== index)
|
||||
onPolygonChange(newPolygon)
|
||||
setPreviewPolygon(null)
|
||||
updatePreviewPolygon(null)
|
||||
},
|
||||
[polygon, previewPolygon, onPolygonChange, minVertices],
|
||||
[polygon, previewPolygon, onPolygonChange, minVertices, updatePreviewPolygon],
|
||||
)
|
||||
|
||||
// Listen to grid:move events to track cursor position
|
||||
@@ -212,9 +257,31 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
||||
} else if (dragState.mode === 'polygon') {
|
||||
const deltaX = newPosition[0] - dragState.initialPosition[0]
|
||||
const deltaZ = newPosition[1] - dragState.initialPosition[1]
|
||||
setPreviewPolygon(
|
||||
updatePreviewPolygon(
|
||||
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 () => {
|
||||
emitter.off('grid:move', onGridMove)
|
||||
}
|
||||
}, [dragState, handleVertexDrag])
|
||||
}, [dragState, handleVertexDrag, updatePreviewPolygon])
|
||||
|
||||
// Set up pointer up listener for ending drag
|
||||
useEffect(() => {
|
||||
@@ -337,6 +404,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
||||
onPointerDown={(e) => {
|
||||
if (e.button !== 0) return
|
||||
e.stopPropagation()
|
||||
setHoveredEdge(null)
|
||||
setDragState({
|
||||
isDragging: true,
|
||||
mode: 'vertex',
|
||||
@@ -375,6 +443,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
||||
onPointerDown={(e) => {
|
||||
if (e.button !== 0) return
|
||||
e.stopPropagation()
|
||||
setHoveredEdge(null)
|
||||
setDragState({
|
||||
isDragging: true,
|
||||
mode: 'polygon',
|
||||
@@ -395,6 +464,62 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
||||
</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) */}
|
||||
{!dragState &&
|
||||
midpoints.map(([x, z], index) => {
|
||||
@@ -413,12 +538,14 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
||||
onPointerDown={(e) => {
|
||||
if (e.button !== 0) return
|
||||
e.stopPropagation()
|
||||
const newVertexIndex = handleAddVertex(index, [x!, z!])
|
||||
if (newVertexIndex >= 0) {
|
||||
const insertedVertex = handleAddVertex(index, [x!, z!])
|
||||
if (insertedVertex.vertexIndex >= 0) {
|
||||
setDragState({
|
||||
isDragging: true,
|
||||
vertexIndex: newVertexIndex,
|
||||
mode: 'vertex',
|
||||
vertexIndex: insertedVertex.vertexIndex,
|
||||
initialPosition: [x!, z!],
|
||||
initialPolygon: insertedVertex.polygon,
|
||||
pointerId: e.pointerId,
|
||||
})
|
||||
setHoveredMidpoint(null)
|
||||
|
||||
@@ -31,6 +31,7 @@ export const SlabBoundaryEditor: React.FC<SlabBoundaryEditorProps> = ({ slabId }
|
||||
|
||||
return (
|
||||
<PolygonEditor
|
||||
allowEdgeMove
|
||||
color="#a3a3a3"
|
||||
levelId={resolveLevelId(slab, useScene.getState().nodes)}
|
||||
minVertices={3}
|
||||
|
||||
@@ -36,6 +36,7 @@ export const SlabHoleEditor: React.FC<SlabHoleEditorProps> = ({ slabId, holeInde
|
||||
|
||||
return (
|
||||
<PolygonEditor
|
||||
allowEdgeMove
|
||||
allowPolygonMove
|
||||
color="#ef4444"
|
||||
levelId={resolveLevelId(slab, useScene.getState().nodes)} // red for holes
|
||||
|
||||
Reference in New Issue
Block a user