diff --git a/packages/core/src/events/bus.ts b/packages/core/src/events/bus.ts index 6f61b2f7..9aca0474 100644 --- a/packages/core/src/events/bus.ts +++ b/packages/core/src/events/bus.ts @@ -12,6 +12,7 @@ import type { RoofSegmentNode, SiteNode, SlabNode, + SpawnNode, StairNode, StairSegmentNode, WallNode, @@ -53,6 +54,7 @@ export type BuildingEvent = NodeEvent export type LevelEvent = NodeEvent export type ZoneEvent = NodeEvent export type SlabEvent = NodeEvent +export type SpawnEvent = NodeEvent export type CeilingEvent = NodeEvent export type RoofEvent = NodeEvent export type RoofSegmentEvent = NodeEvent @@ -144,6 +146,7 @@ type EditorEvents = GridEvents & NodeEvents<'level', LevelEvent> & NodeEvents<'zone', ZoneEvent> & NodeEvents<'slab', SlabEvent> & + NodeEvents<'spawn', SpawnEvent> & NodeEvents<'ceiling', CeilingEvent> & NodeEvents<'roof', RoofEvent> & NodeEvents<'roof-segment', RoofSegmentEvent> & diff --git a/packages/core/src/hooks/scene-registry/scene-registry.ts b/packages/core/src/hooks/scene-registry/scene-registry.ts index c44e7909..ec727481 100644 --- a/packages/core/src/hooks/scene-registry/scene-registry.ts +++ b/packages/core/src/hooks/scene-registry/scene-registry.ts @@ -18,6 +18,7 @@ export const sceneRegistry = { fence: new Set(), item: new Set(), slab: new Set(), + spawn: new Set(), zone: new Set(), roof: new Set(), 'roof-segment': new Set(), diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 7ef4e545..fc01f3be 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -13,6 +13,7 @@ export type { RoofSegmentEvent, SiteEvent, SlabEvent, + SpawnEvent, StairEvent, StairSegmentEvent, WallEvent, diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index 1383216d..a86f1a71 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -50,6 +50,7 @@ export { ScanNode } from './nodes/scan' // Nodes export { SiteNode } from './nodes/site' export { SlabNode } from './nodes/slab' +export { SpawnNode } from './nodes/spawn' export { getEffectiveStairSurfaceMaterial, StairNode, diff --git a/packages/core/src/schema/nodes/door.ts b/packages/core/src/schema/nodes/door.ts index 9d8ad95d..519ac6a4 100644 --- a/packages/core/src/schema/nodes/door.ts +++ b/packages/core/src/schema/nodes/door.ts @@ -77,7 +77,7 @@ export const DoorNode = BaseNode.extend({ }).describe(dedent`Door node - a parametric door placed on a wall - position: center of the door in wall-local coordinate system (Y = height/2, always at floor) - segments: rows stacked top to bottom, each defining its own columnRatios - - type 'empty' = flush flat fill, 'panel' = raised/recessed panel, 'glass' = glazed + - type 'empty' = no leaf fill for that segment, 'panel' = raised/recessed panel, 'glass' = glazed - hingesSide/swingDirection: which way the door opens - doorCloser/panicBar: commercial and emergency hardware options `) diff --git a/packages/core/src/schema/nodes/level.ts b/packages/core/src/schema/nodes/level.ts index 4761161c..60c4e258 100644 --- a/packages/core/src/schema/nodes/level.ts +++ b/packages/core/src/schema/nodes/level.ts @@ -7,6 +7,7 @@ import { GuideNode } from './guide' import { RoofNode } from './roof' import { ScanNode } from './scan' import { SlabNode } from './slab' +import { SpawnNode } from './spawn' import { StairNode } from './stair' import { WallNode } from './wall' import { ZoneNode } from './zone' @@ -26,6 +27,7 @@ export const LevelNode = BaseNode.extend({ StairNode.shape.id, ScanNode.shape.id, GuideNode.shape.id, + SpawnNode.shape.id, ]), ) .default([]), diff --git a/packages/core/src/schema/nodes/spawn.ts b/packages/core/src/schema/nodes/spawn.ts new file mode 100644 index 00000000..521d3f81 --- /dev/null +++ b/packages/core/src/schema/nodes/spawn.ts @@ -0,0 +1,11 @@ +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' + +export const SpawnNode = BaseNode.extend({ + id: objectId('spawn'), + type: nodeType('spawn'), + position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), + rotation: z.number().default(0), +}) + +export type SpawnNode = z.infer diff --git a/packages/core/src/schema/types.ts b/packages/core/src/schema/types.ts index 1b17a6b0..00e07fa1 100644 --- a/packages/core/src/schema/types.ts +++ b/packages/core/src/schema/types.ts @@ -11,6 +11,7 @@ import { RoofSegmentNode } from './nodes/roof-segment' import { ScanNode } from './nodes/scan' import { SiteNode } from './nodes/site' import { SlabNode } from './nodes/slab' +import { SpawnNode } from './nodes/spawn' import { StairNode } from './nodes/stair' import { StairSegmentNode } from './nodes/stair-segment' import { WallNode } from './nodes/wall' @@ -33,6 +34,7 @@ export const AnyNode = z.discriminatedUnion('type', [ StairSegmentNode, ScanNode, GuideNode, + SpawnNode, WindowNode, DoorNode, ]) diff --git a/packages/core/src/systems/door/door-system.tsx b/packages/core/src/systems/door/door-system.tsx index 24dacaa1..b9b5c9e9 100644 --- a/packages/core/src/systems/door/door-system.tsx +++ b/packages/core/src/systems/door/door-system.tsx @@ -86,6 +86,7 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) { contentPadding, hingesSide, } = node + const hasLeafContent = segments.some((seg) => seg.type !== 'empty') // Leaf occupies the full opening (no bottom frame bar — door opens to floor) const leafW = width - 2 * frameThickness @@ -146,13 +147,13 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) { // ── Leaf — contentPadding border strips (no full backing; glass areas are open) ── const cpX = contentPadding[0] const cpY = contentPadding[1] - if (cpY > 0) { + if (hasLeafContent && cpY > 0) { // Top strip addBox(mesh, baseMaterial, leafW, cpY, leafDepth, 0, leafCenterY + leafH / 2 - cpY / 2, 0) // Bottom strip addBox(mesh, baseMaterial, leafW, cpY, leafDepth, 0, leafCenterY - leafH / 2 + cpY / 2, 0) } - if (cpX > 0) { + if (hasLeafContent && cpX > 0) { const innerH = leafH - 2 * cpY // Left strip addBox(mesh, baseMaterial, cpX, innerH, leafDepth, -leafW / 2 + cpX / 2, leafCenterY, 0) @@ -188,20 +189,22 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) { } // Column dividers within this segment - cx = -contentW / 2 - for (let c = 0; c < numCols - 1; c++) { - cx += colWidths[c]! - addBox( - mesh, - baseMaterial, - seg.dividerThickness, - segH, - leafDepth + 0.001, - cx + seg.dividerThickness / 2, - segCenterY, - 0, - ) - cx += seg.dividerThickness + if (seg.type !== 'empty') { + cx = -contentW / 2 + for (let c = 0; c < numCols - 1; c++) { + cx += colWidths[c]! + addBox( + mesh, + baseMaterial, + seg.dividerThickness, + segH, + leafDepth + 0.001, + cx + seg.dividerThickness / 2, + segCenterY, + 0, + ) + cx += seg.dividerThickness + } } // Segment content per column @@ -225,8 +228,7 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) { addBox(mesh, baseMaterial, panelW, panelH, effectiveDepth, colX, segCenterY, panelZ) } } else { - // 'empty' — opaque backing, no detail - addBox(mesh, baseMaterial, colW, segH, leafDepth, colX, segCenterY, 0) + // 'empty' leaves the opening unfilled } } @@ -234,7 +236,7 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) { } // ── Handle ── - if (handle) { + if (hasLeafContent && handle) { // Convert from floor-based height to mesh-center-based Y const handleY = handleHeight - height / 2 // Handle grip sits on the front face (+Z) of the leaf @@ -250,7 +252,7 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) { } // ── Door closer (commercial hardware at top) ── - if (doorCloser) { + if (hasLeafContent && doorCloser) { const closerY = leafCenterY + leafH / 2 - 0.04 // Body addBox(mesh, baseMaterial, 0.28, 0.055, 0.055, 0, closerY, leafDepth / 2 + 0.03) @@ -268,13 +270,13 @@ function updateDoorMesh(node: DoorNode, mesh: THREE.Mesh) { } // ── Panic bar ── - if (panicBar) { + if (hasLeafContent && panicBar) { const barY = panicBarHeight - height / 2 addBox(mesh, baseMaterial, leafW * 0.72, 0.04, 0.055, 0, barY, leafDepth / 2 + 0.03) } // ── Hinges (3 knuckle-style hinges on the hinge side) ── - { + if (hasLeafContent) { const hingeX = hingesSide === 'right' ? leafW / 2 - 0.012 : -leafW / 2 + 0.012 const hingeZ = 0 // centered in leaf depth const hingeH = 0.1 diff --git a/packages/core/src/systems/stair/stair-opening-sync.ts b/packages/core/src/systems/stair/stair-opening-sync.ts index 3cf828f8..41fdddca 100644 --- a/packages/core/src/systems/stair/stair-opening-sync.ts +++ b/packages/core/src/systems/stair/stair-opening-sync.ts @@ -1,4 +1,12 @@ -import type { AnyNode, AnyNodeId, CeilingNode, LevelNode, SlabNode, StairNode, StairSegmentNode } from '../../schema' +import type { + AnyNode, + AnyNodeId, + CeilingNode, + LevelNode, + SlabNode, + StairNode, + StairSegmentNode, +} from '../../schema' import { resolveLevelId } from '../../hooks/spatial-grid/spatial-grid-sync' import { DEFAULT_WALL_HEIGHT } from '../wall/wall-footprint' @@ -27,9 +35,10 @@ type AxisAlignedRect = { maxZ: number } -const CURVED_STAIR_SLAB_OPENING_RATIO = 0.8 +const CURVED_STAIR_SLAB_OPENING_RATIO = 0.9 const STRAIGHT_STAIR_TARGET_THRESHOLD_MIN = 0.35 const STAIR_SLAB_OPENING_TIGHTENING = 0 +const CURVED_STAIR_OPENING_STEP_PADDING = 3 function clamp(value: number, min: number, max: number) { return Math.min(max, Math.max(min, value)) @@ -58,7 +67,8 @@ function metadataEqual(left: SurfaceHoleMetadata[], right: SurfaceHoleMetadata[] if (left.length !== right.length) return false return left.every( (entry, index) => - entry.source === right[index]?.source && (entry.stairId ?? null) === (right[index]?.stairId ?? null), + entry.source === right[index]?.source && + (entry.stairId ?? null) === (right[index]?.stairId ?? null), ) } @@ -178,7 +188,10 @@ function getResolvedStairLevelIds(stair: StairNode, nodes: Record) { return (stair.children ?? []) .map((childId) => nodes[childId as AnyNodeId] as StairSegmentNode | undefined) - .filter((segment): segment is StairSegmentNode => segment?.type === 'stair-segment' && segment.visible !== false) + .filter( + (segment): segment is StairSegmentNode => + segment?.type === 'stair-segment' && segment.visible !== false, + ) } function toWorldPlanPoint(stair: StairNode, localX: number, localZ: number): Point2D { @@ -186,7 +199,10 @@ function toWorldPlanPoint(stair: StairNode, localX: number, localZ: number): Poi return [stair.position[0] + worldX, stair.position[2] + worldZ] } -function getStraightStairLayouts(stair: StairNode, nodes: Record): StraightStairLayout[] { +function getStraightStairLayouts( + stair: StairNode, + nodes: Record, +): StraightStairLayout[] { const segments = resolveStraightSegments(stair, nodes) const transforms = computeSegmentTransforms(segments) @@ -204,7 +220,10 @@ function getStraightStairLayouts(stair: StairNode, nodes: Record toWorldPlanPoint(stair, x, z)) + return getStraightSegmentLocalSlicePolygon(layout, startAlong, endAlong).map(([x, z]) => + toWorldPlanPoint(stair, x, z), + ) } function getStraightFlightOpeningDepth(stair: StairNode, segment: StairSegmentNode) { - const treadDepth = Math.max(0.2, segment.length / Math.max(segment.stepCount || stair.stepCount || 10, 1)) + const treadDepth = Math.max( + 0.2, + segment.length / Math.max(segment.stepCount || stair.stepCount || 10, 1), + ) return Math.min(segment.length, Math.max(treadDepth * 6, segment.length * 0.62, 1.8)) } @@ -289,12 +313,16 @@ function expandRect(rect: AxisAlignedRect, offset: number): AxisAlignedRect { function buildUnionPolygonsFromRects(rects: AxisAlignedRect[]): Point2D[][] { if (rects.length === 0) return [] - const xs = Array.from(new Set(rects.flatMap((rect) => [rect.minX, rect.maxX]).map((value) => Number(value.toFixed(6))))).sort( - (a, b) => a - b, - ) - const zs = Array.from(new Set(rects.flatMap((rect) => [rect.minZ, rect.maxZ]).map((value) => Number(value.toFixed(6))))).sort( - (a, b) => a - b, - ) + const xs = Array.from( + new Set( + rects.flatMap((rect) => [rect.minX, rect.maxX]).map((value) => Number(value.toFixed(6))), + ), + ).sort((a, b) => a - b) + const zs = Array.from( + new Set( + rects.flatMap((rect) => [rect.minZ, rect.maxZ]).map((value) => Number(value.toFixed(6))), + ), + ).sort((a, b) => a - b) if (xs.length < 2 || zs.length < 2) return [] const occupied = new Set() @@ -367,24 +395,39 @@ function buildUnionPolygonsFromRects(rects: AxisAlignedRect[]): Point2D[][] { return polygons } -function getCurvedOpeningPolygon(stair: StairNode): Point2D[] { - const width = Math.max(stair.width ?? 1, 0.4) - const innerRadius = Math.max(0.2, stair.innerRadius ?? 0.9) - const outerRadius = innerRadius + width - const totalSweep = stair.sweepAngle ?? Math.PI / 2 - const openingSweep = - Math.sign(totalSweep || 1) * +function getCurvedOpeningStepCount( + stair: StairNode, + innerRadius: number, + outerRadius: number, + totalSweep: number, +) { + const stepCount = Math.max(2, Math.round(stair.stepCount ?? 10)) + const stepSweep = Math.abs(totalSweep) / stepCount + const midRadius = Math.max((innerRadius + outerRadius) * 0.5, 0.01) + const treadDepth = Math.max(stepSweep * midRadius, 0.2) + return Math.min( + stepCount, Math.max( - Math.abs(totalSweep) * CURVED_STAIR_SLAB_OPENING_RATIO, - Math.abs(totalSweep) / Math.max(stair.stepCount ?? 1, 1), - ) - const startAngle = totalSweep / 2 - openingSweep - const endAngle = totalSweep / 2 + 1, + Math.ceil(1.8 / treadDepth), + Math.ceil(stepCount * CURVED_STAIR_SLAB_OPENING_RATIO), + ), + ) +} + +function buildArcOpeningPolygon( + stair: StairNode, + innerRadius: number, + outerRadius: number, + startAngle: number, + endAngle: number, +): Point2D[] { + const sweep = endAngle - startAngle const segmentCount = Math.max( 10, Math.min( 32, - Math.ceil(Math.abs(openingSweep) / (Math.PI / 24) + Math.max(stair.stepCount ?? 1, 1) * 0.5), + Math.ceil(Math.abs(sweep) / (Math.PI / 24) + Math.max(stair.stepCount ?? 1, 1) * 0.5), ), ) const outerPoints: Point2D[] = [] @@ -392,19 +435,56 @@ function getCurvedOpeningPolygon(stair: StairNode): Point2D[] { for (let index = 0; index <= segmentCount; index++) { const t = index / segmentCount - const angle = startAngle + (endAngle - startAngle) * t - outerPoints.push(toWorldPlanPoint(stair, Math.cos(angle) * outerRadius, Math.sin(angle) * outerRadius)) + const angle = startAngle + sweep * t + outerPoints.push( + toWorldPlanPoint(stair, Math.cos(angle) * outerRadius, Math.sin(angle) * outerRadius), + ) } for (let index = segmentCount; index >= 0; index--) { const t = index / segmentCount - const angle = startAngle + (endAngle - startAngle) * t - innerPoints.push(toWorldPlanPoint(stair, Math.cos(angle) * innerRadius, Math.sin(angle) * innerRadius)) + const angle = startAngle + sweep * t + innerPoints.push( + toWorldPlanPoint(stair, Math.cos(angle) * innerRadius, Math.sin(angle) * innerRadius), + ) } return [...outerPoints, ...innerPoints] } +function getCurvedOpeningPolygon(stair: StairNode, targetElevation?: number): Point2D[] { + const width = Math.max(stair.width ?? 1, 0.4) + const innerRadius = Math.max(0.2, stair.innerRadius ?? 0.9) + const outerRadius = innerRadius + width + const totalSweep = stair.sweepAngle ?? Math.PI / 2 + const stepCount = Math.max(2, Math.round(stair.stepCount ?? 10)) + const stepHeight = Math.max(stair.totalRise ?? 2.5, 0.1) / stepCount + const stepSweep = totalSweep / stepCount + const targetThreshold = Math.max(stepHeight * 2, STRAIGHT_STAIR_TARGET_THRESHOLD_MIN) + const endAngle = totalSweep / 2 + + const fallbackStartStepIndex = Math.max( + 0, + stepCount - getCurvedOpeningStepCount(stair, innerRadius, outerRadius, totalSweep), + ) + let startStepIndex = fallbackStartStepIndex + if (typeof targetElevation === 'number') { + for (let index = 0; index < stepCount; index += 1) { + const stepTopElevation = stepHeight * (index + 1) + if (stepTopElevation >= targetElevation - targetThreshold) { + startStepIndex = Math.max( + 0, + Math.min(fallbackStartStepIndex, index - CURVED_STAIR_OPENING_STEP_PADDING), + ) + break + } + } + } + + const startAngle = -totalSweep / 2 + stepSweep * startStepIndex + return buildArcOpeningPolygon(stair, innerRadius, outerRadius, startAngle, endAngle) +} + function getSpiralOpeningPolygon(stair: StairNode): Point2D[] { const radius = Math.max(0.05, stair.innerRadius ?? 0.9) + Math.max(stair.width ?? 1, 0.4) const segmentCount = 48 @@ -440,7 +520,11 @@ function getStraightOpeningPolygonsForSurface( if (Math.abs(targetElevation - segmentTopElevation) <= targetThreshold) { const openingDepth = getStraightFlightOpeningDepth(stair, segment) const flightRect = getAxisAlignedRectFromPolygon( - getStraightSegmentLocalSlicePolygon(layout, Math.max(0, segment.length - openingDepth), segment.length), + getStraightSegmentLocalSlicePolygon( + layout, + Math.max(0, segment.length - openingDepth), + segment.length, + ), ) if (flightRect) openingRects.push(expandRect(flightRect, openingOffset)) } @@ -452,7 +536,9 @@ function getStraightOpeningPolygonsForSurface( } const landingRects: AxisAlignedRect[] = [] - const landingRect = getAxisAlignedRectFromPolygon(getStraightSegmentLocalSlicePolygon(layout, 0, layout.segment.length)) + const landingRect = getAxisAlignedRectFromPolygon( + getStraightSegmentLocalSlicePolygon(layout, 0, layout.segment.length), + ) if (landingRect) landingRects.push(expandRect(landingRect, openingOffset)) const previous = layouts[index - 1] if (previous?.segment.segmentType === 'stair') { @@ -503,7 +589,7 @@ function getStairOpeningPolygons( } if (stair.stairType === 'curved') { - return [getCurvedOpeningPolygon(stair)] + return [getCurvedOpeningPolygon(stair, targetElevation)] } if (stair.stairType === 'spiral') { @@ -556,10 +642,18 @@ function getTargetCeilingElevationForStair( return ceiling.height ?? DEFAULT_WALL_HEIGHT } - return (ceilingLevel - fromLevel) * DEFAULT_WALL_HEIGHT + (ceiling.height ?? DEFAULT_WALL_HEIGHT) - (stair.position[1] ?? 0) + return ( + (ceilingLevel - fromLevel) * DEFAULT_WALL_HEIGHT + + (ceiling.height ?? DEFAULT_WALL_HEIGHT) - + (stair.position[1] ?? 0) + ) } -function shouldApplyStairToSlab(stair: StairNode, slabLevelId: string, nodes: Record) { +function shouldApplyStairToSlab( + stair: StairNode, + slabLevelId: string, + nodes: Record, +) { const { fromLevelId, toLevelId } = getResolvedStairLevelIds(stair, nodes) const fromLevel = getLevelNumber(fromLevelId, nodes) const toLevel = getLevelNumber(toLevelId, nodes) @@ -578,7 +672,11 @@ function shouldApplyStairToSlab(stair: StairNode, slabLevelId: string, nodes: Re return slabLevel > minLevel && slabLevel <= maxLevel } -function shouldApplyStairToCeiling(stair: StairNode, ceilingLevelId: string, nodes: Record) { +function shouldApplyStairToCeiling( + stair: StairNode, + ceilingLevelId: string, + nodes: Record, +) { const { fromLevelId, toLevelId } = getResolvedStairLevelIds(stair, nodes) const fromLevel = getLevelNumber(fromLevelId, nodes) const toLevel = getLevelNumber(toLevelId, nodes) @@ -598,16 +696,22 @@ function shouldApplyStairToCeiling(stair: StairNode, ceilingLevelId: string, nod } export function syncAutoStairOpenings(nodes: Record) { - const stairs = Object.values(nodes).filter((node): node is StairNode => node.type === 'stair' && node.visible !== false) + const stairs = Object.values(nodes).filter( + (node): node is StairNode => node.type === 'stair' && node.visible !== false, + ) const slabs = Object.values(nodes).filter((node): node is SlabNode => node.type === 'slab') - const ceilings = Object.values(nodes).filter((node): node is CeilingNode => node.type === 'ceiling') + const ceilings = Object.values(nodes).filter( + (node): node is CeilingNode => node.type === 'ceiling', + ) const updates: Array<{ id: AnyNodeId; data: Partial }> = [] for (const slab of slabs) { const slabLevelId = resolveLevelId(slab, nodes) const existingHoles = slab.holes ?? [] const existingMetadata = normalizeExistingMetadata(existingHoles, slab.holeMetadata) - const manualHoles = existingHoles.filter((_hole, index) => existingMetadata[index]?.source !== 'stair') + const manualHoles = existingHoles.filter( + (_hole, index) => existingMetadata[index]?.source !== 'stair', + ) const manualMetadata = existingMetadata .filter((entry) => entry.source !== 'stair') .map((entry) => ({ ...entry })) @@ -637,7 +741,10 @@ export function syncAutoStairOpenings(nodes: Record) { const nextHoles = [...manualHoles, ...stairHoles.map((hole) => hole.polygon)] const nextMetadata = [...manualMetadata, ...stairHoles.map((hole) => hole.metadata)] - if (!polygonsEqual(existingHoles, nextHoles) || !metadataEqual(existingMetadata, nextMetadata)) { + if ( + !polygonsEqual(existingHoles, nextHoles) || + !metadataEqual(existingMetadata, nextMetadata) + ) { updates.push({ id: slab.id, data: { @@ -652,7 +759,9 @@ export function syncAutoStairOpenings(nodes: Record) { const ceilingLevelId = resolveLevelId(ceiling, nodes) const existingHoles = ceiling.holes ?? [] const existingMetadata = normalizeExistingMetadata(existingHoles, ceiling.holeMetadata) - const manualHoles = existingHoles.filter((_hole, index) => existingMetadata[index]?.source !== 'stair') + const manualHoles = existingHoles.filter( + (_hole, index) => existingMetadata[index]?.source !== 'stair', + ) const manualMetadata = existingMetadata .filter((entry) => entry.source !== 'stair') .map((entry) => ({ ...entry })) @@ -682,7 +791,10 @@ export function syncAutoStairOpenings(nodes: Record) { const nextHoles = [...manualHoles, ...stairHoles.map((hole) => hole.polygon)] const nextMetadata = [...manualMetadata, ...stairHoles.map((hole) => hole.metadata)] - if (!polygonsEqual(existingHoles, nextHoles) || !metadataEqual(existingMetadata, nextMetadata)) { + if ( + !polygonsEqual(existingHoles, nextHoles) || + !metadataEqual(existingMetadata, nextMetadata) + ) { updates.push({ id: ceiling.id, data: { diff --git a/packages/editor/package.json b/packages/editor/package.json index 0b0eb3c3..dbe2e5d0 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -46,6 +46,7 @@ "motion": "^12.34.3", "nanoid": "^5.1.6", "tailwind-merge": "^3.5.0", + "three-mesh-bvh": "^0.9.8", "zod": "^4.3.6", "zustand": "^5.0.11" }, diff --git a/packages/editor/src/components/editor/custom-camera-controls.tsx b/packages/editor/src/components/editor/custom-camera-controls.tsx index 0bee7583..8224220c 100644 --- a/packages/editor/src/components/editor/custom-camera-controls.tsx +++ b/packages/editor/src/components/editor/custom-camera-controls.tsx @@ -1,7 +1,7 @@ 'use client' import { type CameraControlEvent, emitter, sceneRegistry, useScene } from '@pascal-app/core' -import { useViewer, WalkthroughControls, ZONE_LAYER } from '@pascal-app/viewer' +import { useViewer, ZONE_LAYER } from '@pascal-app/viewer' import { CameraControls, CameraControlsImpl } from '@react-three/drei' import { useThree } from '@react-three/fiber' import { useCallback, useEffect, useMemo, useRef } from 'react' @@ -22,7 +22,7 @@ const DEBUG_MAX_POLAR_ANGLE = Math.PI - 0.05 export const CustomCameraControls = () => { const controls = useRef(null!) const isPreviewMode = useEditor((s) => s.isPreviewMode) - const walkthroughMode = useViewer((s) => s.walkthroughMode) + const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode) const allowUndergroundCamera = useEditor((s) => s.allowUndergroundCamera) const selection = useViewer((s) => s.selection) const currentLevelId = selection.levelId @@ -365,8 +365,8 @@ export const CustomCameraControls = () => { useViewer.getState().setCameraDragging(false) }, []) - if (walkthroughMode) { - return + if (isFirstPersonMode) { + return null } return ( diff --git a/packages/editor/src/components/editor/first-person-controls.tsx b/packages/editor/src/components/editor/first-person-controls.tsx index b90bcdf3..b6365ce9 100644 --- a/packages/editor/src/components/editor/first-person-controls.tsx +++ b/packages/editor/src/components/editor/first-person-controls.tsx @@ -1,102 +1,153 @@ 'use client' +import '../../three-types' +import { KeyboardControls } from '@react-three/drei' +import { sceneRegistry, useScene } from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' import { useFrame, useThree } from '@react-three/fiber' -import { useCallback, useEffect, useRef } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Euler, Vector3 } from 'three' import useEditor from '../../store/use-editor' +import BVHEcctrl from './first-person/bvh-ecctrl' +import type { BVHEcctrlApi } from './first-person/bvh-ecctrl' +import { + buildFirstPersonColliderWorldFromRegistry, + deriveFirstPersonSpawn, + FIRST_PERSON_SPAWN_EYE_HEIGHT, + type FirstPersonColliderWorld, + type FirstPersonSpawn, +} from './first-person/build-collider-world' -// Average human eye height in meters -const EYE_HEIGHT = 1.65 -// Movement speed in meters per second -const MOVE_SPEED = 5 -// Sprint multiplier when holding Shift -const SPRINT_MULTIPLIER = 2 -// Vertical float speed in meters per second -const VERTICAL_SPEED = 3 -// Mouse look sensitivity -const MOUSE_SENSITIVITY = 0.002 -// Min Y position (eye height above ground) -const MIN_Y = EYE_HEIGHT +const CAMERA_EYE_OFFSET = 0.45 +const LOOK_SENSITIVITY = 0.002 +const CONTROLLER_CENTER_FROM_EYE = 0.85 +const keyboardMap = [ + { name: 'forward', keys: ['ArrowUp', 'KeyW'] }, + { name: 'backward', keys: ['ArrowDown', 'KeyS'] }, + { name: 'leftward', keys: ['ArrowLeft', 'KeyA'] }, + { name: 'rightward', keys: ['ArrowRight', 'KeyD'] }, + { name: 'jump', keys: ['Space'] }, + { name: 'run', keys: ['ShiftLeft', 'ShiftRight'] }, +] -// Reusable vectors to avoid allocations in the render loop -const _forward = new Vector3() -const _right = new Vector3() -const _moveVector = new Vector3() -const _euler = new Euler(0, 0, 0, 'YXZ') +const cameraOffset = new Vector3(0, CAMERA_EYE_OFFSET, 0) +const cameraEuler = new Euler(0, 0, 0, 'YXZ') +const spawnWorldPosition = new Vector3() +const spawnWorldEuler = new Euler(0, 0, 0, 'YXZ') + +const resolvePlacedSpawnNode = ( + nodes: ReturnType['nodes'], + _levelId: string | null, + ) => { + const candidates = Object.values(nodes).filter((node) => node.type === 'spawn') + if (candidates.length === 0) return null + + return [...candidates].sort((a, b) => a.id.localeCompare(b.id))[0] ?? null +} export const FirstPersonControls = () => { const { camera, gl } = useThree() - const keysRef = useRef>(new Set()) + const selectedLevelId = useViewer((state) => state.selection.levelId) + const placedSpawnNode = useScene((state) => resolvePlacedSpawnNode(state.nodes, selectedLevelId)) + const controllerRef = useRef(null) const yawRef = useRef(0) const pitchRef = useRef(0) - const isLockedRef = useRef(false) - const initializedRef = useRef(false) + const [world, setWorld] = useState(null) - // Initialize camera for first-person view: start at center of scene, on the ground - useEffect(() => { - if (initializedRef.current) return - initializedRef.current = true + const placedSpawn = useMemo(() => { + if (!(placedSpawnNode && placedSpawnNode.type === 'spawn')) return null - // Place camera at the origin (center of grid) at eye height, looking along +X - camera.position.set(0, EYE_HEIGHT, 0) - yawRef.current = 0 - pitchRef.current = 0 - }, [camera]) + const spawnObject = sceneRegistry.nodes.get(placedSpawnNode.id) + if (spawnObject) { + spawnObject.updateWorldMatrix(true, false) + spawnObject.getWorldPosition(spawnWorldPosition) + spawnWorldEuler.setFromRotationMatrix(spawnObject.matrixWorld, 'YXZ') - // Pointer lock and event handlers - useEffect(() => { - const canvas = gl.domElement - - const requestLock = () => { - if (!isLockedRef.current) { - canvas.requestPointerLock() + return { + position: [ + spawnWorldPosition.x, + spawnWorldPosition.y + FIRST_PERSON_SPAWN_EYE_HEIGHT, + spawnWorldPosition.z, + ], + yaw: spawnWorldEuler.y, } } - const handlePointerLockChange = () => { - isLockedRef.current = document.pointerLockElement === canvas + return { + position: [ + placedSpawnNode.position[0], + placedSpawnNode.position[1] + FIRST_PERSON_SPAWN_EYE_HEIGHT, + placedSpawnNode.position[2], + ], + yaw: placedSpawnNode.rotation, + } + }, [placedSpawnNode]) + + useEffect(() => { + const nextWorld = buildFirstPersonColliderWorldFromRegistry() + if (!nextWorld) { + setWorld(null) + return } - const handleMouseMove = (e: MouseEvent) => { - if (!isLockedRef.current) return + setWorld(nextWorld) - yawRef.current -= e.movementX * MOUSE_SENSITIVITY - pitchRef.current -= e.movementY * MOUSE_SENSITIVITY - // Clamp pitch to prevent flipping (almost straight up/down) + return () => { + nextWorld.dispose() + setWorld(null) + } + }, [camera]) + + useEffect(() => { + if (!world) return + yawRef.current = (placedSpawn ?? deriveFirstPersonSpawn(camera, world)).yaw + pitchRef.current = 0 + }, [camera, placedSpawn, world]) + + useEffect(() => { + const canvas = gl.domElement + const handleMouseMove = (e: MouseEvent) => { + if (document.pointerLockElement !== canvas) return + + yawRef.current -= e.movementX * LOOK_SENSITIVITY pitchRef.current = Math.max( - -Math.PI / 2 + 0.05, - Math.min(Math.PI / 2 - 0.05, pitchRef.current), + -(Math.PI / 2 - 0.05), + Math.min(Math.PI / 2 - 0.05, pitchRef.current - e.movementY * LOOK_SENSITIVITY), ) } - const handleKeyDown = (e: KeyboardEvent) => { - // Skip if user is typing in an input - if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) { + const handleClick = (event: MouseEvent) => { + const target = event.target + if (!(target instanceof HTMLElement)) return + if (!canvas.contains(target)) return + if (document.pointerLockElement !== canvas) { + canvas.requestPointerLock?.() + } + } + + document.addEventListener('mousemove', handleMouseMove) + document.addEventListener('click', handleClick) + + return () => { + document.removeEventListener('mousemove', handleMouseMove) + document.removeEventListener('click', handleClick) + if (document.pointerLockElement === canvas) { + document.exitPointerLock() + } + } + }, [gl]) + + useEffect(() => { + const canvas = gl.domElement + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) { return } - const code = e.code - - // Movement keys - if ( - code === 'KeyW' || - code === 'KeyA' || - code === 'KeyS' || - code === 'KeyD' || - code === 'KeyQ' || - code === 'KeyE' || - code === 'ShiftLeft' || - code === 'ShiftRight' - ) { - e.preventDefault() - e.stopPropagation() - keysRef.current.add(code) - } - - // ESC exits first-person mode - if (code === 'Escape') { - e.preventDefault() - e.stopPropagation() + if (event.code === 'Escape') { + event.preventDefault() + event.stopPropagation() if (document.pointerLockElement === canvas) { document.exitPointerLock() } @@ -104,75 +155,73 @@ export const FirstPersonControls = () => { } } - const handleKeyUp = (e: KeyboardEvent) => { - keysRef.current.delete(e.code) - } - - canvas.addEventListener('click', requestLock) - document.addEventListener('pointerlockchange', handlePointerLockChange) - document.addEventListener('mousemove', handleMouseMove) - // Use capture phase so we intercept movement keys before the global keyboard handler document.addEventListener('keydown', handleKeyDown, true) - document.addEventListener('keyup', handleKeyUp) - return () => { - canvas.removeEventListener('click', requestLock) - document.removeEventListener('pointerlockchange', handlePointerLockChange) - document.removeEventListener('mousemove', handleMouseMove) document.removeEventListener('keydown', handleKeyDown, true) - document.removeEventListener('keyup', handleKeyUp) - if (document.pointerLockElement === canvas) { - document.exitPointerLock() - } - keysRef.current.clear() } }, [gl]) - // Per-frame movement and camera rotation useFrame((_, delta) => { - // Clamp delta to avoid huge jumps (e.g. tab switching) - const dt = Math.min(delta, 0.1) - const keys = keysRef.current + if (!controllerRef.current?.group) return - const isSprinting = keys.has('ShiftLeft') || keys.has('ShiftRight') - const speed = MOVE_SPEED * (isSprinting ? SPRINT_MULTIPLIER : 1) - - // Calculate forward and right vectors on the XZ plane (ignore pitch for movement) - _forward.set(-Math.sin(yawRef.current), 0, -Math.cos(yawRef.current)) - _right.set(Math.cos(yawRef.current), 0, -Math.sin(yawRef.current)) - - _moveVector.set(0, 0, 0) - - if (keys.has('KeyW')) _moveVector.add(_forward) - if (keys.has('KeyS')) _moveVector.sub(_forward) - if (keys.has('KeyA')) _moveVector.sub(_right) - if (keys.has('KeyD')) _moveVector.add(_right) - - // Normalize diagonal movement so it's not faster - if (_moveVector.lengthSq() > 0) { - _moveVector.normalize().multiplyScalar(speed * dt) - camera.position.add(_moveVector) - } - - // Vertical movement (Q = up, E = down) - if (keys.has('KeyQ')) { - camera.position.y += VERTICAL_SPEED * dt - } - if (keys.has('KeyE')) { - camera.position.y -= VERTICAL_SPEED * dt - } - - // Clamp Y so camera never goes below ground level + eye height - if (camera.position.y < MIN_Y) { - camera.position.y = MIN_Y - } - - // Apply look rotation - _euler.set(pitchRef.current, yawRef.current, 0, 'YXZ') - camera.quaternion.setFromEuler(_euler) + const group = controllerRef.current.group + group.rotation.y = 0 + camera.position.copy(group.position).add(cameraOffset) + cameraEuler.set(pitchRef.current, yawRef.current, 0, 'YXZ') + camera.quaternion.setFromEuler(cameraEuler) + camera.updateMatrixWorld(true) }) - return null + const controllerPosition = useMemo(() => { + if (!world) return null + const [x, y, z] = (placedSpawn ?? deriveFirstPersonSpawn(camera, world)).position + return [x, y - CONTROLLER_CENTER_FROM_EYE, z] as const + }, [camera, placedSpawn, world]) + + const spawnYaw = useMemo(() => { + if (!world) return 0 + return (placedSpawn ?? deriveFirstPersonSpawn(camera, world)).yaw + }, [camera, placedSpawn, world]) + + if (!world) { + return null + } + + return ( + <> + {controllerPosition && ( + + + + )} + + ) } /** @@ -180,6 +229,23 @@ export const FirstPersonControls = () => { * Rendered as a regular DOM overlay (not inside the Canvas). */ export const FirstPersonOverlay = ({ onExit }: { onExit: () => void }) => { + const [isLocked, setIsLocked] = useState(false) + const hasPlacedSpawn = useScene((state) => + Object.values(state.nodes).some((node) => node.type === 'spawn'), + ) + + useEffect(() => { + const handlePointerLockChange = () => { + setIsLocked(document.pointerLockElement != null) + } + + handlePointerLockChange() + document.addEventListener('pointerlockchange', handlePointerLockChange) + return () => { + document.removeEventListener('pointerlockchange', handlePointerLockChange) + } + }, []) + const handleExit = useCallback(() => { if (document.pointerLockElement) { document.exitPointerLock() @@ -189,15 +255,15 @@ export const FirstPersonOverlay = ({ onExit }: { onExit: () => void }) => { return ( <> - {/* Crosshair */} -
-
-
-
+ {isLocked && ( +
+
+
+
+
-
+ )} - {/* Exit button — top-right */}
- {/* Controls hint — bottom-center */} -
-
- -
- - -
- -
- Click to look around + {!hasPlacedSpawn && ( +
+
+ Place a Spawn Point from the Build tab to control where walkthrough starts. +
-
+ )} + + {isLocked && ( +
+
+ +
+ + +
+ Click to look around +
+
+ )} ) } function ControlHint({ label, keys }: { label: string; keys: string[] }) { return ( -
+
{label} -
+
{keys.map((key) => ( ) } + +function InlineControlHint({ label, keyLabel }: { label: string; keyLabel: string }) { + return ( +
+ + {label} + + + {keyLabel} + +
+ ) +} diff --git a/packages/editor/src/components/editor/first-person/build-collider-world.ts b/packages/editor/src/components/editor/first-person/build-collider-world.ts new file mode 100644 index 00000000..d6a6e82e --- /dev/null +++ b/packages/editor/src/components/editor/first-person/build-collider-world.ts @@ -0,0 +1,262 @@ +import { sceneRegistry, useScene } from '@pascal-app/core' +import { + acceleratedRaycast, + computeBoundsTree, + disposeBoundsTree, +} from 'three-mesh-bvh' +import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' +import * as THREE from 'three' + +const COLLIDER_NODE_TYPES = [ + 'wall', + 'fence', + 'slab', + 'stair', + 'stair-segment', + 'roof', + 'roof-segment', + 'door', + 'item', +] as const + +const SKIPPED_MESH_NAMES = new Set(['cutout', 'collision-mesh']) +const COLLIDER_MATERIAL = new THREE.MeshBasicMaterial() +const DOWN = new THREE.Vector3(0, -1, 0) +const UP = new THREE.Vector3(0, 1, 0) +const SPAWN_EYE_HEIGHT = 1.65 +const RAYCAST_CLEARANCE = 25 + +export const FIRST_PERSON_SPAWN_EYE_HEIGHT = SPAWN_EYE_HEIGHT + +export type FirstPersonColliderWorld = { + mesh: THREE.Mesh + bounds: THREE.Box3 | null + dispose: () => void +} + +export type FirstPersonSpawn = { + position: [number, number, number] + yaw: number +} + +type ColliderNodeType = (typeof COLLIDER_NODE_TYPES)[number] + +function isMesh(object: THREE.Object3D): object is THREE.Mesh { + return 'isMesh' in object && (object as THREE.Mesh).isMesh +} + +function cloneWorldGeometry(mesh: THREE.Mesh) { + const sourceGeometry = mesh.geometry + const position = sourceGeometry.getAttribute('position') + if (!position || position.count < 3) return null + + const workingGeometry = sourceGeometry.index ? sourceGeometry.toNonIndexed() : sourceGeometry.clone() + const cleanGeometry = new THREE.BufferGeometry() + cleanGeometry.setAttribute('position', workingGeometry.getAttribute('position').clone()) + + const normal = workingGeometry.getAttribute('normal') + if (normal) { + cleanGeometry.setAttribute('normal', normal.clone()) + } else { + cleanGeometry.computeVertexNormals() + } + + cleanGeometry.applyMatrix4(mesh.matrixWorld) + workingGeometry.dispose() + + const worldPosition = cleanGeometry.getAttribute('position') + if (!worldPosition || worldPosition.count < 3) { + cleanGeometry.dispose() + return null + } + + return cleanGeometry +} + +function shouldSkipColliderNode(nodeId: string, type: (typeof COLLIDER_NODE_TYPES)[number]) { + if (type !== 'door') return false + + const node = useScene.getState().nodes[nodeId] + if (!node || node.type !== 'door') return false + + if (!node.segments.length) return true + + return node.segments.every((segment) => segment.type === 'empty') +} + +function buildRegisteredNodeTypeLookup() { + const nodeTypes = new Map() + + for (const type of COLLIDER_NODE_TYPES) { + for (const nodeId of sceneRegistry.byType[type]) { + nodeTypes.set(nodeId, type) + } + } + + return nodeTypes +} + +function collectColliderGeometriesFromNode( + root: THREE.Object3D, + rootNodeId: string, + visitedMeshes: WeakSet, + registeredObjectIds: Map, + registeredNodeTypes: Map, +): THREE.BufferGeometry[] { + const geometries: THREE.BufferGeometry[] = [] + + const visit = (object: THREE.Object3D) => { + if (visitedMeshes.has(object)) return + visitedMeshes.add(object) + + if (isMesh(object) && object.visible && !SKIPPED_MESH_NAMES.has(object.name)) { + const geometry = cloneWorldGeometry(object) + if (geometry) { + geometries.push(geometry) + } + } + + for (const child of object.children) { + const childNodeId = registeredObjectIds.get(child) + if (childNodeId && childNodeId !== rootNodeId) { + const childType = registeredNodeTypes.get(childNodeId) + if (childType && COLLIDER_NODE_TYPES.includes(childType)) { + continue + } + } + + visit(child) + } + } + + visit(root) + + return geometries +} + +export function buildFirstPersonColliderWorldFromRegistry(): FirstPersonColliderWorld | null { + const geometries: THREE.BufferGeometry[] = [] + const visitedMeshes = new WeakSet() + const registeredNodeTypes = buildRegisteredNodeTypeLookup() + const registeredObjectIds = new Map() + + for (const [nodeId, object] of sceneRegistry.nodes) { + registeredObjectIds.set(object, nodeId) + } + + for (const type of COLLIDER_NODE_TYPES) { + for (const nodeId of sceneRegistry.byType[type]) { + if (shouldSkipColliderNode(nodeId, type)) continue + + const root = sceneRegistry.nodes.get(nodeId) + if (!root) continue + + root.updateMatrixWorld(true) + geometries.push( + ...collectColliderGeometriesFromNode( + root, + nodeId, + visitedMeshes, + registeredObjectIds, + registeredNodeTypes, + ), + ) + } + } + + if (geometries.length === 0) { + return null + } + + const mergedGeometry = mergeGeometries(geometries, false) + geometries.forEach((geometry) => geometry.dispose()) + + if (!mergedGeometry || mergedGeometry.getAttribute('position') == null) { + mergedGeometry?.dispose() + return null + } + + const bvhGeometry = mergedGeometry as THREE.BufferGeometry & { + computeBoundsTree?: typeof computeBoundsTree + disposeBoundsTree?: typeof disposeBoundsTree + } + + ;(bvhGeometry as any).computeBoundsTree = computeBoundsTree + ;(bvhGeometry as any).disposeBoundsTree = disposeBoundsTree + bvhGeometry.computeBoundsTree?.({ + maxLeafTris: 12, + strategy: 0, + } as never) + bvhGeometry.computeBoundingBox() + + const mesh = new THREE.Mesh(bvhGeometry, COLLIDER_MATERIAL) + mesh.raycast = acceleratedRaycast + mesh.visible = true + mesh.userData = { + type: 'STATIC', + friction: 0.8, + restitution: 0.05, + excludeFloatHit: false, + excludeCollisionCheck: false, + } + mesh.updateMatrixWorld(true) + + return { + mesh, + bounds: bvhGeometry.boundingBox?.clone() ?? null, + dispose: () => { + bvhGeometry.disposeBoundsTree?.() + bvhGeometry.dispose() + }, + } +} + +export function deriveFirstPersonSpawn( + camera: THREE.Camera, + world: FirstPersonColliderWorld, +): FirstPersonSpawn { + const direction = new THREE.Vector3() + camera.getWorldDirection(direction) + direction.y = 0 + if (direction.lengthSq() < 1e-6) { + direction.set(0, 0, -1) + } else { + direction.normalize() + } + + const yaw = Math.atan2(-direction.x, -direction.z) + const raycaster = new THREE.Raycaster() + const candidates: Array<[number, number]> = [[camera.position.x, camera.position.z]] + + const boundsCenter = world.bounds?.getCenter(new THREE.Vector3()) + if (boundsCenter) { + candidates.push([boundsCenter.x, boundsCenter.z]) + } + + for (const [x, z] of candidates) { + const topY = Math.max(world.bounds?.max.y ?? camera.position.y, camera.position.y) + RAYCAST_CLEARANCE + raycaster.set(new THREE.Vector3(x, topY, z), DOWN) + const intersections = raycaster.intersectObject(world.mesh, false) + const hit = intersections.find((intersection) => { + if (!intersection.face) return true + const normal = intersection.face.normal.clone().transformDirection(world.mesh.matrixWorld) + return normal.dot(UP) > 0.2 + }) + + if (hit) { + return { + position: [hit.point.x, hit.point.y + SPAWN_EYE_HEIGHT, hit.point.z], + yaw, + } + } + } + + return { + position: [ + camera.position.x, + Math.max(camera.position.y, SPAWN_EYE_HEIGHT), + camera.position.z, + ], + yaw, + } +} diff --git a/packages/editor/src/components/editor/first-person/bvh-ecctrl.tsx b/packages/editor/src/components/editor/first-person/bvh-ecctrl.tsx new file mode 100644 index 00000000..2a3e1e79 --- /dev/null +++ b/packages/editor/src/components/editor/first-person/bvh-ecctrl.tsx @@ -0,0 +1,795 @@ +import '../../../three-types' +import { TransformControls, useKeyboardControls } from '@react-three/drei' +import { useFrame, useThree, type ThreeElements } from '@react-three/fiber' +import { + Suspense, + forwardRef, + useCallback, + useImperativeHandle, + useMemo, + useRef, +} from 'react' +import type { ReactNode } from 'react' +import * as THREE from 'three' +import { clamp } from 'three/src/math/MathUtils.js' + +export type MovementInput = { + forward?: boolean + backward?: boolean + leftward?: boolean + rightward?: boolean + joystick?: { x: number; y: number } + run?: boolean + jump?: boolean +} + +export type CharacterAnimationStatus = + | 'IDLE' + | 'WALK' + | 'RUN' + | 'JUMP_START' + | 'JUMP_IDLE' + | 'JUMP_FALL' + | 'JUMP_LAND' + +export type FloatCheckType = 'RAYCAST' | 'SHAPECAST' | 'BOTH' + +export interface BVHEcctrlApi { + group: THREE.Group | null + model: THREE.Group | null + resetLinVel: () => void + addLinVel: (v: THREE.Vector3) => void + setLinVel: (v: THREE.Vector3) => void + setMovement: (input: MovementInput) => void +} + +export interface EcctrlProps extends Omit { + children?: ReactNode + debug?: boolean + colliderMeshes?: THREE.Mesh[] + colliderCapsuleArgs?: [ + radius: number, + length: number, + capSegments: number, + radialSegments: number, + ] + paused?: boolean + delay?: number + gravity?: number + fallGravityFactor?: number + maxFallSpeed?: number + mass?: number + sleepTimeout?: number + slowMotionFactor?: number + turnSpeed?: number + maxWalkSpeed?: number + maxRunSpeed?: number + acceleration?: number + deceleration?: number + counterAccFactor?: number + airDragFactor?: number + jumpVel?: number + floatCheckType?: FloatCheckType + maxSlope?: number + floatHeight?: number + floatPullBackHeight?: number + floatSensorRadius?: number + floatSpringK?: number + floatDampingC?: number + collisionCheckIteration?: number + collisionPushBackDamping?: number + collisionPushBackThreshold?: number +} + +type CharacterStatus = { + position: THREE.Vector3 + linvel: THREE.Vector3 + quaternion: THREE.Quaternion + inputDir: THREE.Vector3 + movingDir: THREE.Vector3 + isOnGround: boolean + isOnMovingPlatform: boolean + animationStatus: CharacterAnimationStatus +} + +export const characterStatus: CharacterStatus = { + position: new THREE.Vector3(), + linvel: new THREE.Vector3(), + quaternion: new THREE.Quaternion(), + inputDir: new THREE.Vector3(), + movingDir: new THREE.Vector3(), + isOnGround: false, + isOnMovingPlatform: false, + animationStatus: 'IDLE', +} + +const BVHEcctrl = forwardRef( + ( + { + children, + debug = false, + colliderMeshes = [], + colliderCapsuleArgs = [0.3, 0.6, 4, 8], + paused = false, + delay = 1.5, + gravity = 9.81, + fallGravityFactor = 4, + maxFallSpeed = 50, + mass = 1, + sleepTimeout = 10, + slowMotionFactor = 1, + turnSpeed = 15, + maxWalkSpeed = 3, + maxRunSpeed = 5, + acceleration = 30, + deceleration = 20, + counterAccFactor = 0.5, + airDragFactor = 0.3, + jumpVel = 5, + floatCheckType = 'BOTH', + maxSlope = 1, + floatHeight = 0.2, + floatPullBackHeight = 0.25, + floatSensorRadius = 0.12, + floatSpringK = 600, + floatDampingC = 28, + collisionCheckIteration = 3, + collisionPushBackDamping = 0.1, + collisionPushBackThreshold = 0.05, + ...props + }, + ref, + ) => { + const { camera } = useThree() + const capsuleRadius = useMemo(() => colliderCapsuleArgs[0], [colliderCapsuleArgs]) + const capsuleLength = useMemo(() => colliderCapsuleArgs[1], [colliderCapsuleArgs]) + const characterGroupRef = useRef(null) + const characterColliderRef = useRef(null) + const characterModelRef = useRef(null) + const debugLineStart = useRef(null) + const debugLineEnd = useRef(null) + const debugRaySensorStart = useRef(null) + const debugRaySensorEnd = useRef(null) + const standPointRef = useRef(null) + const lookDirRef = useRef(null) + const inputDirRef = useRef(null) + const moveDirRef = useRef(null) + const elapsedRef = useRef(0) + + function useIsInsideKeyboardControls() { + try { + return !!useKeyboardControls() + } catch { + return false + } + } + + const isInsideKeyboardControls = useIsInsideKeyboardControls() + const [_, getKeys] = isInsideKeyboardControls ? useKeyboardControls() : [null, null] + const presetKeys = { + forward: false, + backward: false, + leftward: false, + rightward: false, + jump: false, + run: false, + } + + const upAxis = useRef(new THREE.Vector3(0, 1, 0)) + const localUpAxis = useRef(new THREE.Vector3()) + const gravityDir = useRef(new THREE.Vector3(0, -1, 0)) + const currentLinVel = useRef(new THREE.Vector3()) + const currentLinVelOnPlane = useRef(new THREE.Vector3()) + const isFalling = useRef(false) + const idleTime = useRef(0) + const isSleeping = useRef(false) + const camProjDir = useRef(new THREE.Vector3()) + const camRightDir = useRef(new THREE.Vector3()) + const inputDir = useRef(new THREE.Vector3()) + const inputDirOnPlane = useRef(new THREE.Vector3()) + const movingDir = useRef(new THREE.Vector3()) + const deltaLinVel = useRef(new THREE.Vector3()) + const wantToMoveVel = useRef(new THREE.Vector3()) + const forwardState = useRef(false) + const backwardState = useRef(false) + const leftwardState = useRef(false) + const rightwardState = useRef(false) + const joystickState = useRef(new THREE.Vector2()) + const runState = useRef(false) + const jumpState = useRef(false) + const isOnGround = useRef(false) + const prevIsOnGround = useRef(false) + const prevAnimation = useRef('IDLE') + const characterModelTargetQuat = useRef(new THREE.Quaternion()) + const characterModelLookMatrix = useRef(new THREE.Matrix4()) + const characterOrigin = useMemo(() => new THREE.Vector3(0, 0, 0), []) + const contactDepth = useRef(0) + const contactNormal = useRef(new THREE.Vector3()) + const triContactPoint = useRef(new THREE.Vector3()) + const capsuleContactPoint = useRef(new THREE.Vector3()) + const totalDepth = useRef(0) + const triangleCount = useRef(0) + const accumulatedContactNormal = useRef(new THREE.Vector3()) + const accumulatedContactPoint = useRef(new THREE.Vector3()) + const absorbVel = useRef(new THREE.Vector3()) + const pushBackVel = useRef(new THREE.Vector3()) + const characterBbox = useRef(new THREE.Box3()) + const characterSegment = useRef(new THREE.Line3()) + const localCharacterBbox = useRef(new THREE.Box3()) + const localCharacterSegment = useRef(new THREE.Line3()) + const collideInvertMatrix = useRef(new THREE.Matrix4()) + const relativeCollideVel = useRef(new THREE.Vector3()) + const scaledContactRadiusVec = useRef(new THREE.Vector3()) + const deltaDist = useRef(new THREE.Vector3()) + const currSlopeAngle = useRef(0) + const localMinDistance = useRef(Infinity) + const localClosestPoint = useRef(new THREE.Vector3()) + const localHitNormal = useRef(new THREE.Vector3()) + const triNormal = useRef(new THREE.Vector3()) + const globalMinDistance = useRef(Infinity) + const globalClosestPoint = useRef(new THREE.Vector3()) + const triHitPoint = useRef(new THREE.Vector3()) + const segHitPoint = useRef(new THREE.Vector3()) + const floatHitNormal = useRef(new THREE.Vector3()) + const groundFriction = useRef(0.8) + const floatSensorBbox = useRef(new THREE.Box3()) + const floatSensorBboxExpendPoint = useRef(new THREE.Vector3()) + const floatSensorSegment = useRef(new THREE.Line3()) + const localFloatSensorBbox = useRef(new THREE.Box3()) + const localFloatSensorBboxExpendPoint = useRef(new THREE.Vector3()) + const localFloatSensorSegment = useRef(new THREE.Line3()) + const floatInvertMatrix = useRef(new THREE.Matrix4()) + const floatNormalInverseMatrix = useRef(new THREE.Matrix3()) + const floatNormalMatrix = useRef(new THREE.Matrix3()) + const floatRaycaster = useRef(new THREE.Raycaster()) + const relativeHitPoint = useRef(new THREE.Vector3()) + const totalPlatformDeltaPos = useRef(new THREE.Vector3()) + const isOnMovingPlatform = useRef(false) + const floatTempPos = useRef(new THREE.Vector3()) + const floatTempQuat = useRef(new THREE.Quaternion()) + const floatTempScale = useRef(new THREE.Vector3()) + const scaledFloatRadiusVec = useRef(new THREE.Vector3()) + const deltaHit = useRef(new THREE.Vector3()) + const rotationDeltaPos = useRef(new THREE.Vector3()) + const yawQuaternion = useRef(new THREE.Quaternion()) + const contactTempPos = useRef(new THREE.Vector3()) + const contactTempQuat = useRef(new THREE.Quaternion()) + const contactTempScale = useRef(new THREE.Vector3()) + + floatRaycaster.current.far = capsuleRadius + floatHeight + floatPullBackHeight + + const floatRaycastCandidates = useMemo( + () => + colliderMeshes.filter( + (mesh) => mesh.geometry.boundsTree && !(mesh instanceof THREE.InstancedMesh), + ), + [colliderMeshes], + ) + + const applyGravity = useCallback( + (delta: number) => { + gravityDir.current.copy(upAxis.current).negate() + const fallingSpeed = currentLinVel.current.dot(gravityDir.current) + isFalling.current = fallingSpeed > 0 + if (fallingSpeed < maxFallSpeed) { + currentLinVel.current.addScaledVector( + gravityDir.current, + gravity * (isFalling.current ? fallGravityFactor : 1) * delta, + ) + } + }, + [fallGravityFactor, gravity, maxFallSpeed], + ) + + const checkCharacterSleep = useCallback( + (jump: boolean, delta: number) => { + const moving = currentLinVel.current.lengthSq() > 1e-6 + const platformIsMoving = totalPlatformDeltaPos.current.lengthSq() > 1e-6 + + if (!moving && isOnGround.current && !jump && !isOnMovingPlatform.current && !platformIsMoving) { + idleTime.current += delta + if (idleTime.current > sleepTimeout) isSleeping.current = true + } else { + idleTime.current = 0 + isSleeping.current = false + } + }, + [sleepTimeout], + ) + + const setInputDirection = useCallback( + (dir: { + forward?: boolean + backward?: boolean + leftward?: boolean + rightward?: boolean + joystick?: THREE.Vector2 + }) => { + inputDir.current.set(0, 0, 0) + + camera.getWorldDirection(camProjDir.current) + camProjDir.current.projectOnPlane(upAxis.current).normalize() + camRightDir.current.crossVectors(camProjDir.current, upAxis.current).normalize() + + if (dir.joystick && dir.joystick.lengthSq() > 0) { + inputDir.current + .addScaledVector(camProjDir.current, dir.joystick.y) + .addScaledVector(camRightDir.current, dir.joystick.x) + } else { + if (dir.forward) inputDir.current.add(camProjDir.current) + if (dir.backward) inputDir.current.sub(camProjDir.current) + if (dir.leftward) inputDir.current.sub(camRightDir.current) + if (dir.rightward) inputDir.current.add(camRightDir.current) + } + + inputDir.current.normalize() + }, + [camera], + ) + + const handleCharacterMovement = useCallback( + (run: boolean, delta: number) => { + const friction = clamp(groundFriction.current, 0, 1) + + if (inputDir.current.lengthSq() > 0) { + if (characterModelRef.current) { + inputDirOnPlane.current.copy(inputDir.current).projectOnPlane(upAxis.current) + characterModelLookMatrix.current.lookAt( + inputDirOnPlane.current, + characterOrigin, + upAxis.current, + ) + characterModelTargetQuat.current.setFromRotationMatrix(characterModelLookMatrix.current) + characterModelRef.current.quaternion.slerp(characterModelTargetQuat.current, delta * turnSpeed) + } + + const maxSpeed = run ? maxRunSpeed : maxWalkSpeed + wantToMoveVel.current.copy(inputDir.current).multiplyScalar(maxSpeed) + const dot = movingDir.current.dot(inputDir.current) + + deltaLinVel.current.subVectors(wantToMoveVel.current, currentLinVelOnPlane.current) + deltaLinVel.current.clampLength( + 0, + (dot <= 0 ? 1 + counterAccFactor : 1) * + acceleration * + friction * + delta * + (isOnGround.current ? 1 : airDragFactor), + ) + currentLinVel.current.add(deltaLinVel.current) + } else if (isOnGround.current) { + deltaLinVel.current.copy(currentLinVelOnPlane.current).clampLength(0, deceleration * friction * delta) + currentLinVel.current.sub(deltaLinVel.current) + } + }, + [acceleration, airDragFactor, counterAccFactor, deceleration, maxRunSpeed, maxWalkSpeed, turnSpeed, characterOrigin], + ) + + const updateSegmentBBox = useCallback(() => { + if (!characterGroupRef.current) return + + characterSegment.current.start.set(0, capsuleLength / 2, 0).add(characterGroupRef.current.position) + characterSegment.current.end.set(0, -capsuleLength / 2, 0).add(characterGroupRef.current.position) + + characterBbox.current + .makeEmpty() + .expandByPoint(characterSegment.current.start) + .expandByPoint(characterSegment.current.end) + .expandByScalar(capsuleRadius) + + floatSensorSegment.current.start.copy(characterSegment.current.end) + floatSensorSegment.current.end + .copy(floatSensorSegment.current.start) + .addScaledVector(gravityDir.current, floatHeight + capsuleRadius) + floatSensorBboxExpendPoint.current + .copy(floatSensorSegment.current.end) + .addScaledVector(gravityDir.current, floatPullBackHeight) + + floatSensorBbox.current + .makeEmpty() + .expandByPoint(floatSensorSegment.current.start) + .expandByPoint(floatSensorBboxExpendPoint.current) + .expandByScalar(floatSensorRadius) + }, [capsuleLength, capsuleRadius, floatHeight, floatPullBackHeight, floatSensorRadius]) + + const collisionCheck = useCallback( + (mesh: THREE.Mesh, originMatrix: THREE.Matrix4, delta: number) => { + if (!mesh.visible || !mesh.geometry.boundsTree || mesh.userData.excludeCollisionCheck) return + + originMatrix.decompose(contactTempPos.current, contactTempQuat.current, contactTempScale.current) + collideInvertMatrix.current.copy(originMatrix).invert() + localCharacterSegment.current.copy(characterSegment.current).applyMatrix4(collideInvertMatrix.current) + + scaledContactRadiusVec.current.set( + capsuleRadius / contactTempScale.current.x, + capsuleRadius / contactTempScale.current.y, + capsuleRadius / contactTempScale.current.z, + ) + + localCharacterBbox.current + .makeEmpty() + .expandByPoint(localCharacterSegment.current.start) + .expandByPoint(localCharacterSegment.current.end) + localCharacterBbox.current.min.addScaledVector(scaledContactRadiusVec.current, -1) + localCharacterBbox.current.max.add(scaledContactRadiusVec.current) + + contactDepth.current = 0 + contactNormal.current.set(0, 0, 0) + absorbVel.current.set(0, 0, 0) + pushBackVel.current.set(0, 0, 0) + totalDepth.current = 0 + triangleCount.current = 0 + accumulatedContactNormal.current.set(0, 0, 0) + accumulatedContactPoint.current.set(0, 0, 0) + + mesh.geometry.boundsTree.shapecast({ + intersectsBounds: (box) => box.intersectsBox(localCharacterBbox.current), + intersectsTriangle: (tri) => { + tri.closestPointToSegment( + localCharacterSegment.current, + triContactPoint.current, + capsuleContactPoint.current, + ) + + deltaDist.current.copy(triContactPoint.current).sub(capsuleContactPoint.current) + deltaDist.current.divide(scaledContactRadiusVec.current) + + if (deltaDist.current.lengthSq() < 1) { + triContactPoint.current.applyMatrix4(originMatrix) + capsuleContactPoint.current.applyMatrix4(originMatrix) + + contactNormal.current + .copy(capsuleContactPoint.current) + .sub(triContactPoint.current) + .normalize() + contactDepth.current = + capsuleRadius - capsuleContactPoint.current.distanceTo(triContactPoint.current) + + accumulatedContactNormal.current.addScaledVector(contactNormal.current, contactDepth.current) + accumulatedContactPoint.current.add(triContactPoint.current) + totalDepth.current += contactDepth.current + triangleCount.current += 1 + } + }, + }) + + if (triangleCount.current > 0) { + accumulatedContactNormal.current.normalize() + accumulatedContactPoint.current.divideScalar(triangleCount.current) + const avgDepth = totalDepth.current / triangleCount.current + relativeCollideVel.current.copy(currentLinVel.current) + const intoSurfaceVel = relativeCollideVel.current.dot(accumulatedContactNormal.current) + + if (intoSurfaceVel < 0) { + absorbVel.current + .copy(accumulatedContactNormal.current) + .multiplyScalar(-intoSurfaceVel * (1 + (mesh.userData.restitution ?? 0.05))) + currentLinVel.current.add(absorbVel.current) + } + + if (avgDepth > collisionPushBackThreshold) { + const correction = (collisionPushBackDamping / delta) * avgDepth + pushBackVel.current.copy(accumulatedContactNormal.current).multiplyScalar(correction) + currentLinVel.current.add(pushBackVel.current) + } + } + }, + [capsuleRadius, collisionPushBackDamping, collisionPushBackThreshold], + ) + + const handleCollisionResponse = useCallback( + (meshes: THREE.Mesh[], delta: number) => { + if (meshes.length === 0) return + + for (let iteration = 0; iteration < collisionCheckIteration; iteration += 1) { + for (const mesh of meshes) { + collisionCheck(mesh, mesh.matrixWorld, delta) + } + } + }, + [collisionCheck, collisionCheckIteration], + ) + + const floatingCheck = useCallback( + (mesh: THREE.Mesh, originMatrix: THREE.Matrix4) => { + if (!mesh.visible || !mesh.geometry.boundsTree || mesh.userData.excludeFloatHit) return + + originMatrix.decompose(floatTempPos.current, floatTempQuat.current, floatTempScale.current) + floatInvertMatrix.current.copy(originMatrix).invert() + floatNormalInverseMatrix.current.getNormalMatrix(floatInvertMatrix.current) + floatNormalMatrix.current.getNormalMatrix(originMatrix) + + localFloatSensorSegment.current.copy(floatSensorSegment.current).applyMatrix4(floatInvertMatrix.current) + localFloatSensorBboxExpendPoint.current + .copy(floatSensorBboxExpendPoint.current) + .applyMatrix4(floatInvertMatrix.current) + + scaledFloatRadiusVec.current.set( + floatSensorRadius / floatTempScale.current.x, + floatSensorRadius / floatTempScale.current.y, + floatSensorRadius / floatTempScale.current.z, + ) + + localFloatSensorBbox.current + .makeEmpty() + .expandByPoint(localFloatSensorSegment.current.start) + .expandByPoint(localFloatSensorBboxExpendPoint.current) + localFloatSensorBbox.current.min.addScaledVector(scaledFloatRadiusVec.current, -1) + localFloatSensorBbox.current.max.add(scaledFloatRadiusVec.current) + + localMinDistance.current = Infinity + localClosestPoint.current.set(Infinity, Infinity, Infinity) + + mesh.geometry.boundsTree.shapecast({ + intersectsBounds: (box) => box.intersectsBox(localFloatSensorBbox.current), + intersectsTriangle: (tri) => { + tri.closestPointToSegment(localFloatSensorSegment.current, triHitPoint.current, segHitPoint.current) + localUpAxis.current.copy(upAxis.current).applyMatrix3(floatNormalInverseMatrix.current).normalize() + deltaHit.current.subVectors(triHitPoint.current, localFloatSensorSegment.current.start) + deltaHit.current.divide(scaledFloatRadiusVec.current) + + const totalLengthSq = deltaHit.current.lengthSq() + const dot = deltaHit.current.dot(localUpAxis.current) + const verticalLength = Math.abs(dot) / ((capsuleRadius + floatHeight + floatPullBackHeight) / floatSensorRadius) + const horizontalLength = Math.sqrt(Math.max(0, totalLengthSq - dot * dot)) + + if (horizontalLength < 1 && verticalLength < 1) { + tri.getNormal(triNormal.current) + triNormal.current.applyMatrix3(floatNormalMatrix.current).normalize() + triHitPoint.current.applyMatrix4(originMatrix) + + const slopeAngle = triNormal.current.angleTo(upAxis.current) + if (verticalLength < localMinDistance.current && slopeAngle < maxSlope) { + localMinDistance.current = verticalLength + localClosestPoint.current.copy(triHitPoint.current) + localHitNormal.current.copy(triNormal.current) + } + } + }, + }) + + if (localMinDistance.current < globalMinDistance.current) { + globalMinDistance.current = localMinDistance.current + globalClosestPoint.current.copy(localClosestPoint.current) + floatHitNormal.current.copy(localHitNormal.current) + } + }, + [capsuleRadius, floatHeight, floatPullBackHeight, floatSensorRadius, maxSlope], + ) + + const handleFloatingResponse = useCallback( + (meshes: THREE.Mesh[], jump: boolean, delta: number) => { + if (meshes.length === 0) return + + globalMinDistance.current = Infinity + globalClosestPoint.current.set(Infinity, Infinity, Infinity) + floatHitNormal.current.set(0, 1, 0) + isOnGround.current = false + totalPlatformDeltaPos.current.set(0, 0, 0) + isOnMovingPlatform.current = false + + if (floatCheckType !== 'RAYCAST') { + for (const mesh of meshes) { + floatingCheck(mesh, mesh.matrixWorld) + } + } + + if (floatCheckType !== 'SHAPECAST' && floatRaycastCandidates.length > 0 && globalMinDistance.current === Infinity) { + floatRaycaster.current.ray.origin.copy(floatSensorSegment.current.start) + floatRaycaster.current.ray.direction.copy(gravityDir.current) + const hits = floatRaycaster.current.intersectObjects(floatRaycastCandidates, false) + const hit = hits[0] + if (hit?.point) { + globalClosestPoint.current.copy(hit.point) + if (hit.face) { + floatHitNormal.current.copy(hit.face.normal).transformDirection(hit.object.matrixWorld).normalize() + } + } + } + + if (globalClosestPoint.current.x === Infinity) return + + relativeHitPoint.current.copy(globalClosestPoint.current).sub(floatSensorSegment.current.start) + const currentDistance = relativeHitPoint.current.length() + currSlopeAngle.current = floatHitNormal.current.angleTo(upAxis.current) + + if (currentDistance < floatHeight + capsuleRadius) { + isOnGround.current = true + jump = false + } + + if (!jump) { + const displacement = floatHeight + capsuleRadius - currentDistance + const velocityOnHitNormal = currentLinVel.current.dot(floatHitNormal.current) + const springForce = displacement * floatSpringK + const dampingForce = -velocityOnHitNormal * floatDampingC + const totalForce = springForce + dampingForce - mass * gravity + + currentLinVel.current.addScaledVector(floatHitNormal.current, (totalForce / mass) * delta) + } + }, + [capsuleRadius, floatCheckType, floatDampingC, floatHeight, floatRaycastCandidates, floatSpringK, floatingCheck, gravity, mass], + ) + + const updateCharacterWithPlatform = useCallback(() => { + if (!characterGroupRef.current) return + rotationDeltaPos.current.copy(totalPlatformDeltaPos.current) + characterGroupRef.current.position.add(rotationDeltaPos.current) + yawQuaternion.current.setFromUnitVectors(upAxis.current, floatHitNormal.current) + }, [upAxis]) + + const updateCharacterAnimation = useCallback( + (run: boolean, jump: boolean): CharacterAnimationStatus => { + if (prevIsOnGround.current && jump) return 'JUMP_START' + if (!isOnGround.current && currentLinVel.current.y > 0) return 'JUMP_IDLE' + if (!isOnGround.current && currentLinVel.current.y <= 0) return 'JUMP_FALL' + if (!prevIsOnGround.current && isOnGround.current) return 'JUMP_LAND' + if (inputDir.current.lengthSq() > 0) return run ? 'RUN' : 'WALK' + return 'IDLE' + }, + [], + ) + + const updateCharacterStatus = useCallback( + (run: boolean, jump: boolean) => { + characterModelRef.current?.getWorldPosition(characterStatus.position) + characterModelRef.current?.getWorldQuaternion(characterStatus.quaternion) + characterStatus.linvel.copy(currentLinVel.current) + characterStatus.inputDir.copy(inputDir.current) + characterStatus.movingDir.copy(movingDir.current) + characterStatus.isOnGround = isOnGround.current + characterStatus.isOnMovingPlatform = isOnMovingPlatform.current + characterStatus.animationStatus = updateCharacterAnimation(run, jump) + prevAnimation.current = characterStatus.animationStatus + }, + [updateCharacterAnimation], + ) + + const resetLinVel = useCallback(() => currentLinVel.current.set(0, 0, 0), []) + const addLinVel = useCallback((velocity: THREE.Vector3) => currentLinVel.current.add(velocity), []) + const setLinVel = useCallback((velocity: THREE.Vector3) => currentLinVel.current.copy(velocity), []) + const setMovement = useCallback((movement: MovementInput) => { + if (movement.forward !== undefined) forwardState.current = movement.forward + if (movement.backward !== undefined) backwardState.current = movement.backward + if (movement.leftward !== undefined) leftwardState.current = movement.leftward + if (movement.rightward !== undefined) rightwardState.current = movement.rightward + if (movement.joystick) joystickState.current.set(movement.joystick.x, movement.joystick.y) + if (movement.run !== undefined) runState.current = movement.run + if (movement.jump !== undefined) jumpState.current = movement.jump + }, []) + + useImperativeHandle( + ref, + () => ({ + get group() { + return characterGroupRef.current + }, + get model() { + return characterModelRef.current + }, + resetLinVel, + addLinVel, + setLinVel, + setMovement, + }), + [addLinVel, resetLinVel, setLinVel, setMovement], + ) + + const updateDebugger = useCallback(() => { + debugLineStart.current?.position.copy(characterSegment.current.start) + debugLineEnd.current?.position.copy(characterSegment.current.end) + debugRaySensorStart.current?.position.copy(floatSensorSegment.current.start) + debugRaySensorEnd.current?.position.copy(floatSensorSegment.current.end) + standPointRef.current?.position.copy(globalClosestPoint.current) + if (characterGroupRef.current) { + lookDirRef.current?.position.copy(characterGroupRef.current.position).addScaledVector(upAxis.current, 0.7) + } + lookDirRef.current?.lookAt(lookDirRef.current.position.clone().add(camProjDir.current)) + inputDirRef.current?.position.copy(characterSegment.current.end) + inputDirRef.current?.setDirection(inputDir.current) + inputDirRef.current?.setLength(inputDir.current.lengthSq()) + moveDirRef.current?.position.copy(characterSegment.current.end) + moveDirRef.current?.setDirection(currentLinVel.current) + moveDirRef.current?.setLength(currentLinVel.current.length() / maxWalkSpeed) + }, [characterSegment, maxWalkSpeed]) + + useFrame((_, delta) => { + elapsedRef.current += delta + if (paused || elapsedRef.current < delay) return + + const deltaTime = Math.min(1 / 45, delta) * slowMotionFactor + const keys = isInsideKeyboardControls && getKeys ? getKeys() : presetKeys + const forward = forwardState.current || keys.forward + const backward = backwardState.current || keys.backward + const leftward = leftwardState.current || keys.leftward + const rightward = rightwardState.current || keys.rightward + const run = runState.current || keys.run + const jump = jumpState.current || keys.jump + + setInputDirection({ + forward, + backward, + leftward, + rightward, + joystick: joystickState.current, + }) + handleCharacterMovement(run, deltaTime) + if (jump && isOnGround.current) currentLinVel.current.y = jumpVel + movingDir.current.copy(currentLinVel.current).normalize() + currentLinVelOnPlane.current.copy(currentLinVel.current).projectOnPlane(upAxis.current) + + checkCharacterSleep(jump, deltaTime) + if (!isSleeping.current) { + if (!isOnGround.current) applyGravity(deltaTime) + + updateSegmentBBox() + handleCollisionResponse(colliderMeshes, deltaTime) + handleFloatingResponse(colliderMeshes, jump, deltaTime) + updateCharacterWithPlatform() + + if (characterGroupRef.current) { + characterGroupRef.current.position.addScaledVector(currentLinVel.current, deltaTime) + } + + updateCharacterStatus(run, jump) + prevIsOnGround.current = isOnGround.current + } + + if (debug) updateDebugger() + }) + + return ( + + + {debug && ( + + + + + )} + + {children} + + + + {debug && ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + )} + + ) + }, +) + +BVHEcctrl.displayName = 'BVHEcctrl' + +export default BVHEcctrl diff --git a/packages/editor/src/components/editor/floating-action-menu.tsx b/packages/editor/src/components/editor/floating-action-menu.tsx index a3ab3da8..ab3a3bc8 100755 --- a/packages/editor/src/components/editor/floating-action-menu.tsx +++ b/packages/editor/src/components/editor/floating-action-menu.tsx @@ -10,6 +10,7 @@ import { RoofNode, RoofSegmentNode, type SlabNode, + SpawnNode, StairNode, StairSegmentNode, sceneRegistry, @@ -39,6 +40,7 @@ const ALLOWED_TYPES = [ 'fence', 'slab', 'ceiling', + 'spawn', ] const DELETE_ONLY_TYPES: string[] = [] const HOLE_TYPES = ['slab', 'ceiling'] @@ -184,6 +186,7 @@ export function FloatingActionMenu() { node.type === 'fence' || node.type === 'slab' || node.type === 'ceiling' || + node.type === 'spawn' || node.type === 'roof' || node.type === 'roof-segment' || node.type === 'stair' || @@ -266,6 +269,8 @@ export function FloatingActionMenu() { duplicate = StairNode.parse(duplicateInfo) } else if (node.type === 'stair-segment') { duplicate = StairSegmentNode.parse(duplicateInfo) + } else if (node.type === 'spawn') { + duplicate = SpawnNode.parse(duplicateInfo) } } catch (error) { console.error('Failed to parse duplicate', error) @@ -358,6 +363,7 @@ export function FloatingActionMenu() { duplicate.type === 'door' || duplicate.type === 'roof' || duplicate.type === 'roof-segment' || + duplicate.type === 'spawn' || duplicate.type === 'stair-segment' ) { setMovingNode(duplicate as any) @@ -453,7 +459,10 @@ export function FloatingActionMenu() { } onDelete={handleDelete} onDuplicate={ - node && !DELETE_ONLY_TYPES.includes(node.type) && !HOLE_TYPES.includes(node.type) + node && + node.type !== 'spawn' && + !DELETE_ONLY_TYPES.includes(node.type) && + !HOLE_TYPES.includes(node.type) ? handleDuplicate : undefined } diff --git a/packages/editor/src/components/editor/index.tsx b/packages/editor/src/components/editor/index.tsx index a7591d90..cebe767f 100644 --- a/packages/editor/src/components/editor/index.tsx +++ b/packages/editor/src/components/editor/index.tsx @@ -939,7 +939,9 @@ export default function Editor({ presetsAdapter, commandPaletteEmptyAction, }: EditorProps) { - useKeyboard({ isVersionPreviewMode }) + const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode) + + useKeyboard({ isVersionPreviewMode, disabled: isFirstPersonMode }) const { isLoadingSceneRef } = useAutoSave({ onSave, @@ -951,7 +953,6 @@ export default function Editor({ const [isSceneLoading, setIsSceneLoading] = useState(false) const [hasLoadedInitialScene, setHasLoadedInitialScene] = useState(false) const isPreviewMode = useEditor((s) => s.isPreviewMode) - const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode) const sidebarWidth = useSidebarStore((s) => s.width) const isSidebarCollapsed = useSidebarStore((s) => s.isCollapsed) diff --git a/packages/editor/src/components/editor/selection-manager.tsx b/packages/editor/src/components/editor/selection-manager.tsx index 884fc80d..b42ee1c8 100755 --- a/packages/editor/src/components/editor/selection-manager.tsx +++ b/packages/editor/src/components/editor/selection-manager.tsx @@ -73,6 +73,7 @@ type SelectableNodeType = | 'roof-segment' | 'stair' | 'stair-segment' + | 'spawn' | 'window' | 'door' @@ -548,6 +549,7 @@ const SELECTION_STRATEGIES: Record = { 'roof-segment', 'stair', 'stair-segment', + 'spawn', 'window', 'door', ], @@ -598,7 +600,8 @@ const SELECTION_STRATEGIES: Record = { node.type === 'roof' || node.type === 'roof-segment' || node.type === 'stair' || - node.type === 'stair-segment' + node.type === 'stair-segment' || + node.type === 'spawn' ) return true if (node.type === 'item') { @@ -661,6 +664,7 @@ const getSelectionTarget = (node: AnyNode): SelectionTarget | null => { node.type === 'roof-segment' || node.type === 'stair' || node.type === 'stair-segment' || + node.type === 'spawn' || node.type === 'window' || node.type === 'door' ) { @@ -965,6 +969,7 @@ export const SelectionManager = () => { 'roof-segment', 'stair', 'stair-segment', + 'spawn', 'window', 'door', 'zone', @@ -1134,6 +1139,7 @@ export const SelectionManager = () => { 'roof-segment', 'stair', 'stair-segment', + 'spawn', 'window', 'door', ] @@ -1227,6 +1233,7 @@ export const SelectionManager = () => { node.type === 'roof-segment' || node.type === 'stair' || node.type === 'stair-segment' || + node.type === 'spawn' || node.type === 'window' || node.type === 'door' ) { @@ -1279,6 +1286,7 @@ export const SelectionManager = () => { 'roof-segment', 'stair', 'stair-segment', + 'spawn', 'window', 'door', 'zone', diff --git a/packages/editor/src/components/tools/item/move-tool.tsx b/packages/editor/src/components/tools/item/move-tool.tsx index 8a873692..018e6933 100644 --- a/packages/editor/src/components/tools/item/move-tool.tsx +++ b/packages/editor/src/components/tools/item/move-tool.tsx @@ -7,6 +7,7 @@ import type { RoofNode, RoofSegmentNode, SlabNode, + SpawnNode, StairNode, StairSegmentNode, WallNode, @@ -21,6 +22,7 @@ import { MoveDoorTool } from '../door/move-door-tool' import { MoveFenceTool } from '../fence/move-fence-tool' import { MoveRoofTool } from '../roof/move-roof-tool' import { MoveSlabTool } from '../slab/move-slab-tool' +import { MoveSpawnTool } from '../spawn/move-spawn-tool' import { MoveWallTool } from '../wall/move-wall-tool' import { MoveWindowTool } from '../window/move-window-tool' import type { PlacementState } from './placement-types' @@ -100,6 +102,7 @@ export const MoveTool: React.FC = () => { if (movingNode.type === 'wall') return if (movingNode.type === 'roof' || movingNode.type === 'roof-segment') return + if (movingNode.type === 'spawn') return if (movingNode.type === 'stair' || movingNode.type === 'stair-segment') return return diff --git a/packages/editor/src/components/tools/spawn/move-spawn-tool.tsx b/packages/editor/src/components/tools/spawn/move-spawn-tool.tsx new file mode 100644 index 00000000..386d3f82 --- /dev/null +++ b/packages/editor/src/components/tools/spawn/move-spawn-tool.tsx @@ -0,0 +1,99 @@ +import '../../../three-types' + +import { + emitter, + type GridEvent, + sceneRegistry, + type SpawnNode, + useLiveTransforms, + useScene, +} from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import { useCallback, useEffect, useState } from 'react' +import { Vector3 } from 'three' +import { sfxEmitter } from '../../../lib/sfx-bus' +import useEditor from '../../../store/use-editor' +import { CursorSphere } from '../shared/cursor-sphere' + +const roundToHalf = (value: number) => Math.round(value * 2) / 2 +const worldVector = new Vector3() + +function getLevelLocalSpawnPosition(node: SpawnNode, event: GridEvent): [number, number, number] { + const levelObject = node.parentId ? sceneRegistry.nodes.get(node.parentId) : null + if (!levelObject) { + return [ + roundToHalf(event.localPosition[0]), + event.localPosition[1], + roundToHalf(event.localPosition[2]), + ] + } + + worldVector.set(event.position[0], event.position[1], event.position[2]) + levelObject.updateWorldMatrix(true, false) + levelObject.worldToLocal(worldVector) + + return [roundToHalf(worldVector.x), worldVector.y, roundToHalf(worldVector.z)] +} + +export const MoveSpawnTool: React.FC<{ node: SpawnNode }> = ({ node }) => { + const [previewPosition, setPreviewPosition] = useState<[number, number, number]>(node.position) + + const exitMoveMode = useCallback(() => { + useEditor.getState().setMovingNode(null) + }, []) + + useEffect(() => { + useScene.temporal.getState().pause() + + let committed = false + + const onGridMove = (event: GridEvent) => { + const nextPosition: [number, number, number] = [ + roundToHalf(event.localPosition[0]), + event.localPosition[1], + roundToHalf(event.localPosition[2]), + ] + setPreviewPosition(nextPosition) + useLiveTransforms.getState().set(node.id, { + position: [...nextPosition], + rotation: node.rotation, + }) + } + + const onGridClick = (event: GridEvent) => { + const nextPosition = getLevelLocalSpawnPosition(node, event) + + committed = true + useScene.temporal.getState().resume() + useScene.getState().updateNode(node.id, { position: nextPosition }) + useViewer.getState().setSelection({ selectedIds: [node.id] }) + useLiveTransforms.getState().clear(node.id) + sfxEmitter.emit('sfx:item-place') + exitMoveMode() + } + + const onCancel = () => { + useLiveTransforms.getState().clear(node.id) + useScene.temporal.getState().resume() + exitMoveMode() + } + + emitter.on('grid:move', onGridMove) + emitter.on('grid:click', onGridClick) + emitter.on('tool:cancel', onCancel) + + return () => { + emitter.off('grid:move', onGridMove) + emitter.off('grid:click', onGridClick) + emitter.off('tool:cancel', onCancel) + useLiveTransforms.getState().clear(node.id) + if (!committed) { + useScene.temporal.getState().resume() + } + } + }, [exitMoveMode, node]) + + return ( + + ) +} diff --git a/packages/editor/src/components/tools/spawn/spawn-tool.tsx b/packages/editor/src/components/tools/spawn/spawn-tool.tsx new file mode 100644 index 00000000..c68cfa10 --- /dev/null +++ b/packages/editor/src/components/tools/spawn/spawn-tool.tsx @@ -0,0 +1,126 @@ +import '../../../three-types' + +import { + emitter, + type GridEvent, + type LevelNode, + sceneRegistry, + SpawnNode, + useScene, +} from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import { useEffect, useRef, useState } from 'react' +import type { Group } from 'three' +import { Vector3 } from 'three' +import { sfxEmitter } from '../../../lib/sfx-bus' +import useEditor from '../../../store/use-editor' +import { CursorSphere } from '../shared/cursor-sphere' + +const SPAWN_ICON = ( + // eslint-disable-next-line @next/next/no-img-element + Spawn Point +) + +const roundToHalf = (value: number) => Math.round(value * 2) / 2 +const worldVector = new Vector3() + +function getExistingSpawnIds() { + const nodes = useScene.getState().nodes + return Object.values(nodes) + .filter((node) => node.type === 'spawn') + .map((node) => node.id) + .sort() +} + +function getLevelLocalSpawnPosition( + levelId: LevelNode['id'], + event: GridEvent, +): [number, number, number] { + const levelObject = sceneRegistry.nodes.get(levelId) + if (!levelObject) { + return [ + roundToHalf(event.localPosition[0]), + event.localPosition[1], + roundToHalf(event.localPosition[2]), + ] + } + + worldVector.set(event.position[0], event.position[1], event.position[2]) + levelObject.updateWorldMatrix(true, false) + levelObject.worldToLocal(worldVector) + + return [roundToHalf(worldVector.x), worldVector.y, roundToHalf(worldVector.z)] +} + +export const SpawnTool: React.FC = () => { + const currentLevelId = useViewer((state) => state.selection.levelId) + const [, setCursorPosition] = useState<[number, number, number] | null>(null) + const cursorRef = useRef(null) + + useEffect(() => { + if (!currentLevelId) return + + const onGridMove = (event: GridEvent) => { + const nextPosition: [number, number, number] = [ + roundToHalf(event.localPosition[0]), + event.localPosition[1], + roundToHalf(event.localPosition[2]), + ] + setCursorPosition(nextPosition) + cursorRef.current?.position.set(nextPosition[0], nextPosition[1], nextPosition[2]) + } + + const onGridClick = (event: GridEvent) => { + const nextPosition = getLevelLocalSpawnPosition(currentLevelId, event) + + const [existingSpawnId, ...duplicateSpawnIds] = getExistingSpawnIds() + if (existingSpawnId) { + useScene.getState().updateNode(existingSpawnId, { + parentId: currentLevelId, + position: nextPosition, + rotation: 0, + }) + if (duplicateSpawnIds.length > 0) { + useScene.getState().deleteNodes(duplicateSpawnIds) + } + useViewer.getState().setSelection({ selectedIds: [existingSpawnId] }) + } else { + const spawn = SpawnNode.parse({ + name: 'Spawn Point', + position: nextPosition, + rotation: 0, + }) + useScene.getState().createNode(spawn, currentLevelId) + useViewer.getState().setSelection({ selectedIds: [spawn.id] }) + } + + sfxEmitter.emit('sfx:structure-build') + useEditor.getState().setTool(null) + useEditor.getState().setMode('select') + } + + emitter.on('grid:move', onGridMove) + emitter.on('grid:click', onGridClick) + + return () => { + emitter.off('grid:move', onGridMove) + emitter.off('grid:click', onGridClick) + } + }, [currentLevelId]) + + if (!currentLevelId) return null + + return ( + + ) +} diff --git a/packages/editor/src/components/tools/tool-manager.tsx b/packages/editor/src/components/tools/tool-manager.tsx index 1a4e13e0..d6e461d3 100644 --- a/packages/editor/src/components/tools/tool-manager.tsx +++ b/packages/editor/src/components/tools/tool-manager.tsx @@ -21,6 +21,7 @@ import { SiteBoundaryEditor } from './site/site-boundary-editor' import { SlabBoundaryEditor } from './slab/slab-boundary-editor' import { SlabHoleEditor } from './slab/slab-hole-editor' import { SlabTool } from './slab/slab-tool' +import { SpawnTool } from './spawn/spawn-tool' import { StairTool } from './stair/stair-tool' import { CurveWallTool } from './wall/curve-wall-tool' import { MoveWallEndpointTool } from './wall/move-wall-endpoint-tool' @@ -43,6 +44,7 @@ const tools: Record>> = { door: DoorTool, item: ItemTool, zone: ZoneTool, + spawn: SpawnTool, window: WindowTool, }, furnish: { diff --git a/packages/editor/src/components/ui/action-menu/structure-tools.tsx b/packages/editor/src/components/ui/action-menu/structure-tools.tsx index d0cf44b5..21937885 100644 --- a/packages/editor/src/components/ui/action-menu/structure-tools.tsx +++ b/packages/editor/src/components/ui/action-menu/structure-tools.tsx @@ -30,6 +30,7 @@ export const tools: ToolConfig[] = [ { id: 'window', iconSrc: '/icons/window.png', label: 'Window' }, { id: 'fence', iconSrc: '/icons/fence.png', label: 'Fence' }, { id: 'zone', iconSrc: '/icons/zone.png', label: 'Zone' }, + { id: 'spawn', iconSrc: '/icons/site.png', label: 'Spawn Point' }, ] export function StructureTools() { diff --git a/packages/editor/src/components/ui/controls/slider-control.tsx b/packages/editor/src/components/ui/controls/slider-control.tsx index 937f637d..26dfe55d 100644 --- a/packages/editor/src/components/ui/controls/slider-control.tsx +++ b/packages/editor/src/components/ui/controls/slider-control.tsx @@ -8,6 +8,7 @@ interface SliderControlProps { label: React.ReactNode value: number onChange: (value: number) => void + onCommit?: (value: number) => void min?: number max?: number precision?: number @@ -39,6 +40,7 @@ export function SliderControl({ label, value, onChange, + onCommit, min = Number.NEGATIVE_INFINITY, max = Number.POSITIVE_INFINITY, precision = 0, @@ -76,10 +78,11 @@ export function SliderControl({ const newValue = clamp(valueRef.current + direction * s) const final = Number.parseFloat(newValue.toFixed(stepPrecision(s))) if (final !== valueRef.current) onChange(final) + onCommit?.(final) } el.addEventListener('wheel', handleWheel, { passive: false }) return () => el.removeEventListener('wheel', handleWheel) - }, [isEditing, step, clamp, onChange]) + }, [isEditing, step, clamp, onChange, onCommit]) // Arrow key support while hovered useEffect(() => { @@ -94,11 +97,12 @@ export function SliderControl({ const newValue = clamp(valueRef.current + direction * s) const final = Number.parseFloat(newValue.toFixed(stepPrecision(s))) if (final !== valueRef.current) onChange(final) + onCommit?.(final) } } window.addEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown) - }, [isHovered, isEditing, step, clamp, onChange]) + }, [isHovered, isEditing, step, clamp, onChange, onCommit]) const handleLabelPointerDown = useCallback( (e: React.PointerEvent) => { @@ -140,11 +144,13 @@ export function SliderControl({ onChange(startValue) useScene.temporal.getState().resume() onChange(finalVal) + onCommit?.(finalVal) } else { useScene.temporal.getState().resume() + onCommit?.(finalVal) } }, - [onChange], + [onChange, onCommit], ) const handleValueClick = useCallback(() => { @@ -157,10 +163,12 @@ export function SliderControl({ if (Number.isNaN(numValue)) { setInputValue(value.toFixed(precision)) } else { - onChange(clamp(Number.parseFloat(numValue.toFixed(precision)))) + const nextValue = clamp(Number.parseFloat(numValue.toFixed(precision))) + onChange(nextValue) + onCommit?.(nextValue) } setIsEditing(false) - }, [inputValue, onChange, clamp, precision, value]) + }, [inputValue, onChange, onCommit, clamp, precision, value]) const handleInputKeyDown = useCallback( (e: React.KeyboardEvent) => { diff --git a/packages/editor/src/components/ui/floating-level-selector.tsx b/packages/editor/src/components/ui/floating-level-selector.tsx index d1353d45..67487cee 100755 --- a/packages/editor/src/components/ui/floating-level-selector.tsx +++ b/packages/editor/src/components/ui/floating-level-selector.tsx @@ -302,7 +302,10 @@ export function FloatingLevelSelector() { createNodes(createOps) - setSelection({ buildingId: resolvedBuildingId ?? undefined, levelId: newLevelId }) + setSelection({ + buildingId: resolvedBuildingId ?? undefined, + levelId: newLevelId as LevelNode['id'], + }) }, [createNodes, levels, resolvedBuildingId, setSelection], ) diff --git a/packages/editor/src/components/ui/panels/panel-manager.tsx b/packages/editor/src/components/ui/panels/panel-manager.tsx index cc20f364..1ded905f 100755 --- a/packages/editor/src/components/ui/panels/panel-manager.tsx +++ b/packages/editor/src/components/ui/panels/panel-manager.tsx @@ -12,6 +12,7 @@ import { ReferencePanel } from './reference-panel' import { RoofPanel } from './roof-panel' import { RoofSegmentPanel } from './roof-segment-panel' import { SlabPanel } from './slab-panel' +import { SpawnPanel } from './spawn-panel' import { StairPanel } from './stair-panel' import { StairSegmentPanel } from './stair-segment-panel' import { WallPanel } from './wall-panel' @@ -60,6 +61,8 @@ export function PanelManager() { return case 'slab': return + case 'spawn': + return case 'ceiling': return case 'wall': diff --git a/packages/editor/src/components/ui/panels/spawn-panel.tsx b/packages/editor/src/components/ui/panels/spawn-panel.tsx new file mode 100644 index 00000000..59b9cdc7 --- /dev/null +++ b/packages/editor/src/components/ui/panels/spawn-panel.tsx @@ -0,0 +1,155 @@ +'use client' + +import { type AnyNode, type SpawnNode, useLiveTransforms, useScene } from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import { Move, Trash2 } from 'lucide-react' +import { useCallback, useEffect, useState } from 'react' +import { sfxEmitter } from '../../../lib/sfx-bus' +import useEditor from '../../../store/use-editor' +import { ActionButton, ActionGroup } from '../controls/action-button' +import { PanelSection } from '../controls/panel-section' +import { SliderControl } from '../controls/slider-control' +import { PanelWrapper } from './panel-wrapper' + +export function SpawnPanel() { + const selectedId = useViewer((s) => s.selection.selectedIds[0]) + const setSelection = useViewer((s) => s.setSelection) + const updateNode = useScene((s) => s.updateNode) + const deleteNode = useScene((s) => s.deleteNode) + const setMovingNode = useEditor((s) => s.setMovingNode) + + const node = useScene((s) => + selectedId ? (s.nodes[selectedId as AnyNode['id']] as SpawnNode | undefined) : undefined, + ) + const [draftRotation, setDraftRotation] = useState(null) + + useEffect(() => { + if (!(node && node.type === 'spawn')) { + setDraftRotation(null) + return + } + + setDraftRotation(node.rotation) + useLiveTransforms.getState().clear(node.id) + }, [node?.id, node?.rotation, node?.type]) + + const handleUpdate = useCallback( + (updates: Partial) => { + if (!(selectedId && node)) return + updateNode(selectedId as AnyNode['id'], updates) + }, + [node, selectedId, updateNode], + ) + + const handleRotationChange = useCallback( + (degrees: number) => { + if (!(node && selectedId)) return + const nextRotation = (degrees * Math.PI) / 180 + setDraftRotation(nextRotation) + useLiveTransforms.getState().set(selectedId as AnyNode['id'], { + position: [...node.position], + rotation: nextRotation, + }) + }, + [node, selectedId], + ) + + const commitRotation = useCallback( + (degrees: number) => { + if (!(node && selectedId)) return + const nextRotation = (degrees * Math.PI) / 180 + useLiveTransforms.getState().clear(selectedId as AnyNode['id']) + setDraftRotation(nextRotation) + if (Math.abs(nextRotation - node.rotation) > 1e-6) { + updateNode(selectedId as AnyNode['id'], { rotation: nextRotation }) + } + }, + [node, selectedId, updateNode], + ) + + const handleClose = useCallback(() => { + setSelection({ selectedIds: [] }) + }, [setSelection]) + + const handleMove = useCallback(() => { + if (!node) return + sfxEmitter.emit('sfx:item-pick') + setMovingNode(node) + setSelection({ selectedIds: [] }) + }, [node, setMovingNode, setSelection]) + + const handleDelete = useCallback(() => { + if (!selectedId) return + sfxEmitter.emit('sfx:structure-delete') + deleteNode(selectedId as AnyNode['id']) + setSelection({ selectedIds: [] }) + }, [deleteNode, selectedId, setSelection]) + + if (!(node && node.type === 'spawn' && selectedId)) return null + + const rotationDegrees = Math.round((((draftRotation ?? node.rotation) * 180) / Math.PI)) + const storedRotationDegrees = Math.round((node.rotation * 180) / Math.PI) + + return ( + + + handleUpdate({ position: [value, node.position[1], node.position[2]] })} + precision={2} + step={0.01} + unit="m" + value={Math.round(node.position[0] * 100) / 100} + /> + handleUpdate({ position: [node.position[0], value, node.position[2]] })} + precision={2} + step={0.01} + unit="m" + value={Math.round(node.position[1] * 100) / 100} + /> + handleUpdate({ position: [node.position[0], node.position[1], value] })} + precision={2} + step={0.01} + unit="m" + value={Math.round(node.position[2] * 100) / 100} + /> + + + + + + + + + } label="Move" onClick={handleMove} /> + } + label="Delete" + onClick={handleDelete} + /> + + + + ) +} diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx index 35a6980d..fdbae4e9 100644 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/building-tree-node.tsx @@ -1,4 +1,4 @@ -import { type AnyNodeId, type BuildingNode, LevelNode, useScene } from '@pascal-app/core' +import { type BuildingNode, LevelNode, useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { Building2, Plus } from 'lucide-react' import { memo, useState } from 'react' @@ -12,7 +12,7 @@ import { focusTreeNode, TreeNode, TreeNodeWrapper } from './tree-node' import { TreeNodeActions } from './tree-node-actions' interface BuildingTreeNodeProps { - nodeId: AnyNodeId + nodeId: BuildingNode['id'] depth: number isLast?: boolean } diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx index c1eac313..8fb3d5c0 100755 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx @@ -366,7 +366,7 @@ const ReferenceItem = memo(function ReferenceItem({ setIsEditing(true)} onStopEditing={() => setIsEditing(false)} /> @@ -688,7 +688,7 @@ const LevelItem = memo(function LevelItem({ setIsEditing(true)} onStopEditing={() => setIsEditing(false)} /> @@ -1133,7 +1133,7 @@ const ZoneItem = memo(function ZoneItem({ zone, isLast }: { zone: ZoneNode; isLa setIsEditing(true)} onStopEditing={() => setIsEditing(false)} /> diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx index 7e390870..2054788e 100644 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/level-tree-node.tsx @@ -1,4 +1,4 @@ -import { type AnyNodeId, type LevelNode, useScene } from '@pascal-app/core' +import { type LevelNode, useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { Layers } from 'lucide-react' import { memo, useCallback, useState } from 'react' @@ -8,7 +8,7 @@ import { focusTreeNode, TreeNode, TreeNodeWrapper } from './tree-node' import { TreeNodeActions } from './tree-node-actions' interface LevelTreeNodeProps { - nodeId: AnyNodeId + nodeId: LevelNode['id'] depth: number isLast?: boolean } diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx new file mode 100644 index 00000000..80c815ec --- /dev/null +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/spawn-tree-node.tsx @@ -0,0 +1,82 @@ +'use client' + +import { type SpawnNode, useScene } from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import Image from 'next/image' +import { memo, useCallback, useState } from 'react' +import useEditor from './../../../../../store/use-editor' +import { InlineRenameInput } from './inline-rename-input' +import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node' +import { TreeNodeActions } from './tree-node-actions' + +interface SpawnTreeNodeProps { + nodeId: SpawnNode['id'] + depth: number + isLast?: boolean +} + +export const SpawnTreeNode = memo(function SpawnTreeNode({ + nodeId, + depth, + isLast, +}: SpawnTreeNodeProps) { + const [isEditing, setIsEditing] = useState(false) + const isVisible = useScene((s) => s.nodes[nodeId]?.visible !== false) + const isSelected = useViewer((state) => state.selection.selectedIds.includes(nodeId)) + const isHovered = useViewer((state) => state.hoveredId === nodeId) + const setSelection = useViewer((state) => state.setSelection) + const setHoveredId = useViewer((state) => state.setHoveredId) + + const handleClick = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation() + const handled = handleTreeSelection( + e, + nodeId, + useViewer.getState().selection.selectedIds, + setSelection, + ) + if (!handled && useEditor.getState().phase === 'furnish') { + useEditor.getState().setPhase('structure') + } + }, + [nodeId, setSelection], + ) + + return ( + } + depth={depth} + expanded={false} + hasChildren={false} + icon={ + + } + isHovered={isHovered} + isLast={isLast} + isSelected={isSelected} + isVisible={isVisible} + label={ + setIsEditing(true)} + onStopEditing={() => setIsEditing(false)} + /> + } + nodeId={nodeId} + onClick={handleClick} + onDoubleClick={() => focusTreeNode(nodeId)} + onMouseEnter={() => setHoveredId(nodeId)} + onMouseLeave={() => setHoveredId(null)} + onToggle={() => {}} + /> + ) +}) diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx index dff2ba2a..4a4d9194 100644 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx @@ -62,6 +62,7 @@ import { ItemTreeNode } from './item-tree-node' import { LevelTreeNode } from './level-tree-node' import { RoofTreeNode } from './roof-tree-node' import { SlabTreeNode } from './slab-tree-node' +import { SpawnTreeNode } from './spawn-tree-node' import { StairTreeNode } from './stair-tree-node' import { WallTreeNode } from './wall-tree-node' import { WindowTreeNode } from './window-tree-node' @@ -80,13 +81,15 @@ export const TreeNode = memo(function TreeNode({ nodeId, depth = 0, isLast }: Tr switch (nodeType) { case 'building': - return + return case 'ceiling': return case 'level': - return + return case 'slab': return + case 'spawn': + return case 'wall': return case 'fence': @@ -102,7 +105,7 @@ export const TreeNode = memo(function TreeNode({ nodeId, depth = 0, isLast }: Tr case 'window': return case 'zone': - return + return default: return null } diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx index c1afbc7d..eb578441 100755 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx @@ -1,4 +1,4 @@ -import { type AnyNodeId, useScene, type ZoneNode } from '@pascal-app/core' +import { useScene, type ZoneNode } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { memo, useCallback, useState } from 'react' import { ColorDot } from './../../../../../components/ui/primitives/color-dot' @@ -7,7 +7,7 @@ import { focusTreeNode, TreeNodeWrapper } from './tree-node' import { TreeNodeActions } from './tree-node-actions' interface ZoneTreeNodeProps { - nodeId: AnyNodeId + nodeId: ZoneNode['id'] depth: number isLast?: boolean } @@ -44,7 +44,7 @@ export const ZoneTreeNode = memo(function ZoneTreeNode({ depth={depth} expanded={false} hasChildren={false} - icon={ updateNode(nodeId, { color: c })} />} + icon={ updateNode(nodeId, { color: c })} />} isHovered={isHovered} isLast={isLast} isSelected={isSelected} @@ -78,8 +78,11 @@ function calculatePolygonArea(polygon: Array<[number, number]>): number { for (let i = 0; i < n; i++) { const j = (i + 1) % n - area += polygon[i]?.[0] * polygon[j]?.[1] - area -= polygon[j]?.[0] * polygon[i]?.[1] + const current = polygon[i] + const next = polygon[j] + if (!(current && next)) continue + area += current[0] * next[1] + area -= next[0] * current[1] } return Math.abs(area) / 2 diff --git a/packages/editor/src/hooks/use-keyboard.ts b/packages/editor/src/hooks/use-keyboard.ts index d0eecc69..328116ad 100755 --- a/packages/editor/src/hooks/use-keyboard.ts +++ b/packages/editor/src/hooks/use-keyboard.ts @@ -12,8 +12,18 @@ export const markToolCancelConsumed = () => { _toolCancelConsumed = true } -export const useKeyboard = ({ isVersionPreviewMode = false } = {}) => { +export const useKeyboard = ({ + isVersionPreviewMode = false, + disabled = false, +}: { + isVersionPreviewMode?: boolean + disabled?: boolean +} = {}) => { useEffect(() => { + if (disabled) { + return + } + const handleKeyDown = (e: KeyboardEvent) => { // Don't handle shortcuts if user is typing in an input if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) { @@ -21,9 +31,6 @@ export const useKeyboard = ({ isVersionPreviewMode = false } = {}) => { } if (e.key === 'Escape') { - // If in walkthrough mode, let WalkthroughControls handle ESC - if (useViewer.getState().walkthroughMode) return - e.preventDefault() _toolCancelConsumed = false emitter.emit('tool:cancel') @@ -220,7 +227,7 @@ export const useKeyboard = ({ isVersionPreviewMode = false } = {}) => { } window.addEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown) - }, [isVersionPreviewMode]) + }, [disabled, isVersionPreviewMode]) return null } diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx index f6d0488b..0cd2691a 100644 --- a/packages/editor/src/store/use-editor.tsx +++ b/packages/editor/src/store/use-editor.tsx @@ -11,6 +11,7 @@ import { type LevelNode, type RoofNode, type RoofSegmentNode, + type SpawnNode, type RoofSurfaceMaterialRole, type SlabNode, type Space, @@ -59,6 +60,7 @@ export type StructureTool = | 'stair' | 'item' | 'zone' + | 'spawn' | 'window' | 'door' @@ -132,6 +134,7 @@ type EditorState = { | WallNode | RoofNode | RoofSegmentNode + | SpawnNode | StairNode | StairSegmentNode | BuildingNode @@ -147,6 +150,7 @@ type EditorState = { | WallNode | RoofNode | RoofSegmentNode + | SpawnNode | StairNode | StairSegmentNode | BuildingNode @@ -200,6 +204,7 @@ type EditorState = { // First-person walkthrough mode (street view) isFirstPersonMode: boolean _viewModeBeforeFirstPerson: ViewMode | null + _levelIdBeforeFirstPerson: LevelNode['id'] | null setFirstPersonMode: (enabled: boolean) => void // Development-only camera debug flag for inspecting underside geometry allowUndergroundCamera: boolean @@ -632,14 +637,18 @@ const useEditor = create()( setAllowUndergroundCamera: (enabled) => set({ allowUndergroundCamera: enabled }), isFirstPersonMode: false, _viewModeBeforeFirstPerson: null as ViewMode | null, + _levelIdBeforeFirstPerson: null as LevelNode['id'] | null, setFirstPersonMode: (enabled) => { if (enabled) { const currentViewMode = get().viewMode + const currentLevelId = useViewer.getState().selection.levelId useViewer.getState().setCameraMode('perspective') useViewer.getState().setWallMode('up') + useViewer.getState().setWalkthroughMode(true) set({ isFirstPersonMode: true, _viewModeBeforeFirstPerson: currentViewMode, + _levelIdBeforeFirstPerson: currentLevelId, viewMode: '3d', isFloorplanOpen: false, mode: 'select', @@ -649,11 +658,25 @@ const useEditor = create()( useViewer.getState().setSelection({ selectedIds: [], zoneId: null }) } else { const prevMode = get()._viewModeBeforeFirstPerson + const prevLevelId = get()._levelIdBeforeFirstPerson + useViewer.getState().setWalkthroughMode(false) set({ isFirstPersonMode: false, _viewModeBeforeFirstPerson: null, + _levelIdBeforeFirstPerson: null, ...(prevMode ? { viewMode: prevMode, isFloorplanOpen: prevMode !== '3d' } : {}), }) + + if (prevLevelId) { + const prevLevelNode = useScene.getState().nodes[prevLevelId] + if (prevLevelNode?.type === 'level') { + useViewer.getState().setSelection({ + levelId: prevLevelId, + zoneId: null, + selectedIds: [], + }) + } + } } }, activeSidebarPanel: DEFAULT_ACTIVE_SIDEBAR_PANEL, diff --git a/packages/viewer/src/components/renderers/node-renderer.tsx b/packages/viewer/src/components/renderers/node-renderer.tsx index 827f6893..80eb5b6a 100644 --- a/packages/viewer/src/components/renderers/node-renderer.tsx +++ b/packages/viewer/src/components/renderers/node-renderer.tsx @@ -13,6 +13,7 @@ import { RoofSegmentRenderer } from './roof-segment/roof-segment-renderer' import { ScanRenderer } from './scan/scan-renderer' import { SiteRenderer } from './site/site-renderer' import { SlabRenderer } from './slab/slab-renderer' +import { SpawnRenderer } from './spawn/spawn-renderer' import { StairRenderer } from './stair/stair-renderer' import { StairSegmentRenderer } from './stair-segment/stair-segment-renderer' import { WallRenderer } from './wall/wall-renderer' @@ -32,6 +33,7 @@ export const NodeRenderer = ({ nodeId }: { nodeId: AnyNode['id'] }) => { {node.type === 'level' && } {node.type === 'item' && } {node.type === 'slab' && } + {node.type === 'spawn' && } {node.type === 'wall' && } {node.type === 'fence' && } {node.type === 'door' && } diff --git a/packages/viewer/src/components/renderers/spawn/spawn-renderer.tsx b/packages/viewer/src/components/renderers/spawn/spawn-renderer.tsx new file mode 100644 index 00000000..61032d42 --- /dev/null +++ b/packages/viewer/src/components/renderers/spawn/spawn-renderer.tsx @@ -0,0 +1,80 @@ +import { type SpawnNode, useLiveTransforms, useRegistry } from '@pascal-app/core' +import { useMemo, useRef } from 'react' +import type { Group } from 'three' +import { Color, Shape } from 'three' +import { useNodeEvents } from '../../../hooks/use-node-events' +import useViewer from '../../../store/use-viewer' + +// Mirrors the current first-person controller capsule in the editor package: +// colliderCapsuleArgs={[0.25, 0.8, 4, 8]} with the controller center 0.8m above the floor. +const PLAYER_CAPSULE_RADIUS = 0.25 +const PLAYER_CAPSULE_LENGTH = 0.8 +const PLAYER_CAPSULE_CENTER_Y = 0.8 + +const SPAWN_COLOR = new Color('#22c55e') + +export const SpawnRenderer = ({ node }: { node: SpawnNode }) => { + const ref = useRef(null!) + const handlers = useNodeEvents(node, 'spawn') + const liveTransform = useLiveTransforms((state) => state.get(node.id)) + const walkthroughMode = useViewer((state) => state.walkthroughMode) + + useRegistry(node.id, 'spawn', ref) + + const materialProps = useMemo( + () => ({ + color: SPAWN_COLOR, + emissive: SPAWN_COLOR, + emissiveIntensity: 0.08, + metalness: 0.03, + roughness: 0.42, + }), + [], + ) + + const arrowShape = useMemo(() => { + const shape = new Shape() + // Positive local Y becomes negative world Z after the -90deg X rotation below, + // so this tip points "forward" for the player/spawn direction. + shape.moveTo(0, 0.24) + shape.lineTo(-0.18, -0.14) + shape.lineTo(0.18, -0.14) + shape.closePath() + return shape + }, []) + + return ( + + + + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/packages/viewer/src/hooks/use-node-events.ts b/packages/viewer/src/hooks/use-node-events.ts index 133ba34b..d0b39b53 100644 --- a/packages/viewer/src/hooks/use-node-events.ts +++ b/packages/viewer/src/hooks/use-node-events.ts @@ -21,6 +21,8 @@ import { type SiteNode, type SlabEvent, type SlabNode, + type SpawnEvent, + type SpawnNode, type StairEvent, type StairNode, type StairSegmentEvent, @@ -44,6 +46,7 @@ type NodeConfig = { level: { node: LevelNode; event: LevelEvent } zone: { node: ZoneNode; event: ZoneEvent } slab: { node: SlabNode; event: SlabEvent } + spawn: { node: SpawnNode; event: SpawnEvent } ceiling: { node: CeilingNode; event: CeilingEvent } roof: { node: RoofNode; event: RoofEvent } 'roof-segment': { node: RoofSegmentNode; event: RoofSegmentEvent } diff --git a/public/icons/spawn-point.svg b/public/icons/spawn-point.svg new file mode 100644 index 00000000..d0bc52eb --- /dev/null +++ b/public/icons/spawn-point.svg @@ -0,0 +1,7 @@ + + + + + + +