Files
EVEngine/test/ik.cpp
T
西风逍遥游 34d1fdb63b refactor(modules): clear remaining back-edges and split the largest TUs (#127)
* chore(ci): add clang-format gate, CODEOWNERS and agent collaboration rules

* refactor(modules): clear remaining back-edges and split the largest TUs

Three known dependency back-edges are gone (module_depgraph.py KNOWN_BACK_EDGES
is now empty; --check passes with 0 violations, 0 cycles):

- window -> graphics: window queries the new IWindowSurfaceHost capability
  (common/WindowSurfaceHost.h) instead of holding a Graphics pointer;
  Window::setGraphics was removed and all call sites updated.
- window -> image: Window::setIcon was replaced by setIconRGBA(const uint8_t*,
  w, h); the window no longer decodes ImageData.
- scene -> graphics: pickScreenAt / collectFrustumIdsAt implementations moved
  to graphics/ScenePicking.cpp (graphics -> scene is a legal downward edge);
  scene now depends only on spatial.

TU splits (pure moves, verified identical method sets and brace balance):

- vulkan/Graphics.cpp (5118 lines) -> Graphics.cpp (lifecycle), GraphicsPipeline,
  Graphics2D, Graphics3D, GraphicsMesh + shared GraphicsInternal.h.
- scene/Scene.cpp -> Scene.cpp (script layer / host lifecycle / serialization)
  + SceneNodes.cpp + shared SceneInternal.h.

Verified with a real build (MSVC 19.51, prebuilt third-party): full build green;
window.* and Scene.* suites pass, including scene picking/culling and GPU cases.

* refactor(graphics): narrow Graphics interfaces and clear public-header leaks

Step 4 — interface segregation:
- New lean interfaces: IGraphics2D, IGraphics3D, IResourceFactory, IPostFX
  (pure virtuals, forward-declared types only; graphics/Color.h extracted from
  Canvas.h so modules that only need the color type stop pulling Canvas.h).
- Graphics now implements all four facets; consumers can depend on the narrow
  interface they use instead of the 129-method god class.
- Migrated voxel (VoxelWorld::drawVisible -> IGraphics3D*) and model3d
  (ModelRenderer::buildRenderable(s) -> IResourceFactory&).
- Graphics.h slims from 24 sub-header includes to a handful of small type
  headers + forward declarations; the destructor moved out-of-line so
  unique_ptr effect members are destroyed where types are complete.

Step 5 — header leaks (module_depgraph.py '*' marks: 14 -> 7):
- map/TileLayer.h, particles/ParticleEmitter.h, avatar/AvatarInstance.h,
  animation/SpineAnim.h, map/TileSystem.h, model3d/ModelData.h,
  model3d/ModelRenderer.h, audio/Audio.h + Source.h, image/ImageData.h +
  Image.h, dialogue/Dialogue.h, procgen Palette/OutputSpec/TextureRecipe/
  PbrMaterial -> forward declarations, includes moved to .cpp.
- voxel/VoxelWorld.h: terrain sampler Pimpl'd (unique_ptr + out-of-line
  destructor), removing the procgen header dependency.
- gpgpu/vulkan backend headers stop pulling graphics/vulkan/Graphics.h.
- Remaining '*' marks are deliberate: tiny POD/enum headers (GridConfig,
  MapObject, BlendMode, Color), an internal backend header
  (GraphicsInternal.h -> filesystem), and lean interface references.

Verified with a full clean build (MSVC) and tests: window/Scene/voxel/
RenderSystem/Quad/shader/TextureSampler/resourceLifetime pass; broad suite
only shows the pre-existing PHYSFS-init filesystem-environment failures.

* feat(resources): revive ResourceManager as unified IAssetReloader-backed cache

* fix(resources): leak ResourceManager singleton to avoid exit-time teardown crashes

Cached CPU resources may own third-party handles (FreeType faces, Assimp
scenes, image decode handlers) whose libraries are torn down at process
exit in an unspecified TU order; destroying cached entries from the
singleton destructor can segfault at exit (seen on CI Windows in
model3d.materialApi.objMtl). Keep the singleton alive until OS reclaim;
explicit unload()/clear() still release entries during the run.

