Skip to content

Commit 41f3cfb

Browse files
committed
feat: add walk mode scene collision
1 parent 2b2e218 commit 41f3cfb

7 files changed

Lines changed: 320 additions & 21 deletions

File tree

include/MeshCraft/Editor/WalkController.hpp

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,17 @@
55
#include <Microsoft/Xna/Framework/Vector3.hpp>
66

77
#include <optional>
8+
#include <span>
89

910
namespace MeshCraft::Editor {
1011

12+
// World-space axis-aligned solid used by walk mode. MeshCraft builds these
13+
// from opt-in MC3 objects with collision="box" when walk mode starts.
14+
struct WalkCollider {
15+
float minX{0.0f}, minY{0.0f}, minZ{0.0f};
16+
float maxX{0.0f}, maxY{0.0f}, maxZ{0.0f};
17+
};
18+
1119
// SYS-W3-01 Phase 6: first-person "walk mode" extracted out of
1220
// MeshCraftApplication (H-series). Self-contained physics/camera state,
1321
// like KeybindingManager/Preferences/UndoManager -- no callback DI Context
@@ -25,6 +33,7 @@ class WalkController {
2533
float speed{5.0f}; // movement speed (m/s)
2634
float turnSpeed{1.5f}; // keyboard yaw speed (rad/s)
2735
float mouseSens{0.003f}; // mouse sensitivity (rad/px)
36+
float collisionRadius{0.30f}; // horizontal player-cylinder radius (m)
2837

2938
[[nodiscard]] bool isActive() const { return active_; }
3039
[[nodiscard]] float posX() const { return posX_; }
@@ -61,7 +70,8 @@ class WalkController {
6170
std::optional<ExitCameraState> update(
6271
float dt,
6372
const Microsoft::Xna::Framework::Input::KeyboardState& ks,
64-
int mouseDx, int mouseDy);
73+
int mouseDx, int mouseDy,
74+
std::span<const WalkCollider> colliders = {});
6575

6676
// First-person view matrix from the current position/yaw/pitch/height.
6777
// The projection matrix is deliberately NOT owned here -- walk mode

include/MeshCraft/MeshCraftApplication.hpp

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -818,6 +818,7 @@ class MeshCraftApplication : public Microsoft::Xna::Framework::Game {
818818
// than carried forward)
819819
// -----------------------------------------------------------------------
820820
Editor::WalkController walkController_;
821+
std::vector<Editor::WalkCollider> walkColliders_;
821822
void updateWalkMode(float dt, const Microsoft::Xna::Framework::Input::KeyboardState& ks,
822823
int mouseDx, int mouseDy);
823824
void enterWalkMode();

plan.md

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -823,7 +823,25 @@ portable through CNA rather than merely hiding its OpenGL dependency.
823823
coverage. **Resolved:** commits `8cb14be`, `026fc2d`.
824824
- **SYS-W14-05** `[DEFERRED]` `P3` — Safe `embed:` mesh/resource support end-to-end. (`AUD-025`)
825825
- **SYS-W14-06** `[DEFERRED]` `P3` — Improved CSG output (smooth normals/UVs/materials).
826-
- **SYS-W14-07** `[DEFERRED]` `P3` — Improved walk/navigation collision.
826+
- **SYS-W14-07** `[DONE]` `P3` — Improved walk/navigation collision.
827+
Walk mode previously treated only the global `y=0` plane as solid, so it
828+
could pass through every scene wall, floor and ceiling. It now snapshots
829+
every primitive object explicitly marked `collision="box"` when entering
830+
walk mode, transforms its eight local bounds corners through its complete
831+
parent hierarchy into a world AABB, and supplies those colliders to
832+
`Editor::WalkController`. The controller uses a swept, radius-expanded AABB
833+
test for horizontal player-cylinder movement (prevents long-frame
834+
tunnelling and preserves tangent movement for wall sliding), resolves a
835+
start inside a newly enabled collider, lands on box tops, and stops jumps
836+
at box ceilings while retaining the existing `y=0` ground behavior.
837+
The Properties panel already exposes the `box` collision mode; the walk HUD
838+
reports the active collider count and exposes the collision radius. Other
839+
serialized proxy labels (`sphere`, `mesh`, `convex`, `capsule`) remain
840+
deliberately unsupported by walk mode rather than being approximated
841+
silently. The real `walk_controller_test` now covers swept-wall blocking,
842+
diagonal wall sliding, platform landing and ceiling blocking in addition to
843+
the prior movement/gravity coverage. EASYGL `MeshCraft` and the targeted
844+
test built and passed with `-j4` on 2026-07-25.
827845
- **SYS-W14-08** `[DONE]` `P2` — AI change preview/diff before destructive
828846
replace. Before this, "Apply to Scene" replaced `document_` wholesale
829847
with only two safety nets: full Undo, and `STAB-0395`'s

src/MeshCraft/Editor/WalkController.cpp

Lines changed: 138 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,107 @@ namespace {
1414
constexpr float kGravity = -9.81f;
1515
constexpr float kJumpSpeed = 5.0f;
1616
constexpr float kPitchMax = 1.48f; // ~85 degrees
17+
constexpr float kCollisionEpsilon = 1.0e-4f;
18+
19+
bool verticallyOverlaps(const WalkCollider& c, float feetY, float height) {
20+
return feetY < c.maxY - kCollisionEpsilon &&
21+
feetY + height > c.minY + kCollisionEpsilon;
22+
}
23+
24+
bool horizontallyOverlaps(const WalkCollider& c, float x, float z, float radius) {
25+
return x >= c.minX - radius && x <= c.maxX + radius &&
26+
z >= c.minZ - radius && z <= c.maxZ + radius;
27+
}
28+
29+
// Raycast the player's horizontal centre through an AABB expanded by the
30+
// player radius. This is a swept test, so a long frame cannot tunnel through
31+
// a thin wall. normalX/normalZ identify the face that blocks the motion.
32+
bool sweepExpandedAabb(const WalkCollider& c, float x, float z, float dx, float dz,
33+
float radius, float& hitT, float& normalX, float& normalZ) {
34+
const float minX = c.minX - radius, maxX = c.maxX + radius;
35+
const float minZ = c.minZ - radius, maxZ = c.maxZ + radius;
36+
float entryX, exitX, entryZ, exitZ;
37+
if (std::abs(dx) < kCollisionEpsilon) {
38+
if (x < minX || x > maxX) return false;
39+
entryX = -INFINITY; exitX = INFINITY;
40+
} else if (dx > 0.0f) {
41+
entryX = (minX - x) / dx; exitX = (maxX - x) / dx;
42+
} else {
43+
entryX = (maxX - x) / dx; exitX = (minX - x) / dx;
44+
}
45+
if (std::abs(dz) < kCollisionEpsilon) {
46+
if (z < minZ || z > maxZ) return false;
47+
entryZ = -INFINITY; exitZ = INFINITY;
48+
} else if (dz > 0.0f) {
49+
entryZ = (minZ - z) / dz; exitZ = (maxZ - z) / dz;
50+
} else {
51+
entryZ = (maxZ - z) / dz; exitZ = (minZ - z) / dz;
52+
}
53+
54+
const float entry = std::max(entryX, entryZ);
55+
const float exit = std::min(exitX, exitZ);
56+
if (entry > exit || exit < 0.0f || entry > 1.0f) return false;
57+
58+
hitT = std::max(0.0f, entry);
59+
normalX = normalZ = 0.0f;
60+
if (entryX > entryZ) normalX = dx > 0.0f ? -1.0f : 1.0f;
61+
else normalZ = dz > 0.0f ? -1.0f : 1.0f;
62+
return true;
63+
}
64+
65+
void pushOutOfOverlaps(float& x, float& z, float feetY, float height, float radius,
66+
std::span<const WalkCollider> colliders) {
67+
// A walk can start inside a newly-enabled collider. Resolve that state
68+
// deterministically before the sweep, choosing the nearest expanded face.
69+
for (int pass = 0; pass < 4; ++pass) {
70+
bool moved = false;
71+
for (const auto& c : colliders) {
72+
if (!verticallyOverlaps(c, feetY, height) || !horizontallyOverlaps(c, x, z, radius)) continue;
73+
const float left = x - (c.minX - radius);
74+
const float right = (c.maxX + radius) - x;
75+
const float front = z - (c.minZ - radius);
76+
const float back = (c.maxZ + radius) - z;
77+
const float nearest = std::min({left, right, front, back});
78+
if (nearest == left) x = c.minX - radius - kCollisionEpsilon;
79+
else if (nearest == right) x = c.maxX + radius + kCollisionEpsilon;
80+
else if (nearest == front) z = c.minZ - radius - kCollisionEpsilon;
81+
else z = c.maxZ + radius + kCollisionEpsilon;
82+
moved = true;
83+
}
84+
if (!moved) return;
85+
}
86+
}
87+
88+
void moveHorizontally(float& x, float& z, float feetY, float height, float radius,
89+
float dx, float dz, std::span<const WalkCollider> colliders) {
90+
pushOutOfOverlaps(x, z, feetY, height, radius, colliders);
91+
for (int pass = 0; pass < 4 && (std::abs(dx) > kCollisionEpsilon || std::abs(dz) > kCollisionEpsilon); ++pass) {
92+
float bestT = 1.0f, normalX = 0.0f, normalZ = 0.0f;
93+
bool blocked = false;
94+
for (const auto& c : colliders) {
95+
if (!verticallyOverlaps(c, feetY, height)) continue;
96+
float hitT, hitNormalX, hitNormalZ;
97+
if (sweepExpandedAabb(c, x, z, dx, dz, radius, hitT, hitNormalX, hitNormalZ) && hitT < bestT) {
98+
bestT = hitT;
99+
normalX = hitNormalX;
100+
normalZ = hitNormalZ;
101+
blocked = true;
102+
}
103+
}
104+
if (!blocked) { x += dx; z += dz; return; }
105+
106+
const float safeT = std::max(0.0f, bestT - kCollisionEpsilon);
107+
x += dx * safeT;
108+
z += dz * safeT;
109+
const float remaining = 1.0f - bestT;
110+
dx *= remaining;
111+
dz *= remaining;
112+
// Remove the component into the blocking face, preserving the
113+
// tangential component so diagonal movement slides along walls.
114+
if (normalX != 0.0f) dx = 0.0f;
115+
if (normalZ != 0.0f) dz = 0.0f;
116+
}
117+
}
17118
} // namespace
18119

19120
void WalkController::enter(Vector3 cameraPos, float cameraYaw) {
@@ -38,7 +139,8 @@ WalkController::ExitCameraState WalkController::exit() {
38139
}
39140

40141
std::optional<WalkController::ExitCameraState> WalkController::update(
41-
float dt, const KeyboardState& ks, int mouseDx, int mouseDy)
142+
float dt, const KeyboardState& ks, int mouseDx, int mouseDy,
143+
std::span<const WalkCollider> colliders)
42144
{
43145
if (ks.IsKeyDown(Keys::Escape))
44146
return exit();
@@ -67,14 +169,10 @@ std::optional<WalkController::ExitCameraState> WalkController::update(
67169
float sinY = std::sin(yaw_);
68170
float cosY = std::cos(yaw_);
69171

70-
if (fwd) {
71-
posX_ += sinY * speed * dt;
72-
posZ_ -= cosY * speed * dt;
73-
}
74-
if (back) {
75-
posX_ -= sinY * speed * dt;
76-
posZ_ += cosY * speed * dt;
77-
}
172+
float moveX = 0.0f, moveZ = 0.0f;
173+
if (fwd) { moveX += sinY * speed * dt; moveZ -= cosY * speed * dt; }
174+
if (back) { moveX -= sinY * speed * dt; moveZ += cosY * speed * dt; }
175+
moveHorizontally(posX_, posZ_, posY_, height, collisionRadius, moveX, moveZ, colliders);
78176

79177
// Jump: Ctrl (only when on ground)
80178
bool ctrl = ks.IsKeyDown(Keys::LeftControl) || ks.IsKeyDown(Keys::RightControl);
@@ -83,14 +181,38 @@ std::optional<WalkController::ExitCameraState> WalkController::update(
83181
onGround_ = false;
84182
}
85183

86-
// Gravity + ground collision
184+
// Gravity + y=0 ground collision + opt-in box-collider floors/ceilings.
87185
velY_ += kGravity * dt;
88-
posY_ += velY_ * dt;
89-
90-
if (posY_ <= 0.0f) {
91-
posY_ = 0.0f;
92-
velY_ = 0.0f;
93-
onGround_ = true;
186+
const float oldY = posY_;
187+
float nextY = posY_ + velY_ * dt;
188+
onGround_ = false;
189+
if (velY_ <= 0.0f) {
190+
float landingY = 0.0f;
191+
for (const auto& c : colliders) {
192+
if (!horizontallyOverlaps(c, posX_, posZ_, collisionRadius)) continue;
193+
if (oldY >= c.maxY - kCollisionEpsilon && nextY <= c.maxY + kCollisionEpsilon)
194+
landingY = std::max(landingY, c.maxY);
195+
}
196+
if (nextY <= landingY) {
197+
posY_ = landingY;
198+
velY_ = 0.0f;
199+
onGround_ = true;
200+
} else {
201+
posY_ = nextY;
202+
}
203+
} else {
204+
float ceilingY = INFINITY;
205+
for (const auto& c : colliders) {
206+
if (!horizontallyOverlaps(c, posX_, posZ_, collisionRadius)) continue;
207+
if (oldY + height <= c.minY + kCollisionEpsilon && nextY + height >= c.minY - kCollisionEpsilon)
208+
ceilingY = std::min(ceilingY, c.minY);
209+
}
210+
if (ceilingY < INFINITY) {
211+
posY_ = ceilingY - height;
212+
velY_ = 0.0f;
213+
} else {
214+
posY_ = nextY;
215+
}
94216
}
95217

96218
return std::nullopt;

src/MeshCraft/MeshCraftApplication_WalkMode.cpp

Lines changed: 98 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,103 @@
33

44
#include <Microsoft/Xna/Framework/Input/Keys.hpp>
55
#include <Microsoft/Xna/Framework/Input/KeyboardState.hpp>
6+
#include <Microsoft/Xna/Framework/Matrix.hpp>
7+
#include <Microsoft/Xna/Framework/Vector3.hpp>
68

9+
#include <array>
10+
#include <cmath>
711
#include <cstdio>
12+
#include <functional>
13+
#include <limits>
14+
#include <numbers>
15+
#include <optional>
816

917
#include <imgui.h>
1018

1119
namespace MeshCraft {
1220

1321
using namespace Microsoft::Xna::Framework::Input;
22+
using namespace Microsoft::Xna::Framework;
23+
24+
namespace {
25+
26+
Matrix walkColliderWorldMatrix(const Mc3::Mc3Transform& t) {
27+
constexpr float radiansPerDegree = std::numbers::pi_v<float> / 180.0f;
28+
const float px = t.pivot[0], py = t.pivot[1], pz = t.pivot[2];
29+
return Matrix::CreateTranslation({-px, -py, -pz}) *
30+
Matrix::CreateScale({t.scale[0], t.scale[1], t.scale[2]}) *
31+
Matrix::CreateFromYawPitchRoll(t.rotation[1] * radiansPerDegree,
32+
t.rotation[0] * radiansPerDegree,
33+
t.rotation[2] * radiansPerDegree) *
34+
Matrix::CreateTranslation({t.position[0] + px, t.position[1] + py, t.position[2] + pz});
35+
}
36+
37+
std::optional<std::array<float, 3>> walkColliderHalfExtents(const Mc3::Mc3Object& obj) {
38+
if (!obj.primitive) return std::nullopt;
39+
const auto& p = *obj.primitive;
40+
const float radius = std::abs(p.radius);
41+
const float halfHeight = std::abs(p.height) * 0.5f;
42+
const float thin = 0.025f; // planes/disks still need a usable solid AABB
43+
switch (p.primitiveType) {
44+
case Mc3::PrimitiveType::Box:
45+
case Mc3::PrimitiveType::Cube:
46+
return std::array<float, 3>{std::abs(p.size[0]) * 0.5f,
47+
std::abs(p.size[1]) * 0.5f,
48+
std::abs(p.size[2]) * 0.5f};
49+
case Mc3::PrimitiveType::Sphere:
50+
case Mc3::PrimitiveType::IcoSphere:
51+
return std::array<float, 3>{radius, radius, radius};
52+
case Mc3::PrimitiveType::Cylinder:
53+
case Mc3::PrimitiveType::Cone:
54+
case Mc3::PrimitiveType::Capsule:
55+
if (p.axis == "x") return std::array<float, 3>{halfHeight, radius, radius};
56+
if (p.axis == "z") return std::array<float, 3>{radius, radius, halfHeight};
57+
return std::array<float, 3>{radius, halfHeight, radius};
58+
case Mc3::PrimitiveType::Plane:
59+
case Mc3::PrimitiveType::Grid:
60+
return std::array<float, 3>{std::abs(p.size[0]) * 0.5f, thin,
61+
std::abs(p.size[2]) * 0.5f};
62+
case Mc3::PrimitiveType::Disk:
63+
return std::array<float, 3>{radius, thin, radius};
64+
case Mc3::PrimitiveType::Torus: {
65+
const float outerRadius = std::abs(p.majorRadius) + std::abs(p.minorRadius);
66+
return std::array<float, 3>{outerRadius, std::abs(p.minorRadius), outerRadius};
67+
}
68+
}
69+
return std::nullopt;
70+
}
71+
72+
std::vector<Editor::WalkCollider> buildWalkColliders(const Mc3::Mc3Document& doc) {
73+
std::vector<Editor::WalkCollider> colliders;
74+
const Matrix identity = Matrix::getIdentityProperty();
75+
std::function<void(const Mc3::Mc3Object&, const Matrix&, int)> visit;
76+
visit = [&](const Mc3::Mc3Object& obj, const Matrix& parentWorld, int depth) {
77+
if (depth > 16) return; // same graph-safety bound as SceneRenderer
78+
const Matrix world = walkColliderWorldMatrix(obj.transform) * parentWorld;
79+
if (obj.collision == "box") {
80+
if (const auto extents = walkColliderHalfExtents(obj)) {
81+
Editor::WalkCollider collider;
82+
collider.minX = collider.minY = collider.minZ = std::numeric_limits<float>::infinity();
83+
collider.maxX = collider.maxY = collider.maxZ = -std::numeric_limits<float>::infinity();
84+
for (int sx : {-1, 1}) for (int sy : {-1, 1}) for (int sz : {-1, 1}) {
85+
const Vector3 p = Vector3::Transform(
86+
Vector3(sx * (*extents)[0], sy * (*extents)[1], sz * (*extents)[2]), world);
87+
collider.minX = std::min(collider.minX, p.X); collider.maxX = std::max(collider.maxX, p.X);
88+
collider.minY = std::min(collider.minY, p.Y); collider.maxY = std::max(collider.maxY, p.Y);
89+
collider.minZ = std::min(collider.minZ, p.Z); collider.maxZ = std::max(collider.maxZ, p.Z);
90+
}
91+
colliders.push_back(collider);
92+
}
93+
}
94+
for (const auto& child : obj.children)
95+
if (child) visit(*child, world, depth + 1);
96+
};
97+
for (const auto& obj : doc.objects)
98+
if (obj) visit(*obj, identity, 0);
99+
return colliders;
100+
}
101+
102+
} // namespace
14103

15104
// SYS-W3-01 Phase 6: the movement/look physics and the view-matrix
16105
// computation now live in the self-contained, CNA-coupled-where-
@@ -23,12 +112,15 @@ using namespace Microsoft::Xna::Framework::Input;
23112
// themselves).
24113

25114
void MeshCraftApplication::enterWalkMode() {
115+
walkColliders_ = buildWalkColliders(document_);
26116
walkController_.enter(camera_.position(), camera_.yaw);
27-
setStatusMsg("Walk mode — Esc to exit", false, 3.0f);
117+
setStatusMsg("Walk mode — " + std::to_string(walkColliders_.size()) +
118+
" box collider(s), Esc to exit", false, 3.0f);
28119
}
29120

30121
void MeshCraftApplication::exitWalkMode() {
31122
auto s = walkController_.exit();
123+
walkColliders_.clear();
32124
camera_.target = s.target;
33125
camera_.yaw = s.yaw;
34126
camera_.pitch = s.pitch;
@@ -40,7 +132,8 @@ void MeshCraftApplication::updateWalkMode(float dt,
40132
const KeyboardState& ks,
41133
int mouseDx, int mouseDy)
42134
{
43-
if (auto exitState = walkController_.update(dt, ks, mouseDx, mouseDy)) {
135+
if (auto exitState = walkController_.update(dt, ks, mouseDx, mouseDy, walkColliders_)) {
136+
walkColliders_.clear();
44137
camera_.target = exitState->target;
45138
camera_.yaw = exitState->yaw;
46139
camera_.pitch = exitState->pitch;
@@ -97,6 +190,9 @@ void MeshCraftApplication::drawWalkModeHud(int screenW, int screenH) {
97190
ImGui::Text("Mouse sensitivity");
98191
ImGui::SetNextItemWidth(140);
99192
ImGui::SliderFloat("##wm", &walkController_.mouseSens, 0.001f, 0.010f, "%.3f", ImGuiSliderFlags_AlwaysClamp);
193+
ImGui::Text("Collision radius (m)");
194+
ImGui::SetNextItemWidth(140);
195+
ImGui::SliderFloat("##wcr", &walkController_.collisionRadius, 0.10f, 1.00f, "%.2f", ImGuiSliderFlags_AlwaysClamp);
100196
if (ImGui::Button("Exit Walk Mode (Esc)"))
101197
exitWalkMode();
102198
ImGui::End();

0 commit comments

Comments
 (0)