From e9d670b755e2841fedbb0b239ed39e657b9f73f6 Mon Sep 17 00:00:00 2001 From: Toastconcern Date: Wed, 21 Oct 2026 13:44:56 -0500 Subject: [PATCH] Invisible walls: placed in Studio, and Meta's converted; Planet removed; submenu safe triangle - World > Add > Invisible Wall: a 2 x 2.5 m box (Object role) standing on the floor across the view. Frame Home never draws it; it stops you and is never stood on. - Meta's invisible walls (a solid collider with nothing drawn, 2 m or taller: Cascadia's, Space Station's, Desert Terrace's, Haven's) load as invisible walls instead of surfaces, so the play space they bound holds. - Walk grid: a drawn floor's edge exemption no longer applies within a body's radius of a wall, so a wall through a drawn floor takes that floor away. tools/walktest/wallcheck: walls at every angle never have walkable area inside. - New items land on the home's own surfaces when the spot is beyond the walkable area. - The Planet (World > Add > Planet, Planet Pull) is gone; older files' planet zones still load. - ImGui menus: a safe triangle from where the pointer was on the item to the whole submenu; crossing other items on the way neither opens them nor closes it (resting 0.3 s still does). - Self-test: invisible wall placed, walked into (with and without the area), through a drawn floor, undone (98/98). Co-Authored-By: Claude Opus 5.5 --- docs/studio-guide.md | 8 +- src/walkgrid.h | 18 +- studio/studio.cpp | 315 +++++++++++-------------- third_party/imgui/imgui_widgets.cpp | 41 ++-- third_party/qhe/loaders/scene_loader.h | 31 +++ tools/walktest/CMakeLists.txt | 4 + tools/walktest/listcolliders.cpp | 6 +- tools/walktest/wallcheck.cpp | 44 ++++ 8 files changed, 271 insertions(+), 196 deletions(-) create mode 100644 tools/walktest/wallcheck.cpp diff --git a/docs/studio-guide.md b/docs/studio-guide.md index e7fff8a..4d94a15 100644 --- a/docs/studio-guide.md +++ b/docs/studio-guide.md @@ -101,12 +101,16 @@ edges, grey are floors you can't reach. **W** shows or hides it. | **Teleport Hotspot** | a spot the teleport arc snaps to | | **Surface** | a box you can scale into a floor, a step or a wall | | **Wall Spot** | where SteamVR's panels dock | -| **Gravity Zone** | inside it, "down" is the zone's own: a direction, any surface you stand on, or a planet's pull | -| **Planet** | a sphere you can walk all the way round | +| **Invisible Wall** | stops you; never drawn on the headset. Scale and turn it to fit; where it stands is no longer walkable | +| **Gravity Zone** | inside it, "down" is the zone's own: a direction, or any surface you stand on | Select an item to move it with the gizmo or type its values in the Selected tab. **World > Clear World Layer** removes them all. +Meta's own homes have invisible walls too, round their play space (Cascadia, Space Station, Desert Terrace, Haven). +Frame Studio and Frame Home read them as invisible walls, so you can't walk out past them. Select one in the Explorer +to delete it, or set its **Role** to Surface, if you want to go further in that home. + ## The environment: name, picture, sound The **Environment** tab, top to bottom: diff --git a/src/walkgrid.h b/src/walkgrid.h index a9ba0b9..08a6d48 100644 --- a/src/walkgrid.h +++ b/src/walkgrid.h @@ -1265,11 +1265,27 @@ inline std::shared_ptr buildWalkGrid(const std::vector& so } } } + // A wall (a Wall / Object surface: an invisible wall) within a body's radius of a span, in the body's way there. + const int wallReach = static_cast(std::ceil(P.radius / cs)); + auto wallNear = [&](size_t s) { + const float h = cs_[s].hi, reach = P.radius + cs; + for (int dz = -wallReach; dz <= wallReach; ++dz) + for (int dx = -wallReach; dx <= wallReach; ++dx) { + const int x = spanX[s] + dx, z = spanZ[s] + dz; + if (x < 0 || z < 0 || x >= nx || z >= nz || static_cast(dx * dx + dz * dz) * cs * cs > reach * reach) continue; + const size_t cell = static_cast(z) * nx + x; + for (uint32_t t = cstart[cell]; t < cstart[cell + 1]; ++t) + if ((cs_[t].flags & kWalkWall) && cs_[t].lo < h + P.headroom && cs_[t].hi > h - P.step) return true; + } + return false; + }; std::vector core(nsp, 0); for (size_t s = 0; s < nsp; ++s) { if (!cand[s]) { dist[s] = 0; continue; } dist[s] = std::sqrt(std::min(dist[s], 1e8f)) * cs; - if (cs_[s].flags & kWalkFloorRole) dist[s] = std::max(dist[s], 2 * P.radius); // its edge is where it ends + // A drawn floor's edge is where it ends (walkable right up to it), but a wall standing on it still keeps + // the body its radius away: one drawn through it takes that floor away. + if ((cs_[s].flags & kWalkFloorRole) && !wallNear(s)) dist[s] = std::max(dist[s], 2 * P.radius); core[s] = dist[s] >= P.radius - 1e-4f; } // 6. Separate areas; keep the ones the player can get to. diff --git a/studio/studio.cpp b/studio/studio.cpp index 79229e0..136b660 100644 --- a/studio/studio.cpp +++ b/studio/studio.cpp @@ -405,7 +405,7 @@ const uint32_t kShowColors[kShowCats] = {0x3fd2ff, 0x5fe07a, 0xffd84a, 0x4cd964, const char* kGravityNames[3] = {"Direction", "Planet", "Walk on any surface"}; const char* kGravityHelp[3] = { "Inside the box, down is the box's -Y: rotate the zone to choose which way is down (a wall or the ceiling as the floor).", - "Down points at the centre within the pull radius: walk all the way round. Give the planet a walkable surface (World > Add > Planet makes both).", + "Down points at the centre within the pull radius: walk all the way round it (made by older versions of Frame Studio).", "Inside the box, the surface under your feet is the floor: walk off the floor up a wall and across the ceiling."}; // A collider's role, as Studio names and explains it. There's one kind of surface: you walk on it where it's flat @@ -1476,7 +1476,7 @@ struct Studio { // What an environment is comes from its file alone: a .fenv's world layer, edits, sound, name and picture; a // Meta home or models opened fresh, as they ship. Nothing is kept on the side between opens. The one exception is - // a reload of the open home (Add Vista, Add Models, a planet): its unsaved changes are carried across it. + // a reload of the open home (Add Vista, Add Models): its unsaved changes are carried across it. struct Carry { bool any = false; std::vector objects; std::string world, title, image; SoundState sound; WorldSettings settings; } carry; std::vector currentObjectEdits() const { std::vector v; @@ -2762,6 +2762,21 @@ struct Studio { void walkSettingsChanged() { walkSaveNext = true; walkPending = true; walkChanged = std::chrono::steady_clock::now(); } // A Frame Home walker on the area as shown (placing things on the floor, the self-test). + // The floor under (x, z) from height y down (up to 0.5 m above it): the walkable area's, else the home's own + // surfaces there (a spot beyond the area: past a home's invisible walls, or out of reach). + bool floorUnder(float x, float z, float y, float& out) { + WorldRuntime rt; + walkRuntime(rt); + if (rt.floorAt(x, z, y, 0.5f, 5.0f, out)) return true; + if (!walkShown) return false; // (that was the surfaces already) + HomeWorld w = scene->world; + w.walkGrid.clear(); + w.walkGridCache.reset(); + WorldRuntime surfaces; + surfaces.build(w); + return surfaces.floorAt(x, z, y, 0.5f, 5.0f, out); + } + void walkRuntime(WorldRuntime& rt) { HomeWorld w = scene->world; w.walkGridCache = walkShown; @@ -3311,131 +3326,41 @@ struct Studio { selected = -1; } - // A planet's surface, 1024 x 512 in latitude / longitude: seas, sand, grass, rock and snow from layered value - // noise sampled on the sphere (no seam at the date line), with soft sunlight baked in. - static void writePlanetTexture(const fs::path& path, uint32_t seed) { - auto hash = [seed](int x, int y, int z) { - uint32_t h = static_cast(x) * 374761393u + static_cast(y) * 668265263u + static_cast(z) * 2147483647u + seed * 974711u; - h = (h ^ (h >> 13)) * 1274126177u; - return ((h ^ (h >> 16)) & 0xffff) / 65535.0f; - }; - auto noise = [&](float x, float y, float z) { // trilinear value noise - const int xi = static_cast(std::floor(x)), yi = static_cast(std::floor(y)), zi = static_cast(std::floor(z)); - const float fx = x - xi, fy = y - yi, fz = z - zi; - auto sm = [](float t) { return t * t * (3 - 2 * t); }; - const float u = sm(fx), v = sm(fy), w = sm(fz); - float r = 0; - for (int c = 0; c < 8; ++c) { - const float wt = (c & 1 ? u : 1 - u) * (c & 2 ? v : 1 - v) * (c & 4 ? w : 1 - w); - r += wt * hash(xi + (c & 1), yi + ((c >> 1) & 1), zi + ((c >> 2) & 1)); - } - return r; - }; - const int W = 1024, H = 512; - std::vector px(static_cast(W) * H * 4); - const float L[3] = {0.48f, 0.78f, 0.40f}; - for (int y = 0; y < H; ++y) - for (int x = 0; x < W; ++x) { - const float th = kPi * (y + 0.5f) / H, ph = 2 * kPi * (x + 0.5f) / W; - const float d[3] = {std::sin(th) * std::cos(ph), std::cos(th), std::sin(th) * std::sin(ph)}; - float h = 0, amp = 0.5f, fr = 1.6f; - for (int o = 0; o < 6; ++o) { h += amp * noise(d[0] * fr + 11, d[1] * fr + 7, d[2] * fr + 3); amp *= 0.5f; fr *= 2.1f; } - const float pole = std::fabs(d[1]); - float c[3]; - auto mixc = [&](const float* a, const float* b, float t) { t = std::clamp(t, 0.0f, 1.0f); for (int k = 0; k < 3; ++k) c[k] = a[k] + (b[k] - a[k]) * t; }; - static const float deep[3] = {0.05f, 0.16f, 0.38f}, shallow[3] = {0.10f, 0.38f, 0.58f}, sand[3] = {0.78f, 0.72f, 0.50f}, - grass[3] = {0.25f, 0.52f, 0.20f}, forest[3] = {0.13f, 0.34f, 0.14f}, rock[3] = {0.46f, 0.42f, 0.38f}, snow[3] = {0.94f, 0.95f, 0.97f}; - if (h < 0.46f) mixc(deep, shallow, (h - 0.30f) / 0.16f); - else if (h < 0.49f) mixc(sand, sand, 0); - else if (h < 0.60f) mixc(grass, forest, (h - 0.49f) / 0.11f); - else if (h < 0.68f) mixc(forest, rock, (h - 0.60f) / 0.08f); - else mixc(rock, snow, (h - 0.68f) / 0.06f); - if (pole > 0.86f) mixc(c, snow, (pole - 0.86f) / 0.06f); // ice caps - const float lit = 0.55f + 0.45f * std::max(0.0f, d[0] * L[0] + d[1] * L[1] + d[2] * L[2]); - u8* p = &px[(static_cast(y) * W + x) * 4]; - for (int k = 0; k < 3; ++k) p[k] = static_cast(std::clamp(c[k] * lit, 0.0f, 1.0f) * 255.0f + 0.5f); - p[3] = 255; - } - if (FILE* f = platform::openFile(path.c_str(), "wb")) { - stbi_write_png_to_func([](void* ctx, void* data, int size) { std::fwrite(data, 1, static_cast(size), static_cast(ctx)); }, - f, W, H, 4, px.data(), W * 4); - std::fclose(f); - } - } - - // World > Add > Planet: a walkable sphere you can see (a generated model) with planet gravity round it, in - // front of the view. Adding the model reloads the home, so the world layer is saved first. - void addPlanet(float radius) { - if (!scene || saving || uploading || texLoading || loadState == 1) return; // it reloads the home - float fwd[3]; - cam.forward(fwd); - const float c[3] = {cam.pivot[0] + fwd[0] * (radius + 4), cam.pivot[1] + fwd[1] * (radius + 4), cam.pivot[2] + fwd[2] * (radius + 4)}; + // World > Add > Invisible Wall: 2 m wide and 2.5 m tall, standing on the floor where the camera looks, across + // the view. Frame Home never draws it; it stops you (Object), and the walkable area it stands in is taken away + // (the walk grid never stands anyone in a wall's columns). + void addInvisibleWall() { + if (!scene) return; pushWorldUndo(); HomeWorld& w = scene->world; + const float x = cam.pivot[0], z = cam.pivot[2]; + float y = cam.pivot[1] - 1.6f; + { // on the floor under the pivot when there is one + float f; + if (floorUnder(x, z, cam.pivot[1], f)) y = f; + } + float fwd[3]; + cam.forward(fwd); + float fx = fwd[0], fz = fwd[2]; + const float fl = std::sqrt(fx * fx + fz * fz); + if (fl < 1e-4f) { fx = 0; fz = 1; } else { fx /= fl; fz /= fl; } + WorldCollider c; int n = 1; - for (const WorldGravity& g : w.gravity) n += g.kind == WorldGravity::Planet; - WorldCollider s; - s.name = "Planet " + std::to_string(n); - s.kind = WorldCollider::Sphere; - s.role = WorldCollider::Solid; - s.radius = radius; - translation(s.world, c[0], c[1], c[2]); - w.colliders.push_back(s); - WorldGravity g; - g.kind = WorldGravity::Planet; - g.name = "Planet " + std::to_string(n) + " gravity"; - g.radius = radius * 3; - g.priority = static_cast(w.gravity.size()); - translation(g.world, c[0], c[1], c[2]); - w.gravity.push_back(g); - wKind = kGravity; - wIndex = worldCount(kGravity) - 1; + for (const WorldCollider& o : w.colliders) n += isInvisibleWall(o); + c.name = "Invisible wall " + std::to_string(n); + c.kind = WorldCollider::Box; + c.role = WorldCollider::Object; + c.placed = true; + c.half[0] = 1.0f; c.half[1] = 1.25f; c.half[2] = 0.05f; + const float m[16] = {fz, 0, -fx, 0, 0, 1, 0, 0, fx, 0, fz, 0, x, y + c.half[1], z, 1}; // local X across the view + std::copy_n(m, 16, c.world); + w.colliders.push_back(c); + wKind = kCollider; + wIndex = static_cast(w.colliders.size()) - 1; selected = -1; - if (openHomeIsFenv()) { - platform::message("Frame Studio", "The planet's surface and gravity are added. To also see it, open the environment's original home: " - "a saved .fenv can't take new models."); - return; - } - // The visible sphere: a UV sphere written as OBJ, added like any imported model. - std::error_code ec; - fs::create_directories(dataDir() / L"models", ec); - // A name no other home's planet has: the sphere's vertices are where this one stands, so reusing a file - // would move another home's planet here. - std::string stem; - for (int k = 0;; ++k) { - stem = "planet-" + std::to_string(n) + "-" + std::to_string(static_cast(radius * 100)) + (k ? "-" + std::to_string(k) : ""); - if (!fs::exists(dataDir() / L"models" / widen(stem + ".obj"), ec)) break; - } - const fs::path obj = dataDir() / L"models" / widen(stem + ".obj"); - writePlanetTexture(dataDir() / L"models" / widen(stem + ".png"), static_cast(n * 7919 + radius * 13)); - if (FILE* m = platform::openFile((dataDir() / L"models" / widen(stem + ".mtl")).c_str(), "wb")) { - std::fprintf(m, "newmtl Planet\nKd 1 1 1\nmap_Kd %s.png\n", stem.c_str()); - std::fclose(m); - } - if (FILE* f = platform::openFile(obj.c_str(), "wb")) { - const int lat = 48, lon = 96; - std::fprintf(f, "# Frame Studio planet, radius %.2f m\nmtllib %s.mtl\no Planet%d\nusemtl Planet\n", radius, stem.c_str(), n); - for (int i = 0; i <= lat; ++i) - for (int j = 0; j <= lon; ++j) { - const float th = kPi * i / lat, ph = 2 * kPi * j / lon; - const float d[3] = {std::sin(th) * std::cos(ph), std::cos(th), std::sin(th) * std::sin(ph)}; - std::fprintf(f, "v %.4f %.4f %.4f\nvn %.4f %.4f %.4f\nvt %.4f %.4f\n", c[0] + d[0] * radius, c[1] + d[1] * radius, c[2] + d[2] * radius, - d[0], d[1], d[2], static_cast(j) / lon, 1.0f - static_cast(i) / lat); - } - for (int i = 0; i < lat; ++i) - for (int j = 0; j < lon; ++j) { - const int a = i * (lon + 1) + j + 1, b = a + lon + 1; - std::fprintf(f, "f %d/%d/%d %d/%d/%d %d/%d/%d\nf %d/%d/%d %d/%d/%d %d/%d/%d\n", a, a, a, a + 1, a + 1, a + 1, b, b, b, - a + 1, a + 1, a + 1, b + 1, b + 1, b + 1, b, b, b); - } - std::fclose(f); - } - carryAcrossReload(); // keeps the new surface and gravity across the reload - dirty = false; - std::vector paths = homePaths; - paths.push_back(narrow(obj.wstring())); - open(paths, false); + walkPending = true; } + static bool isInvisibleWall(const WorldCollider& c) { return c.kind != WorldCollider::Mesh && c.role == WorldCollider::Object; } // New items go where the camera looks, on the floor below it. void addWorld(int kind) { @@ -3445,10 +3370,8 @@ struct Studio { const float x = cam.pivot[0], z = cam.pivot[2]; float y = cam.pivot[1] - 1.6f; { // drop onto the floor under the pivot when there is one - WorldRuntime rt; - walkRuntime(rt); float f; - if (rt.floorAt(x, z, cam.pivot[1], 0.5f, 5.0f, f)) y = f; + if (floorUnder(x, z, cam.pivot[1], f)) y = f; } WorldPoint p; p.pos[0] = x; p.pos[1] = y; p.pos[2] = z; @@ -4040,19 +3963,12 @@ struct Studio { if (ImGui::MenuItem("Teleport Hotspot")) addWorld(kHotspot); ImGui::Separator(); if (ImGui::MenuItem("Surface (2 x 2 m box: scale it into a floor, a step or a wall)")) addWorld(kCollider); + if (ImGui::MenuItem("Invisible Wall")) addInvisibleWall(); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Stops the player, never drawn on the headset. Scale and turn it to fit;\nwhere it stands is no longer walkable."); if (ImGui::MenuItem("Wall Spot (panel placement)")) addWorld(kWallSpot); ImGui::Separator(); if (ImGui::MenuItem("Gravity Zone: Direction")) addGravity(WorldGravity::Direction); if (ImGui::MenuItem("Gravity Zone: Walk on Any Surface")) addGravity(WorldGravity::Surface); - if (ImGui::MenuItem("Gravity Zone: Planet Pull")) addGravity(WorldGravity::Planet); - if (ImGui::BeginMenu("Planet (sphere + gravity)")) { - for (float rad : {5.0f, 10.0f, 25.0f, 50.0f}) { - char lbl[64]; - std::snprintf(lbl, sizeof lbl, "%.0f m radius", rad); - if (ImGui::MenuItem(lbl)) addPlanet(rad); - } - ImGui::EndMenu(); - } ImGui::EndMenu(); } { // falling this far puts the player back at the spawn (Frame Home) @@ -5092,7 +5008,7 @@ struct Studio { WorldCollider& c = scene->world.colliders[wIndex]; static const char* kinds[] = {"Box", "Sphere", "Capsule", "Mesh"}; fieldLabel("Role"); - static const char* roles[4] = {"Surface", "Collider", "Walk through", "Drawn floor"}; + const char* roles[4] = {"Surface", c.kind == WorldCollider::Mesh ? "Collider" : "Invisible wall", "Walk through", "Drawn floor"}; static const int roleOf[4] = {WorldCollider::Solid, WorldCollider::Object, WorldCollider::Trigger, WorldCollider::Walkable}; int role = c.role == WorldCollider::Trigger ? 2 : c.role == WorldCollider::Object ? 1 : c.role == WorldCollider::Walkable ? 3 : 0; if (ImGui::Combo("##wrole", &role, roles, 4) && roleOf[role] != c.role) { @@ -5149,7 +5065,13 @@ struct Studio { track(); fieldLabel("Kind"); int kind = std::clamp(g.kind, 0, 2); - if (ImGui::Combo("##gkind", &kind, kGravityNames, 3) && kind != g.kind) { before = std::make_shared(scene->world); g.kind = kind; changed = true; } + if (ImGui::BeginCombo("##gkind", kGravityNames[kind])) { + for (int k = 0; k < 3; ++k) { + if (k == WorldGravity::Planet && kind != k) continue; // planet zones are no longer made (older files keep theirs) + if (ImGui::Selectable(kGravityNames[k], k == kind) && k != g.kind) { before = std::make_shared(scene->world); g.kind = k; changed = true; } + } + ImGui::EndCombo(); + } ImGui::PushTextWrapPos(0); ImGui::TextDisabled("%s", kGravityHelp[std::clamp(g.kind, 0, 2)]); ImGui::PopTextWrapPos(); @@ -7617,9 +7539,29 @@ struct SelfTest { // ── Draw Floor: fill its flat faces (walkable even on furniture), the brush erases, one Ctrl+Z each stages.push_back([this] { - a.fillDrawnFloor(static_cast(obj), true); - const int f = a.drawnFloorIndex(a.objects[obj]); - check(f >= 0 && a.scene->world.colliders[f].role == WorldCollider::Walkable && a.surfaceParts(a.objects[obj]).size() == 1, + // Its own object: furniture-like, inside the walkable area's reach (a home's invisible walls keep the + // area to its play space; one beyond them is never stood on), else the one edited above. + a.rebakeWalk(); + drawObj = obj; + float best = 1e30f; + for (size_t i = 0; i < a.objects.size() && a.walkShown; ++i) { + const Object& o = a.objects[i]; + if (!a.canBeSurface(o) || o.hidden || o.category == kPlants) continue; + float mn[3], mx[3]; + a.worldBounds(o, mn, mx); + const float d = dist3(mn, mx); + int cx, cz; + if (d <= 0.4f || std::fabs(d - 1.5f) >= best || !a.walkShown->cellOf((mn[0] + mx[0]) * 0.5f, (mn[2] + mx[2]) * 0.5f, cx, cz)) continue; + std::vector fl, wl; + a.classifyObject(o, fl, wl); + if (fl.size() < 4) continue; + best = std::fabs(d - 1.5f); + drawObj = static_cast(i); + } + const size_t partsBefore = a.surfaceParts(a.objects[drawObj]).size(); + a.fillDrawnFloor(static_cast(drawObj), true); + const int f = a.drawnFloorIndex(a.objects[drawObj]); + check(f >= 0 && a.scene->world.colliders[f].role == WorldCollider::Walkable && a.surfaceParts(a.objects[drawObj]).size() == partsBefore, "Fill Flat Faces draws a floor on it (its own part; the surface stays)", f >= 0 ? std::to_string(a.scene->world.colliders[f].indices.size() / 3) + " triangles" : "none"); // its drawn faces: walkable (where there's headroom over them) once the area is baked a.rebakeWalk(); @@ -7643,17 +7585,17 @@ struct SelfTest { }); stages.push_back([this] { a.pushWorldUndo(); - a.paintFloor(static_cast(obj), wallAt, 100.0f, true, -1); // a brush bigger than it: all erased - check(a.drawnFloorIndex(a.objects[obj]) < 0, "the brush with Shift erases it"); + a.paintFloor(static_cast(drawObj), wallAt, 100.0f, true, -1); // a brush bigger than it: all erased + check(a.drawnFloorIndex(a.objects[drawObj]) < 0, "the brush with Shift erases it"); key(ImGuiKey_Z, true); queue.push_back(In{}); return 3; }); stages.push_back([this] { - check(a.drawnFloorIndex(a.objects[obj]) >= 0, "Ctrl+Z brings the drawn floor back"); + check(a.drawnFloorIndex(a.objects[drawObj]) >= 0, "Ctrl+Z brings the drawn floor back"); a.pushWorldUndo(); - a.paintFloor(static_cast(obj), wallAt, 0.2f, false, -1); - a.fillDrawnFloor(static_cast(obj), false); - check(a.drawnFloorIndex(a.objects[obj]) < 0, "Clear takes it away"); + a.paintFloor(static_cast(drawObj), wallAt, 0.2f, false, -1); + a.fillDrawnFloor(static_cast(drawObj), false); + check(a.drawnFloorIndex(a.objects[drawObj]) < 0, "Clear takes it away"); a.rebakeWalk(); return 2; }); @@ -7948,29 +7890,59 @@ struct SelfTest { screenshot("06_gravity_zone"); return 1; }); - // ── Planet: a sphere you can see and walk round (reloads the home with the generated model) + // ── Invisible walls: one placed on walkable floor takes that floor away, and stops Frame Home's walker; one + // through a drawn floor takes that away too stages.push_back([this] { - if (a.openHomeIsFenv()) { log("(planet model skipped: the open home is a .fenv)"); return 0; } - std::error_code ec; - for (const auto& e : fs::directory_iterator(dataDir() / L"models", ec)) modelsBefore.insert(e.path().wstring()); - planetMade = true; - a.addPlanet(5.0f); - return 10; + a.rebakeWalk(); + if (!a.walkShown || a.scene->world.spawns.empty()) { check(false, "World > Add > Invisible Wall", "no walkable area or spawn"); return 1; } + const WorldPoint& sp = a.scene->world.spawns[0]; + float h = 0; + if (!a.walkShown->walkable(sp.pos[0], sp.pos[2], sp.pos[1] + 0.05f, &h)) { check(false, "World > Add > Invisible Wall", "the spawn isn't walkable"); return 1; } + invisibleWallAt[0] = sp.pos[0]; invisibleWallAt[1] = h; invisibleWallAt[2] = sp.pos[2]; + const size_t before = a.scene->world.colliders.size(); + a.cam.pivot[0] = invisibleWallAt[0]; a.cam.pivot[1] = invisibleWallAt[1] + 1.0f; a.cam.pivot[2] = invisibleWallAt[2]; + a.addInvisibleWall(); + const WorldCollider& w = a.scene->world.colliders.back(); + check(a.scene->world.colliders.size() == before + 1 && Studio::isInvisibleWall(w) && std::fabs(w.world[13] - (h + 1.25f)) < 0.01f, + "World > Add > Invisible Wall stands one on the floor", w.name); + a.rebakeWalk(); + check(!a.walkShown->walkable(invisibleWallAt[0], invisibleWallAt[2], invisibleWallAt[1] + 0.05f), "...and where it stands is no longer walkable"); + // Frame Home's walker, a metre in front of it, walking straight at it. + const float nx = w.world[8], nz = w.world[10]; + for (int grid = 1; grid >= 0; --grid) { + WorldRuntime rt; + if (grid) a.walkRuntime(rt); + else { HomeWorld hw = a.scene->world; hw.walkGrid.clear(); hw.walkGridCache.reset(); rt.build(hw); } // (no area: its shapes) + float x = invisibleWallAt[0] - nx * 1.0f, z = invisibleWallAt[2] - nz * 1.0f; + for (int k = 0; k < 40; ++k) rt.move(x, z, invisibleWallAt[1], nx * 0.05f, nz * 0.05f); + const float past = (x - invisibleWallAt[0]) * nx + (z - invisibleWallAt[2]) * nz; // > 0: through it + check(past < -0.04f, grid ? "Frame Home's walker can't go through it" : "...nor without a walkable area (its shape alone)", + "stopped " + std::to_string(-past) + " m before it"); + } + a.step(a.undo, a.redo); + check(a.scene->world.colliders.size() == before, "Ctrl+Z takes the wall away"); + // A drawn floor there instead, then a wall through that. + WorldCollider f; + f.name = "selftest drawn floor"; + f.kind = WorldCollider::Box; + f.role = WorldCollider::Walkable; + f.half[0] = f.half[2] = 1.0f; f.half[1] = 0.02f; + translation(f.world, invisibleWallAt[0], invisibleWallAt[1] + 0.02f, invisibleWallAt[2]); + a.scene->world.colliders.push_back(f); + a.rebakeWalk(); + const bool drawnWalkable = a.walkShown->walkable(invisibleWallAt[0], invisibleWallAt[2], invisibleWallAt[1] + 0.1f); + a.addInvisibleWall(); + a.rebakeWalk(); + check(drawnWalkable && !a.walkShown->walkable(invisibleWallAt[0], invisibleWallAt[2], invisibleWallAt[1] + 0.1f), + "a wall through a drawn floor takes that floor away", drawnWalkable ? "" : "the drawn floor wasn't walkable to begin with"); + screenshot("07_invisible_wall"); + a.step(a.undo, a.redo); + auto& cs = a.scene->world.colliders; + cs.erase(std::remove_if(cs.begin(), cs.end(), [](const WorldCollider& c) { return c.name == "selftest drawn floor"; }), cs.end()); + check(cs.size() == before, "...and the one through the drawn floor"); + a.rebakeWalk(); + return 1; }); - stages.push_back([this] { - if (a.loadState != 0 || !a.scene || a.uploading || a.texLoading || a.objects.empty()) return -1; - bool model = false; - for (const Object& o : a.objects) model |= o.name.find("lanet") != std::string::npos; - int spheres = 0, planets = 0; - for (const WorldCollider& c : a.scene->world.colliders) spheres += c.kind == WorldCollider::Sphere && std::fabs(c.radius - 5) < 1e-3f; - for (const WorldGravity& g : a.scene->world.gravity) planets += g.kind == WorldGravity::Planet && std::fabs(g.radius - 15) < 1e-3f; - if (!a.openHomeIsFenv()) check(model, "World > Add > Planet adds a visible sphere"); - check(spheres == 1 && planets == 1, "...with a walkable surface and planet gravity (kept across the reload)"); - a.wKind = kGravity; a.wIndex = static_cast(a.scene->world.gravity.size()) - 1; - a.frameWorld(a.wKind, a.wIndex); - return 20; - }); - stages.push_back([this] { screenshot("07_planet"); return 1; }); // ── A background sound: added, undone, saved with the edits, back on reopening, packed in a .fenv stages.push_back([this] { std::error_code ec; @@ -8136,11 +8108,11 @@ struct SelfTest { struct SavedFile { fs::path path; bool existed; std::string bytes; }; std::vector saved; - std::set modelsBefore; - bool planetMade = false; fs::path soundWav, soundSaved, soundFenv; fs::path imageFenv; fs::path bundleFile; + int drawObj = -1; // the Draw Floor test's object + float invisibleWallAt[3] = {}; // the invisible-wall test's spot (on the spawn's floor) size_t libraryBefore = 0; // My Environments' count before the bundle is unpacked into it std::string titleWas, imageWas; std::array genCounts{}; // Auto-generate's walls, objects, wall spots, seats + hotspots (first run) @@ -8148,13 +8120,6 @@ struct SelfTest { size_t linkedBefore = 0; // mesh-linked surfaces before Make Walkable Floors (the home links its own) // the background-sound test's files (removed when it ends) void finish() { - if (planetMade) { // files the planet test generated (only those: not planets the user made) - std::error_code ec; - std::vector made; - for (const auto& e : fs::directory_iterator(dataDir() / L"models", ec)) - if (!modelsBefore.count(e.path().wstring()) && e.path().filename().wstring().rfind(L"planet-", 0) == 0) made.push_back(e.path()); - for (const fs::path& f : made) fs::remove(f, ec); - } for (const SavedFile& f : saved) { std::error_code ec; if (f.existed) std::ofstream(f.path, std::ios::binary) << f.bytes; diff --git a/third_party/imgui/imgui_widgets.cpp b/third_party/imgui/imgui_widgets.cpp index b5d66ba..8b83b2f 100644 --- a/third_party/imgui/imgui_widgets.cpp +++ b/third_party/imgui/imgui_widgets.cpp @@ -8854,22 +8854,33 @@ bool ImGui::BeginMenuEx(const char* label, const char* icon, bool enabled) bool moving_toward_child_menu = false; ImGuiPopupData* child_popup = (g.BeginPopupStack.Size < g.OpenPopupStack.Size) ? &g.OpenPopupStack[g.BeginPopupStack.Size] : NULL; // Popup candidate (testing below) ImGuiWindow* child_menu_window = (child_popup && child_popup->Window && child_popup->Window->ParentWindow == window) ? child_popup->Window : NULL; - if (g.HoveredWindow == window && child_menu_window != NULL) + // Frame Studio: a safe triangle from where the pointer last was on the item whose submenu is open to the + // submenu's whole near edge (not just the last frame's move, capped at 8 lines): while the pointer stays inside + // it, crossing other items on the way there neither opens them nor closes the submenu. Resting still on another + // item for a moment still opens that one. + if (child_menu_window != NULL) { - const float ref_unit = g.FontSize; // FIXME-DPI - const float child_dir = (window->Pos.x < child_menu_window->Pos.x) ? 1.0f : -1.0f; - const ImRect next_window_rect = child_menu_window->Rect(); - ImVec2 ta = (g.IO.MousePos - g.IO.MouseDelta); - ImVec2 tb = (child_dir > 0.0f) ? next_window_rect.GetTL() : next_window_rect.GetTR(); - ImVec2 tc = (child_dir > 0.0f) ? next_window_rect.GetBL() : next_window_rect.GetBR(); - const float pad_farmost_h = ImClamp(ImFabs(ta.x - tb.x) * 0.30f, ref_unit * 0.5f, ref_unit * 2.5f); // Add a bit of extra slack. - ta.x += child_dir * -0.5f; - tb.x += child_dir * ref_unit; - tc.x += child_dir * ref_unit; - tb.y = ta.y + ImMax((tb.y - pad_farmost_h) - ta.y, -ref_unit * 8.0f); // Triangle has maximum height to limit the slope and the bias toward large sub-menus - tc.y = ta.y + ImMin((tc.y + pad_farmost_h) - ta.y, +ref_unit * 8.0f); - moving_toward_child_menu = ImTriangleContainsPoint(ta, tb, tc, g.IO.MousePos); - //GetForegroundDrawList()->AddTriangleFilled(ta, tb, tc, moving_toward_child_menu ? IM_COL32(0,128,0,128) : IM_COL32(128,0,0,128)); // [DEBUG] + ImGuiStorage* aim = &window->StateStorage; + const ImGuiID aim_x = ImHashStr("##menu_aim_x", 0, child_popup->PopupId), aim_y = ImHashStr("##menu_aim_y", 0, child_popup->PopupId); + const ImGuiID aim_t = ImHashStr("##menu_aim_t", 0, child_popup->PopupId); + if (child_popup->PopupId == id && hovered) { aim->SetFloat(aim_x, g.IO.MousePos.x); aim->SetFloat(aim_y, g.IO.MousePos.y); } + if (g.IO.MouseDelta.x != 0.0f || g.IO.MouseDelta.y != 0.0f) aim->SetFloat(aim_t, (float)g.Time); + if (g.HoveredWindow == window && aim->GetFloat(aim_x, -FLT_MAX) != -FLT_MAX) + { + const float ref_unit = g.FontSize; // FIXME-DPI + const float child_dir = (window->Pos.x < child_menu_window->Pos.x) ? 1.0f : -1.0f; + const ImRect next_window_rect = child_menu_window->Rect(); + ImVec2 ta(aim->GetFloat(aim_x), aim->GetFloat(aim_y)); + ImVec2 tb = (child_dir > 0.0f) ? next_window_rect.GetTL() : next_window_rect.GetTR(); + ImVec2 tc = (child_dir > 0.0f) ? next_window_rect.GetBL() : next_window_rect.GetBR(); + ta.x += child_dir * -0.5f; + tb.x += child_dir * ref_unit; + tc.x += child_dir * ref_unit; + tb.y -= ref_unit * 0.5f; + tc.y += ref_unit * 0.5f; + const bool resting = (float)g.Time - aim->GetFloat(aim_t, (float)g.Time) > 0.30f; + moving_toward_child_menu = !resting && ImTriangleContainsPoint(ta, tb, tc, g.IO.MousePos); + } } // The 'HovereWindow == window' check creates an inconsistency (e.g. moving away from menu slowly tends to hit same window, whereas moving away fast does not) diff --git a/third_party/qhe/loaders/scene_loader.h b/third_party/qhe/loaders/scene_loader.h index 2e0a0c3..6cf6f60 100644 --- a/third_party/qhe/loaders/scene_loader.h +++ b/third_party/qhe/loaders/scene_loader.h @@ -225,6 +225,32 @@ public: struct SpawnPt { std::string name, tag; float pos[3]={0,0,0}, euler[3]={0,0,0}; int meshIdx=-1; }; std::vector spawnPts; HomeWorld world; // spawns, seats, hotspots, colliders, wall spots (world space), from every HSTF + + // Whether an HSTF entity draws anything (a MeshPlatformComponent): its colliders are then that thing's. + static bool entityDrawn(const tinyjson::Value& ent) { + for (const auto& c : ent["components"].arrVal) + if (c["data"]["class"].asString() == "horizon::platform_api::MeshPlatformComponent") return true; + return false; + } + // How tall a collider stands in the world (world Y extent), metres. + static float verticalExtent(const WorldCollider& c) { + const float* m = c.world; + auto axisY = [&](int k) { return std::fabs(m[k * 4 + 1]); }; // a local axis' world Y + auto axisLen = [&](int k) { return std::sqrt(m[k * 4] * m[k * 4] + m[k * 4 + 1] * m[k * 4 + 1] + m[k * 4 + 2] * m[k * 4 + 2]); }; + switch (c.kind) { + case WorldCollider::Box: return 2 * (axisY(0) * c.half[0] + axisY(1) * c.half[1] + axisY(2) * c.half[2]); + case WorldCollider::Sphere: return 2 * c.radius * std::max({axisLen(0), axisLen(1), axisLen(2)}); + case WorldCollider::Capsule: return axisY(1) * c.length + 2 * c.radius * std::max({axisLen(0), axisLen(1), axisLen(2)}); + default: { + float lo = 1e30f, hi = -1e30f; + for (size_t v = 0; v + 2 < c.positions.size(); v += 3) { + const float y = m[1] * c.positions[v] + m[5] * c.positions[v + 1] + m[9] * c.positions[v + 2] + m[13]; + lo = std::min(lo, y); hi = std::max(hi, y); + } + return hi > lo ? hi - lo : 0.0f; + } + } + } bool hasAnimation() const { for (auto& g : animGroups) if (g.skel.ok() && g.clip.ok() && !g.skinRecs.empty()) return true; return false; @@ -650,6 +676,11 @@ public: if (hi[0] - lo[0] > 200 || hi[1] - lo[1] > 200 || hi[2] - lo[2] > 200) continue; col.indices = std::move(idx); } + // Meta's invisible walls: a solid collider with nothing drawn for it, standing 2 m or more + // (the bounds round a home's play area: Cascadia's, Space Station's, Desert Terrace's, Haven's + // moats). They become invisible walls (Object): they stop you, and their tops, metres up, are + // never floor. Flatter ones stay surfaces (floors, ceilings, a desk's top). + if (col.role == WorldCollider::Solid && !entityDrawn(ent) && verticalExtent(col) >= 2.0f) col.role = WorldCollider::Object; world.colliders.push_back(std::move(col)); } } diff --git a/tools/walktest/CMakeLists.txt b/tools/walktest/CMakeLists.txt index cc10969..b25313f 100644 --- a/tools/walktest/CMakeLists.txt +++ b/tools/walktest/CMakeLists.txt @@ -8,6 +8,7 @@ # listcolliders the world's box / sphere / capsule colliders # animcheck [sweep s] a Clay / glTF home's animation from the APK against the baked .fenv (bit for bit) # animrender the home drawn at those animation times (APK against .fenv) +# wallcheck [drawn] invisible walls at every angle: no walkable area inside them (exit 2 if there is) # bakecmp both baked as they load, byte for byte (a .fenv's scene re-baked against its APK's bake) # fenv-tool report the home report (home_report.h), as Frame Studio and Frame Home make it # worldrender [seats|spots|cams x,y,z,yaw,pitch ...] [view=N hide=bits world]: the home rendered with the area and @@ -95,6 +96,9 @@ add_executable(animrender animrender.cpp ${R}/src/hsl_renderer.cpp ${TP}/volk/vo target_compile_definitions(animrender PRIVATE FH_USE_VOLK) target_include_directories(animrender PRIVATE ${TP}/volk ${TP}/vulkan-headers/include) target_link_libraries(animrender frame-scene) +# wallcheck [drawn]: invisible walls at every angle never have walkable area inside them +add_executable(wallcheck wallcheck.cpp) +target_link_libraries(wallcheck frame-scene) # bakecmp : both baked as loaded, byte for byte (a .fenv re-baked against its APK's bake) add_executable(bakecmp bakecmp.cpp) target_link_libraries(bakecmp frame-scene) diff --git a/tools/walktest/listcolliders.cpp b/tools/walktest/listcolliders.cpp index 28df8d3..21eeca1 100644 --- a/tools/walktest/listcolliders.cpp +++ b/tools/walktest/listcolliders.cpp @@ -1,4 +1,4 @@ -// The world's non-mesh colliders (boxes, spheres, capsules): name, role, size and height. +// The world's non-mesh colliders (boxes, spheres, capsules): name, role, size and centre. #include "hsl_scene.h" #include #include @@ -8,7 +8,7 @@ int main(int argc, char** argv) { for (const WorldCollider& c : s.world.colliders) { if (c.kind == WorldCollider::Mesh) continue; auto len = [&](int a) { return std::sqrt(c.world[a*4]*c.world[a*4] + c.world[a*4+1]*c.world[a*4+1] + c.world[a*4+2]*c.world[a*4+2]); }; - std::printf(" %-36s kind %d role %d size %.2f x %.2f x %.2f at y %.2f\n", c.name.c_str(), c.kind, c.role, - 2 * c.half[0] * len(0), 2 * c.half[1] * len(1), 2 * c.half[2] * len(2), c.world[13]); + std::printf(" %-36s kind %d role %d size %.2f x %.2f x %.2f at %.2f, %.2f, %.2f\n", c.name.c_str(), c.kind, c.role, + 2 * c.half[0] * len(0), 2 * c.half[1] * len(1), 2 * c.half[2] * len(2), c.world[12], c.world[13], c.world[14]); } } diff --git a/tools/walktest/wallcheck.cpp b/tools/walktest/wallcheck.cpp new file mode 100644 index 0000000..b48ed75 --- /dev/null +++ b/tools/walktest/wallcheck.cpp @@ -0,0 +1,44 @@ +// Invisible walls (2 x 2.5 x 0.1 m Object boxes) at the spawn at 0..165 degrees: the walkable area sampled every 2 cm +// round each, and the closest walkable sample to the wall's footprint (XZ). The area may run up to a wall's face, never +// into it: exit 2 when any sample is inside one. +// wallcheck [drawn] drawn: the wall stands on a drawn floor (a Walkable box) too +#include "hsl_scene.h" +#include "walkgrid.h" +#include +#include +int main(int argc, char** argv) { + HslScene s; std::string err; + if (!loadHslScene(std::string(argv[1]), s, err)) return 1; + const float* sp = s.world.spawns[0].pos; + const bool drawn = argc > 2; // on a drawn floor (Walkable box) too + float worst = 1e9f; + for (int deg = 0; deg < 180; deg += 15) { + const float a = deg * 3.14159265f / 180, c = std::cos(a), sn = std::sin(a); + HomeWorld w = s.world; + if (drawn) { + WorldCollider f; f.kind = WorldCollider::Box; f.role = WorldCollider::Walkable; f.half[0] = f.half[2] = 1.5f; f.half[1] = 0.02f; + f.world[12] = sp[0]; f.world[13] = sp[1] + 0.02f; f.world[14] = sp[2]; + w.colliders.push_back(f); + } + WorldCollider b; b.kind = WorldCollider::Box; b.role = WorldCollider::Object; b.half[0] = 1.0f; b.half[1] = 1.25f; b.half[2] = 0.05f; + const float m[16] = {c, 0, -sn, 0, 0, 1, 0, 0, sn, 0, c, 0, sp[0] + 0.013f, sp[1] + 1.25f, sp[2] + 0.027f, 1}; // off the cell grid + for (int k = 0; k < 16; ++k) b.world[k] = m[k]; + w.colliders.push_back(b); + auto g = bakeWalkGrid(s, w, WalkParams{}); + float closest = 1e9f; + for (float dz = -1.6f; dz <= 1.6f; dz += 0.02f) + for (float dx = -1.6f; dx <= 1.6f; dx += 0.02f) { + const float x = m[12] + dx, z = m[14] + dz; + if (!g->walkable(x, z, sp[1] + 0.1f)) continue; + // distance to the wall's footprint (a rectangle 2 x 0.1 in its own frame) + const float lx = dx * c - dz * sn, lz = dx * sn + dz * c; + const float ox = std::fabs(lx) - 1.0f, oz = std::fabs(lz) - 0.05f; + const float d = ox > 0 || oz > 0 ? std::hypot(std::max(ox, 0.0f), std::max(oz, 0.0f)) : std::max(ox, oz); + closest = std::min(closest, d); + } + std::printf("%3d deg: closest walkable %.3f m from the wall\n", deg, closest); + worst = std::min(worst, closest); + } + std::printf("worst %.3f m\n", worst); + return worst < 0 ? 2 : 0; +}