* refactor(devtools): consume business modules through capability interfaces

DevTools / cmdline no longer include business-module headers, so trimming
scene / physics / procgen / particles / audio / ui / graphics no longer
requires disabling devtools, and devtools can be switched off with
-DEVENGINE_MODULE_DEVTOOLS=OFF.

New capability interfaces in common/:
- ISceneQuery (scene) - host/node inspection + mutation + transform sync
- IRenderCapture (graphics) - render status, PNG capture, camera control,
  visible-entity snapshot, entity-ID mask, G-buffer PNGs
- IPhysicsQuery (physics) - 2D world lifecycle + ray casts
- IProcgenQuery (procgen) - recipe listing, map/mesh generation
- IParticlesQuery (particles) - emitter status + one-shot emit
- IAudioQuery (audio) - volume / stop-all
- IEditorHost (ui) - declarative editor host (used by cmdline too)

Providers register in their module constructors. DevTools rewires McpServer
(render/scene/procgen/physics/particles/audio/host tools), SceneInspect
(now delegates to ISceneQuery + IRenderCapture) and RenderVision (uses
IRenderCapture::capturePngDataUrl); cmdline/Mcp.cpp uses IEditorHost.

manifest: devtools DEPS graphics scene physics procgen particles audio ui
image event filesystem -> event; cmdline DEPS filesystem ui -> filesystem.

Verified: full build green; devtools.mcp.* tests pass; broad suite shows
only the pre-existing PHYSFS-init filesystem-environment failures.

* style: apply clang-format to changed lines after dev merge

* fix(webgpu): include RenderControl/TextureSampler explicitly after Graphics.h slimming

* fix(webgpu): provide newFont/print stubs to satisfy IGraphics2D/IResourceFactory vtables

* fix(common): make ProcgenQuery.h self-sufficient (cstdint/utility for GCC)
2026-08-20 05:53:33 +08:00

419 lines
13 KiB
C++

