feat: add persistent site boundary handles
This commit is contained in:
@@ -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