Feature: Implement climbable status for box faces across UI and runtime geometry

This commit is contained in:
2026-04-30 00:13:52 +02:00
parent 086dd69c64
commit 7b03df09f6
4 changed files with 89 additions and 3 deletions

View File

@@ -34,6 +34,7 @@ import { createMoveBoxBrushCommand } from "../commands/move-box-brush-command";
import { createRotateBoxBrushCommand } from "../commands/rotate-box-brush-command"; import { createRotateBoxBrushCommand } from "../commands/rotate-box-brush-command";
import { createResizeBoxBrushCommand } from "../commands/resize-box-brush-command"; import { createResizeBoxBrushCommand } from "../commands/resize-box-brush-command";
import { createSetBoxBrushAllFaceMaterialsCommand } from "../commands/set-box-brush-all-face-materials-command"; import { createSetBoxBrushAllFaceMaterialsCommand } from "../commands/set-box-brush-all-face-materials-command";
import { createSetBoxBrushFaceClimbableCommand } from "../commands/set-box-brush-face-climbable-command";
import { createSetBoxBrushFaceMaterialCommand } from "../commands/set-box-brush-face-material-command"; import { createSetBoxBrushFaceMaterialCommand } from "../commands/set-box-brush-face-material-command";
import { createSetBoxBrushAuthoredStateCommand } from "../commands/set-box-brush-authored-state-command"; import { createSetBoxBrushAuthoredStateCommand } from "../commands/set-box-brush-authored-state-command";
import { createSetBoxBrushNameCommand } from "../commands/set-box-brush-name-command"; import { createSetBoxBrushNameCommand } from "../commands/set-box-brush-name-command";
@@ -13380,6 +13381,43 @@ export function App({ store, initialStatusMessage }: AppProps) {
); );
}; };
const handleFaceClimbableChange = (climbable: boolean) => {
if (
selectedBrush === null ||
selectedFaceId === null ||
selectedFace === null
) {
setStatusMessage("Select a single box face before editing climbability.");
return;
}
if (selectedFace.climbable === climbable) {
setStatusMessage(
`${getBrushFaceLabel(selectedBrush, selectedFaceId)} is already ${
climbable ? "climbable" : "not climbable"
}.`
);
return;
}
try {
store.executeCommand(
createSetBoxBrushFaceClimbableCommand({
brushId: selectedBrush.id,
faceId: selectedFaceId,
climbable
})
);
setStatusMessage(
climbable
? `Marked ${getBrushFaceLabel(selectedBrush, selectedFaceId)} climbable.`
: `Cleared climbable from ${getBrushFaceLabel(selectedBrush, selectedFaceId)}.`
);
} catch (error) {
setStatusMessage(getErrorMessage(error));
}
};
const applyFaceUvState = ( const applyFaceUvState = (
uvState: FaceUvState, uvState: FaceUvState,
label: string, label: string,
@@ -25230,6 +25268,25 @@ export function App({ store, initialStatusMessage }: AppProps) {
) : null} ) : null}
</div> </div>
{materialInspectorScope === "face" &&
selectedFace !== null ? (
<div className="form-section">
<label className="form-field form-field--toggle">
<span className="label">Climbable</span>
<input
data-testid="face-climbable"
type="checkbox"
checked={selectedFace.climbable}
onChange={(event) =>
handleFaceClimbableChange(
event.currentTarget.checked
)
}
/>
</label>
</div>
) : null}
<div className="form-section"> <div className="form-section">
<div className="label">Material</div> <div className="label">Material</div>
<div className="material-browser"> <div className="material-browser">

View File