#include "zeroerr/assert.h"
#include "zeroerr/unittest.h"
#include "ik/IK.h"
#include "ik/Skeleton2D.h"
#include "ik/Skeleton3D.h"
#include "ik/Solver2D.h"
#include "ik/Solver3D.h"
#include "common/Exception.h"
#include "graphics/AmbientOcclusion.h"
#include "graphics/AntiAliasing.h"
#include "graphics/Canvas.h"
#include "graphics/DrawItem2D.h"
#include "graphics/Font.h"
#include "graphics/GBuffer.h"
#include "graphics/GlobalIllumination.h"
#include "graphics/Graphics.h"
#include "graphics/Grass.h"
#include "graphics/Light.h"
#include "graphics/Material.h"
#include "graphics/Mesh.h"
#include "graphics/Outline.h"
#include "graphics/Quad.h"
#include "graphics/RenderControl.h"
#include "graphics/ScreenSpaceReflection.h"
#include "graphics/Shader.h"
#include "graphics/Texture.h"
#include "graphics/Volumetric.h"
#include "graphics/Water.h"
#include "graphics/Waterfall.h"
#include "window/Window.h"
#include <SDL2/SDL.h>
#include <cmath>
#include <memory>
// Color lives in eve::graphics (see graphics/Canvas.h); keep the unqualified form.
using eve::graphics::Color;
#ifndef M_PI
#define M_PI 3.14159265358979323846
#endif
using namespace eve::ik;
using namespace eve::graphics;
static bool near(float a, float b, float eps = 1e-3f) {
return std::fabs(a - b) <= eps;
}
TEST_CASE("ik.module.name") {
auto *mod = IK::create();
CHECK_EQ(mod->getName(), std::string("IK"));
CHECK_EQ(IK::create(), mod);
}
TEST_CASE("ik.skeleton2d.topology") {
std::unique_ptr<Skeleton2D> sk(new Skeleton2D());
CHECK_EQ(sk->getBoneCount(), 1);
CHECK_EQ(sk->getRootId(), 0);
CHECK_EQ(sk->getParent(0), -1);
int b1 = sk->createBone(0, 1.f);
int b2 = sk->createBone(b1, 1.f);
CHECK_EQ(sk->getBoneCount(), 3);
CHECK_EQ(b1, 1);
CHECK_EQ(b2, 2);
CHECK_EQ(sk->getParent(b2), b1);
CHECK_EQ(sk->getChildCount(0), 1);
CHECK_EQ(sk->getChild(0, 0), b1);
CHECK(near(sk->getLength(b1), 1.f));
CHECK(near(sk->totalLengthTo(b2), 2.f));
}
TEST_CASE("ik.skeleton2d.forwardKinematics") {
std::unique_ptr<Skeleton2D> sk(new Skeleton2D());
int b1 = sk->createBone(0, 1.f);
int b2 = sk->createBone(b1, 1.f);
sk->setPosition(0, 0.f, 0.f);
sk->setRotation(0, 0.f);
sk->setRotation(b1, 0.f);
sk->setRotation(b2, float(M_PI / 2.0));
sk->forwardKinematics();
CHECK(near(sk->getX(b1), 1.f));
CHECK(near(sk->getY(b1), 0.f));
CHECK(near(sk->getX(b2), 1.f));
CHECK(near(sk->getY(b2), 1.f));
}
TEST_CASE("ik.solver2d.reachesTarget") {
std::unique_ptr<Skeleton2D> sk(new Skeleton2D());
int b1 = sk->createBone(0, 1.f);
int b2 = sk->createBone(b1, 1.f);
sk->initStraightPose(0.f, 0.f);
std::unique_ptr<Solver2D> solver(new Solver2D());
solver->setMaxIterations(32);
solver->setTolerance(1e-3f);
solver->addTarget(b2, 1.f, 1.f, 1.f);
CHECK_EQ(solver->getTargetCount(), 1);
CHECK(solver->solve(sk.get()));
CHECK(near(sk->getX(b2), 1.f, 1e-2f));
CHECK(near(sk->getY(b2), 1.f, 1e-2f));
}
TEST_CASE("ik.solver2d.constraints") {
std::unique_ptr<Skeleton2D> sk(new Skeleton2D());
int b1 = sk->createBone(0, 1.f);
int b2 = sk->createBone(b1, 1.f);
sk->initStraightPose(0.f, 0.f);
sk->setConstraints(b1, -0.2f, 0.2f);
CHECK(sk->hasConstraints(b1));
std::unique_ptr<Solver2D> solver(new Solver2D());
solver->setMaxIterations(64);
solver->setTolerance(1e-3f);
solver->addTarget(b2, 0.f, 2.f, 1.f);
solver->solve(sk.get());
// Hinge limit on b1 should keep the chain from folding fully upward.
CHECK(std::fabs(sk->getRotation(b1)) <= 0.25f + 1e-3f);
}
TEST_CASE("ik.skeleton3d.straightPoseAndSolve") {
std::unique_ptr<Skeleton3D> sk(new Skeleton3D());
int b1 = sk->createBone(0, 1.f);
int b2 = sk->createBone(b1, 1.f);
sk->initStraightPose(0.f, 0.f, 0.f);
CHECK(near(sk->getX(b2), 2.f));
CHECK(near(sk->getY(b2), 0.f));
CHECK(near(sk->getZ(b2), 0.f));
std::unique_ptr<Solver3D> solver(new Solver3D());
solver->setMaxIterations(48);
solver->addTarget(b2, 1.f, 1.f, 0.f, 1.f);
CHECK(solver->solve(sk.get()));
CHECK(near(sk->getX(b2), 1.f, 2e-2f));
CHECK(near(sk->getY(b2), 1.f, 2e-2f));
}
TEST_CASE("ik.solver2d.solveChain") {
std::unique_ptr<Skeleton2D> sk(new Skeleton2D());
int b1 = sk->createBone(0, 1.f);
int b2 = sk->createBone(b1, 1.f);
int b3 = sk->createBone(0, 1.f); // side branch under the skeleton root
// createBone renumbers the skeleton (BFS order), so re-query ids afterwards.
b1 = sk->getChild(0, 0);
b3 = sk->getChild(0, 1);
b2 = sk->getChild(b1, 0);
sk->initStraightPose(10.f, 20.f);
CHECK(near(sk->getX(b1), 11.f));
CHECK(near(sk->getX(b2), 12.f));
CHECK(near(sk->getX(b3), 11.f));
std::unique_ptr<Solver2D> solver(new Solver2D());
solver->setMaxIterations(32);
solver->setTolerance(1e-3f);
solver->addTarget(b2, 11.f, 21.f, 1.f);
CHECK(solver->solveChain(sk.get(), b1, b2));
// Chain root (b1) stays pinned; skeleton root and the side branch are untouched.
CHECK(near(sk->getX(0), 10.f));
CHECK(near(sk->getY(0), 20.f));
CHECK(near(sk->getX(b1), 11.f));
CHECK(near(sk->getY(b1), 20.f));
CHECK(near(sk->getX(b3), 11.f));
CHECK(near(sk->getY(b3), 20.f));
CHECK(near(sk->getX(b2), 11.f, 1e-2f));
CHECK(near(sk->getY(b2), 21.f, 1e-2f));
}
TEST_CASE("ik.solver2d.solveChain.validation") {
std::unique_ptr<Skeleton2D> sk(new Skeleton2D());
int b1 = sk->createBone(0, 1.f);
int b2 = sk->createBone(b1, 1.f);
int b3 = sk->createBone(0, 1.f);
b1 = sk->getChild(0, 0);
b3 = sk->getChild(0, 1);
b2 = sk->getChild(b1, 0);
sk->initStraightPose(0.f, 0.f);
std::unique_ptr<Solver2D> solver(new Solver2D());
solver->addTarget(b3, 1.f, 1.f, 1.f);
// Target on the side branch is not part of the chain: nothing to solve.
CHECK(solver->solveChain(sk.get(), b1, b2));
CHECK(near(sk->getX(b2), 2.f));
CHECK_THROWS(solver->solveChain(sk.get(), b2, b1)); // tip not a descendant of root
CHECK_THROWS(solver->solveChain(sk.get(), b1, b1)); // root == tip
CHECK_THROWS(solver->solveChain(sk.get(), 0, 99)); // out of range
}
TEST_CASE("ik.solver2d.influence") {
std::unique_ptr<Skeleton2D> sk(new Skeleton2D());
int b1 = sk->createBone(0, 1.f);
int b2 = sk->createBone(b1, 1.f);
sk->initStraightPose(0.f, 0.f);
std::unique_ptr<Solver2D> solver(new Solver2D());
solver->setMaxIterations(32);
solver->addTarget(b2, 0.f, 2.f, 1.f);
solver->setInfluence(0.5f);
solver->solve(sk.get());
CHECK(near(sk->getX(b2), 1.f, 1e-2f));
CHECK(near(sk->getY(b2), 1.f, 1e-2f));
sk->initStraightPose(0.f, 0.f);
solver->setInfluence(0.f);
solver->solve(sk.get());
CHECK(near(sk->getX(b2), 2.f));
CHECK(near(sk->getY(b2), 0.f));
CHECK_THROWS((solver->setInfluence(1.5f), false));
}
TEST_CASE("ik.solver3d.solveChain.pinsChainRoot") {
std::unique_ptr<Skeleton3D> sk(new Skeleton3D());
int b1 = sk->createBone(0, 1.f);
int b2 = sk->createBone(b1, 1.f);
int b3 = sk->createBone(0, 1.f);
b1 = sk->getChild(0, 0);
b3 = sk->getChild(0, 1);
b2 = sk->getChild(b1, 0);
sk->initStraightPose(0.f, 0.f, 0.f);
std::unique_ptr<Solver3D> solver(new Solver3D());
solver->setMaxIterations(48);
solver->setTolerance(1e-3f);
solver->addTarget(b2, 1.f, 1.f, 0.f, 1.f);
CHECK(solver->solveChain(sk.get(), b1, b2));
CHECK(near(sk->getX(0), 0.f));
CHECK(near(sk->getX(b1), 1.f));
CHECK(near(sk->getY(b1), 0.f));
CHECK(near(sk->getX(b3), 1.f));
CHECK(near(sk->getY(b3), 0.f));
CHECK(near(sk->getX(b2), 1.f, 2e-2f));
CHECK(near(sk->getY(b2), 1.f, 2e-2f));
}
TEST_CASE("ik.solver3d.pole") {
std::unique_ptr<Skeleton3D> sk(new Skeleton3D());
int b1 = sk->createBone(0, 1.f);
int b2 = sk->createBone(b1, 1.f);
sk->initStraightPose(0.f, 0.f, 0.f);
std::unique_ptr<Solver3D> solver(new Solver3D());
solver->setMaxIterations(64);
solver->setTolerance(1e-4f);
solver->addTarget(b2, 1.5f, 0.5f, 0.f, 1.f);
solver->setPole(1.f, 1.f, 0.f, 1.f);
CHECK(solver->hasPole());
CHECK(near(solver->getPoleWeight(), 1.f));
CHECK(solver->solveChain(sk.get(), 0, b2));
const float upY = sk->getY(b1);
CHECK(upY > 0.1f);
sk->initStraightPose(0.f, 0.f, 0.f);
solver->setPole(1.f, -1.f, 0.f, 1.f);
CHECK(solver->solveChain(sk.get(), 0, b2));
const float downY = sk->getY(b1);
CHECK(downY < -0.1f);
sk->initStraightPose(0.f, 0.f, 0.f);
solver->clearPole();
CHECK(!solver->hasPole());
CHECK(solver->solveChain(sk.get(), 0, b2));
}
TEST_CASE("ik.solver3d.tipRotation") {
std::unique_ptr<Skeleton3D> sk(new Skeleton3D());
int b1 = sk->createBone(0, 1.f);
int b2 = sk->createBone(b1, 1.f);
sk->initStraightPose(0.f, 0.f, 0.f);
std::unique_ptr<Solver3D> solver(new Solver3D());
solver->setMaxIterations(48);
solver->addTarget(b2, 2.f, 0.f, 0.f, 1.f);
solver->setTipRotation(b2, 0.f, static_cast<float>(M_PI / 2.0), 1.f);
CHECK(near(solver->getTipRotationWeight(), 1.f));
CHECK(solver->solveChain(sk.get(), 0, b2));
// Tip keeps reaching the target while its world direction is pitched up.
CHECK(near(sk->getX(b2), 2.f, 2e-2f));
CHECK(near(sk->getOrientationZ(b2), 1.f, 1e-2f));
CHECK(near(sk->getRotationPitch(b2), static_cast<float>(M_PI / 2.0), 1e-2f));
solver->clearTipRotation();
CHECK(near(solver->getTipRotationWeight(), 0.f));
CHECK(solver->solveChain(sk.get(), 0, b2));
CHECK(near(sk->getOrientationZ(b2), 0.f, 1e-2f));
}
TEST_CASE("ik.solver2d.stepChain") {
std::unique_ptr<Skeleton2D> sk(new Skeleton2D());
int b1 = sk->createBone(0, 1.f);
int b2 = sk->createBone(b1, 1.f);
sk->initStraightPose(0.f, 0.f);
std::unique_ptr<Solver2D> solver(new Solver2D());
solver->addTarget(b2, 0.f, 2.f, 1.f);
solver->stepChain(sk.get(), 0, b2, 1.f);
CHECK(near(sk->getX(b2), 0.f, 1e-2f));
CHECK(near(sk->getY(b2), 2.f, 1e-2f));
}
TEST_CASE("ik.factory") {
auto *mod = IK::create();
std::unique_ptr<Skeleton2D> sk(mod->newSkeleton2D());
std::unique_ptr<Solver2D> solver(mod->newSolver2D());
CHECK_EQ(sk->getBoneCount(), 1);
CHECK_EQ(solver->getMaxIterations(), 16);
}
TEST_CASE("ik.invalidBoneThrows") {
std::unique_ptr<Skeleton2D> sk(new Skeleton2D());
bool threw = false;
try {
sk->getX(99);
} catch (const eve::Exception &) {
threw = true;
}
CHECK(threw);
}
static void drawSeg(Graphics *gfx, float x0, float y0, float x1, float y1, const Color &c) {
const float dx = x1 - x0;
const float dy = y1 - y0;
const float len = std::sqrt(dx * dx + dy * dy);
if (len < 0.5f) return;
const float steps = std::max(1.f, len / 2.5f);
for (float t = 0.f; t <= 1.f; t += 1.f / steps) {
gfx->drawSolidRect(x0 + dx * t - 2.f, y0 + dy * t - 2.f, 4.f, 4.f, c);
}
}
TEST_CASE("ik.solver2d.armReachPreview") {
std::unique_ptr<Skeleton2D> sk(new Skeleton2D());
// Multi-segment arm in world units (will scale to pixels).
int b1 = sk->createBone(0, 1.1f);
int b2 = sk->createBone(b1, 0.95f);
int b3 = sk->createBone(b2, 0.7f);
sk->initStraightPose(0.f, 0.f);
sk->setConstraints(b1, -1.2f, 1.2f);
sk->setConstraints(b2, -1.6f, 1.6f);
std::unique_ptr<Solver2D> solver(new Solver2D());
solver->setMaxIterations(40);
solver->setTolerance(1e-3f);
solver->addTarget(b3, 1.5f, 0.8f, 1.f);
auto *win = eve::window::Window::create();
auto *gfx = Graphics::create();
REQUIRE(win != nullptr);
REQUIRE(gfx != nullptr);
eve::window::WindowSettings s;
s.width = 560;
s.height = 420;
s.centered = true;
REQUIRE(win->setWindowSettings(s));
const float scale = 110.f;
const float ox = float(gfx->getWidth()) * 0.35f;
const float oy = float(gfx->getHeight()) * 0.55f;
gfx->setBackgroundColorRGBA(0.07f, 0.08f, 0.11f, 1.f);
float tipErr = 1.f;
for (int frame = 0; frame < 120; ++frame) {
const float t = float(frame) * 0.05f;
// Orbiting reach target (figure-eight-ish).
const float tx = 1.1f + 0.85f * std::cos(t);
const float ty = 0.2f + 0.75f * std::sin(t * 1.3f);
solver->clearTargets();
solver->addTarget(b3, tx, ty, 1.f);
solver->solve(sk.get());
const float tipX = sk->getX(b3);
const float tipY = sk->getY(b3);
tipErr = std::sqrt((tipX - tx) * (tipX - tx) + (tipY - ty) * (tipY - ty));
gfx->clearScreen();
// Ground / base
gfx->drawSolidRect(ox - 18.f, oy - 6.f, 36.f, 12.f, Color(0.35f, 0.38f, 0.45f, 1.f));
auto toScreen = [&](float wx, float wy, float &sx, float &sy) {
sx = ox + wx * scale;
sy = oy - wy * scale;
};
float sx0, sy0, sx1, sy1;
toScreen(sk->getX(0), sk->getY(0), sx0, sy0);
for (int b = 1; b < sk->getBoneCount(); ++b) {
const int p = sk->getParent(b);
toScreen(sk->getX(p), sk->getY(p), sx0, sy0);
toScreen(sk->getX(b), sk->getY(b), sx1, sy1);
drawSeg(gfx, sx0, sy0, sx1, sy1, Color(0.45f, 0.85f, 1.f, 1.f));
gfx->drawSolidRect(sx1 - 3.f, sy1 - 3.f, 6.f, 6.f, Color(1.f, 1.f, 1.f, 1.f));
}
float tSx, tSy;
toScreen(tx, ty, tSx, tSy);
gfx->drawSolidRect(tSx - 5.f, tSy - 5.f, 10.f, 10.f, Color(1.f, 0.45f, 0.25f, 1.f));
gfx->present();
SDL_Event e;
while (SDL_PollEvent(&e)) {
if (e.type == SDL_QUIT) break;
}
SDL_Delay(16);
}
CHECK(tipErr < 0.25f);
win->close();
}