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 <noreply@anthropic.com>
This commit is contained in:
ToastconcernandClaude Opus 5.5 committed 2026-10-21 13:44:56 -05:00
1 parent 8b0c2aa5a3
commit e9d670b755
8 files changed
+271 -196

No files matched your search

+6 -2
View File
@@ -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:
+17 -1
View File
@@ -1265,11 +1265,27 @@ inline std::shared_ptr<WalkGrid> buildWalkGrid(const std::vector<WalkSource>& 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<int>(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<float>(dx * dx + dz * dz) * cs * cs > reach * reach) continue;
const size_t cell = static_cast<size_t>(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<uint8_t> 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.
+140 -175
View File
@@ -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<ObjectEdit> objects; std::string world, title, image; SoundState sound; WorldSettings settings; } carry;
std::vector<ObjectEdit> currentObjectEdits() const {
std::vector<ObjectEdit> 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<uint32_t>(x) * 374761393u + static_cast<uint32_t>(y) * 668265263u + static_cast<uint32_t>(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<int>(std::floor(x)), yi = static_cast<int>(std::floor(y)), zi = static_cast<int>(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<u8> px(static_cast<size_t>(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<size_t>(y) * W + x) * 4];
for (int k = 0; k < 3; ++k) p[k] = static_cast<u8>(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_t>(size), static_cast<FILE*>(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<int>(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<int>(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<int>(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<uint32_t>(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<float>(j) / lon, 1.0f - static_cast<float>(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<std::string> 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<HomeWorld>(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<HomeWorld>(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<size_t>(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<u32> fl, wl;
a.classifyObject(o, fl, wl);
if (fl.size() < 4) continue;
best = std::fabs(d - 1.5f);
drawObj = static_cast<int>(i);
}
const size_t partsBefore = a.surfaceParts(a.objects[drawObj]).size();
a.fillDrawnFloor(static_cast<size_t>(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<size_t>(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<size_t>(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<size_t>(obj), wallAt, 0.2f, false, -1);
a.fillDrawnFloor(static_cast<size_t>(obj), false);
check(a.drawnFloorIndex(a.objects[obj]) < 0, "Clear takes it away");
a.paintFloor(static_cast<size_t>(drawObj), wallAt, 0.2f, false, -1);
a.fillDrawnFloor(static_cast<size_t>(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<int>(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<SavedFile> saved;
std::set<std::wstring> 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<int, 4> 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<fs::path> 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;
+26 -15
View File
@@ -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)
+31
View File
@@ -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<SpawnPt> 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));
}
}
+4
View File
@@ -8,6 +8,7 @@
# listcolliders <apk> the world's box / sphere / capsule colliders
# animcheck <apk> <fenv> [sweep s] a Clay / glTF home's animation from the APK against the baked .fenv (bit for bit)
# animrender <home> <prefix> <size> <t,t,...> the home drawn at those animation times (APK against .fenv)
# wallcheck <home> [drawn] invisible walls at every angle: no walkable area inside them (exit 2 if there is)
# bakecmp <home> <home> both baked as they load, byte for byte (a .fenv's scene re-baked against its APK's bake)
# fenv-tool report <apk[+vista]|fenv> the home report (home_report.h), as Frame Studio and Frame Home make it
# worldrender <apk> <prefix> <size> [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 <home> [drawn]: invisible walls at every angle never have walkable area inside them
add_executable(wallcheck wallcheck.cpp)
target_link_libraries(wallcheck frame-scene)
# bakecmp <home> <home>: 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)
+3 -3
View File
@@ -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 <cmath>
#include <cstdio>
@@ -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]);
}
}
+44
View File
@@ -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 <home> [drawn] drawn: the wall stands on a drawn floor (a Walkable box) too
#include "hsl_scene.h"
#include "walkgrid.h"
#include <cmath>
#include <cstdio>
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;
}