feat: add persistent site boundary handles
Add persistent site boundary handles
This commit is contained in:
@@ -53,7 +53,7 @@ const BUILD_TYPES: BuildType[] = [
|
||||
{ id: 'window', label: 'Window', iconSrc: '/icons/window.png', kind: 'window' },
|
||||
{ id: 'column', label: 'Column', iconSrc: '/icons/column.png', kind: 'column' },
|
||||
{ id: 'shelf', label: 'Shelf', iconSrc: '/icons/shelf.png', kind: 'shelf' },
|
||||
{ id: 'spawn', label: 'Spawn Point', iconSrc: '/icons/site.png', kind: 'spawn' },
|
||||
{ id: 'spawn', label: 'Spawn Point', iconSrc: '/icons/spawn-point.png', kind: 'spawn' },
|
||||
{ id: 'painting', label: 'Painting', iconSrc: '/icons/paint.png', mode: 'material-paint' },
|
||||
]
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 52 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 85 KiB |
@@ -75,9 +75,10 @@ import {
|
||||
} from '../../lib/floorplan'
|
||||
import { guideEmitter } from '../../lib/guide-events'
|
||||
import { sfxEmitter } from '../../lib/sfx-bus'
|
||||
import { SITE_BOUNDARY_DRAG_LABEL } from '../../lib/site-boundary'
|
||||
import { cn } from '../../lib/utils'
|
||||
import type { GuideUiState, NavigationSyncPose } from '../../store/use-editor'
|
||||
import useEditor from '../../store/use-editor'
|
||||
import useEditor, { selectSiteFloorplanContext } from '../../store/use-editor'
|
||||
import { FloorplanAlignmentGuideLayer } from '../editor-2d/floorplan-alignment-guide-layer'
|
||||
import { FloorplanCursorIndicatorOverlay as Editor2dFloorplanCursorIndicatorOverlay } from '../editor-2d/floorplan-cursor-indicator-overlay'
|
||||
import { FloorplanSiteKeyHandler } from '../editor-2d/floorplan-hotkey-handlers'
|
||||
@@ -217,7 +218,6 @@ const FLOORPLAN_GUIDE_HANDLE_HINT_PADDING_X = 92
|
||||
const FLOORPLAN_GUIDE_HANDLE_HINT_PADDING_Y = 48
|
||||
const FLOORPLAN_GUIDE_ROTATION_SNAP_DEGREES = 45
|
||||
const FLOORPLAN_GUIDE_ROTATION_FINE_SNAP_DEGREES = 1
|
||||
const FLOORPLAN_SITE_COLOR = '#10b981'
|
||||
const FLOORPLAN_VIEW_ROTATION_DEG = 90
|
||||
const FLOORPLAN_ROTATION_DEGREES_PER_PIXEL = 0.35
|
||||
const FLOORPLAN_VIEW_ANIMATION_TIME_CONSTANT_MS = 90
|
||||
@@ -2483,7 +2483,7 @@ function polygonsEqual(a: WallPlanPoint[], b: Array<[number, number]>): boolean
|
||||
return false
|
||||
}
|
||||
|
||||
return pointsEqual(point, otherPoint)
|
||||
return Math.abs(point[0] - otherPoint[0]) < 1e-6 && Math.abs(point[1] - otherPoint[1]) < 1e-6
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -3710,29 +3710,175 @@ const FloorplanReferenceFloorLayer = memo(function FloorplanReferenceFloorLayer(
|
||||
})
|
||||
|
||||
const FloorplanSiteLayer = memo(function FloorplanSiteLayer({
|
||||
isEditing,
|
||||
isHighlighted,
|
||||
palette,
|
||||
sitePolygon,
|
||||
}: {
|
||||
isEditing: boolean
|
||||
isHighlighted: boolean
|
||||
palette: FloorplanPalette
|
||||
sitePolygon: SitePolygonEntry | null
|
||||
}) {
|
||||
if (!sitePolygon) {
|
||||
return null
|
||||
}
|
||||
|
||||
const stroke = isHighlighted ? palette.cursor : palette.measurementStroke
|
||||
const dashLength = isHighlighted ? 8 : 7
|
||||
const gapLength = isHighlighted ? 5 : 6
|
||||
const strokeWidth = isHighlighted ? 2.2 : 1.5
|
||||
const contrastStrokeWidth = isHighlighted ? 4.2 : 3.4
|
||||
const dashPattern = `${dashLength} ${gapLength}`
|
||||
|
||||
return (
|
||||
<>
|
||||
<polygon
|
||||
fill={FLOORPLAN_SITE_COLOR}
|
||||
fillOpacity={isEditing ? 0.12 : 0.08}
|
||||
fill="none"
|
||||
pointerEvents="none"
|
||||
points={sitePolygon.points}
|
||||
stroke={FLOORPLAN_SITE_COLOR}
|
||||
strokeDasharray={isEditing ? '0.16 0.1' : undefined}
|
||||
stroke={palette.surface}
|
||||
strokeDasharray={dashPattern}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeOpacity={isEditing ? 0.92 : 0.72}
|
||||
strokeWidth={isEditing ? '0.08' : '0.06'}
|
||||
strokeOpacity={isHighlighted ? 0.72 : 0.82}
|
||||
strokeWidth={contrastStrokeWidth}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
<polygon
|
||||
fill="none"
|
||||
pointerEvents="none"
|
||||
points={sitePolygon.points}
|
||||
stroke={stroke}
|
||||
strokeDasharray={dashPattern}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeOpacity={isHighlighted ? 0.98 : 0.78}
|
||||
strokeWidth={strokeWidth}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
const FloorplanSiteEdgeLabelLayer = memo(function FloorplanSiteEdgeLabelLayer({
|
||||
labelBackground,
|
||||
labelText,
|
||||
palette,
|
||||
sceneRotationDeg,
|
||||
shouldShow,
|
||||
sitePolygon,
|
||||
unit,
|
||||
unitsPerPixel,
|
||||
}: {
|
||||
labelBackground: string
|
||||
labelText: string
|
||||
palette: FloorplanPalette
|
||||
sceneRotationDeg: number
|
||||
shouldShow: boolean
|
||||
sitePolygon: SitePolygonEntry | null
|
||||
unit: 'metric' | 'imperial'
|
||||
unitsPerPixel: number
|
||||
}) {
|
||||
if (!(shouldShow && sitePolygon && sitePolygon.polygon.length >= 2)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const upx = unitsPerPixel
|
||||
const fontSize = Math.max(upx * 10, 0.08)
|
||||
const padX = upx * 6
|
||||
const padY = upx * 3
|
||||
const minWidth = upx * 38
|
||||
const labelOffset = upx * 18
|
||||
const centroid = sitePolygon.polygon.reduce(
|
||||
(acc, point) => ({
|
||||
x: acc.x + point.x / sitePolygon.polygon.length,
|
||||
y: acc.y + point.y / sitePolygon.polygon.length,
|
||||
}),
|
||||
{ x: 0, y: 0 },
|
||||
)
|
||||
const edges = sitePolygon.polygon.flatMap((start, edgeIndex, polygon) => {
|
||||
const end = polygon[(edgeIndex + 1) % polygon.length]
|
||||
if (!end) {
|
||||
return []
|
||||
}
|
||||
|
||||
const dx = end.x - start.x
|
||||
const dy = end.y - start.y
|
||||
const length = Math.hypot(dx, dy)
|
||||
if (length < 1e-6) {
|
||||
return []
|
||||
}
|
||||
|
||||
const midX = (start.x + end.x) / 2
|
||||
const midY = (start.y + end.y) / 2
|
||||
let normalX = -dy / length
|
||||
let normalY = dx / length
|
||||
if (normalX * (midX - centroid.x) + normalY * (midY - centroid.y) < 0) {
|
||||
normalX = -normalX
|
||||
normalY = -normalY
|
||||
}
|
||||
|
||||
let labelAngleDeg = (Math.atan2(dy, dx) * 180) / Math.PI
|
||||
let screenDeg = labelAngleDeg + sceneRotationDeg
|
||||
screenDeg = ((((screenDeg + 180) % 360) + 360) % 360) - 180
|
||||
if (screenDeg > 90) {
|
||||
labelAngleDeg -= 180
|
||||
} else if (screenDeg <= -90) {
|
||||
labelAngleDeg += 180
|
||||
}
|
||||
|
||||
const label = formatMeasurement(length, unit)
|
||||
const width = Math.max(label.length * upx * 6.4 + padX * 2, minWidth)
|
||||
const height = fontSize + padY * 2
|
||||
|
||||
return [
|
||||
{
|
||||
edgeIndex,
|
||||
height,
|
||||
label,
|
||||
labelAngleDeg,
|
||||
labelX: midX + normalX * labelOffset,
|
||||
labelY: midY + normalY * labelOffset,
|
||||
width,
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
return (
|
||||
<g pointerEvents="none">
|
||||
{edges.map(({ edgeIndex, height, label, labelAngleDeg, labelX, labelY, width }) => (
|
||||
<g
|
||||
key={`site-edge-label-${edgeIndex}`}
|
||||
transform={`translate(${labelX} ${labelY}) rotate(${labelAngleDeg})`}
|
||||
>
|
||||
<rect
|
||||
fill={labelBackground}
|
||||
height={height}
|
||||
opacity={0.94}
|
||||
rx={upx * 3}
|
||||
ry={upx * 3}
|
||||
stroke={palette.cursor}
|
||||
strokeOpacity={0.58}
|
||||
strokeWidth={1}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
width={width}
|
||||
x={-width / 2}
|
||||
y={-height / 2}
|
||||
/>
|
||||
<text
|
||||
dominantBaseline="middle"
|
||||
fill={labelText}
|
||||
fontFamily="ui-monospace, SFMono-Regular, Menlo, monospace"
|
||||
fontSize={fontSize}
|
||||
fontWeight={700}
|
||||
textAnchor="middle"
|
||||
x={0}
|
||||
y={0}
|
||||
>
|
||||
{label}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
</g>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -4035,7 +4181,7 @@ const FloorplanPolygonHandleLayer = memo(function FloorplanPolygonHandleLayer({
|
||||
onVertexDoubleClick: (
|
||||
nodeId: string,
|
||||
vertexIndex: number,
|
||||
event: ReactPointerEvent<SVGCircleElement>,
|
||||
event: ReactPointerEvent<SVGCircleElement> | ReactMouseEvent<SVGCircleElement>,
|
||||
) => void
|
||||
onMidpointPointerDown: (
|
||||
nodeId: string,
|
||||
@@ -4050,6 +4196,8 @@ const FloorplanPolygonHandleLayer = memo(function FloorplanPolygonHandleLayer({
|
||||
palette: FloorplanPalette
|
||||
unitsPerPixel: number
|
||||
}) {
|
||||
const vertexPointerDoubleClickRef = useRef<string | null>(null)
|
||||
|
||||
return (
|
||||
<>
|
||||
{edgeHandles.map(({ nodeId, edgeIndex, start, end, isActive }) => {
|
||||
@@ -4057,7 +4205,7 @@ const FloorplanPolygonHandleLayer = memo(function FloorplanPolygonHandleLayer({
|
||||
const isHovered = hoveredHandleId === handleId
|
||||
const startSvg = toSvgPlanPoint(start)
|
||||
const endSvg = toSvgPlanPoint(end)
|
||||
const visibleStroke = isActive ? palette.endpointHandleActiveStroke : palette.selectedStroke
|
||||
const visibleStroke = isActive || isHovered ? palette.cursor : palette.measurementStroke
|
||||
|
||||
return (
|
||||
<g
|
||||
@@ -4124,7 +4272,21 @@ const FloorplanPolygonHandleLayer = memo(function FloorplanPolygonHandleLayer({
|
||||
{vertexHandles.map(({ nodeId, vertexIndex, point, isActive }) => {
|
||||
const handleId = `${nodeId}:vertex:${vertexIndex}`
|
||||
const isHovered = hoveredHandleId === handleId
|
||||
const stroke = isActive ? palette.endpointHandleActiveStroke : palette.endpointHandleStroke
|
||||
const hoveredEdgePrefix = `${nodeId}:edge:`
|
||||
const hoveredMidpointPrefix = `${nodeId}:midpoint:`
|
||||
const hoveredEdgeIndex = hoveredHandleId?.startsWith(hoveredEdgePrefix)
|
||||
? Number(hoveredHandleId.slice(hoveredEdgePrefix.length))
|
||||
: hoveredHandleId?.startsWith(hoveredMidpointPrefix)
|
||||
? Number(hoveredHandleId.slice(hoveredMidpointPrefix.length))
|
||||
: null
|
||||
const isNearHoveredEdge =
|
||||
Number.isInteger(hoveredEdgeIndex) &&
|
||||
hoveredEdgeIndex !== null &&
|
||||
(vertexIndex === hoveredEdgeIndex ||
|
||||
vertexIndex === (hoveredEdgeIndex + 1) % vertexHandles.length)
|
||||
const stroke =
|
||||
isActive || isHovered || isNearHoveredEdge ? palette.cursor : palette.measurementStroke
|
||||
const handleOpacity = isActive || isHovered || isNearHoveredEdge ? 1 : 0
|
||||
const outerRadius =
|
||||
(isActive
|
||||
? FLOORPLAN_POLYGON_VERTEX_ACTIVE_RADIUS_PX
|
||||
@@ -4154,7 +4316,7 @@ const FloorplanPolygonHandleLayer = memo(function FloorplanPolygonHandleLayer({
|
||||
strokeOpacity={0.18}
|
||||
strokeWidth={FLOORPLAN_ENDPOINT_HOVER_GLOW_STROKE_WIDTH}
|
||||
style={{
|
||||
opacity: isHovered ? 1 : 0,
|
||||
opacity: handleOpacity,
|
||||
transition: FLOORPLAN_HOVER_TRANSITION,
|
||||
}}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
@@ -4163,19 +4325,23 @@ const FloorplanPolygonHandleLayer = memo(function FloorplanPolygonHandleLayer({
|
||||
cx={svgPoint.x}
|
||||
cy={svgPoint.y}
|
||||
fill={isActive ? palette.endpointHandleActiveFill : palette.endpointHandleFill}
|
||||
fillOpacity={0.96}
|
||||
fillOpacity={0.96 * handleOpacity}
|
||||
pointerEvents="none"
|
||||
r={outerRadius}
|
||||
stroke={stroke}
|
||||
strokeOpacity={handleOpacity}
|
||||
strokeWidth="0.045"
|
||||
style={{ transition: FLOORPLAN_HOVER_TRANSITION }}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
<circle
|
||||
cx={svgPoint.x}
|
||||
cy={svgPoint.y}
|
||||
fill={stroke}
|
||||
fillOpacity={handleOpacity}
|
||||
pointerEvents="none"
|
||||
r={dotRadius}
|
||||
style={{ transition: FLOORPLAN_HOVER_TRANSITION }}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
<circle
|
||||
@@ -4185,9 +4351,25 @@ const FloorplanPolygonHandleLayer = memo(function FloorplanPolygonHandleLayer({
|
||||
onDoubleClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
onVertexDoubleClick(nodeId, vertexIndex, event as any)
|
||||
if (vertexPointerDoubleClickRef.current === handleId) {
|
||||
vertexPointerDoubleClickRef.current = null
|
||||
return
|
||||
}
|
||||
|
||||
onVertexDoubleClick(nodeId, vertexIndex, event)
|
||||
}}
|
||||
onPointerDown={(event) => {
|
||||
if (event.button === 0 && event.detail >= 2) {
|
||||
vertexPointerDoubleClickRef.current = handleId
|
||||
window.setTimeout(() => {
|
||||
if (vertexPointerDoubleClickRef.current === handleId) {
|
||||
vertexPointerDoubleClickRef.current = null
|
||||
}
|
||||
}, 400)
|
||||
onVertexDoubleClick(nodeId, vertexIndex, event)
|
||||
return
|
||||
}
|
||||
|
||||
onVertexPointerDown(nodeId, vertexIndex, event)
|
||||
}}
|
||||
pointerEvents="all"
|
||||
@@ -4204,12 +4386,14 @@ const FloorplanPolygonHandleLayer = memo(function FloorplanPolygonHandleLayer({
|
||||
{midpointHandles.map(({ nodeId, edgeIndex, point }) => {
|
||||
const handleId = `${nodeId}:midpoint:${edgeIndex}`
|
||||
const isHovered = hoveredHandleId === handleId
|
||||
const isEdgeHovered = hoveredHandleId === `${nodeId}:edge:${edgeIndex}`
|
||||
const isVisible = isHovered || isEdgeHovered
|
||||
const isAddHandle = midpointStyle === 'add'
|
||||
const stroke = isAddHandle
|
||||
? '#111827'
|
||||
: isHovered
|
||||
? palette.endpointHandleHoverStroke
|
||||
: palette.endpointHandleStroke
|
||||
: isVisible
|
||||
? palette.cursor
|
||||
: palette.measurementStroke
|
||||
const radius =
|
||||
(isAddHandle
|
||||
? isHovered
|
||||
@@ -4241,7 +4425,7 @@ const FloorplanPolygonHandleLayer = memo(function FloorplanPolygonHandleLayer({
|
||||
strokeOpacity={0.16}
|
||||
strokeWidth={FLOORPLAN_ENDPOINT_HOVER_RING_STROKE_WIDTH}
|
||||
style={{
|
||||
opacity: isHovered ? 1 : 0,
|
||||
opacity: isVisible ? 1 : 0,
|
||||
transition: FLOORPLAN_HOVER_TRANSITION,
|
||||
}}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
@@ -4250,12 +4434,13 @@ const FloorplanPolygonHandleLayer = memo(function FloorplanPolygonHandleLayer({
|
||||
cx={svgPoint.x}
|
||||
cy={svgPoint.y}
|
||||
fill={isAddHandle ? '#ffffff' : palette.surface}
|
||||
fillOpacity={isAddHandle ? 1 : 0.94}
|
||||
fillOpacity={isVisible ? (isAddHandle ? 1 : 0.94) : 0}
|
||||
pointerEvents="none"
|
||||
r={radius}
|
||||
stroke={stroke}
|
||||
strokeOpacity={0.9}
|
||||
strokeOpacity={isVisible ? 0.9 : 0}
|
||||
strokeWidth={isAddHandle ? '1.4' : '0.035'}
|
||||
style={{ transition: FLOORPLAN_HOVER_TRANSITION }}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
{isAddHandle ? (
|
||||
@@ -4263,8 +4448,10 @@ const FloorplanPolygonHandleLayer = memo(function FloorplanPolygonHandleLayer({
|
||||
<line
|
||||
pointerEvents="none"
|
||||
stroke="#111827"
|
||||
strokeOpacity={isVisible ? 1 : 0}
|
||||
strokeLinecap="round"
|
||||
strokeWidth="1.6"
|
||||
style={{ transition: FLOORPLAN_HOVER_TRANSITION }}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
x1={svgPoint.x - plusHalfLength}
|
||||
x2={svgPoint.x + plusHalfLength}
|
||||
@@ -4274,8 +4461,10 @@ const FloorplanPolygonHandleLayer = memo(function FloorplanPolygonHandleLayer({
|
||||
<line
|
||||
pointerEvents="none"
|
||||
stroke="#111827"
|
||||
strokeOpacity={isVisible ? 1 : 0}
|
||||
strokeLinecap="round"
|
||||
strokeWidth="1.6"
|
||||
style={{ transition: FLOORPLAN_HOVER_TRANSITION }}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
x1={svgPoint.x}
|
||||
x2={svgPoint.x}
|
||||
@@ -4288,9 +4477,10 @@ const FloorplanPolygonHandleLayer = memo(function FloorplanPolygonHandleLayer({
|
||||
cx={svgPoint.x}
|
||||
cy={svgPoint.y}
|
||||
fill={stroke}
|
||||
fillOpacity={0.82}
|
||||
fillOpacity={isVisible ? 0.82 : 0}
|
||||
pointerEvents="none"
|
||||
r={dotRadius}
|
||||
style={{ transition: FLOORPLAN_HOVER_TRANSITION }}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
)}
|
||||
@@ -4374,6 +4564,7 @@ export function FloorplanPanel() {
|
||||
const curvingFence = useEditor((state) => state.curvingFence)
|
||||
const phase = useEditor((state) => state.phase)
|
||||
const mode = useEditor((state) => state.mode)
|
||||
const activeHandleDrag = useEditor((state) => state.activeHandleDrag)
|
||||
const setPhase = useEditor((state) => state.setPhase)
|
||||
const setMovingFenceEndpoint = useEditor((state) => state.setMovingFenceEndpoint)
|
||||
const setMovingNode = useEditor((state) => state.setMovingNode)
|
||||
@@ -4553,6 +4744,18 @@ export function FloorplanPanel() {
|
||||
[elevatorIds],
|
||||
),
|
||||
)
|
||||
const siteLivePolygon = useLiveNodeOverrides(
|
||||
useCallback(
|
||||
(state) => {
|
||||
if (!site?.id) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (state.overrides.get(site.id)?.polygon as SiteNode['polygon'] | undefined) ?? null
|
||||
},
|
||||
[site?.id],
|
||||
),
|
||||
)
|
||||
const [stairBuildPreviewPoint, setStairBuildPreviewPoint] = useState<WallPlanPoint | null>(null)
|
||||
const [stairBuildPreviewRotation, setStairBuildPreviewRotation] = useState(0)
|
||||
const [isSpacePanPressed, setIsSpacePanPressed] = useState(false)
|
||||
@@ -4616,12 +4819,14 @@ export function FloorplanPanel() {
|
||||
)
|
||||
|
||||
const sitePolygonEntry = useMemo(() => {
|
||||
const polygonPoints = site?.polygon?.points
|
||||
const polygonPoints = siteLivePolygon?.points ?? site?.polygon?.points
|
||||
if (!(site && polygonPoints)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const polygon = toFloorplanPolygon(polygonPoints)
|
||||
const polygon = polygonPoints.map(([x, z]) =>
|
||||
worldToFloorplanLocalPoint(x, z, buildingPosition, buildingRotationY),
|
||||
)
|
||||
if (polygon.length < 3) {
|
||||
return null
|
||||
}
|
||||
@@ -4631,7 +4836,7 @@ export function FloorplanPanel() {
|
||||
polygon,
|
||||
points: formatPolygonPoints(polygon),
|
||||
}
|
||||
}, [site])
|
||||
}, [buildingPosition, buildingRotationY, site, siteLivePolygon])
|
||||
const displaySitePolygon = useMemo(() => {
|
||||
if (!sitePolygonEntry) {
|
||||
return null
|
||||
@@ -4649,6 +4854,28 @@ export function FloorplanPanel() {
|
||||
points: formatPolygonPoints(polygon),
|
||||
}
|
||||
}, [siteBoundaryDraft, sitePolygonEntry])
|
||||
const siteBoundaryWorldPolygon = useCallback(
|
||||
(polygon: WallPlanPoint[]) =>
|
||||
polygon.map((point) => {
|
||||
const worldPoint = floorplanLocalToWorldPoint(point, buildingPosition, buildingRotationY)
|
||||
return [worldPoint.x, worldPoint.z] as [number, number]
|
||||
}),
|
||||
[buildingPosition, buildingRotationY],
|
||||
)
|
||||
const setSiteBoundaryLivePreview = useCallback(
|
||||
(siteId: SiteNode['id'], polygon: WallPlanPoint[]) => {
|
||||
useLiveNodeOverrides.getState().set(siteId, {
|
||||
polygon: {
|
||||
type: 'polygon',
|
||||
points: siteBoundaryWorldPolygon(polygon),
|
||||
},
|
||||
})
|
||||
},
|
||||
[siteBoundaryWorldPolygon],
|
||||
)
|
||||
const clearSiteBoundaryLivePreview = useCallback((siteId: SiteNode['id']) => {
|
||||
useLiveNodeOverrides.getState().clearFields(siteId, ['polygon'])
|
||||
}, [])
|
||||
const movingOpeningType =
|
||||
movingNode?.type === 'door' || movingNode?.type === 'window' ? movingNode.type : null
|
||||
|
||||
@@ -5331,8 +5558,14 @@ export function FloorplanPanel() {
|
||||
!movingNode &&
|
||||
!movingFenceEndpoint &&
|
||||
isFloorplanItemContextActive
|
||||
const visibleSitePolygon = phase === 'site' ? displaySitePolygon : null
|
||||
const shouldShowSiteBoundaryHandles = isSiteEditActive && visibleSitePolygon !== null
|
||||
const visibleSitePolygon = displaySitePolygon
|
||||
const canUseSiteBoundaryVertexHandles =
|
||||
visibleSitePolygon !== null && (isSiteEditActive || mode === 'select')
|
||||
const isSiteBoundaryHighlighted = isSiteEditActive || siteVertexDragState !== null
|
||||
const shouldShowSiteEdgeLabels =
|
||||
Boolean(visibleSitePolygon) &&
|
||||
activeHandleDrag?.nodeId === visibleSitePolygon?.site.id &&
|
||||
activeHandleDrag?.label === SITE_BOUNDARY_DRAG_LABEL
|
||||
const visibleZonePolygons = displayZonePolygons
|
||||
const selectedIdSet = useMemo(() => new Set(selectedIds), [selectedIds])
|
||||
const highlightedFloorplanIdSet = useMemo(
|
||||
@@ -5369,7 +5602,7 @@ export function FloorplanPanel() {
|
||||
return toSvgSelectionBounds(visibleMarqueeBounds)
|
||||
}, [visibleMarqueeBounds])
|
||||
const siteVertexHandles = useMemo(() => {
|
||||
if (!(shouldShowSiteBoundaryHandles && visibleSitePolygon)) {
|
||||
if (!(canUseSiteBoundaryVertexHandles && visibleSitePolygon)) {
|
||||
return []
|
||||
}
|
||||
|
||||
@@ -5381,9 +5614,24 @@ export function FloorplanPanel() {
|
||||
siteVertexDragState?.siteId === visibleSitePolygon.site.id &&
|
||||
siteVertexDragState.vertexIndex === vertexIndex,
|
||||
}))
|
||||
}, [shouldShowSiteBoundaryHandles, siteVertexDragState, visibleSitePolygon])
|
||||
}, [canUseSiteBoundaryVertexHandles, siteVertexDragState, visibleSitePolygon])
|
||||
const siteEdgeHandles = useMemo(() => {
|
||||
if (!(canUseSiteBoundaryVertexHandles && visibleSitePolygon && !siteVertexDragState)) {
|
||||
return []
|
||||
}
|
||||
|
||||
return visibleSitePolygon.polygon.map((point, edgeIndex, polygon) => {
|
||||
const nextPoint = polygon[(edgeIndex + 1) % polygon.length]
|
||||
return {
|
||||
nodeId: visibleSitePolygon.site.id,
|
||||
edgeIndex,
|
||||
start: toWallPlanPoint(point),
|
||||
end: toWallPlanPoint(nextPoint ?? point),
|
||||
}
|
||||
})
|
||||
}, [canUseSiteBoundaryVertexHandles, siteVertexDragState, visibleSitePolygon])
|
||||
const siteMidpointHandles = useMemo(() => {
|
||||
if (!(shouldShowSiteBoundaryHandles && visibleSitePolygon && !siteVertexDragState)) {
|
||||
if (!(canUseSiteBoundaryVertexHandles && visibleSitePolygon && !siteVertexDragState)) {
|
||||
return []
|
||||
}
|
||||
|
||||
@@ -5398,7 +5646,7 @@ export function FloorplanPanel() {
|
||||
] as WallPlanPoint,
|
||||
}
|
||||
})
|
||||
}, [shouldShowSiteBoundaryHandles, siteVertexDragState, visibleSitePolygon])
|
||||
}, [canUseSiteBoundaryVertexHandles, siteVertexDragState, visibleSitePolygon])
|
||||
|
||||
const draftPolygon = useMemo(() => {
|
||||
if (!(levelId && draftStart && draftEnd && isSegmentLongEnough(draftStart, draftEnd))) {
|
||||
@@ -6917,10 +7165,28 @@ export function FloorplanPanel() {
|
||||
setHoveredWallCurveHandleId(null)
|
||||
}, [])
|
||||
const clearSiteBoundaryInteraction = useCallback(() => {
|
||||
const draft = siteBoundaryDraftRef.current
|
||||
if (draft) {
|
||||
clearSiteBoundaryLivePreview(draft.siteId)
|
||||
const editor = useEditor.getState()
|
||||
if (
|
||||
editor.activeHandleDrag?.nodeId === draft.siteId &&
|
||||
editor.activeHandleDrag.label === SITE_BOUNDARY_DRAG_LABEL
|
||||
) {
|
||||
editor.setActiveHandleDrag(null)
|
||||
}
|
||||
}
|
||||
|
||||
siteBoundaryDraftRef.current = null
|
||||
setSiteVertexDragState(null)
|
||||
setSiteBoundaryDraft(null)
|
||||
setHoveredSiteHandleId(null)
|
||||
}, [])
|
||||
}, [clearSiteBoundaryLivePreview])
|
||||
const exitSiteEditingToSelect = useCallback(() => {
|
||||
setPhase('structure')
|
||||
setStructureLayer('elements')
|
||||
setMode('select')
|
||||
}, [setMode, setPhase, setStructureLayer])
|
||||
|
||||
const clearDraft = useCallback(() => {
|
||||
clearWallPlacementDraft()
|
||||
@@ -7629,12 +7895,12 @@ export function FloorplanPanel() {
|
||||
}, [clearWallCurveDrag, clearWallEndpointDrag])
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldShowSiteBoundaryHandles) {
|
||||
if (canUseSiteBoundaryVertexHandles) {
|
||||
return
|
||||
}
|
||||
|
||||
clearSiteBoundaryInteraction()
|
||||
}, [clearSiteBoundaryInteraction, shouldShowSiteBoundaryHandles])
|
||||
}, [canUseSiteBoundaryVertexHandles, clearSiteBoundaryInteraction])
|
||||
|
||||
useEffect(() => {
|
||||
const dragState = siteVertexDragState
|
||||
@@ -7657,26 +7923,27 @@ export function FloorplanPanel() {
|
||||
const snappedPoint: WallPlanPoint = [snapToHalf(planPoint[0]), snapToHalf(planPoint[1])]
|
||||
setCursorPoint(snappedPoint)
|
||||
|
||||
setSiteBoundaryDraft((currentDraft) => {
|
||||
const currentDraft = siteBoundaryDraftRef.current
|
||||
if (!currentDraft || currentDraft.siteId !== dragState.siteId) {
|
||||
return currentDraft
|
||||
return
|
||||
}
|
||||
|
||||
const currentPoint = currentDraft.polygon[dragState.vertexIndex]
|
||||
if (currentPoint && pointsEqual(currentPoint, snappedPoint)) {
|
||||
return currentDraft
|
||||
return
|
||||
}
|
||||
|
||||
sfxEmitter.emit('sfx:grid-snap')
|
||||
|
||||
const nextPolygon = [...currentDraft.polygon]
|
||||
nextPolygon[dragState.vertexIndex] = snappedPoint
|
||||
|
||||
return {
|
||||
const nextDraft = {
|
||||
...currentDraft,
|
||||
polygon: nextPolygon,
|
||||
}
|
||||
})
|
||||
siteBoundaryDraftRef.current = nextDraft
|
||||
setSiteBoundaryDraft(nextDraft)
|
||||
setSiteBoundaryLivePreview(dragState.siteId, nextPolygon)
|
||||
}
|
||||
|
||||
const commitSiteVertexDrag = (event: PointerEvent) => {
|
||||
@@ -7685,11 +7952,13 @@ export function FloorplanPanel() {
|
||||
}
|
||||
|
||||
const draft = siteBoundaryDraftRef.current
|
||||
const worldPolygon = draft ? siteBoundaryWorldPolygon(draft.polygon) : null
|
||||
if (
|
||||
draft &&
|
||||
worldPolygon &&
|
||||
site &&
|
||||
draft.siteId === site.id &&
|
||||
!polygonsEqual(draft.polygon, site.polygon?.points ?? [])
|
||||
!polygonsEqual(worldPolygon, site.polygon?.points ?? [])
|
||||
) {
|
||||
const suppressClick = (clickEvent: MouseEvent) => {
|
||||
clickEvent.stopImmediatePropagation()
|
||||
@@ -7704,13 +7973,14 @@ export function FloorplanPanel() {
|
||||
updateNode(draft.siteId, {
|
||||
polygon: {
|
||||
type: 'polygon',
|
||||
points: draft.polygon,
|
||||
points: worldPolygon,
|
||||
},
|
||||
})
|
||||
sfxEmitter.emit('sfx:structure-build')
|
||||
}
|
||||
|
||||
clearSiteBoundaryInteraction()
|
||||
exitSiteEditingToSelect()
|
||||
setCursorPoint(null)
|
||||
}
|
||||
|
||||
@@ -7720,6 +7990,7 @@ export function FloorplanPanel() {
|
||||
}
|
||||
|
||||
clearSiteBoundaryInteraction()
|
||||
exitSiteEditingToSelect()
|
||||
setCursorPoint(null)
|
||||
}
|
||||
|
||||
@@ -7734,8 +8005,11 @@ export function FloorplanPanel() {
|
||||
}
|
||||
}, [
|
||||
clearSiteBoundaryInteraction,
|
||||
exitSiteEditingToSelect,
|
||||
getPlanPointFromClientPoint,
|
||||
setSiteBoundaryLivePreview,
|
||||
site,
|
||||
siteBoundaryWorldPolygon,
|
||||
siteVertexDragState,
|
||||
updateNode,
|
||||
])
|
||||
@@ -9249,10 +9523,19 @@ export function FloorplanPanel() {
|
||||
return
|
||||
}
|
||||
|
||||
setSiteBoundaryDraft({
|
||||
if (useEditor.getState().phase !== 'site') {
|
||||
useEditor.setState({ catalogCategory: null, mode: 'select', phase: 'site', tool: null })
|
||||
}
|
||||
selectSiteFloorplanContext()
|
||||
|
||||
const nextDraft = {
|
||||
siteId,
|
||||
polygon: displaySitePolygon.polygon.map(toWallPlanPoint),
|
||||
})
|
||||
}
|
||||
siteBoundaryDraftRef.current = nextDraft
|
||||
setSiteBoundaryDraft(nextDraft)
|
||||
setSiteBoundaryLivePreview(siteId, nextDraft.polygon)
|
||||
useEditor.getState().setActiveHandleDrag({ nodeId: siteId, label: SITE_BOUNDARY_DRAG_LABEL })
|
||||
setSiteVertexDragState({
|
||||
pointerId: event.pointerId,
|
||||
siteId,
|
||||
@@ -9260,10 +9543,14 @@ export function FloorplanPanel() {
|
||||
})
|
||||
setCursorPoint(toWallPlanPoint(vertexPoint))
|
||||
},
|
||||
[displaySitePolygon],
|
||||
[displaySitePolygon, setSiteBoundaryLivePreview],
|
||||
)
|
||||
const handleSiteVertexDoubleClick = useCallback(
|
||||
(siteId: SiteNode['id'], vertexIndex: number, event: ReactPointerEvent<SVGCircleElement>) => {
|
||||
(
|
||||
siteId: SiteNode['id'],
|
||||
vertexIndex: number,
|
||||
event: ReactPointerEvent<SVGCircleElement> | ReactMouseEvent<SVGCircleElement>,
|
||||
) => {
|
||||
if (event.button !== 0) {
|
||||
return
|
||||
}
|
||||
@@ -9275,7 +9562,6 @@ export function FloorplanPanel() {
|
||||
return
|
||||
}
|
||||
|
||||
siteBoundaryDraftRef.current = null
|
||||
clearSiteBoundaryInteraction()
|
||||
|
||||
updateNode(siteId, {
|
||||
@@ -9319,10 +9605,19 @@ export function FloorplanPanel() {
|
||||
...basePolygon.slice(insertIndex),
|
||||
]
|
||||
|
||||
setSiteBoundaryDraft({
|
||||
if (useEditor.getState().phase !== 'site') {
|
||||
useEditor.setState({ catalogCategory: null, mode: 'select', phase: 'site', tool: null })
|
||||
}
|
||||
selectSiteFloorplanContext()
|
||||
|
||||
const nextDraft = {
|
||||
siteId,
|
||||
polygon: nextPolygon,
|
||||
})
|
||||
}
|
||||
siteBoundaryDraftRef.current = nextDraft
|
||||
setSiteBoundaryDraft(nextDraft)
|
||||
setSiteBoundaryLivePreview(siteId, nextPolygon)
|
||||
useEditor.getState().setActiveHandleDrag({ nodeId: siteId, label: SITE_BOUNDARY_DRAG_LABEL })
|
||||
setSiteVertexDragState({
|
||||
pointerId: event.pointerId,
|
||||
siteId,
|
||||
@@ -9330,7 +9625,7 @@ export function FloorplanPanel() {
|
||||
})
|
||||
setCursorPoint(insertedPoint)
|
||||
},
|
||||
[displaySitePolygon],
|
||||
[displaySitePolygon, setSiteBoundaryLivePreview],
|
||||
)
|
||||
|
||||
const handlePointerLeave = useCallback(() => {
|
||||
@@ -10020,8 +10315,6 @@ export function FloorplanPanel() {
|
||||
selectedGuideId={selectedGuideId}
|
||||
/>
|
||||
|
||||
<FloorplanSiteLayer isEditing={isSiteEditActive} sitePolygon={visibleSitePolygon} />
|
||||
|
||||
{/* Stair is fully registry-driven for committed nodes
|
||||
(`def.floorplan` on the stair kind). This layer only
|
||||
carries the in-flight stair preview, which lives outside
|
||||
@@ -10058,24 +10351,6 @@ export function FloorplanPanel() {
|
||||
unitsPerPixel={floorplanUnitsPerPixel}
|
||||
/>
|
||||
|
||||
<FloorplanPolygonHandleLayer
|
||||
hoveredHandleId={hoveredSiteHandleId}
|
||||
midpointHandles={siteMidpointHandles}
|
||||
onHandleHoverChange={setHoveredSiteHandleId}
|
||||
onMidpointPointerDown={(nodeId, edgeIndex, event) =>
|
||||
handleSiteMidpointPointerDown(nodeId as SiteNode['id'], edgeIndex, event)
|
||||
}
|
||||
onVertexDoubleClick={(nodeId, vertexIndex, event) =>
|
||||
handleSiteVertexDoubleClick(nodeId as SiteNode['id'], vertexIndex, event)
|
||||
}
|
||||
onVertexPointerDown={(nodeId, vertexIndex, event) =>
|
||||
handleSiteVertexPointerDown(nodeId as SiteNode['id'], vertexIndex, event)
|
||||
}
|
||||
palette={palette}
|
||||
unitsPerPixel={floorplanUnitsPerPixel}
|
||||
vertexHandles={siteVertexHandles}
|
||||
/>
|
||||
|
||||
{isMarqueeSelectionToolActive && (
|
||||
<rect
|
||||
fill="transparent"
|
||||
@@ -10150,6 +10425,42 @@ export function FloorplanPanel() {
|
||||
paint on top of node geometry. */}
|
||||
<FloorplanAlignmentGuideLayer />
|
||||
|
||||
<FloorplanSiteLayer
|
||||
isHighlighted={isSiteBoundaryHighlighted}
|
||||
palette={palette}
|
||||
sitePolygon={visibleSitePolygon}
|
||||
/>
|
||||
|
||||
<FloorplanPolygonHandleLayer
|
||||
edgeHandles={siteEdgeHandles}
|
||||
hoveredHandleId={hoveredSiteHandleId}
|
||||
midpointHandles={siteMidpointHandles}
|
||||
onHandleHoverChange={setHoveredSiteHandleId}
|
||||
onMidpointPointerDown={(nodeId, edgeIndex, event) =>
|
||||
handleSiteMidpointPointerDown(nodeId as SiteNode['id'], edgeIndex, event)
|
||||
}
|
||||
onVertexDoubleClick={(nodeId, vertexIndex, event) =>
|
||||
handleSiteVertexDoubleClick(nodeId as SiteNode['id'], vertexIndex, event)
|
||||
}
|
||||
onVertexPointerDown={(nodeId, vertexIndex, event) =>
|
||||
handleSiteVertexPointerDown(nodeId as SiteNode['id'], vertexIndex, event)
|
||||
}
|
||||
palette={palette}
|
||||
unitsPerPixel={floorplanUnitsPerPixel}
|
||||
vertexHandles={siteVertexHandles}
|
||||
/>
|
||||
|
||||
<FloorplanSiteEdgeLabelLayer
|
||||
labelBackground={isDark ? '#0f172a' : '#ffffff'}
|
||||
labelText={isDark ? '#e2e8f0' : '#171717'}
|
||||
palette={palette}
|
||||
sceneRotationDeg={floorplanSceneRotationDeg}
|
||||
shouldShow={shouldShowSiteEdgeLabels}
|
||||
sitePolygon={visibleSitePolygon}
|
||||
unit={unit}
|
||||
unitsPerPixel={floorplanUnitsPerPixel}
|
||||
/>
|
||||
|
||||
{/* "Magnetic" wall-snap beacon — per-kind glyph at the active
|
||||
draft / endpoint-move snap point. Same store + coord space as
|
||||
the alignment guides. */}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
'use client'
|
||||
|
||||
import type { SiteNode } from '@pascal-app/core'
|
||||
import { sceneRegistry, useScene } from '@pascal-app/core'
|
||||
import { sceneRegistry, useLiveNodeOverrides, useScene } from '@pascal-app/core'
|
||||
import { getSceneTheme, useViewer } from '@pascal-app/viewer'
|
||||
import { Html } from '@react-three/drei'
|
||||
import { createPortal, useFrame, useThree } from '@react-three/fiber'
|
||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { type Camera, type Object3D, Vector3 } from 'three'
|
||||
import { SITE_BOUNDARY_DRAG_LABEL } from '../../lib/site-boundary'
|
||||
import useEditor from '../../store/use-editor'
|
||||
import { formatMeasurement } from './measurement-pill'
|
||||
|
||||
type ViewportSize = {
|
||||
width: number
|
||||
@@ -24,17 +27,6 @@ function calculateHtmlPosition(el: Object3D, camera: Camera, size: ViewportSize)
|
||||
return [htmlPosition.x * widthHalf + widthHalf, -htmlPosition.y * heightHalf + heightHalf]
|
||||
}
|
||||
|
||||
function formatMeasurement(value: number, unit: 'metric' | 'imperial') {
|
||||
if (unit === 'imperial') {
|
||||
const feet = value * 3.280_84
|
||||
const wholeFeet = Math.floor(feet)
|
||||
const inches = Math.round((feet - wholeFeet) * 12)
|
||||
if (inches === 12) return `${wholeFeet + 1}'0"`
|
||||
return `${wholeFeet}'${inches}"`
|
||||
}
|
||||
return `${Number.parseFloat(value.toFixed(2))}m`
|
||||
}
|
||||
|
||||
export function SiteEdgeLabels() {
|
||||
// Narrow subscription to just the site node — subscribing to the full
|
||||
// s.nodes dict re-rendered this on every wall/level mutation even though
|
||||
@@ -45,6 +37,7 @@ export function SiteEdgeLabels() {
|
||||
const node = state.nodes[firstRoot]
|
||||
return node?.type === 'site' ? (node as SiteNode) : null
|
||||
})
|
||||
const activeHandleDrag = useEditor((state) => state.activeHandleDrag)
|
||||
const unit = useViewer((state) => state.unit)
|
||||
const cameraMode = useViewer((state) => state.cameraMode)
|
||||
const isNight = useViewer((state) => getSceneTheme(state.sceneTheme).appearance === 'dark')
|
||||
@@ -56,6 +49,15 @@ export function SiteEdgeLabels() {
|
||||
)
|
||||
|
||||
const siteNodeId = siteNode?.id
|
||||
const livePolygon = useLiveNodeOverrides((state) => {
|
||||
if (!siteNodeId) return null
|
||||
return (state.overrides.get(siteNodeId)?.polygon as SiteNode['polygon'] | undefined) ?? null
|
||||
})
|
||||
const polygon = livePolygon?.points ?? siteNode?.polygon?.points ?? []
|
||||
const shouldShowLabels =
|
||||
Boolean(siteNodeId) &&
|
||||
activeHandleDrag?.nodeId === siteNodeId &&
|
||||
activeHandleDrag?.label === SITE_BOUNDARY_DRAG_LABEL
|
||||
|
||||
const color = isNight ? '#ffffff' : '#111111'
|
||||
const shadowColor = isNight ? '#111111' : '#ffffff'
|
||||
@@ -77,7 +79,6 @@ export function SiteEdgeLabels() {
|
||||
})
|
||||
|
||||
const edges = useMemo(() => {
|
||||
const polygon = siteNode?.polygon?.points ?? []
|
||||
if (polygon.length < 2) return []
|
||||
return polygon.map(([x1, z1], i) => {
|
||||
const [x2, z2] = polygon[(i + 1) % polygon.length]!
|
||||
@@ -86,9 +87,9 @@ export function SiteEdgeLabels() {
|
||||
const dist = Math.sqrt((x2 - x1!) ** 2 + (z2 - z1!) ** 2)
|
||||
return { midX, midZ, dist }
|
||||
})
|
||||
}, [siteNode?.polygon?.points])
|
||||
}, [polygon])
|
||||
|
||||
if (!siteObj || edges.length === 0) return null
|
||||
if (!shouldShowLabels || !siteObj || edges.length === 0) return null
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { emitter, type GridEvent, sceneRegistry } from '@pascal-app/core'
|
||||
import { SCENE_LAYER, useViewer } from '@pascal-app/viewer'
|
||||
import { createPortal, type ThreeEvent } from '@react-three/fiber'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
BufferGeometry,
|
||||
Color,
|
||||
@@ -98,6 +98,26 @@ export interface PolygonEditorProps {
|
||||
allowPolygonMove?: boolean
|
||||
/** Whether polygon edges can be dragged along their perpendicular normal. */
|
||||
allowEdgeMove?: boolean
|
||||
/** Called just before a vertex drag session starts. */
|
||||
onBeforeVertexDrag?: (vertexIndex: number, position: [number, number]) => void
|
||||
/** Called when a vertex handle enters or leaves hover. */
|
||||
onVertexHoverChange?: (vertexIndex: number | null) => void
|
||||
/** Called when a midpoint add-vertex handle enters or leaves hover. */
|
||||
onMidpointHoverChange?: (edgeIndex: number | null) => void
|
||||
/** Called when any polygon drag starts or ends. */
|
||||
onDragStateChange?: (isDragging: boolean) => void
|
||||
/** Called once when a polygon drag starts. */
|
||||
onDragStart?: () => void
|
||||
/** Called once when a polygon drag commits on pointer release. */
|
||||
onDragCommit?: () => void
|
||||
/** Whether to render the editor-owned polygon outline. */
|
||||
showBorderLine?: boolean
|
||||
/** Whether midpoint handles can add new vertices. */
|
||||
showMidpointHandles?: boolean
|
||||
/** Optional vertex handle renderer for host-specific affordances. */
|
||||
renderVertexHandle?: PolygonVertexHandleRenderer
|
||||
/** Optional midpoint handle renderer for host-specific add-vertex affordances. */
|
||||
renderMidpointHandle?: PolygonMidpointHandleRenderer
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -120,7 +140,7 @@ function getEdgeNormal(start: [number, number], end: [number, number]): [number,
|
||||
type HandleClickHandler = (event: ThreeEvent<MouseEvent>) => void
|
||||
type HandlePointerHandler = (event: ThreeEvent<PointerEvent>) => void
|
||||
|
||||
type HandleHandlers = {
|
||||
export type PolygonHandleHandlers = {
|
||||
onClick?: HandleClickHandler
|
||||
onDoubleClick?: HandleClickHandler
|
||||
onPointerDown?: HandlePointerHandler
|
||||
@@ -128,14 +148,42 @@ type HandleHandlers = {
|
||||
onPointerLeave?: HandlePointerHandler
|
||||
}
|
||||
|
||||
export type PolygonVertexHandleRenderProps = {
|
||||
canDelete: boolean
|
||||
handleProps: PolygonHandleHandlers
|
||||
height: number
|
||||
index: number
|
||||
isDragging: boolean
|
||||
isHovered: boolean
|
||||
point: [number, number]
|
||||
position: [number, number, number]
|
||||
radius: number
|
||||
}
|
||||
|
||||
export type PolygonVertexHandleRenderer = (props: PolygonVertexHandleRenderProps) => React.ReactNode
|
||||
|
||||
export type PolygonMidpointHandleRenderProps = {
|
||||
handleProps: PolygonHandleHandlers
|
||||
height: number
|
||||
index: number
|
||||
isHovered: boolean
|
||||
point: [number, number]
|
||||
position: [number, number, number]
|
||||
radius: number
|
||||
}
|
||||
|
||||
export type PolygonMidpointHandleRenderer = (
|
||||
props: PolygonMidpointHandleRenderProps,
|
||||
) => React.ReactNode
|
||||
|
||||
function usePolygonNodeMaterial(color: string, opacity = 1): MeshBasicNodeMaterial {
|
||||
const material = useMemo(
|
||||
() =>
|
||||
new MeshBasicNodeMaterial({
|
||||
color: new Color(color),
|
||||
color: new Color('#ffffff'),
|
||||
depthTest: false,
|
||||
depthWrite: true,
|
||||
opacity,
|
||||
opacity: 1,
|
||||
transparent: true,
|
||||
}),
|
||||
[],
|
||||
@@ -168,7 +216,7 @@ function OutlinedCylinderHandle({
|
||||
color: string
|
||||
opacity?: number
|
||||
position: [number, number, number]
|
||||
} & HandleHandlers) {
|
||||
} & PolygonHandleHandlers) {
|
||||
const geometry = useMemo(() => new CylinderGeometry(radius, radius, height, 16), [height, radius])
|
||||
const material = usePolygonNodeMaterial(color, opacity)
|
||||
useEffect(() => () => geometry.dispose(), [geometry])
|
||||
@@ -197,7 +245,7 @@ function OutlinedCrossHandle({
|
||||
}: {
|
||||
color: string
|
||||
position: [number, number, number]
|
||||
} & HandleHandlers) {
|
||||
} & PolygonHandleHandlers) {
|
||||
const geometry = useMemo(() => createMoveCrossHandleGeometry(), [])
|
||||
const material = usePolygonNodeMaterial(color)
|
||||
const hitGeometry = useMemo(() => new CylinderGeometry(0.24, 0.24, 0.18, 24), [])
|
||||
@@ -241,7 +289,7 @@ function OutlinedEdgeArrowHandle({
|
||||
position: [number, number, number]
|
||||
rotationY: number
|
||||
scale: number
|
||||
} & HandleHandlers) {
|
||||
} & PolygonHandleHandlers) {
|
||||
const material = useArrowMaterial()
|
||||
useEffect(() => {
|
||||
material.color.set(color)
|
||||
@@ -273,6 +321,16 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
||||
surfaceHeight = 0,
|
||||
allowPolygonMove = false,
|
||||
allowEdgeMove = false,
|
||||
onBeforeVertexDrag,
|
||||
onVertexHoverChange,
|
||||
onMidpointHoverChange,
|
||||
onDragStateChange,
|
||||
onDragStart,
|
||||
onDragCommit,
|
||||
showBorderLine = true,
|
||||
showMidpointHandles = true,
|
||||
renderMidpointHandle,
|
||||
renderVertexHandle,
|
||||
}) => {
|
||||
const [levelNode, setLevelNode] = useState<Object3D | null>(() =>
|
||||
levelId ? (sceneRegistry.nodes.get(levelId) ?? null) : null,
|
||||
@@ -319,6 +377,21 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
||||
const previewPolygonRef = useRef<Array<[number, number]> | null>(null)
|
||||
const previousInputDraggingRef = useRef(false)
|
||||
|
||||
const onDragStateChangeRef = useRef(onDragStateChange)
|
||||
useEffect(() => {
|
||||
onDragStateChangeRef.current = onDragStateChange
|
||||
}, [onDragStateChange])
|
||||
|
||||
const onDragStartRef = useRef(onDragStart)
|
||||
useEffect(() => {
|
||||
onDragStartRef.current = onDragStart
|
||||
}, [onDragStart])
|
||||
|
||||
const onDragCommitRef = useRef(onDragCommit)
|
||||
useEffect(() => {
|
||||
onDragCommitRef.current = onDragCommit
|
||||
}, [onDragCommit])
|
||||
|
||||
const onPolygonPreviewRef = useRef(onPolygonPreview)
|
||||
useEffect(() => {
|
||||
onPolygonPreviewRef.current = onPolygonPreview
|
||||
@@ -344,13 +417,32 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
||||
const [hoveredEdge, setHoveredEdge] = useState<number | null>(null)
|
||||
const [cursorPosition, setCursorPosition] = useState<[number, number]>([0, 0])
|
||||
|
||||
useEffect(() => {
|
||||
onVertexHoverChange?.(hoveredVertex)
|
||||
}, [hoveredVertex, onVertexHoverChange])
|
||||
|
||||
useEffect(() => () => onVertexHoverChange?.(null), [onVertexHoverChange])
|
||||
|
||||
useEffect(() => {
|
||||
onMidpointHoverChange?.(hoveredMidpoint)
|
||||
}, [hoveredMidpoint, onMidpointHoverChange])
|
||||
|
||||
useEffect(() => () => onMidpointHoverChange?.(null), [onMidpointHoverChange])
|
||||
|
||||
const lineRef = useRef<Line>(null!)
|
||||
const previousPositionRef = useRef<[number, number] | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
onDragStateChangeRef.current?.(dragState?.isDragging ?? false)
|
||||
}, [dragState?.isDragging])
|
||||
|
||||
useEffect(() => () => onDragStateChangeRef.current?.(false), [])
|
||||
|
||||
const startDrag = useCallback((nextDragState: DragState) => {
|
||||
previousInputDraggingRef.current = useViewer.getState().inputDragging
|
||||
useViewer.getState().setInputDragging(true)
|
||||
setDragState(nextDragState)
|
||||
onDragStartRef.current?.()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -483,6 +575,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
||||
if (previewPolygonRef.current) {
|
||||
onPolygonChange(previewPolygonRef.current)
|
||||
}
|
||||
onDragCommitRef.current?.()
|
||||
updatePreviewPolygon(null)
|
||||
setDragState(null)
|
||||
}, [onPolygonChange, updatePreviewPolygon])
|
||||
@@ -522,8 +615,9 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
||||
// Listen to grid:move events to track cursor position
|
||||
useEffect(() => {
|
||||
const onGridMove = (event: GridEvent) => {
|
||||
const gridX = snapToHalf(event.localPosition[0])
|
||||
const gridZ = snapToHalf(event.localPosition[2])
|
||||
const point = levelNode ? event.localPosition : event.position
|
||||
const gridX = snapToHalf(point[0])
|
||||
const gridZ = snapToHalf(point[2])
|
||||
const newPosition: [number, number] = [gridX, gridZ]
|
||||
|
||||
// Play snap sound when cursor moves to a new grid cell during drag
|
||||
@@ -579,7 +673,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
||||
return () => {
|
||||
emitter.off('grid:move', onGridMove)
|
||||
}
|
||||
}, [dragState, handleVertexDrag, updatePreviewPolygon])
|
||||
}, [dragState, handleVertexDrag, levelNode, updatePreviewPolygon])
|
||||
|
||||
// Set up pointer up listener for ending drag
|
||||
useEffect(() => {
|
||||
@@ -624,7 +718,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
||||
|
||||
// Update line geometry when polygon changes
|
||||
useEffect(() => {
|
||||
if (!lineRef.current || displayPolygon.length < 2) return
|
||||
if (!showBorderLine || !lineRef.current || displayPolygon.length < 2) return
|
||||
|
||||
const positions: number[] = []
|
||||
for (const [x, z] of displayPolygon) {
|
||||
@@ -639,7 +733,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
||||
|
||||
lineRef.current.geometry.dispose()
|
||||
lineRef.current.geometry = geometry
|
||||
}, [displayPolygon, editY])
|
||||
}, [displayPolygon, editY, showBorderLine])
|
||||
|
||||
if (displayPolygon.length < minVertices) return null
|
||||
|
||||
@@ -656,6 +750,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
||||
const editorContent = (
|
||||
<group>
|
||||
{/* Border line */}
|
||||
{showBorderLine && (
|
||||
<line
|
||||
frustumCulled={false}
|
||||
layers={EDITOR_LAYER}
|
||||
@@ -674,6 +769,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
||||
transparent
|
||||
/>
|
||||
</line>
|
||||
)}
|
||||
|
||||
{/* Vertex handles - blue cylinders that match surface height */}
|
||||
{displayPolygon.map(([x, z], index) => {
|
||||
@@ -681,27 +777,25 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
||||
const isDragging = dragState?.mode === 'vertex' && dragState.vertexIndex === index
|
||||
const radius = 0.1
|
||||
const height = handleHeight
|
||||
|
||||
return (
|
||||
<OutlinedCylinderHandle
|
||||
color={isDragging || isHovered ? EDGE_ARROW_HOVER_COLOR : EDGE_ARROW_COLOR}
|
||||
height={height}
|
||||
key={`vertex-${index}`}
|
||||
onClick={(e) => {
|
||||
const point: [number, number] = [x!, z!]
|
||||
const position: [number, number, number] = [x!, editY + height / 2, z!]
|
||||
const handleProps: PolygonHandleHandlers = {
|
||||
onClick: (e) => {
|
||||
if (e.button !== 0) return
|
||||
e.stopPropagation()
|
||||
}}
|
||||
onDoubleClick={(e) => {
|
||||
},
|
||||
onDoubleClick: (e) => {
|
||||
if (e.button !== 0) return
|
||||
e.stopPropagation()
|
||||
if (canDelete) {
|
||||
handleDeleteVertex(index)
|
||||
}
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
},
|
||||
onPointerDown: (e) => {
|
||||
if (e.button !== 0) return
|
||||
e.stopPropagation()
|
||||
setHoveredEdge(null)
|
||||
onBeforeVertexDrag?.(index, point)
|
||||
startDrag({
|
||||
isDragging: true,
|
||||
mode: 'vertex',
|
||||
@@ -710,16 +804,42 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
||||
initialPolygon: displayPolygon.map(([px, pz]) => [px, pz] as [number, number]),
|
||||
pointerId: e.pointerId,
|
||||
})
|
||||
}}
|
||||
onPointerEnter={(e) => {
|
||||
},
|
||||
onPointerEnter: (e) => {
|
||||
e.stopPropagation()
|
||||
setHoveredVertex(index)
|
||||
}}
|
||||
onPointerLeave={(e) => {
|
||||
},
|
||||
onPointerLeave: (e) => {
|
||||
e.stopPropagation()
|
||||
setHoveredVertex(null)
|
||||
}}
|
||||
position={[x!, editY + height / 2, z!]}
|
||||
},
|
||||
}
|
||||
|
||||
if (renderVertexHandle) {
|
||||
return (
|
||||
<Fragment key={`vertex-${index}`}>
|
||||
{renderVertexHandle({
|
||||
canDelete,
|
||||
handleProps,
|
||||
height,
|
||||
index,
|
||||
isDragging,
|
||||
isHovered,
|
||||
point,
|
||||
position,
|
||||
radius,
|
||||
})}
|
||||
</Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<OutlinedCylinderHandle
|
||||
color={isDragging || isHovered ? EDGE_ARROW_HOVER_COLOR : EDGE_ARROW_COLOR}
|
||||
height={height}
|
||||
key={`vertex-${index}`}
|
||||
{...handleProps}
|
||||
position={position}
|
||||
radius={radius}
|
||||
/>
|
||||
)
|
||||
@@ -830,47 +950,70 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
|
||||
})}
|
||||
|
||||
{/* Midpoint handles - smaller green cylinders for adding vertices (hidden while dragging) */}
|
||||
{!dragState &&
|
||||
{showMidpointHandles &&
|
||||
!dragState &&
|
||||
midpoints.map(([x, z], index) => {
|
||||
const isHovered = hoveredMidpoint === index
|
||||
const radius = 0.06
|
||||
const height = handleHeight
|
||||
const point: [number, number] = [x!, z!]
|
||||
const position: [number, number, number] = [x!, editY + height / 2, z!]
|
||||
const handleProps: PolygonHandleHandlers = {
|
||||
onClick: (e) => {
|
||||
if (e.button !== 0) return
|
||||
e.stopPropagation()
|
||||
},
|
||||
onPointerDown: (e) => {
|
||||
if (e.button !== 0) return
|
||||
e.stopPropagation()
|
||||
onBeforeVertexDrag?.(index + 1, point)
|
||||
const insertedVertex = handleAddVertex(index, point)
|
||||
if (insertedVertex.vertexIndex >= 0) {
|
||||
startDrag({
|
||||
isDragging: true,
|
||||
mode: 'vertex',
|
||||
vertexIndex: insertedVertex.vertexIndex,
|
||||
initialPosition: point,
|
||||
initialPolygon: insertedVertex.polygon,
|
||||
pointerId: e.pointerId,
|
||||
})
|
||||
setHoveredMidpoint(null)
|
||||
}
|
||||
},
|
||||
onPointerEnter: (e) => {
|
||||
e.stopPropagation()
|
||||
setHoveredMidpoint(index)
|
||||
},
|
||||
onPointerLeave: (e) => {
|
||||
e.stopPropagation()
|
||||
setHoveredMidpoint(null)
|
||||
},
|
||||
}
|
||||
|
||||
if (renderMidpointHandle) {
|
||||
return (
|
||||
<Fragment key={`midpoint-${index}`}>
|
||||
{renderMidpointHandle({
|
||||
handleProps,
|
||||
height,
|
||||
index,
|
||||
isHovered,
|
||||
point,
|
||||
position,
|
||||
radius,
|
||||
})}
|
||||
</Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<OutlinedCylinderHandle
|
||||
color={isHovered ? EDGE_ARROW_HOVER_COLOR : EDGE_ARROW_COLOR}
|
||||
height={height}
|
||||
key={`midpoint-${index}`}
|
||||
onClick={(e) => {
|
||||
if (e.button !== 0) return
|
||||
e.stopPropagation()
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
if (e.button !== 0) return
|
||||
e.stopPropagation()
|
||||
const insertedVertex = handleAddVertex(index, [x!, z!])
|
||||
if (insertedVertex.vertexIndex >= 0) {
|
||||
startDrag({
|
||||
isDragging: true,
|
||||
mode: 'vertex',
|
||||
vertexIndex: insertedVertex.vertexIndex,
|
||||
initialPosition: [x!, z!],
|
||||
initialPolygon: insertedVertex.polygon,
|
||||
pointerId: e.pointerId,
|
||||
})
|
||||
setHoveredMidpoint(null)
|
||||
}
|
||||
}}
|
||||
onPointerEnter={(e) => {
|
||||
e.stopPropagation()
|
||||
setHoveredMidpoint(index)
|
||||
}}
|
||||
onPointerLeave={(e) => {
|
||||
e.stopPropagation()
|
||||
setHoveredMidpoint(null)
|
||||
}}
|
||||
{...handleProps}
|
||||
opacity={isHovered ? 1 : 0.7}
|
||||
position={[x!, editY + height / 2, z!]}
|
||||
position={position}
|
||||
radius={radius}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -1,19 +1,319 @@
|
||||
import { type SiteNode, useScene } from '@pascal-app/core'
|
||||
import { useCallback } from 'react'
|
||||
import { PolygonEditor } from '../shared/polygon-editor'
|
||||
import { emitter, type SiteNode, useLiveNodeOverrides, useScene } from '@pascal-app/core'
|
||||
import { SCENE_LAYER } from '@pascal-app/viewer'
|
||||
import { useGLTF } from '@react-three/drei/core/Gltf'
|
||||
import { useFrame } from '@react-three/fiber'
|
||||
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Color, CylinderGeometry, DoubleSide, type Mesh, type Object3D, RingGeometry } from 'three'
|
||||
import { MeshBasicNodeMaterial } from 'three/webgpu'
|
||||
import { EDITOR_LAYER } from '../../../lib/constants'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import { SITE_BOUNDARY_DRAG_LABEL } from '../../../lib/site-boundary'
|
||||
import useEditor, { selectSiteFloorplanContext } from '../../../store/use-editor'
|
||||
import {
|
||||
ARROW_COLOR,
|
||||
ARROW_HOVER_COLOR,
|
||||
NO_RAYCAST,
|
||||
useInvisibleHitAreaMaterial,
|
||||
} from '../../editor/node-arrow-handles'
|
||||
import {
|
||||
PolygonEditor,
|
||||
type PolygonHandleHandlers,
|
||||
type PolygonMidpointHandleRenderProps,
|
||||
type PolygonVertexHandleRenderProps,
|
||||
} from '../shared/polygon-editor'
|
||||
import { SITE_FLAG_MODEL_URL } from './site-flag-model'
|
||||
|
||||
const SITE_FLAG_BASE_Y = 0
|
||||
const SITE_FLAG_HIT_HEIGHT = 0.5
|
||||
const SITE_FLAG_HIT_RADIUS = 0.24
|
||||
const SITE_FLAG_HIT_Y = SITE_FLAG_BASE_Y + SITE_FLAG_HIT_HEIGHT / 2
|
||||
const SITE_FLAG_MODEL_MIN_Y = -0.034798760316334665
|
||||
const SITE_FLAG_SCALE = 0.5
|
||||
const SITE_FLAG_ACTIVE_LIFT = 0.08
|
||||
const SITE_FLAG_MODEL_Y = SITE_FLAG_BASE_Y - SITE_FLAG_MODEL_MIN_Y * SITE_FLAG_SCALE
|
||||
const SITE_FLAG_HALO_INNER_RADIUS = 0.08
|
||||
const SITE_FLAG_HALO_OUTER_RADIUS = SITE_FLAG_HIT_RADIUS * 0.95
|
||||
const SITE_FLAG_HALO_Y = SITE_FLAG_BASE_Y + 0.002
|
||||
const SITE_FLAG_HALO_COLOR = '#6366f1'
|
||||
|
||||
type TintableMaterial = {
|
||||
color?: Color
|
||||
depthWrite: boolean
|
||||
opacity: number
|
||||
needsUpdate: boolean
|
||||
transparent: boolean
|
||||
}
|
||||
|
||||
function SiteFlagModel({
|
||||
active,
|
||||
hovered,
|
||||
opacity = 1,
|
||||
scale = SITE_FLAG_SCALE,
|
||||
}: {
|
||||
active: boolean
|
||||
hovered: boolean
|
||||
opacity?: number
|
||||
scale?: number
|
||||
}) {
|
||||
const { scene } = useGLTF(SITE_FLAG_MODEL_URL, true)
|
||||
const modelRef = useRef<Object3D>(null)
|
||||
const flagScene = useMemo(() => {
|
||||
const cloned = scene.clone(true)
|
||||
|
||||
cloned.traverse((object) => {
|
||||
object.layers.set(SCENE_LAYER)
|
||||
|
||||
if ((object as Mesh).isMesh) {
|
||||
const mesh = object as Mesh
|
||||
mesh.castShadow = false
|
||||
mesh.frustumCulled = false
|
||||
mesh.raycast = NO_RAYCAST
|
||||
mesh.receiveShadow = false
|
||||
mesh.renderOrder = 1010
|
||||
mesh.material = new MeshBasicNodeMaterial({
|
||||
color: new Color(ARROW_COLOR),
|
||||
depthTest: false,
|
||||
depthWrite: opacity >= 0.999,
|
||||
opacity,
|
||||
transparent: opacity < 0.999,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return cloned
|
||||
}, [opacity, scene])
|
||||
|
||||
useEffect(() => {
|
||||
const color = new Color(active || hovered ? ARROW_HOVER_COLOR : ARROW_COLOR)
|
||||
|
||||
flagScene.traverse((object) => {
|
||||
if ((object as Mesh).isMesh) {
|
||||
const mesh = object as Mesh
|
||||
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
|
||||
|
||||
for (const material of materials as Array<MeshBasicNodeMaterial & TintableMaterial>) {
|
||||
material.color?.copy(color)
|
||||
material.opacity = opacity
|
||||
material.transparent = opacity < 0.999
|
||||
material.depthWrite = opacity >= 0.999
|
||||
material.needsUpdate = true
|
||||
}
|
||||
}
|
||||
})
|
||||
}, [active, flagScene, hovered, opacity])
|
||||
|
||||
useFrame((_, delta) => {
|
||||
const model = modelRef.current
|
||||
if (!model) return
|
||||
|
||||
const smoothing = 1 - Math.exp(-delta * 18)
|
||||
const targetY = SITE_FLAG_MODEL_Y + (active ? SITE_FLAG_ACTIVE_LIFT : 0)
|
||||
model.position.y += (targetY - model.position.y) * smoothing
|
||||
})
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
flagScene.traverse((object) => {
|
||||
if ((object as Mesh).isMesh) {
|
||||
const materials = (object as Mesh).material
|
||||
if (Array.isArray(materials)) {
|
||||
materials.forEach((material) => {
|
||||
material.dispose()
|
||||
})
|
||||
} else {
|
||||
materials.dispose()
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
[flagScene],
|
||||
)
|
||||
|
||||
return (
|
||||
<primitive
|
||||
object={flagScene}
|
||||
position={[0, SITE_FLAG_MODEL_Y, 0]}
|
||||
ref={modelRef}
|
||||
scale={scale}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SiteFlagHoverHalo({ visible }: { visible: boolean }) {
|
||||
const haloRef = useRef<Mesh>(null)
|
||||
const geometry = useMemo(() => {
|
||||
const nextGeometry = new RingGeometry(
|
||||
SITE_FLAG_HALO_INNER_RADIUS,
|
||||
SITE_FLAG_HALO_OUTER_RADIUS,
|
||||
56,
|
||||
)
|
||||
nextGeometry.rotateX(-Math.PI / 2)
|
||||
return nextGeometry
|
||||
}, [])
|
||||
const material = useMemo(
|
||||
() =>
|
||||
new MeshBasicNodeMaterial({
|
||||
color: new Color(SITE_FLAG_HALO_COLOR),
|
||||
depthTest: true,
|
||||
depthWrite: false,
|
||||
opacity: 0,
|
||||
side: DoubleSide,
|
||||
transparent: true,
|
||||
}),
|
||||
[],
|
||||
)
|
||||
|
||||
useFrame((_, delta) => {
|
||||
const mesh = haloRef.current
|
||||
if (!mesh) return
|
||||
|
||||
const smoothing = 1 - Math.exp(-delta * 18)
|
||||
const targetOpacity = visible ? 0.52 : 0
|
||||
const targetScale = visible ? 1 : 0.08
|
||||
const nextOpacity = material.opacity + (targetOpacity - material.opacity) * smoothing
|
||||
const nextScale = mesh.scale.x + (targetScale - mesh.scale.x) * smoothing
|
||||
|
||||
material.opacity = nextOpacity
|
||||
mesh.scale.setScalar(nextScale)
|
||||
mesh.visible = visible || nextOpacity > 0.01
|
||||
})
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
geometry.dispose()
|
||||
material.dispose()
|
||||
},
|
||||
[geometry, material],
|
||||
)
|
||||
|
||||
return (
|
||||
<mesh
|
||||
frustumCulled={false}
|
||||
geometry={geometry}
|
||||
layers={SCENE_LAYER}
|
||||
material={material}
|
||||
position={[0, SITE_FLAG_HALO_Y, 0]}
|
||||
raycast={NO_RAYCAST}
|
||||
ref={haloRef}
|
||||
renderOrder={1009}
|
||||
scale={0.08}
|
||||
visible={false}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SiteFlagFallback({
|
||||
active,
|
||||
hovered,
|
||||
opacity = 1,
|
||||
}: {
|
||||
active: boolean
|
||||
hovered: boolean
|
||||
opacity?: number
|
||||
}) {
|
||||
const color = active || hovered ? ARROW_HOVER_COLOR : ARROW_COLOR
|
||||
|
||||
return (
|
||||
<group position={[0, SITE_FLAG_BASE_Y + (active ? SITE_FLAG_ACTIVE_LIFT : 0), 0]}>
|
||||
<mesh layers={SCENE_LAYER} position={[0, 0.16, 0]} raycast={NO_RAYCAST}>
|
||||
<cylinderGeometry args={[0.11, 0.16, 0.32, 24]} />
|
||||
<meshBasicMaterial color={color} depthTest={false} opacity={opacity} transparent />
|
||||
</mesh>
|
||||
<mesh layers={SCENE_LAYER} position={[0, 0.34, 0]} raycast={NO_RAYCAST}>
|
||||
<cylinderGeometry args={[0.04, 0.11, 0.14, 24]} />
|
||||
<meshBasicMaterial color={color} depthTest={false} opacity={opacity} transparent />
|
||||
</mesh>
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
function SiteBoundaryFlagHandle({
|
||||
active = false,
|
||||
baseY,
|
||||
handleProps,
|
||||
hovered,
|
||||
modelOpacity = 1,
|
||||
modelScale = SITE_FLAG_SCALE,
|
||||
modelVisible = true,
|
||||
point,
|
||||
}: {
|
||||
active?: boolean
|
||||
baseY: number
|
||||
handleProps: PolygonHandleHandlers
|
||||
hovered: boolean
|
||||
modelOpacity?: number
|
||||
modelScale?: number
|
||||
modelVisible?: boolean
|
||||
point: [number, number]
|
||||
}) {
|
||||
const hitGeometry = useMemo(
|
||||
() =>
|
||||
new CylinderGeometry(SITE_FLAG_HIT_RADIUS, SITE_FLAG_HIT_RADIUS, SITE_FLAG_HIT_HEIGHT, 24),
|
||||
[],
|
||||
)
|
||||
const hitMaterial = useInvisibleHitAreaMaterial()
|
||||
|
||||
useEffect(() => () => hitGeometry.dispose(), [hitGeometry])
|
||||
|
||||
return (
|
||||
<group position={[point[0], baseY, point[1]]}>
|
||||
<SiteFlagHoverHalo visible={active || hovered} />
|
||||
{(modelVisible || active || hovered) && (
|
||||
<Suspense
|
||||
fallback={<SiteFlagFallback active={active} hovered={hovered} opacity={modelOpacity} />}
|
||||
>
|
||||
<SiteFlagModel
|
||||
active={active}
|
||||
hovered={hovered}
|
||||
opacity={modelOpacity}
|
||||
scale={modelScale}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
<mesh
|
||||
frustumCulled={false}
|
||||
geometry={hitGeometry}
|
||||
layers={EDITOR_LAYER}
|
||||
material={hitMaterial}
|
||||
position={[0, SITE_FLAG_HIT_Y, 0]}
|
||||
renderOrder={1012}
|
||||
{...handleProps}
|
||||
/>
|
||||
</group>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Site boundary editor - allows editing site polygon when in site phase
|
||||
* Uses the generic PolygonEditor component
|
||||
*/
|
||||
export const SiteBoundaryEditor: React.FC = () => {
|
||||
const nodes = useScene((state) => state.nodes)
|
||||
const rootNodeIds = useScene((state) => state.rootNodeIds)
|
||||
const updateNode = useScene((state) => state.updateNode)
|
||||
const phase = useEditor((state) => state.phase)
|
||||
const mode = useEditor((state) => state.mode)
|
||||
const [hoveredVertex, setHoveredVertex] = useState<number | null>(null)
|
||||
const [hoveredMidpoint, setHoveredMidpoint] = useState<number | null>(null)
|
||||
|
||||
// Get the site node (first root node)
|
||||
const siteNode = rootNodeIds[0] ? nodes[rootNodeIds[0]] : null
|
||||
const site = siteNode?.type === 'site' ? (siteNode as SiteNode) : null
|
||||
const siteId = site?.id
|
||||
const isSiteEditing = phase === 'site'
|
||||
const showSiteHandles = isSiteEditing || mode === 'select'
|
||||
const isSiteBoundaryHighlighted =
|
||||
isSiteEditing || hoveredVertex !== null || hoveredMidpoint !== null
|
||||
const [isDraggingSiteBoundary, setIsDraggingSiteBoundary] = useState(false)
|
||||
const isDraggingSiteBoundaryRef = useRef(false)
|
||||
const livePolygon = useLiveNodeOverrides(
|
||||
useCallback(
|
||||
(state) => {
|
||||
if (!siteId) return null
|
||||
return (state.overrides.get(siteId)?.polygon as SiteNode['polygon'] | undefined) ?? null
|
||||
},
|
||||
[siteId],
|
||||
),
|
||||
)
|
||||
const displayPolygon =
|
||||
!(isDraggingSiteBoundary || isDraggingSiteBoundaryRef.current) && livePolygon?.points
|
||||
? livePolygon.points
|
||||
: site?.polygon?.points
|
||||
|
||||
const handlePolygonChange = useCallback(
|
||||
(newPolygon: Array<[number, number]>) => {
|
||||
@@ -29,14 +329,166 @@ export const SiteBoundaryEditor: React.FC = () => {
|
||||
[site, updateNode],
|
||||
)
|
||||
|
||||
if (!site?.polygon?.points || site.polygon.points.length < 3) return null
|
||||
const handlePolygonPreview = useCallback(
|
||||
(previewPolygon: ReadonlyArray<readonly [number, number]> | null) => {
|
||||
if (!siteId) return
|
||||
|
||||
if (!previewPolygon) {
|
||||
useLiveNodeOverrides.getState().clearFields(siteId, ['polygon'])
|
||||
return
|
||||
}
|
||||
|
||||
useLiveNodeOverrides.getState().set(siteId, {
|
||||
polygon: {
|
||||
type: 'polygon',
|
||||
points: previewPolygon.map(([x, z]) => [x, z] as [number, number]),
|
||||
},
|
||||
})
|
||||
},
|
||||
[siteId],
|
||||
)
|
||||
|
||||
const handleSiteBoundaryDragChange = useCallback(
|
||||
(isDragging: boolean) => {
|
||||
isDraggingSiteBoundaryRef.current = isDragging
|
||||
setIsDraggingSiteBoundary(isDragging)
|
||||
|
||||
if (!siteId) return
|
||||
|
||||
const editor = useEditor.getState()
|
||||
if (isDragging) {
|
||||
editor.setActiveHandleDrag({ nodeId: siteId, label: SITE_BOUNDARY_DRAG_LABEL })
|
||||
} else if (
|
||||
editor.activeHandleDrag?.nodeId === siteId &&
|
||||
editor.activeHandleDrag.label === SITE_BOUNDARY_DRAG_LABEL
|
||||
) {
|
||||
editor.setActiveHandleDrag(null)
|
||||
}
|
||||
|
||||
if (!isDragging) {
|
||||
useLiveNodeOverrides.getState().clearFields(siteId, ['polygon'])
|
||||
}
|
||||
},
|
||||
[siteId],
|
||||
)
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (!siteId) return
|
||||
const editor = useEditor.getState()
|
||||
if (
|
||||
editor.activeHandleDrag?.nodeId === siteId &&
|
||||
editor.activeHandleDrag.label === SITE_BOUNDARY_DRAG_LABEL
|
||||
) {
|
||||
editor.setActiveHandleDrag(null)
|
||||
}
|
||||
useLiveNodeOverrides.getState().clearFields(siteId, ['polygon'])
|
||||
isDraggingSiteBoundaryRef.current = false
|
||||
},
|
||||
[siteId],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (isSiteEditing && siteId) {
|
||||
selectSiteFloorplanContext()
|
||||
}
|
||||
}, [isSiteEditing, siteId])
|
||||
|
||||
const activateSiteEditing = useCallback(() => {
|
||||
isDraggingSiteBoundaryRef.current = true
|
||||
setIsDraggingSiteBoundary(true)
|
||||
if (useEditor.getState().phase !== 'site') {
|
||||
useEditor.setState({ catalogCategory: null, mode: 'select', phase: 'site', tool: null })
|
||||
}
|
||||
selectSiteFloorplanContext()
|
||||
}, [])
|
||||
|
||||
const exitSiteEditing = useCallback(() => {
|
||||
const editor = useEditor.getState()
|
||||
editor.setPhase('structure')
|
||||
editor.setStructureLayer('elements')
|
||||
editor.setMode('select')
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSiteEditing) return
|
||||
|
||||
const onGridClick = () => {
|
||||
if (useEditor.getState().phase !== 'site') return
|
||||
exitSiteEditing()
|
||||
}
|
||||
|
||||
emitter.on('grid:click', onGridClick)
|
||||
return () => {
|
||||
emitter.off('grid:click', onGridClick)
|
||||
}
|
||||
}, [exitSiteEditing, isSiteEditing])
|
||||
|
||||
const renderSiteFlagVertex = useCallback(
|
||||
({
|
||||
handleProps,
|
||||
height,
|
||||
isDragging,
|
||||
isHovered,
|
||||
point,
|
||||
position,
|
||||
}: PolygonVertexHandleRenderProps) => {
|
||||
const baseY = position[1] - height / 2
|
||||
|
||||
return (
|
||||
<SiteBoundaryFlagHandle
|
||||
active={isDragging}
|
||||
baseY={baseY}
|
||||
handleProps={handleProps}
|
||||
hovered={isHovered}
|
||||
point={point}
|
||||
/>
|
||||
)
|
||||
},
|
||||
[],
|
||||
)
|
||||
const renderSiteFlagMidpoint = useCallback(
|
||||
({ handleProps, height, isHovered, point, position }: PolygonMidpointHandleRenderProps) => {
|
||||
const baseY = position[1] - height / 2
|
||||
|
||||
return (
|
||||
<SiteBoundaryFlagHandle
|
||||
baseY={baseY}
|
||||
handleProps={handleProps}
|
||||
hovered={isHovered}
|
||||
modelOpacity={0.48}
|
||||
modelScale={SITE_FLAG_SCALE * 0.92}
|
||||
modelVisible={isHovered}
|
||||
point={point}
|
||||
/>
|
||||
)
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
if (!displayPolygon || displayPolygon.length < 3) return null
|
||||
if (!showSiteHandles) return null
|
||||
|
||||
return (
|
||||
<PolygonEditor
|
||||
color="#10b981"
|
||||
color={isSiteBoundaryHighlighted ? ARROW_COLOR : '#10b981'}
|
||||
minVertices={3}
|
||||
onBeforeVertexDrag={activateSiteEditing}
|
||||
onDragCommit={() => {
|
||||
sfxEmitter.emit('sfx:item-place')
|
||||
exitSiteEditing()
|
||||
}}
|
||||
onDragStart={() => sfxEmitter.emit('sfx:item-pick')}
|
||||
onDragStateChange={handleSiteBoundaryDragChange}
|
||||
onMidpointHoverChange={setHoveredMidpoint}
|
||||
onPolygonChange={handlePolygonChange}
|
||||
polygon={site.polygon.points}
|
||||
onPolygonPreview={handlePolygonPreview}
|
||||
onVertexHoverChange={setHoveredVertex}
|
||||
polygon={displayPolygon}
|
||||
renderMidpointHandle={renderSiteFlagMidpoint}
|
||||
renderVertexHandle={renderSiteFlagVertex}
|
||||
showBorderLine={isSiteBoundaryHighlighted}
|
||||
showMidpointHandles={showSiteHandles}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
const SITE_FLAG_MODEL_BASE64 = [
|
||||
'Z2xURgIAAADgAwAAAAMAAEpTT057ImFzc2V0Ijp7ImdlbmVyYXRvciI6ImdsVEYtVHJhbnNmb3JtIHY0LjMuMCIsInZlcnNpb24i',
|
||||
'OiIyLjAifSwiYWNjZXNzb3JzIjpbeyJ0eXBlIjoiU0NBTEFSIiwiY29tcG9uZW50VHlwZSI6NTEyMywiY291bnQiOjE2OH0seyJ0',
|
||||
'eXBlIjoiVkVDMyIsImNvbXBvbmVudFR5cGUiOjUxMjYsImNvdW50IjozMCwibWF4IjpbMC4wMDY3MzM3MTM2NzE1NjUwNTYsMC4w',
|
||||
'MDYyNDAyODE3NDIwNjYxNDUsMC4wMDAzNDc5ODc2MDMxNjMzNDY2NV0sIm1pbiI6Wy0wLjAwMDMwMTM2NjExODk0NzA0NCwtMC4w',
|
||||
'MDAzNDc5ODc2MDMxNjMzNDY2NSwtMC4wMDAzNDc5ODc4NjUwOTc4MjA3Nl19XSwiYnVmZmVyVmlld3MiOlt7ImJ1ZmZlciI6MCwi',
|
||||
'Ynl0ZU9mZnNldCI6MCwiYnl0ZUxlbmd0aCI6MTkzfV0sImJ1ZmZlcnMiOlt7ImJ5dGVMZW5ndGgiOjE5M31dLCJtZXNoZXMiOlt7',
|
||||
'InByaW1pdGl2ZXMiOlt7ImF0dHJpYnV0ZXMiOnsiUE9TSVRJT04iOjF9LCJtb2RlIjo0LCJpbmRpY2VzIjowLCJleHRlbnNpb25z',
|
||||
'Ijp7IktIUl9kcmFjb19tZXNoX2NvbXByZXNzaW9uIjp7ImJ1ZmZlclZpZXciOjAsImF0dHJpYnV0ZXMiOnsiUE9TSVRJT04iOjB9',
|
||||
'fX19XX1dLCJub2RlcyI6W3sic2NhbGUiOlsxMDAsMTAwLDEwMF0sIm1lc2giOjB9XSwic2NlbmVzIjpbeyJub2RlcyI6WzBdfV0s',
|
||||
'InNjZW5lIjowLCJleHRlbnNpb25zVXNlZCI6WyJLSFJfZHJhY29fbWVzaF9jb21wcmVzc2lvbiJdLCJleHRlbnNpb25zUmVxdWly',
|
||||
'ZWQiOlsiS0hSX2RyYWNvX21lc2hfY29tcHJlc3Npb24iXX0gICDEAAAAQklOAERSQUNPAgIBAQAAAB44ADcDAA7f6qkv1VVRZfUo',
|
||||
'1qKKAgEBEAH/AAABAAkDAAACAQEBAA0DEREXaQaJCEUEiQg1EwsuVMV2v4nwGqm5Tl7xigAAAFMDACwAZQAcaFlXAMoAAJS7Umpl',
|
||||
'CAIAyqteasnAqAAAAEt9IJaxqKVWGb4CAIBXSq0MAKDUyQAA/CcAQABAmVEBAADA1KdWGV4BKPOK/1QGgAWQDAAAAAD/DwAArQCe',
|
||||
'uRtytrkkcra5h4bmOwwAAAA=',
|
||||
].join('')
|
||||
|
||||
export const SITE_FLAG_MODEL_URL = `data:model/gltf-binary;base64,${SITE_FLAG_MODEL_BASE64}`
|
||||
@@ -90,8 +90,9 @@ export const ToolManager: React.FC = () => {
|
||||
| CeilingNode['id']
|
||||
| undefined
|
||||
|
||||
// Show site boundary editor when in site phase (toggle controls entry/exit)
|
||||
const showSiteBoundaryEditor = phase === 'site'
|
||||
// Keep the site vertex flags available in select mode; the editor component
|
||||
// switches to full polygon editing only after a flag activates site mode.
|
||||
const showSiteBoundaryEditor = phase === 'site' || mode === 'select'
|
||||
|
||||
// Show slab boundary editor when in structure/select mode with a slab selected (but not editing a hole)
|
||||
const showSlabBoundaryEditor =
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useViewer } from '@pascal-app/viewer'
|
||||
import { type LucideIcon, Trash2 } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import { cn } from './../../../lib/utils'
|
||||
import useEditor from './../../../store/use-editor'
|
||||
import useEditor, { selectSiteFloorplanContext } from './../../../store/use-editor'
|
||||
import { ActionButton } from './action-button'
|
||||
|
||||
type ControlId = 'select' | 'box-select' | 'site-edit' | 'zone' | 'delete'
|
||||
@@ -34,7 +34,7 @@ const controls: ControlConfig[] = [
|
||||
},
|
||||
{
|
||||
id: 'site-edit',
|
||||
imageSrc: '/icons/site.png',
|
||||
imageSrc: '/icons/site-flag.png',
|
||||
label: 'Edit site',
|
||||
color: 'hover:bg-white/5',
|
||||
activeColor: 'bg-white/10 hover:bg-white/10',
|
||||
@@ -100,12 +100,8 @@ export function ControlModes() {
|
||||
setMode('select')
|
||||
setStructureLayer('elements')
|
||||
} else if (isGroundFloor) {
|
||||
// Enter site editing — set state directly to preserve level selection.
|
||||
// setPhase('site') calls viewer.resetSelection() which clears levelId,
|
||||
// breaking the 2D floorplan (it needs a level to render the SVG).
|
||||
useEditor.setState({ phase: 'site', mode: 'select', tool: null, catalogCategory: null })
|
||||
// Clear object selection so the polygon editor handles receive pointer events
|
||||
useViewer.getState().setSelection({ selectedIds: [] })
|
||||
selectSiteFloorplanContext()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -23,6 +23,6 @@ export const tools: ToolConfig[] = [
|
||||
{ id: 'slab', iconSrc: '/icons/floor.png', label: 'Slab' },
|
||||
{ id: 'ceiling', iconSrc: '/icons/ceiling.png', label: 'Ceiling' },
|
||||
{ id: 'zone', iconSrc: '/icons/zone.png', label: 'Zone' },
|
||||
{ id: 'spawn', iconSrc: '/icons/site.png', label: 'Spawn Point' },
|
||||
{ id: 'spawn', iconSrc: '/icons/spawn-point.png', label: 'Spawn Point' },
|
||||
{ id: 'shelf', iconSrc: '/icons/shelf.png', label: 'Shelf' },
|
||||
]
|
||||
|
||||
@@ -1569,7 +1569,7 @@ export function SitePanel({ projectId, onUploadAsset, onDeleteAsset }: SitePanel
|
||||
'h-5 w-5 object-contain transition-all',
|
||||
phase !== 'site' && 'opacity-60 grayscale',
|
||||
)}
|
||||
src="/icons/site.png"
|
||||
src="/icons/site-flag.png"
|
||||
/>
|
||||
<span className="font-medium text-sm">{siteNode.name || 'Site'}</span>
|
||||
</div>
|
||||
|
||||
@@ -50,7 +50,13 @@ export const SpawnTreeNode = memo(function SpawnTreeNode({
|
||||
expanded={false}
|
||||
hasChildren={false}
|
||||
icon={
|
||||
<Image alt="" className="object-contain" height={14} src="/icons/site.png" width={14} />
|
||||
<Image
|
||||
alt=""
|
||||
className="object-contain"
|
||||
height={14}
|
||||
src="/icons/spawn-point.png"
|
||||
width={14}
|
||||
/>
|
||||
}
|
||||
isHovered={isHovered}
|
||||
isLast={isLast}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const SITE_BOUNDARY_DRAG_LABEL = 'site-boundary'
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import type { AssetInput } from '@pascal-app/core'
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
type BuildingNode,
|
||||
type CeilingNode,
|
||||
@@ -422,6 +423,10 @@ export const DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE: PersistedEditorLayoutState =
|
||||
|
||||
const GRID_SNAP_STEPS: GridSnapStep[] = [0.5, 0.25, 0.1, 0.05]
|
||||
|
||||
type SelectDefaultBuildingAndLevelOptions = {
|
||||
forceGroundLevel?: boolean
|
||||
}
|
||||
|
||||
function normalizeModeForPhase(phase: Phase, mode: Mode | undefined): Mode {
|
||||
if (phase === 'site') {
|
||||
return 'select'
|
||||
@@ -558,49 +563,94 @@ export function hasCustomPersistedEditorUiState(
|
||||
)
|
||||
}
|
||||
|
||||
function getDefaultLevelId(
|
||||
buildingNode: BuildingNode,
|
||||
nodes: Record<string, AnyNode>,
|
||||
): LevelNode['id'] | null {
|
||||
const levels = buildingNode.children
|
||||
.map((childId) => nodes[childId as AnyNodeId])
|
||||
.filter((node): node is LevelNode => node?.type === 'level')
|
||||
|
||||
if (levels.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const groundLevel = levels.find((level) => level.level === 0)
|
||||
if (groundLevel) {
|
||||
return groundLevel.id
|
||||
}
|
||||
|
||||
const firstLevel = levels[0]
|
||||
if (!firstLevel) {
|
||||
return null
|
||||
}
|
||||
|
||||
let lowestLevel = firstLevel
|
||||
for (const level of levels.slice(1)) {
|
||||
if (level.level < lowestLevel.level) {
|
||||
lowestLevel = level
|
||||
}
|
||||
}
|
||||
|
||||
return lowestLevel.id
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects the first building and level 0 in the scene.
|
||||
* Safe to call any time — no-ops if already selected or scene is empty.
|
||||
*/
|
||||
export function selectDefaultBuildingAndLevel() {
|
||||
export function selectDefaultBuildingAndLevel(options: SelectDefaultBuildingAndLevelOptions = {}) {
|
||||
const viewer = useViewer.getState()
|
||||
const scene = useScene.getState()
|
||||
|
||||
let buildingId = viewer.selection.buildingId
|
||||
const selectedBuilding = viewer.selection.buildingId
|
||||
? scene.nodes[viewer.selection.buildingId]
|
||||
: null
|
||||
let buildingNode =
|
||||
selectedBuilding?.type === 'building' ? (selectedBuilding as BuildingNode) : null
|
||||
|
||||
// If no building selected, find the first one from site's children
|
||||
if (!buildingId) {
|
||||
if (!buildingNode) {
|
||||
const siteNode = scene.rootNodeIds[0] ? scene.nodes[scene.rootNodeIds[0]] : null
|
||||
if (siteNode?.type === 'site') {
|
||||
const firstBuilding = siteNode.children
|
||||
buildingNode =
|
||||
siteNode.children
|
||||
.map((childId) => scene.nodes[childId as AnyNodeId])
|
||||
.find((node) => node?.type === 'building')
|
||||
if (firstBuilding) {
|
||||
buildingId = firstBuilding.id as BuildingNode['id']
|
||||
viewer.setSelection({ buildingId })
|
||||
}
|
||||
.find((node): node is BuildingNode => node?.type === 'building') ?? null
|
||||
}
|
||||
}
|
||||
|
||||
// If no level selected, find level 0 in the building
|
||||
if (buildingId && !viewer.selection.levelId) {
|
||||
const buildingNode = scene.nodes[buildingId] as BuildingNode
|
||||
const level0Id = buildingNode.children.find((childId) => {
|
||||
const levelNode = scene.nodes[childId] as LevelNode
|
||||
return levelNode?.type === 'level' && levelNode.level === 0
|
||||
if (!buildingNode) {
|
||||
return
|
||||
}
|
||||
|
||||
const selectedLevel = viewer.selection.levelId ? scene.nodes[viewer.selection.levelId] : null
|
||||
const selectedLevelBelongsToBuilding =
|
||||
selectedLevel?.type === 'level' && selectedLevel.parentId === buildingNode.id
|
||||
const shouldSelectDefaultLevel = options.forceGroundLevel || !selectedLevelBelongsToBuilding
|
||||
const defaultLevelId = shouldSelectDefaultLevel
|
||||
? getDefaultLevelId(buildingNode, scene.nodes as Record<string, AnyNode>)
|
||||
: null
|
||||
|
||||
const selectionUpdate: Parameters<typeof viewer.setSelection>[0] = {}
|
||||
if (viewer.selection.buildingId !== buildingNode.id) {
|
||||
selectionUpdate.buildingId = buildingNode.id
|
||||
}
|
||||
if (defaultLevelId) {
|
||||
selectionUpdate.levelId = defaultLevelId
|
||||
}
|
||||
|
||||
if (Object.keys(selectionUpdate).length > 0) {
|
||||
viewer.setSelection(selectionUpdate)
|
||||
}
|
||||
}
|
||||
|
||||
export function selectSiteFloorplanContext() {
|
||||
selectDefaultBuildingAndLevel({ forceGroundLevel: true })
|
||||
useViewer.getState().setSelection({
|
||||
selectedIds: [],
|
||||
zoneId: null,
|
||||
})
|
||||
if (level0Id) {
|
||||
viewer.setSelection({ levelId: level0Id as LevelNode['id'] })
|
||||
} else {
|
||||
// Fallback to first level if level 0 doesn't exist
|
||||
const firstLevelId = buildingNode.children.find(
|
||||
(childId) => scene.nodes[childId]?.type === 'level',
|
||||
)
|
||||
if (firstLevelId) {
|
||||
viewer.setSelection({ levelId: firstLevelId as LevelNode['id'] })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const useEditor = create<EditorState>()(
|
||||
@@ -631,12 +681,9 @@ const useEditor = create<EditorState>()(
|
||||
set({ mode: 'select', tool: null, catalogCategory: null })
|
||||
}
|
||||
|
||||
const viewer = useViewer.getState()
|
||||
|
||||
switch (phase) {
|
||||
case 'site':
|
||||
// In Site mode, we zoom out and deselect specific levels/buildings
|
||||
viewer.resetSelection()
|
||||
selectSiteFloorplanContext()
|
||||
break
|
||||
|
||||
case 'structure':
|
||||
|
||||
@@ -39,7 +39,7 @@ export const siteDefinition: NodeDefinition<typeof SiteNode> = {
|
||||
presentation: {
|
||||
label: 'Site',
|
||||
description: 'The top-level container holding buildings, zones, and the property boundary.',
|
||||
icon: { kind: 'url', src: '/icons/site.png' },
|
||||
icon: { kind: 'url', src: '/icons/site-flag.png' },
|
||||
paletteSection: 'site',
|
||||
paletteOrder: 5,
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
type AnyNodeId,
|
||||
type SiteNode,
|
||||
type SlabNode,
|
||||
useLiveNodeOverrides,
|
||||
useRegistry,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
@@ -51,6 +52,10 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => {
|
||||
useRegistry(node.id, 'site', ref)
|
||||
|
||||
const bgColor = useViewer((state) => getSceneTheme(state.sceneTheme).ground)
|
||||
const livePolygon = useLiveNodeOverrides(
|
||||
(state) => (state.overrides.get(node.id)?.polygon as SiteNode['polygon'] | undefined) ?? null,
|
||||
)
|
||||
const polygonPoints = livePolygon?.points ?? node.polygon?.points
|
||||
|
||||
// Lit (not Basic) so the site ground receives the directional shadow — Basic
|
||||
// is unlit, which is why shadows used to stop dead at the slab edge. polygonOffset
|
||||
@@ -102,9 +107,9 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => {
|
||||
|
||||
// Ground shape: site polygon with slab footprints punched as holes
|
||||
const groundShape = useMemo(() => {
|
||||
if (!node?.polygon?.points || node.polygon.points.length < 3) return null
|
||||
if (!polygonPoints || polygonPoints.length < 3) return null
|
||||
|
||||
const pts = node.polygon.points
|
||||
const pts = polygonPoints
|
||||
const shape = new Shape()
|
||||
shape.moveTo(pts[0]![0], -pts[0]![1])
|
||||
for (let i = 1; i < pts.length; i++) shape.lineTo(pts[i]![0], -pts[i]![1])
|
||||
@@ -122,13 +127,13 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => {
|
||||
}
|
||||
|
||||
return shape
|
||||
}, [node?.polygon?.points, slabPolygons])
|
||||
}, [polygonPoints, slabPolygons])
|
||||
|
||||
// Create boundary line geometry
|
||||
const lineGeometry = useMemo(() => {
|
||||
if (!node?.polygon?.points || node.polygon.points.length < 2) return null
|
||||
return createBoundaryLineGeometry(node.polygon.points)
|
||||
}, [node?.polygon?.points])
|
||||
if (!polygonPoints || polygonPoints.length < 2) return null
|
||||
return createBoundaryLineGeometry(polygonPoints)
|
||||
}, [polygonPoints])
|
||||
|
||||
const handlers = useNodeEvents(node, 'site')
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ describe('spawn definition', () => {
|
||||
])
|
||||
})
|
||||
|
||||
test('floorplan uses indigo marker color and selected rotation affordance', () => {
|
||||
test('floorplan uses footprint marker oriented to the spawn view and selected rotation affordance', () => {
|
||||
const spawn = SpawnNode.parse({
|
||||
id: 'spawn_test1234567890ab',
|
||||
position: [1, 0, 2],
|
||||
@@ -102,8 +102,15 @@ describe('spawn definition', () => {
|
||||
},
|
||||
} satisfies GeometryContext)
|
||||
|
||||
expect(geometry.kind).toBe('group')
|
||||
const marker = geometry.kind === 'group' ? geometry.children[0] : null
|
||||
expect(marker?.kind).toBe('group')
|
||||
if (marker?.kind === 'group') {
|
||||
expect(marker.transform?.rotate).toBe(-spawn.rotation)
|
||||
}
|
||||
|
||||
const flat = flattenFloorplan(geometry)
|
||||
expect(flat.some((entry) => entry.kind === 'polygon' && entry.fill === '#818cf8')).toBe(true)
|
||||
expect(flat.some((entry) => entry.kind === 'path' && entry.stroke === '#818cf8')).toBe(true)
|
||||
expect(flat.some((entry) => entry.kind === 'rotate-arrow')).toBe(true)
|
||||
})
|
||||
|
||||
|
||||
@@ -104,7 +104,7 @@ export const spawnDefinition: NodeDefinition<typeof SpawnNode> = {
|
||||
presentation: {
|
||||
label: 'Spawn Point',
|
||||
description: 'Player or camera origin within a level. One per level.',
|
||||
icon: { kind: 'url', src: '/icons/site.png' },
|
||||
icon: { kind: 'url', src: '/icons/spawn-point.png' },
|
||||
paletteSection: 'structure',
|
||||
paletteOrder: 90, // bottom of structure list — matches legacy palette order
|
||||
},
|
||||
|
||||
@@ -2,12 +2,45 @@ import type { FloorplanGeometry, FloorplanPoint, GeometryContext } from '@pascal
|
||||
import type { SpawnNode } from './schema'
|
||||
|
||||
const SPAWN_COLOR = '#818cf8'
|
||||
const SPAWN_MARKER_HIT_RADIUS = 0.52
|
||||
const FOOTPRINT_ICON_SCALE = 0.045
|
||||
const FOOTPRINT_ICON_CENTER = 12
|
||||
const ROTATE_ARROW_CORNER_OFFSET = 0.22
|
||||
|
||||
const iconPoint = (x: number, y: number) =>
|
||||
`${formatIconCoord(x - FOOTPRINT_ICON_CENTER)} ${formatIconCoord(y - FOOTPRINT_ICON_CENTER)}`
|
||||
const iconY = (y: number) => formatIconCoord(y - FOOTPRINT_ICON_CENTER)
|
||||
const iconX = (x: number) => formatIconCoord(x - FOOTPRINT_ICON_CENTER)
|
||||
const iconArcRadius = formatIconCoord(2)
|
||||
|
||||
const FOOTPRINT_LEFT_PATH = [
|
||||
`M ${iconPoint(4, 16)}`,
|
||||
`V ${iconY(13.62)}`,
|
||||
`C ${iconPoint(4, 11.5)} ${iconPoint(2.97, 10.5)} ${iconPoint(3, 8)}`,
|
||||
`C ${iconPoint(3.03, 5.28)} ${iconPoint(4.49, 2)} ${iconPoint(7.5, 2)}`,
|
||||
`C ${iconPoint(9.37, 2)} ${iconPoint(10, 3.8)} ${iconPoint(10, 5.5)}`,
|
||||
`C ${iconPoint(10, 8.61)} ${iconPoint(8, 11.16)} ${iconPoint(8, 14.18)}`,
|
||||
`V ${iconY(16)}`,
|
||||
`A ${iconArcRadius} ${iconArcRadius} 0 1 1 ${iconPoint(4, 16)}`,
|
||||
'Z',
|
||||
].join(' ')
|
||||
|
||||
const FOOTPRINT_RIGHT_PATH = [
|
||||
`M ${iconPoint(20, 20)}`,
|
||||
`V ${iconY(17.62)}`,
|
||||
`C ${iconPoint(20, 15.5)} ${iconPoint(21.03, 14.5)} ${iconPoint(21, 12)}`,
|
||||
`C ${iconPoint(20.97, 9.28)} ${iconPoint(19.51, 6)} ${iconPoint(16.5, 6)}`,
|
||||
`C ${iconPoint(14.63, 6)} ${iconPoint(14, 7.8)} ${iconPoint(14, 9.5)}`,
|
||||
`C ${iconPoint(14, 12.61)} ${iconPoint(16, 15.16)} ${iconPoint(16, 18.18)}`,
|
||||
`V ${iconY(20)}`,
|
||||
`A ${iconArcRadius} ${iconArcRadius} 0 1 0 ${iconPoint(20, 20)}`,
|
||||
'Z',
|
||||
].join(' ')
|
||||
|
||||
/**
|
||||
* 2D floor-plan marker for a spawn point. A small filled circle at the
|
||||
* spawn's position, with a triangular arrow indicating the facing
|
||||
* direction (rotation around Y, looking down at the X-Z plane).
|
||||
* 2D floor-plan marker for a spawn point. Uses the same footprint icon
|
||||
* as the walkthrough-mode control and rotates it toward the spawn's
|
||||
* first-person starting view.
|
||||
*
|
||||
* Color matches the 3D renderer's indigo spawn material so the user
|
||||
* sees the same visual identity in both views.
|
||||
@@ -17,36 +50,63 @@ const ROTATE_ARROW_CORNER_OFFSET = 0.22
|
||||
export function buildSpawnFloorplan(node: SpawnNode, ctx: GeometryContext): FloorplanGeometry {
|
||||
const [px, , pz] = node.position
|
||||
const ry = node.rotation
|
||||
const planRotation = -ry
|
||||
const isSelected = ctx.viewState?.selected ?? false
|
||||
|
||||
const children: FloorplanGeometry[] = [
|
||||
{
|
||||
kind: 'group',
|
||||
transform: { translate: [px, pz], rotate: ry },
|
||||
transform: { translate: [px, pz], rotate: planRotation },
|
||||
children: [
|
||||
// Direction-pointing triangle, base centered at origin, tip in -Z
|
||||
// (forward). Matches the 3D arrow's orientation.
|
||||
{
|
||||
kind: 'polygon',
|
||||
points: [
|
||||
[0, -0.28],
|
||||
[-0.18, 0.12],
|
||||
[0.18, 0.12],
|
||||
],
|
||||
fill: SPAWN_COLOR,
|
||||
opacity: 0.85,
|
||||
},
|
||||
// Spawn body marker — circle outline so the spawn is legible at
|
||||
// small zoom levels where the triangle would shrink past visibility.
|
||||
{
|
||||
kind: 'circle',
|
||||
cx: 0,
|
||||
cy: 0,
|
||||
r: 0.34,
|
||||
r: SPAWN_MARKER_HIT_RADIUS,
|
||||
fill: 'transparent',
|
||||
pointerEvents: 'all',
|
||||
},
|
||||
{
|
||||
kind: 'path',
|
||||
d: FOOTPRINT_LEFT_PATH,
|
||||
stroke: SPAWN_COLOR,
|
||||
strokeWidth: 0.025,
|
||||
fill: SPAWN_COLOR,
|
||||
opacity: 0.18,
|
||||
strokeWidth: 2,
|
||||
vectorEffect: 'non-scaling-stroke',
|
||||
strokeLinecap: 'round',
|
||||
strokeLinejoin: 'round',
|
||||
fill: 'none',
|
||||
},
|
||||
{
|
||||
kind: 'path',
|
||||
d: FOOTPRINT_RIGHT_PATH,
|
||||
stroke: SPAWN_COLOR,
|
||||
strokeWidth: 2,
|
||||
vectorEffect: 'non-scaling-stroke',
|
||||
strokeLinecap: 'round',
|
||||
strokeLinejoin: 'round',
|
||||
fill: 'none',
|
||||
},
|
||||
{
|
||||
kind: 'line',
|
||||
x1: iconX(16),
|
||||
y1: iconY(17),
|
||||
x2: iconX(20),
|
||||
y2: iconY(17),
|
||||
stroke: SPAWN_COLOR,
|
||||
strokeWidth: 2,
|
||||
vectorEffect: 'non-scaling-stroke',
|
||||
strokeLinecap: 'round',
|
||||
},
|
||||
{
|
||||
kind: 'line',
|
||||
x1: iconX(4),
|
||||
y1: iconY(13),
|
||||
x2: iconX(8),
|
||||
y2: iconY(13),
|
||||
stroke: SPAWN_COLOR,
|
||||
strokeWidth: 2,
|
||||
vectorEffect: 'non-scaling-stroke',
|
||||
strokeLinecap: 'round',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -55,8 +115,8 @@ export function buildSpawnFloorplan(node: SpawnNode, ctx: GeometryContext): Floo
|
||||
if (isSelected) {
|
||||
const cornerLocalX = 0.34 + ROTATE_ARROW_CORNER_OFFSET
|
||||
const cornerLocalZ = 0.34 + ROTATE_ARROW_CORNER_OFFSET
|
||||
const [cornerX, cornerZ] = rotatePlanVector(cornerLocalX, cornerLocalZ, ry)
|
||||
const [radialX, radialZ] = rotatePlanVector(1, 1, ry)
|
||||
const [cornerX, cornerZ] = rotatePlanVector(cornerLocalX, cornerLocalZ, planRotation)
|
||||
const [radialX, radialZ] = rotatePlanVector(1, 1, planRotation)
|
||||
children.push({
|
||||
kind: 'rotate-arrow',
|
||||
point: [px + cornerX, pz + cornerZ],
|
||||
@@ -77,3 +137,8 @@ function rotatePlanVector(x: number, y: number, rotation: number): FloorplanPoin
|
||||
const s = Math.sin(rotation)
|
||||
return [x * c - y * s, x * s + y * c]
|
||||
}
|
||||
|
||||
function formatIconCoord(value: number): number {
|
||||
const scaled = value * FOOTPRINT_ICON_SCALE
|
||||
return Number(scaled.toFixed(4))
|
||||
}
|
||||
|
||||
@@ -94,7 +94,12 @@ export default function SpawnPanel() {
|
||||
const storedRotationDegrees = Math.round((node.rotation * 180) / Math.PI)
|
||||
|
||||
return (
|
||||
<PanelWrapper icon="/icons/site.png" onClose={handleClose} title="Spawn Point" width={300}>
|
||||
<PanelWrapper
|
||||
icon="/icons/spawn-point.png"
|
||||
onClose={handleClose}
|
||||
title="Spawn Point"
|
||||
width={300}
|
||||
>
|
||||
<PanelSection title="Position">
|
||||
<SliderControl
|
||||
label="X"
|
||||
|
||||
Reference in New Issue
Block a user