@@ -217,7 +217,8 @@ export type PlayerStartMovementAction =
export const PLAYER_START_LOCOMOTION_ACTIONS = [ export const PLAYER_START_LOCOMOTION_ACTIONS = [
"jump", "jump",
"sprint", "sprint",
"crouch" "crouch",
"climb"
] as const; ] as const;
export type PlayerStartLocomotionAction = export type PlayerStartLocomotionAction =
(typeof PLAYER_START_LOCOMOTION_ACTIONS)[number]; (typeof PLAYER_START_LOCOMOTION_ACTIONS)[number];
@@ -283,6 +284,7 @@ export interface PlayerStartKeyboardBindings {
jump: PlayerStartKeyboardBindingCode; jump: PlayerStartKeyboardBindingCode;
sprint: PlayerStartKeyboardBindingCode; sprint: PlayerStartKeyboardBindingCode;
crouch: PlayerStartKeyboardBindingCode; crouch: PlayerStartKeyboardBindingCode;
climb: PlayerStartKeyboardBindingCode;
interact: PlayerStartKeyboardBindingCode; interact: PlayerStartKeyboardBindingCode;
clearTarget: PlayerStartKeyboardBindingCode; clearTarget: PlayerStartKeyboardBindingCode;
pauseTime: PlayerStartKeyboardBindingCode; pauseTime: PlayerStartKeyboardBindingCode;
@@ -296,6 +298,7 @@ export interface PlayerStartGamepadBindings {
jump: PlayerStartGamepadActionBinding; jump: PlayerStartGamepadActionBinding;
sprint: PlayerStartGamepadActionBinding; sprint: PlayerStartGamepadActionBinding;
crouch: PlayerStartGamepadActionBinding; crouch: PlayerStartGamepadActionBinding;
climb: PlayerStartGamepadActionBinding;
interact: PlayerStartGamepadActionBinding; interact: PlayerStartGamepadActionBinding;
clearTarget: PlayerStartGamepadActionBinding; clearTarget: PlayerStartGamepadActionBinding;
pauseTime: PlayerStartGamepadActionBinding; pauseTime: PlayerStartGamepadActionBinding;
@@ -551,6 +554,7 @@ export const DEFAULT_PLAYER_START_KEYBOARD_BINDINGS: PlayerStartKeyboardBindings
jump: "Space", jump: "Space",
sprint: "ShiftLeft", sprint: "ShiftLeft",
crouch: "ControlLeft", crouch: "ControlLeft",
climb: "KeyE",
interact: "MouseLeft", interact: "MouseLeft",
clearTarget: "KeyQ", clearTarget: "KeyQ",
pauseTime: "KeyP" pauseTime: "KeyP"
@@ -564,6 +568,7 @@ export const DEFAULT_PLAYER_START_GAMEPAD_BINDINGS: PlayerStartGamepadBindings =
jump: "buttonSouth", jump: "buttonSouth",
sprint: "leftStickPress", sprint: "leftStickPress",
crouch: "buttonEast", crouch: "buttonEast",
climb: "rightShoulder",
interact: "buttonWest", interact: "buttonWest",
clearTarget: "buttonNorth", clearTarget: "buttonNorth",
pauseTime: "buttonMenu", pauseTime: "buttonMenu",

View File

@@ -263,7 +263,8 @@ function createRuntimeGeometryBrush(brush: RuntimeBoxBrushInstance): Brush {
faceId, faceId,
{ {
materialId: face.materialId, materialId: face.materialId,
uv: cloneFaceUvState(face.uv) uv: cloneFaceUvState(face.uv),
climbable: face.climbable
} }
]) ])
); );

View File

@@ -46,6 +46,7 @@ import {
type BoxBrushLightFalloffMode, type BoxBrushLightFalloffMode,
type BoxBrushVolumeSettings, type BoxBrushVolumeSettings,
type WhiteboxFaceId, type WhiteboxFaceId,
type WhiteboxVertexId,
type FaceUvState type FaceUvState
} from "../document/brushes"; } from "../document/brushes";
import type { ProjectDocument, SceneDocument } from "../document/scene-document"; import type { ProjectDocument, SceneDocument } from "../document/scene-document";
@@ -142,6 +143,16 @@ export interface RuntimeBrushFace {
materialId: string | null; materialId: string | null;
material: MaterialDef | null; material: MaterialDef | null;
uv: FaceUvState; uv: FaceUvState;
climbable: boolean;
}
export interface RuntimeBrushColliderFace {
faceId: WhiteboxFaceId;
climbable: boolean;
normal: Vec3;
vertexIds: readonly WhiteboxVertexId[];
vertices: Vec3[];
triangles: Array<readonly [number, number, number]>;
} }
export interface RuntimeBoxBrushInstance { export interface RuntimeBoxBrushInstance {
@@ -241,6 +252,7 @@ export interface RuntimeBrushTriMeshCollider {
rotationDegrees: Vec3; rotationDegrees: Vec3;
vertices: Float32Array; vertices: Float32Array;
indices: Uint32Array; indices: Uint32Array;
faces: RuntimeBrushColliderFace[];
worldBounds: { worldBounds: {
min: Vec3; min: Vec3;
max: Vec3; max: Vec3;
@@ -768,7 +780,8 @@ function buildRuntimeBrush(
{ {
materialId: face.materialId, materialId: face.materialId,
material: resolveRuntimeMaterial(document, face.materialId), material: resolveRuntimeMaterial(document, face.materialId),
uv: cloneFaceUvState(face.uv) uv: cloneFaceUvState(face.uv),
climbable: face.climbable
} }
]; ];
}) })
@@ -848,6 +861,16 @@ function buildRuntimeCollider(brush: Brush): RuntimeBrushTriMeshCollider {
rotationDegrees: cloneVec3(brush.rotationDegrees), rotationDegrees: cloneVec3(brush.rotationDegrees),
vertices: derivedMesh.colliderVertices, vertices: derivedMesh.colliderVertices,
indices: derivedMesh.colliderIndices, indices: derivedMesh.colliderIndices,
faces: derivedMesh.faceSurfaces.map((surface) => ({
faceId: surface.faceId,
climbable: brush.faces[surface.faceId]?.climbable ?? false,
normal: cloneVec3(surface.normal),
vertexIds: surface.vertexIds,
vertices: surface.vertexIds.map((vertexId) =>
cloneVec3(brush.geometry.vertices[vertexId])
),
triangles: surface.triangles
})),
worldBounds: { worldBounds: {
min: cloneVec3(bounds.min), min: cloneVec3(bounds.min),
max: cloneVec3(bounds.max) max: cloneVec3(bounds.max)