fix: restore walkthrough collisions and spawn controls
This commit is contained in:
@@ -1,6 +1,11 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { SpawnNode as SpawnSchemaFromCore } from '@pascal-app/core'
|
||||
import {
|
||||
type FloorplanGeometry,
|
||||
type GeometryContext,
|
||||
SpawnNode as SpawnSchemaFromCore,
|
||||
} from '@pascal-app/core'
|
||||
import { spawnDefinition } from '../definition'
|
||||
import { buildSpawnFloorplan } from '../floorplan'
|
||||
import { SpawnNode } from '../schema'
|
||||
|
||||
/**
|
||||
@@ -8,7 +13,7 @@ import { SpawnNode } from '../schema'
|
||||
*
|
||||
* The new renderer is a near-line-by-line port of the legacy
|
||||
* `@pascal-app/viewer/components/renderers/spawn/spawn-renderer.tsx` —
|
||||
* same mesh count, same primitives, same colors. The "parity" assertion
|
||||
* same mesh count and primitives. The "parity" assertion
|
||||
* for the spike is structural (definition is well-formed, both lazy
|
||||
* modules resolve to React components) plus a manual visual eyeball check
|
||||
* documented in the plan. Pixel-level Playwright parity lands in Phase 4
|
||||
@@ -52,6 +57,56 @@ describe('spawn definition', () => {
|
||||
expect(angles).toContain(0)
|
||||
})
|
||||
|
||||
test('handles expose rotation and move controls', () => {
|
||||
expect(Array.isArray(spawnDefinition.handles)).toBe(true)
|
||||
if (!Array.isArray(spawnDefinition.handles)) return
|
||||
expect(spawnDefinition.handles.map((handle) => handle.kind)).toEqual([
|
||||
'arc-resize',
|
||||
'translate',
|
||||
])
|
||||
})
|
||||
|
||||
test('floorplan uses indigo marker color and selected rotation affordance', () => {
|
||||
const spawn = SpawnNode.parse({
|
||||
id: 'spawn_test1234567890ab',
|
||||
position: [1, 0, 2],
|
||||
rotation: Math.PI / 4,
|
||||
})
|
||||
const geometry = buildSpawnFloorplan(spawn, {
|
||||
resolve: () => undefined,
|
||||
children: [],
|
||||
siblings: [],
|
||||
parent: null,
|
||||
viewState: {
|
||||
selected: true,
|
||||
highlighted: false,
|
||||
hovered: false,
|
||||
moving: false,
|
||||
palette: {
|
||||
selectedStroke: '#60a5fa',
|
||||
selectedFill: '#dbeafe',
|
||||
selectedHatch: '#60a5fa',
|
||||
wallHoverStroke: '#60a5fa',
|
||||
endpointHandleFill: '#fed7aa',
|
||||
endpointHandleStroke: '#f97316',
|
||||
endpointHandleHoverStroke: '#fb923c',
|
||||
endpointHandleActiveFill: '#fdba74',
|
||||
endpointHandleActiveStroke: '#ea580c',
|
||||
curveHandleFill: '#99f6e4',
|
||||
curveHandleStroke: '#14b8a6',
|
||||
curveHandleHoverStroke: '#2dd4bf',
|
||||
measurementStroke: '#6366f1',
|
||||
measurementLabelBackground: '#ffffff',
|
||||
measurementLabelText: '#111827',
|
||||
},
|
||||
},
|
||||
} satisfies GeometryContext)
|
||||
|
||||
const flat = flattenFloorplan(geometry)
|
||||
expect(flat.some((entry) => entry.kind === 'polygon' && entry.fill === '#818cf8')).toBe(true)
|
||||
expect(flat.some((entry) => entry.kind === 'rotate-arrow')).toBe(true)
|
||||
})
|
||||
|
||||
test('renderer is a parametric lazy module reference', () => {
|
||||
expect(spawnDefinition.renderer.kind).toBe('parametric')
|
||||
if (spawnDefinition.renderer.kind !== 'parametric') return
|
||||
@@ -67,3 +122,8 @@ describe('spawn definition', () => {
|
||||
expect(spawnDefinition.mcp?.description?.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
function flattenFloorplan(geometry: FloorplanGeometry): FloorplanGeometry[] {
|
||||
if (geometry.kind !== 'group') return [geometry]
|
||||
return geometry.children.flatMap((child) => flattenFloorplan(child))
|
||||
}
|
||||
|
||||
@@ -1,10 +1,36 @@
|
||||
import type { HandleDescriptor, NodeDefinition, SpawnNode as SpawnNodeType } from '@pascal-app/core'
|
||||
import { buildSpawnFloorplan } from './floorplan'
|
||||
import { spawnRotateAffordance } from './floorplan-affordances'
|
||||
import { spawnParametrics } from './parametrics'
|
||||
import { SpawnNode } from './schema'
|
||||
|
||||
const SPAWN_FOOTPRINT = 0.6
|
||||
const SPAWN_HANDLE_HEIGHT = 0.46
|
||||
const MOVE_FRONT_OFFSET = 0.35
|
||||
const ROTATE_CORNER_OFFSET = 0.32
|
||||
const ROTATE_RING_OFFSET = 0.04
|
||||
|
||||
function spawnRotateHandle(): HandleDescriptor<SpawnNodeType> {
|
||||
return {
|
||||
kind: 'arc-resize',
|
||||
axis: 'angular',
|
||||
shape: 'rotate',
|
||||
apply: (initial, delta) => ({ rotation: (initial.rotation ?? 0) - delta }),
|
||||
placement: {
|
||||
position: () => [
|
||||
SPAWN_FOOTPRINT / 2,
|
||||
SPAWN_HANDLE_HEIGHT,
|
||||
SPAWN_FOOTPRINT / 2 + ROTATE_CORNER_OFFSET,
|
||||
],
|
||||
rotationY: () => -Math.PI / 4,
|
||||
},
|
||||
decoration: {
|
||||
kind: 'ring',
|
||||
radius: () => Math.hypot(SPAWN_FOOTPRINT / 2, SPAWN_FOOTPRINT / 2) + ROTATE_RING_OFFSET,
|
||||
y: () => SPAWN_HANDLE_HEIGHT,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function spawnMoveHandle(): HandleDescriptor<SpawnNodeType> {
|
||||
return {
|
||||
@@ -52,7 +78,7 @@ export const spawnDefinition: NodeDefinition<typeof SpawnNode> = {
|
||||
},
|
||||
|
||||
parametrics: spawnParametrics,
|
||||
handles: [spawnMoveHandle()],
|
||||
handles: [spawnRotateHandle(), spawnMoveHandle()],
|
||||
|
||||
renderer: {
|
||||
kind: 'parametric',
|
||||
@@ -66,6 +92,9 @@ export const spawnDefinition: NodeDefinition<typeof SpawnNode> = {
|
||||
// delete. Legacy spawn click handlers in FloorplanNodeLayer become
|
||||
// dead code once Phase 6 cleanup removes the [] entries path.
|
||||
floorplan: buildSpawnFloorplan,
|
||||
floorplanAffordances: {
|
||||
'spawn-rotate': spawnRotateAffordance,
|
||||
},
|
||||
tool: () => import('./tool'),
|
||||
toolHints: [
|
||||
{ key: 'Left click', label: 'Place spawn point' },
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import {
|
||||
type AnyNodeId,
|
||||
type FloorplanAffordance,
|
||||
type SpawnNode,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
|
||||
export const spawnRotateAffordance: FloorplanAffordance<SpawnNode> = {
|
||||
start({ node, initialPlanPoint }) {
|
||||
const spawnId = node.id as AnyNodeId
|
||||
const initialRotation = node.rotation ?? 0
|
||||
const cx = node.position[0]
|
||||
const cz = node.position[2]
|
||||
const initialAngle = Math.atan2(initialPlanPoint[1] - cz, initialPlanPoint[0] - cx)
|
||||
let lastRotation = initialRotation
|
||||
|
||||
return {
|
||||
affectedIds: [spawnId],
|
||||
apply({ planPoint }) {
|
||||
const currentAngle = Math.atan2(planPoint[1] - cz, planPoint[0] - cx)
|
||||
let delta = currentAngle - initialAngle
|
||||
while (delta > Math.PI) delta -= 2 * Math.PI
|
||||
while (delta < -Math.PI) delta += 2 * Math.PI
|
||||
lastRotation = initialRotation - delta
|
||||
useScene.getState().updateNode(spawnId, { rotation: lastRotation })
|
||||
},
|
||||
canCommit() {
|
||||
return true
|
||||
},
|
||||
commit() {
|
||||
useScene.getState().updateNode(spawnId, { rotation: lastRotation })
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -1,48 +1,79 @@
|
||||
import type { FloorplanGeometry } from '@pascal-app/core'
|
||||
import type { FloorplanGeometry, FloorplanPoint, GeometryContext } from '@pascal-app/core'
|
||||
import type { SpawnNode } from './schema'
|
||||
|
||||
const SPAWN_COLOR = '#818cf8'
|
||||
const ROTATE_ARROW_CORNER_OFFSET = 0.22
|
||||
|
||||
/**
|
||||
* 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).
|
||||
*
|
||||
* Color matches the 3D renderer's `SPAWN_COLOR = '#22c55e'` so the user
|
||||
* Color matches the 3D renderer's indigo spawn material so the user
|
||||
* sees the same visual identity in both views.
|
||||
*
|
||||
* Coordinates are level-local meters; rotation is radians.
|
||||
*/
|
||||
export function buildSpawnFloorplan(node: SpawnNode): FloorplanGeometry {
|
||||
export function buildSpawnFloorplan(node: SpawnNode, ctx: GeometryContext): FloorplanGeometry {
|
||||
const [px, , pz] = node.position
|
||||
const ry = node.rotation
|
||||
const isSelected = ctx.viewState?.selected ?? false
|
||||
|
||||
const children: FloorplanGeometry[] = [
|
||||
{
|
||||
kind: 'group',
|
||||
transform: { translate: [px, pz], rotate: ry },
|
||||
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,
|
||||
stroke: SPAWN_COLOR,
|
||||
strokeWidth: 0.025,
|
||||
fill: SPAWN_COLOR,
|
||||
opacity: 0.18,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
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)
|
||||
children.push({
|
||||
kind: 'rotate-arrow',
|
||||
point: [px + cornerX, pz + cornerZ],
|
||||
angle: Math.atan2(radialZ, radialX),
|
||||
affordance: 'spawn-rotate',
|
||||
pivot: [px, pz],
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'group',
|
||||
transform: { translate: [px, pz], rotate: ry },
|
||||
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: '#22c55e',
|
||||
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,
|
||||
stroke: '#22c55e',
|
||||
strokeWidth: 0.025,
|
||||
fill: '#22c55e',
|
||||
opacity: 0.18,
|
||||
},
|
||||
],
|
||||
children,
|
||||
}
|
||||
}
|
||||
|
||||
function rotatePlanVector(x: number, y: number, rotation: number): FloorplanPoint {
|
||||
const c = Math.cos(rotation)
|
||||
const s = Math.sin(rotation)
|
||||
return [x * c - y * s, x * s + y * c]
|
||||
}
|
||||
|
||||
@@ -11,12 +11,12 @@ import { createDefaultMaterial, useNodeEvents, useViewer } from '@pascal-app/vie
|
||||
import { useMemo, useRef } from 'react'
|
||||
import { Color, type Group, Shape } from 'three'
|
||||
|
||||
const SPAWN_COLOR = new Color('#22c55e')
|
||||
const SPAWN_COLOR = new Color('#818cf8')
|
||||
|
||||
/**
|
||||
* Registry-driven spawn renderer. Behaviorally identical to the legacy
|
||||
* `@pascal-app/viewer/components/renderers/spawn/spawn-renderer.tsx` — same
|
||||
* geometry, same colors, same event surface. When the spawn definition lands
|
||||
* geometry and event surface. When the spawn definition lands
|
||||
* in `builtinPlugin.nodes`, the Phase 0 dispatch shims switch the renderer
|
||||
* here and the legacy one is short-circuited.
|
||||
*
|
||||
@@ -38,7 +38,7 @@ const SpawnRenderer = ({ node }: { node: SpawnNode }) => {
|
||||
useRegistry(node.id, 'spawn', ref)
|
||||
|
||||
const material = useMemo(() => {
|
||||
const next = createDefaultMaterial('#22c55e', 0.42, shading) as ReturnType<
|
||||
const next = createDefaultMaterial('#818cf8', 0.42, shading) as ReturnType<
|
||||
typeof createDefaultMaterial
|
||||
> & {
|
||||
emissive?: Color
|
||||
|
||||
@@ -120,7 +120,7 @@ const SpawnTool = () => {
|
||||
|
||||
if (!activeLevelId) return null
|
||||
|
||||
return <CursorSphere color="#60a5fa" height={2.2} ref={cursorRef} />
|
||||
return <CursorSphere color="#818cf8" height={2.2} ref={cursorRef} />
|
||||
}
|
||||
|
||||
export default SpawnTool
|
||||
|
||||
Reference in New Issue
Block a user