mirror of
https://github.com/Toastconcern/FrameStudio.git
synced 2026-10-28 19:04:39 +01:00
- the window is laid out as an editor dock: a tool strip (drawn icons) on the left edge, the Explorer, the 3D view, and a tabbed Inspector on the right (Selected, World, Environment, Sound, Headset); the timeline and the status bar run along the bottom - the old one-piece properties panel is split into one function per tab; selecting something shows the Selected tab - the Frame menu is now Headset (and opens the Headset panel); the tuning tooltip describes the paced walk Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
8449 lines
496 KiB
C++
8449 lines
496 KiB
C++
// Frame Studio — the desktop side of Frame Home (Windows, Linux, macOS).
|
||
// Opens a Meta home, renders it with the home's own shaders (the same renderer the Frame runs),
|
||
// lets you move / rotate / scale / hide / delete its objects, and sends it to the Frame over SSH.
|
||
//
|
||
// Viewport MMB orbit · Shift+MMB pan · Ctrl+MMB or wheel zoom · Alt+LMB orbit
|
||
// RMB + WASD/QE fly (wheel = speed) · LMB select
|
||
// Keys G/R/S move/rotate/scale tool · X/Del delete · H hide · Alt+H unhide all
|
||
// F frame selected · Home frame all · Numpad 0 / P player view
|
||
// Ctrl+Z undo · Ctrl+Shift+Z / Ctrl+Y redo · Ctrl+O open · Ctrl+S save edits
|
||
#include "home_edits.h"
|
||
#include "perf_settings.h"
|
||
#include "hsl_renderer.h"
|
||
#include "home_report.h"
|
||
#include "core/audio.h"
|
||
#include "core/fsb5_vorbis.h"
|
||
#include "loaders/rendtxtr_parser.h"
|
||
#include "loaders/rendmesh_parser.h"
|
||
#include "hsl_scene.h"
|
||
#include "world_gen.h"
|
||
#include "surface_mesh.h"
|
||
#include "fenv.h"
|
||
#include "scene_bake.h"
|
||
#include "anim_bake.h"
|
||
#include "visibility_bake.h"
|
||
#include "model_import.h"
|
||
#include "world_runtime.h"
|
||
#include "frame_pacer.h"
|
||
#include "platform.h"
|
||
#define STB_IMAGE_WRITE_IMPLEMENTATION
|
||
#define STBI_WRITE_NO_STDIO
|
||
#include "stb_image_write.h"
|
||
extern "C" unsigned char* stbi_load_from_memory(const unsigned char* buffer, int len, int* x, int* y, int* comp, int req_comp);
|
||
extern "C" void stbi_image_free(void* retval_from_stbi_load);
|
||
|
||
#include <imgui.h>
|
||
#include <imgui_internal.h> // the self-test reads ImGui's ID-conflict detector
|
||
#include <ImGuizmo.h> // needs imgui.h first
|
||
#include <backends/imgui_impl_vulkan.h>
|
||
#ifdef _WIN32
|
||
#include <backends/imgui_impl_win32.h>
|
||
#include <windows.h> // the window itself (the rest of the system is behind platform.h)
|
||
#include <dwmapi.h>
|
||
#include <shellapi.h>
|
||
#else
|
||
#define GLFW_INCLUDE_NONE
|
||
#include <GLFW/glfw3.h>
|
||
#include <dlfcn.h>
|
||
#include <backends/imgui_impl_glfw.h>
|
||
#endif
|
||
|
||
#include <algorithm>
|
||
#include <cstdarg>
|
||
#include <deque>
|
||
#include <functional>
|
||
#include <array>
|
||
#include <atomic>
|
||
#include <cctype>
|
||
#include <chrono>
|
||
#include <map>
|
||
#include <cmath>
|
||
#include <cstdio>
|
||
#include <cstring>
|
||
#include <ctime>
|
||
#include <filesystem>
|
||
#include <fstream>
|
||
#include <memory>
|
||
#include <mutex>
|
||
#include <set>
|
||
#include <string>
|
||
#include <thread>
|
||
#include <vector>
|
||
|
||
#ifdef _WIN32
|
||
extern IMGUI_IMPL_API LRESULT ImGui_ImplWin32_WndProcHandler(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam);
|
||
#endif
|
||
|
||
namespace fs = std::filesystem;
|
||
|
||
std::atomic<bool> g_audioMuted{false}; // core/audio.h: the timeline's mute
|
||
|
||
namespace {
|
||
|
||
constexpr float kPi = 3.14159265f;
|
||
constexpr float kNear = 0.05f;
|
||
constexpr float kFovY = 60.0f * kPi / 180.0f;
|
||
constexpr const char* kFrameHost = "frame"; // default headset: ~/.ssh/config alias (Install window edits it)
|
||
constexpr const char* kFrameEnvDir = "frame-home/envs"; // relative to the Frame user's home
|
||
|
||
// ── Strings / files ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
using platform::widen;
|
||
using platform::narrow;
|
||
|
||
// Name a home gets on the Frame: lowercase, spaces -> '-', only [a-z0-9._-].
|
||
std::string frameName(const std::string& path) {
|
||
std::string base = narrow(fs::path(widen(path)).filename().wstring()), out;
|
||
bool dropped = false;
|
||
for (char c : base) {
|
||
if (static_cast<unsigned char>(c) & 0x80) { dropped = true; continue; } // non-ASCII (UTF-8 bytes)
|
||
c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
|
||
if (std::isalnum(static_cast<unsigned char>(c)) || c == '.' || c == '_' || c == '-') out += c;
|
||
else if (c == ' ' && !out.empty() && out.back() != '-') out += '-';
|
||
}
|
||
if (dropped) { // "日本.fenv" and "中国.fenv" must not both become ".fenv": a short hash of the real name keeps them apart
|
||
uint32_t h = 2166136261u;
|
||
for (char c : base) h = (h ^ static_cast<unsigned char>(c)) * 16777619u;
|
||
const size_t dot = out.rfind('.');
|
||
std::string stem = dot == std::string::npos ? out : out.substr(0, dot), ext = dot == std::string::npos ? "" : out.substr(dot);
|
||
while (!stem.empty() && (stem.back() == '-' || stem.back() == '.')) stem.pop_back();
|
||
char tag[16];
|
||
std::snprintf(tag, sizeof tag, "%06x", h & 0xffffff);
|
||
out = (stem.empty() ? std::string("home") : stem) + "-" + tag + ext;
|
||
}
|
||
return out.empty() ? "home.apk" : out;
|
||
}
|
||
|
||
// A home is a footprint (the room) plus a vista (the world outside: its lighting, sky and view); the footprint loads
|
||
// first. Meta names them "Shell Env Footprint …" and "Shell Env Vista …".
|
||
bool nameHas(const std::string& p, const char* word) {
|
||
std::string n = narrow(fs::path(widen(p)).filename().wstring());
|
||
for (char& c : n) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
|
||
return n.find(word) != std::string::npos;
|
||
}
|
||
bool isFootprintApk(const std::string& p) { return nameHas(p, "footprint"); }
|
||
bool isVistaApk(const std::string& p) { return nameHas(p, "vista") && !nameHas(p, "footprint"); }
|
||
std::vector<std::string> footprintFirst(std::vector<std::string> apks) {
|
||
std::stable_partition(apks.begin(), apks.end(), isFootprintApk);
|
||
return apks;
|
||
}
|
||
// The vistas beside a footprint (its folder), to pair it with.
|
||
std::vector<std::string> vistasBeside(const std::string& footprint) {
|
||
std::vector<std::string> out;
|
||
std::error_code ec;
|
||
for (const auto& e : fs::directory_iterator(fs::path(widen(footprint)).parent_path(), ec)) {
|
||
const std::string f = narrow(e.path().wstring());
|
||
if (e.is_regular_file(ec) && e.path().extension() == L".apk" && isVistaApk(f)) out.push_back(f);
|
||
}
|
||
std::sort(out.begin(), out.end());
|
||
return out;
|
||
}
|
||
|
||
// "Shell Env Footprint Haven2025 Quest3s-buck_206.0.0.0.70_974700778.apk" -> "Haven2025"
|
||
std::string prettyHome(const std::string& apk) {
|
||
std::string n = narrow(fs::path(widen(apk)).stem().wstring());
|
||
const size_t us = n.find('_');
|
||
if (us != std::string::npos) n = n.substr(0, us);
|
||
for (const char* pre : {"Shell Env Footprint ", "Shell Env Vista ", "Shell Env "})
|
||
if (n.rfind(pre, 0) == 0) n = n.substr(std::strlen(pre));
|
||
for (const char* suf : {" Quest3s-buck", " Quest3s", " Quest3", "-buck"}) {
|
||
const size_t p = n.find(suf);
|
||
if (p != std::string::npos) n = n.substr(0, p);
|
||
}
|
||
return n.empty() ? narrow(fs::path(widen(apk)).stem().wstring()) : n;
|
||
}
|
||
|
||
std::string displayName(const std::vector<std::string>& apks) {
|
||
std::string n;
|
||
for (const auto& p : apks) n += (n.empty() ? "" : " + ") + prettyHome(p);
|
||
return n;
|
||
}
|
||
|
||
// The folder Frame Studio runs from (the Frame payload ships beside the exe, in frame/).
|
||
fs::path exeDir() { return platform::exeDir(); }
|
||
|
||
// Studio's own files (settings, edits, recent homes, auto-saves): %APPDATA%\FrameStudio, ~/.local/share/FrameStudio,
|
||
// ~/Library/Application Support/FrameStudio.
|
||
fs::path dataDir() {
|
||
fs::path p = platform::userDataDir() / L"FrameStudio";
|
||
std::error_code ec;
|
||
fs::create_directories(p / L"edits", ec);
|
||
return p;
|
||
}
|
||
|
||
bool g_selfTestRun = false; // --selftest: saves go to a scratch library, never into My Environments
|
||
|
||
// My Environments: Documents/Frame Studio/Environments, where what you make is saved (File > Save). A self-test
|
||
// saves into <dataDir>/selftest/library instead (it saves its test home several times a run).
|
||
fs::path libraryDir() {
|
||
const fs::path p = g_selfTestRun ? dataDir() / L"selftest" / L"library" : platform::documentsDir() / L"Frame Studio" / L"Environments";
|
||
std::error_code ec;
|
||
fs::create_directories(p, ec);
|
||
return p;
|
||
}
|
||
|
||
// Runs a command line with no console window; stdout + stderr land in `out`. Returns the exit code.
|
||
// (Studio builds its command lines as wide strings: these pass them on as UTF-8.)
|
||
int run(const std::wstring& cmdline, const std::wstring& cwd, std::string& out) { return platform::run(narrow(cmdline), fs::path(cwd), out); }
|
||
|
||
// ssh / scp, quoted, with a trailing space, ready to prefix options.
|
||
std::wstring sshTool(const wchar_t* name) { return widen(platform::sshTool(narrow(name).c_str())); }
|
||
|
||
// For a command line: nothing in it a shell could read into.
|
||
std::string base64(const std::string& in) {
|
||
static const char* b64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||
std::string enc;
|
||
for (size_t i = 0; i < in.size(); i += 3) {
|
||
const uint32_t n = (static_cast<uint8_t>(in[i]) << 16) | (i + 1 < in.size() ? static_cast<uint8_t>(in[i + 1]) << 8 : 0) |
|
||
(i + 2 < in.size() ? static_cast<uint8_t>(in[i + 2]) : 0);
|
||
enc += b64[(n >> 18) & 63]; enc += b64[(n >> 12) & 63];
|
||
enc += i + 1 < in.size() ? b64[(n >> 6) & 63] : '='; enc += i + 2 < in.size() ? b64[n & 63] : '=';
|
||
}
|
||
return enc;
|
||
}
|
||
|
||
std::string lastLine(const std::string& s) {
|
||
size_t e = s.find_last_not_of("\r\n ");
|
||
if (e == std::string::npos) return {};
|
||
size_t b = s.find_last_of('\n', e);
|
||
return s.substr(b == std::string::npos ? 0 : b + 1, e + 1 - (b == std::string::npos ? 0 : b + 1));
|
||
}
|
||
|
||
// ── Column-major 4x4 math ───────────────────────────────────────────────────────────────────────
|
||
|
||
void identity(float* m) { std::memset(m, 0, 64); m[0] = m[5] = m[10] = m[15] = 1; }
|
||
|
||
void mul(const float* a, const float* b, float* o) {
|
||
float r[16];
|
||
for (int c = 0; c < 4; ++c)
|
||
for (int row = 0; row < 4; ++row)
|
||
r[c * 4 + row] = a[row] * b[c * 4] + a[4 + row] * b[c * 4 + 1] + a[8 + row] * b[c * 4 + 2] + a[12 + row] * b[c * 4 + 3];
|
||
std::memcpy(o, r, 64);
|
||
}
|
||
|
||
void translation(float* m, float x, float y, float z) { identity(m); m[12] = x; m[13] = y; m[14] = z; }
|
||
|
||
bool invert(const float* m, float* out) {
|
||
float inv[16];
|
||
inv[0] = m[5] * m[10] * m[15] - m[5] * m[11] * m[14] - m[9] * m[6] * m[15] + m[9] * m[7] * m[14] + m[13] * m[6] * m[11] - m[13] * m[7] * m[10];
|
||
inv[4] = -m[4] * m[10] * m[15] + m[4] * m[11] * m[14] + m[8] * m[6] * m[15] - m[8] * m[7] * m[14] - m[12] * m[6] * m[11] + m[12] * m[7] * m[10];
|
||
inv[8] = m[4] * m[9] * m[15] - m[4] * m[11] * m[13] - m[8] * m[5] * m[15] + m[8] * m[7] * m[13] + m[12] * m[5] * m[11] - m[12] * m[7] * m[9];
|
||
inv[12] = -m[4] * m[9] * m[14] + m[4] * m[10] * m[13] + m[8] * m[5] * m[14] - m[8] * m[6] * m[13] - m[12] * m[5] * m[10] + m[12] * m[6] * m[9];
|
||
inv[1] = -m[1] * m[10] * m[15] + m[1] * m[11] * m[14] + m[9] * m[2] * m[15] - m[9] * m[3] * m[14] - m[13] * m[2] * m[11] + m[13] * m[3] * m[10];
|
||
inv[5] = m[0] * m[10] * m[15] - m[0] * m[11] * m[14] - m[8] * m[2] * m[15] + m[8] * m[3] * m[14] + m[12] * m[2] * m[11] - m[12] * m[3] * m[10];
|
||
inv[9] = -m[0] * m[9] * m[15] + m[0] * m[11] * m[13] + m[8] * m[1] * m[15] - m[8] * m[3] * m[13] - m[12] * m[1] * m[11] + m[12] * m[3] * m[9];
|
||
inv[13] = m[0] * m[9] * m[14] - m[0] * m[10] * m[13] - m[8] * m[1] * m[14] + m[8] * m[2] * m[13] + m[12] * m[1] * m[10] - m[12] * m[2] * m[9];
|
||
inv[2] = m[1] * m[6] * m[15] - m[1] * m[7] * m[14] - m[5] * m[2] * m[15] + m[5] * m[3] * m[14] + m[13] * m[2] * m[7] - m[13] * m[3] * m[6];
|
||
inv[6] = -m[0] * m[6] * m[15] + m[0] * m[7] * m[14] + m[4] * m[2] * m[15] - m[4] * m[3] * m[14] - m[12] * m[2] * m[7] + m[12] * m[3] * m[6];
|
||
inv[10] = m[0] * m[5] * m[15] - m[0] * m[7] * m[13] - m[4] * m[1] * m[15] + m[4] * m[3] * m[13] + m[12] * m[1] * m[7] - m[12] * m[3] * m[5];
|
||
inv[14] = -m[0] * m[5] * m[14] + m[0] * m[6] * m[13] + m[4] * m[1] * m[14] - m[4] * m[2] * m[13] - m[12] * m[1] * m[6] + m[12] * m[2] * m[5];
|
||
inv[3] = -m[1] * m[6] * m[11] + m[1] * m[7] * m[10] + m[5] * m[2] * m[11] - m[5] * m[3] * m[10] - m[9] * m[2] * m[7] + m[9] * m[3] * m[6];
|
||
inv[7] = m[0] * m[6] * m[11] - m[0] * m[7] * m[10] - m[4] * m[2] * m[11] + m[4] * m[3] * m[10] + m[8] * m[2] * m[7] - m[8] * m[3] * m[6];
|
||
inv[11] = -m[0] * m[5] * m[11] + m[0] * m[7] * m[9] + m[4] * m[1] * m[11] - m[4] * m[3] * m[9] - m[8] * m[1] * m[7] + m[8] * m[3] * m[5];
|
||
inv[15] = m[0] * m[5] * m[10] - m[0] * m[6] * m[9] - m[4] * m[1] * m[10] + m[4] * m[2] * m[9] + m[8] * m[1] * m[6] - m[8] * m[2] * m[5];
|
||
float det = m[0] * inv[0] + m[1] * inv[4] + m[2] * inv[8] + m[3] * inv[12];
|
||
if (std::fabs(det) < 1e-20f) return false;
|
||
for (int i = 0; i < 16; ++i) out[i] = inv[i] / det;
|
||
return true;
|
||
}
|
||
|
||
void point(const float* m, const float* p, float* o) {
|
||
float r[3];
|
||
for (int k = 0; k < 3; ++k) r[k] = m[k] * p[0] + m[4 + k] * p[1] + m[8 + k] * p[2] + m[12 + k];
|
||
std::memcpy(o, r, 12);
|
||
}
|
||
|
||
void direction(const float* m, const float* d, float* o) {
|
||
float r[3];
|
||
for (int k = 0; k < 3; ++k) r[k] = m[k] * d[0] + m[4 + k] * d[1] + m[8 + k] * d[2];
|
||
std::memcpy(o, r, 12);
|
||
}
|
||
|
||
// Reversed-Z, infinite-far Vulkan projection (y-down) from view-space tangents — what the renderer expects.
|
||
Mat4 rendererProjection(float tx, float ty) {
|
||
Mat4 m = {};
|
||
m.m[0] = 1.0f / tx;
|
||
m.m[5] = -1.0f / ty;
|
||
m.m[14] = kNear;
|
||
m.m[11] = -1.0f;
|
||
return m;
|
||
}
|
||
|
||
// OpenGL-style projection for ImGuizmo and screen-space overlays.
|
||
void glProjection(float tx, float ty, float* m) {
|
||
const float f = 2000.0f;
|
||
std::memset(m, 0, 64);
|
||
m[0] = 1.0f / tx;
|
||
m[5] = 1.0f / ty;
|
||
m[10] = -(f + kNear) / (f - kNear);
|
||
m[11] = -1.0f;
|
||
m[14] = -2.0f * f * kNear / (f - kNear);
|
||
}
|
||
|
||
// ── Camera ──────────────────────────────────────────────────────────────────────────────────────
|
||
|
||
struct Camera {
|
||
float pivot[3] = {0, 1.6f, -2.0f};
|
||
float yaw = 0, pitch = 0, dist = 2.0f;
|
||
|
||
void forward(float* f) const {
|
||
f[0] = -std::sin(yaw) * std::cos(pitch);
|
||
f[1] = std::sin(pitch);
|
||
f[2] = -std::cos(yaw) * std::cos(pitch);
|
||
}
|
||
void eye(float* e) const {
|
||
float f[3];
|
||
forward(f);
|
||
for (int k = 0; k < 3; ++k) e[k] = pivot[k] - f[k] * dist;
|
||
}
|
||
// camera-to-world (looks down -Z at yaw = pitch = 0)
|
||
void pose(float* m) const {
|
||
const float cy = std::cos(yaw), sy = std::sin(yaw), cp = std::cos(pitch), sp = std::sin(pitch);
|
||
m[0] = cy; m[1] = 0; m[2] = -sy; m[3] = 0;
|
||
m[4] = sy * sp; m[5] = cp; m[6] = cy * sp; m[7] = 0;
|
||
m[8] = sy * cp; m[9] = -sp; m[10] = cy * cp; m[11] = 0;
|
||
eye(&m[12]);
|
||
m[15] = 1;
|
||
}
|
||
void playerView() {
|
||
yaw = pitch = 0;
|
||
dist = 2.0f;
|
||
pivot[0] = 0; pivot[1] = 1.6f; pivot[2] = -dist;
|
||
}
|
||
};
|
||
|
||
// ── Objects (one per uploaded mesh) ─────────────────────────────────────────────────────────────
|
||
|
||
bool byFileLayout = true; // the Explorer's folders: the file's own layout (else Studio's groups by kind)
|
||
|
||
struct Object {
|
||
size_t mesh = 0; // renderer mesh index
|
||
int scene = -1; // HslScene::meshes index
|
||
std::string name, surface;
|
||
bool hidden = false, deleted = false, skybox = false;
|
||
float pivot[16], pivot0[16]; // gizmo frame (now / as loaded); world = pivot * offset
|
||
float offset[16];
|
||
float lmin[3] = {0, 0, 0}, lmax[3] = {0, 0, 0};
|
||
size_t triangles = 0;
|
||
int source = 0; // HslScene::apkPaths index: 0 footprint, 1 vista
|
||
int category = 0; // Category (outliner group)
|
||
std::string label; // display name (name minus the cook's Root_Xform_/SM_/LBG_ prefixes)
|
||
std::vector<std::string> folders; // where it sits in its file (MeshData::path), outermost first
|
||
};
|
||
|
||
// Outliner groups, in display order.
|
||
enum Category { kArchitecture, kProps, kPlants, kLights, kTerrain, kSky, kCategoryCount };
|
||
const char* kCategoryNames[kCategoryCount] = {"Architecture", "Furniture & Props", "Plants", "Lights & Glow", "Terrain & Water", "Sky"};
|
||
|
||
bool anyOf(const std::string& s, std::initializer_list<const char*> words) {
|
||
for (const char* w : words) if (s.find(w) != std::string::npos) return true;
|
||
return false;
|
||
}
|
||
|
||
int categorize(const std::string& name, const std::string& surface, bool skybox) {
|
||
if (skybox) return kSky;
|
||
std::string n = name + " " + surface;
|
||
for (char& c : n) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
|
||
if (anyOf(n, {"emiss", "light", "lamp", "glow", "candle", "sconce", "globe", "lantern", "neon"})) return kLights;
|
||
if (anyOf(n, {"plant", "leaves", "leaf", "tree", "bush", "grass", "flower", "branch", "vege", "fern", "vine", "moss"})) return kPlants;
|
||
if (anyOf(n, {"terrain", "ground", "rock", "boulder", "cliff", "mountain", "water", "lake", "river", "waterfall", "sand",
|
||
"island", "ocean", "sea", "fog", "cloud", "hill", "dirt", "gravel", "shore"})) return kTerrain;
|
||
if (anyOf(n, {"wall", "floor", "ceiling", "cieling", "beam", "trim", "window", "door", "platform", "moat", "blocker", "blkt",
|
||
"stair", "column", "pillar", "roof", "slat", "panel", "arch", "frame_", "baseboard", "tray", "portal"})) return kArchitecture;
|
||
return kProps;
|
||
}
|
||
|
||
std::string cleanName(std::string n) {
|
||
// Clay (.opa) meshes are named "<export>.fbx.<material>[_pbr...]": keep the material part.
|
||
for (const char* ext : {".fbx.", ".FBX.", ".gltf.", ".glb."}) {
|
||
const size_t f = n.rfind(ext);
|
||
if (f != std::string::npos && f + std::strlen(ext) < n.size()) { n = n.substr(f + std::strlen(ext)); break; }
|
||
}
|
||
for (const char* pre : {"M_", "m_", "MI_", "mi_"})
|
||
if (n.rfind(pre, 0) == 0 && n.size() > std::strlen(pre)) { n = n.substr(std::strlen(pre)); break; }
|
||
for (const char* suf : {".mat.asset", ".mat", "_pbr"})
|
||
if (n.size() > std::strlen(suf) && n.compare(n.size() - std::strlen(suf), std::strlen(suf), suf) == 0)
|
||
n.erase(n.size() - std::strlen(suf));
|
||
for (bool again = true; again;) {
|
||
again = false;
|
||
for (const char* pre : {"Root_Xform_", "root_xform_", "SM_", "sm_", "LBG_", "lbg_", "Geo_", "geo_"})
|
||
if (n.rfind(pre, 0) == 0 && n.size() > std::strlen(pre)) { n = n.substr(std::strlen(pre)); again = true; }
|
||
}
|
||
return n;
|
||
}
|
||
|
||
// The environment's background sound in Studio: its settings (HomeSound: also how loud the home's own ambience
|
||
// plays) and the chosen file's bytes, shared so undo steps hold them without copies.
|
||
struct SoundState {
|
||
HomeSound s;
|
||
std::shared_ptr<const std::vector<uint8_t>> bytes;
|
||
std::string name; // the file it came from, as shown
|
||
};
|
||
|
||
struct UndoStep {
|
||
size_t object;
|
||
float pivot[16];
|
||
bool hidden, deleted;
|
||
int group = 0; // steps sharing a non-zero group undo / redo together (one click, one Ctrl+Z)
|
||
std::shared_ptr<HomeWorld> world; // a world-layer edit: the whole layer as it was (object fields unused)
|
||
std::shared_ptr<SoundState> sound; // a sound edit: the sound as it was (object fields unused)
|
||
};
|
||
|
||
// The world layer's kinds of things (Studio's world editor).
|
||
enum WorldKind { kSpawn, kSeat, kHotspot, kCollider, kWallSpot, kGravity, kWorldKinds };
|
||
const char* kWorldNames[kWorldKinds] = {"Spawns", "Seats", "Teleport Hotspots", "Walls & Surfaces", "Wall Spots", "Gravity Zones"};
|
||
|
||
// What the viewport can show, each with its own switch (View > Show, the explorer's World headers).
|
||
enum ShowCat { kShowSpawns, kShowSeats, kShowHotspots, kShowFloors, kShowWalls, kShowSolidMeshes, kShowTriggers, kShowWallSpots, kShowGravity, kShowWalkArea, kShowUnreached, kShowCats };
|
||
const char* kShowNames[kShowCats] = {"Spawn points", "Seats", "Teleport hotspots", "Drawn floors", "Surface shapes", "Surface & collider meshes", "Triggers", "Wall spots", "Gravity zones", "Walkable area", "Floors out of reach"};
|
||
const uint32_t kShowColors[kShowCats] = {0x3fd2ff, 0x5fe07a, 0xffd84a, 0x4cd964, 0xff7a2e, 0xffa033, 0x9a9a9a, 0x5b8def, 0xc07aff, 0x2ec756, 0x808080};
|
||
|
||
// A gravity zone's kind (WorldGravity::Kind), as Studio names and explains it.
|
||
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).",
|
||
"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
|
||
// enough, and where it's too steep to walk up it's a wall (WorldCollider::Solid). A thing that isn't a floor (a vase,
|
||
// a sofa, a lamp) gets a Collider: it stops you, you never stand on it (Object). Or you walk through it (Trigger).
|
||
// (Older files' Floor and Wall roles are read as Solid: core/world.h.)
|
||
const char* kRoleNames[6] = {"Surface", "Surface", "Surface", "Walk through", "Collider", "Drawn floor"};
|
||
const char* kRoleHelp[6] = {
|
||
"Walk on it where it's flat enough; where it's too steep to walk up, it's a wall.",
|
||
"Walk on it where it's flat enough; where it's too steep to walk up, it's a wall.",
|
||
"Walk on it where it's flat enough; where it's too steep to walk up, it's a wall.",
|
||
"Doesn't block or carry you (Meta's trigger volumes).",
|
||
"Stops you: you don't walk through it, and never stand on it.",
|
||
"A floor drawn on a thing (Draw Floor): you can always stand on it, even on furniture."};
|
||
const uint32_t kRoleColors[6] = {0xffa033, 0x4cd964, 0xff7a2e, 0x9a9a9a, 0xb07aff, 0x4cd964};
|
||
|
||
// What an object is for the player: a Surface (one linked Solid collider of all its triangles: walkable where it's
|
||
// flat enough, a wall where it's too steep to walk up), walked through, or left to the walkable area (Auto).
|
||
enum SurfaceMode { kSurfNone, kSurfSolid, kSurfObject, kSurfTrigger, kSurfModes };
|
||
const char* kSurfNames[kSurfModes] = {"Auto", "Surface", "Collider", "Walk through"};
|
||
const char* kSurfHelp[kSurfModes] = {
|
||
"Automatic: the walkable area decides from its shape (floors you can reach and stand under headroom; anything in the way blocks).",
|
||
"Walk on it where it's flat enough; where it's too steep to walk up, it's a wall.",
|
||
"A thing, not a floor: it stops you (you don't walk through it) and you never stand on it.",
|
||
"Left out: you walk through it (leaves, curtains, light shafts). A trigger volume."};
|
||
constexpr float kFloorCos = 0.766f; // cos 40 deg: steeper than this is wall, never floor
|
||
constexpr uint32_t kFloorTint = 0x4cd964, kWallTint = 0xff7a2e;
|
||
const char* kAutoPrefix = "Auto "; // surfaces World > Auto-generate made (a re-run replaces them)
|
||
|
||
enum class Tool { Move, Rotate, Scale, DrawFloor };
|
||
|
||
// ── Theme ───────────────────────────────────────────────────────────────────────────────────────
|
||
|
||
ImVec4 hex(uint32_t rgb, float a = 1.0f) {
|
||
return ImVec4(((rgb >> 16) & 0xFF) / 255.0f, ((rgb >> 8) & 0xFF) / 255.0f, (rgb & 0xFF) / 255.0f, a);
|
||
}
|
||
|
||
// One calm, neutral dark palette: greys with a faint cool cast, and one blue accent kept for primary actions,
|
||
// selection and progress. Orange stays Frame Studio's own mark (the brand, the badge, the selected mesh).
|
||
constexpr uint32_t kPanel = 0x2b2d31, kList = 0x232528, kListAlt = 0x26282c, kBar = 0x1e1f22, kWidget = 0x3c3f45;
|
||
constexpr uint32_t kWidgetHover = 0x4a4e55, kWidgetActive = 0x55595f;
|
||
constexpr uint32_t kBlue = 0x3f7bd6, kBlueHover = 0x528ae0, kBlueActive = 0x346ac0, kSelect = 0x2e4a78;
|
||
constexpr uint32_t kOrange = 0xffa033, kText = 0xe3e5e8, kDim = 0x8b9097, kHeading = 0xb9bec6;
|
||
constexpr uint32_t kLine = 0x3a3d43, kSplit = 0x1a1b1e; // hairlines between things, and the panel splitters
|
||
constexpr uint32_t kToolBg = 0x24262a, kToolHover = 0x3d4047; // buttons floating over the 3D view
|
||
constexpr uint32_t kDanger = 0x7a3434, kDangerHover = 0x9a4040; // Delete, Remove, Cancel the load
|
||
constexpr uint32_t kGo = 0x2f8a4a, kGoHover = 0x3aa35a; // Draw Floor, the chosen walk preset
|
||
|
||
ImU32 col(uint32_t rgb, float a = 1.0f) { return ImGui::ColorConvertFloat4ToU32(hex(rgb, a)); }
|
||
|
||
// Fonts beside the body text (main loads them; null when the Windows font is missing, then the body font is used).
|
||
ImFont* g_fontBold = nullptr; // section headers, the brand
|
||
ImFont* g_fontTitle = nullptr; // the start screen's title
|
||
ImFont* fontOr(ImFont* f) { return f ? f : ImGui::GetFont(); }
|
||
|
||
// A primary (accent) button: the one thing a panel or screen is for. Pop with ImGui::PopStyleColor(3).
|
||
void pushPrimary() {
|
||
ImGui::PushStyleColor(ImGuiCol_Button, hex(kBlue));
|
||
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, hex(kBlueHover));
|
||
ImGui::PushStyleColor(ImGuiCol_ButtonActive, hex(kBlueActive));
|
||
}
|
||
// A button that throws something away. Pop with ImGui::PopStyleColor(3).
|
||
void pushDanger() {
|
||
ImGui::PushStyleColor(ImGuiCol_Button, hex(kDanger));
|
||
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, hex(kDangerHover));
|
||
ImGui::PushStyleColor(ImGuiCol_ButtonActive, hex(kDanger));
|
||
}
|
||
|
||
void applyTheme(float scale) {
|
||
ImGuiStyle& s = ImGui::GetStyle();
|
||
s.WindowRounding = 8; // floating windows; the full-window panels push 0
|
||
s.ChildRounding = 0;
|
||
s.FrameRounding = 5;
|
||
s.PopupRounding = 6;
|
||
s.GrabRounding = 4;
|
||
s.TabRounding = 5;
|
||
s.ScrollbarRounding = 8;
|
||
s.WindowBorderSize = 1;
|
||
s.ChildBorderSize = 0;
|
||
s.PopupBorderSize = 1;
|
||
s.FrameBorderSize = 0;
|
||
s.WindowTitleAlign = ImVec2(0.0f, 0.5f);
|
||
s.WindowMenuButtonPosition = ImGuiDir_None;
|
||
s.FramePadding = ImVec2(8, 4);
|
||
s.ItemSpacing = ImVec2(6, 5);
|
||
s.ItemInnerSpacing = ImVec2(6, 4);
|
||
s.WindowPadding = ImVec2(12, 10);
|
||
s.CellPadding = ImVec2(6, 3);
|
||
s.ScrollbarSize = 10;
|
||
s.GrabMinSize = 9;
|
||
s.IndentSpacing = 14;
|
||
s.SeparatorTextBorderSize = 1;
|
||
s.SeparatorTextPadding = ImVec2(0, 4);
|
||
s.SeparatorTextAlign = ImVec2(0.0f, 0.5f);
|
||
s.DisabledAlpha = 0.45f;
|
||
s.ScaleAllSizes(scale);
|
||
ImVec4* c = s.Colors;
|
||
c[ImGuiCol_Text] = hex(kText);
|
||
c[ImGuiCol_TextDisabled] = hex(kDim);
|
||
c[ImGuiCol_WindowBg] = hex(kPanel);
|
||
c[ImGuiCol_ChildBg] = hex(0, 0);
|
||
c[ImGuiCol_PopupBg] = hex(0x1f2023, 0.98f);
|
||
c[ImGuiCol_Border] = hex(kLine);
|
||
c[ImGuiCol_BorderShadow] = hex(0, 0);
|
||
c[ImGuiCol_FrameBg] = hex(kWidget);
|
||
c[ImGuiCol_FrameBgHovered] = hex(kWidgetHover);
|
||
c[ImGuiCol_FrameBgActive] = hex(0x1f2124);
|
||
c[ImGuiCol_TitleBg] = c[ImGuiCol_TitleBgActive] = c[ImGuiCol_TitleBgCollapsed] = hex(kBar);
|
||
c[ImGuiCol_MenuBarBg] = hex(kBar);
|
||
c[ImGuiCol_ScrollbarBg] = hex(0, 0);
|
||
c[ImGuiCol_ScrollbarGrab] = hex(0x4a4d53);
|
||
c[ImGuiCol_ScrollbarGrabHovered] = hex(0x5c6067);
|
||
c[ImGuiCol_ScrollbarGrabActive] = hex(0x6d7178);
|
||
c[ImGuiCol_CheckMark] = hex(0xffffff);
|
||
c[ImGuiCol_SliderGrab] = hex(kBlue);
|
||
c[ImGuiCol_SliderGrabActive] = hex(kBlueHover);
|
||
c[ImGuiCol_Button] = hex(kWidget);
|
||
c[ImGuiCol_ButtonHovered] = hex(kWidgetHover);
|
||
c[ImGuiCol_ButtonActive] = hex(kBlue);
|
||
c[ImGuiCol_Header] = hex(kSelect);
|
||
c[ImGuiCol_HeaderHovered] = hex(0x383b41);
|
||
c[ImGuiCol_HeaderActive] = hex(kSelect);
|
||
c[ImGuiCol_Separator] = c[ImGuiCol_SeparatorHovered] = hex(kLine);
|
||
c[ImGuiCol_SeparatorActive] = hex(kBlue);
|
||
c[ImGuiCol_ResizeGrip] = hex(0, 0);
|
||
c[ImGuiCol_ResizeGripHovered] = hex(kBlue, 0.6f);
|
||
c[ImGuiCol_ResizeGripActive] = hex(kBlue);
|
||
c[ImGuiCol_Tab] = hex(kWidget);
|
||
c[ImGuiCol_TabHovered] = hex(kWidgetHover);
|
||
c[ImGuiCol_TabSelected] = hex(kSelect);
|
||
c[ImGuiCol_TableHeaderBg] = hex(kBar);
|
||
c[ImGuiCol_TableBorderStrong] = c[ImGuiCol_TableBorderLight] = hex(kLine);
|
||
c[ImGuiCol_TableRowBg] = hex(0, 0);
|
||
c[ImGuiCol_TableRowBgAlt] = hex(0xffffff, 0.025f);
|
||
c[ImGuiCol_PlotHistogram] = hex(kBlue);
|
||
c[ImGuiCol_PlotHistogramHovered] = hex(kBlueHover);
|
||
c[ImGuiCol_TextSelectedBg] = hex(kBlue, 0.45f);
|
||
c[ImGuiCol_DragDropTarget] = hex(kOrange);
|
||
c[ImGuiCol_NavHighlight] = hex(kBlue);
|
||
c[ImGuiCol_ModalWindowDimBg] = hex(0x000000, 0.55f);
|
||
}
|
||
|
||
// ── The app ─────────────────────────────────────────────────────────────────────────────────────
|
||
|
||
struct Studio {
|
||
// The window it's shown in, as the window code (Win32 or GLFW) provides it.
|
||
struct WindowHooks {
|
||
std::function<void(const std::string&)> setTitle;
|
||
std::function<void()> requestClose; // as if its close box was clicked (asks to save first)
|
||
// The window's picture as shown (UI included), RGBA, top row first; false when it can't be read back.
|
||
std::function<bool(std::vector<u8>& rgba, int& width, int& height)> capture;
|
||
} window;
|
||
float scale = 1.0f;
|
||
HslRenderer renderer;
|
||
VkSampler viewSampler = VK_NULL_HANDLE;
|
||
VkDescriptorSet viewTex = VK_NULL_HANDLE;
|
||
uint32_t viewW = 0, viewH = 0; // renderer size
|
||
ImVec2 viewPos, viewSize; // viewport rect (screen pixels) from the last UI frame
|
||
bool rendered = false; // the renderer image holds a frame this tick
|
||
float renderMs = 0; // status bar: smoothed 3D frame time
|
||
|
||
// Home
|
||
std::unique_ptr<HslScene> scene;
|
||
// Animation timeline (bottom of the viewport): the clock every animation reads, and the home's ambience.
|
||
float animTime = 0, animLength = 0;
|
||
bool animPlaying = true;
|
||
std::chrono::steady_clock::time_point animTick = std::chrono::steady_clock::now();
|
||
AudioPlayer ambience;
|
||
AudioPlayer addedSound; // the background sound added to the environment (soundPanel)
|
||
SoundState sound;
|
||
std::vector<std::string> homePaths; // footprint [+ vista]
|
||
std::string homeName;
|
||
std::vector<Object> objects;
|
||
int selected = -1;
|
||
bool dirty = false;
|
||
std::vector<UndoStep> undo, redo;
|
||
int undoGroup = 0, nextUndoGroup = 1; // pushUndo tags steps with undoGroup while a batch is open
|
||
std::vector<std::vector<std::string>> recent; // File > Open Recent: each entry footprint [+ vista]
|
||
bool showControls = false; // Help > Controls
|
||
bool testing = false; // --selftest: leave the recent list and edits alone
|
||
struct TestEye { ImVec2 at; std::vector<size_t> members; };
|
||
std::vector<TestEye> testEyes; // --selftest: the explorer's eye buttons this frame (folders and rows)
|
||
float animBase = 0; // completed timeline loops: HSL shaders read one continuous clock
|
||
// World editor: the selected world item (kind -1 = none), what is drawn, and whether the layer was edited.
|
||
int wKind = -1, wIndex = -1;
|
||
bool showWorld = true;
|
||
// What the viewport shows. View only: the per-item eyes (hidden) are kept with the world layer.
|
||
bool showCat[kShowCats] = {true, true, true, true, true, false, false, true, true, true, true};
|
||
bool showMeshes = true; // the home's own objects
|
||
bool isolating = false; // View > Show Only Selected (I)
|
||
int isoObject = -1, isoKind = -1, isoIndex = -1;
|
||
bool gizmoHot = false; // the mouse was over the gizmo last frame (see viewport())
|
||
HomeWorld dragBase; // the world layer as a gizmo drag began: scaling resizes from it
|
||
std::shared_ptr<HomeWorld> fieldBefore; // properties: the world layer as the active field was clicked
|
||
bool fieldPushed = false; // ...and whether its undo step is already in
|
||
bool worldEdited = false;
|
||
// Save Environment runs in the background (baking a big home takes seconds).
|
||
std::thread saver;
|
||
std::atomic<bool> saving{false};
|
||
std::string saveStatus;
|
||
std::string savedEnvironment; // the last .fenv written (recent list, Send to Frame)
|
||
std::atomic<bool> saveFailed{false}; // the last Save didn't make it: still unsaved
|
||
bool fastPack = true; // File > Faster Saving: parallel LZMA chunks (~1% bigger; fast-pack.txt), on unless turned off
|
||
bool optimizeMeshes = true; // File > Leave Out What Can't Be Seen (visibility_bake.h; optimize-meshes.txt), on unless turned off
|
||
// Edit > Settings: this PC's (studio-settings.txt) and the headset's (frame-home/settings.json over ssh).
|
||
struct StudioPerf { float viewScale = 1.0f; int msaa = 4; int textureCap = 2048; int anisotropy = 8; };
|
||
StudioPerf studioPerf, studioPerfAtStart;
|
||
bool showSettings = false;
|
||
PerfSettings headsetPerf;
|
||
std::string headsetStats, headsetStatus = "Not read from the headset yet"; // statusMutex
|
||
std::atomic<bool> headsetLoaded{false};
|
||
PerfSettings headsetPerfOnHeadset; // what the headset has, as last read or written
|
||
std::atomic<bool> headsetBusy{false};
|
||
std::thread headsetThread;
|
||
// Debug menu: the 3D view's debug views, the home report, and the headset's debug tools (frame-home/debug.json).
|
||
int debugView = HslRenderer::kViewNormal;
|
||
unsigned debugHide = 0;
|
||
bool showReport = false;
|
||
std::unique_ptr<HomeReport> report; // for reportScene
|
||
const HslScene* reportScene = nullptr;
|
||
bool showHeadsetDebug = false;
|
||
DebugSettings headsetDebug; // statusMutex
|
||
std::string headsetDebugStatus = "Not read from the headset yet", headsetReport; // statusMutex
|
||
long headsetClock = 0; // the headset's clock minus this PC's, seconds (picture requests use its time)
|
||
std::atomic<bool> headsetDebugBusy{false}, headsetDebugLoaded{false};
|
||
std::thread headsetDebugThread;
|
||
|
||
// Background load
|
||
std::thread loader;
|
||
std::atomic<int> loadState{0}; // 0 idle, 1 loading, 2 ready, 3 failed
|
||
bool uploading = false; // meshes still streaming to the GPU (a few per frame)
|
||
bool texLoading = false; // then every texture, before the home is shown
|
||
int benchFrames = 0; // FS_BENCH_PICK: frames to wait (viewport sized) before the click benchmark
|
||
std::unique_ptr<HslScene> loaded;
|
||
std::vector<std::string> loadingPaths;
|
||
std::string loadError;
|
||
// A load that takes too long can be stopped. The loader can't be interrupted mid-parse, so a stopped one is
|
||
// let go (detached) and whatever it finishes with is thrown away: each load has a generation number, and
|
||
// only the current generation publishes its result.
|
||
std::atomic<uint64_t> loadGen{0};
|
||
std::mutex loadMutex;
|
||
std::chrono::steady_clock::time_point loadStarted{};
|
||
float loadPromptAfter = 45; // seconds of loading before asking whether to keep waiting
|
||
bool loadPromptOpen = false;
|
||
bool loadAbandoned = false; // a stopped loader may still be running: exit without destructors
|
||
|
||
// Send to Frame
|
||
std::thread sender;
|
||
std::atomic<bool> sending{false};
|
||
std::mutex statusMutex;
|
||
std::string frameStatus = "Not sent yet";
|
||
char frameHost[128] = {}; // ssh target for the headset (frame-host.txt; default kFrameHost)
|
||
|
||
// Headset > Install on Steam Frame: Frame Home + environments, as the headset's environment
|
||
bool showInstall = false;
|
||
std::thread installer;
|
||
std::atomic<bool> installing{false};
|
||
std::string installStatus = "Not installed from this PC yet"; // statusMutex
|
||
std::vector<std::string> installEnvs; // .fenv files to send
|
||
bool installOpenHome = true;
|
||
|
||
// View
|
||
Camera cam;
|
||
Tool tool = Tool::Move;
|
||
bool localSpace = false;
|
||
float flySpeed = 2.0f;
|
||
int drag = -1; // mouse button dragging the camera (0 alt-orbit, 1 fly, 2 MMB)
|
||
bool flying = false;
|
||
ImVec2 clickPos;
|
||
bool clickArmed = false;
|
||
bool gizmoWasUsing = false;
|
||
float outlinerW = 0, propertiesW = 0; // the Explorer (left) and the Inspector (right), dragged by their splitters
|
||
std::vector<float> clip; // selection wire: clip-space vertices of the selected mesh
|
||
char filter[128] = {};
|
||
std::set<int> collapsed; // outliner groups the user folded
|
||
std::chrono::steady_clock::time_point start = std::chrono::steady_clock::now();
|
||
|
||
// ── Home lifecycle ──
|
||
|
||
void setStatus(const std::string& s) { std::lock_guard<std::mutex> l(statusMutex); frameStatus = s; }
|
||
void setInstallStatus(const std::string& s) { std::lock_guard<std::mutex> l(statusMutex); installStatus = s; }
|
||
std::string getInstallStatus() { std::lock_guard<std::mutex> l(statusMutex); return installStatus; }
|
||
|
||
void loadSettings() {
|
||
loadHost();
|
||
if (FILE* f = platform::openFile((dataDir() / L"studio-settings.txt").c_str(), "rb")) {
|
||
char line[128];
|
||
while (std::fgets(line, sizeof line, f)) {
|
||
float v = 0;
|
||
char key[64] = {};
|
||
if (std::sscanf(line, "%63[^=]=%f", key, &v) != 2) continue;
|
||
const std::string k = key;
|
||
if (k == "viewScale") studioPerf.viewScale = std::clamp(v, 0.25f, 1.0f);
|
||
else if (k == "msaa") studioPerf.msaa = static_cast<int>(v);
|
||
else if (k == "textureCap") studioPerf.textureCap = static_cast<int>(v);
|
||
else if (k == "anisotropy") studioPerf.anisotropy = static_cast<int>(v);
|
||
}
|
||
std::fclose(f);
|
||
}
|
||
studioPerfAtStart = studioPerf;
|
||
setTextureCap(static_cast<uint32_t>(studioPerf.textureCap));
|
||
if (FILE* f = platform::openFile((dataDir() / L"fast-pack.txt").c_str(), "rb")) { fastPack = std::fgetc(f) == '1'; std::fclose(f); }
|
||
if (FILE* f = platform::openFile((dataDir() / L"optimize-meshes.txt").c_str(), "rb")) { optimizeMeshes = std::fgetc(f) == '1'; std::fclose(f); }
|
||
}
|
||
|
||
void saveStudioPerf() {
|
||
if (FILE* f = platform::openFile((dataDir() / L"studio-settings.txt").c_str(), "wb")) {
|
||
std::fprintf(f, "viewScale=%.2f\nmsaa=%d\ntextureCap=%d\nanisotropy=%d\n", studioPerf.viewScale, studioPerf.msaa, studioPerf.textureCap, studioPerf.anisotropy);
|
||
std::fclose(f);
|
||
}
|
||
setTextureCap(static_cast<uint32_t>(studioPerf.textureCap));
|
||
}
|
||
|
||
// The headset's settings and stats, read (or written, when `write`) over ssh on a worker thread.
|
||
void headsetSettings(bool write) {
|
||
if (headsetBusy.exchange(true)) return;
|
||
if (headsetThread.joinable()) headsetThread.join();
|
||
const std::string host8 = hostArg(), json = headsetPerf.toJson();
|
||
headsetThread = std::thread([this, write, host8, json] {
|
||
const std::wstring ssh = sshTool(L"ssh") + L"-o BatchMode=yes -o ConnectTimeout=8 " + widen(host8) + L" ";
|
||
std::string out;
|
||
if (write) { // base64: nothing in the command line for a shell to read into
|
||
const std::string enc = base64(json);
|
||
const int rc = run(ssh + L"\"echo " + widen(enc) + L" | base64 -d > frame-home/settings.json.part && mv -f frame-home/settings.json.part frame-home/settings.json\"", L"", out);
|
||
std::lock_guard<std::mutex> l(statusMutex);
|
||
if (rc == 0) { headsetStatus = "Saved on the headset: Frame Home applies it now"; headsetPerf.fromJson(json); headsetPerfOnHeadset = headsetPerf; }
|
||
else { headsetStatus = "Couldn't save: " + lastLine(out); headsetPerf = headsetPerfOnHeadset; } // show what it still has
|
||
headsetBusy = false;
|
||
return;
|
||
}
|
||
// Markers around each file: ssh's own messages share the output, and a missing file is just empty.
|
||
const int rc = run(ssh + L"\"echo @@settings; cat frame-home/settings.json 2>/dev/null; echo; echo @@stats; cat frame-home/stats.json 2>/dev/null; true\"", L"", out);
|
||
std::lock_guard<std::mutex> l(statusMutex);
|
||
const size_t a = out.find("@@settings\n"), sep = out.find("@@stats");
|
||
if (rc != 0 || a == std::string::npos || sep == std::string::npos || sep < a) {
|
||
headsetStatus = "Can't reach the headset (" + host8 + "): " + lastLine(out); headsetBusy = false; return; }
|
||
PerfSettings p;
|
||
if (p.fromJson(out.substr(a + 11, sep - a - 11))) { headsetPerf = p; headsetPerfOnHeadset = p; headsetLoaded = true; }
|
||
headsetStats.clear();
|
||
try {
|
||
const tinyjson::Value st = tinyjson::parse(out.substr(sep + 7));
|
||
if (!st.isObject() || !st.has("gpuMs")) throw std::runtime_error("no stats");
|
||
char line[200];
|
||
std::snprintf(line, sizeof line, "%.1f of %.1f ms a frame \u00b7 %d x %d per eye (%.0f%%) \u00b7 foveation %s",
|
||
st["gpuMs"].asFloat(), st["budgetMs"].asFloat(), static_cast<int>(st["width"].asFloat()), static_cast<int>(st["height"].asFloat()),
|
||
st["scale"].asFloat() * 100, !st["foveation"].boolVal ? "off" : st["eyeTracking"].boolVal ? "following your eyes" : "on (eyes not tracked right now)");
|
||
headsetStats = line;
|
||
} catch (...) {}
|
||
headsetStatus = headsetLoaded ? "Read from the headset" : "The headset has no settings yet (install Frame Home first)";
|
||
headsetBusy = false;
|
||
});
|
||
}
|
||
|
||
void settingsWindow() {
|
||
if (!showSettings) return;
|
||
ImGui::SetNextWindowSize(ImVec2(560 * scale, 0), ImGuiCond_Appearing);
|
||
ImGui::SetNextWindowPos(ImVec2(ImGui::GetIO().DisplaySize.x * 0.5f, ImGui::GetIO().DisplaySize.y * 0.5f), ImGuiCond_Appearing, ImVec2(0.5f, 0.5f));
|
||
if (!ImGui::Begin("Settings", &showSettings, ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_AlwaysAutoResize)) { ImGui::End(); return; }
|
||
const float lw = 170 * scale;
|
||
auto choice = [&](const char* label, const char* const* opts, int n, int cur, const char* tip) -> int {
|
||
ImGui::AlignTextToFramePadding();
|
||
ImGui::TextUnformatted(label, ImGui::FindRenderedTextEnd(label)); // not the "##id" part
|
||
if (tip && ImGui::IsItemHovered()) ImGui::SetTooltip("%s", tip);
|
||
ImGui::SameLine(lw);
|
||
int picked = -1;
|
||
ImGui::PushID(label);
|
||
for (int i = 0; i < n; ++i) {
|
||
if (i) ImGui::SameLine();
|
||
const bool on = i == cur;
|
||
if (on) ImGui::PushStyleColor(ImGuiCol_Button, hex(kBlue));
|
||
if (ImGui::Button(opts[i])) picked = i;
|
||
if (on) ImGui::PopStyleColor();
|
||
}
|
||
ImGui::PopID();
|
||
return picked;
|
||
};
|
||
ImGui::SeparatorText("This PC (Frame Studio)");
|
||
{
|
||
static const char* scales[] = {"50%", "75%", "100%"};
|
||
int cur = studioPerf.viewScale < 0.6f ? 0 : studioPerf.viewScale < 0.9f ? 1 : 2;
|
||
if (int p = choice("Viewport resolution", scales, 3, cur, "Draws the 3D view at a share of its size: lower is faster on big homes."); p >= 0) {
|
||
studioPerf.viewScale = p == 0 ? 0.5f : p == 1 ? 0.75f : 1.0f; saveStudioPerf(); }
|
||
static const char* aa[] = {"Off", "2x", "4x"};
|
||
cur = studioPerf.msaa >= 4 ? 2 : studioPerf.msaa >= 2 ? 1 : 0;
|
||
if (int p = choice("Anti-aliasing", aa, 3, cur, "Smooths jagged edges in the 3D view."); p >= 0) { studioPerf.msaa = p == 2 ? 4 : p == 1 ? 2 : 1; saveStudioPerf(); }
|
||
static const char* caps[] = {"1024", "2048", "4096", "Full"};
|
||
cur = studioPerf.textureCap == 1024 ? 0 : studioPerf.textureCap == 2048 ? 1 : studioPerf.textureCap == 4096 ? 2 : 3;
|
||
if (int p = choice("Texture limit", caps, 4, cur, "Largest texture size kept on this PC's graphics card. Lower uses less video memory."); p >= 0) {
|
||
studioPerf.textureCap = p == 0 ? 1024 : p == 1 ? 2048 : p == 2 ? 4096 : 0; saveStudioPerf(); }
|
||
static const char* af[] = {"4x", "8x", "16x"};
|
||
cur = studioPerf.anisotropy >= 16 ? 2 : studioPerf.anisotropy >= 8 ? 1 : 0;
|
||
if (int p = choice("Texture filtering", af, 3, cur, "Keeps floors and walls sharp at an angle."); p >= 0) { studioPerf.anisotropy = p == 2 ? 16 : p == 1 ? 8 : 4; saveStudioPerf(); }
|
||
const bool restartNeeded = studioPerf.msaa != studioPerfAtStart.msaa || studioPerf.anisotropy != studioPerfAtStart.anisotropy;
|
||
ImGui::TextDisabled("%s", restartNeeded ? "Anti-aliasing and texture filtering apply the next time Frame Studio starts."
|
||
: "The texture limit applies to homes opened from now on.");
|
||
}
|
||
ImGui::Spacing();
|
||
ImGui::SeparatorText("Steam Frame (Frame Home)");
|
||
{
|
||
std::string status, stats;
|
||
bool loaded;
|
||
PerfSettings hp;
|
||
{
|
||
std::lock_guard<std::mutex> l(statusMutex);
|
||
status = headsetStatus; stats = headsetStats; loaded = headsetLoaded; hp = headsetPerf;
|
||
}
|
||
ImGui::BeginDisabled(headsetBusy || !loaded);
|
||
bool changed = false;
|
||
static const char* res[] = {"Auto", "80%", "100%", "120%", "140%"};
|
||
int cur = hp.autoResolution ? 0 : hp.resolution < 0.9f ? 1 : hp.resolution < 1.1f ? 2 : hp.resolution < 1.3f ? 3 : 4;
|
||
if (int p = choice("Resolution", res, 5, cur, "Auto keeps every frame on time and sharpens whenever the home is light enough (70-150%)."); p >= 0) {
|
||
hp.autoResolution = p == 0; if (p > 0) hp.resolution = 0.6f + 0.2f * p; changed = true; }
|
||
static const char* fr[] = {"Full", "Half"};
|
||
if (int p = choice("Frame rate", fr, 2, hp.frameRate, "Half draws the environment every other frame and turns it with your head in between, as Meta's home does: "
|
||
"each frame gets twice the GPU time."); p >= 0) { hp.frameRate = p; changed = true; }
|
||
static const char* aa[] = {"Off", "2x", "4x"};
|
||
cur = hp.msaa >= 4 ? 2 : hp.msaa >= 2 ? 1 : 0;
|
||
if (int p = choice("Anti-aliasing##hs", aa, 3, cur, nullptr); p >= 0) { hp.msaa = p == 2 ? 4 : p == 1 ? 2 : 1; changed = true; }
|
||
static const char* af[] = {"4x", "8x", "16x"};
|
||
cur = hp.anisotropy >= 16 ? 2 : hp.anisotropy >= 8 ? 1 : 0;
|
||
if (int p = choice("Texture filtering##hs", af, 3, cur, nullptr); p >= 0) { hp.anisotropy = p == 2 ? 16 : p == 1 ? 8 : 4; changed = true; }
|
||
static const char* fov[] = {"Off", "Fixed", "Eye tracked"};
|
||
if (int p = choice("Foveated rendering", fov, 3, hp.foveation, "Full detail where you look, coarser toward the edges, using the Steam Frame's eye tracking. "
|
||
"The time it frees goes to resolution (with Auto)."); p >= 0) { hp.foveation = p; changed = true; }
|
||
static const char* lv[] = {"Gentle", "Medium", "Strong"};
|
||
ImGui::BeginDisabled(hp.foveation == 0);
|
||
if (int p = choice("Foveation strength", lv, 3, hp.foveationLevel - 1, nullptr); p >= 0) { hp.foveationLevel = p + 1; changed = true; }
|
||
static const char* tl[] = {"Off", "80\u00b0", "86\u00b0", "90\u00b0"};
|
||
cur = hp.tempLimit <= 0 ? 0 : hp.tempLimit <= 82 ? 1 : hp.tempLimit <= 88 ? 2 : 3;
|
||
if (int p = choice("Keep cool", tl, 4, cur, "Above this temperature the edges of view give up a little detail at a time until the headset cools "
|
||
"(where you look stays sharp). A hot Steam Frame slows its GPU to a third."); p >= 0) {
|
||
hp.tempLimit = p == 0 ? 0 : p == 1 ? 80 : p == 2 ? 86 : 90; changed = true; }
|
||
ImGui::EndDisabled();
|
||
ImGui::Spacing();
|
||
ImGui::TextDisabled("Moving around");
|
||
static const char* walk[] = {"Off", "Smooth"};
|
||
if (int p = choice("Walking##hs", walk, 2, hp.walk, "Left stick: walk where you look. Off for teleport only."); p >= 0) { hp.walk = p; changed = true; }
|
||
static const char* tp[] = {"Off", "Instant", "Slide"};
|
||
if (int p = choice("Teleport##hs", tp, 3, hp.teleport, "Right stick forward aims, let go to go there: in a blink, or gliding there."); p >= 0) { hp.teleport = p; changed = true; }
|
||
static const char* tn[] = {"Snap", "Smooth"};
|
||
if (int p = choice("Turning##hs", tn, 2, hp.turn, "Right stick left / right."); p >= 0) { hp.turn = p; changed = true; }
|
||
static const char* sa[] = {"30\u00b0", "45\u00b0", "90\u00b0"};
|
||
ImGui::BeginDisabled(hp.turn != 0);
|
||
if (int p = choice("Snap angle##hs", sa, 3, hp.snapAngle >= 90 ? 2 : hp.snapAngle >= 45 ? 1 : 0, nullptr); p >= 0) { hp.snapAngle = p == 2 ? 90 : p == 1 ? 45 : 30; changed = true; }
|
||
ImGui::EndDisabled();
|
||
static const char* sp[] = {"Slow", "Normal", "Fast"};
|
||
if (int p = choice("Walking speed##hs", sp, 3, hp.moveSpeed < 1.7f ? 0 : hp.moveSpeed < 2.5f ? 1 : 2, nullptr); p >= 0) {
|
||
hp.moveSpeed = p == 0 ? 1.4f : p == 1 ? 2.0f : 3.0f; changed = true; }
|
||
static const char* vg[] = {"Off", "Gentle", "Strong"};
|
||
if (int p = choice("Comfort vignette", vg, 3, hp.vignette, "Darkens the edges of view while the stick moves or turns you. Helps if smooth walking makes you queasy.");
|
||
p >= 0) { hp.vignette = p; changed = true; }
|
||
static const char* sm[] = {"Shown", "Hidden"};
|
||
if (int p = choice("Seat markers", sm, 2, hp.seatMarkers ? 0 : 1, "Markers on the seats near you. Hidden, pointing at a seat still finds it."); p >= 0) {
|
||
hp.seatMarkers = p == 0 ? 1 : 0; changed = true; }
|
||
ImGui::Spacing();
|
||
ImGui::TextDisabled("The environment");
|
||
static const char* vol[] = {"Off", "25%", "50%", "75%", "100%"};
|
||
if (int p = choice("Volume", vol, 5, static_cast<int>(std::lround(hp.volume * 4)), "All of the environment's sound."); p >= 0) { hp.volume = p * 0.25f; changed = true; }
|
||
static const char* an[] = {"Play", "Hold still"};
|
||
if (int p = choice("Animations", an, 2, hp.animations ? 0 : 1, "Hold still stops the environment's moving parts; animated environments then cost less."); p >= 0) {
|
||
hp.animations = p == 0 ? 1 : 0; changed = true; }
|
||
static const char* mr[] = {"Half rate", "Full rate"};
|
||
if (int p = choice("Behind the Steam menu", mr, 2, hp.menuRate, "With the menu open the environment is only its backdrop. Half rate leaves the GPU to the menu and its apps.");
|
||
p >= 0) { hp.menuRate = p; changed = true; }
|
||
ImGui::EndDisabled();
|
||
if (changed) {
|
||
{ std::lock_guard<std::mutex> l(statusMutex); headsetPerf = hp; headsetStatus = "Saving\u2026"; }
|
||
headsetSettings(true);
|
||
}
|
||
if (!stats.empty()) ImGui::TextDisabled("Now on the headset: %s", stats.c_str());
|
||
ImGui::BeginDisabled(headsetBusy);
|
||
if (ImGui::Button(headsetBusy ? "Working\u2026" : "Read from Headset")) headsetSettings(false);
|
||
ImGui::EndDisabled();
|
||
ImGui::SameLine();
|
||
ImGui::TextWrapped("%s", status.c_str());
|
||
ImGui::TextDisabled("Anti-aliasing, filtering and foveation on / off restart Frame Home (a few seconds).");
|
||
}
|
||
ImGui::End();
|
||
}
|
||
|
||
// ── Debug ──
|
||
|
||
// The object showing scene mesh si, selected and framed (the report's lists point at meshes).
|
||
void selectSceneMesh(int si) {
|
||
for (size_t i = 0; i < objects.size(); ++i)
|
||
if (objects[i].scene == si && !objects[i].deleted) { selected = static_cast<int>(i); wKind = wIndex = -1; frameSelected(); return; }
|
||
}
|
||
|
||
// Debug > Home Report: what the open home is made of, what it costs and what's wrong with it. Rows that name a mesh
|
||
// select it.
|
||
void reportWindow() {
|
||
if (!showReport) return;
|
||
if (!scene) { showReport = false; return; }
|
||
if (!report || reportScene != scene.get()) {
|
||
report = std::make_unique<HomeReport>(makeHomeReport(*scene, scene->world.walkGridCache ? &scene->world : nullptr));
|
||
reportScene = scene.get();
|
||
}
|
||
ImGui::SetNextWindowSize(ImVec2(640 * scale, 560 * scale), ImGuiCond_Appearing);
|
||
ImGui::SetNextWindowPos(ImVec2(ImGui::GetIO().DisplaySize.x * 0.5f, ImGui::GetIO().DisplaySize.y * 0.5f), ImGuiCond_Appearing, ImVec2(0.5f, 0.5f));
|
||
if (!ImGui::Begin("Home Report", &showReport, ImGuiWindowFlags_NoCollapse)) { ImGui::End(); return; }
|
||
const HomeReport& r = *report;
|
||
ImGui::TextUnformatted(r.name.c_str());
|
||
if (!r.format.empty()) { ImGui::SameLine(); ImGui::TextDisabled("%s", r.format.c_str()); }
|
||
ImGui::TextWrapped("%zu meshes, %.2f M triangles (%zu see-through, %zu cut-out, %zu animated), %zu shaders", r.meshes, r.triangles / 1e6, r.seeThrough,
|
||
r.cutout, r.animated, r.programs);
|
||
ImGui::TextWrapped("%zu textures: %.0f MB compressed, %.0f MB decoded; geometry %.0f MB", r.textures, r.textureMB, r.decodedMB, r.geometryMB);
|
||
if (r.walkArea >= 0) ImGui::TextWrapped("%.0f m² to walk on; %zu spawns, %zu seats, %zu surfaces, %zu wall spots", r.walkArea, r.spawns, r.seats, r.colliders, r.wallSpots);
|
||
else ImGui::TextWrapped("%zu spawns, %zu seats, %zu surfaces, %zu wall spots", r.spawns, r.seats, r.colliders, r.wallSpots);
|
||
if (ImGui::Button("Check Again")) report.reset();
|
||
ImGui::SameLine();
|
||
if (ImGui::Button("Copy as Text")) ImGui::SetClipboardText(r.text().c_str());
|
||
ImGui::BeginChild("##report", ImVec2(0, 0), ImGuiChildFlags_None);
|
||
auto row = [&](const std::string& text, int mesh, uint32_t color, int id) {
|
||
ImGui::PushID(id);
|
||
ImGui::PushStyleColor(ImGuiCol_Text, hex(color));
|
||
if (ImGui::Selectable(text.c_str(), mesh >= 0 && selected >= 0 && objects[selected].scene == mesh) && mesh >= 0) selectSceneMesh(mesh);
|
||
ImGui::PopStyleColor();
|
||
if (mesh >= 0 && ImGui::IsItemHovered()) ImGui::SetTooltip("Select %s", r.meshName(mesh).c_str());
|
||
ImGui::PopID();
|
||
};
|
||
ImGui::SeparatorText("Problems");
|
||
if (r.problems.empty()) ImGui::TextDisabled("Nothing wrong found.");
|
||
for (size_t i = 0; i < r.problems.size(); ++i) {
|
||
const HomeProblem& pr = r.problems[i];
|
||
row(std::string(pr.level >= 2 ? "Broken: " : pr.level == 1 ? "" : "Note: ") + pr.what, pr.mesh, pr.level >= 2 ? 0xe06c66 : pr.level == 1 ? kText : kDim,
|
||
static_cast<int>(i));
|
||
}
|
||
if (debugView == HslRenderer::kViewCost) {
|
||
ImGui::SeparatorText("Costliest right now (this PC's GPU, both eyes' share)");
|
||
const auto top = renderer.costliest(10);
|
||
if (top.empty()) ImGui::TextDisabled("Measuring…");
|
||
for (size_t i = 0; i < top.size(); ++i) {
|
||
char b[64];
|
||
std::snprintf(b, sizeof b, "%7.3f ms ", top[i].first);
|
||
row(b + (top[i].second >= 0 && static_cast<size_t>(top[i].second) < scene->meshes.size() ? scene->meshes[top[i].second].name : std::string("?")),
|
||
top[i].second, kText, 1000 + static_cast<int>(i));
|
||
}
|
||
}
|
||
ImGui::SeparatorText("Most triangles");
|
||
for (size_t i = 0; i < r.biggestMeshes.size(); ++i) {
|
||
char b[64];
|
||
std::snprintf(b, sizeof b, "%9zu ", r.biggestMeshes[i].tris);
|
||
row(b + r.meshName(r.biggestMeshes[i].mesh), r.biggestMeshes[i].mesh, kText, 2000 + static_cast<int>(i));
|
||
}
|
||
ImGui::SeparatorText("Biggest textures");
|
||
for (size_t i = 0; i < r.biggestTextures.size(); ++i) {
|
||
const HomeReport::Tex& t = r.biggestTextures[i];
|
||
char b[128];
|
||
std::snprintf(b, sizeof b, "%6.1f MB %ux%u, %u mip%s, %zu mesh%s ", t.mb, t.w, t.h, t.mips, t.mips == 1 ? "" : "s", t.users, t.users == 1 ? "" : "es");
|
||
row(b + r.meshName(t.mesh), t.mesh, kText, 3000 + static_cast<int>(i));
|
||
}
|
||
ImGui::EndChild();
|
||
ImGui::End();
|
||
}
|
||
|
||
// The headset's debug tools over ssh (on a worker thread): 0 read debug.json and the report, 1 write debug.json,
|
||
// 2 ask for a picture and bring it here (opened when it arrives).
|
||
void headsetDebugCall(int action) {
|
||
if (headsetDebugBusy.exchange(true)) return;
|
||
if (headsetDebugThread.joinable()) headsetDebugThread.join();
|
||
DebugSettings d;
|
||
{
|
||
std::lock_guard<std::mutex> l(statusMutex);
|
||
d = headsetDebug;
|
||
headsetDebugStatus = action == 0 ? "Reading…" : action == 1 ? "Saving…" : "Asking for a picture…";
|
||
}
|
||
if (action == 2) d.shot = static_cast<int>(std::time(nullptr) + headsetClock); // in the headset's time
|
||
const std::string host8 = hostArg();
|
||
headsetDebugThread = std::thread([this, action, d, host8] {
|
||
const std::wstring ssh = sshTool(L"ssh") + L"-o BatchMode=yes -o ConnectTimeout=8 " + widen(host8) + L" ";
|
||
auto done = [&](const std::string& st) { std::lock_guard<std::mutex> l(statusMutex); headsetDebugStatus = st; headsetDebugBusy = false; };
|
||
std::string out;
|
||
if (action == 0) {
|
||
const int rc = run(ssh + L"\"echo @@debug; cat frame-home/debug.json 2>/dev/null; echo; echo @@time; date +%s; echo @@report; cat frame-home/logs/report.txt 2>/dev/null; true\"", L"", out);
|
||
const size_t a = out.find("@@debug\n"), t = out.find("@@time\n"), rp = out.find("@@report\n");
|
||
if (rc != 0 || a == std::string::npos || t == std::string::npos || rp == std::string::npos) return done("Can't reach the headset (" + host8 + "): " + lastLine(out));
|
||
DebugSettings got;
|
||
if (!got.fromJson(out.substr(a + 8, t - a - 8))) got = DebugSettings(); // none: everything off
|
||
const long headsetNow = std::atol(out.c_str() + t + 7);
|
||
std::lock_guard<std::mutex> l(statusMutex);
|
||
headsetDebug = got;
|
||
if (headsetNow > 0) headsetClock = headsetNow - static_cast<long>(std::time(nullptr));
|
||
headsetReport = out.substr(rp + 9);
|
||
headsetDebugLoaded = true;
|
||
headsetDebugStatus = "Read from the headset";
|
||
headsetDebugBusy = false;
|
||
return;
|
||
}
|
||
const int rc = run(ssh + L"\"echo " + widen(base64(d.toJson())) + L" | base64 -d > frame-home/debug.json.part && mv -f frame-home/debug.json.part frame-home/debug.json\"", L"", out);
|
||
if (rc != 0) return done("Couldn't save: " + lastLine(out));
|
||
{ std::lock_guard<std::mutex> l(statusMutex); headsetDebug = d; }
|
||
if (action == 1) return done("Saved: Frame Home shows it within a second");
|
||
// The picture: Frame Home takes it on its next frame; then the newest one comes here.
|
||
std::this_thread::sleep_for(std::chrono::milliseconds(2500));
|
||
out.clear();
|
||
if (run(ssh + L"\"ls -t frame-home/logs/frame-*.png 2>/dev/null | head -1\"", L"", out) != 0) return done("Couldn't look for the picture: " + lastLine(out));
|
||
std::string remote = lastLine(out);
|
||
if (remote.find("frame-") == std::string::npos || remote.find(".png") == std::string::npos) return done("No picture yet: is the environment showing?");
|
||
for (char c : remote) if (!std::isalnum(static_cast<unsigned char>(c)) && !std::strchr("/-_.", c)) return done("Unexpected picture name: " + remote);
|
||
const fs::path dir = dataDir() / L"pictures";
|
||
std::error_code ec;
|
||
fs::create_directories(dir, ec);
|
||
const std::string name = remote.substr(remote.find_last_of('/') + 1);
|
||
out.clear();
|
||
const std::wstring scp = sshTool(L"scp") + L"-q -o BatchMode=yes -o ConnectTimeout=8 ";
|
||
if (run(scp + widen(host8) + L":" + widen(remote) + L" \"" + widen(name) + L"\"", dir.wstring(), out) != 0) return done("Couldn't copy the picture: " + lastLine(out));
|
||
platform::openPath(narrow(fs::path((dir / widen(name)).c_str()).wstring()));
|
||
done("Picture saved: " + narrow((dir / widen(name)).wstring()));
|
||
});
|
||
}
|
||
|
||
// Debug > Headset Debug: the Environments panel's Debug page, from here.
|
||
void headsetDebugWindow() {
|
||
if (!showHeadsetDebug) return;
|
||
ImGui::SetNextWindowSize(ImVec2(600 * scale, 0), ImGuiCond_Appearing);
|
||
ImGui::SetNextWindowPos(ImVec2(ImGui::GetIO().DisplaySize.x * 0.5f, ImGui::GetIO().DisplaySize.y * 0.5f), ImGuiCond_Appearing, ImVec2(0.5f, 0.5f));
|
||
if (!ImGui::Begin("Headset Debug", &showHeadsetDebug, ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_AlwaysAutoResize)) { ImGui::End(); return; }
|
||
DebugSettings d;
|
||
std::string status, rep;
|
||
{
|
||
std::lock_guard<std::mutex> l(statusMutex);
|
||
d = headsetDebug; status = headsetDebugStatus; rep = headsetReport;
|
||
}
|
||
const float lw = 170 * scale;
|
||
auto choice = [&](const char* label, const char* const* opts, int n, int cur, const char* tip) -> int {
|
||
ImGui::AlignTextToFramePadding();
|
||
ImGui::TextUnformatted(label, ImGui::FindRenderedTextEnd(label));
|
||
if (tip && ImGui::IsItemHovered()) ImGui::SetTooltip("%s", tip);
|
||
ImGui::SameLine(lw);
|
||
int picked = -1;
|
||
ImGui::PushID(label);
|
||
for (int i = 0; i < n; ++i) {
|
||
if (i) ImGui::SameLine();
|
||
const bool on = i == cur;
|
||
if (on) ImGui::PushStyleColor(ImGuiCol_Button, hex(kBlue));
|
||
if (ImGui::Button(opts[i])) picked = i;
|
||
if (on) ImGui::PopStyleColor();
|
||
}
|
||
ImGui::PopID();
|
||
return picked;
|
||
};
|
||
ImGui::BeginDisabled(headsetDebugBusy || !headsetDebugLoaded);
|
||
bool changed = false;
|
||
static const char* views[] = {"Normal", "Wireframe", "Meshes", "GPU cost", "Overdraw"};
|
||
if (int p = choice("View##hd", views, 5, d.view, "What Frame Home draws: the environment, or how it's made and what it costs."); p >= 0) { d.view = p; changed = true; }
|
||
static const char* wl[] = {"Hidden", "Shown"};
|
||
if (int p = choice("World layer##hd", wl, 2, d.world, "Where you can walk (green), surfaces and colliders, spawns, seats, wall spots and gravity zones."); p >= 0) {
|
||
d.world = p; changed = true; }
|
||
static const char* lo[] = {"Shown", "Left out"};
|
||
if (int p = choice("See-through##hd", lo, 2, d.hideSeeThrough, "Glass, water, glow and fog."); p >= 0) { d.hideSeeThrough = p; changed = true; }
|
||
if (int p = choice("Cut-out##hd", lo, 2, d.hideCutout, "Leaves, grass and fences."); p >= 0) { d.hideCutout = p; changed = true; }
|
||
if (int p = choice("Sky##hd", lo, 2, d.hideSky, nullptr); p >= 0) { d.hideSky = p; changed = true; }
|
||
ImGui::EndDisabled();
|
||
if (changed) {
|
||
{ std::lock_guard<std::mutex> l(statusMutex); headsetDebug = d; }
|
||
headsetDebugCall(1);
|
||
}
|
||
ImGui::BeginDisabled(headsetDebugBusy || !headsetDebugLoaded);
|
||
if (ImGui::Button("Take a Picture")) headsetDebugCall(2);
|
||
ImGui::SameLine();
|
||
ImGui::BeginDisabled(!d.any());
|
||
if (ImGui::Button("Turn Debug Views Off")) { { std::lock_guard<std::mutex> l(statusMutex); const int shot = headsetDebug.shot; headsetDebug = DebugSettings(); headsetDebug.shot = shot; } headsetDebugCall(1); }
|
||
ImGui::EndDisabled();
|
||
ImGui::EndDisabled();
|
||
ImGui::SameLine();
|
||
ImGui::BeginDisabled(headsetDebugBusy);
|
||
if (ImGui::Button(headsetDebugBusy ? "Working…" : "Read from Headset##hd")) headsetDebugCall(0);
|
||
ImGui::EndDisabled();
|
||
ImGui::TextWrapped("%s", status.c_str());
|
||
if (!rep.empty()) {
|
||
ImGui::SeparatorText("The environment showing on the headset");
|
||
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 560 * scale);
|
||
ImGui::TextUnformatted(rep.c_str());
|
||
ImGui::PopTextWrapPos();
|
||
}
|
||
ImGui::TextDisabled("Debug views are off again whenever Frame Home restarts.");
|
||
ImGui::End();
|
||
}
|
||
|
||
void loadHost() {
|
||
std::snprintf(frameHost, sizeof frameHost, "%s", kFrameHost);
|
||
if (FILE* f = platform::openFile((dataDir() / L"frame-host.txt").c_str(), "rb")) {
|
||
char line[128] = {};
|
||
if (std::fgets(line, sizeof line, f)) {
|
||
std::string h = line;
|
||
while (!h.empty() && std::isspace(static_cast<unsigned char>(h.back()))) h.pop_back();
|
||
if (!h.empty()) std::snprintf(frameHost, sizeof frameHost, "%s", h.c_str());
|
||
}
|
||
std::fclose(f);
|
||
}
|
||
}
|
||
void saveHost() {
|
||
if (FILE* f = platform::openFile((dataDir() / L"frame-host.txt").c_str(), "wb")) { std::fprintf(f, "%s\n", frameHost); std::fclose(f); }
|
||
}
|
||
// The ssh target, safe for a command line (an alias or user@host: nothing a shell could act on).
|
||
std::string hostArg() const {
|
||
std::string h;
|
||
for (const char* c = frameHost; *c; ++c)
|
||
if (std::isalnum(static_cast<unsigned char>(*c)) || std::strchr("@.-_:", *c)) h += *c;
|
||
return h.empty() ? kFrameHost : h;
|
||
}
|
||
std::string status() { std::lock_guard<std::mutex> l(statusMutex); return frameStatus; }
|
||
|
||
// File > Open Recent, kept in recent.txt (one home per line, its APKs separated by '|').
|
||
void loadRecent() {
|
||
recent.clear();
|
||
FILE* f = platform::openFile((dataDir() / L"recent.txt").c_str(), "rb");
|
||
if (!f) return;
|
||
char line[8192];
|
||
while (std::fgets(line, sizeof line, f) && recent.size() < 8) {
|
||
std::string l = line;
|
||
while (!l.empty() && (l.back() == '\n' || l.back() == '\r')) l.pop_back();
|
||
std::vector<std::string> paths;
|
||
for (size_t a = 0, b; a < l.size(); a = b + 1) {
|
||
b = l.find('|', a);
|
||
if (b == std::string::npos) b = l.size();
|
||
if (b > a) paths.push_back(l.substr(a, b - a));
|
||
}
|
||
if (!paths.empty()) recent.push_back(paths);
|
||
}
|
||
std::fclose(f);
|
||
}
|
||
|
||
void addRecent(const std::vector<std::string>& paths) {
|
||
if (testing) return;
|
||
recent.erase(std::remove(recent.begin(), recent.end(), paths), recent.end());
|
||
recent.insert(recent.begin(), paths);
|
||
if (recent.size() > 8) recent.resize(8);
|
||
FILE* f = platform::openFile((dataDir() / L"recent.txt").c_str(), "wb");
|
||
if (!f) return;
|
||
for (const auto& r : recent) {
|
||
std::string l;
|
||
for (const auto& p : r) l += (l.empty() ? "" : "|") + p;
|
||
std::fprintf(f, "%s\n", l.c_str());
|
||
}
|
||
std::fclose(f);
|
||
}
|
||
|
||
// One line per APK: "Vista · HSL · v210.0.0.0.502".
|
||
std::string sourceLine(size_t i) const {
|
||
if (!scene || i >= scene->sources.size()) return {};
|
||
const HslSourceInfo& s = scene->sources[i];
|
||
return s.role + " \u00b7 " + s.format + (s.version.empty() ? "" : " \u00b7 " + std::string(std::isdigit(static_cast<unsigned char>(s.version[0])) ? "v" : "") + s.version);
|
||
}
|
||
|
||
fs::path worldFile() const { return dataDir() / L"edits" / widen(frameName(homePaths.front()) + ".world.json"); }
|
||
|
||
fs::path editsFile() const { return dataDir() / L"edits" / widen(frameName(homePaths.front()) + ".edits.json"); }
|
||
|
||
void updateTitle() {
|
||
std::wstring t = L"Frame Studio";
|
||
if (!homeName.empty()) t = widen(shownName()) + (dirty ? L"*" : L"") + L" \u2014 Frame Studio";
|
||
if (window.setTitle) window.setTitle(narrow(t));
|
||
}
|
||
|
||
bool confirmDiscard() {
|
||
if (!dirty) return true;
|
||
const platform::Answer r = platform::ask("Frame Studio", "Save your changes to " + shownName() + " first?", true);
|
||
if (r == platform::Answer::Cancel) return false;
|
||
if (r == platform::Answer::Yes) saveToLibrary(); // (the environment saves from its own copies)
|
||
return true;
|
||
}
|
||
|
||
// A footprint opened on its own is paired with a vista (it's only the room: the lighting, sky and world outside
|
||
// ship in a vista): the one it was last opened with, else the one beside it, else you pick one of those beside it.
|
||
std::string vistaFootprint; // waiting for its vista to be picked
|
||
std::vector<std::string> vistaChoices;
|
||
bool pairVista(std::vector<std::string>& paths) {
|
||
if (paths.size() != 1 || !isFootprintApk(paths[0])) return true;
|
||
for (const auto& r : recent)
|
||
if (r.size() == 2 && r[0] == paths[0] && fs::exists(fs::path(widen(r[1])))) { paths.push_back(r[1]); return true; }
|
||
const std::vector<std::string> v = vistasBeside(paths[0]);
|
||
if (v.size() == 1) { paths.push_back(v[0]); return true; }
|
||
if (v.empty()) return true; // none: the room alone (the banner offers Add Vista)
|
||
vistaFootprint = paths[0];
|
||
vistaChoices = v;
|
||
return false;
|
||
}
|
||
void vistaPicker() {
|
||
if (vistaChoices.empty()) return;
|
||
if (!ImGui::IsPopupOpen("Pick a vista")) ImGui::OpenPopup("Pick a vista");
|
||
ImGui::SetNextWindowPos(ImVec2(ImGui::GetIO().DisplaySize.x * 0.5f, ImGui::GetIO().DisplaySize.y * 0.5f), ImGuiCond_Appearing, ImVec2(0.5f, 0.5f));
|
||
if (!ImGui::BeginPopupModal("Pick a vista", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) return;
|
||
ImGui::Text("%s is a footprint: the room.", prettyHome(vistaFootprint).c_str());
|
||
ImGui::TextDisabled("Its lighting, sky and the world outside come from a vista. They become one home (one file on the Frame).");
|
||
ImGui::Spacing();
|
||
std::vector<std::string> pick;
|
||
for (const auto& v : vistaChoices)
|
||
if (ImGui::Button((prettyHome(v) + "##" + v).c_str(), ImVec2(260 * scale, 0))) pick = {vistaFootprint, v};
|
||
ImGui::Spacing();
|
||
if (ImGui::Button("The room alone", ImVec2(260 * scale, 0))) pick = {vistaFootprint};
|
||
if (!pick.empty()) { vistaChoices.clear(); vistaFootprint.clear(); ImGui::CloseCurrentPopup(); }
|
||
ImGui::EndPopup();
|
||
if (pick.size() == 2) open(pick);
|
||
else if (pick.size() == 1) open(pick, false);
|
||
}
|
||
|
||
void open(std::vector<std::string> paths, bool pair = true) {
|
||
if (paths.empty() || loadState == 1 || saving) return;
|
||
if (pair) carry = Carry(); // another environment: nothing carries over
|
||
if (pair && !pairVista(paths)) return; // asked which vista: opens from the picker
|
||
if (!confirmDiscard()) return;
|
||
if (loader.joinable()) loader.join();
|
||
paths = footprintFirst(paths);
|
||
loadingPaths = paths;
|
||
loadError.clear();
|
||
loadState = 1;
|
||
loadStarted = std::chrono::steady_clock::now();
|
||
loadPromptAfter = 45;
|
||
const uint64_t gen = ++loadGen;
|
||
loader = std::thread([this, paths, gen] {
|
||
auto s = std::make_unique<HslScene>();
|
||
std::string err;
|
||
const bool ok = loadHslScene(paths, *s, err);
|
||
std::lock_guard<std::mutex> l(loadMutex);
|
||
if (gen != loadGen) return; // stopped meanwhile: drop it
|
||
if (ok) { loaded = std::move(s); loadState = 2; }
|
||
else { loadError = err.empty() ? "could not load the home" : err; loadState = 3; }
|
||
});
|
||
}
|
||
|
||
// Stops a load in progress: the background parse (let go, its result dropped) or the GPU upload after it.
|
||
// Whatever home was open before the load stays closed (opening one already replaced it).
|
||
void stopLoading() {
|
||
{
|
||
std::lock_guard<std::mutex> l(loadMutex);
|
||
++loadGen;
|
||
if (loadState == 1 && loader.joinable()) { loader.detach(); loadAbandoned = true; }
|
||
else if (loader.joinable()) loader.join();
|
||
loadState = 0;
|
||
loaded.reset();
|
||
}
|
||
if (uploading || texLoading || scene) {
|
||
vkDeviceWaitIdle(renderer.device());
|
||
renderer.unload();
|
||
ambience.stop();
|
||
addedSound.stop();
|
||
sound = SoundState();
|
||
scene.reset();
|
||
objects.clear();
|
||
undo.clear();
|
||
redo.clear();
|
||
homePaths.clear();
|
||
homeName.clear();
|
||
selected = -1;
|
||
wKind = wIndex = -1;
|
||
dirty = worldEdited = false;
|
||
uploading = texLoading = false;
|
||
updateTitle();
|
||
}
|
||
loadPromptOpen = false;
|
||
std::fprintf(stderr, "[studio] loading stopped by the user\n");
|
||
}
|
||
|
||
bool isLoading() const { return loadState == 1 || uploading || texLoading; }
|
||
// Whether the window's picture can change without any input: the frame pacer stops drawing when it can't.
|
||
bool viewChangesByItself() const { return isLoading() || saving || flying || (scene && animPlaying); }
|
||
|
||
// After loadPromptAfter seconds: keep waiting (asked again a minute later), stop loading, or close Studio now.
|
||
void loadPrompt() {
|
||
if (!isLoading()) { // the load finished (or failed) with the question up: take it down
|
||
if (loadPromptOpen && ImGui::BeginPopupModal("Still loading", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) { ImGui::CloseCurrentPopup(); ImGui::EndPopup(); }
|
||
loadPromptOpen = false;
|
||
return;
|
||
}
|
||
const float secs = std::chrono::duration<float>(std::chrono::steady_clock::now() - loadStarted).count();
|
||
if (!loadPromptOpen && secs > loadPromptAfter) { loadPromptOpen = true; ImGui::OpenPopup("Still loading"); }
|
||
ImGui::SetNextWindowPos(ImVec2(ImGui::GetIO().DisplaySize.x * 0.5f, ImGui::GetIO().DisplaySize.y * 0.5f), ImGuiCond_Appearing, ImVec2(0.5f, 0.5f));
|
||
if (!ImGui::BeginPopupModal("Still loading", nullptr, ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoMove)) return;
|
||
ImGui::PushTextWrapPos(440 * scale);
|
||
const std::string what = loadingPaths.empty() ? std::string("this home") : displayName(loadingPaths);
|
||
ImGui::TextWrapped("%s has been loading for %.0f seconds (%s).", what.c_str(), secs,
|
||
loadState == 1 ? "reading and converting it" : uploading ? "uploading it to the graphics card" : "loading its textures");
|
||
ImGui::TextDisabled("Big homes with a vista can take a while on the first open.");
|
||
ImGui::PopTextWrapPos();
|
||
ImGui::Spacing();
|
||
if (ImGui::Button("Keep Waiting", ImVec2(130 * scale, 0))) { loadPromptAfter = secs + 60; loadPromptOpen = false; ImGui::CloseCurrentPopup(); }
|
||
ImGui::SameLine();
|
||
if (ImGui::Button("Stop Loading", ImVec2(130 * scale, 0))) { ImGui::CloseCurrentPopup(); ImGui::EndPopup(); stopLoading(); return; }
|
||
ImGui::SameLine();
|
||
ImGui::PushStyleColor(ImGuiCol_Button, hex(kDanger));
|
||
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, hex(kDangerHover));
|
||
if (ImGui::Button("Close Frame Studio", ImVec2(150 * scale, 0))) {
|
||
// Now, not after the load: nothing of a home still loading can be lost, and waiting is what stalled.
|
||
std::fprintf(stderr, "[studio] closed by the user while loading\n");
|
||
std::fflush(stderr);
|
||
std::_Exit(0);
|
||
}
|
||
ImGui::PopStyleColor(2);
|
||
ImGui::EndPopup();
|
||
}
|
||
|
||
// A footprint (the room) gets its baked lighting, sky and outside world from a vista: add one to it.
|
||
void addVistaDialog() {
|
||
if (homePaths.empty()) return;
|
||
const std::vector<std::string> picked = platform::openFiles("Add a vista (e.g. Shell Env Vista Calming)", {{"Meta vista", {"*.apk"}}}, false);
|
||
if (picked.empty()) return;
|
||
carryAcrossReload(); // the same environment, with a vista now
|
||
dirty = false;
|
||
open({homePaths.front(), picked.front()}, false);
|
||
}
|
||
|
||
bool footprintAlone() const {
|
||
if (homePaths.size() != 1) return false;
|
||
std::string n = homePaths.front();
|
||
for (char& c : n) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
|
||
return n.find("footprint") != std::string::npos;
|
||
}
|
||
|
||
// Pick the footprint, or the footprint and its vista together (Ctrl-click both).
|
||
void openDialog() {
|
||
const std::vector<std::string> picked = platform::openFiles("Open a home (footprint + vista together), an environment (.fenv) or models",
|
||
{{"Homes, environments and models", {"*.apk", "*.fenv", "*.glb", "*.gltf", "*.fbx", "*.obj"}}, {"Meta home", {"*.apk"}},
|
||
{"Frame environment", {"*.fenv"}}, {"Model from Blender / Unity", {"*.glb", "*.gltf", "*.fbx", "*.obj"}}}, true);
|
||
if (picked.empty()) return;
|
||
open(picked);
|
||
}
|
||
|
||
// Main thread: upload the finished load and build the object list.
|
||
// Diagnostics (FS_BENCH_PICK=1): click a 6x4 grid over the viewport without touching the mouse, timing the
|
||
// pick and the selection highlight each click builds.
|
||
void benchPick() {
|
||
double worstPick = 0, worstSel = 0;
|
||
for (int gy = 0; gy < 4; ++gy)
|
||
for (int gx = 0; gx < 6; ++gx) {
|
||
const float x = viewPos.x + viewSize.x * (gx + 0.5f) / 6, y = viewPos.y + viewSize.y * (gy + 0.5f) / 4;
|
||
const auto a = std::chrono::steady_clock::now();
|
||
const int hit = pick(x, y);
|
||
const auto b = std::chrono::steady_clock::now();
|
||
renderer.setSelection(hit >= 0 ? static_cast<int>(objects[hit].mesh) : -1);
|
||
const auto c = std::chrono::steady_clock::now();
|
||
const double pm = std::chrono::duration<double, std::milli>(b - a).count(), sm = std::chrono::duration<double, std::milli>(c - b).count();
|
||
worstPick = std::max(worstPick, pm);
|
||
worstSel = std::max(worstSel, sm);
|
||
std::fprintf(stderr, "[bench] click (%d,%d) -> %s [%s]: pick %.1f ms, highlight %.1f ms\n", gx, gy,
|
||
hit >= 0 ? objects[hit].name.c_str() : "nothing",
|
||
hit >= 0 ? scene->meshes[objects[hit].scene].shaderPath.c_str() : "", pm, sm);
|
||
}
|
||
// Leave the biggest hit selected (highlight drawn every frame from now on) to exercise the overlay draw.
|
||
int best = -1;
|
||
size_t bestTris = 0;
|
||
for (int gy = 0; gy < 4; ++gy)
|
||
for (int gx = 0; gx < 6; ++gx) {
|
||
const int hit = pick(viewPos.x + viewSize.x * (gx + 0.5f) / 6, viewPos.y + viewSize.y * (gy + 0.5f) / 4);
|
||
if (hit < 0) continue;
|
||
const size_t t = scene->meshes[objects[hit].scene].indices.size();
|
||
if (t > bestTris) { bestTris = t; best = hit; }
|
||
}
|
||
selected = best;
|
||
std::fprintf(stderr, "[bench] leaving '%s' selected (%zu indices)\n", best >= 0 ? objects[best].label.c_str() : "nothing", bestTris);
|
||
std::fprintf(stderr, "[bench] worst pick %.1f ms, worst highlight %.1f ms\n", worstPick, worstSel);
|
||
}
|
||
|
||
void finishLoad() {
|
||
if (benchFrames > 0 && --benchFrames == 0) benchPick();
|
||
if (!saving && saver.joinable()) {
|
||
saver.join();
|
||
if (!savedEnvironment.empty()) { recentInfo.erase(savedEnvironment + "|"); addRecent({savedEnvironment}); savedEnvironment.clear(); }
|
||
if (saveFailed.exchange(false)) { dirty = true; updateTitle(); }
|
||
}
|
||
if (!saving && saveQueued && scene && !uploading) { saveQueued = false; saveToLibrary(); } // a Save asked for mid auto-save
|
||
if (scene && dirty && !saving && !uploading && loadState == 0 && std::chrono::steady_clock::now() - autosavedAt > std::chrono::minutes(5)) {
|
||
autosaveVersion();
|
||
autosavedAt = std::chrono::steady_clock::now();
|
||
}
|
||
if (loadState == 3) {
|
||
if (loader.joinable()) loader.join();
|
||
loadState = 0;
|
||
carry = Carry(); // (a reload that failed: its changes went with it)
|
||
platform::message("Couldn't open this home", loadError, platform::Icon::Warning);
|
||
return;
|
||
}
|
||
if (uploading) { // a few milliseconds of GPU upload per frame
|
||
if (!renderer.loadStep(12.0)) return;
|
||
uploading = false;
|
||
buildObjects();
|
||
renderer.requestAllTextures(); // everything at once: nothing streams in while moving
|
||
texLoading = renderer.texturesPending() > 0;
|
||
if (!texLoading && std::getenv("FS_BENCH_PICK")) benchFrames = 30;
|
||
return;
|
||
}
|
||
if (texLoading) {
|
||
if (renderer.texturesPending() == 0) {
|
||
texLoading = false;
|
||
if (std::getenv("FS_BENCH_PICK")) benchFrames = 30;
|
||
}
|
||
return;
|
||
}
|
||
if (loadState != 2) return;
|
||
if (loader.joinable()) loader.join();
|
||
loadState = 0;
|
||
renderer.unload();
|
||
scene = std::move(loaded);
|
||
windSign = 2; // read again off this home's floor
|
||
walkShown.reset(); walkReport = WalkReport{}; walkSig = 0; walkPending = walkSaveNext = false; walkGuideDirty = true;
|
||
objects.clear();
|
||
undo.clear();
|
||
redo.clear();
|
||
selected = -1;
|
||
wKind = wIndex = -1;
|
||
isolating = false;
|
||
worldEdited = false;
|
||
dirty = false;
|
||
homePaths = loadingPaths;
|
||
homeName = displayName(homePaths);
|
||
if (!carry.any) envFile = homePaths.size() == 1 && isFenvPath(homePaths[0]) ? homePaths[0] : std::string(); // a home or models: a new one
|
||
autosavedAt = std::chrono::steady_clock::now();
|
||
addRecent(homePaths);
|
||
renderer.setDynamicMeshes(scene->animatedMeshes);
|
||
renderer.beginLoad(*scene);
|
||
animTime = animBase = 0;
|
||
animPlaying = true;
|
||
animLength = scene->animDuration > 0 ? scene->animDuration : 60.0f; // no authored loop: a minute of shader time
|
||
sound = SoundState(); // (the saved one comes back with the edits)
|
||
restartSounds();
|
||
uploading = true;
|
||
cam.playerView();
|
||
updateTitle();
|
||
}
|
||
|
||
// Once every mesh is on the GPU: the editable object list, saved edits, and the view.
|
||
void buildObjects() {
|
||
if (renderer.meshCount() == 0)
|
||
platform::message("Frame Studio", "Nothing in this home could be drawn with its shaders.", platform::Icon::Warning);
|
||
for (size_t i = 0; i < renderer.meshCount(); ++i) {
|
||
const MeshData& md = scene->meshes[renderer.meshSceneIndex(i)];
|
||
Object o;
|
||
o.mesh = i;
|
||
o.scene = renderer.meshSceneIndex(i);
|
||
o.source = scene->meshSource[o.scene];
|
||
o.name = md.name.empty() ? "mesh " + std::to_string(o.scene) : md.name;
|
||
const std::string& prog = renderer.meshProgram(i);
|
||
size_t s = prog.find(".surface");
|
||
size_t b = s == std::string::npos ? std::string::npos : prog.rfind('/', s);
|
||
o.surface = s == std::string::npos ? prog : prog.substr(b == std::string::npos ? 0 : b + 1, s - (b == std::string::npos ? 0 : b + 1));
|
||
o.skybox = md.isSkybox;
|
||
o.triangles = md.indices.size() / 3;
|
||
o.category = categorize(o.name, o.surface, o.skybox);
|
||
o.label = cleanName(o.name);
|
||
o.folders.clear();
|
||
for (size_t a = 0; a <= md.path.size();) { // a template instance and its root often share
|
||
const size_t b = std::min(md.path.find('/', a), md.path.size()); // a name: one folder, not two
|
||
const std::string seg = md.path.substr(a, b - a);
|
||
if (!seg.empty() && (o.folders.empty() || o.folders.back() != seg)) o.folders.push_back(seg);
|
||
a = b + 1;
|
||
}
|
||
for (int k = 0; k < 3; ++k) { o.lmin[k] = 1e30f; o.lmax[k] = -1e30f; }
|
||
for (size_t v = 0; v + 2 < md.positions.size(); v += 3)
|
||
for (int k = 0; k < 3; ++k) {
|
||
o.lmin[k] = std::min(o.lmin[k], md.positions[v + k]);
|
||
o.lmax[k] = std::max(o.lmax[k], md.positions[v + k]);
|
||
}
|
||
// Gizmo frame at the object's visual centre (many homes bake world positions into the vertices).
|
||
const float* w = renderer.meshWorld(i);
|
||
float c[3] = {(o.lmin[0] + o.lmax[0]) * 0.5f, (o.lmin[1] + o.lmax[1]) * 0.5f, (o.lmin[2] + o.lmax[2]) * 0.5f}, wc[3];
|
||
point(w, c, wc);
|
||
translation(o.pivot0, wc[0], wc[1], wc[2]);
|
||
std::memcpy(o.pivot, o.pivot0, 64);
|
||
float inv[16];
|
||
translation(inv, -wc[0], -wc[1], -wc[2]);
|
||
mul(inv, w, o.offset);
|
||
objects.push_back(o);
|
||
}
|
||
loadEdits();
|
||
updateTitle();
|
||
}
|
||
|
||
// 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.
|
||
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;
|
||
for (const Object& o : objects) {
|
||
if (!o.deleted && std::memcmp(o.pivot, o.pivot0, 64) == 0) continue;
|
||
ObjectEdit x;
|
||
x.index = o.scene; x.name = o.name; x.deleted = o.deleted;
|
||
std::memcpy(x.pivot, o.pivot, 64);
|
||
mul(o.pivot, o.offset, x.world);
|
||
v.push_back(x);
|
||
}
|
||
return v;
|
||
}
|
||
void carryAcrossReload() {
|
||
if (!scene) return;
|
||
carry.any = true;
|
||
carry.objects = currentObjectEdits();
|
||
carry.world = worldToJson(scene->world);
|
||
carry.title = envTitle; carry.image = envImage;
|
||
carry.sound = sound;
|
||
carry.settings = worldSettings;
|
||
}
|
||
|
||
void loadEdits() {
|
||
std::vector<ObjectEdit> objs;
|
||
if (carry.any) { // the same environment, reloaded: as it was
|
||
HomeWorld w;
|
||
if (worldFromJson(carry.world, w)) { scene->world = std::move(w); worldEdited = true; }
|
||
prepareWorld();
|
||
objs = carry.objects;
|
||
envTitle = carry.title; envImage = carry.image;
|
||
worldSettings = carry.settings;
|
||
sound = carry.sound;
|
||
restartSounds();
|
||
carry = Carry();
|
||
dirty = true;
|
||
} else {
|
||
prepareWorld(); // the file's own world layer (or the home's)
|
||
HomeEdits fe;
|
||
const bool own = !scene->fenvEdits.empty() && loadHomeEdits(scene->fenvEdits, fe);
|
||
loadSound(own ? fe.sound : HomeSound(), own ? scene->fenvEdits : std::string());
|
||
envTitle = own ? fe.title : std::string();
|
||
envImage.clear();
|
||
worldSettings = WorldSettings();
|
||
FenvManifest m;
|
||
std::vector<uint8_t> thumb;
|
||
if (openHomeIsFenv() && fenvReadManifest(homePaths[0], m, &thumb)) {
|
||
worldSettings = m.settings;
|
||
if (envTitle.empty() && m.name != homeName) envTitle = m.name;
|
||
if (own && !fe.image.empty() && !thumb.empty()) { // its picture: the thumbnail it carries
|
||
std::error_code ec;
|
||
fs::create_directories(dataDir() / L"edits", ec);
|
||
std::ofstream out(dataDir() / L"edits" / fs::u8path(fe.image), std::ios::binary);
|
||
out.write(reinterpret_cast<const char*>(thumb.data()), static_cast<std::streamsize>(thumb.size()));
|
||
if (out) envImage = fe.image;
|
||
}
|
||
}
|
||
if (own) objs = fe.objects;
|
||
}
|
||
for (const ObjectEdit& x : objs)
|
||
for (Object& o : objects)
|
||
if (o.scene == x.index && o.name == x.name) {
|
||
std::memcpy(o.pivot, x.pivot, 64);
|
||
o.deleted = x.deleted;
|
||
apply(o);
|
||
}
|
||
unifySurfaces();
|
||
}
|
||
|
||
// Older saves split an object into a Floor part and a Wall part, and copied every triangle of the mesh: one
|
||
// simplified Surface now (the roles already read as Solid). An already simplified surface is left as it is.
|
||
void unifySurfaces() {
|
||
bool any = false;
|
||
for (const Object& o : objects) {
|
||
const std::vector<int> parts = surfaceParts(o);
|
||
if (parts.empty()) continue;
|
||
const bool split = parts.size() > 1 && surfaceMode(o) != kSurfTrigger;
|
||
const bool full = parts.size() == 1 && scene->world.colliders[parts[0]].indices.size() == scene->meshes[o.scene].indices.size() &&
|
||
scene->meshes[o.scene].indices.size() > 3000;
|
||
if (!split && !full) continue;
|
||
const std::string name = scene->world.colliders[parts[0]].name;
|
||
makeSurface(o, surfaceMode(o), autoMade(o));
|
||
if (!split) for (int p : surfaceParts(o)) scene->world.colliders[p].name = name; // (kept as you named it)
|
||
any = true;
|
||
}
|
||
if (any) worldEdited = true;
|
||
}
|
||
|
||
// Saving is saving the environment (File > Save): there's no other copy of its edits.
|
||
bool saveEdits() { saveToLibrary(); return true; }
|
||
|
||
// ── The background sound (soundPanel): any sound file, looped behind the home, here and in Frame Home ──
|
||
// It's saved beside the edits as "<home>.sound-<hash>.<ext>" (Send to Frame copies it beside the home, a .fenv
|
||
// packs it beside its edits.json), and the edits name it with its volume and the home's own ambience's.
|
||
|
||
void restartSounds() {
|
||
ambience.stop();
|
||
addedSound.stop();
|
||
if (!scene) return;
|
||
if (sound.s.homeVolume > 0 && !scene->ambientOgg.empty()) ambience.start(scene->ambientOgg.data(), scene->ambientOgg.size(), sound.s.homeVolume);
|
||
if (sound.bytes && !sound.bytes->empty()) addedSound.start(sound.bytes->data(), sound.bytes->size(), sound.s.volume, false);
|
||
}
|
||
|
||
void loadSound(const HomeSound& s, const std::string& editsPath) {
|
||
sound = SoundState();
|
||
sound.s = s;
|
||
const std::string path = soundPathFor(editsPath, s);
|
||
sound.s.file.clear();
|
||
if (!path.empty()) {
|
||
std::ifstream f(fs::path(widen(path)), std::ios::binary);
|
||
auto b = std::make_shared<std::vector<uint8_t>>((std::istreambuf_iterator<char>(f)), std::istreambuf_iterator<char>());
|
||
if (b->empty()) std::fprintf(stderr, "[studio] the background sound %s is missing\n", path.c_str());
|
||
else { sound.bytes = std::move(b); sound.s.file = s.file; sound.name = s.name.empty() ? s.file : s.name; }
|
||
}
|
||
restartSounds();
|
||
}
|
||
|
||
// Writes the sound beside Studio's edits (once: its name carries its hash) and clears out this home's old ones.
|
||
bool saveSoundFile() {
|
||
const fs::path dir = editsFile().parent_path();
|
||
const std::wstring prefix = widen(frameName(homePaths.front()) + ".sound-");
|
||
std::error_code ec;
|
||
for (const auto& f : fs::directory_iterator(dir, ec)) {
|
||
const std::wstring n = f.path().filename().wstring();
|
||
if (n.rfind(prefix, 0) == 0 && n != widen(sound.s.file)) fs::remove(f.path(), ec);
|
||
}
|
||
if (!sound.bytes) return true;
|
||
const fs::path out = dir / widen(sound.s.file);
|
||
if (fs::exists(out, ec) && fs::file_size(out, ec) == sound.bytes->size()) return true;
|
||
std::ofstream f(out, std::ios::binary);
|
||
f.write(reinterpret_cast<const char*>(sound.bytes->data()), static_cast<std::streamsize>(sound.bytes->size()));
|
||
return static_cast<bool>(f);
|
||
}
|
||
|
||
void pushSoundUndo(const SoundState& before) {
|
||
UndoStep u = {};
|
||
u.group = undoGroup;
|
||
u.sound = std::make_shared<SoundState>(before);
|
||
undo.push_back(u);
|
||
redo.clear();
|
||
dirty = true;
|
||
updateTitle();
|
||
}
|
||
|
||
// A sound file chosen (or dropped on the window) becomes the background sound.
|
||
void addSoundFile(const std::string& path) {
|
||
if (!scene) return;
|
||
const fs::path fp = fs::path(widen(path));
|
||
std::error_code ec;
|
||
const auto size = fs::file_size(fp, ec);
|
||
if (ec || size == 0 || size > (512ull << 20)) {
|
||
platform::message("Frame Studio", "Couldn't read " + narrow(fp.filename().wstring()) + (size > (512ull << 20) ? " (over 512 MB)." : "."), platform::Icon::Warning);
|
||
return;
|
||
}
|
||
std::ifstream f(fp, std::ios::binary);
|
||
auto bytes = std::make_shared<std::vector<uint8_t>>((std::istreambuf_iterator<char>(f)), std::istreambuf_iterator<char>());
|
||
std::vector<short> pcm;
|
||
int chs = 0, sr = 0;
|
||
const char* fmt = "sound";
|
||
if (bytes->empty() || !AudioPlayer::decode(bytes->data(), bytes->size(), pcm, chs, sr, &fmt)) {
|
||
platform::message("Frame Studio", narrow(fp.filename().wstring()) + " isn't a sound Frame Studio can play.\n\nIt takes Ogg (Vorbis or Opus), WAV, MP3 and FLAC files.",
|
||
platform::Icon::Warning);
|
||
return;
|
||
}
|
||
uint32_t h = 2166136261u; // FNV-1a: a new file gets a new name (the Frame sees it change)
|
||
for (uint8_t b : *bytes) h = (h ^ b) * 16777619u;
|
||
std::string ext = narrow(fp.extension().wstring());
|
||
for (char& c : ext) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
|
||
char hex[16];
|
||
std::snprintf(hex, sizeof hex, "%08x", h);
|
||
const SoundState before = sound;
|
||
sound.bytes = std::move(bytes);
|
||
sound.name = narrow(fp.filename().wstring());
|
||
sound.s.name = sound.name;
|
||
sound.s.file = frameName(homePaths.front()) + ".sound-" + hex + ext;
|
||
pushSoundUndo(before);
|
||
addedSound.stop();
|
||
addedSound.gain = sound.s.volume;
|
||
addedSound.startPCM(pcm.data(), pcm.size() / chs, chs, sr, false);
|
||
const int secs = static_cast<int>(pcm.size() / chs / std::max(1, sr));
|
||
char msg[300];
|
||
std::snprintf(msg, sizeof msg, "Background sound: %s (%s, %d:%02d)", sound.name.c_str(), fmt, secs / 60, secs % 60);
|
||
setStatus(msg);
|
||
}
|
||
|
||
void chooseSound() {
|
||
const std::vector<std::string> picked = platform::openFiles("Background sound for this environment",
|
||
{{"Sounds", {"*.mp3", "*.ogg", "*.oga", "*.opus", "*.wav", "*.flac"}}}, false);
|
||
if (!picked.empty()) addSoundFile(picked.front());
|
||
}
|
||
|
||
// In ENVIRONMENT: how loud the home's own ambience plays, and a background sound of your own.
|
||
void soundPanel() {
|
||
const SoundState before = sound;
|
||
auto percent = [&](const char* id, float& v, const char* on, const char* off, const char* tip, AudioPlayer& p) {
|
||
int pc = static_cast<int>(std::lround(v * 100));
|
||
ImGui::SetNextItemWidth(-1);
|
||
const bool changed = ImGui::SliderInt(id, &pc, 0, 100, pc ? on : off);
|
||
if (ImGui::IsItemActivated()) pushSoundUndo(before); // a drag is one undo step
|
||
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", tip);
|
||
if (!changed) return;
|
||
const bool wasOff = v <= 0;
|
||
v = pc / 100.0f;
|
||
if (wasOff && v > 0 && !p.ok) restartSounds(); // switched back on: decode it again
|
||
else p.gain = v;
|
||
dirty = true;
|
||
updateTitle();
|
||
};
|
||
if (!scene->ambientOgg.empty())
|
||
percent("##homevolume", sound.s.homeVolume, "Home's own sound %d%%", "Home's own sound: off",
|
||
"How loud the home's own ambience plays (in Frame Home too). Off: only your background sound.", ambience);
|
||
ImGui::Spacing();
|
||
if (sound.bytes) {
|
||
ImGui::PushStyleColor(ImGuiCol_Text, hex(kOrange));
|
||
ImGui::TextWrapped("♪ %s", sound.name.c_str());
|
||
ImGui::PopStyleColor();
|
||
if (!addedSound.ok && ImGui::IsItemHovered()) ImGui::SetTooltip("It isn't playing here (no audio device?): see studio.log");
|
||
percent("##soundvolume", sound.s.volume, "Background sound %d%%", "Background sound: silent",
|
||
"How loud your background sound plays, here and in Frame Home.", addedSound);
|
||
const float w = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f;
|
||
if (ImGui::Button("Replace…", ImVec2(w, 0))) chooseSound();
|
||
ImGui::SameLine();
|
||
if (ImGui::Button("Remove", ImVec2(w, 0))) {
|
||
pushSoundUndo(before);
|
||
sound.bytes.reset();
|
||
sound.s.file.clear();
|
||
sound.s.name.clear();
|
||
sound.name.clear();
|
||
addedSound.stop();
|
||
setStatus("Background sound removed");
|
||
}
|
||
} else {
|
||
if (ImGui::Button("Add Background Sound…", ImVec2(-1, 0))) chooseSound();
|
||
ImGui::PushTextWrapPos(0);
|
||
ImGui::TextDisabled("Loops behind the home, on the Frame too. Ogg, Opus, WAV, MP3 or FLAC — or drop one on the window.");
|
||
ImGui::PopTextWrapPos();
|
||
}
|
||
}
|
||
|
||
void closeHome() {
|
||
// Not mid-load: the upload steps in finishLoad still use the scene.
|
||
if (!scene || saving || uploading || texLoading || loadState == 1 || !confirmDiscard()) return;
|
||
if (loader.joinable() && loadState != 1) loader.join();
|
||
vkDeviceWaitIdle(renderer.device());
|
||
renderer.unload();
|
||
ambience.stop();
|
||
addedSound.stop();
|
||
sound = SoundState();
|
||
scene.reset();
|
||
walkShown.reset(); walkReport = WalkReport{}; walkSig = 0; walkPending = walkSaveNext = false;
|
||
renderer.setStaticGuide({}); walkGuideOn = false;
|
||
objects.clear();
|
||
undo.clear();
|
||
redo.clear();
|
||
homePaths.clear();
|
||
homeName.clear();
|
||
selected = -1;
|
||
wKind = wIndex = -1;
|
||
dirty = worldEdited = false;
|
||
updateTitle();
|
||
}
|
||
|
||
// The start screen (nothing open): open a home, an environment or models, or pick up a recent one.
|
||
struct RecentInfo { std::string title, kind; };
|
||
std::map<std::string, RecentInfo> recentInfo;
|
||
const RecentInfo& recentDetails(const std::vector<std::string>& paths) {
|
||
std::string key;
|
||
for (const auto& p : paths) key += p + "|";
|
||
auto it = recentInfo.find(key);
|
||
if (it != recentInfo.end()) return it->second;
|
||
RecentInfo info;
|
||
info.title = displayName(paths);
|
||
if (paths.size() == 1 && isFenvPath(paths[0])) {
|
||
FenvManifest m;
|
||
if (fenvReadManifest(paths[0], m)) {
|
||
info.title = m.name.empty() ? info.title : m.name;
|
||
info.kind = "Frame environment" + std::string(m.scene.empty() ? "" : " \u00b7 baked") + (m.world.empty() ? "" : " \u00b7 world layer") +
|
||
(m.models.empty() ? "" : " \u00b7 " + std::to_string(m.models.size()) + " models");
|
||
} else info.kind = "Frame environment (missing)";
|
||
} else {
|
||
size_t models = 0;
|
||
for (const auto& p : paths) models += isModelPath(p);
|
||
info.kind = models == paths.size() ? "Models" : paths.size() - models > 1 ? "Footprint + vista" : "Home APK";
|
||
if (models && models < paths.size()) info.kind += " + " + std::to_string(models) + " models";
|
||
}
|
||
std::error_code ec;
|
||
for (const auto& p : paths) if (!fs::exists(fs::path(widen(p)), ec)) { info.kind += " \u00b7 file missing"; break; }
|
||
return recentInfo[key] = info;
|
||
}
|
||
|
||
// A row on the start screen: a tile with the kind's initial, the name, and what it is. True when clicked.
|
||
bool startCard(ImDrawList* dl, const char* id, const ImVec2& a, float w, float h, const RecentInfo& info) {
|
||
const ImVec2 b(a.x + w, a.y + h);
|
||
ImGui::SetCursorScreenPos(a);
|
||
const bool clicked = ImGui::InvisibleButton(id, ImVec2(w, h));
|
||
const bool hov = ImGui::IsItemHovered(), held = ImGui::IsItemActive();
|
||
dl->AddRectFilled(a, b, col(held ? 0x33363c : hov ? 0x34373d : 0x2a2c30), 8 * scale);
|
||
dl->AddRect(a, b, col(hov ? kBlue : kLine, hov ? 0.85f : 0.6f), 8 * scale, 0, 1.0f);
|
||
// The tile: green for a Frame environment, blue for a Meta home, violet for models, grey when a file is gone.
|
||
const bool missing = info.kind.find("missing") != std::string::npos;
|
||
const bool env = info.kind.rfind("Frame environment", 0) == 0, models = info.kind.rfind("Models", 0) == 0;
|
||
const uint32_t tint = missing ? 0x6b6f76 : env ? 0x3f9a5c : models ? 0x8a63d2 : 0x3f7bd6;
|
||
const char* letter = env ? "E" : models ? "M" : "H";
|
||
const float t = h - 16 * scale;
|
||
const ImVec2 t0(a.x + 8 * scale, a.y + 8 * scale), t1(t0.x + t, t0.y + t);
|
||
dl->AddRectFilled(t0, t1, col(tint, 0.22f), 6 * scale);
|
||
ImFont* bf = fontOr(g_fontBold);
|
||
const ImVec2 ls = bf->CalcTextSizeA(bf->FontSize, FLT_MAX, 0, letter);
|
||
dl->AddText(bf, bf->FontSize, ImVec2((t0.x + t1.x - ls.x) * 0.5f, (t0.y + t1.y - ls.y) * 0.5f), col(tint == 0x6b6f76 ? kDim : 0xffffff, 0.9f), letter);
|
||
const float tx = t1.x + 12 * scale, lh = ImGui::GetTextLineHeight();
|
||
const float ty = a.y + (h - 2 * lh - 2 * scale) * 0.5f;
|
||
dl->PushClipRect(a, ImVec2(b.x - 28 * scale, b.y), true);
|
||
dl->AddText(ImVec2(tx, ty), col(missing ? kDim : kText), info.title.c_str());
|
||
dl->AddText(ImVec2(tx, ty + lh + 2 * scale), col(kDim), info.kind.c_str());
|
||
dl->PopClipRect();
|
||
if (hov) dl->AddText(ImVec2(b.x - 22 * scale, a.y + (h - lh) * 0.5f), col(kDim), "\u203a");
|
||
return clicked;
|
||
}
|
||
|
||
void startScreen() {
|
||
// A quieter backdrop than the 3D view's grey, so the cards read as the screen's content.
|
||
ImDrawList* dl = ImGui::GetWindowDrawList();
|
||
dl->AddRectFilled(viewPos, ImVec2(viewPos.x + viewSize.x, viewPos.y + viewSize.y), col(0x25272b));
|
||
const float w = std::min(viewSize.x - 80 * scale, 720 * scale), x0 = viewPos.x + (viewSize.x - w) * 0.5f;
|
||
float y = viewPos.y + std::max(36 * scale, viewSize.y * 0.10f);
|
||
ImFont* tf = fontOr(g_fontTitle);
|
||
const float titleSize = g_fontTitle ? tf->FontSize : ImGui::GetFontSize() * 2.0f;
|
||
dl->AddText(tf, titleSize, ImVec2(x0, y), col(kText), "Frame Studio");
|
||
{ // the brand mark: a small orange bar under the title
|
||
const float tw = tf->CalcTextSizeA(titleSize, FLT_MAX, 0, "Frame Studio").x;
|
||
dl->AddRectFilled(ImVec2(x0 + 1 * scale, y + titleSize * 1.12f), ImVec2(x0 + std::min(tw, 44 * scale), y + titleSize * 1.12f + 3 * scale), col(kOrange), 2 * scale);
|
||
}
|
||
y += titleSize * 1.12f + 14 * scale;
|
||
dl->AddText(ImVec2(x0, y), col(kDim), "Open a Meta home (HSL, Clay or glTF), a Frame environment, or models from Blender / Unity.");
|
||
y += ImGui::GetTextLineHeight() + 22 * scale;
|
||
ImGui::SetCursorScreenPos(ImVec2(x0, y));
|
||
const float bigH = ImGui::GetFrameHeight() * 1.7f;
|
||
const ImVec2 big(w * 0.62f, bigH), side(w - big.x - 10 * scale, bigH);
|
||
pushPrimary();
|
||
if (ImGui::Button("Open Home, Environment or Model\u2026", big)) openDialog();
|
||
ImGui::PopStyleColor(3);
|
||
if (ImGui::IsItemHovered()) ImGui::SetTooltip("Ctrl+O \u00b7 or drop files on the window");
|
||
ImGui::SameLine(0, 10 * scale);
|
||
if (ImGui::Button("Controls (F1)", side)) showControls = true;
|
||
y += big.y + 30 * scale;
|
||
const float rowH = ImGui::GetTextLineHeight() * 2.0f + 22 * scale;
|
||
auto listTitle = [&](const char* t) {
|
||
ImGui::SetCursorScreenPos(ImVec2(x0, y));
|
||
caption(t);
|
||
};
|
||
{ // My Environments: what you've made, newest first
|
||
listTitle("MY ENVIRONMENTS");
|
||
const char* folder = "Show the Folder";
|
||
ImGui::SetCursorScreenPos(ImVec2(x0 + w - ImGui::CalcTextSize(folder).x - ImGui::GetStyle().FramePadding.x * 2, y - 1 * scale));
|
||
if (ImGui::SmallButton(folder)) platform::openPath(narrow(fs::path(libraryDir().c_str()).wstring()));
|
||
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", narrow(libraryDir().wstring()).c_str());
|
||
y += ImGui::GetTextLineHeight() + 10 * scale;
|
||
static std::vector<std::string> mine;
|
||
static auto listed = std::chrono::steady_clock::time_point();
|
||
if (std::chrono::steady_clock::now() - listed > std::chrono::seconds(2)) { mine = libraryEnvs(); listed = std::chrono::steady_clock::now(); }
|
||
if (mine.empty()) {
|
||
dl->AddText(ImVec2(x0, y), col(0x6b6f76), "Nothing yet. Open a home, make it yours, and Save (Ctrl+S): it's kept here.");
|
||
y += ImGui::GetTextLineHeight() + 22 * scale;
|
||
}
|
||
for (size_t r = 0; r < mine.size() && r < 6; ++r) {
|
||
const RecentInfo& info = recentDetails({mine[r]});
|
||
ImGui::PushID(static_cast<int>(r) + 7000);
|
||
const bool clicked = startCard(dl, "##mine", ImVec2(x0, y), w, rowH, info);
|
||
const bool hov = ImGui::IsItemHovered();
|
||
ImGui::PopID();
|
||
if (hov) ImGui::SetTooltip("%s", mine[r].c_str());
|
||
if (clicked) { open({mine[r]}); return; }
|
||
y += rowH + 6 * scale;
|
||
}
|
||
y += 18 * scale;
|
||
}
|
||
listTitle("RECENT");
|
||
y += ImGui::GetTextLineHeight() + 10 * scale;
|
||
if (recent.empty()) {
|
||
dl->AddText(ImVec2(x0, y), col(0x6b6f76), "Nothing yet. Homes and environments you open show up here.");
|
||
return;
|
||
}
|
||
for (size_t r = 0; r < recent.size(); ++r) {
|
||
const RecentInfo& info = recentDetails(recent[r]);
|
||
ImGui::PushID(static_cast<int>(r) + 9000);
|
||
const bool clicked = startCard(dl, "##recent", ImVec2(x0, y), w, rowH, info);
|
||
const bool hov = ImGui::IsItemHovered();
|
||
ImGui::PopID();
|
||
if (hov) {
|
||
std::string tip;
|
||
for (const auto& p : recent[r]) tip += (tip.empty() ? "" : "\n") + p;
|
||
ImGui::SetTooltip("%s", tip.c_str());
|
||
}
|
||
if (clicked) { const auto paths = recent[r]; open(paths); }
|
||
y += rowH + 6 * scale;
|
||
if (y > viewPos.y + viewSize.y - rowH) break;
|
||
}
|
||
}
|
||
|
||
// Adds Blender / Unity models to the open home (it reloads with them; edits are kept by index).
|
||
void importModelDialog() {
|
||
if (!scene) return;
|
||
if (homePaths.size() == 1 && isFenvPath(homePaths[0])) {
|
||
platform::message("Frame Studio", "This environment is a saved .fenv. Open its original home to add models, or add models to a new environment.");
|
||
return;
|
||
}
|
||
const std::vector<std::string> picked = platform::openFiles("Add models from Blender or Unity", {{"Models", {"*.glb", "*.gltf", "*.fbx", "*.obj"}}}, true);
|
||
if (picked.empty()) return;
|
||
std::vector<std::string> paths = homePaths;
|
||
paths.insert(paths.end(), picked.begin(), picked.end());
|
||
carryAcrossReload();
|
||
dirty = false; // (carried: nothing to ask about)
|
||
open(paths, false);
|
||
}
|
||
|
||
// File > Save (Ctrl+S): the environment, into its own file: an environment opened saves back into itself; a Meta
|
||
// home or models opened is a new one, saved into My Environments under its name the first time.
|
||
std::string envFile; // the .fenv it saves to ("" = new: named on its first save)
|
||
void saveToLibrary() {
|
||
if (saving) { saveQueued = true; return; } // after the one under way (an auto-save)
|
||
if (!scene || uploading) return;
|
||
if (envFile.empty()) {
|
||
std::wstring name = widen(shownName());
|
||
for (wchar_t& c : name) if (std::wcschr(L"<>:\"/\\|?*", c) || c < 32) c = L'-';
|
||
fs::path p = libraryDir() / (name + L".fenv");
|
||
for (int n = 2; fs::exists(p); ++n) p = libraryDir() / (name + L" " + std::to_wstring(n) + L".fenv");
|
||
envFile = narrow(p.wstring());
|
||
}
|
||
saveEnvironment(envFile);
|
||
dirty = false; // (back on if the save fails)
|
||
updateTitle();
|
||
}
|
||
// Auto-save: every 5 minutes with unsaved changes, a version of it in My Environments\Versions\<name>\, the last
|
||
// 10 kept. The environment's own file only changes when you save it.
|
||
bool saveQueued = false;
|
||
std::chrono::steady_clock::time_point autosavedAt;
|
||
fs::path versionsDir() const {
|
||
std::wstring name = widen(shownName());
|
||
for (wchar_t& c : name) if (std::wcschr(L"<>:\"/\\|?*", c) || c < 32) c = L'-';
|
||
return libraryDir() / L"Versions" / name;
|
||
}
|
||
std::vector<fs::path> versions() const {
|
||
std::vector<fs::path> v;
|
||
std::error_code ec;
|
||
for (const auto& e : fs::directory_iterator(versionsDir(), ec))
|
||
if (e.is_regular_file(ec) && e.path().extension() == L".fenv") v.push_back(e.path());
|
||
std::sort(v.rbegin(), v.rend()); // (named by time: newest first)
|
||
return v;
|
||
}
|
||
void autosaveVersion() {
|
||
std::error_code ec;
|
||
fs::create_directories(versionsDir(), ec);
|
||
const std::time_t t = std::time(nullptr);
|
||
const std::tm tm = platform::localTime(t);
|
||
wchar_t stamp[64];
|
||
std::wcsftime(stamp, 64, L"%Y-%m-%d %H-%M-%S", &tm);
|
||
saveEnvironment(narrow((versionsDir() / (std::wstring(stamp) + L".fenv")).wstring()), false);
|
||
const std::vector<fs::path> v = versions();
|
||
for (size_t i = 10; i < v.size(); ++i) fs::remove(v[i], ec);
|
||
}
|
||
|
||
// The environments in My Environments, newest first.
|
||
std::vector<std::string> libraryEnvs() const {
|
||
std::vector<std::pair<fs::file_time_type, std::string>> v;
|
||
std::error_code ec;
|
||
for (const auto& e : fs::directory_iterator(libraryDir(), ec))
|
||
if (e.is_regular_file(ec) && e.path().extension() == L".fenv") v.push_back({e.last_write_time(ec), narrow(e.path().wstring())});
|
||
std::sort(v.begin(), v.end(), [](const auto& a, const auto& b) { return a.first > b.first; });
|
||
std::vector<std::string> out;
|
||
for (auto& x : v) out.push_back(std::move(x.second));
|
||
return out;
|
||
}
|
||
|
||
// File > Save As: the open home with its edits, world layer, models and a thumbnail, as one .fenv anywhere.
|
||
void saveEnvironmentDialog() {
|
||
if (!scene || saving || uploading) return;
|
||
std::string name = shownName();
|
||
for (char& c : name) if (c == '+' || c == '/' || c == '\\' || c == ':') c = '-';
|
||
const std::string path = platform::saveFile("Save as a Frame environment", name + ".fenv", {{"Frame environment", {"*.fenv"}}}, "fenv");
|
||
if (path.empty()) return;
|
||
envFile = path; // Save goes there from now on
|
||
saveEnvironment(envFile);
|
||
dirty = false;
|
||
updateTitle();
|
||
}
|
||
|
||
// remember: list it under Open Recent (not for the installer's own copies).
|
||
void saveEnvironment(const std::string& out, bool remember = true) {
|
||
// Everything the file needs is gathered and baked here, on the UI thread: the worker only packs and writes,
|
||
// from its own copies, so editing, loading another home or closing this one meanwhile can't pull the scene
|
||
// out from under it. Baking is a memory copy (well under a second); packing is the slow part.
|
||
HomeEdits e;
|
||
e.home = narrow(fs::path(widen(out)).filename().wstring());
|
||
e.objects = currentObjectEdits();
|
||
e.image = envImage;
|
||
e.sound = sound.s; // the background sound rides with the edits
|
||
e.title = envTitle;
|
||
const fs::path tmpEdits = dataDir() / L"edits" / L"_fenv_edits.json";
|
||
const bool hasEdits = !e.empty() && saveHomeEdits(narrow(tmpEdits.wstring()), e);
|
||
if (!e.empty() && !hasEdits) { // never report "Saved" for a file missing its edits
|
||
std::lock_guard<std::mutex> l(statusMutex);
|
||
saveStatus = "Couldn't save: the object edits couldn't be written to " + narrow(tmpEdits.wstring());
|
||
return;
|
||
}
|
||
std::vector<u8> thumb = imageThumb(); // the picture chosen for it, else the current view
|
||
if (thumb.empty()) { // the current view, 512 px wide
|
||
std::vector<u8> rgba;
|
||
const uint32_t w = renderer.width(), h = renderer.height();
|
||
if (rendered && w > 8 && h > 8 && renderer.readback(0, rgba) && rgba.size() >= static_cast<size_t>(w) * h * 4) {
|
||
const uint32_t tw = 512, th = std::max<uint32_t>(1, h * tw / w);
|
||
std::vector<u8> small(static_cast<size_t>(tw) * th * 4);
|
||
for (uint32_t y = 0; y < th; ++y)
|
||
for (uint32_t x = 0; x < tw; ++x) {
|
||
const u8* src = &rgba[(static_cast<size_t>(y * h / th) * w + x * w / tw) * 4];
|
||
u8* dst = &small[(static_cast<size_t>(y) * tw + x) * 4];
|
||
dst[0] = src[0]; dst[1] = src[1]; dst[2] = src[2]; dst[3] = 255;
|
||
}
|
||
stbi_write_png_to_func([](void* ctx, void* data, int size) {
|
||
auto* v = static_cast<std::vector<u8>*>(ctx);
|
||
v->insert(v->end(), static_cast<u8*>(data), static_cast<u8*>(data) + size);
|
||
}, &thumb, tw, th, 4, small.data(), tw * 4);
|
||
}
|
||
}
|
||
if (saver.joinable()) saver.join();
|
||
saving = true;
|
||
{ std::lock_guard<std::mutex> l(statusMutex); saveStatus = "Saving " + narrow(fs::path(widen(out)).filename().wstring()) + "\u2026"; }
|
||
const std::string name = shownName(), worldJson = worldToJson(scene->world), editsPath = hasEdits ? narrow(tmpEdits.wstring()) : std::string();
|
||
const WorldSettings settings = worldSettings;
|
||
const FenvPacking packing = fastPack ? FenvPacking::Fast : FenvPacking::Smallest;
|
||
std::string bakeErr;
|
||
std::vector<u8> blob;
|
||
// The mesh optimizer (what nobody can see from anywhere they can be is left out) takes a while: it runs on the
|
||
// worker, on a copy of the scene taken now, and bakes there; otherwise the bake is here (a memory copy).
|
||
std::shared_ptr<HslScene> toOptimize;
|
||
std::vector<ObjectEdit> objectEdits = e.objects;
|
||
bool baked = false;
|
||
if (optimizeMeshes) {
|
||
toOptimize = std::make_shared<HslScene>(*scene);
|
||
// A Clay / glTF home's animation: read here (this thread plays it), the copy keeps that and plays nothing
|
||
if (scene->saveAnimation) {
|
||
std::vector<u8> anim;
|
||
scene->saveAnimation(anim);
|
||
toOptimize->saveAnimation = [anim = std::move(anim)](std::vector<u8>& out) { out = anim; };
|
||
toOptimize->animate = nullptr;
|
||
}
|
||
baked = true;
|
||
} else {
|
||
baked = bakeScene(*scene, blob, bakeErr);
|
||
}
|
||
std::vector<std::string> apkPaths = scene->apkPaths;
|
||
std::vector<HslSourceInfo> sources = scene->sources;
|
||
std::vector<std::vector<std::string>> sourceFiles = scene->sourceFiles;
|
||
std::shared_ptr<const std::vector<uint8_t>> soundBytes = sound.bytes;
|
||
const std::string soundFile = sound.s.file;
|
||
saver = std::thread([this, out, name, worldJson, editsPath, settings, thumb, packing, remember, baked, soundBytes, soundFile, blob = std::move(blob),
|
||
apkPaths = std::move(apkPaths), sources = std::move(sources), sourceFiles = std::move(sourceFiles), toOptimize,
|
||
objectEdits = std::move(objectEdits)]() mutable {
|
||
if (toOptimize) {
|
||
{ std::lock_guard<std::mutex> l(statusMutex); saveStatus = "Saving: finding what can't be seen from anywhere you can be\u2026"; }
|
||
VisibilityResult vis;
|
||
BakeOptions opt;
|
||
animbake::toStart(*toOptimize); // its animated meshes as a load has them (the optimizer reads the vertices)
|
||
const bool optimized = optimizeForBake(*toOptimize, objectEdits.empty() ? nullptr : &objectEdits, vis, opt);
|
||
std::string err;
|
||
if (!bakeScene(*toOptimize, blob, err, optimized ? &opt : nullptr)) baked = false;
|
||
toOptimize.reset();
|
||
if (optimized)
|
||
std::fprintf(stderr, "save: %zu of %zu triangles can't be seen from anywhere you can be: left out (%zu meshes emptied, %.1f s)\n",
|
||
vis.trisBefore - vis.trisAfter, vis.trisBefore, vis.meshesEmptied, vis.ms / 1000);
|
||
}
|
||
FenvManifest m;
|
||
m.name = name;
|
||
m.settings = settings;
|
||
std::vector<FenvMember> members;
|
||
std::string err;
|
||
if (baked) { FenvMember b; b.name = m.scene = "scene.bin"; b.bytes = std::move(blob); b.lzma = true; b.packing = packing; members.push_back(std::move(b)); }
|
||
for (size_t i = 0; i < apkPaths.size(); ++i) {
|
||
const HslSourceInfo info = i < sources.size() ? sources[i] : HslSourceInfo{};
|
||
const std::string& path = apkPaths[i];
|
||
if (info.role == "Model") {
|
||
if (baked) continue;
|
||
const fs::path base = fs::u8path(path).parent_path();
|
||
const std::string dir = "models/" + fs::u8path(path).stem().u8string() + "/";
|
||
const std::vector<std::string> files = i < sourceFiles.size() && !sourceFiles[i].empty() ? sourceFiles[i] : std::vector<std::string>{path};
|
||
for (const std::string& f : files) {
|
||
std::error_code ec;
|
||
fs::path rel = fs::relative(fs::u8path(f), base, ec);
|
||
if (ec || rel.empty() || rel.u8string().rfind("..", 0) == 0) rel = fs::u8path(f).filename();
|
||
FenvMember mm; mm.name = dir + rel.generic_u8string(); mm.file = f;
|
||
if (fs::equivalent(fs::u8path(f), fs::u8path(path), ec)) m.models.push_back(mm.name);
|
||
members.push_back(mm);
|
||
}
|
||
continue;
|
||
}
|
||
FenvSource src;
|
||
src.file = "apks/" + fs::u8path(path).filename().u8string();
|
||
src.role = info.role; src.format = info.format; src.package = info.package; src.version = info.version;
|
||
m.sources.push_back(src);
|
||
if (!baked) { FenvMember a; a.name = src.file; a.file = path; a.store = true; members.push_back(a); }
|
||
}
|
||
if (!baked) { // a baked scene carries the world layer itself (in binary): no second, text copy
|
||
FenvMember wj; wj.name = m.world = "world.json"; wj.bytes.assign(worldJson.begin(), worldJson.end()); members.push_back(std::move(wj));
|
||
}
|
||
if (!editsPath.empty()) { FenvMember ed; ed.name = m.edits = "edits.json"; ed.file = editsPath; members.push_back(ed); }
|
||
if (soundBytes && !soundFile.empty()) { // beside edits.json, which names it
|
||
FenvMember snd;
|
||
snd.name = soundFile;
|
||
snd.bytes = *soundBytes;
|
||
snd.store = soundFile.size() < 4 || soundFile.compare(soundFile.size() - 4, 4, ".wav") != 0; // already compressed
|
||
members.push_back(std::move(snd));
|
||
}
|
||
if (!thumb.empty()) { FenvMember t; t.name = m.thumbnail = "thumb.png"; t.bytes = thumb; t.store = true; members.push_back(std::move(t)); }
|
||
const std::string file = fs::u8path(out).filename().u8string();
|
||
std::atomic<int> shown{-1}; // called from the packing threads
|
||
const bool ok = fenvWrite(out, m, members, err, [&](size_t done, size_t total) {
|
||
const int pct = total ? static_cast<int>(done * 100 / total) : 0;
|
||
if (shown.exchange(pct) == pct) return;
|
||
std::lock_guard<std::mutex> l(statusMutex);
|
||
saveStatus = "Saving " + file + "\u2026 " + std::to_string(pct) + "%";
|
||
});
|
||
std::error_code ec;
|
||
const double mb = ok ? fs::file_size(fs::u8path(out), ec) / 1048576.0 : 0;
|
||
char msg[512];
|
||
if (ok) std::snprintf(msg, sizeof msg, "Saved %s (%.0f MB, %s)", fs::u8path(out).filename().u8string().c_str(), mb, baked ? "baked: opens fast" : "with its homes");
|
||
else std::snprintf(msg, sizeof msg, "Couldn't save: %s", err.c_str());
|
||
if (!ok) std::fprintf(stderr, "[studio] %s (%s)\n", msg, out.c_str());
|
||
{ std::lock_guard<std::mutex> l(statusMutex); saveStatus = msg; }
|
||
if (ok && remember) savedEnvironment = out;
|
||
if (!ok && remember) saveFailed = true;
|
||
saving = false;
|
||
});
|
||
}
|
||
|
||
// Headset > Send to Frame: the environment as it is now (saved or not), as one file, becomes the headset's current one.
|
||
void sendToFrame() {
|
||
if (!scene || sending || uploading) return;
|
||
sendAsOneFile();
|
||
}
|
||
|
||
// Bytes (a sound, a file of the home), saved to a file of your choice under `name`.
|
||
void saveSoundAs(const std::vector<uint8_t>& bytes, std::string name) {
|
||
for (char& c : name) if (std::strchr("<>:\"/\\|?*", c)) c = '-';
|
||
const std::string ext = narrow(fs::path(widen(name)).extension().wstring()); // ".ogg"
|
||
std::vector<platform::FileFilter> filters;
|
||
if (ext.size() > 1) filters.push_back({ext.substr(1) + " file", {"*" + ext}});
|
||
const std::string path = platform::saveFile("Save as", name, filters, ext.size() > 1 ? ext.substr(1) : std::string());
|
||
if (path.empty()) return;
|
||
std::ofstream f(fs::path(widen(path)), std::ios::binary);
|
||
f.write(reinterpret_cast<const char*>(bytes.data()), static_cast<std::streamsize>(bytes.size()));
|
||
setStatus(f ? "Saved " + narrow(fs::path(widen(path)).filename().wstring()) : "Couldn't save the sound");
|
||
}
|
||
|
||
// ── Its name and picture: what the headset's Environments panel shows for it (a .fenv's name and thumbnail). The
|
||
// picture is copied beside the edits as "<home>.image-<hash>.<ext>", like the background sound. ──
|
||
// ── Its own settings on the headset (world_settings.h), carried in the .fenv: by hand, or Calculate Best Settings
|
||
// (the world sent to the headset, walked there at candidate settings by `fenv-tool tune`, the best kept here). ──
|
||
WorldSettings worldSettings;
|
||
std::thread tuner;
|
||
std::atomic<bool> tuning{false}, stopTune{false};
|
||
std::string tuneStatus; // (statusMutex)
|
||
void setTuneStatus(const std::string& s) { std::lock_guard<std::mutex> l(statusMutex); tuneStatus = s; }
|
||
void calculateBestSettings() {
|
||
if (!scene || tuning || saving) return;
|
||
if (tuner.joinable()) tuner.join();
|
||
std::error_code ec;
|
||
fs::create_directories(dataDir() / L"install", ec);
|
||
const std::string fenv = narrow((dataDir() / L"install" / L"_tune.fenv").wstring());
|
||
fs::remove(fs::path(widen(fenv)), ec);
|
||
const WorldSettings keep = worldSettings;
|
||
worldSettings = WorldSettings(); // measured as the headset's settings alone
|
||
saveEnvironment(fenv, false);
|
||
worldSettings = keep;
|
||
tuning = true;
|
||
const std::string host8 = hostArg();
|
||
tuner = std::thread([this, fenv, host8] {
|
||
const std::wstring ssh = sshTool(L"ssh") + L"-o BatchMode=yes -o ConnectTimeout=8 " + widen(host8) + L" ";
|
||
const std::wstring scp = sshTool(L"scp") + L"-q -o BatchMode=yes -o ConnectTimeout=8 ";
|
||
setTuneStatus("Baking the world to measure…");
|
||
while (saving) std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||
const fs::path f = fs::path(widen(fenv));
|
||
std::error_code ec;
|
||
if (!fs::exists(f, ec)) { setTuneStatus("Couldn't bake the world to measure it"); tuning = false; return; }
|
||
std::string out;
|
||
setTuneStatus("Copying it to the headset\u2026");
|
||
int rc = run(ssh + L"\"mkdir -p /tmp/frame-tune && rm -f /tmp/frame-tune/log\"", L"", out);
|
||
if (rc != 0) { setTuneStatus("Can't reach the headset (" + host8 + "): " + lastLine(out)); tuning = false; return; }
|
||
out.clear();
|
||
rc = run(scp + L"\"" + f.filename().wstring() + L"\" " + widen(host8) + L":/tmp/frame-tune/tune.fenv", f.parent_path().wstring(), out);
|
||
fs::remove(f, ec);
|
||
if (rc != 0) { setTuneStatus("Copy failed: " + lastLine(out)); tuning = false; return; }
|
||
// Measured on the headset (a few minutes: each try is a walk through the world, the headset cooled first),
|
||
// in the background there, its last line read every few seconds.
|
||
out.clear();
|
||
rc = run(ssh + L"\"nohup sh -c 'frame-home/bin/fenv-tool tune --dry-run /tmp/frame-tune/tune.fenv > /tmp/frame-tune/log 2>&1; echo @@done >> /tmp/frame-tune/log' > /dev/null 2>&1 &\"", L"", out);
|
||
if (rc != 0) { setTuneStatus("Couldn't start measuring: " + lastLine(out)); tuning = false; return; }
|
||
std::string log;
|
||
for (int i = 0; i < 600 && !stopTune; ++i) { // (30 minutes at most)
|
||
std::this_thread::sleep_for(std::chrono::seconds(3));
|
||
log.clear();
|
||
if (run(ssh + L"\"cat /tmp/frame-tune/log\"", L"", log) != 0) continue;
|
||
if (log.find("@@done") != std::string::npos) break;
|
||
const size_t t = log.rfind("tune: ");
|
||
if (t != std::string::npos) setTuneStatus("On the headset: " + log.substr(t + 6, log.find('\n', t) - t - 6));
|
||
}
|
||
out.clear();
|
||
run(ssh + L"\"rm -rf /tmp/frame-tune\"", L"", out);
|
||
const size_t j = log.rfind("{\"settings\":");
|
||
WorldSettings w;
|
||
bool ok = false;
|
||
if (j != std::string::npos) {
|
||
try {
|
||
const tinyjson::Value v = tinyjson::parse(log.substr(j, log.find('\n', j) - j));
|
||
ok = w.fromValue(v["settings"]);
|
||
} catch (...) {}
|
||
}
|
||
if (!ok) {
|
||
setTuneStatus(log.find("usage:") != std::string::npos ? "The headset's Frame Home is too old to measure: install it again (Install on Steam Frame)"
|
||
: "Measuring didn't finish: " + lastLine(log));
|
||
tuning = false;
|
||
return;
|
||
}
|
||
{
|
||
std::lock_guard<std::mutex> l(statusMutex);
|
||
tunedResult = w;
|
||
tunedReady = true;
|
||
tuneStatus = "Best on the headset: " + w.describe() + (w.tuned.empty() ? "" : " \u00b7 " + w.tuned);
|
||
}
|
||
tuning = false;
|
||
});
|
||
}
|
||
WorldSettings tunedResult; // (statusMutex) taken up by the UI thread
|
||
bool tunedReady = false;
|
||
|
||
std::string envTitle; // "" = the home's own name
|
||
std::string envImage; // file name in the edits folder ("" = a view of it, taken when saved)
|
||
std::string shownName() const { return envTitle.empty() ? homeName : envTitle; }
|
||
void chooseImage() {
|
||
const std::vector<std::string> picked = platform::openFiles("A picture for this environment",
|
||
{{"Pictures", {"*.png", "*.jpg", "*.jpeg", "*.bmp"}}}, false);
|
||
if (picked.empty()) return;
|
||
const fs::path src(widen(picked.front()));
|
||
std::ifstream in(src, std::ios::binary);
|
||
const std::vector<u8> bytes((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>());
|
||
int w = 0, h = 0, c = 0;
|
||
unsigned char* px = bytes.empty() ? nullptr : stbi_load_from_memory(bytes.data(), static_cast<int>(bytes.size()), &w, &h, &c, 4);
|
||
if (!px) { platform::message("Frame Studio", "That isn't a picture Frame Studio can read: " + narrow(src.filename().wstring()), platform::Icon::Warning); return; }
|
||
stbi_image_free(px);
|
||
uint32_t hsh = 2166136261u;
|
||
for (u8 b : bytes) hsh = (hsh ^ b) * 16777619u;
|
||
char hx[16];
|
||
std::snprintf(hx, sizeof hx, "%08x", hsh);
|
||
std::string ext = narrow(src.extension().wstring());
|
||
for (char& ch : ext) ch = static_cast<char>(std::tolower(static_cast<unsigned char>(ch)));
|
||
const std::string name = frameName(homePaths.front()) + ".image-" + hx + ext;
|
||
std::error_code ec;
|
||
fs::create_directories(editsFile().parent_path(), ec);
|
||
std::ofstream out(editsFile().parent_path() / fs::u8path(name), std::ios::binary);
|
||
out.write(reinterpret_cast<const char*>(bytes.data()), static_cast<std::streamsize>(bytes.size()));
|
||
if (!out) { platform::message("Frame Studio", "Couldn't copy the picture beside the edits.", platform::Icon::Warning); return; }
|
||
envImage = name;
|
||
dirty = true;
|
||
updateTitle();
|
||
}
|
||
// The picture as the .fenv's thumbnail: 512 px wide, PNG (empty: none chosen, or it doesn't read).
|
||
std::vector<u8> imageThumb() const {
|
||
std::vector<u8> png;
|
||
if (envImage.empty()) return png;
|
||
std::ifstream in(editsFile().parent_path() / fs::u8path(envImage), std::ios::binary);
|
||
const std::vector<u8> bytes((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>());
|
||
int w = 0, h = 0, c = 0;
|
||
unsigned char* px = bytes.empty() ? nullptr : stbi_load_from_memory(bytes.data(), static_cast<int>(bytes.size()), &w, &h, &c, 4);
|
||
if (!px) return png;
|
||
const int tw = std::min(512, w), th = std::max(1, h * tw / std::max(1, w));
|
||
std::vector<u8> small(static_cast<size_t>(tw) * th * 4);
|
||
for (int y = 0; y < th; ++y)
|
||
for (int x = 0; x < tw; ++x) { // box filter over the source pixels each covers
|
||
const int x0 = x * w / tw, x1 = std::max(x0 + 1, (x + 1) * w / tw), y0 = y * h / th, y1 = std::max(y0 + 1, (y + 1) * h / th);
|
||
uint32_t acc[4] = {}, n = 0;
|
||
for (int yy = y0; yy < y1; ++yy)
|
||
for (int xx = x0; xx < x1; ++xx, ++n)
|
||
for (int k = 0; k < 4; ++k) acc[k] += px[(static_cast<size_t>(yy) * w + xx) * 4 + k];
|
||
for (int k = 0; k < 4; ++k) small[(static_cast<size_t>(y) * tw + x) * 4 + k] = static_cast<u8>(k == 3 ? 255 : acc[k] / n);
|
||
}
|
||
stbi_image_free(px);
|
||
stbi_write_png_to_func([](void* ctx, void* data, int size) {
|
||
auto* v = static_cast<std::vector<u8>*>(ctx);
|
||
v->insert(v->end(), static_cast<u8*>(data), static_cast<u8*>(data) + size);
|
||
}, &png, tw, th, 4, small.data(), tw * 4);
|
||
return png;
|
||
}
|
||
|
||
// A footprint with its vista goes to the Frame as one home in one file: the pair baked into a .fenv with the
|
||
// edits, world layer and background sound (what Save Environment writes). The pair's separate copies an earlier
|
||
// Send left there go, so the headset lists it once.
|
||
void sendAsOneFile() {
|
||
if (saving) { setStatus("Wait for the environment being saved to finish, then send."); return; }
|
||
if (sender.joinable()) sender.join();
|
||
std::error_code ec;
|
||
fs::create_directories(dataDir() / L"install", ec);
|
||
const std::string fenv = narrow((dataDir() / L"install" / widen(frameName(homeName + ".fenv"))).wstring());
|
||
fs::remove(fs::path(widen(fenv)), ec); // a failed save must not send the last one
|
||
saveEnvironment(fenv, false);
|
||
sending = true;
|
||
std::vector<std::string> old; // the pair as separately sent before
|
||
for (const auto& p : homePaths) old.push_back(frameName(p));
|
||
const std::string editsName = frameName(homePaths.front()), host8 = hostArg();
|
||
sender = std::thread([this, fenv, old, editsName, host8] {
|
||
const std::wstring ssh = sshTool(L"ssh") + L"-o BatchMode=yes -o ConnectTimeout=8 ";
|
||
const std::wstring scp = sshTool(L"scp") + L"-q -o BatchMode=yes -o ConnectTimeout=8 ";
|
||
const std::wstring host = widen(host8), dir = widen(kFrameEnvDir);
|
||
setStatus("Baking the home and its vista into one file\u2026");
|
||
while (saving) std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||
const fs::path f = fs::path(widen(fenv));
|
||
if (!fs::exists(f)) { setStatus("Not sent: the home couldn't be saved as one file"); sending = false; return; }
|
||
const std::string name = frameName(fenv);
|
||
const std::wstring wname = widen(name);
|
||
std::string out;
|
||
setStatus("Connecting to the Frame\u2026");
|
||
int rc = run(ssh + host + L" \"mkdir -p " + dir + L"\"", L"", out);
|
||
if (rc != 0) { setStatus("Can't reach the Frame: " + lastLine(out)); sending = false; return; }
|
||
std::error_code ec2;
|
||
char msg[200];
|
||
std::snprintf(msg, sizeof msg, "Copying %s (%.0f MB)\u2026", name.c_str(), fs::file_size(f, ec2) / 1048576.0);
|
||
setStatus(msg);
|
||
out.clear();
|
||
rc = run(scp + L"\"" + f.filename().wstring() + L"\" " + host + L":" + dir + L"/" + wname + L".part", f.parent_path().wstring(), out);
|
||
if (rc == 0) rc = run(ssh + host + L" \"mv -f " + dir + L"/" + wname + L".part " + dir + L"/" + wname + L"\"", L"", out);
|
||
if (rc != 0) { setStatus("Copy failed: " + lastLine(out)); sending = false; return; }
|
||
std::wstring rm = L"rm -f";
|
||
for (const auto& o : old) if (o != name) rm += L" " + dir + L"/'" + widen(o) + L"'";
|
||
rm += L" " + dir + L"/'" + widen(editsName) + L".edits.json' " + dir + L"/'" + widen(editsName) + L".world.json' " + dir + L"/'" + widen(editsName) + L"'.sound-*";
|
||
out.clear();
|
||
run(ssh + host + L" \"" + rm + L"\"", L"", out);
|
||
out.clear();
|
||
rc = run(ssh + host + L" \"printf '" + wname + L"\\n' > frame-home/current\"", L"", out);
|
||
if (rc != 0) { setStatus("Couldn't set the current home: " + lastLine(out)); sending = false; return; }
|
||
setStatus("On the Frame: " + name + (old.size() > 1 ? " (the room and its vista, one file)" : " (one file)"));
|
||
sending = false;
|
||
});
|
||
}
|
||
|
||
// ── Editing ──
|
||
|
||
void apply(Object& o) {
|
||
float w[16];
|
||
mul(o.pivot, o.offset, w);
|
||
renderer.setMeshWorld(o.mesh, w);
|
||
renderer.setMeshVisible(o.mesh, objectShown(o));
|
||
followSurface(o);
|
||
}
|
||
|
||
bool objectShown(const Object& o) const {
|
||
if (o.hidden || o.deleted) return false;
|
||
if (isolating) return static_cast<int>(&o - objects.data()) == isoObject;
|
||
return showMeshes;
|
||
}
|
||
|
||
void refreshVisibility() {
|
||
for (const Object& o : objects) renderer.setMeshVisible(o.mesh, objectShown(o));
|
||
}
|
||
|
||
// View > Show Only Selected: the selected object or world item alone, until pressed again.
|
||
void toggleIsolate() {
|
||
if (isolating) isolating = false;
|
||
else if (selected >= 0) { isolating = true; isoObject = selected; isoKind = isoIndex = -1; }
|
||
else if (wKind >= 0 && wIndex >= 0) { isolating = true; isoObject = -1; isoKind = wKind; isoIndex = wIndex; }
|
||
refreshVisibility();
|
||
}
|
||
|
||
// ── Surfaces made from the home's objects (floors, walls, wall + floor) ──
|
||
// A mesh surface keeps the object's own triangles (the ones its role uses) in the object's frame, so it
|
||
// follows the object when moved.
|
||
|
||
static std::string objectKey(const Object& o) { return std::to_string(o.scene) + ":" + o.name; }
|
||
|
||
std::vector<int> surfaceParts(const Object& o) const {
|
||
std::vector<int> v;
|
||
if (!scene) return v;
|
||
const std::string key = objectKey(o);
|
||
const auto& cs = scene->world.colliders;
|
||
for (size_t i = 0; i < cs.size(); ++i)
|
||
if (cs[i].kind == WorldCollider::Mesh && cs[i].mesh == key) v.push_back(static_cast<int>(i));
|
||
return v;
|
||
}
|
||
|
||
int surfaceIndex(const Object& o) const { const std::vector<int> v = surfaceParts(o); return v.empty() ? -1 : v[0]; }
|
||
|
||
// The object's SurfaceMode, read off the roles of its parts.
|
||
int surfaceMode(const Object& o) const {
|
||
const std::vector<int> parts = surfaceParts(o);
|
||
if (parts.empty()) return kSurfNone;
|
||
for (int i : parts) if (scene->world.colliders[i].role == WorldCollider::Trigger) return kSurfTrigger;
|
||
for (int i : parts) if (scene->world.colliders[i].role == WorldCollider::Object) return kSurfObject;
|
||
return kSurfSolid;
|
||
}
|
||
|
||
bool autoMade(const Object& o) const {
|
||
for (int i : surfaceParts(o)) if (scene->world.colliders[i].name.rfind(kAutoPrefix, 0) == 0) return true;
|
||
return false;
|
||
}
|
||
|
||
// Opaque, solid, not a distant backdrop or the sky.
|
||
bool canBeSurface(const Object& o) const {
|
||
if (!scene || o.deleted || o.skybox || o.scene < 0 || o.scene >= static_cast<int>(scene->meshes.size())) return false;
|
||
const MeshData& md = scene->meshes[o.scene];
|
||
if (md.useBlend || md.additive || md.overlayKind || md.indices.empty() || md.positions.empty()) return false;
|
||
float mn[3], mx[3];
|
||
worldBounds(o, mn, mx);
|
||
return mx[0] - mn[0] < 200 && mx[1] - mn[1] < 200 && mx[2] - mn[2] < 200;
|
||
}
|
||
|
||
// Each triangle of an object in world space: f(offset into its index list, a, b, c, unit front normal, area).
|
||
template <typename F> void worldTris(const Object& o, F&& f) const {
|
||
const MeshData& md = scene->meshes[o.scene];
|
||
float m[16];
|
||
mul(o.pivot, o.offset, m);
|
||
const float det = m[0] * (m[5] * m[10] - m[6] * m[9]) - m[4] * (m[1] * m[10] - m[2] * m[9]) + m[8] * (m[1] * m[6] - m[2] * m[5]);
|
||
const float flip = det < 0 ? -1.0f : 1.0f; // a mirrored object winds the other way round
|
||
const size_t nv = md.positions.size() / 3;
|
||
for (size_t t = 0; t + 2 < md.indices.size(); t += 3) {
|
||
const u32 ia = md.indices[t], ib = md.indices[t + 1], ic = md.indices[t + 2];
|
||
if (ia >= nv || ib >= nv || ic >= nv) continue;
|
||
float a[3], b[3], c[3];
|
||
point(m, &md.positions[ia * 3], a); point(m, &md.positions[ib * 3], b); point(m, &md.positions[ic * 3], c);
|
||
const float e1[3] = {b[0] - a[0], b[1] - a[1], b[2] - a[2]}, e2[3] = {c[0] - a[0], c[1] - a[1], c[2] - a[2]};
|
||
float n[3] = {e1[1] * e2[2] - e1[2] * e2[1], e1[2] * e2[0] - e1[0] * e2[2], e1[0] * e2[1] - e1[1] * e2[0]};
|
||
const float L = std::sqrt(n[0] * n[0] + n[1] * n[1] + n[2] * n[2]);
|
||
if (L < 1e-12f) continue;
|
||
for (float& v : n) v *= flip / L;
|
||
f(t, a, b, c, n, 0.5f * L);
|
||
}
|
||
}
|
||
|
||
// Which way the home's triangles wind (Studio draws both sides, so nothing else says): +1 when (b-a)x(c-a)
|
||
// points out of a face's front, -1 the other way, 0 unknown (up and down faces then count alike). Read once per
|
||
// home off its main floor, the lowest big level surface: it is only ever seen from above. (The biggest one can
|
||
// be the ceiling, seen from below.)
|
||
int windSign = 2; // 2: not read yet
|
||
int windingSign() {
|
||
if (windSign != 2) return windSign;
|
||
if (!scene || objects.empty()) return 0;
|
||
std::map<int, std::pair<double, double>> levels; // 10 cm bins: area facing up, down
|
||
for (const Object& o : objects) {
|
||
if (o.hidden || !canBeSurface(o) || o.category == kPlants) continue;
|
||
worldTris(o, [&](size_t, const float* a, const float* b, const float* c, const float* n, float area) {
|
||
if (std::fabs(n[1]) < 0.95f) return;
|
||
auto& l = levels[static_cast<int>(std::floor((a[1] + b[1] + c[1]) / 3 / 0.1f))];
|
||
(n[1] > 0 ? l.first : l.second) += area;
|
||
});
|
||
}
|
||
double big = 0;
|
||
for (const auto& kv : levels) big = std::max(big, kv.second.first + kv.second.second);
|
||
windSign = 0;
|
||
for (const auto& kv : levels)
|
||
if (kv.second.first + kv.second.second >= big * 0.25) {
|
||
if (kv.second.first > 2 * kv.second.second) windSign = 1;
|
||
else if (kv.second.second > 2 * kv.second.first) windSign = -1;
|
||
break;
|
||
}
|
||
return windSign;
|
||
}
|
||
|
||
// Floor (1), wall (2) or neither (0, facing down) for a world-space front normal.
|
||
static int faceClass(const float* n, int sign) {
|
||
const float up = sign ? n[1] * sign : std::fabs(n[1]);
|
||
if (up > kFloorCos) return 1;
|
||
return std::fabs(n[1]) < kFloorCos ? 2 : 0;
|
||
}
|
||
|
||
void classifyObject(const Object& o, std::vector<u32>& floor, std::vector<u32>& wall) {
|
||
const int sign = windingSign();
|
||
worldTris(o, [&](size_t t, const float*, const float*, const float*, const float* n, float) {
|
||
const int k = faceClass(n, sign);
|
||
if (k == 1) floor.push_back(static_cast<u32>(t)); else if (k == 2) wall.push_back(static_cast<u32>(t));
|
||
});
|
||
}
|
||
|
||
// A linked mesh surface of some of an object's triangles (offsets into its index list), vertices compacted.
|
||
WorldCollider surfacePart(const Object& o, const std::vector<u32>& tris, int role, const std::string& name, bool simplify = true) const {
|
||
const MeshData& md = scene->meshes[o.scene];
|
||
WorldCollider c;
|
||
c.name = name;
|
||
c.kind = WorldCollider::Mesh;
|
||
c.role = role;
|
||
c.mesh = objectKey(o);
|
||
mul(o.pivot, o.offset, c.world);
|
||
std::vector<u32> remap(md.positions.size() / 3, UINT32_MAX);
|
||
c.indices.reserve(tris.size() * 3);
|
||
for (u32 t : tris)
|
||
for (int k = 0; k < 3; ++k) {
|
||
const u32 v = md.indices[t + k];
|
||
if (remap[v] == UINT32_MAX) { remap[v] = static_cast<u32>(c.positions.size() / 3); c.positions.insert(c.positions.end(), &md.positions[v * 3], &md.positions[v * 3] + 3); }
|
||
c.indices.push_back(remap[v]);
|
||
}
|
||
if (simplify) simplifySurfaceMesh(c.positions, c.indices, surfaceCellFor(c.world)); // for walking on, not for looking at
|
||
return c;
|
||
}
|
||
|
||
std::string partName(const Object& o, bool autoName, bool collider = false) const {
|
||
return autoName ? std::string(kAutoPrefix) + (collider ? "collider · " : "surface · ") + o.label : o.label;
|
||
}
|
||
|
||
// Gives an object its surface for `mode`, in place of any it had (kept at its place in the list). No undo step:
|
||
// callers take one for the whole batch.
|
||
void makeSurface(const Object& o, int mode, bool autoName = false) {
|
||
HomeWorld& w = scene->world;
|
||
const std::vector<int> old = surfaceParts(o);
|
||
bool hidden = false;
|
||
for (int i : old) hidden |= w.colliders[i].hidden;
|
||
const size_t at = old.empty() ? w.colliders.size() : static_cast<size_t>(old[0]);
|
||
for (auto it = old.rbegin(); it != old.rend(); ++it) w.colliders.erase(w.colliders.begin() + *it);
|
||
if (mode == kSurfNone) return;
|
||
std::vector<WorldCollider> parts;
|
||
std::vector<u32> all;
|
||
for (size_t t = 0; t + 2 < scene->meshes[o.scene].indices.size(); t += 3) all.push_back(static_cast<u32>(t));
|
||
const int role = mode == kSurfTrigger ? WorldCollider::Trigger : mode == kSurfObject ? WorldCollider::Object : WorldCollider::Solid;
|
||
parts.push_back(surfacePart(o, all, role, mode == kSurfTrigger ? o.label : partName(o, autoName, mode == kSurfObject)));
|
||
for (WorldCollider& c : parts) c.hidden = hidden;
|
||
w.colliders.insert(w.colliders.begin() + static_cast<std::ptrdiff_t>(std::min(at, w.colliders.size())), parts.begin(), parts.end());
|
||
}
|
||
|
||
void setSurfaces(const std::vector<size_t>& objs, int mode) {
|
||
if (!scene) return;
|
||
pushWorldUndo();
|
||
for (size_t i : objs)
|
||
if (mode == kSurfNone || canBeSurface(objects[i])) makeSurface(objects[i], mode);
|
||
worldReplaced();
|
||
}
|
||
|
||
// Every visible object that can be one (World > All Meshes).
|
||
std::vector<size_t> surfaceCandidates() const {
|
||
std::vector<size_t> v;
|
||
for (size_t i = 0; i < objects.size(); ++i)
|
||
if (!objects[i].hidden && canBeSurface(objects[i])) v.push_back(i);
|
||
return v;
|
||
}
|
||
|
||
void followSurface(const Object& o) {
|
||
if (!scene) return;
|
||
for (int at : surfaceParts(o)) mul(o.pivot, o.offset, scene->world.colliders[at].world);
|
||
if (const int f = drawnFloorIndex(o); f >= 0) mul(o.pivot, o.offset, scene->world.colliders[f].world);
|
||
}
|
||
|
||
// ── Draw Floor: a floor drawn on an object's faces, by hand (the brush) or all its flat faces at once. It's its own
|
||
// collider (Walkable, linked as "<object>#floor"): always floor, even on a table; the object still blocks. ──
|
||
float floorBrush = 0.3f; // the brush's radius (m)
|
||
bool painting = false; // a stroke is going (one undo step)
|
||
bool brushShown = false;
|
||
float brushAt[3] = {};
|
||
int drawnFloorIndex(const Object& o) const {
|
||
if (!scene) return -1;
|
||
const std::string key = objectKey(o) + "#floor";
|
||
for (size_t i = 0; i < scene->world.colliders.size(); ++i) if (scene->world.colliders[i].mesh == key) return static_cast<int>(i);
|
||
return -1;
|
||
}
|
||
// Which of the object's triangles its drawn floor has (matched by their centres in the object's own frame).
|
||
std::vector<char> drawnFloorTris(const Object& o) const {
|
||
const MeshData& md = scene->meshes[o.scene];
|
||
std::vector<char> on(md.indices.size() / 3, 0);
|
||
const int f = drawnFloorIndex(o);
|
||
if (f < 0) return on;
|
||
auto key = [](const float* a, const float* b, const float* c) {
|
||
uint64_t k = 1469598103934665603ull;
|
||
for (int i = 0; i < 3; ++i) { k ^= static_cast<uint64_t>(std::llround((a[i] + b[i] + c[i]) / 3 * 10000.0)); k *= 1099511628211ull; }
|
||
return k;
|
||
};
|
||
std::unordered_map<uint64_t, size_t> of;
|
||
const size_t nv = md.positions.size() / 3;
|
||
for (size_t t = 0; t + 2 < md.indices.size(); t += 3) {
|
||
if (md.indices[t] >= nv || md.indices[t + 1] >= nv || md.indices[t + 2] >= nv) continue;
|
||
of.emplace(key(&md.positions[md.indices[t] * 3], &md.positions[md.indices[t + 1] * 3], &md.positions[md.indices[t + 2] * 3]), t / 3);
|
||
}
|
||
const WorldCollider& c = scene->world.colliders[f];
|
||
const size_t cv = c.positions.size() / 3;
|
||
for (size_t t = 0; t + 2 < c.indices.size(); t += 3) {
|
||
if (c.indices[t] >= cv || c.indices[t + 1] >= cv || c.indices[t + 2] >= cv) continue;
|
||
auto it = of.find(key(&c.positions[c.indices[t] * 3], &c.positions[c.indices[t + 1] * 3], &c.positions[c.indices[t + 2] * 3]));
|
||
if (it != of.end()) on[it->second] = 1;
|
||
}
|
||
return on;
|
||
}
|
||
void writeDrawnFloor(const Object& o, const std::vector<char>& on) {
|
||
auto& cs = scene->world.colliders;
|
||
std::vector<u32> tris;
|
||
for (size_t t = 0; t < on.size(); ++t) if (on[t]) tris.push_back(static_cast<u32>(t * 3));
|
||
const int f = drawnFloorIndex(o);
|
||
if (tris.empty()) { if (f >= 0) cs.erase(cs.begin() + f); }
|
||
else {
|
||
WorldCollider c = surfacePart(o, tris, WorldCollider::Walkable, o.label + " \u00b7 drawn floor", false);
|
||
c.mesh = objectKey(o) + "#floor";
|
||
if (f >= 0) { c.hidden = cs[f].hidden; cs[f] = std::move(c); } else cs.push_back(std::move(c));
|
||
}
|
||
walkPending = true;
|
||
dirty = worldEdited = true;
|
||
}
|
||
// The brush: the object's faces facing up within the radius of the point (and the one under it) go in the
|
||
// drawn floor, or out of it (erase).
|
||
void paintFloor(size_t i, const float* at, float radius, bool erase, long hitTri) {
|
||
const Object& o = objects[i];
|
||
std::vector<char> on = drawnFloorTris(o);
|
||
bool changed = false;
|
||
worldTris(o, [&](size_t t, const float* a, const float* b, const float* c, const float* n, float) {
|
||
const size_t k = t / 3;
|
||
if (on[k] == !erase) return;
|
||
if (!erase && std::fabs(n[1]) < 0.5f) return; // a floor faces up (either winding)
|
||
const float m[3] = {(a[0] + b[0] + c[0]) / 3, (a[1] + b[1] + c[1]) / 3, (a[2] + b[2] + c[2]) / 3};
|
||
const float d = std::hypot(m[0] - at[0], m[2] - at[2]);
|
||
if (static_cast<long>(k) != hitTri && (d > radius || std::fabs(m[1] - at[1]) > std::max(radius, 0.3f))) return;
|
||
on[k] = !erase;
|
||
changed = true;
|
||
});
|
||
if (changed) writeDrawnFloor(o, on);
|
||
}
|
||
// All of it at once: every face flat enough to stand on (or none).
|
||
void fillDrawnFloor(size_t i, bool fill) {
|
||
const Object& o = objects[i];
|
||
std::vector<char> on(scene->meshes[o.scene].indices.size() / 3, 0);
|
||
if (fill) worldTris(o, [&](size_t t, const float*, const float*, const float*, const float* n, float) { on[t / 3] = std::fabs(n[1]) >= 0.866f; });
|
||
pushWorldUndo();
|
||
writeDrawnFloor(o, on);
|
||
}
|
||
|
||
// After a move / rotate / scale of an object: its surface keeps every triangle and follows it (followSurface);
|
||
// which parts are flat enough to walk on is the walkable area's to work out, so nothing changes here.
|
||
void transformDone(size_t) {}
|
||
|
||
// Floor and wall area of an object's surfaces (m²), for its panel and the viewport legend. Cached per object
|
||
// and layer state: a big mesh is a few hundred thousand triangles.
|
||
struct AreaCache { int obj = -1; size_t colliders = 0, tris = 0; float m[16] = {}; double floor = 0, wall = 0; } areaCache;
|
||
void surfaceAreas(size_t i, double& floor, double& wall) {
|
||
const std::vector<int> parts = surfaceParts(objects[i]);
|
||
size_t tris = 0;
|
||
for (int p : parts) tris += scene->world.colliders[p].indices.size();
|
||
AreaCache& ac = areaCache;
|
||
if (ac.obj == static_cast<int>(i) && ac.colliders == scene->world.colliders.size() && ac.tris == tris &&
|
||
(parts.empty() || std::memcmp(ac.m, scene->world.colliders[parts[0]].world, 64) == 0)) { floor = ac.floor; wall = ac.wall; return; }
|
||
ac = AreaCache{};
|
||
ac.obj = static_cast<int>(i); ac.colliders = scene->world.colliders.size(); ac.tris = tris;
|
||
if (!parts.empty()) std::memcpy(ac.m, scene->world.colliders[parts[0]].world, 64);
|
||
for (int p : parts) {
|
||
const WorldCollider& c = scene->world.colliders[p];
|
||
const std::vector<float> wp = worldMeshPositions(c);
|
||
const size_t nv = wp.size() / 3;
|
||
for (size_t t = 0; t + 2 < c.indices.size(); t += 3) {
|
||
const u32 ia = c.indices[t], ib = c.indices[t + 1], ic = c.indices[t + 2];
|
||
if (ia >= nv || ib >= nv || ic >= nv) continue;
|
||
const float* a = &wp[ia * 3]; const float* b = &wp[ib * 3]; const float* d = &wp[ic * 3];
|
||
const float e1[3] = {b[0] - a[0], b[1] - a[1], b[2] - a[2]}, e2[3] = {d[0] - a[0], d[1] - a[1], d[2] - a[2]};
|
||
const float n[3] = {e1[1] * e2[2] - e1[2] * e2[1], e1[2] * e2[0] - e1[0] * e2[2], e1[0] * e2[1] - e1[1] * e2[0]};
|
||
const float L = std::sqrt(n[0] * n[0] + n[1] * n[1] + n[2] * n[2]);
|
||
if (L < 1e-12f) continue;
|
||
// what Frame Home makes of it (WorldRuntime: floor parts above ~60 deg up, walls below ~45)
|
||
const float up = std::fabs(n[1]) / L;
|
||
const bool isFloor = c.role == WorldCollider::Floor ? up > 0.5f : c.role == WorldCollider::Solid && up > 0.7f;
|
||
const bool isWall = (c.role == WorldCollider::Wall || c.role == WorldCollider::Solid) && up < 0.7f;
|
||
if (isFloor) ac.floor += 0.5 * L; else if (isWall) ac.wall += 0.5 * L;
|
||
}
|
||
}
|
||
floor = ac.floor; wall = ac.wall;
|
||
}
|
||
|
||
// ── The walkable area (src/walkgrid.h) ──
|
||
// Frame Home walks one baked area: floors you can reach, furniture and walls in the way, seams bridged, the body's
|
||
// radius kept from every edge. The viewport shows it (green: walkable; blue: seams it bridged; orange: its edges;
|
||
// grey: floors you can't get to) and it follows every edit: objects moved or deleted, surfaces set, spawns placed.
|
||
// Until World > Auto-generate Walkable Area (or a change to its settings) saves it with the home, Studio shows what
|
||
// Frame Home bakes by itself when it loads the home; once saved (world.json) it's re-baked here after each edit,
|
||
// so Frame Home walks exactly what you see.
|
||
WalkParams walkEdit; // the settings: the saved area's, else the defaults Frame Home uses
|
||
std::shared_ptr<const WalkGrid> walkShown;
|
||
WalkReport walkReport;
|
||
uint64_t walkSig = 0;
|
||
std::chrono::steady_clock::time_point walkChanged;
|
||
bool walkPending = false, walkSaveNext = false, walkSliding = false, walkGuideDirty = true, walkGuideOn = false;
|
||
float walkBakeMs = 0;
|
||
size_t walkGuideVerts = 0; // --selftest: what the viewport draws of it
|
||
|
||
bool walkSaved() const { return scene && !scene->world.walkGrid.empty(); }
|
||
|
||
// Bakes the area from the objects as they are now (moved, deleted) and the world layer's surfaces and spawns.
|
||
// The home's drawn meshes as they stand now (moved, deleted), for World > Auto-generate's walls and seats.
|
||
std::vector<WalkSource> drawnNow() const {
|
||
std::vector<const float*> mw(scene->meshes.size(), nullptr);
|
||
std::vector<char> removed(scene->meshes.size(), 0);
|
||
std::vector<std::array<float, 16>> mats(objects.size());
|
||
for (size_t i = 0; i < objects.size(); ++i) {
|
||
const Object& o = objects[i];
|
||
if (o.scene < 0 || o.scene >= static_cast<int>(scene->meshes.size())) continue;
|
||
mul(o.pivot, o.offset, mats[i].data());
|
||
mw[o.scene] = mats[i].data();
|
||
removed[o.scene] = o.deleted;
|
||
}
|
||
return drawnWalkSources(*scene, scene->world, &mw, &removed, false); // (the matrices are copied in)
|
||
}
|
||
|
||
std::shared_ptr<const WalkGrid> bakeWalk(const WalkParams& p, WalkReport* rep) {
|
||
std::vector<const float*> mw(scene->meshes.size(), nullptr);
|
||
std::vector<char> removed(scene->meshes.size(), 0);
|
||
std::vector<std::array<float, 16>> mats(objects.size());
|
||
for (size_t i = 0; i < objects.size(); ++i) {
|
||
const Object& o = objects[i];
|
||
if (o.scene < 0 || o.scene >= static_cast<int>(scene->meshes.size())) continue;
|
||
mul(o.pivot, o.offset, mats[i].data());
|
||
mw[o.scene] = mats[i].data();
|
||
removed[o.scene] = o.deleted;
|
||
}
|
||
const auto t0 = std::chrono::steady_clock::now();
|
||
std::shared_ptr<const WalkGrid> g = bakeWalkGrid(*scene, scene->world, p, rep, &mw, &removed);
|
||
walkBakeMs = std::chrono::duration<float, std::milli>(std::chrono::steady_clock::now() - t0).count();
|
||
return g;
|
||
}
|
||
|
||
// Everything the area is made from: when it changes, the area is baked again.
|
||
uint64_t walkSignature() const {
|
||
uint64_t h = 1469598103934665603ull;
|
||
auto mix = [&](const void* p, size_t n) { const uint8_t* b = static_cast<const uint8_t*>(p); for (size_t i = 0; i < n; ++i) h = (h ^ b[i]) * 1099511628211ull; };
|
||
for (const Object& o : objects) { mix(o.pivot, 64); mix(&o.deleted, 1); }
|
||
const HomeWorld& w = scene->world;
|
||
for (const WorldCollider& c : w.colliders) {
|
||
mix(&c.kind, 4); mix(&c.role, 4); mix(c.world, 64); mix(c.half, 12); mix(&c.radius, 4); mix(&c.length, 4);
|
||
const size_t n[2] = {c.positions.size(), c.indices.size()};
|
||
mix(n, sizeof n); mix(c.mesh.data(), c.mesh.size());
|
||
}
|
||
for (const auto* v : {&w.spawns, &w.seats, &w.hotspots}) for (const WorldPoint& p : *v) { mix(p.pos, 12); mix(&p.yaw, 4); mix(p.exit, 12); }
|
||
const bool saved = !w.walkGrid.empty();
|
||
mix(&saved, 1);
|
||
mix(&walkEdit, sizeof walkEdit);
|
||
return h;
|
||
}
|
||
|
||
// Each frame: keep the area current (a moment after the last edit, never mid-drag) and in the viewport.
|
||
void updateWalk() {
|
||
if (!scene || objects.empty() || uploading) {
|
||
if (walkGuideOn) { renderer.setStaticGuide({}); walkGuideOn = false; }
|
||
return;
|
||
}
|
||
const auto now = std::chrono::steady_clock::now();
|
||
if (!walkSliding && !walkSaveNext) { // the settings follow the world: an undo can bring back a saved area, or none
|
||
WalkParams want;
|
||
if (walkSaved()) {
|
||
if (!scene->world.walkGridCache) scene->world.walkGridCache = decodeWalkGrid(scene->world.walkGrid);
|
||
if (scene->world.walkGridCache) want = scene->world.walkGridCache->p;
|
||
}
|
||
if (std::memcmp(&want, &walkEdit, sizeof want) != 0) walkEdit = want;
|
||
}
|
||
const uint64_t sig = walkSignature();
|
||
if (sig != walkSig) { walkSig = sig; walkPending = true; walkChanged = now; }
|
||
if (walkPending && !walkSliding && !ImGuizmo::IsUsingAny() && (!walkShown || now - walkChanged > std::chrono::milliseconds(250))) rebakeWalk();
|
||
const bool on = walkShown && showWorld && (showCat[kShowWalkArea] || showCat[kShowUnreached]) && !(isolating && isoObject < 0 && isoKind >= 0);
|
||
if (on != walkGuideOn || (on && walkGuideDirty)) {
|
||
std::vector<GuideVertex> v;
|
||
if (on) v = walkGuide();
|
||
walkGuideVerts = v.size();
|
||
renderer.setStaticGuide(v); // it changes only with the walk area: uploaded once, not every frame
|
||
walkGuideOn = on;
|
||
walkGuideDirty = false;
|
||
}
|
||
}
|
||
|
||
void rebakeWalk() {
|
||
walkPending = false;
|
||
const bool save = walkSaved() || walkSaveNext;
|
||
WalkReport rep;
|
||
std::shared_ptr<const WalkGrid> g = bakeWalk(save ? walkEdit : WalkParams{}, &rep);
|
||
walkReport = rep;
|
||
walkShown = g;
|
||
walkGuideDirty = true;
|
||
if (save && g) {
|
||
std::string enc = encodeWalkGrid(*g);
|
||
if (enc != scene->world.walkGrid) {
|
||
scene->world.walkGrid = std::move(enc);
|
||
dirty = worldEdited = true;
|
||
scene->world.origin = "studio";
|
||
updateTitle();
|
||
}
|
||
scene->world.walkGridCache = g;
|
||
}
|
||
walkSaveNext = false;
|
||
walkSig = walkSignature();
|
||
}
|
||
|
||
// Changes to the settings save the area with the home (one undo step per change).
|
||
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).
|
||
void walkRuntime(WorldRuntime& rt) {
|
||
HomeWorld w = scene->world;
|
||
w.walkGridCache = walkShown;
|
||
if (!walkShown) w = walkWorld(*scene);
|
||
rt.build(w);
|
||
}
|
||
|
||
// The viewport's picture of it (the renderer's guide pass: depth-tested, a quarter strength behind things).
|
||
std::vector<GuideVertex> walkGuide() const {
|
||
std::vector<GuideVertex> v;
|
||
const WalkGrid& g = *walkShown;
|
||
const float cs = g.p.cell, lift = 0.025f;
|
||
auto vert = [&](float x, float y, float z, const float* c) { GuideVertex q; q.pos[0] = x; q.pos[1] = y; q.pos[2] = z; std::memcpy(q.rgba, c, 16); v.push_back(q); };
|
||
auto quad = [&](const float* a, const float* b, const float* c, const float* d, const float* col) {
|
||
vert(a[0], a[1], a[2], col); vert(b[0], b[1], b[2], col); vert(c[0], c[1], c[2], col);
|
||
vert(a[0], a[1], a[2], col); vert(c[0], c[1], c[2], col); vert(d[0], d[1], d[2], col);
|
||
};
|
||
static const float kCol[3][4] = {{0.10f, 0.78f, 0.30f, 0.40f}, {0.10f, 0.55f, 1.00f, 0.60f}, {0.50f, 0.50f, 0.50f, 0.22f}};
|
||
static const float kEdge[4] = {1.00f, 0.45f, 0.08f, 0.95f};
|
||
const bool showWalk = showCat[kShowWalkArea], showGrey = showCat[kShowUnreached];
|
||
// the walkable area as it's walked: its smooth outline (WalkGrid::outline), with the edge as a strip on the
|
||
// floor and a low kerb (seen from above and from the side)
|
||
if (showWalk)
|
||
g.outline([&](const float* a, const float* b, const float* c, bool bridged) {
|
||
const float* col = kCol[bridged ? 1 : 0];
|
||
vert(a[0], a[1] + lift, a[2], col); vert(b[0], b[1] + lift, b[2], col); vert(c[0], c[1] + lift, c[2], col);
|
||
},
|
||
[&](const float* a, const float* b) {
|
||
float nx = -(b[2] - a[2]), nz = b[0] - a[0];
|
||
const float L = std::sqrt(nx * nx + nz * nz);
|
||
if (L < 1e-5f) return;
|
||
nx /= L; nz /= L;
|
||
const float mx = (a[0] + b[0]) * 0.5f, mz = (a[2] + b[2]) * 0.5f, my = (a[1] + b[1]) * 0.5f;
|
||
if (g.field(mx + nx * 0.02f, mz + nz * 0.02f, my) < g.field(mx - nx * 0.02f, mz - nz * 0.02f, my)) { nx = -nx; nz = -nz; }
|
||
const float w = 0.035f, y0 = a[1] + lift + 0.003f, y1 = b[1] + lift + 0.003f;
|
||
const float e0[3] = {a[0], y0, a[2]}, e1[3] = {b[0], y1, b[2]};
|
||
const float i0[3] = {a[0] + nx * w, y0, a[2] + nz * w}, i1[3] = {b[0] + nx * w, y1, b[2] + nz * w};
|
||
quad(e0, e1, i1, i0, kEdge);
|
||
const float t0[3] = {e0[0], y0 + 0.06f, e0[2]}, t1[3] = {e1[0], y1 + 0.06f, e1[2]};
|
||
quad(e0, e1, t1, t0, kEdge);
|
||
});
|
||
// floors left out (can't be reached): runs along x of cells at one height
|
||
if (!showGrey) return v;
|
||
struct Run { int x0, x1; float h; };
|
||
std::vector<Run> open, next;
|
||
for (int z = 0; z < g.nz; ++z) {
|
||
const float za = g.oz + z * cs, zb = za + cs;
|
||
open.clear();
|
||
auto flush = [&](const Run& r) {
|
||
const float x0 = g.ox + r.x0 * cs, x1 = g.ox + (r.x1 + 1) * cs, y = r.h + lift;
|
||
const float a[3] = {x0, y, za}, b[3] = {x1, y, za}, c[3] = {x1, y, zb}, d[3] = {x0, y, zb};
|
||
quad(a, b, c, d, kCol[2]);
|
||
};
|
||
for (int x = 0; x < g.nx; ++x) {
|
||
next.clear();
|
||
for (const WalkSpan* s = g.begin(x, z); s != g.end(x, z); ++s) {
|
||
if (!(s->flags & kWalkUnreached)) continue;
|
||
bool joined = false;
|
||
for (Run& r : open)
|
||
if (r.x1 == x - 1 && std::fabs(r.h - s->hi) < 0.03f) { r.x1 = x; next.push_back(r); r.x1 = -100; joined = true; break; }
|
||
if (!joined) next.push_back({x, x, s->hi});
|
||
}
|
||
for (const Run& r : open) if (r.x1 != -100 && r.x1 == x - 1) flush(r);
|
||
open.swap(next);
|
||
}
|
||
for (const Run& r : open) flush(r);
|
||
}
|
||
return v;
|
||
}
|
||
|
||
// ── World > Auto-generate Walkable Area ──
|
||
// Bakes the area with the current settings and saves it with the home, replacing what the old Floors and Walls
|
||
// generator made ("Auto floor / wall" surfaces). A home with no spawn gets one in the middle of its biggest
|
||
// floor. Surfaces you set on objects (Floor, Wall, Walk through) override what it decides. One undo step.
|
||
struct AutoReport {
|
||
bool ok = false;
|
||
int replaced = 0, kept = 0;
|
||
bool spawnAdded = false;
|
||
int walls = 0, objects = 0, colliders = 0, seats = 0, spots = 0, hotspots = 0; // world items made
|
||
WalkReport walk;
|
||
float ms = 0;
|
||
} autoReport;
|
||
bool autoSummaryOpen = false; // open the summary next frame
|
||
ImVec2 autoUndoAt{-1, -1}, autoDoneAt{-1, -1}; // --selftest: where the summary's buttons are
|
||
|
||
static bool isAuto(const std::string& name) { return name.rfind(kAutoPrefix, 0) == 0; }
|
||
|
||
// The middle of a walkable area's main floor: the walkable spot nearest the centre of its biggest level.
|
||
static bool walkMiddle(const WalkGrid& g, float out[3]) {
|
||
std::map<int, double> levels;
|
||
for (const WalkSpan& s : g.spans) if (s.flags & kWalkOk) levels[static_cast<int>(std::floor(s.hi / 0.1f))] += 1;
|
||
if (levels.empty()) return false;
|
||
int main = levels.begin()->first;
|
||
for (const auto& kv : levels) if (kv.second > levels[main]) main = kv.first;
|
||
double sx = 0, sz = 0, n = 0;
|
||
for (int z = 0; z < g.nz; ++z)
|
||
for (int x = 0; x < g.nx; ++x)
|
||
for (const WalkSpan* s = g.begin(x, z); s != g.end(x, z); ++s)
|
||
if ((s->flags & kWalkOk) && static_cast<int>(std::floor(s->hi / 0.1f)) == main) { sx += g.centerX(x); sz += g.centerZ(z); n += 1; }
|
||
const float cx = static_cast<float>(sx / n), cz = static_cast<float>(sz / n);
|
||
float best = 1e30f;
|
||
for (int z = 0; z < g.nz; ++z)
|
||
for (int x = 0; x < g.nx; ++x)
|
||
for (const WalkSpan* s = g.begin(x, z); s != g.end(x, z); ++s) {
|
||
if (!(s->flags & kWalkOk) || static_cast<int>(std::floor(s->hi / 0.1f)) != main) continue;
|
||
// away from edges: all 8 neighbours walkable too
|
||
bool inner = true;
|
||
for (int k = 0; k < 9 && inner; ++k) inner = g.walkNear(x + k % 3 - 1, z + k / 3 - 1, s->hi, 0.05f) != nullptr;
|
||
const float d = std::hypot(g.centerX(x) - cx, g.centerZ(z) - cz) + (inner ? 0 : 5);
|
||
if (d < best) { best = d; out[0] = g.centerX(x); out[1] = s->hi; out[2] = g.centerZ(z); }
|
||
}
|
||
return true;
|
||
}
|
||
|
||
void autoGenerate() {
|
||
if (!scene) return;
|
||
AutoReport r;
|
||
pushWorldUndo();
|
||
HomeWorld& w = scene->world;
|
||
const size_t n0 = w.colliders.size();
|
||
w.colliders.erase(std::remove_if(w.colliders.begin(), w.colliders.end(), [](const WorldCollider& c) { return isAuto(c.name); }), w.colliders.end());
|
||
r.replaced = static_cast<int>(n0 - w.colliders.size());
|
||
w.spawns.erase(std::remove_if(w.spawns.begin(), w.spawns.end(), [](const WorldPoint& p) { return p.name == "Auto spawn"; }), w.spawns.end());
|
||
// the walls, seats, wall spots and hotspots it made last time go too (yours stay)
|
||
auto autoPoint = [](const WorldPoint& p) { return isAuto(p.name); };
|
||
w.seats.erase(std::remove_if(w.seats.begin(), w.seats.end(), autoPoint), w.seats.end());
|
||
w.hotspots.erase(std::remove_if(w.hotspots.begin(), w.hotspots.end(), autoPoint), w.hotspots.end());
|
||
w.wallSpots.erase(std::remove_if(w.wallSpots.begin(), w.wallSpots.end(), [](const WorldWallSpot& s) { return isAuto(s.name); }), w.wallSpots.end());
|
||
for (const Object& o : objects) if (!o.deleted && surfaceIndex(o) >= 0) ++r.kept;
|
||
const auto t0 = std::chrono::steady_clock::now();
|
||
{ // Everything that can be a floor is one: a mesh you stand on somewhere (the floor, stairs, a rug, a deck,
|
||
// terrain) is a Surface, walkable where it's flat enough. Everything else (furniture, vases, plants in
|
||
// pots, lamps: the random objects) gets a Collider, so you don't walk through it and never stand on it.
|
||
// Not the ones you gave a surface, a vista's (scenery), or see-through and cut-out ones (glass, foliage:
|
||
// the bake keeps them blocking but never floor already).
|
||
std::vector<char> plain(scene->meshes.size(), 0);
|
||
for (const WalkSource& src : drawnNow())
|
||
if (src.tag >= 0 && static_cast<size_t>(src.tag) < plain.size()) plain[src.tag] = !src.noFloor && !src.cutout;
|
||
// A mesh is a floor where its faces that face up lie on the floors the area has (in reach or not), at
|
||
// least 0.1 m² of them: by shape, not by which mesh the bake credits (a home's own collision mesh often
|
||
// lies exactly on its drawn floor).
|
||
WalkReport fr;
|
||
const std::shared_ptr<const WalkGrid> g0 = bakeWalk(walkEdit, &fr);
|
||
auto onFloor = [&](const float* p) {
|
||
int cx, cz;
|
||
if (!g0 || !g0->cellOf(p[0], p[2], cx, cz)) return false;
|
||
for (const WalkSpan* q = g0->begin(cx, cz); q != g0->end(cx, cz); ++q)
|
||
if ((q->flags & (kWalkOk | kWalkNear | kWalkUnreached)) && std::fabs(q->hi - p[1]) <= 0.08f) return true;
|
||
return false;
|
||
};
|
||
for (const Object& o : objects) {
|
||
if (o.deleted || o.hidden || surfaceIndex(o) >= 0 || !canBeSurface(o) || !plain[o.scene]) continue;
|
||
std::vector<u32> all;
|
||
for (size_t t = 0; t + 2 < scene->meshes[o.scene].indices.size(); t += 3) all.push_back(static_cast<u32>(t));
|
||
double floorArea = 0;
|
||
worldTris(o, [&](size_t, const float* a, const float* b, const float* c, const float* n, float area) {
|
||
if (floorArea >= 0.1 || std::fabs(n[1]) < 0.866f) return;
|
||
const float m[3] = {(a[0] + b[0] + c[0]) / 3, (a[1] + b[1] + c[1]) / 3, (a[2] + b[2] + c[2]) / 3};
|
||
if (onFloor(m)) floorArea += area;
|
||
else if (area > 0.05f) { // a big face: where its corners are, too
|
||
int on = 0;
|
||
for (const float* v : {a, b, c}) {
|
||
const float q[3] = {v[0] * 0.8f + m[0] * 0.2f, v[1] * 0.8f + m[1] * 0.2f, v[2] * 0.8f + m[2] * 0.2f};
|
||
on += onFloor(q);
|
||
}
|
||
floorArea += area * on / 3;
|
||
}
|
||
});
|
||
const bool floor = floorArea >= 0.1;
|
||
w.colliders.push_back(surfacePart(o, all, floor ? WorldCollider::Solid : WorldCollider::Object,
|
||
std::string(kAutoPrefix) + (floor ? "surface · " : "collider · ") + o.label));
|
||
++(floor ? r.objects : r.colliders);
|
||
}
|
||
}
|
||
if (w.spawns.empty()) { // a start in the middle of the main floor
|
||
WalkReport rep;
|
||
std::shared_ptr<const WalkGrid> g = bakeWalk(walkEdit, &rep);
|
||
float at[3];
|
||
if (g && walkMiddle(*g, at)) {
|
||
WorldPoint p;
|
||
p.name = "Auto spawn";
|
||
std::memcpy(p.pos, at, 12);
|
||
w.spawns.push_back(p);
|
||
r.spawnAdded = true;
|
||
}
|
||
}
|
||
walkSaveNext = true;
|
||
rebakeWalk();
|
||
r.ms = std::chrono::duration<float, std::milli>(std::chrono::steady_clock::now() - t0).count();
|
||
r.walk = walkReport;
|
||
r.ok = walkShown && walkShown->any();
|
||
if (!r.ok) { // nothing to stand on: change nothing
|
||
scene->world = *undo.back().world;
|
||
undo.pop_back();
|
||
walkPending = true;
|
||
}
|
||
autoReport = r;
|
||
autoSummaryOpen = true;
|
||
worldReplaced();
|
||
wKind = wIndex = -1;
|
||
}
|
||
|
||
// What stops you at the area's edges, by kind of object (the summary and the panel).
|
||
struct WalkBlame { int objects[kCategoryCount + 1] = {}; std::string names[kCategoryCount + 1]; int homeShapes = 0; float ledges = 0; };
|
||
WalkBlame walkBlame(const WalkReport& rep) const {
|
||
WalkBlame b;
|
||
std::vector<int> objOf(scene->meshes.size(), -1);
|
||
for (size_t i = 0; i < objects.size(); ++i) if (objects[i].scene >= 0 && objects[i].scene < static_cast<int>(objOf.size())) objOf[objects[i].scene] = static_cast<int>(i);
|
||
std::set<int> seen;
|
||
for (const auto& bl : rep.blockers) {
|
||
int obj = -1;
|
||
const int t = bl.first;
|
||
if (t < static_cast<int>(scene->meshes.size())) obj = objOf[t];
|
||
else if (t - scene->meshes.size() < scene->world.colliders.size()) {
|
||
const WorldCollider& c = scene->world.colliders[t - scene->meshes.size()];
|
||
for (size_t i = 0; i < objects.size() && obj < 0; ++i) if (!c.mesh.empty() && objectKey(objects[i]) == c.mesh) obj = static_cast<int>(i);
|
||
if (obj < 0) { ++b.homeShapes; continue; }
|
||
}
|
||
if (obj < 0 || !seen.insert(obj).second) continue;
|
||
const int cat = std::clamp(objects[obj].category, 0, kCategoryCount - 1);
|
||
if (b.objects[cat]++ < 3) b.names[cat] += (b.names[cat].empty() ? "" : ", ") + objects[obj].label;
|
||
}
|
||
b.ledges = rep.ledgeCells * (walkShown ? walkShown->p.cell : 0.1f);
|
||
return b;
|
||
}
|
||
|
||
// The result, with its own Undo (the whole run is one step).
|
||
void autoSummaryWindow() {
|
||
const char* title = "Walkable Area";
|
||
if (autoSummaryOpen) { ImGui::OpenPopup(title); autoSummaryOpen = false; }
|
||
autoUndoAt = autoDoneAt = ImVec2(-1, -1);
|
||
const ImGuiIO& io = ImGui::GetIO();
|
||
// Centred every frame, not only as it appears: its report settles its height over the first frames, and a popup
|
||
// placed before that grew past the bottom of a short window (Done and Undo out of reach).
|
||
ImGui::SetNextWindowPos(ImVec2(io.DisplaySize.x * 0.5f, io.DisplaySize.y * 0.5f), ImGuiCond_Always, ImVec2(0.5f, 0.5f));
|
||
if (!ImGui::BeginPopupModal(title, nullptr, ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoSavedSettings)) return;
|
||
const AutoReport& r = autoReport;
|
||
auto swatch = [&](uint32_t c) {
|
||
const ImVec2 p = ImGui::GetCursorScreenPos();
|
||
const float s = ImGui::GetTextLineHeight();
|
||
ImGui::GetWindowDrawList()->AddRectFilled(ImVec2(p.x, p.y + s * 0.15f), ImVec2(p.x + s * 0.7f, p.y + s * 0.85f), col(c), 2 * scale);
|
||
ImGui::Dummy(ImVec2(s * 0.7f, s));
|
||
ImGui::SameLine();
|
||
};
|
||
if (!r.ok) {
|
||
ImGui::TextUnformatted("No walkable floor found: nothing level with headroom over it near the start.");
|
||
ImGui::TextDisabled("Nothing was changed. Mark the floor's objects with Surface › Floor, or add a spawn point on it.");
|
||
if (ImGui::Button("OK", ImVec2(120 * scale, 0))) ImGui::CloseCurrentPopup();
|
||
autoDoneAt = ImVec2((ImGui::GetItemRectMin().x + ImGui::GetItemRectMax().x) * 0.5f, (ImGui::GetItemRectMin().y + ImGui::GetItemRectMax().y) * 0.5f);
|
||
ImGui::EndPopup();
|
||
return;
|
||
}
|
||
const WalkReport& w = r.walk;
|
||
// The report scrolls inside the window when the window is short; Done and Undo stay below it, always in view.
|
||
const ImGuiStyle& style = ImGui::GetStyle();
|
||
const float buttonsH = ImGui::GetFrameHeightWithSpacing() + style.ItemSpacing.y * 2;
|
||
const float titleH = ImGui::GetFontSize() + style.FramePadding.y * 2;
|
||
const float maxReportH = std::max(120.0f * scale, io.DisplaySize.y - titleH - buttonsH - style.WindowPadding.y * 2 - 40 * scale);
|
||
ImGui::SetNextWindowSizeConstraints(ImVec2(0, 0), ImVec2(FLT_MAX, maxReportH));
|
||
ImGui::BeginChild("report", ImVec2(490 * scale, 0), ImGuiChildFlags_AutoResizeY);
|
||
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 470 * scale);
|
||
swatch(0x2ec756);
|
||
ImGui::Text("You can walk on %.0f m²%s", w.walkArea, w.areas > 1 ? (" (" + std::to_string(w.areas) + " separate areas)").c_str() : "");
|
||
if (w.bridgedArea > 0.05) { swatch(0x1a8cff); ImGui::Text("%.1f m² of seams and small gaps bridged (up to %.0f cm across)", w.bridgedArea, walkEdit.gap * 100); }
|
||
swatch(0xff7314);
|
||
ImGui::TextUnformatted("Edges: you stop a body's width from these");
|
||
const WalkBlame b = walkBlame(w);
|
||
ImGui::Indent(22 * scale);
|
||
static const char* kCatHow[kCategoryCount] = {"walls & architecture", "furniture & props", "plants", "lights & glow", "terrain & water", "sky"};
|
||
for (int c = 0; c < kCategoryCount; ++c)
|
||
if (b.objects[c]) ImGui::Text("%d %s%s", b.objects[c], kCatHow[c], b.names[c].empty() ? "" : (" · " + b.names[c] + (b.objects[c] > 3 ? "…" : "")).c_str());
|
||
if (b.homeShapes) ImGui::Text("%d of the home's own collision shapes", b.homeShapes);
|
||
if (b.ledges > 0.5f) ImGui::Text("%.0f m of ledges and drops (higher than a %.0f cm step)", b.ledges, walkEdit.step * 100);
|
||
ImGui::Unindent(22 * scale);
|
||
if (w.unreached) {
|
||
swatch(0x808080);
|
||
ImGui::Text("%d floor%s you can't reach from the start left out (%.0f m²: table tops, roofs, behind walls)", w.unreached, w.unreached == 1 ? "" : "s", w.unreachedArea);
|
||
}
|
||
if (r.spawnAdded) { swatch(kShowColors[kShowSpawns]); ImGui::TextUnformatted("A spawn point in the middle of the main floor"); }
|
||
if (r.walls) { swatch(kShowColors[kShowWalls]); ImGui::Text("%d wall%s on the room's walls and round its open edges", r.walls, r.walls == 1 ? "" : "s"); }
|
||
if (r.objects) { swatch(kShowColors[kShowSolidMeshes]); ImGui::Text("%d floor mesh%s made surfaces: walkable where flat, blocking where too steep", r.objects, r.objects == 1 ? "" : "es"); }
|
||
if (r.colliders) { swatch(kRoleColors[WorldCollider::Object]); ImGui::Text("%d object%s given a collider: you don't walk through %s", r.colliders, r.colliders == 1 ? "" : "s", r.colliders == 1 ? "it" : "them"); }
|
||
if (r.seats) { swatch(kShowColors[kShowSeats]); ImGui::Text("%d seat%s on the chairs, sofas and stools it found", r.seats, r.seats == 1 ? "" : "s"); }
|
||
if (r.spots) { swatch(kShowColors[kShowWallSpots]); ImGui::Text("%d wall spot%s for panels, on large flat walls", r.spots, r.spots == 1 ? "" : "s"); }
|
||
if (r.hotspots) { swatch(kShowColors[kShowHotspots]); ImGui::Text("%d teleport hotspot%s spread over the floor", r.hotspots, r.hotspots == 1 ? "" : "s"); }
|
||
if (r.walls + r.objects + r.colliders + r.seats + r.spots + r.hotspots) ImGui::TextDisabled("They're all named \"Auto …\" in the World list: edit or delete any; running it again replaces them.");
|
||
ImGui::Spacing();
|
||
ImGui::PushStyleColor(ImGuiCol_Text, hex(kDim));
|
||
if (r.kept) ImGui::Text(r.kept == 1 ? "1 object keeps the surface you gave it (Surface or Walk through)" : "%d objects keep the surfaces you gave them (Surface or Walk through)", r.kept);
|
||
if (r.replaced) ImGui::Text("Replaced %d surfaces from the last run", r.replaced);
|
||
ImGui::Text("Step %.0f cm · headroom %.2f m · body radius %.0f cm · baked in %.0f ms", walkEdit.step * 100, walkEdit.headroom, walkEdit.radius * 100, r.ms);
|
||
ImGui::PopStyleColor();
|
||
ImGui::Spacing();
|
||
ImGui::TextDisabled("Saved with the home: Frame Home walks exactly this. It updates as you edit; fix any object with its Surface buttons (or right-click it).");
|
||
ImGui::PopTextWrapPos();
|
||
ImGui::EndChild();
|
||
ImGui::Spacing();
|
||
if (ImGui::Button("Done", ImVec2(120 * scale, 0))) ImGui::CloseCurrentPopup();
|
||
autoDoneAt = ImVec2((ImGui::GetItemRectMin().x + ImGui::GetItemRectMax().x) * 0.5f, (ImGui::GetItemRectMin().y + ImGui::GetItemRectMax().y) * 0.5f);
|
||
ImGui::SameLine();
|
||
if (ImGui::Button("Undo", ImVec2(120 * scale, 0))) { step(undo, redo); ImGui::CloseCurrentPopup(); }
|
||
autoUndoAt = ImVec2((ImGui::GetItemRectMin().x + ImGui::GetItemRectMax().x) * 0.5f, (ImGui::GetItemRectMin().y + ImGui::GetItemRectMax().y) * 0.5f);
|
||
ImGui::EndPopup();
|
||
}
|
||
|
||
// The panel's WALKABLE AREA section: what Frame Home walks, its settings, and the button.
|
||
void walkPanel() {
|
||
if (!scene) return;
|
||
const WalkReport& w = walkReport;
|
||
ImGui::PushTextWrapPos(0);
|
||
if (!walkShown) ImGui::TextDisabled("Working it out…");
|
||
else {
|
||
ImGui::Text("%.0f m² to walk on%s", w.walkArea, w.areas > 1 ? (" in " + std::to_string(w.areas) + " areas").c_str() : "");
|
||
if (w.unreached) { ImGui::SameLine(); ImGui::TextDisabled("· %d out of reach", w.unreached); }
|
||
ImGui::TextDisabled("%s", walkSaved() ? "Saved with the home: Frame Home walks exactly this (it updates as you edit)."
|
||
: "Not saved yet: Frame Home works out the same from the home when it loads it. Auto-generate (or change a setting) to save it with your edits.");
|
||
}
|
||
ImGui::PopTextWrapPos();
|
||
ImGui::Spacing();
|
||
// presets, then the settings
|
||
struct Preset { const char* name; const char* tip; WalkParams p; };
|
||
static const Preset presets[3] = {
|
||
{"Open", "Walk close to things, up higher steps and over wider gaps: small rooms, cluttered homes.", [] { WalkParams p; p.radius = 0.05f; p.gap = 0.35f; p.step = 0.45f; p.headroom = 1.4f; return p; }()},
|
||
{"Standard", "Frame Home's defaults.", WalkParams{}},
|
||
{"Careful", "Keep well clear of edges, low steps only, only bridge seams.", [] { WalkParams p; p.radius = 0.25f; p.gap = 0.15f; p.step = 0.2f; p.headroom = 1.8f; return p; }()}};
|
||
const float bw = (ImGui::GetContentRegionAvail().x - 8 * scale) / 3;
|
||
for (int i = 0; i < 3; ++i) {
|
||
if (i) ImGui::SameLine(0, 4 * scale);
|
||
const WalkParams& p = presets[i].p;
|
||
const bool on = std::fabs(p.step - walkEdit.step) < 1e-4f && std::fabs(p.radius - walkEdit.radius) < 1e-4f && std::fabs(p.gap - walkEdit.gap) < 1e-4f && std::fabs(p.headroom - walkEdit.headroom) < 1e-4f;
|
||
ImGui::PushStyleColor(ImGuiCol_Button, on ? hex(kGo) : hex(kWidget, 0.55f));
|
||
if (ImGui::Button(presets[i].name, ImVec2(bw, 0)) && !on) { pushWorldUndo(); const float mb = walkEdit.metaBounds; walkEdit = p; walkEdit.metaBounds = mb; walkSettingsChanged(); }
|
||
ImGui::PopStyleColor();
|
||
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", presets[i].tip);
|
||
}
|
||
walkSliding = false;
|
||
auto slider = [&](const char* name, float* v, float lo, float hi, float unit, const char* fmt, const char* tip) {
|
||
ImGui::AlignTextToFramePadding();
|
||
ImGui::TextDisabled("%s", name);
|
||
ImGui::SameLine(88 * scale);
|
||
float shown = *v * unit;
|
||
ImGui::SetNextItemWidth(-1);
|
||
const bool changed = ImGui::SliderFloat((std::string("##walk") + name).c_str(), &shown, lo * unit, hi * unit, fmt);
|
||
if (ImGui::IsItemActivated()) pushWorldUndo();
|
||
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", tip);
|
||
walkSliding |= ImGui::IsItemActive();
|
||
if (changed) { *v = shown / unit; walkSettingsChanged(); }
|
||
};
|
||
slider("Step up", &walkEdit.step, 0.05f, 0.6f, 100, "%.0f cm", "The highest step you walk up or down. Higher is a ledge: you can't climb it or walk off it.");
|
||
slider("Headroom", &walkEdit.headroom, 1.0f, 2.4f, 1, "%.2f m", "The clear height a floor needs. Lower than this under something (a table, a shelf, a low beam): not walkable.");
|
||
slider("Body radius", &walkEdit.radius, 0.0f, 0.5f, 100, "%.0f cm", "How close you get to walls, furniture and ledges.");
|
||
slider("Bridge gaps", &walkEdit.gap, 0.0f, 0.6f, 100, "up to %.0f cm", "Seams and holes in the floor up to this wide are walked over (at the floor's height), so you never drop into a crack.");
|
||
bool stop = walkEdit.metaBounds >= 0.5f;
|
||
if (ImGui::Checkbox("Stop at Meta's invisible walls", &stop)) { pushWorldUndo(); walkEdit.metaBounds = stop ? 1.0f : 0.0f; walkSettingsChanged(); }
|
||
if (ImGui::IsItemHovered())
|
||
ImGui::SetTooltip("Quest homes are made to be seen from one spot: invisible walls keep you there (a terrace's far end,\n"
|
||
"Space Station's other rooms, most of Cascadia). Off (the default), you walk to everything modelled beyond them.");
|
||
if (ImGui::Button(walkSaved() ? "Auto-generate Again" : "Auto-generate Walkable Surfaces", ImVec2(-1, 0))) autoGenerate();
|
||
if (ImGui::IsItemHovered())
|
||
ImGui::SetTooltip("Bakes where you can walk from everything solid in the home and saves it with it (one undo step),\n"
|
||
"and adds walls along its edges, seats on chairs and sofas, wall spots for panels and teleport hotspots.\n"
|
||
"Floors need headroom; furniture, plants, railings and walls block; steps up to the step height connect;\n"
|
||
"seams are bridged; floors you can't reach from the start are left out. Object Surfaces override it.");
|
||
}
|
||
|
||
// Mesh surfaces get their pivot at their centre (Meta's and geometry ones come in world space, which put the
|
||
// gizmo at the world origin). The triangles stay exactly where they were.
|
||
static void recenterMesh(WorldCollider& c) {
|
||
if (c.kind != WorldCollider::Mesh || !c.mesh.empty() || c.positions.size() < 3) return;
|
||
static const float I[16] = {1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1};
|
||
if (std::memcmp(c.world, I, 64) != 0) return; // already in its own frame (Meta's colliders: the mesh's vertices as-is)
|
||
float lo[3] = {1e30f, 1e30f, 1e30f}, hi[3] = {-1e30f, -1e30f, -1e30f};
|
||
for (size_t v = 0; v + 2 < c.positions.size(); v += 3)
|
||
for (int k = 0; k < 3; ++k) { lo[k] = std::min(lo[k], c.positions[v + k]); hi[k] = std::max(hi[k], c.positions[v + k]); }
|
||
const float ctr[3] = {(lo[0] + hi[0]) * 0.5f, (lo[1] + hi[1]) * 0.5f, (lo[2] + hi[2]) * 0.5f};
|
||
if (std::fabs(ctr[0]) + std::fabs(ctr[1]) + std::fabs(ctr[2]) < 1e-4f) return;
|
||
for (size_t v = 0; v + 2 < c.positions.size(); v += 3)
|
||
for (int k = 0; k < 3; ++k) c.positions[v + k] -= ctr[k];
|
||
float t[16], nw[16];
|
||
translation(t, ctr[0], ctr[1], ctr[2]);
|
||
mul(c.world, t, nw);
|
||
std::memcpy(c.world, nw, 64);
|
||
}
|
||
|
||
void prepareWorld() {
|
||
if (!scene) return;
|
||
for (WorldCollider& c : scene->world.colliders) recenterMesh(c);
|
||
}
|
||
|
||
void pushUndo(size_t i, const float* pivotBefore = nullptr) {
|
||
UndoStep u;
|
||
u.object = i;
|
||
std::memcpy(u.pivot, pivotBefore ? pivotBefore : objects[i].pivot, 64);
|
||
u.hidden = objects[i].hidden;
|
||
u.deleted = objects[i].deleted;
|
||
u.group = undoGroup;
|
||
undo.push_back(u);
|
||
redo.clear();
|
||
dirty = true;
|
||
updateTitle();
|
||
}
|
||
|
||
// ── World layer (spawns, seats, hotspots, walls, wall spots, walkable areas) ──
|
||
|
||
int worldCount(int kind) const {
|
||
if (!scene) return 0;
|
||
const HomeWorld& w = scene->world;
|
||
switch (kind) {
|
||
case kSpawn: return static_cast<int>(w.spawns.size());
|
||
case kSeat: return static_cast<int>(w.seats.size());
|
||
case kHotspot: return static_cast<int>(w.hotspots.size());
|
||
case kCollider: return static_cast<int>(w.colliders.size());
|
||
case kWallSpot: return static_cast<int>(w.wallSpots.size());
|
||
case kGravity: return static_cast<int>(w.gravity.size());
|
||
default: return 0;
|
||
}
|
||
}
|
||
|
||
int worldCat(int kind, int i) const {
|
||
switch (kind) {
|
||
case kSpawn: return kShowSpawns;
|
||
case kSeat: return kShowSeats;
|
||
case kHotspot: return kShowHotspots;
|
||
case kWallSpot: return kShowWallSpots;
|
||
case kGravity: return kShowGravity;
|
||
default: {
|
||
const WorldCollider& c = scene->world.colliders[i];
|
||
if (c.role == WorldCollider::Trigger) return kShowTriggers;
|
||
if (c.role == WorldCollider::Floor || c.role == WorldCollider::Walkable) return kShowFloors;
|
||
if ((c.role == WorldCollider::Solid || c.role == WorldCollider::Object) && c.kind == WorldCollider::Mesh) return kShowSolidMeshes;
|
||
return kShowWalls;
|
||
}
|
||
}
|
||
}
|
||
|
||
bool* worldHidden(int kind, int i) {
|
||
if (WorldPoint* p = worldPoint(kind, i)) return &p->hidden;
|
||
if (kind == kCollider) return &scene->world.colliders[i].hidden;
|
||
if (kind == kWallSpot) return &scene->world.wallSpots[i].hidden;
|
||
if (kind == kGravity) return &scene->world.gravity[i].hidden;
|
||
return nullptr;
|
||
}
|
||
|
||
// Drawn and clickable in the viewport.
|
||
bool worldShown(int kind, int i) {
|
||
if (!showWorld) return false;
|
||
if (isolating) return kind == isoKind && i == isoIndex;
|
||
const bool* h = worldHidden(kind, i);
|
||
return showCat[worldCat(kind, i)] && !(h && *h);
|
||
}
|
||
|
||
WorldPoint* worldPoint(int kind, int i) {
|
||
HomeWorld& w = scene->world;
|
||
std::vector<WorldPoint>* v = kind == kSpawn ? &w.spawns : kind == kSeat ? &w.seats : kind == kHotspot ? &w.hotspots : nullptr;
|
||
return v && i >= 0 && i < static_cast<int>(v->size()) ? &(*v)[i] : nullptr;
|
||
}
|
||
|
||
std::string worldName(int kind, int i) {
|
||
if (WorldPoint* p = worldPoint(kind, i)) return p->name.empty() ? std::string(kWorldNames[kind]) + " " + std::to_string(i + 1) : p->name;
|
||
if (kind == kCollider) {
|
||
const WorldCollider& c = scene->world.colliders[i];
|
||
static const char* shape[] = {"box", "sphere", "capsule", "mesh"};
|
||
return (c.name.empty() ? std::string("surface ") + std::to_string(i + 1) : c.name) + " (" + shape[std::clamp(c.kind, 0, 3)] + ")";
|
||
}
|
||
if (kind == kWallSpot) { const WorldWallSpot& ws = scene->world.wallSpots[i]; return ws.name.empty() ? "wall spot " + std::to_string(i + 1) : ws.name; }
|
||
if (kind == kGravity) {
|
||
const WorldGravity& g = scene->world.gravity[i];
|
||
return (g.name.empty() ? "gravity " + std::to_string(i + 1) : g.name) + " (" + kGravityNames[std::clamp(g.kind, 0, 2)] + ")";
|
||
}
|
||
return {};
|
||
}
|
||
|
||
// An item's frame for the gizmo (points: position + yaw; shapes: their own matrix). False: not movable.
|
||
bool worldMatrix(int kind, int i, float* m) {
|
||
if (WorldPoint* p = worldPoint(kind, i)) {
|
||
const float c = std::cos(p->yaw), sn = std::sin(p->yaw);
|
||
const float r[16] = {c, 0, -sn, 0, 0, 1, 0, 0, sn, 0, c, 0, p->pos[0], p->pos[1], p->pos[2], 1};
|
||
std::memcpy(m, r, 64);
|
||
return true;
|
||
}
|
||
if (kind == kCollider) {
|
||
WorldCollider& c = scene->world.colliders[i];
|
||
if (!c.mesh.empty()) return false; // follows its object: move the object
|
||
recenterMesh(c);
|
||
std::memcpy(m, c.world, 64);
|
||
return true;
|
||
}
|
||
if (kind == kWallSpot) { std::memcpy(m, scene->world.wallSpots[i].world, 64); return true; }
|
||
if (kind == kGravity) { std::memcpy(m, scene->world.gravity[i].world, 64); return true; }
|
||
return false;
|
||
}
|
||
|
||
// Splits a frame into its rotation + translation (out) and per-axis scale.
|
||
static void unscale(const float* in, float* out, float* sc) {
|
||
std::memcpy(out, in, 64);
|
||
for (int k = 0; k < 3; ++k) {
|
||
sc[k] = std::sqrt(in[k * 4] * in[k * 4] + in[k * 4 + 1] * in[k * 4 + 1] + in[k * 4 + 2] * in[k * 4 + 2]);
|
||
if (sc[k] > 1e-6f) for (int r = 0; r < 3; ++r) out[k * 4 + r] /= sc[k];
|
||
}
|
||
}
|
||
|
||
// From the gizmo. Shapes stay unscaled frames: scaling resizes them (box size, radius, panel size) from their
|
||
// size when the drag began, so the gizmo's running scale never compounds. Meshes keep the scale in their frame.
|
||
void setWorldMatrix(int kind, int i, const float* m) {
|
||
if (WorldPoint* p = worldPoint(kind, i)) {
|
||
p->pos[0] = m[12]; p->pos[1] = m[13]; p->pos[2] = m[14];
|
||
p->yaw = std::atan2(m[8], m[10]);
|
||
} else if (kind == kCollider) {
|
||
WorldCollider& c = scene->world.colliders[i];
|
||
if (c.kind == WorldCollider::Mesh) { std::memcpy(c.world, m, 64); return; }
|
||
const WorldCollider& b = i < static_cast<int>(dragBase.colliders.size()) ? dragBase.colliders[i] : c;
|
||
float sc[3];
|
||
unscale(m, c.world, sc);
|
||
if (c.kind == WorldCollider::Box) for (int k = 0; k < 3; ++k) c.half[k] = std::max(0.01f, b.half[k] * sc[k]);
|
||
else if (c.kind == WorldCollider::Sphere) c.radius = std::max(0.01f, b.radius * (sc[0] + sc[1] + sc[2]) / 3);
|
||
else { c.radius = std::max(0.01f, b.radius * (sc[0] + sc[2]) * 0.5f); c.length = std::max(0.0f, b.length * sc[1]); }
|
||
} else if (kind == kWallSpot) {
|
||
WorldWallSpot& ws = scene->world.wallSpots[i];
|
||
const WorldWallSpot& b = i < static_cast<int>(dragBase.wallSpots.size()) ? dragBase.wallSpots[i] : ws;
|
||
float sc[3];
|
||
unscale(m, ws.world, sc);
|
||
ws.width = std::max(0.05f, b.width * sc[0]);
|
||
ws.height = std::max(0.05f, b.height * sc[1]);
|
||
} else if (kind == kGravity) { // scaling resizes the region (box) or the pull (planet)
|
||
WorldGravity& g = scene->world.gravity[i];
|
||
const WorldGravity& b = i < static_cast<int>(dragBase.gravity.size()) ? dragBase.gravity[i] : g;
|
||
float sc[3];
|
||
unscale(m, g.world, sc);
|
||
if (g.kind == WorldGravity::Planet) g.radius = std::max(0.5f, b.radius * (sc[0] + sc[1] + sc[2]) / 3);
|
||
else for (int k = 0; k < 3; ++k) g.half[k] = std::max(0.1f, b.half[k] * sc[k]);
|
||
}
|
||
}
|
||
|
||
// A gravity zone around the view's centre.
|
||
void addGravity(int kind) {
|
||
if (!scene) return;
|
||
pushWorldUndo();
|
||
WorldGravity g;
|
||
g.kind = kind;
|
||
g.name = std::string(kind == WorldGravity::Planet ? "Planet gravity " : kind == WorldGravity::Surface ? "Surface walking " : "Gravity ") +
|
||
std::to_string(scene->world.gravity.size() + 1);
|
||
translation(g.world, cam.pivot[0], cam.pivot[1], cam.pivot[2]);
|
||
g.priority = static_cast<int>(scene->world.gravity.size()); // a newer zone wins where they overlap
|
||
scene->world.gravity.push_back(g);
|
||
wKind = kGravity;
|
||
wIndex = worldCount(kGravity) - 1;
|
||
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)};
|
||
pushWorldUndo();
|
||
HomeWorld& w = scene->world;
|
||
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;
|
||
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);
|
||
}
|
||
|
||
// New items go where the camera looks, on the floor below it.
|
||
void addWorld(int kind) {
|
||
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;
|
||
{ // 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;
|
||
}
|
||
WorldPoint p;
|
||
p.pos[0] = x; p.pos[1] = y; p.pos[2] = z;
|
||
p.yaw = cam.yaw;
|
||
if (kind == kSpawn) { p.name = "Spawn " + std::to_string(w.spawns.size() + 1); w.spawns.push_back(p); }
|
||
else if (kind == kSeat) { p.name = "Seat " + std::to_string(w.seats.size() + 1); p.pos[1] = y + 0.45f; p.exit[2] = 0.5f; w.seats.push_back(p); }
|
||
else if (kind == kHotspot) { p.name = "Hotspot " + std::to_string(w.hotspots.size() + 1); w.hotspots.push_back(p); }
|
||
else if (kind == kCollider) {
|
||
WorldCollider c;
|
||
int n = 1;
|
||
for (const WorldCollider& o : w.colliders) n += o.kind != WorldCollider::Mesh && o.role == WorldCollider::Solid;
|
||
c.name = "Surface " + std::to_string(n);
|
||
c.kind = WorldCollider::Box;
|
||
c.role = WorldCollider::Solid; // walk on its top; its sides are too steep: walls
|
||
c.placed = true;
|
||
c.half[0] = 1.0f; c.half[1] = 0.1f; c.half[2] = 1.0f;
|
||
translation(c.world, x, y - 0.08f, z);
|
||
w.colliders.push_back(c);
|
||
wKind = kCollider;
|
||
wIndex = combineTouching(static_cast<int>(w.colliders.size()) - 1);
|
||
selected = -1;
|
||
return;
|
||
} else if (kind == kWallSpot) {
|
||
WorldWallSpot ws;
|
||
ws.name = "Wall spot " + std::to_string(w.wallSpots.size() + 1);
|
||
translation(ws.world, x, y + 1.4f, z);
|
||
w.wallSpots.push_back(ws);
|
||
}
|
||
wKind = kind;
|
||
wIndex = worldCount(kind) - 1;
|
||
selected = -1;
|
||
}
|
||
|
||
// Points the camera at a world item (double-click in the explorer, F).
|
||
void frameWorld(int kind, int i) {
|
||
float m[16], mn[3], mx[3];
|
||
if (WorldPoint* p = worldPoint(kind, i)) {
|
||
for (int k = 0; k < 3; ++k) { mn[k] = p->pos[k] - 1; mx[k] = p->pos[k] + 1; }
|
||
} else if (kind == kCollider) {
|
||
const WorldCollider& c = scene->world.colliders[i];
|
||
for (int k = 0; k < 3; ++k) { mn[k] = 1e30f; mx[k] = -1e30f; }
|
||
if (c.kind == WorldCollider::Mesh) {
|
||
const std::vector<float> wp = worldMeshPositions(c);
|
||
for (size_t v = 0; v + 2 < wp.size(); v += 3)
|
||
for (int k = 0; k < 3; ++k) { mn[k] = std::min(mn[k], wp[v + k]); mx[k] = std::max(mx[k], wp[v + k]); }
|
||
if (wp.empty()) for (int k = 0; k < 3; ++k) mn[k] = mx[k] = c.world[12 + k];
|
||
} else {
|
||
const float r = c.kind == WorldCollider::Box ? std::max({c.half[0], c.half[1], c.half[2]}) : c.radius + c.length * 0.5f;
|
||
for (int k = 0; k < 3; ++k) { mn[k] = c.world[12 + k] - r; mx[k] = c.world[12 + k] + r; }
|
||
}
|
||
} else if (kind == kGravity) {
|
||
const WorldGravity& g = scene->world.gravity[i];
|
||
const float r = g.kind == WorldGravity::Planet ? g.radius : std::max({g.half[0], g.half[1], g.half[2]});
|
||
for (int k = 0; k < 3; ++k) { mn[k] = g.world[12 + k] - r; mx[k] = g.world[12 + k] + r; }
|
||
} else if (kind == kWallSpot && worldMatrix(kind, i, m)) {
|
||
const WorldWallSpot& ws = scene->world.wallSpots[i];
|
||
const float r = std::max(ws.width, ws.height) * 0.5f;
|
||
for (int k = 0; k < 3; ++k) { mn[k] = m[12 + k] - r; mx[k] = m[12 + k] + r; }
|
||
} else return;
|
||
frameBounds(mn, mx);
|
||
}
|
||
|
||
// Frame Home walks on the home's own geometry while no surface blocks (see walkWorld).
|
||
bool geometryStandsIn() const {
|
||
for (const WorldCollider& c : scene->world.colliders)
|
||
if (c.role == WorldCollider::Solid || c.role == WorldCollider::Wall) return false;
|
||
return true;
|
||
}
|
||
|
||
void deleteWorld() {
|
||
if (!scene || wKind < 0 || wIndex < 0 || wIndex >= worldCount(wKind)) return;
|
||
pushWorldUndo();
|
||
HomeWorld& w = scene->world;
|
||
switch (wKind) {
|
||
case kSpawn: w.spawns.erase(w.spawns.begin() + wIndex); break;
|
||
case kSeat: w.seats.erase(w.seats.begin() + wIndex); break;
|
||
case kHotspot: w.hotspots.erase(w.hotspots.begin() + wIndex); break;
|
||
case kCollider: w.colliders.erase(w.colliders.begin() + wIndex); break;
|
||
case kWallSpot: w.wallSpots.erase(w.wallSpots.begin() + wIndex); break;
|
||
case kGravity: w.gravity.erase(w.gravity.begin() + wIndex); break;
|
||
}
|
||
if (isolating && isoKind == wKind) { // keep Show Only Selected on the same item (or end it with it)
|
||
if (isoIndex == wIndex) { isolating = false; refreshVisibility(); }
|
||
else if (isoIndex > wIndex) --isoIndex;
|
||
}
|
||
wKind = wIndex = -1;
|
||
}
|
||
|
||
// The world layer was replaced wholesale (undo, clear, bulk surface changes): indices into it no longer
|
||
// name the same items.
|
||
// A surface you placed that now overlaps another one you placed becomes one with it (boxes put together into a
|
||
// room are one surface; the faces buried inside each other go). Surfaces made from the home's objects follow their
|
||
// object, and the home's own colliders are its: neither is combined. Returns the index the surface ends up at.
|
||
static bool combinable(const WorldCollider& c) {
|
||
return c.placed && c.mesh.empty() && c.role == WorldCollider::Solid && (c.kind == WorldCollider::Box || c.kind == WorldCollider::Mesh);
|
||
}
|
||
int combineTouching(int i) {
|
||
auto& cs = scene->world.colliders;
|
||
if (i < 0 || i >= static_cast<int>(cs.size()) || !combinable(cs[i])) return i;
|
||
for (bool again = true; again;) {
|
||
again = false;
|
||
const std::vector<float> ti = colliderTriangles(cs[i]);
|
||
for (int j = 0; j < static_cast<int>(cs.size()); ++j) {
|
||
if (j == i || !combinable(cs[j]) || !surfacesTouch(ti, colliderTriangles(cs[j]))) continue;
|
||
const int lo = std::min(i, j), hi = std::max(i, j);
|
||
WorldCollider m = combineSurfaces(cs[lo], cs[hi]); // named after the one placed first
|
||
cs[lo] = std::move(m);
|
||
cs.erase(cs.begin() + hi);
|
||
i = lo;
|
||
again = true;
|
||
walkPending = true;
|
||
break;
|
||
}
|
||
}
|
||
return i;
|
||
}
|
||
|
||
void worldReplaced() {
|
||
if (isolating && isoKind >= 0) { isolating = false; refreshVisibility(); }
|
||
if (wKind >= 0 && wIndex >= worldCount(wKind)) wKind = wIndex = -1;
|
||
}
|
||
|
||
void pushWorldUndo() {
|
||
if (!scene) return;
|
||
UndoStep u = {};
|
||
u.group = undoGroup;
|
||
u.world = std::make_shared<HomeWorld>(scene->world);
|
||
undo.push_back(u);
|
||
redo.clear();
|
||
dirty = worldEdited = true;
|
||
scene->world.origin = "studio";
|
||
updateTitle();
|
||
}
|
||
|
||
// A batch of edits (hide a folder, unhide all, revert) that undoes in one step.
|
||
void beginBatch() { undoGroup = nextUndoGroup++; }
|
||
void endBatch() { undoGroup = 0; }
|
||
|
||
void step(std::vector<UndoStep>& from, std::vector<UndoStep>& to) {
|
||
if (from.empty()) return;
|
||
const int group = from.back().group;
|
||
do {
|
||
UndoStep u = from.back();
|
||
from.pop_back();
|
||
if (u.sound) { // a sound edit: the sound as it was
|
||
UndoStep back = {};
|
||
back.group = group;
|
||
back.sound = std::make_shared<SoundState>(sound);
|
||
to.push_back(back);
|
||
const bool same = sound.bytes == u.sound->bytes && (sound.s.homeVolume > 0) == (u.sound->s.homeVolume > 0);
|
||
sound = *u.sound;
|
||
if (same) { ambience.gain = sound.s.homeVolume; addedSound.gain = sound.s.volume; } // only volumes
|
||
else restartSounds();
|
||
continue;
|
||
}
|
||
if (u.world) { // a world-layer edit: swap the whole layer back
|
||
UndoStep back = {};
|
||
back.group = group;
|
||
back.world = std::make_shared<HomeWorld>(scene->world);
|
||
to.push_back(back);
|
||
scene->world = *u.world;
|
||
worldEdited = true;
|
||
worldReplaced();
|
||
continue;
|
||
}
|
||
Object& o = objects[u.object];
|
||
UndoStep back = {u.object, {}, o.hidden, o.deleted, group};
|
||
std::memcpy(back.pivot, o.pivot, 64);
|
||
to.push_back(back);
|
||
std::memcpy(o.pivot, u.pivot, 64);
|
||
o.hidden = u.hidden;
|
||
o.deleted = u.deleted;
|
||
apply(o);
|
||
if (o.deleted && selected == static_cast<int>(u.object)) selected = -1;
|
||
} while (group != 0 && !from.empty() && from.back().group == group);
|
||
dirty = true;
|
||
updateTitle();
|
||
}
|
||
|
||
void deleteSelected() {
|
||
if (selected < 0) return;
|
||
beginBatch();
|
||
const bool floor = drawnFloorIndex(objects[selected]) >= 0;
|
||
if (surfaceIndex(objects[selected]) >= 0 || floor) {
|
||
pushWorldUndo();
|
||
makeSurface(objects[selected], kSurfNone);
|
||
if (floor) scene->world.colliders.erase(scene->world.colliders.begin() + drawnFloorIndex(objects[selected]));
|
||
}
|
||
pushUndo(selected);
|
||
objects[selected].deleted = true;
|
||
apply(objects[selected]);
|
||
endBatch();
|
||
selected = -1;
|
||
}
|
||
|
||
void toggleHidden(size_t i) {
|
||
pushUndo(i);
|
||
objects[i].hidden = !objects[i].hidden;
|
||
apply(objects[i]);
|
||
}
|
||
|
||
// Every object back to how the home shipped (undoable).
|
||
void revertAll() {
|
||
beginBatch();
|
||
for (size_t i = 0; i < objects.size(); ++i) {
|
||
Object& o = objects[i];
|
||
if (!o.deleted && !o.hidden && std::memcmp(o.pivot, o.pivot0, 64) == 0) continue;
|
||
pushUndo(i);
|
||
std::memcpy(o.pivot, o.pivot0, 64);
|
||
o.deleted = o.hidden = false;
|
||
apply(o);
|
||
}
|
||
endBatch();
|
||
}
|
||
|
||
void unhideAll() {
|
||
beginBatch();
|
||
for (size_t i = 0; i < objects.size(); ++i)
|
||
if (objects[i].hidden && !objects[i].deleted) { pushUndo(i); objects[i].hidden = false; apply(objects[i]); }
|
||
endBatch();
|
||
}
|
||
|
||
void worldBounds(const Object& o, float* mn, float* mx) const {
|
||
float w[16];
|
||
mul(o.pivot, o.offset, w);
|
||
for (int k = 0; k < 3; ++k) { mn[k] = 1e30f; mx[k] = -1e30f; }
|
||
for (int c = 0; c < 8; ++c) {
|
||
float p[3] = {c & 1 ? o.lmax[0] : o.lmin[0], c & 2 ? o.lmax[1] : o.lmin[1], c & 4 ? o.lmax[2] : o.lmin[2]}, q[3];
|
||
point(w, p, q);
|
||
for (int k = 0; k < 3; ++k) { mn[k] = std::min(mn[k], q[k]); mx[k] = std::max(mx[k], q[k]); }
|
||
}
|
||
}
|
||
|
||
void frameBounds(const float* mn, const float* mx) {
|
||
for (int k = 0; k < 3; ++k) cam.pivot[k] = (mn[k] + mx[k]) * 0.5f;
|
||
float r = 0.5f * std::sqrt((mx[0] - mn[0]) * (mx[0] - mn[0]) + (mx[1] - mn[1]) * (mx[1] - mn[1]) + (mx[2] - mn[2]) * (mx[2] - mn[2]));
|
||
cam.dist = std::max(0.5f, r / std::tan(kFovY * 0.5f) * 1.1f);
|
||
}
|
||
|
||
void frameSelected() {
|
||
if (selected < 0) return;
|
||
float mn[3], mx[3];
|
||
worldBounds(objects[selected], mn, mx);
|
||
frameBounds(mn, mx);
|
||
}
|
||
|
||
void frameAll() {
|
||
float mn[3] = {1e30f, 1e30f, 1e30f}, mx[3] = {-1e30f, -1e30f, -1e30f};
|
||
bool any = false;
|
||
for (const Object& o : objects) {
|
||
if (o.deleted || o.hidden || o.skybox) continue;
|
||
float a[3], b[3];
|
||
worldBounds(o, a, b);
|
||
if (b[0] - a[0] > 500 || b[1] - a[1] > 500 || b[2] - a[2] > 500) continue; // distant backdrops
|
||
for (int k = 0; k < 3; ++k) { mn[k] = std::min(mn[k], a[k]); mx[k] = std::max(mx[k], b[k]); }
|
||
any = true;
|
||
}
|
||
if (any) frameBounds(mn, mx);
|
||
}
|
||
|
||
// The ray under a viewport pixel (world space; d not unit).
|
||
void mouseRay(float px, float py, float o[3], float d[3]) const {
|
||
const float ty = std::tan(kFovY * 0.5f), tx = ty * viewSize.x / std::max(1.0f, viewSize.y);
|
||
const float u = (px - viewPos.x) / std::max(1.0f, viewSize.x), v = (py - viewPos.y) / std::max(1.0f, viewSize.y);
|
||
float pose[16];
|
||
cam.pose(pose);
|
||
const float dc[3] = {(2 * u - 1) * tx, (1 - 2 * v) * ty, -1};
|
||
direction(pose, dc, d);
|
||
for (int i = 0; i < 3; ++i) o[i] = pose[12 + i];
|
||
}
|
||
// Where that ray meets the level plane at height y (false: it doesn't, in front).
|
||
bool mouseOnLevel(float px, float py, float y, float out[3]) const {
|
||
float o[3], d[3];
|
||
mouseRay(px, py, o, d);
|
||
if (std::fabs(d[1]) < 1e-6f) return false;
|
||
const float t = (y - o[1]) / d[1];
|
||
if (t <= 0 || t > 1e4f) return false;
|
||
for (int i = 0; i < 3; ++i) out[i] = o[i] + d[i] * t;
|
||
return true;
|
||
}
|
||
// Where you stand up from a seat (Frame Home: its exit, turned by its yaw; half a metre in front when it has none),
|
||
// on the floor below it.
|
||
void seatExitWorld(const WorldPoint& st, float out[3]) const {
|
||
const float c = std::cos(st.yaw), s = std::sin(st.yaw);
|
||
float ex = st.exit[0], ez = st.exit[2];
|
||
if (std::hypot(ex, ez) < 0.05f) { ex = 0; ez = -0.5f; }
|
||
out[0] = st.pos[0] + c * ex + s * ez;
|
||
out[2] = st.pos[2] - s * ex + c * ez;
|
||
out[1] = st.pos[1] - 0.45f;
|
||
if (walkShown) { float f; if (walkShown->floorAt(out[0], out[2], st.pos[1], 0.3f, 2.0f, f)) out[1] = f; }
|
||
}
|
||
// Dragging things in the view: press on a world item (a wall, a seat, a spawn...), a home object, or a seat's
|
||
// stand-up spot, and move the mouse: it slides over its own level, one undo step.
|
||
enum DragKind { kDragNone, kDragWorld, kDragObject, kDragExit };
|
||
int itemDrag = kDragNone, dragIndex = -1, dragKindW = -1;
|
||
bool dragMoved = false;
|
||
float dragY = 0, dragGrab[2] = {0, 0};
|
||
ImVec2 dragFrom;
|
||
|
||
// Where on object `only` the viewport pixel is (world point and its triangle), if it's on it.
|
||
bool pickOn(int only, float px, float py, float* hit, long* tri = nullptr) {
|
||
float t;
|
||
long k;
|
||
if (pickRay(px, py, only, &t, &k) != only) return false;
|
||
const float ty = std::tan(kFovY * 0.5f), tx = ty * viewSize.x / viewSize.y;
|
||
const float u = (px - viewPos.x) / viewSize.x, v = (py - viewPos.y) / viewSize.y;
|
||
float pose[16];
|
||
cam.pose(pose);
|
||
const float dc[3] = {(2 * u - 1) * tx, (1 - 2 * v) * ty, -1};
|
||
float dir[3];
|
||
direction(pose, dc, dir);
|
||
for (int i = 0; i < 3; ++i) hit[i] = pose[12 + i] + dir[i] * t;
|
||
if (tri) *tri = k;
|
||
return true;
|
||
}
|
||
|
||
// Nearest visible object under a viewport pixel (ray against its triangles, in object space).
|
||
int pick(float px, float py) { return pickRay(px, py, -1, nullptr, nullptr); }
|
||
// The same, against one object only (only >= 0); outT: the distance along the (world) ray, outTri its triangle.
|
||
int pickRay(float px, float py, int only, float* outT, long* outTri) {
|
||
if (!scene || viewSize.x < 1 || viewSize.y < 1) return -1;
|
||
const float ty = std::tan(kFovY * 0.5f), tx = ty * viewSize.x / viewSize.y;
|
||
const float u = (px - viewPos.x) / viewSize.x, v = (py - viewPos.y) / viewSize.y;
|
||
float pose[16];
|
||
cam.pose(pose);
|
||
const float dc[3] = {(2 * u - 1) * tx, (1 - 2 * v) * ty, -1};
|
||
float origin[3] = {pose[12], pose[13], pose[14]}, dir[3];
|
||
direction(pose, dc, dir);
|
||
int best = -1;
|
||
float bestT = 1e30f;
|
||
long bestTri = -1;
|
||
for (size_t i = 0; i < objects.size(); ++i) {
|
||
const Object& o = objects[i];
|
||
if (o.deleted || o.hidden || o.skybox || (only >= 0 && static_cast<int>(i) != only)) continue;
|
||
float w[16], inv[16];
|
||
mul(o.pivot, o.offset, w);
|
||
if (!invert(w, inv)) continue;
|
||
float lo[3], ld[3];
|
||
point(inv, origin, lo);
|
||
direction(inv, dir, ld);
|
||
// slab test against the local bounds
|
||
float t0 = 0, t1 = bestT;
|
||
bool miss = false;
|
||
for (int k = 0; k < 3 && !miss; ++k) {
|
||
if (std::fabs(ld[k]) < 1e-12f) { miss = lo[k] < o.lmin[k] || lo[k] > o.lmax[k]; continue; }
|
||
float a = (o.lmin[k] - lo[k]) / ld[k], b = (o.lmax[k] - lo[k]) / ld[k];
|
||
if (a > b) std::swap(a, b);
|
||
t0 = std::max(t0, a);
|
||
t1 = std::min(t1, b);
|
||
miss = t0 > t1;
|
||
}
|
||
if (miss) continue;
|
||
const MeshData& md = scene->meshes[o.scene];
|
||
const float* P = md.positions.data();
|
||
const size_t nv = md.positions.size() / 3;
|
||
for (size_t t = 0; t + 2 < md.indices.size(); t += 3) {
|
||
const u32 ia = md.indices[t], ib = md.indices[t + 1], ic = md.indices[t + 2];
|
||
if (ia >= nv || ib >= nv || ic >= nv) continue;
|
||
const float* a = P + ia * 3; const float* b = P + ib * 3; const float* c = P + ic * 3;
|
||
float e1[3] = {b[0] - a[0], b[1] - a[1], b[2] - a[2]}, e2[3] = {c[0] - a[0], c[1] - a[1], c[2] - a[2]};
|
||
float pv[3] = {ld[1] * e2[2] - ld[2] * e2[1], ld[2] * e2[0] - ld[0] * e2[2], ld[0] * e2[1] - ld[1] * e2[0]};
|
||
float det = e1[0] * pv[0] + e1[1] * pv[1] + e1[2] * pv[2];
|
||
if (std::fabs(det) < 1e-12f) continue;
|
||
float id = 1.0f / det;
|
||
float tv[3] = {lo[0] - a[0], lo[1] - a[1], lo[2] - a[2]};
|
||
float bu = (tv[0] * pv[0] + tv[1] * pv[1] + tv[2] * pv[2]) * id;
|
||
if (bu < 0 || bu > 1) continue;
|
||
float qv[3] = {tv[1] * e1[2] - tv[2] * e1[1], tv[2] * e1[0] - tv[0] * e1[2], tv[0] * e1[1] - tv[1] * e1[0]};
|
||
float bv = (ld[0] * qv[0] + ld[1] * qv[1] + ld[2] * qv[2]) * id;
|
||
if (bv < 0 || bu + bv > 1) continue;
|
||
float t_ = (e2[0] * qv[0] + e2[1] * qv[1] + e2[2] * qv[2]) * id;
|
||
if (t_ > 1e-4f && t_ < bestT) { bestT = t_; best = static_cast<int>(i); bestTri = static_cast<long>(t / 3); }
|
||
}
|
||
}
|
||
if (outT) *outT = bestT;
|
||
if (outTri) *outTri = bestTri;
|
||
return best;
|
||
}
|
||
|
||
// ── Per frame ──
|
||
|
||
void renderViewport() {
|
||
rendered = false;
|
||
const uint32_t w = static_cast<uint32_t>(std::max(0.0f, viewSize.x * studioPerf.viewScale)),
|
||
h = static_cast<uint32_t>(std::max(0.0f, viewSize.y * studioPerf.viewScale)); // Settings: viewport resolution
|
||
if (!scene || w < 8 || h < 8) return;
|
||
if (w != viewW || h != viewH) {
|
||
renderer.resize(w, h);
|
||
if (viewTex) ImGui_ImplVulkan_RemoveTexture(viewTex);
|
||
viewTex = ImGui_ImplVulkan_AddTexture(viewSampler, renderer.layerView(0), VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL);
|
||
viewW = w;
|
||
viewH = h;
|
||
}
|
||
const float ty = std::tan(kFovY * 0.5f), tx = ty * w / h;
|
||
float pose[16], view[16];
|
||
cam.pose(pose);
|
||
invert(pose, view);
|
||
EyeView eye;
|
||
eye.proj = rendererProjection(tx, ty);
|
||
std::memcpy(eye.view.m, view, 64);
|
||
std::memcpy(eye.position, &pose[12], 12);
|
||
EyeView eyes[2] = {eye, eye};
|
||
const auto r0 = std::chrono::steady_clock::now();
|
||
{ // the timeline clock drives every animation: Clay / glTF on the CPU, HSL rigs + shaders on the GPU
|
||
const auto now = std::chrono::steady_clock::now();
|
||
const float dt = std::min(0.1f, std::chrono::duration<float>(now - animTick).count());
|
||
animTick = now;
|
||
if (animPlaying) {
|
||
animTime += dt;
|
||
if (animLength > 0 && animTime >= animLength) {
|
||
animBase += animLength * std::floor(animTime / animLength);
|
||
animTime = std::fmod(animTime, animLength);
|
||
}
|
||
}
|
||
if (scene->animate && !scene->animatedMeshes.empty()) scene->animate(animTime, scene->meshes);
|
||
}
|
||
// Clay / glTF loops are baked on the CPU above. HSL shaders (water, flipbooks, VAT, sway) and rigs read the
|
||
// clock the way the headset does, never wrapping: wrapping it made every shader animation jump each loop.
|
||
renderer.render(eyes, scene->animate ? animTime : animBase + animTime);
|
||
// Smoothed cost of the 3D frame (includes waiting for the previous one on the GPU).
|
||
renderMs = renderMs * 0.9f + 0.1f * std::chrono::duration<float, std::milli>(std::chrono::steady_clock::now() - r0).count();
|
||
rendered = true;
|
||
}
|
||
|
||
void shortcuts() {
|
||
ImGuiIO& io = ImGui::GetIO();
|
||
if (io.WantTextInput || flying) return;
|
||
const bool ctrl = io.KeyCtrl, shift = io.KeyShift, alt = io.KeyAlt;
|
||
if (ctrl && ImGui::IsKeyPressed(ImGuiKey_O, false)) openDialog();
|
||
if (ctrl && shift && ImGui::IsKeyPressed(ImGuiKey_S, false)) saveEnvironmentDialog();
|
||
else if (ctrl && ImGui::IsKeyPressed(ImGuiKey_S, false)) saveToLibrary();
|
||
if (ImGui::IsKeyPressed(ImGuiKey_F1, false)) showControls = !showControls;
|
||
// Mid gizmo drag or field edit: no undo / redo, delete or selection changes (Ctrl is also the snap key, and a
|
||
// selection change mid-drag left ImGuizmo dragging the next item).
|
||
if (ImGuizmo::IsUsingAny() || gizmoWasUsing || ImGui::IsAnyItemActive()) return;
|
||
if (ctrl && !shift && ImGui::IsKeyPressed(ImGuiKey_Z)) step(undo, redo);
|
||
if ((ctrl && shift && ImGui::IsKeyPressed(ImGuiKey_Z)) || (ctrl && ImGui::IsKeyPressed(ImGuiKey_Y))) step(redo, undo);
|
||
if (ctrl || !scene) return;
|
||
if (ImGui::IsKeyPressed(ImGuiKey_G, false)) tool = Tool::Move;
|
||
if (ImGui::IsKeyPressed(ImGuiKey_R, false)) tool = Tool::Rotate;
|
||
if (ImGui::IsKeyPressed(ImGuiKey_S, false)) tool = Tool::Scale;
|
||
if (ImGui::IsKeyPressed(ImGuiKey_B, false)) tool = Tool::DrawFloor;
|
||
if (tool == Tool::DrawFloor && ImGui::IsKeyPressed(ImGuiKey_LeftBracket)) floorBrush = std::max(0.05f, floorBrush / 1.25f);
|
||
if (tool == Tool::DrawFloor && ImGui::IsKeyPressed(ImGuiKey_RightBracket)) floorBrush = std::min(5.0f, floorBrush * 1.25f);
|
||
if (ImGui::IsKeyPressed(ImGuiKey_X, false) || ImGui::IsKeyPressed(ImGuiKey_Delete, false)) { if (wKind >= 0) deleteWorld(); else deleteSelected(); }
|
||
if (ImGui::IsKeyPressed(ImGuiKey_W, false)) showWorld = !showWorld;
|
||
if (ImGui::IsKeyPressed(ImGuiKey_I, false) || ImGui::IsKeyPressed(ImGuiKey_KeypadDivide, false)) toggleIsolate();
|
||
if (ImGui::IsKeyPressed(ImGuiKey_H, false)) { if (alt) unhideAll(); else if (selected >= 0) toggleHidden(selected); }
|
||
if (ImGui::IsKeyPressed(ImGuiKey_F, false) || ImGui::IsKeyPressed(ImGuiKey_KeypadDecimal, false)) { if (wKind >= 0) frameWorld(wKind, wIndex); else frameSelected(); }
|
||
if (ImGui::IsKeyPressed(ImGuiKey_Home, false)) frameAll();
|
||
if (ImGui::IsKeyPressed(ImGuiKey_Keypad0, false) || ImGui::IsKeyPressed(ImGuiKey_P, false)) cam.playerView();
|
||
if (ImGui::IsKeyPressed(ImGuiKey_Escape, false)) {
|
||
if (isolating) toggleIsolate();
|
||
selected = -1; wKind = wIndex = -1;
|
||
}
|
||
}
|
||
|
||
void menuBar() {
|
||
ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0);
|
||
const bool bar = ImGui::BeginMainMenuBar();
|
||
ImGui::PopStyleVar();
|
||
if (!bar) return;
|
||
{ // the brand: a small orange mark and the name, in the heading font
|
||
const float x = ImGui::GetCursorScreenPos().x, r = 3.5f * scale;
|
||
const float cy = ImGui::GetWindowPos().y + ImGui::GetWindowHeight() * 0.5f;
|
||
ImGui::GetWindowDrawList()->AddCircleFilled(ImVec2(x + r + 1 * scale, cy), r, col(kOrange));
|
||
ImGui::Dummy(ImVec2(r * 2 + 6 * scale, 0));
|
||
ImGui::PushFont(fontOr(g_fontBold));
|
||
ImGui::PushStyleColor(ImGuiCol_Text, hex(kOrange));
|
||
ImGui::TextUnformatted("Frame Studio");
|
||
ImGui::PopStyleColor();
|
||
ImGui::PopFont();
|
||
}
|
||
ImGui::Dummy(ImVec2(6 * scale, 0));
|
||
if (ImGui::BeginMenu("File")) {
|
||
if (ImGui::MenuItem("Open Home\u2026", "Ctrl+O")) openDialog();
|
||
{
|
||
const std::vector<std::string> mine = libraryEnvs();
|
||
if (ImGui::BeginMenu("My Environments")) {
|
||
if (mine.empty()) ImGui::TextDisabled("None yet: Save (Ctrl+S) puts what you make here");
|
||
for (size_t r = 0; r < mine.size(); ++r) {
|
||
ImGui::PushID(static_cast<int>(r) + 5000);
|
||
if (ImGui::MenuItem(recentDetails({mine[r]}).title.c_str())) open({mine[r]});
|
||
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", mine[r].c_str());
|
||
ImGui::PopID();
|
||
}
|
||
ImGui::Separator();
|
||
if (ImGui::MenuItem("Show the Folder")) platform::openPath(narrow(fs::path(libraryDir().c_str()).wstring()));
|
||
ImGui::EndMenu();
|
||
}
|
||
}
|
||
if (ImGui::BeginMenu("Open Recent", !recent.empty())) {
|
||
for (size_t r = 0; r < recent.size(); ++r) {
|
||
ImGui::PushID(static_cast<int>(r));
|
||
if (ImGui::MenuItem(displayName(recent[r]).c_str())) { const auto paths = recent[r]; open(paths); }
|
||
if (ImGui::IsItemHovered()) {
|
||
std::string tip;
|
||
for (const auto& p : recent[r]) tip += (tip.empty() ? "" : "\n") + p;
|
||
ImGui::SetTooltip("%s", tip.c_str());
|
||
}
|
||
ImGui::PopID();
|
||
}
|
||
ImGui::EndMenu();
|
||
}
|
||
if (ImGui::MenuItem("Add Vista\u2026", nullptr, false, scene != nullptr && homePaths.size() == 1)) addVistaDialog();
|
||
if (ImGui::MenuItem("Add Models\u2026", nullptr, false, scene != nullptr && !uploading)) importModelDialog();
|
||
ImGui::Separator();
|
||
if (ImGui::MenuItem(envFile.empty() ? "Save to My Environments" : "Save", "Ctrl+S", false, scene != nullptr && !saving && !uploading)) saveToLibrary();
|
||
if (ImGui::IsItemHovered())
|
||
ImGui::SetTooltip("%s", envFile.empty() ? "A new environment: saved into My Environments (Documents\\Frame Studio\\Environments) under its name"
|
||
: ("Into " + envFile).c_str());
|
||
if (ImGui::MenuItem("Save As\u2026", "Ctrl+Shift+S", false, scene != nullptr && !saving && !uploading)) saveEnvironmentDialog();
|
||
if (ImGui::BeginMenu("Auto-saved Versions", scene != nullptr)) {
|
||
const std::vector<fs::path> v = versions();
|
||
if (v.empty()) ImGui::TextDisabled("None yet: every 5 minutes with unsaved changes, one is kept (the last 10)");
|
||
for (size_t i = 0; i < v.size(); ++i) {
|
||
ImGui::PushID(static_cast<int>(i) + 8000);
|
||
if (ImGui::MenuItem(narrow(v[i].stem().wstring()).c_str())) open({narrow(v[i].wstring())});
|
||
if (ImGui::IsItemHovered()) ImGui::SetTooltip("Opens this version on its own (Save As to make it the one you keep)");
|
||
ImGui::PopID();
|
||
}
|
||
ImGui::EndMenu();
|
||
}
|
||
if (ImGui::MenuItem("Faster Saving", nullptr, fastPack)) {
|
||
fastPack = !fastPack;
|
||
if (FILE* f = platform::openFile((dataDir() / L"fast-pack.txt").c_str(), "wb")) { std::fputs(fastPack ? "1\n" : "0\n", f); std::fclose(f); }
|
||
}
|
||
if (ImGui::IsItemHovered())
|
||
ImGui::SetTooltip("Packs environments on all CPU cores: about 3x quicker to save, about 1%% bigger.\nOff: the smallest file. Either way nothing is lost.");
|
||
if (ImGui::MenuItem("Leave Out What Can't Be Seen", nullptr, optimizeMeshes)) {
|
||
optimizeMeshes = !optimizeMeshes;
|
||
if (FILE* f = platform::openFile((dataDir() / L"optimize-meshes.txt").c_str(), "wb")) { std::fputs(optimizeMeshes ? "1\n" : "0\n", f); std::fclose(f); }
|
||
}
|
||
if (ImGui::IsItemHovered())
|
||
ImGui::SetTooltip("When saving: geometry nobody can see from anywhere they can walk, sit or teleport to (the backs of\n"
|
||
"things, what's behind walls) is left out, so the headset draws less. Moved and deleted objects are kept whole.\n"
|
||
"Saving takes up to a minute longer.");
|
||
if (ImGui::MenuItem("Close Home", nullptr, false, scene != nullptr && !saving && !uploading && !texLoading && loadState != 1)) closeHome();
|
||
if (ImGui::MenuItem("Revert to Original", nullptr, false, scene != nullptr)) revertAll();
|
||
ImGui::Separator();
|
||
if (ImGui::MenuItem("Quit", "Alt+F4") && window.requestClose) window.requestClose();
|
||
ImGui::EndMenu();
|
||
}
|
||
if (ImGui::BeginMenu("Edit")) {
|
||
if (ImGui::MenuItem("Undo", "Ctrl+Z", false, !undo.empty())) step(undo, redo);
|
||
if (ImGui::MenuItem("Redo", "Ctrl+Shift+Z", false, !redo.empty())) step(redo, undo);
|
||
ImGui::Separator();
|
||
if (ImGui::MenuItem("Move", "G", tool == Tool::Move)) tool = Tool::Move;
|
||
if (ImGui::MenuItem("Rotate", "R", tool == Tool::Rotate)) tool = Tool::Rotate;
|
||
if (ImGui::MenuItem("Scale", "S", tool == Tool::Scale)) tool = Tool::Scale;
|
||
if (ImGui::MenuItem("Draw Floor", "B", tool == Tool::DrawFloor)) tool = Tool::DrawFloor;
|
||
ImGui::Separator();
|
||
if (ImGui::MenuItem("Hide Selected", "H", false, selected >= 0)) toggleHidden(selected);
|
||
if (ImGui::MenuItem("Unhide All", "Alt+H", false, scene != nullptr)) unhideAll();
|
||
if (ImGui::MenuItem("Delete Selected", "X", false, selected >= 0)) deleteSelected();
|
||
ImGui::Separator();
|
||
if (ImGui::MenuItem("Settings\u2026")) { showSettings = true; if (!headsetLoaded && !headsetBusy) headsetSettings(false); }
|
||
ImGui::EndMenu();
|
||
}
|
||
if (ImGui::BeginMenu("View")) {
|
||
if (ImGui::MenuItem("Player View", "Numpad 0")) cam.playerView();
|
||
if (ImGui::MenuItem("Frame Selected", "F", false, selected >= 0)) frameSelected();
|
||
if (ImGui::MenuItem("Frame All", "Home", false, scene != nullptr)) frameAll();
|
||
ImGui::Separator();
|
||
if (ImGui::MenuItem("Show Only Selected", "I", isolating, isolating || selected >= 0 || wKind >= 0)) toggleIsolate();
|
||
ImGui::Separator();
|
||
if (ImGui::MenuItem("The Home's Meshes", nullptr, showMeshes)) { showMeshes = !showMeshes; refreshVisibility(); }
|
||
ImGui::MenuItem("World Layer", "W", &showWorld);
|
||
ImGui::BeginDisabled(!showWorld);
|
||
for (int k = 0; k < kShowCats; ++k) {
|
||
ImGui::PushStyleColor(ImGuiCol_Text, hex(kShowColors[k]));
|
||
ImGui::MenuItem((std::string(" ") + kShowNames[k]).c_str(), nullptr, &showCat[k]);
|
||
ImGui::PopStyleColor();
|
||
}
|
||
ImGui::EndDisabled();
|
||
ImGui::Separator();
|
||
if (ImGui::MenuItem("Show Everything")) {
|
||
showMeshes = showWorld = true;
|
||
for (bool& b : showCat) b = true;
|
||
isolating = false;
|
||
refreshVisibility();
|
||
}
|
||
ImGui::EndMenu();
|
||
}
|
||
if (ImGui::BeginMenu("World", scene != nullptr)) {
|
||
if (ImGui::BeginMenu("Add at the View's Centre")) {
|
||
if (ImGui::MenuItem("Spawn Point")) addWorld(kSpawn);
|
||
if (ImGui::MenuItem("Seat")) addWorld(kSeat);
|
||
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("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)
|
||
float fl = scene->world.fallLimit;
|
||
ImGui::SetNextItemWidth(120 * scale);
|
||
if (ImGui::DragFloat("Respawn after falling", &fl, 0.5f, 2.0f, 1000.0f, "%.0f m")) { scene->world.fallLimit = fl; worldEdited = dirty = true; }
|
||
if (ImGui::IsItemActivated()) pushWorldUndo();
|
||
if (ImGui::IsItemHovered()) ImGui::SetTooltip("In Frame Home, falling further than this puts you back at the spawn point.");
|
||
}
|
||
ImGui::Separator();
|
||
if (ImGui::MenuItem("Auto-generate Walkable Surfaces")) autoGenerate();
|
||
if (ImGui::IsItemHovered()) ImGui::SetTooltip("Bakes where you can walk from everything solid in the home and saves it with it:\nfloors with headroom you can reach from the start; furniture, plants and walls block; seams are bridged.\nObject Surfaces (Surface, Walk through) override it. Settings: the WALKABLE AREA panel.");
|
||
const bool objSel = selected >= 0 && canBeSurface(objects[selected]);
|
||
if (ImGui::BeginMenu("Selected Object", objSel)) {
|
||
const int mode = surfaceMode(objects[selected]);
|
||
for (int m = 0; m < kSurfModes; ++m) {
|
||
if (ImGui::MenuItem(m == kSurfNone ? "Auto (from its shape)" : kSurfNames[m], nullptr, mode == m) && mode != m) setSurfaces({static_cast<size_t>(selected)}, m);
|
||
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", kSurfHelp[m]);
|
||
}
|
||
ImGui::EndMenu();
|
||
}
|
||
if (ImGui::BeginMenu("All Visible Meshes")) {
|
||
const std::vector<size_t> all = surfaceCandidates();
|
||
ImGui::TextDisabled("%zu meshes (hidden ones are left out)", all.size());
|
||
if (ImGui::MenuItem("Make Surfaces")) setSurfaces(all, kSurfSolid);
|
||
ImGui::Separator();
|
||
if (ImGui::MenuItem("Remove Mesh Surfaces")) {
|
||
pushWorldUndo();
|
||
auto& cs = scene->world.colliders;
|
||
cs.erase(std::remove_if(cs.begin(), cs.end(), [](const WorldCollider& c) { return !c.mesh.empty(); }), cs.end());
|
||
worldReplaced();
|
||
wKind = wIndex = -1;
|
||
}
|
||
ImGui::EndMenu();
|
||
}
|
||
ImGui::Separator();
|
||
if (ImGui::MenuItem("Delete Selected Item", "Delete", false, wKind >= 0)) deleteWorld();
|
||
if (ImGui::MenuItem("Clear World Layer", nullptr, false, !scene->world.empty())) { pushWorldUndo(); scene->world = HomeWorld{}; scene->world.origin = "studio"; worldReplaced(); wKind = wIndex = -1; }
|
||
ImGui::Separator();
|
||
ImGui::TextDisabled("%s", scene->world.origin == "meta" ? "Converted from the home's Meta components" : scene->world.origin == "studio" ? "Edited in Frame Studio" : "No world layer yet");
|
||
ImGui::TextDisabled("%s", walkSaved() ? "Walking: the walkable area saved with the home" : "Walking: the walkable area Frame Home works out at load");
|
||
ImGui::EndMenu();
|
||
}
|
||
if (ImGui::BeginMenu("Headset")) {
|
||
if (ImGui::MenuItem("Send to Frame", nullptr, false, scene != nullptr && !sending)) sendToFrame();
|
||
ImGui::Separator();
|
||
if (ImGui::MenuItem("Install on Steam Frame\u2026")) openInstall();
|
||
if (ImGui::MenuItem("Headset Panel")) showInspectorTab(InspectorTab::Headset);
|
||
ImGui::EndMenu();
|
||
}
|
||
if (ImGui::BeginMenu("Debug")) {
|
||
static const char* views[] = {"Normal", "Wireframe", "Meshes", "GPU Cost", "Overdraw"};
|
||
static const char* tips[] = {nullptr, "Every triangle edge over the home: see-through meshes blue, cut-out green.",
|
||
"Each mesh in its own colour, shaded by its slope: no textures or lighting.",
|
||
"Tinted by what each mesh costs this PC's GPU: green cheap, yellow, red costly. The Home Report lists the costliest.",
|
||
"Everything added up, nothing hidden: bright where many surfaces cover the same pixels."};
|
||
for (int v = 0; v < HslRenderer::kDebugViews; ++v) {
|
||
const bool off = v == HslRenderer::kViewWireframe && !renderer.wireframeAvailable();
|
||
if (ImGui::MenuItem(views[v], nullptr, debugView == v, !off)) { debugView = v; renderer.setDebugView(v); debugView = renderer.debugView(); }
|
||
if (tips[v] && ImGui::IsItemHovered()) ImGui::SetTooltip("%s", off ? "This GPU can't draw lines." : tips[v]);
|
||
}
|
||
ImGui::Separator();
|
||
if (ImGui::BeginMenu("Leave Out")) {
|
||
static const char* parts[] = {"See-through Surfaces", "Cut-out Surfaces", "Sky", "Animated Meshes"};
|
||
for (int b = 0; b < 4; ++b) {
|
||
const unsigned bit = 1u << b;
|
||
if (ImGui::MenuItem(parts[b], nullptr, (debugHide & bit) != 0)) { debugHide ^= bit; renderer.debugHide = debugHide; }
|
||
}
|
||
ImGui::EndMenu();
|
||
}
|
||
ImGui::Separator();
|
||
if (ImGui::MenuItem("Home Report\u2026", nullptr, showReport, scene != nullptr && !uploading)) { showReport = !showReport; if (showReport) report.reset(); }
|
||
if (ImGui::MenuItem("Headset Debug\u2026", nullptr, showHeadsetDebug)) {
|
||
showHeadsetDebug = !showHeadsetDebug;
|
||
if (showHeadsetDebug && !headsetDebugLoaded && !headsetDebugBusy) headsetDebugCall(0);
|
||
}
|
||
ImGui::EndMenu();
|
||
}
|
||
if (ImGui::BeginMenu("Help")) {
|
||
if (ImGui::MenuItem("Controls", "F1", showControls)) showControls = !showControls;
|
||
ImGui::EndMenu();
|
||
}
|
||
ImGui::EndMainMenuBar();
|
||
}
|
||
|
||
// Eye toggle drawn like Blender's outliner restriction column.
|
||
bool eyeButton(const char* id, bool open) {
|
||
const float sz = ImGui::GetFrameHeight();
|
||
ImVec2 p = ImGui::GetCursorScreenPos();
|
||
bool clicked = ImGui::InvisibleButton(id, ImVec2(sz, sz * 0.8f));
|
||
ImDrawList* dl = ImGui::GetWindowDrawList();
|
||
ImVec2 c(p.x + sz * 0.5f, p.y + sz * 0.4f);
|
||
ImU32 color = col(ImGui::IsItemHovered() ? 0xffffff : open ? 0xc8c8c8 : 0x6a6a6a);
|
||
if (open) {
|
||
dl->AddEllipse(c, ImVec2(sz * 0.30f, sz * 0.17f), color, 0, 0, 1.4f * scale);
|
||
dl->AddCircleFilled(c, sz * 0.09f, color);
|
||
} else {
|
||
dl->PathArcTo(ImVec2(c.x, c.y - sz * 0.12f), sz * 0.30f, 0.25f * kPi, 0.75f * kPi, 12);
|
||
dl->PathStroke(color, 0, 1.4f * scale);
|
||
}
|
||
return clicked;
|
||
}
|
||
|
||
// The name a set of repeated objects shares: trailing numbers, "(n)", "INST", "LOD", "copy" and a "_v" version
|
||
// prefix are dropped ("LightFixtureB3" -> "LightFixtureB", "plant BB10 13 INST(1)" -> "plant BB").
|
||
static std::string familyOf(std::string n) {
|
||
auto strip = [&](void) -> bool {
|
||
const size_t len = n.size();
|
||
while (!n.empty() && (n.back() == ' ' || n.back() == '_' || n.back() == '-' || n.back() == '.')) n.pop_back();
|
||
if (!n.empty() && n.back() == ')') { const size_t o = n.rfind('('); if (o != std::string::npos) n.erase(o); }
|
||
while (!n.empty() && std::isdigit(static_cast<unsigned char>(n.back()))) n.pop_back();
|
||
for (const char* suf : {"inst", "INST", "Inst", "lod", "LOD", "copy", "Copy"}) {
|
||
const size_t sl = std::strlen(suf);
|
||
if (n.size() > sl && n.compare(n.size() - sl, sl, suf) == 0) { n.erase(n.size() - sl); break; }
|
||
}
|
||
if (n.size() > 2 && (n.back() == 'v' || n.back() == 'V') && !std::isalpha(static_cast<unsigned char>(n[n.size() - 2]))) n.pop_back();
|
||
return n.size() != len;
|
||
};
|
||
const std::string orig = n;
|
||
while (strip()) {}
|
||
return n.size() >= 3 ? n : orig;
|
||
}
|
||
|
||
// Play / pause, the scrubber over the home's animation loop, the time, and the ambience mute.
|
||
// ── The tool strip (left edge): the transform tools, Draw Floor, then how the gizmo and the view behave ──
|
||
enum class ToolIcon { Move, Rotate, Scale, DrawFloor, Global, Local, PlayerView, FrameAll };
|
||
|
||
// A small line icon in a square of side `size` centred on `c`.
|
||
void drawToolIcon(ImDrawList* dl, ToolIcon icon, ImVec2 c, float size, ImU32 color) {
|
||
const float r = size * 0.5f, t = std::max(1.0f, 1.6f * scale);
|
||
auto arrowHead = [&](ImVec2 tip, ImVec2 dir) { // dir: unit vector the arrow points along
|
||
const float l = r * 0.32f;
|
||
const ImVec2 back(tip.x - dir.x * l, tip.y - dir.y * l), side(-dir.y * l * 0.7f, dir.x * l * 0.7f);
|
||
dl->AddTriangleFilled(tip, ImVec2(back.x + side.x, back.y + side.y), ImVec2(back.x - side.x, back.y - side.y), color);
|
||
};
|
||
switch (icon) {
|
||
case ToolIcon::Move: {
|
||
dl->AddLine(ImVec2(c.x - r * 0.8f, c.y), ImVec2(c.x + r * 0.8f, c.y), color, t);
|
||
dl->AddLine(ImVec2(c.x, c.y - r * 0.8f), ImVec2(c.x, c.y + r * 0.8f), color, t);
|
||
arrowHead(ImVec2(c.x + r, c.y), ImVec2(1, 0)); arrowHead(ImVec2(c.x - r, c.y), ImVec2(-1, 0));
|
||
arrowHead(ImVec2(c.x, c.y - r), ImVec2(0, -1)); arrowHead(ImVec2(c.x, c.y + r), ImVec2(0, 1));
|
||
break;
|
||
}
|
||
case ToolIcon::Rotate: {
|
||
dl->PathArcTo(c, r * 0.72f, -1.2f, 3.9f, 24);
|
||
dl->PathStroke(color, 0, t);
|
||
arrowHead(ImVec2(c.x + std::cos(-1.2f) * r * 0.72f, c.y + std::sin(-1.2f) * r * 0.72f), ImVec2(0.93f, 0.36f));
|
||
break;
|
||
}
|
||
case ToolIcon::Scale: {
|
||
dl->AddRect(ImVec2(c.x - r * 0.8f, c.y - r * 0.1f), ImVec2(c.x + r * 0.1f, c.y + r * 0.8f), color, 1.5f * scale, 0, t);
|
||
dl->AddLine(ImVec2(c.x - r * 0.1f, c.y + r * 0.1f), ImVec2(c.x + r * 0.7f, c.y - r * 0.7f), color, t);
|
||
arrowHead(ImVec2(c.x + r * 0.85f, c.y - r * 0.85f), ImVec2(0.707f, -0.707f));
|
||
break;
|
||
}
|
||
case ToolIcon::DrawFloor: {
|
||
dl->AddLine(ImVec2(c.x + r * 0.75f, c.y - r * 0.75f), ImVec2(c.x - r * 0.15f, c.y + r * 0.15f), color, t * 1.6f);
|
||
dl->AddCircleFilled(ImVec2(c.x - r * 0.4f, c.y + r * 0.4f), r * 0.32f, color);
|
||
dl->AddLine(ImVec2(c.x - r * 0.95f, c.y + r * 0.9f), ImVec2(c.x + r * 0.2f, c.y + r * 0.9f), color, t);
|
||
break;
|
||
}
|
||
case ToolIcon::Global: {
|
||
dl->AddCircle(c, r * 0.8f, color, 24, t);
|
||
dl->AddEllipse(c, ImVec2(r * 0.35f, r * 0.8f), color, 0, 20, t);
|
||
dl->AddLine(ImVec2(c.x - r * 0.8f, c.y), ImVec2(c.x + r * 0.8f, c.y), color, t);
|
||
break;
|
||
}
|
||
case ToolIcon::Local: { // a little cube: its own axes
|
||
const float a = r * 0.55f, d = r * 0.3f;
|
||
dl->AddRect(ImVec2(c.x - a, c.y - a + d), ImVec2(c.x + a - d, c.y + a), color, 0, 0, t);
|
||
dl->AddLine(ImVec2(c.x - a, c.y - a + d), ImVec2(c.x - a + d, c.y - a), color, t);
|
||
dl->AddLine(ImVec2(c.x + a - d, c.y - a + d), ImVec2(c.x + a, c.y - a), color, t);
|
||
dl->AddLine(ImVec2(c.x + a - d, c.y + a), ImVec2(c.x + a, c.y + a - d), color, t);
|
||
dl->AddLine(ImVec2(c.x - a + d, c.y - a), ImVec2(c.x + a, c.y - a), color, t);
|
||
dl->AddLine(ImVec2(c.x + a, c.y - a), ImVec2(c.x + a, c.y + a - d), color, t);
|
||
break;
|
||
}
|
||
case ToolIcon::PlayerView: { // a head and shoulders
|
||
dl->AddCircle(ImVec2(c.x, c.y - r * 0.35f), r * 0.3f, color, 16, t);
|
||
dl->PathArcTo(ImVec2(c.x, c.y + r * 0.85f), r * 0.65f, 3.4f, 6.02f, 16);
|
||
dl->PathStroke(color, 0, t);
|
||
break;
|
||
}
|
||
case ToolIcon::FrameAll: { // four corners
|
||
const float a = r * 0.8f, l = r * 0.35f;
|
||
for (int sx = -1; sx <= 1; sx += 2)
|
||
for (int sy = -1; sy <= 1; sy += 2) {
|
||
const ImVec2 k(c.x + sx * a, c.y + sy * a);
|
||
dl->AddLine(k, ImVec2(k.x - sx * l, k.y), color, t);
|
||
dl->AddLine(k, ImVec2(k.x, k.y - sy * l), color, t);
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
// One square button of the strip; `on`: drawn as the active tool. True when clicked.
|
||
bool toolStripButton(const char* id, ToolIcon icon, bool on, const char* tip, uint32_t onColor = kBlue) {
|
||
const float side = 34 * scale;
|
||
const ImVec2 p = ImGui::GetCursorScreenPos();
|
||
ImGui::SetCursorScreenPos(ImVec2(p.x + 5 * scale, p.y));
|
||
const bool clicked = ImGui::InvisibleButton(id, ImVec2(side, side));
|
||
const bool hot = ImGui::IsItemHovered();
|
||
const ImVec2 a = ImGui::GetItemRectMin(), b = ImGui::GetItemRectMax();
|
||
ImDrawList* dl = ImGui::GetWindowDrawList();
|
||
if (on || hot) dl->AddRectFilled(a, b, col(on ? onColor : kToolHover), 6 * scale);
|
||
drawToolIcon(dl, icon, ImVec2((a.x + b.x) * 0.5f, (a.y + b.y) * 0.5f), side * 0.5f, col(on ? 0xffffff : hot ? kText : 0xb4b8be));
|
||
if (hot) ImGui::SetTooltip("%s", tip);
|
||
ImGui::SetCursorScreenPos(ImVec2(p.x, b.y + 4 * scale));
|
||
return clicked;
|
||
}
|
||
|
||
void toolStrip(float width, float height) {
|
||
ImGui::PushStyleColor(ImGuiCol_ChildBg, hex(kBar));
|
||
ImGui::BeginChild("toolStrip", ImVec2(width, height), ImGuiChildFlags_None, ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse);
|
||
ImGui::PopStyleColor();
|
||
ImGui::SetCursorPosY(8 * scale);
|
||
ImGui::BeginDisabled(!scene);
|
||
if (toolStripButton("##tMove", ToolIcon::Move, tool == Tool::Move, "Move (G) \u00b7 hold Ctrl to snap")) tool = Tool::Move;
|
||
if (toolStripButton("##tRotate", ToolIcon::Rotate, tool == Tool::Rotate, "Rotate (R) \u00b7 hold Ctrl to snap 15\u00b0")) tool = Tool::Rotate;
|
||
if (toolStripButton("##tScale", ToolIcon::Scale, tool == Tool::Scale, "Scale (S) \u00b7 hold Ctrl to snap")) tool = Tool::Scale;
|
||
if (toolStripButton("##tFloor", ToolIcon::DrawFloor, tool == Tool::DrawFloor,
|
||
"Draw Floor (B) \u00b7 select an object, then drag over it to draw walkable floor on it\n"
|
||
"Shift+drag erases \u00b7 [ and ] size the brush", kGo))
|
||
tool = Tool::DrawFloor;
|
||
{ // a hairline between the tools and the view options
|
||
const ImVec2 p = ImGui::GetCursorScreenPos();
|
||
ImGui::GetWindowDrawList()->AddLine(ImVec2(p.x + 10 * scale, p.y + 2 * scale), ImVec2(p.x + width - 10 * scale, p.y + 2 * scale), col(kLine), 1.0f);
|
||
ImGui::SetCursorScreenPos(ImVec2(p.x, p.y + 8 * scale));
|
||
}
|
||
if (toolStripButton("##tSpace", localSpace ? ToolIcon::Local : ToolIcon::Global, false,
|
||
localSpace ? "Gizmo: the object's own axes (click: the world's)" : "Gizmo: the world's axes (click: the object's own)"))
|
||
localSpace = !localSpace;
|
||
if (toolStripButton("##tPlayer", ToolIcon::PlayerView, false, "Player view (P or Numpad 0) \u00b7 from the middle of the home, at head height"))
|
||
cam.playerView();
|
||
if (toolStripButton("##tFrameAll", ToolIcon::FrameAll, false, "Frame everything (Home)")) frameAll();
|
||
ImGui::EndDisabled();
|
||
ImGui::EndChild();
|
||
}
|
||
|
||
// The timeline row under the 3D view: play / pause, the scrubber over the home's animation loop, the sound switch.
|
||
void timelineRow(const ImVec2& at, const ImVec2& size) {
|
||
ImGui::SetCursorScreenPos(at);
|
||
ImGui::BeginChild("timeline", size, ImGuiChildFlags_None, ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse);
|
||
timeline(ImGui::GetWindowDrawList(), at, ImVec2(at.x + size.x, at.y + size.y));
|
||
ImGui::EndChild();
|
||
}
|
||
|
||
void timeline(ImDrawList* dl, const ImVec2& a, const ImVec2& end) {
|
||
const float h = end.y - a.y;
|
||
dl->AddRectFilled(a, end, col(kBar));
|
||
dl->AddLine(a, ImVec2(end.x, a.y), col(kSplit), 1.0f);
|
||
const float pad = 8 * scale, bw = h - 2 * pad + 18 * scale;
|
||
ImGui::SetCursorScreenPos(ImVec2(a.x + pad, a.y + pad * 0.6f));
|
||
ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(6 * scale, 3 * scale));
|
||
// as wide as its wider label in the font in use (not a fixed width: Linux's fonts run wider than Segoe UI)
|
||
const float playW = std::max(bw + 20 * scale, ImGui::CalcTextSize("\u275a\u275a Pause").x + ImGui::GetStyle().FramePadding.x * 2 + 4 * scale);
|
||
if (ImGui::Button(animPlaying ? "\u275a\u275a Pause###play" : "\u25b6 Play###play", ImVec2(playW, 0))) animPlaying = !animPlaying;
|
||
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", animPlaying ? "Pause the home's animations" : "Play the home's animations");
|
||
ImGui::SameLine(0, pad);
|
||
const float right = 150 * scale;
|
||
ImGui::SetNextItemWidth(std::max(60.0f, end.x - ImGui::GetCursorScreenPos().x - right));
|
||
char fmt[48];
|
||
std::snprintf(fmt, sizeof fmt, "%%.1f / %.1f s", animLength);
|
||
if (ImGui::SliderFloat("##animtime", &animTime, 0.0f, std::max(0.1f, animLength), fmt)) animTick = std::chrono::steady_clock::now();
|
||
ImGui::SameLine(0, pad);
|
||
const bool muted = g_audioMuted.load();
|
||
// What is actually playing: the home may ship no ambience, or one that failed to decode / no audio device.
|
||
const bool playing = ambience.ok || addedSound.ok;
|
||
const char* label = playing ? (muted ? "Sound off" : "Sound on")
|
||
: scene && (!scene->ambientOgg.empty() || sound.bytes) ? (sound.s.homeVolume <= 0 && !sound.bytes ? "Sound off" : "Sound failed") : "No sound";
|
||
if (ImGui::Button(label, ImVec2(right - 2 * pad, 0)) && playing) g_audioMuted.store(!muted);
|
||
if (ImGui::IsItemHovered())
|
||
ImGui::SetTooltip("%s", addedSound.ok ? "Your background sound (and the home's own) \u00b7 click to mute"
|
||
: ambience.ok ? (scene->ambientSource == "FMOD bank" ? "Ambience from the home's FMOD bank \u00b7 click to mute"
|
||
: "Ambience loop \u00b7 click to mute")
|
||
: scene && !scene->ambientOgg.empty() ? "The ambience couldn't be played (no audio device?) \u2014 see studio.log"
|
||
: "This home ships no ambience");
|
||
ImGui::PopStyleVar();
|
||
}
|
||
|
||
// Right-click menu for an object (viewport and explorer): what it is for the player first, then the usual.
|
||
bool objMenuOpen = false; // --selftest: the viewport's object menu is showing
|
||
int menuObject = -1;
|
||
ImVec2 surfButtonAt[kSurfModes]; // --selftest: the object panel's Surface buttons
|
||
bool surfScroll = false; // --selftest: scroll the panel to them (a small window hides them)
|
||
bool rArmed = false; // right button down on the viewport: a click opens the menu, a drag flies
|
||
float rMoved = 0;
|
||
void surfaceItems(const std::vector<size_t>& objs) {
|
||
int mode = -2; // the one they share (-1: mixed)
|
||
size_t can = 0;
|
||
for (size_t i : objs) { if (!canBeSurface(objects[i])) continue; ++can; const int m = surfaceMode(objects[i]); mode = mode == -2 ? m : mode == m ? m : -1; }
|
||
ImGui::TextDisabled(objs.size() > 1 ? "Surface (all %zu)" : "Surface", can);
|
||
for (int m = 0; m < kSurfModes; ++m) {
|
||
const uint32_t tint = m == kSurfSolid ? 0xc8b45a : m == kSurfObject ? 0xb07aff : kText;
|
||
ImGui::PushStyleColor(ImGuiCol_Text, hex(can ? tint : kDim));
|
||
const bool hit = ImGui::MenuItem((std::string(" ") + kSurfNames[m]).c_str(), nullptr, mode == m, can > 0);
|
||
ImGui::PopStyleColor();
|
||
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) ImGui::SetTooltip("%s", can ? kSurfHelp[m] : "Not solid (see-through, glow, sky or a far backdrop)");
|
||
if (hit && mode != m) {
|
||
std::vector<size_t> v;
|
||
for (size_t i : objs) if (canBeSurface(objects[i])) v.push_back(i);
|
||
setSurfaces(v, m);
|
||
}
|
||
}
|
||
}
|
||
void objectMenu(size_t i) {
|
||
ImGui::TextUnformatted(objects[i].label.c_str());
|
||
ImGui::Separator();
|
||
surfaceItems({i});
|
||
ImGui::Separator();
|
||
if (ImGui::MenuItem("Frame", "F")) { selected = static_cast<int>(i); wKind = wIndex = -1; frameSelected(); }
|
||
if (ImGui::MenuItem(objects[i].hidden ? "Unhide" : "Hide", "H")) toggleHidden(i);
|
||
if (ImGui::MenuItem(isolating ? "Show All" : "Show Only This", "I")) { selected = static_cast<int>(i); wKind = wIndex = -1; toggleIsolate(); }
|
||
if (ImGui::MenuItem("Delete", "X")) { selected = static_cast<int>(i); wKind = wIndex = -1; deleteSelected(); }
|
||
}
|
||
|
||
// ── Files: everything inside the open home's files, by what each thing was. Meta's homes keep each file as it
|
||
// was cooked: "<the original file>/<hash>/<cooked form>" ("t_lamp.png/8d1b8474/tex_r676"), a scene's
|
||
// meshes and materials inside it ("shell.usda/__mesh_sub_targets___ms61/root_xform_sm_lbg_vasec_wxuu").
|
||
// Here the original names and folders show, a scene's meshes, materials and collision meshes grouped under
|
||
// it, each with what it is; saved, each becomes what it was again (a texture a PNG, a mesh an OBJ, a sound
|
||
// bank its .bank or the sound in it), or exactly as stored. Read in the background the first time. ──
|
||
enum SaveAs { kSaveRaw, kSavePng, kSaveObj };
|
||
struct FileEntry {
|
||
std::string name, kind; // its original name, and what it is ("Texture", "Mesh", "Sound bank")
|
||
uint64_t size = 0;
|
||
std::vector<std::string> chain; // the file, then the entries into the archives (as stored)
|
||
std::string saveName; // what it was
|
||
int saveAs = kSaveRaw;
|
||
};
|
||
struct FileDir { std::map<std::string, FileDir> dirs; std::vector<FileEntry> files; uint64_t bytes = 0; size_t count = 0; };
|
||
struct FileTree { std::string file; FileDir root; };
|
||
std::vector<FileTree> fileTrees;
|
||
std::string filesFor; // the home they were read for
|
||
std::mutex filesMutex;
|
||
std::thread filesReader;
|
||
std::atomic<bool> filesBusy{false};
|
||
static std::string lowerOf(std::string n) { for (char& c : n) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c))); return n; }
|
||
static std::string extOf(const std::string& n) { const size_t d = n.find_last_of('.'); return d == std::string::npos ? std::string() : lowerOf(n.substr(d)); }
|
||
static bool isArchiveName(const std::string& n) {
|
||
const std::string e = extOf(n);
|
||
return e == ".zip" || e == ".ovrscene" || e == ".fenv" || e == ".apk";
|
||
}
|
||
// A file that was something before it was cooked: a texture, a scene, a sound bank, a template, a shader...
|
||
static bool isSourceName(const std::string& n) {
|
||
static const std::set<std::string> k = {".png", ".jpg", ".jpeg", ".tga", ".exr", ".hdr", ".bank", ".usda", ".usd", ".usdc", ".fbx",
|
||
".hstf", ".surface", ".wav", ".ogg", ".gltf", ".glb", ".json", ".jsonc", ".ktx", ".mat", ".anim"};
|
||
return k.count(extOf(n)) != 0;
|
||
}
|
||
static bool isHashSeg(const std::string& s) { return s.size() == 8 && s.find_first_not_of("0123456789abcdef") == std::string::npos; }
|
||
// "root_xform_sm_lbg_vasec_wxuu" -> "root_xform_sm_lbg_vasec" (the cook's 4-character tag)
|
||
static std::string untagged(const std::string& s) {
|
||
if (s.size() > 5 && s[s.size() - 5] == '_' && s.substr(s.size() - 4).find_first_not_of("0123456789abcdefghijklmnopqrstuvwxyz") == std::string::npos)
|
||
return s.substr(0, s.size() - 5);
|
||
return s;
|
||
}
|
||
// "__mesh_sub_targets___ms61" -> "Meshes"
|
||
static std::string groupName(const std::string& s) {
|
||
if (s.rfind("__mesh_sub_targets", 0) == 0) return "Meshes";
|
||
if (s.rfind("__material_sub_targets", 0) == 0) return "Materials";
|
||
if (s.rfind("__phys_mesh_sub_targets", 0) == 0) return "Collision meshes";
|
||
if (s.rfind("__phys_convex_mesh_sub_targets", 0) == 0) return "Convex collision meshes";
|
||
if (s.rfind("__tex_sub_targets", 0) == 0) return "Texture streams";
|
||
if (s.rfind("__", 0) == 0 && s.find("_sub_targets") != std::string::npos) {
|
||
std::string g = s.substr(2, s.find("_sub_targets") - 2);
|
||
for (char& c : g) if (c == '_') c = ' ';
|
||
if (!g.empty()) g[0] = static_cast<char>(std::toupper(static_cast<unsigned char>(g[0])));
|
||
return g + "s";
|
||
}
|
||
return s;
|
||
}
|
||
static std::string kindOfExt(const std::string& e) {
|
||
if (e == ".png" || e == ".jpg" || e == ".jpeg" || e == ".tga" || e == ".ktx" || e == ".exr" || e == ".hdr") return "Picture";
|
||
if (e == ".ogg" || e == ".wav" || e == ".mp3" || e == ".opus" || e == ".flac") return "Sound";
|
||
if (e == ".bank") return "Sound bank";
|
||
if (e == ".gltf" || e == ".glb" || e == ".fbx" || e == ".obj") return "Model";
|
||
if (e == ".hstf") return "Template";
|
||
if (e == ".json" || e == ".jsonc" || e == ".txt" || e == ".xml") return "Text";
|
||
if (e == ".dex") return "App code";
|
||
return "File";
|
||
}
|
||
// Where a stored entry goes in the tree, and what it is.
|
||
static void describe(const std::string& path, std::vector<std::string>& folders, FileEntry& f) {
|
||
std::vector<std::string> segs;
|
||
for (size_t a = 0; a <= path.size();) { const size_t b = std::min(path.find('/', a), path.size()); if (b > a) segs.push_back(path.substr(a, b - a)); a = b + 1; }
|
||
if (segs.empty()) return;
|
||
size_t k = segs.size() - 1;
|
||
for (size_t i = 0; i + 1 < segs.size(); ++i) if (isSourceName(segs[i])) { k = i; break; }
|
||
folders.assign(segs.begin(), segs.begin() + static_cast<std::ptrdiff_t>(k));
|
||
const std::string asset = segs[k];
|
||
if (k + 1 == segs.size()) { // a plain file, as it is
|
||
f.name = f.saveName = asset;
|
||
f.kind = kindOfExt(extOf(asset));
|
||
return;
|
||
}
|
||
std::vector<std::string> rest;
|
||
for (size_t i = k + 1; i < segs.size(); ++i) if (!isHashSeg(segs[i])) rest.push_back(segs[i]);
|
||
const std::string leaf = rest.empty() ? segs.back() : rest.back(), type = leaf.substr(0, leaf.find('_'));
|
||
const std::string stem = asset.substr(0, asset.find_last_of('.')), e = extOf(asset);
|
||
if (rest.size() <= 1 && rest.size() == 1 && untagged(leaf) == type) { // the file itself, cooked
|
||
f.name = asset;
|
||
if (type == "tex") {
|
||
f.kind = "Texture";
|
||
if (e == ".exr" || e == ".hdr") f.saveName = asset; // (HDR: kept as stored)
|
||
else { f.saveName = stem + ".png"; f.saveAs = kSavePng; }
|
||
} else if (type == "bank") { f.kind = "Sound bank"; f.saveName = asset; }
|
||
else if (type == "shader") { f.kind = "Shader"; f.saveName = asset; }
|
||
else if (type == "template") { f.kind = e == ".hstf" ? "Template" : "Template (from " + e.substr(1) + ")"; f.saveName = e == ".hstf" ? asset : stem + ".hstf"; }
|
||
else { f.kind = "Cooked " + type; f.saveName = asset; }
|
||
return;
|
||
}
|
||
folders.push_back(asset); // a scene: what it holds, grouped
|
||
for (size_t i = 0; i + 1 < rest.size(); ++i) folders.push_back(groupName(rest[i]));
|
||
f.name = untagged(leaf);
|
||
const std::string group = rest.size() > 1 ? groupName(rest[rest.size() - 2]) : std::string();
|
||
if (group == "Meshes") { f.kind = "Mesh"; f.saveName = f.name + ".obj"; f.saveAs = kSaveObj; }
|
||
else if (group == "Materials") { f.kind = "Material"; f.saveName = f.name + ".material"; }
|
||
else if (group == "Collision meshes" || group == "Convex collision meshes") {
|
||
f.kind = group == "Collision meshes" ? "Collision mesh" : "Convex collision mesh";
|
||
f.saveName = f.name + ".collision";
|
||
} else if (group == "Texture streams") { f.kind = "Texture stream (more detail for " + asset + ")"; f.saveName = stem + "." + f.name; }
|
||
else { f.kind = group.empty() ? "Part" : group; f.saveName = f.name; }
|
||
}
|
||
// An archive's entries into `dir` (archives inside it become folders, two levels deep).
|
||
static void listArchive(mz_zip_archive& z, const std::vector<std::string>& chain, FileDir& dir, int depth) {
|
||
for (mz_uint i = 0; i < mz_zip_reader_get_num_files(&z); ++i) {
|
||
mz_zip_archive_file_stat st;
|
||
if (!mz_zip_reader_file_stat(&z, i, &st) || st.m_is_directory) continue;
|
||
const std::string path = st.m_filename;
|
||
std::vector<std::string> ch = chain;
|
||
ch.push_back(path);
|
||
std::vector<std::string> folders;
|
||
FileEntry f;
|
||
f.size = st.m_uncomp_size;
|
||
f.chain = ch;
|
||
describe(path, folders, f);
|
||
FileDir* d = &dir;
|
||
for (const auto& fo : folders) d = &d->dirs[fo];
|
||
if (depth < 2 && isArchiveName(f.name) && st.m_uncomp_size < (1ull << 31)) {
|
||
std::vector<uint8_t> bytes(static_cast<size_t>(st.m_uncomp_size));
|
||
mz_zip_archive in = {};
|
||
if (mz_zip_reader_extract_to_mem(&z, i, bytes.data(), bytes.size(), 0) && mz_zip_reader_init_mem(&in, bytes.data(), bytes.size(), 0)) {
|
||
listArchive(in, ch, d->dirs[f.name], depth + 1);
|
||
mz_zip_reader_end(&in);
|
||
continue;
|
||
}
|
||
}
|
||
d->files.push_back(std::move(f));
|
||
}
|
||
}
|
||
static void tally(FileDir& d) {
|
||
d.bytes = 0; d.count = d.files.size();
|
||
for (const auto& f : d.files) d.bytes += f.size;
|
||
for (auto& kv : d.dirs) { tally(kv.second); d.bytes += kv.second.bytes; d.count += kv.second.count; }
|
||
}
|
||
void readFiles() {
|
||
std::string key;
|
||
for (const auto& p : homePaths) key += p + "|";
|
||
if (key == filesFor || filesBusy) return;
|
||
if (filesReader.joinable()) filesReader.join();
|
||
filesFor = key;
|
||
filesBusy = true;
|
||
{ std::lock_guard<std::mutex> l(filesMutex); fileTrees.clear(); }
|
||
filesReader = std::thread([this, paths = homePaths, key] {
|
||
std::vector<FileTree> trees;
|
||
for (const auto& p : paths) {
|
||
FileTree t;
|
||
t.file = p;
|
||
mz_zip_archive z = {};
|
||
if (mz_zip_reader_init_file(&z, p.c_str(), 0)) { listArchive(z, {p}, t.root, 0); mz_zip_reader_end(&z); }
|
||
else { // a model file: itself
|
||
std::error_code ec;
|
||
FileEntry f;
|
||
f.name = f.saveName = narrow(fs::path(widen(p)).filename().wstring());
|
||
f.kind = kindOfExt(extOf(f.name));
|
||
f.size = fs::file_size(fs::path(widen(p)), ec);
|
||
f.chain = {p};
|
||
t.root.files.push_back(f);
|
||
}
|
||
tally(t.root);
|
||
trees.push_back(std::move(t));
|
||
}
|
||
std::lock_guard<std::mutex> l(filesMutex);
|
||
if (filesFor == key) fileTrees = std::move(trees);
|
||
filesBusy = false;
|
||
});
|
||
}
|
||
// A file's bytes as stored, opened down its chain of archives.
|
||
static std::vector<uint8_t> readFileEntry(const std::vector<std::string>& chain) {
|
||
std::vector<uint8_t> bytes;
|
||
if (chain.empty()) return bytes;
|
||
if (chain.size() == 1) { std::ifstream f(fs::path(widen(chain[0])), std::ios::binary); bytes.assign(std::istreambuf_iterator<char>(f), std::istreambuf_iterator<char>()); return bytes; }
|
||
mz_zip_archive z = {};
|
||
if (!mz_zip_reader_init_file(&z, chain[0].c_str(), 0)) return bytes;
|
||
std::vector<uint8_t> outer;
|
||
for (size_t k = 1; k < chain.size(); ++k) {
|
||
size_t n = 0;
|
||
void* p = mz_zip_reader_extract_file_to_heap(&z, chain[k].c_str(), &n, 0);
|
||
mz_zip_reader_end(&z);
|
||
if (!p) return {};
|
||
bytes.assign(static_cast<uint8_t*>(p), static_cast<uint8_t*>(p) + n);
|
||
mz_free(p);
|
||
if (k + 1 == chain.size()) return bytes;
|
||
outer = std::move(bytes);
|
||
z = {};
|
||
if (!mz_zip_reader_init_mem(&z, outer.data(), outer.size(), 0)) return {};
|
||
}
|
||
return bytes;
|
||
}
|
||
static std::string sizeText(uint64_t b) {
|
||
char t[32];
|
||
if (b >= 1048576) std::snprintf(t, sizeof t, "%.1f MB", b / 1048576.0);
|
||
else if (b >= 1024) std::snprintf(t, sizeof t, "%.0f KB", b / 1024.0);
|
||
else std::snprintf(t, sizeof t, "%llu B", static_cast<unsigned long long>(b));
|
||
return t;
|
||
}
|
||
static bool isSoundKind(const std::string& k) { return k == "Sound" || k == "Sound bank"; }
|
||
// Saves a file: as what it was (asStored false), or exactly as stored; a sound bank's sound as an Ogg (asOgg).
|
||
void saveFileCopy(const FileEntry& f, bool asStored, bool asOgg = false) {
|
||
std::vector<uint8_t> bytes = readFileEntry(f.chain);
|
||
if (bytes.empty()) { setStatus("Couldn't read " + f.name); return; }
|
||
std::string name = asStored ? f.chain.back().substr(f.chain.back().find_last_of('/') + 1) : f.saveName;
|
||
if (asOgg) { // the sound in an FMOD bank, as the Ogg it plays
|
||
std::string why;
|
||
bytes = fsb5::ambienceOgg(std::vector<const std::vector<uint8_t>*>{&bytes}, &why);
|
||
if (bytes.empty()) { setStatus("No sound Frame Studio can play in " + f.name); return; }
|
||
const size_t q0 = why.find('"'), q1 = q0 == std::string::npos ? q0 : why.find('"', q0 + 1);
|
||
name = (q1 != std::string::npos ? why.substr(q0 + 1, q1 - q0 - 1) : f.name) + ".ogg";
|
||
} else if (!asStored && f.saveAs == kSavePng) { // a cooked texture: the picture again
|
||
RendtxtrInfo ti;
|
||
std::vector<uint8_t> rgba;
|
||
if (!parseRendtxtrHeader(bytes, ti) || static_cast<uint64_t>(ti.rawDataOffset) + ti.rawDataLen > bytes.size() ||
|
||
!astc::decodeASTC(bytes.data() + ti.rawDataOffset, ti.rawDataLen, ti.width, ti.height, ti.blockW, ti.blockH, rgba, false) ||
|
||
rgba.size() < static_cast<size_t>(ti.width) * ti.height * 4) {
|
||
setStatus("Couldn't turn " + f.name + " back into a picture: saving it as stored");
|
||
name = f.chain.back().substr(f.chain.back().find_last_of('/') + 1);
|
||
} else {
|
||
std::vector<uint8_t> png;
|
||
stbi_write_png_to_func([](void* ctx, void* data, int size) {
|
||
auto* v = static_cast<std::vector<uint8_t>*>(ctx);
|
||
v->insert(v->end(), static_cast<uint8_t*>(data), static_cast<uint8_t*>(data) + size);
|
||
}, &png, static_cast<int>(ti.width), static_cast<int>(ti.height), 4, rgba.data(), static_cast<int>(ti.width) * 4);
|
||
bytes = std::move(png);
|
||
}
|
||
} else if (!asStored && f.saveAs == kSaveObj) { // a cooked mesh: an OBJ of it
|
||
std::vector<float> pos, uv;
|
||
std::vector<u32> idx;
|
||
if (!parseRendMesh(bytes, pos, uv, idx) || pos.empty()) {
|
||
setStatus("Couldn't read " + f.name + " as a mesh: saving it as stored");
|
||
name = f.chain.back().substr(f.chain.back().find_last_of('/') + 1);
|
||
} else {
|
||
std::string o = "# " + f.name + ", from " + f.chain.back() + " (Frame Studio)\no " + f.name + "\n";
|
||
char ln[128];
|
||
for (size_t v = 0; v + 2 < pos.size(); v += 3) { std::snprintf(ln, sizeof ln, "v %.6g %.6g %.6g\n", pos[v], pos[v + 1], pos[v + 2]); o += ln; }
|
||
const bool hasUv = uv.size() / 2 == pos.size() / 3;
|
||
if (hasUv) for (size_t v = 0; v + 1 < uv.size(); v += 2) { std::snprintf(ln, sizeof ln, "vt %.6g %.6g\n", uv[v], 1.0f - uv[v + 1]); o += ln; }
|
||
for (size_t t = 0; t + 2 < idx.size(); t += 3) {
|
||
const u32 a = idx[t] + 1, b = idx[t + 1] + 1, c = idx[t + 2] + 1;
|
||
if (hasUv) std::snprintf(ln, sizeof ln, "f %u/%u %u/%u %u/%u\n", a, a, b, b, c, c);
|
||
else std::snprintf(ln, sizeof ln, "f %u %u %u\n", a, b, c);
|
||
o += ln;
|
||
}
|
||
bytes.assign(o.begin(), o.end());
|
||
}
|
||
}
|
||
saveSoundAs(bytes, name);
|
||
}
|
||
void filesSection(float h) {
|
||
const ImVec2 p = ImGui::GetCursorScreenPos();
|
||
const float w = ImGui::GetContentRegionAvail().x;
|
||
ImDrawList* dl = ImGui::GetWindowDrawList();
|
||
dl->AddRectFilled(p, ImVec2(p.x + w, p.y + h), col(0x2a2333));
|
||
const bool open = collapsed.count(3001) != 0; // closed until opened (reading takes a moment)
|
||
if (ImGui::Selectable("##fileshdr", false, ImGuiSelectableFlags_AllowOverlap, ImVec2(w, h))) {
|
||
if (open) collapsed.erase(3001); else collapsed.insert(3001);
|
||
}
|
||
if (ImGui::IsItemHovered()) ImGui::SetTooltip("Everything inside the home's files, under the names they had before Meta cooked them.\nRight-click one to save it as what it was (a texture a PNG, a mesh an OBJ), or exactly as stored.");
|
||
dl->AddText(ImVec2(p.x + 8 * scale, p.y + (h - ImGui::GetTextLineHeight()) * 0.5f), col(0xc9a8ff), open ? "▾ Files" : "▸ Files");
|
||
if (!open) return;
|
||
readFiles();
|
||
std::lock_guard<std::mutex> l(filesMutex);
|
||
if (fileTrees.empty()) {
|
||
ImGui::SetCursorPosX(ImGui::GetCursorPosX() + 14 * scale);
|
||
ImGui::TextDisabled(filesBusy ? "Reading the files…" : "No files");
|
||
return;
|
||
}
|
||
ImGui::PushStyleVar(ImGuiStyleVar_IndentSpacing, 14 * scale);
|
||
std::function<void(const FileDir&)> dir = [&](const FileDir& d) {
|
||
for (const auto& kv : d.dirs) {
|
||
ImGui::PushID(kv.first.c_str());
|
||
const bool group = kv.first == "Meshes" || kv.first == "Materials" || kv.first == "Collision meshes" ||
|
||
kv.first == "Convex collision meshes" || kv.first == "Texture streams";
|
||
if (group) ImGui::PushStyleColor(ImGuiCol_Text, hex(0x9ab8e0));
|
||
const bool o = ImGui::TreeNodeEx("##dir", ImGuiTreeNodeFlags_SpanAvailWidth, "%s", kv.first.c_str());
|
||
if (group) ImGui::PopStyleColor();
|
||
ImGui::SameLine();
|
||
ImGui::TextDisabled("%zu · %s", kv.second.count, sizeText(kv.second.bytes).c_str());
|
||
if (o) { dir(kv.second); ImGui::TreePop(); }
|
||
ImGui::PopID();
|
||
}
|
||
for (size_t i = 0; i < d.files.size(); ++i) {
|
||
const FileEntry& f = d.files[i];
|
||
ImGui::PushID(static_cast<int>(i));
|
||
ImGui::PushStyleColor(ImGuiCol_Text, hex(isSoundKind(f.kind) ? kOrange : 0xd8d8d8));
|
||
ImGui::TreeNodeEx("##file", ImGuiTreeNodeFlags_Leaf | ImGuiTreeNodeFlags_NoTreePushOnOpen | ImGuiTreeNodeFlags_SpanAvailWidth, "%s", f.name.c_str());
|
||
ImGui::PopStyleColor();
|
||
const bool hov = ImGui::IsItemHovered();
|
||
const bool menu = ImGui::BeginPopupContextItem("##filemenu");
|
||
ImGui::SameLine();
|
||
ImGui::TextDisabled("%s · %s", f.kind.c_str(), sizeText(f.size).c_str());
|
||
std::string stored;
|
||
for (size_t k = 0; k < f.chain.size(); ++k) stored += (k ? "\n › " : "") + (k ? f.chain[k] : narrow(fs::path(widen(f.chain[k])).filename().wstring()));
|
||
if (hov) ImGui::SetTooltip("%s (%s)\nStored in the home as:\n%s", f.name.c_str(), f.kind.c_str(), stored.c_str());
|
||
if (menu) {
|
||
ImGui::TextDisabled("%s · %s", f.name.c_str(), f.kind.c_str());
|
||
ImGui::Separator();
|
||
if (ImGui::MenuItem(("Save as " + f.saveName + "…").c_str())) saveFileCopy(f, false);
|
||
if (f.saveAs != kSaveRaw || f.saveName != f.chain.back().substr(f.chain.back().find_last_of('/') + 1))
|
||
if (ImGui::MenuItem("Save as Stored (cooked)…")) saveFileCopy(f, true);
|
||
if (f.kind == "Sound bank" && ImGui::MenuItem("Save Its Sound as Ogg…")) saveFileCopy(f, false, true);
|
||
if (ImGui::MenuItem("Copy Where It's Stored")) ImGui::SetClipboardText(stored.c_str());
|
||
ImGui::EndPopup();
|
||
}
|
||
ImGui::PopID();
|
||
}
|
||
};
|
||
for (size_t t = 0; t < fileTrees.size(); ++t) {
|
||
ImGui::PushID(static_cast<int>(t) + 31000);
|
||
const bool o = ImGui::TreeNodeEx("##src", ImGuiTreeNodeFlags_SpanAvailWidth | ImGuiTreeNodeFlags_DefaultOpen, "%s",
|
||
narrow(fs::path(widen(fileTrees[t].file)).filename().wstring()).c_str());
|
||
ImGui::SameLine();
|
||
ImGui::TextDisabled("%zu files · %s", fileTrees[t].root.count, sizeText(fileTrees[t].root.bytes).c_str());
|
||
if (o) { dir(fileTrees[t].root); ImGui::TreePop(); }
|
||
ImGui::PopID();
|
||
}
|
||
ImGui::PopStyleVar();
|
||
}
|
||
|
||
void outliner(float height) {
|
||
testEyes.clear();
|
||
ImGui::BeginChild("outliner", ImVec2(0, height), ImGuiChildFlags_AlwaysUseWindowPadding, ImGuiWindowFlags_NoScrollbar);
|
||
caption("EXPLORER");
|
||
ImGui::SameLine();
|
||
{ // the folders: as the file lays them out, or Studio's groups by kind
|
||
const char* mode = byFileLayout ? "as in the file" : "by kind";
|
||
ImGui::SetCursorPosX(ImGui::GetWindowContentRegionMax().x - ImGui::CalcTextSize(mode).x - ImGui::GetStyle().FramePadding.x * 2);
|
||
ImGui::PushStyleColor(ImGuiCol_Button, hex(kListAlt));
|
||
if (ImGui::SmallButton(mode)) byFileLayout = !byFileLayout;
|
||
ImGui::PopStyleColor();
|
||
if (ImGui::IsItemHovered()) ImGui::SetTooltip(byFileLayout ? "Folders as the home's file lays them out (click: by kind instead)"
|
||
: "Folders by kind: Architecture, Furniture & Props, Plants... (click: as in the file)");
|
||
}
|
||
ImGui::SetNextItemWidth(-1);
|
||
ImGui::InputTextWithHint("##filter", "Filter objects", filter, sizeof filter);
|
||
ImGui::Spacing();
|
||
ImGui::PushStyleColor(ImGuiCol_ChildBg, hex(kList));
|
||
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0, 0));
|
||
ImGui::BeginChild("rows", ImVec2(0, 0), ImGuiChildFlags_None);
|
||
ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(0, 0));
|
||
std::string f = filter;
|
||
for (char& c : f) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
|
||
if (!scene || objects.empty()) {
|
||
ImGui::SetCursorPos(ImVec2(10 * scale, 8 * scale));
|
||
ImGui::TextDisabled(scene ? "Loading…" : "No home open");
|
||
}
|
||
auto matches = [&](const Object& o) {
|
||
if (o.deleted) return false;
|
||
if (f.empty()) return true;
|
||
std::string n = o.label + " " + o.name;
|
||
for (char& c : n) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
|
||
return n.find(f) != std::string::npos;
|
||
};
|
||
int row = 0;
|
||
const float h = ImGui::GetFrameHeight(), indent = 14 * scale;
|
||
// Rows outside the list's visible part take their space and draw nothing: a home with thousands of objects
|
||
// drew every one of them every frame (storybook: ~1.5 ms of UI a frame).
|
||
auto rowVisible = [&]() {
|
||
const ImVec2 p = ImGui::GetCursorScreenPos();
|
||
if (ImGui::IsRectVisible(p, ImVec2(p.x + ImGui::GetContentRegionAvail().x, p.y + h))) return true;
|
||
ImGui::Dummy(ImVec2(ImGui::GetContentRegionAvail().x, h));
|
||
return false;
|
||
};
|
||
// A collapsible header row (arrow, label, count) with an eye that shows/hides everything inside it.
|
||
// Folders, object rows and world groups each get their own ID namespace: a folder key (src * 100 + category)
|
||
// and an object index are both small numbers, and sharing one space gave a folder's eye the same ID as an
|
||
// object's (ImGui's "conflicting ID" error; the folder's eye didn't hide it).
|
||
auto header = [&](int key, const std::string& text, int depth, const std::vector<size_t>& members, bool strong) -> bool {
|
||
const bool open = !f.empty() || !collapsed.count(key);
|
||
if (!rowVisible()) return open; // scrolled out of view: only its space
|
||
ImGui::PushID("folder");
|
||
ImGui::PushID(key);
|
||
const ImVec2 p = ImGui::GetCursorScreenPos();
|
||
const float w = ImGui::GetContentRegionAvail().x;
|
||
ImDrawList* dl = ImGui::GetWindowDrawList();
|
||
dl->AddRectFilled(p, ImVec2(p.x + w, p.y + h), col(strong ? 0x202020 : 0x262626));
|
||
ImGui::SetCursorScreenPos(p);
|
||
if (ImGui::Selectable("##hdr", false, ImGuiSelectableFlags_AllowOverlap, ImVec2(w, h)) && f.empty()) {
|
||
if (open) collapsed.insert(key); else collapsed.erase(key);
|
||
}
|
||
if (ImGui::BeginPopupContextItem("##hdrmenu")) { // one surface role for everything in the folder
|
||
ImGui::TextUnformatted(text.c_str());
|
||
ImGui::Separator();
|
||
surfaceItems(members);
|
||
ImGui::EndPopup();
|
||
}
|
||
const float x = p.x + 8 * scale + depth * indent, cy = p.y + h * 0.5f, a = h * 0.16f;
|
||
if (open) dl->AddTriangleFilled(ImVec2(x - a, cy - a * 0.6f), ImVec2(x + a, cy - a * 0.6f), ImVec2(x, cy + a * 0.8f), col(kDim));
|
||
else dl->AddTriangleFilled(ImVec2(x - a * 0.6f, cy - a), ImVec2(x - a * 0.6f, cy + a), ImVec2(x + a * 0.8f, cy), col(kDim));
|
||
const float ty = p.y + (h - ImGui::GetTextLineHeight()) * 0.5f;
|
||
{ // folder: a tab and a body, open folders drawn hollow
|
||
const float fx = x + 11 * scale, fw = 13 * scale, fh = 9 * scale, fy = cy - fh * 0.5f + 1 * scale;
|
||
const ImU32 fc = col(strong ? 0xd9a441 : 0xb98f45);
|
||
dl->AddRectFilled(ImVec2(fx, fy - 2 * scale), ImVec2(fx + fw * 0.45f, fy + 1 * scale), fc, 1.5f * scale);
|
||
if (open) dl->AddRect(ImVec2(fx, fy), ImVec2(fx + fw, fy + fh), fc, 1.5f * scale, 0, 1.3f * scale);
|
||
else dl->AddRectFilled(ImVec2(fx, fy), ImVec2(fx + fw, fy + fh), fc, 1.5f * scale);
|
||
}
|
||
const std::string count = std::to_string(members.size());
|
||
const ImVec2 cs = ImGui::CalcTextSize(count.c_str());
|
||
// Long folder names stop before the count and the eye instead of running under them.
|
||
dl->PushClipRect(p, ImVec2(p.x + w - h - cs.x - 16 * scale, p.y + h), true);
|
||
dl->AddText(ImVec2(x + 30 * scale, ty), col(strong ? kText : 0xc8c8c8), text.c_str());
|
||
dl->PopClipRect();
|
||
dl->AddText(ImVec2(p.x + w - h - cs.x - 10 * scale, ty), col(kDim), count.c_str());
|
||
bool anyShown = false;
|
||
for (size_t i : members) anyShown |= !objects[i].hidden;
|
||
ImGui::SetCursorScreenPos(ImVec2(p.x + w - h - 4 * scale, p.y + h * 0.1f));
|
||
const ImVec2 eyeAt = ImGui::GetCursorScreenPos();
|
||
if (eyeButton("eye", anyShown)) {
|
||
beginBatch();
|
||
for (size_t i : members) // anything shown: hide the rest of them too; all hidden: show them all
|
||
if (objects[i].hidden != anyShown) toggleHidden(i);
|
||
endBatch();
|
||
}
|
||
if (testing && ImGui::IsRectVisible(eyeAt, ImVec2(eyeAt.x + h, eyeAt.y + h * 0.8f))) testEyes.push_back({ImVec2(eyeAt.x + h * 0.5f, eyeAt.y + h * 0.4f), members});
|
||
ImGui::SetCursorScreenPos(ImVec2(p.x, p.y + h));
|
||
ImGui::PopID();
|
||
ImGui::PopID();
|
||
return open;
|
||
};
|
||
auto objectRow = [&](size_t i, int depth) {
|
||
if (!rowVisible()) { ++row; return; } // scrolled out of view: only its space (and its stripe parity)
|
||
Object& o = objects[i];
|
||
ImGui::PushID("object");
|
||
ImGui::PushID(static_cast<int>(i));
|
||
const ImVec2 p = ImGui::GetCursorScreenPos();
|
||
const float w = ImGui::GetContentRegionAvail().x;
|
||
if (row++ & 1) ImGui::GetWindowDrawList()->AddRectFilled(p, ImVec2(p.x + w, p.y + h), col(kListAlt));
|
||
const bool sel = selected == static_cast<int>(i);
|
||
ImGui::SetCursorScreenPos(p);
|
||
if (ImGui::Selectable("##row", sel, ImGuiSelectableFlags_AllowOverlap | ImGuiSelectableFlags_AllowDoubleClick, ImVec2(w, h))) {
|
||
selected = static_cast<int>(i);
|
||
wKind = wIndex = -1;
|
||
if (ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) frameSelected();
|
||
}
|
||
if (ImGui::IsItemHovered() && o.label != o.name) ImGui::SetTooltip("%s", o.name.c_str());
|
||
if (ImGui::BeginPopupContextItem("##rowmenu")) {
|
||
if (selected != static_cast<int>(i)) { selected = static_cast<int>(i); wKind = wIndex = -1; }
|
||
objectMenu(i);
|
||
ImGui::EndPopup();
|
||
}
|
||
ImDrawList* dl = ImGui::GetWindowDrawList();
|
||
const float ix = p.x + 10 * scale + depth * indent, iy = p.y + h * 0.5f, r = h * 0.2f;
|
||
dl->AddTriangleFilled(ImVec2(ix, iy - r), ImVec2(ix + r * 0.95f, iy + r * 0.7f), ImVec2(ix - r * 0.95f, iy + r * 0.7f),
|
||
col(o.hidden ? kDim : kOrange));
|
||
dl->PushClipRect(p, ImVec2(p.x + w - h - 6 * scale, p.y + h), true);
|
||
dl->AddText(ImVec2(ix + 12 * scale, p.y + (h - ImGui::GetTextLineHeight()) * 0.5f),
|
||
col(o.hidden ? kDim : sel ? 0xffffff : kText), o.label.c_str());
|
||
dl->PopClipRect();
|
||
ImGui::SetCursorScreenPos(ImVec2(p.x + w - h - 4 * scale, p.y + h * 0.1f));
|
||
const ImVec2 eyeAt = ImGui::GetCursorScreenPos();
|
||
if (eyeButton("eye", !o.hidden)) toggleHidden(i);
|
||
if (testing && ImGui::IsRectVisible(eyeAt, ImVec2(eyeAt.x + h, eyeAt.y + h * 0.8f))) testEyes.push_back({ImVec2(eyeAt.x + h * 0.5f, eyeAt.y + h * 0.4f), {i}});
|
||
ImGui::SetCursorScreenPos(ImVec2(p.x, p.y + h));
|
||
ImGui::PopID();
|
||
ImGui::PopID();
|
||
};
|
||
// Footprint / Vista > the folders of the file, as it's laid out (or by kind: Architecture, Furniture & Props,
|
||
// Plants, Lights & Glow, Terrain & Water, Sky) > objects
|
||
const int sources = scene ? static_cast<int>(scene->apkPaths.size()) : 0;
|
||
// The file's own folders: each folder's subfolders, then its objects, in the order the file has them.
|
||
std::function<void(const std::vector<size_t>&, size_t, int, const std::string&)> layoutTree =
|
||
[&](const std::vector<size_t>& members, size_t level, int depth, const std::string& keyPath) {
|
||
std::vector<std::string> order;
|
||
std::map<std::string, std::vector<size_t>> sub;
|
||
std::vector<size_t> here;
|
||
for (size_t i : members) {
|
||
if (objects[i].folders.size() <= level) { here.push_back(i); continue; }
|
||
const std::string& k = objects[i].folders[level];
|
||
if (!sub.count(k)) order.push_back(k);
|
||
sub[k].push_back(i);
|
||
}
|
||
for (const std::string& k : order) {
|
||
const std::string kp = keyPath + "/" + k;
|
||
const int key = static_cast<int>(std::hash<std::string>()(kp) & 0x3fffffff) | 0x40000000;
|
||
if (header(key, k, depth, sub[k], depth == 0)) layoutTree(sub[k], level + 1, depth + 1, kp);
|
||
}
|
||
for (size_t i : here) objectRow(i, depth);
|
||
};
|
||
for (int src = 0; src < sources; ++src) {
|
||
std::vector<size_t> inSource;
|
||
for (size_t i = 0; i < objects.size(); ++i) if (objects[i].source == src && matches(objects[i])) inSource.push_back(i);
|
||
if (inSource.empty()) continue;
|
||
int depth = 0;
|
||
if (sources > 1) {
|
||
const std::string title = std::string(src == 0 ? "Footprint · " : "Vista · ") +
|
||
prettyHome(scene->apkPaths[src]);
|
||
if (!header(1000 + src, title, 0, inSource, true)) continue;
|
||
depth = 1;
|
||
}
|
||
if (byFileLayout) { layoutTree(inSource, 0, depth, std::to_string(src)); continue; }
|
||
for (int cat = 0; cat < kCategoryCount; ++cat) {
|
||
std::vector<size_t> members;
|
||
for (size_t i : inSource) if (objects[i].category == cat) members.push_back(i);
|
||
if (members.empty()) continue;
|
||
if (!header(src * 100 + cat, kCategoryNames[cat], depth, members, false)) continue;
|
||
// Repeated objects (LightFixtureB, B2, B3...; plantBB10_7_INST, _13_INST(1)...) share a folder.
|
||
std::map<std::string, std::vector<size_t>> fam;
|
||
std::vector<std::string> order;
|
||
for (size_t i : members) {
|
||
const std::string k = familyOf(objects[i].label);
|
||
if (!fam.count(k)) order.push_back(k);
|
||
fam[k].push_back(i);
|
||
}
|
||
for (const std::string& k : order) {
|
||
const std::vector<size_t>& v = fam[k];
|
||
if (v.size() < 2) { objectRow(v[0], depth + 1); continue; }
|
||
const int key = static_cast<int>(std::hash<std::string>()(std::to_string(src * 100 + cat) + "/" + k) & 0x3fffffff) | 0x40000000;
|
||
if (header(key, k, depth + 1, v, false))
|
||
for (size_t i : v) objectRow(i, depth + 2);
|
||
}
|
||
}
|
||
}
|
||
// World: the home's spawns, seats, hotspots, walls and wall spots (Studio's own format, from Meta's when converted).
|
||
if (scene && !objects.empty()) {
|
||
int total = 0;
|
||
for (int k = 0; k < kWorldKinds; ++k) total += worldCount(k);
|
||
const ImVec2 p = ImGui::GetCursorScreenPos();
|
||
const float w = ImGui::GetContentRegionAvail().x;
|
||
ImDrawList* hdl = ImGui::GetWindowDrawList();
|
||
hdl->AddRectFilled(p, ImVec2(p.x + w, p.y + h), col(0x1d2330));
|
||
const bool open = !collapsed.count(2000);
|
||
if (ImGui::Selectable("##worldhdr", false, ImGuiSelectableFlags_AllowOverlap, ImVec2(w, h))) { if (open) collapsed.insert(2000); else collapsed.erase(2000); }
|
||
const std::string title = std::string(open ? "\u25be " : "\u25b8 ") + "World" + (scene->world.origin == "meta" ? " \u00b7 converted from Meta" : scene->world.origin == "studio" ? " \u00b7 edited" : "");
|
||
hdl->AddText(ImVec2(p.x + 8 * scale, p.y + (h - ImGui::GetTextLineHeight()) * 0.5f), col(0x8fb4ff), title.c_str());
|
||
const std::string cnt = std::to_string(total);
|
||
hdl->AddText(ImVec2(p.x + w - ImGui::CalcTextSize(cnt.c_str()).x - 10 * scale, p.y + (h - ImGui::GetTextLineHeight()) * 0.5f), col(kDim), cnt.c_str());
|
||
if (open) {
|
||
// One group per kind of thing: its eye shows / hides the whole group in the viewport, each row's eye
|
||
// that item alone.
|
||
for (int cat = 0; cat < kShowCats; ++cat) {
|
||
std::vector<std::pair<int, int>> items;
|
||
for (int k = 0; k < kWorldKinds; ++k)
|
||
for (int i = 0, n = worldCount(k); i < n; ++i)
|
||
if (worldCat(k, i) == cat) items.emplace_back(k, i);
|
||
if (items.empty()) continue;
|
||
ImGui::PushID("worldgroup");
|
||
ImGui::PushID(cat);
|
||
const ImVec2 gp = ImGui::GetCursorScreenPos();
|
||
const bool gopen = !collapsed.count(2100 + cat);
|
||
if (ImGui::Selectable("##grp", false, ImGuiSelectableFlags_AllowOverlap, ImVec2(w, h))) { if (gopen) collapsed.insert(2100 + cat); else collapsed.erase(2100 + cat); }
|
||
const float ty = gp.y + (h - ImGui::GetTextLineHeight()) * 0.5f;
|
||
hdl->AddCircleFilled(ImVec2(gp.x + 20 * scale, gp.y + h * 0.5f), 4 * scale, col(kShowColors[cat], showCat[cat] ? 1.0f : 0.3f));
|
||
const std::string gt = std::string(gopen ? "\u25be " : "\u25b8 ") + kShowNames[cat];
|
||
hdl->AddText(ImVec2(gp.x + 30 * scale, ty), col(showCat[cat] ? kText : kDim), gt.c_str());
|
||
const std::string gc = std::to_string(items.size());
|
||
hdl->AddText(ImVec2(gp.x + w - h - ImGui::CalcTextSize(gc.c_str()).x - 10 * scale, ty), col(kDim), gc.c_str());
|
||
ImGui::SetCursorScreenPos(ImVec2(gp.x + w - h - 4 * scale, gp.y + h * 0.1f));
|
||
if (eyeButton("geye", showCat[cat])) showCat[cat] = !showCat[cat];
|
||
ImGui::SetCursorScreenPos(ImVec2(gp.x, gp.y + h));
|
||
if (gopen) {
|
||
const size_t shown = std::min<size_t>(items.size(), 300);
|
||
for (size_t r = 0; r < shown; ++r) {
|
||
const int k = items[r].first, i = items[r].second;
|
||
ImGui::PushID(static_cast<int>(r));
|
||
const ImVec2 rp = ImGui::GetCursorScreenPos();
|
||
const bool sel = wKind == k && wIndex == i;
|
||
bool* hid = worldHidden(k, i);
|
||
if (ImGui::Selectable("##wrow", sel, ImGuiSelectableFlags_AllowOverlap | ImGuiSelectableFlags_AllowDoubleClick, ImVec2(w, h))) {
|
||
wKind = k; wIndex = i; selected = -1;
|
||
if (ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) frameWorld(k, i);
|
||
}
|
||
hdl->PushClipRect(rp, ImVec2(rp.x + w - h - 6 * scale, rp.y + h), true);
|
||
hdl->AddText(ImVec2(rp.x + 40 * scale, rp.y + (h - ImGui::GetTextLineHeight()) * 0.5f),
|
||
col(hid && *hid ? kDim : sel ? 0xffffff : kText), worldName(k, i).c_str());
|
||
hdl->PopClipRect();
|
||
ImGui::SetCursorScreenPos(ImVec2(rp.x + w - h - 4 * scale, rp.y + h * 0.1f));
|
||
if (hid && eyeButton("weye", !*hid)) { *hid = !*hid; worldEdited = dirty = true; updateTitle(); }
|
||
ImGui::SetCursorScreenPos(ImVec2(rp.x, rp.y + h));
|
||
ImGui::PopID();
|
||
}
|
||
if (items.size() > shown) { ImGui::SetCursorPosX(ImGui::GetCursorPosX() + 40 * scale); ImGui::TextDisabled("\u2026 %zu more", items.size() - shown); }
|
||
}
|
||
ImGui::PopID();
|
||
ImGui::PopID();
|
||
}
|
||
if (total == 0) {
|
||
ImGui::SetCursorPosX(ImGui::GetCursorPosX() + 14 * scale);
|
||
ImGui::TextDisabled("None yet \u2014 World menu \u203a Add");
|
||
}
|
||
}
|
||
}
|
||
if (scene && !objects.empty()) filesSection(h);
|
||
ImGui::PopStyleVar();
|
||
ImGui::EndChild();
|
||
ImGui::PopStyleVar();
|
||
ImGui::PopStyleColor();
|
||
ImGui::EndChild();
|
||
}
|
||
|
||
// A panel's section header: a small semibold caption with a hairline under it, a little air above it.
|
||
void section(const char* label) {
|
||
if (ImGui::GetCursorPosY() > ImGui::GetStyle().WindowPadding.y + 1) ImGui::Dummy(ImVec2(0, 6 * scale));
|
||
caption(label);
|
||
const ImVec2 p = ImGui::GetCursorScreenPos();
|
||
ImGui::GetWindowDrawList()->AddLine(ImVec2(p.x, p.y), ImVec2(p.x + ImGui::GetContentRegionAvail().x, p.y), col(kLine), 1.0f);
|
||
ImGui::Dummy(ImVec2(0, 5 * scale));
|
||
}
|
||
// The caption alone (section headers, the explorer's title, the start screen's lists).
|
||
void caption(const char* label) {
|
||
ImGui::PushFont(fontOr(g_fontBold));
|
||
ImGui::PushStyleColor(ImGuiCol_Text, hex(kHeading));
|
||
ImGui::TextUnformatted(label);
|
||
ImGui::PopStyleColor();
|
||
ImGui::PopFont();
|
||
}
|
||
|
||
// ── The Inspector (right): the selection's and the environment's settings, one tab each ──
|
||
enum class InspectorTab { Selection, World, Environment, Sound, Headset };
|
||
InspectorTab inspectorTab = InspectorTab::Selection;
|
||
int lastSelected = -1, lastWorldKind = -1, lastWorldIndex = -1; // selecting something new shows its tab
|
||
|
||
// A field's label, left of it, every field starting at the same column.
|
||
void fieldLabel(const char* text) {
|
||
ImGui::AlignTextToFramePadding();
|
||
ImGui::TextDisabled("%s", text);
|
||
ImGui::SameLine(78 * scale);
|
||
ImGui::SetNextItemWidth(-1);
|
||
}
|
||
|
||
void inspector(float height) {
|
||
if (selected != lastSelected || wKind != lastWorldKind || wIndex != lastWorldIndex) {
|
||
if (selected >= 0 || wKind >= 0) inspectorTab = InspectorTab::Selection;
|
||
lastSelected = selected; lastWorldKind = wKind; lastWorldIndex = wIndex;
|
||
}
|
||
static const struct { InspectorTab tab; const char* name; const char* tip; } kTabs[] = {
|
||
{InspectorTab::Selection, "Selected", "The object or world item you selected"},
|
||
{InspectorTab::World, "World", "Where you can walk: the walkable area and its settings"},
|
||
{InspectorTab::Environment, "Environment", "Its name, picture, what it's made of, and its headset settings"},
|
||
{InspectorTab::Sound, "Sound", "Its ambience, and a background sound of your own"},
|
||
{InspectorTab::Headset, "Headset", "Send it to the Steam Frame, or install Frame Home there"},
|
||
};
|
||
ImGui::BeginChild("inspector", ImVec2(0, height), ImGuiChildFlags_AlwaysUseWindowPadding);
|
||
if (ImGui::BeginTabBar("##inspectorTabs", ImGuiTabBarFlags_FittingPolicyResizeDown)) {
|
||
for (const auto& t : kTabs) {
|
||
const bool force = inspectorTab == t.tab && forceInspectorTab;
|
||
if (ImGui::BeginTabItem(t.name, nullptr, force ? ImGuiTabItemFlags_SetSelected : 0)) {
|
||
if (!forceInspectorTab) inspectorTab = t.tab;
|
||
ImGui::EndTabItem();
|
||
}
|
||
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", t.tip);
|
||
}
|
||
ImGui::EndTabBar();
|
||
}
|
||
forceInspectorTab = false;
|
||
ImGui::BeginChild("inspectorBody", ImVec2(0, 0), ImGuiChildFlags_None);
|
||
switch (inspectorTab) {
|
||
case InspectorTab::Selection: selectionTab(); break;
|
||
case InspectorTab::World: worldTab(); break;
|
||
case InspectorTab::Environment: environmentTab(); break;
|
||
case InspectorTab::Sound: soundTab(); break;
|
||
case InspectorTab::Headset: headsetTab(); break;
|
||
}
|
||
ImGui::EndChild();
|
||
ImGui::EndChild();
|
||
}
|
||
bool forceInspectorTab = false; // inspectorTab was set in code: the tab bar shows it
|
||
void showInspectorTab(InspectorTab t) { inspectorTab = t; forceInspectorTab = true; }
|
||
|
||
// Selected: the world item or object picked in the view or the Explorer.
|
||
void selectionTab() {
|
||
if (scene && wKind >= 0 && wIndex >= 0 && wIndex < worldCount(wKind)) {
|
||
section("WORLD ITEM");
|
||
ImGui::TextUnformatted(worldName(wKind, wIndex).c_str());
|
||
ImGui::TextDisabled("%s", kWorldNames[wKind]);
|
||
ImGui::Spacing();
|
||
bool changed = false, letGo = false; // letGo: a field edited and let go
|
||
std::shared_ptr<HomeWorld> before; // a one-shot edit (a combo, a checkbox): its undo step, pushed below
|
||
// Fields and drags: the layer as the field was activated, kept across frames and pushed as one undo step
|
||
// the first time the value really changes (just clicking a field leaves the history alone).
|
||
auto track = [&]() {
|
||
if (ImGui::IsItemActivated() && !fieldBefore) fieldBefore = std::make_shared<HomeWorld>(scene->world);
|
||
if (ImGui::IsItemEdited()) {
|
||
changed = true;
|
||
if (fieldBefore && !fieldPushed) { UndoStep u = {}; u.world = fieldBefore; undo.push_back(u); redo.clear(); fieldPushed = true; }
|
||
}
|
||
if (ImGui::IsItemDeactivatedAfterEdit()) letGo = true;
|
||
if (ImGui::IsItemDeactivated()) { fieldBefore.reset(); fieldPushed = false; }
|
||
};
|
||
if (WorldPoint* pt = worldPoint(wKind, wIndex)) {
|
||
char nm[128];
|
||
std::snprintf(nm, sizeof nm, "%s", pt->name.c_str());
|
||
fieldLabel("Name");
|
||
if (ImGui::InputText("##wname", nm, sizeof nm)) { pt->name = nm; changed = true; }
|
||
track();
|
||
fieldLabel("Position");
|
||
ImGui::DragFloat3("##wpos", pt->pos, 0.01f, 0, 0, "%.2f m"); track();
|
||
float deg = pt->yaw * 180.0f / kPi;
|
||
fieldLabel("Facing");
|
||
if (ImGui::DragFloat("##wyaw", °, 1.0f, -360, 360, "%.0f\u00b0")) pt->yaw = deg * kPi / 180.0f;
|
||
track();
|
||
if (wKind == kSeat) { fieldLabel("Stand up at"); ImGui::DragFloat3("##wexit", pt->exit, 0.01f, 0, 0, "%.2f m"); track(); }
|
||
} else if (wKind == kCollider) {
|
||
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"};
|
||
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) {
|
||
before = std::make_shared<HomeWorld>(scene->world);
|
||
c.role = roleOf[role];
|
||
changed = true;
|
||
}
|
||
ImGui::PushTextWrapPos(0);
|
||
ImGui::TextDisabled("%s", kRoleHelp[std::clamp(c.role, 0, 5)]);
|
||
ImGui::PopTextWrapPos();
|
||
fieldLabel("Shape");
|
||
ImGui::Text("%s%s", kinds[std::clamp(c.kind, 0, 3)],
|
||
c.kind == WorldCollider::Mesh ? (" \u00b7 " + std::to_string(c.indices.size() / 3) + " triangles").c_str() : "");
|
||
if (!c.mesh.empty()) {
|
||
// Made from a home object: it follows the object, so the object is what moves.
|
||
int obj = -1;
|
||
for (size_t o = 0; o < objects.size(); ++o) if (objectKey(objects[o]) == c.mesh) obj = static_cast<int>(o);
|
||
ImGui::TextDisabled("Made from %s \u2014 it moves with it.", obj >= 0 ? objects[obj].label.c_str() : "a home object");
|
||
if (obj >= 0 && ImGui::Button("Select the Object")) { selected = obj; wKind = wIndex = -1; }
|
||
} else {
|
||
float t[3], r[3], sc[3];
|
||
ImGuizmo::DecomposeMatrixToComponents(c.world, t, r, sc);
|
||
bool moved = false;
|
||
fieldLabel("Location"); moved |= ImGui::DragFloat3("##wloc", t, 0.01f, 0, 0, "%.2f m"); track();
|
||
fieldLabel("Rotation"); moved |= ImGui::DragFloat3("##wrot", r, 0.5f, 0, 0, "%.0f\u00b0"); track();
|
||
if (c.kind == WorldCollider::Mesh) { fieldLabel("Scale"); moved |= ImGui::DragFloat3("##wscl", sc, 0.01f, 0.01f, 1000.0f, "%.2f"); track(); }
|
||
if (moved) ImGuizmo::RecomposeMatrixFromComponents(t, r, sc, c.world);
|
||
if (c.kind == WorldCollider::Box) {
|
||
float size[3] = {c.half[0] * 2, c.half[1] * 2, c.half[2] * 2};
|
||
fieldLabel("Size");
|
||
if (ImGui::DragFloat3("##wsize", size, 0.01f, 0.02f, 200.0f, "%.2f m")) for (int k = 0; k < 3; ++k) c.half[k] = std::max(0.01f, size[k] * 0.5f);
|
||
track();
|
||
}
|
||
if (c.kind == WorldCollider::Sphere || c.kind == WorldCollider::Capsule) { fieldLabel("Radius"); ImGui::DragFloat("##wrad", &c.radius, 0.01f, 0.01f, 50.0f, "%.2f m"); track(); }
|
||
if (c.kind == WorldCollider::Capsule) { fieldLabel("Length"); ImGui::DragFloat("##wlen", &c.length, 0.01f, 0.0f, 50.0f, "%.2f m"); track(); }
|
||
}
|
||
} else if (wKind == kWallSpot) {
|
||
WorldWallSpot& ws = scene->world.wallSpots[wIndex];
|
||
float t[3], r[3], sc[3];
|
||
ImGuizmo::DecomposeMatrixToComponents(ws.world, t, r, sc);
|
||
bool moved = false;
|
||
fieldLabel("Location"); moved |= ImGui::DragFloat3("##wsloc", t, 0.01f, 0, 0, "%.2f m"); track();
|
||
fieldLabel("Rotation"); moved |= ImGui::DragFloat3("##wsrot", r, 0.5f, 0, 0, "%.0f\u00b0"); track();
|
||
if (moved) { sc[0] = sc[1] = sc[2] = 1; ImGuizmo::RecomposeMatrixFromComponents(t, r, sc, ws.world); }
|
||
fieldLabel("Width"); ImGui::DragFloat("##wwid", &ws.width, 0.01f, 0.1f, 20.0f, "%.2f m"); track();
|
||
fieldLabel("Height"); ImGui::DragFloat("##whei", &ws.height, 0.01f, 0.1f, 20.0f, "%.2f m"); track();
|
||
}
|
||
if (wKind == kGravity) {
|
||
WorldGravity& g = scene->world.gravity[wIndex];
|
||
char nm[128];
|
||
std::snprintf(nm, sizeof nm, "%s", g.name.c_str());
|
||
fieldLabel("Name");
|
||
if (ImGui::InputText("##gname", nm, sizeof nm)) { g.name = nm; changed = true; }
|
||
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; }
|
||
ImGui::PushTextWrapPos(0);
|
||
ImGui::TextDisabled("%s", kGravityHelp[std::clamp(g.kind, 0, 2)]);
|
||
ImGui::PopTextWrapPos();
|
||
float t[3], r[3], sc[3];
|
||
ImGuizmo::DecomposeMatrixToComponents(g.world, t, r, sc);
|
||
bool moved = false;
|
||
fieldLabel(g.kind == WorldGravity::Planet ? "Centre" : "Location"); moved |= ImGui::DragFloat3("##gloc", t, 0.01f, 0, 0, "%.2f m"); track();
|
||
if (g.kind != WorldGravity::Planet) { fieldLabel("Rotation"); moved |= ImGui::DragFloat3("##grot", r, 0.5f, 0, 0, "%.0f\u00b0"); track(); }
|
||
if (moved) { sc[0] = sc[1] = sc[2] = 1; ImGuizmo::RecomposeMatrixFromComponents(t, r, sc, g.world); }
|
||
if (g.kind == WorldGravity::Planet) { fieldLabel("Pull radius"); ImGui::DragFloat("##grad", &g.radius, 0.1f, 0.5f, 5000.0f, "%.1f m"); track(); }
|
||
else {
|
||
float size[3] = {g.half[0] * 2, g.half[1] * 2, g.half[2] * 2};
|
||
fieldLabel("Size");
|
||
if (ImGui::DragFloat3("##gsize", size, 0.05f, 0.2f, 5000.0f, "%.1f m")) for (int k = 0; k < 3; ++k) g.half[k] = std::max(0.1f, size[k] * 0.5f);
|
||
track();
|
||
}
|
||
fieldLabel("Priority"); ImGui::DragInt("##gpri", &g.priority, 0.1f, -100, 100); track();
|
||
if (ImGui::IsItemHovered()) ImGui::SetTooltip("Where zones overlap, the higher priority one decides down.");
|
||
}
|
||
if (bool* hid = worldHidden(wKind, wIndex)) {
|
||
bool vis = !*hid;
|
||
if (ImGui::Checkbox("Visible", &vis)) { *hid = !vis; worldEdited = dirty = true; updateTitle(); }
|
||
ImGui::SameLine();
|
||
}
|
||
if (ImGui::Button(isolating ? "Show All (I)" : "Show Only This (I)")) toggleIsolate();
|
||
if (before) { // one undo step per drag / field
|
||
UndoStep u = {};
|
||
u.world = before;
|
||
undo.push_back(u);
|
||
redo.clear();
|
||
}
|
||
if (changed) { dirty = worldEdited = true; scene->world.origin = "studio"; updateTitle(); }
|
||
if (letGo && wKind == kCollider) { // moved or resized by a field: overlapping placed surfaces become one
|
||
const int was = static_cast<int>(scene->world.colliders.size());
|
||
wIndex = combineTouching(wIndex);
|
||
if (static_cast<int>(scene->world.colliders.size()) != was) worldReplaced();
|
||
}
|
||
ImGui::Spacing();
|
||
pushDanger();
|
||
if (ImGui::Button("Delete")) deleteWorld();
|
||
ImGui::PopStyleColor(3);
|
||
}
|
||
|
||
section("OBJECT");
|
||
if (selected >= 0) {
|
||
Object& o = objects[selected];
|
||
ImGui::TextUnformatted(o.name.c_str());
|
||
ImGui::TextDisabled("%s \u00b7 %zu triangles", o.surface.c_str(), o.triangles);
|
||
ImGui::Spacing();
|
||
float t[3], r[3], s[3];
|
||
ImGuizmo::DecomposeMatrixToComponents(o.pivot, t, r, s);
|
||
bool changed = false;
|
||
bool done = false; // a field let go: its surfaces split again if it turned
|
||
fieldLabel("Location");
|
||
changed |= ImGui::DragFloat3("##loc", t, 0.01f, 0, 0, "%.3f m");
|
||
if (ImGui::IsItemActivated()) pushUndo(selected);
|
||
done |= ImGui::IsItemDeactivatedAfterEdit();
|
||
fieldLabel("Rotation");
|
||
changed |= ImGui::DragFloat3("##rot", r, 0.5f, 0, 0, "%.1f\u00b0");
|
||
if (ImGui::IsItemActivated()) pushUndo(selected);
|
||
done |= ImGui::IsItemDeactivatedAfterEdit();
|
||
fieldLabel("Scale");
|
||
changed |= ImGui::DragFloat3("##scl", s, 0.01f, 0.001f, 1000.0f, "%.3f");
|
||
if (ImGui::IsItemActivated()) pushUndo(selected);
|
||
done |= ImGui::IsItemDeactivatedAfterEdit();
|
||
if (changed) {
|
||
ImGuizmo::RecomposeMatrixFromComponents(t, r, s, o.pivot);
|
||
apply(o);
|
||
}
|
||
if (done) transformDone(static_cast<size_t>(selected));
|
||
// What this object is for the player: walk on it, bump into it, both, or neither. The buttons set it;
|
||
// the viewport tints what each part gives (floor green, wall orange) while it's selected.
|
||
if (canBeSurface(o)) {
|
||
const int mode = surfaceMode(o);
|
||
ImGui::Spacing();
|
||
ImGui::AlignTextToFramePadding();
|
||
ImGui::TextDisabled("Surface");
|
||
const float sp = 4 * scale, avail = ImGui::GetContentRegionAvail().x;
|
||
float tw[kSurfModes], sum = 0;
|
||
for (int m = 0; m < kSurfModes; ++m) sum += tw[m] = ImGui::CalcTextSize(kSurfNames[m]).x;
|
||
const float pad = std::max(4 * scale, (avail - sum - (kSurfModes - 1) * sp) / kSurfModes);
|
||
for (int m = 0; m < kSurfModes; ++m) {
|
||
if (m) ImGui::SameLine(0, sp);
|
||
const bool on = mode == m;
|
||
const uint32_t tint = m == kSurfSolid ? 0x7a6a2a : m == kSurfObject ? 0x5e3f8a : m == kSurfTrigger ? 0x3a5a7a : 0x4a4a4a;
|
||
ImGui::PushStyleColor(ImGuiCol_Button, on ? hex(tint) : hex(kWidget, 0.55f));
|
||
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, on ? hex(tint) : hex(kWidget));
|
||
ImGui::PushStyleColor(ImGuiCol_Text, on ? hex(0xffffff) : hex(0xc8c8c8));
|
||
const std::string id = std::string(kSurfNames[m]) + "##surf" + std::to_string(m);
|
||
if (ImGui::Button(id.c_str(), ImVec2(tw[m] + pad, 0)) && (!on || (m != kSurfNone && autoMade(o)))) setSurfaces({static_cast<size_t>(selected)}, m);
|
||
ImGui::PopStyleColor(3);
|
||
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", kSurfHelp[m]);
|
||
if (testing) surfButtonAt[m] = ImVec2((ImGui::GetItemRectMin().x + ImGui::GetItemRectMax().x) * 0.5f, (ImGui::GetItemRectMin().y + ImGui::GetItemRectMax().y) * 0.5f);
|
||
}
|
||
if (testing && surfScroll) { ImGui::SetScrollHereY(0.5f); surfScroll = false; }
|
||
ImGui::PushTextWrapPos(0);
|
||
if (mode == kSurfNone)
|
||
ImGui::TextDisabled("%s", kSurfHelp[kSurfNone]);
|
||
else {
|
||
ImGui::TextDisabled("%s", kSurfHelp[mode]);
|
||
double fa = 0, wa = 0;
|
||
surfaceAreas(static_cast<size_t>(selected), fa, wa);
|
||
auto swatch = [&](uint32_t c) {
|
||
const ImVec2 p = ImGui::GetCursorScreenPos();
|
||
const float sz = ImGui::GetTextLineHeight();
|
||
ImGui::GetWindowDrawList()->AddRectFilled(ImVec2(p.x, p.y + sz * 0.2f), ImVec2(p.x + sz * 0.6f, p.y + sz * 0.8f), col(c), 2 * scale);
|
||
ImGui::Dummy(ImVec2(sz * 0.6f, sz));
|
||
ImGui::SameLine(0, 4 * scale);
|
||
};
|
||
if (mode == kSurfSolid) {
|
||
swatch(kFloorTint); ImGui::Text("flat enough %.1f m\u00b2", fa);
|
||
ImGui::SameLine(0, 12 * scale);
|
||
swatch(kWallTint); ImGui::Text("too steep %.1f m\u00b2", wa);
|
||
}
|
||
if (autoMade(o)) ImGui::TextDisabled("Made by Auto-generate Walkable Surfaces (a re-run replaces it; click its button to make it yours).");
|
||
}
|
||
ImGui::PopTextWrapPos();
|
||
// a floor drawn on it by hand: always walkable, even on furniture
|
||
ImGui::Spacing();
|
||
ImGui::AlignTextToFramePadding();
|
||
ImGui::TextDisabled("Floor");
|
||
ImGui::SameLine();
|
||
const int df = drawnFloorIndex(o);
|
||
if (ImGui::Button(tool == Tool::DrawFloor ? "Drawing (B)" : "Draw (B)")) tool = Tool::DrawFloor;
|
||
if (ImGui::IsItemHovered()) ImGui::SetTooltip("Drag over the object in the view to draw walkable floor on it; Shift+drag erases.");
|
||
ImGui::SameLine();
|
||
if (ImGui::Button("Fill Flat Faces")) fillDrawnFloor(static_cast<size_t>(selected), true);
|
||
if (ImGui::IsItemHovered()) ImGui::SetTooltip("Every face of it flat enough to stand on becomes walkable floor.");
|
||
if (df >= 0) {
|
||
ImGui::SameLine();
|
||
if (ImGui::Button("Clear")) fillDrawnFloor(static_cast<size_t>(selected), false);
|
||
double a = 0;
|
||
const WorldCollider& c = scene->world.colliders[df];
|
||
const std::vector<float> wp = worldMeshPositions(c);
|
||
for (size_t t = 0; t + 2 < c.indices.size(); t += 3) {
|
||
const float* p0 = &wp[c.indices[t] * 3]; const float* p1 = &wp[c.indices[t + 1] * 3]; const float* p2 = &wp[c.indices[t + 2] * 3];
|
||
a += 0.5 * std::fabs((p1[0] - p0[0]) * (p2[2] - p0[2]) - (p2[0] - p0[0]) * (p1[2] - p0[2]));
|
||
}
|
||
ImGui::TextDisabled("%.1f m\u00b2 of floor drawn on it", a);
|
||
}
|
||
}
|
||
ImGui::Spacing();
|
||
bool visible = !o.hidden;
|
||
if (ImGui::Checkbox("Visible", &visible)) toggleHidden(selected);
|
||
ImGui::SameLine();
|
||
if (ImGui::Button("Reset")) { pushUndo(selected); std::memcpy(o.pivot, o.pivot0, 64); apply(o); transformDone(static_cast<size_t>(selected)); }
|
||
ImGui::SameLine();
|
||
if (ImGui::Button(isolating ? "Show All" : "Only This")) toggleIsolate();
|
||
ImGui::SameLine();
|
||
pushDanger();
|
||
if (ImGui::Button("Delete")) deleteSelected();
|
||
ImGui::PopStyleColor(3);
|
||
} else {
|
||
ImGui::TextDisabled(scene ? "Nothing selected.\nClick an object in the 3D view or the Explorer." : "Open a home to start.");
|
||
}
|
||
|
||
}
|
||
|
||
// World: the walkable area, as Frame Home walks it.
|
||
void worldTab() {
|
||
section("WALKABLE AREA");
|
||
walkPanel();
|
||
|
||
}
|
||
|
||
// Environment: its name and picture, what it's made of, saving, and its own headset settings.
|
||
void environmentTab() {
|
||
section("ENVIRONMENT");
|
||
if (scene) {
|
||
fieldLabel("Name");
|
||
{
|
||
char nb[128];
|
||
std::snprintf(nb, sizeof nb, "%s", envTitle.c_str());
|
||
if (ImGui::InputTextWithHint("##envname", homeName.c_str(), nb, sizeof nb)) { envTitle = nb; dirty = true; updateTitle(); }
|
||
if (ImGui::IsItemHovered()) ImGui::SetTooltip("What it's called on the headset (Environments panel) and in a saved .fenv");
|
||
}
|
||
fieldLabel("Picture");
|
||
if (ImGui::Button(envImage.empty() ? "Choose\u2026##envimg" : "Change\u2026##envimg")) chooseImage();
|
||
if (ImGui::IsItemHovered()) ImGui::SetTooltip("Its picture on the headset (Environments panel); without one, a view of it is taken when it's saved");
|
||
if (!envImage.empty()) {
|
||
ImGui::SameLine();
|
||
if (ImGui::Button("Remove##envimg")) { envImage.clear(); dirty = true; updateTitle(); }
|
||
ImGui::SameLine();
|
||
ImGui::TextDisabled("%s", envImage.substr(envImage.rfind(".image-") == std::string::npos ? 0 : envImage.rfind(".image-") + 1).c_str());
|
||
} else {
|
||
ImGui::SameLine();
|
||
ImGui::TextDisabled("a view of it");
|
||
}
|
||
ImGui::Spacing();
|
||
ImGui::TextWrapped("%s", homeName.c_str());
|
||
for (size_t i = 0; i < scene->sources.size(); ++i) {
|
||
const HslSourceInfo& src = scene->sources[i];
|
||
ImGui::PushStyleColor(ImGuiCol_Text, hex(kOrange));
|
||
ImGui::TextUnformatted(sourceLine(i).c_str());
|
||
ImGui::PopStyleColor();
|
||
if (!src.package.empty()) ImGui::TextDisabled("%s", src.package.c_str());
|
||
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", scene->apkPaths[i].c_str());
|
||
}
|
||
if (scene->sources.size() == 1 && scene->sources[0].role == "Footprint")
|
||
ImGui::TextDisabled("Room only: its lighting, sky and outside world ship in a vista.");
|
||
ImGui::Spacing();
|
||
size_t live = 0, hidden = 0, deleted = 0, lightmapped = 0;
|
||
for (const Object& o : objects) { if (o.deleted) ++deleted; else if (o.hidden) ++hidden; else ++live; }
|
||
for (const MeshData& md : scene->meshes) lightmapped += md.hasLightmap;
|
||
const bool clay = scene->animate != nullptr;
|
||
fieldLabel("Objects");
|
||
ImGui::Text("%zu shown, %zu hidden, %zu deleted", live, hidden, deleted);
|
||
fieldLabel("Shaders");
|
||
ImGui::Text(clay ? "built-in unlit (Clay)" : "%zu of the home's own", scene->programs.size());
|
||
fieldLabel("Lighting");
|
||
ImGui::Text("%s", lightmapped ? (std::to_string(lightmapped) + " baked lightmaps").c_str()
|
||
: clay ? "baked into textures" : scene->hasEnvAmbient ? "light probes" : "estimated ambient");
|
||
fieldLabel("Animation");
|
||
if (clay) ImGui::Text("%zu animated objects, %.0f s loop", scene->animatedMeshes.size(), scene->animDuration);
|
||
else ImGui::Text("%zu rigs + shader motion", scene->anims.size());
|
||
ImGui::Spacing();
|
||
ImGui::BeginDisabled(!dirty);
|
||
if (ImGui::Button(envFile.empty() ? "Save to My Environments" : "Save")) saveToLibrary();
|
||
ImGui::EndDisabled();
|
||
ImGui::SameLine();
|
||
ImGui::TextDisabled(dirty ? "unsaved changes" : "saved");
|
||
} else {
|
||
if (ImGui::Button("Open Home\u2026", ImVec2(-1, 0))) openDialog();
|
||
ImGui::TextDisabled("Or drop an .apk on the window.");
|
||
}
|
||
|
||
if (scene) {
|
||
section("WORLD SETTINGS");
|
||
{
|
||
std::lock_guard<std::mutex> l(statusMutex);
|
||
if (tunedReady) { tunedReady = false; if (tunedResult != worldSettings) { worldSettings = tunedResult; dirty = true; updateTitle(); } }
|
||
}
|
||
const WorldSettings was = worldSettings;
|
||
ImGui::PushTextWrapPos(0);
|
||
ImGui::TextDisabled("On the headset it uses %s", worldSettings.describe().c_str());
|
||
ImGui::PopTextWrapPos();
|
||
fieldLabel("Resolution");
|
||
{
|
||
static const float kRes[] = {0, 0.8f, 0.9f, 1.0f, 1.1f, 1.2f, 1.3f, 1.4f, 1.5f};
|
||
char cur[32];
|
||
if (worldSettings.resolution > 0) std::snprintf(cur, sizeof cur, "%.0f%%", worldSettings.resolution * 100);
|
||
if (ImGui::BeginCombo("##wsres", worldSettings.resolution > 0 ? cur : "The headset's")) {
|
||
for (float v : kRes) {
|
||
char t[32];
|
||
if (v > 0) std::snprintf(t, sizeof t, "%.0f%%", v * 100);
|
||
if (ImGui::Selectable(v > 0 ? t : "The headset's", std::abs(worldSettings.resolution - v) < 1e-3f)) worldSettings.resolution = v;
|
||
}
|
||
ImGui::EndCombo();
|
||
}
|
||
}
|
||
fieldLabel("Foveation");
|
||
{
|
||
bool own = worldSettings.foveation > 0;
|
||
if (ImGui::Checkbox("##wsfovown", &own)) worldSettings.foveation = own ? 1.0f : 0.0f;
|
||
if (ImGui::IsItemHovered()) ImGui::SetTooltip("Its own foveation strength (off: the headset's level)");
|
||
ImGui::SameLine();
|
||
ImGui::SetNextItemWidth(-1);
|
||
ImGui::BeginDisabled(!own);
|
||
float fv = own ? worldSettings.foveation : 1.0f;
|
||
if (ImGui::SliderFloat("##wsfov", &fv, 1.0f, 3.0f, own ? "%.2f" : "the headset's")) worldSettings.foveation = std::round(fv * 4) / 4;
|
||
ImGui::EndDisabled();
|
||
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled))
|
||
ImGui::SetTooltip("1 gentle .. 3 strong: where you look stays sharp, the edges of view get coarser (cheaper)");
|
||
}
|
||
fieldLabel("MSAA");
|
||
{
|
||
static const int kMsaa[] = {0, 1, 2, 4};
|
||
static const char* kMsaaNames[] = {"The headset's", "Off", "2x", "4x"};
|
||
int at = 0;
|
||
for (int i = 0; i < 4; ++i) if (kMsaa[i] == worldSettings.msaa) at = i;
|
||
if (ImGui::Combo("##wsmsaa", &at, kMsaaNames, 4)) worldSettings.msaa = kMsaa[at];
|
||
if (ImGui::IsItemHovered()) ImGui::SetTooltip("Different from the headset's: Frame Home restarts itself to show this world");
|
||
}
|
||
fieldLabel("Frame rate");
|
||
{
|
||
static const char* kRates[] = {"The headset's", "Full", "Half"};
|
||
int at = worldSettings.frameRate + 1;
|
||
if (ImGui::Combo("##wsrate", &at, kRates, 3)) worldSettings.frameRate = at - 1;
|
||
if (ImGui::IsItemHovered()) ImGui::SetTooltip("Half: drawn every other display frame, turned with your head in between (twice the GPU time a frame)");
|
||
}
|
||
if (worldSettings != was) { worldSettings.tuned.clear(); worldSettings.clamp(); dirty = true; updateTitle(); }
|
||
ImGui::BeginDisabled(tuning || saving || sending);
|
||
if (ImGui::Button(tuning ? "Calculating\u2026" : "Calculate Best Settings")) calculateBestSettings();
|
||
ImGui::EndDisabled();
|
||
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled))
|
||
ImGui::SetTooltip("On the headset: walks through the world at 120 Hz at a few settings and keeps the sharpest one that holds\n"
|
||
"its frame rate (at most 1%% of frames late). Your MSAA stays; foveation moves first, then resolution.\n"
|
||
"Takes a few minutes; the headset cools between tries.");
|
||
ImGui::SameLine();
|
||
ImGui::BeginDisabled(worldSettings.empty() && worldSettings.tuned.empty());
|
||
if (ImGui::Button("Use Headset Settings")) { worldSettings = WorldSettings(); setTuneStatus(""); dirty = true; updateTitle(); }
|
||
ImGui::EndDisabled();
|
||
{
|
||
std::lock_guard<std::mutex> l(statusMutex);
|
||
const std::string t = !tuneStatus.empty() ? tuneStatus : worldSettings.tuned;
|
||
if (!t.empty()) {
|
||
ImGui::PushTextWrapPos(0);
|
||
ImGui::TextDisabled("%s", t.c_str());
|
||
ImGui::PopTextWrapPos();
|
||
}
|
||
}
|
||
}
|
||
|
||
}
|
||
|
||
// Sound: the home's own ambience and a background sound added to it.
|
||
void soundTab() {
|
||
if (!scene) { ImGui::TextDisabled("Open a home to start."); return; }
|
||
section("SOUND");
|
||
fieldLabel("Sound");
|
||
ImGui::Text("%s", scene->ambientSource.empty() ? "none of its own" : scene->ambientSource == "FMOD bank" ? "FMOD bank (ambience bed)" : "Ogg loop");
|
||
soundPanel();
|
||
{ // the sounds it uses, to keep: the home's own ambience (an Ogg file) and the one added to it
|
||
const bool own = scene && !scene->ambientOgg.empty();
|
||
ImGui::BeginDisabled(!own);
|
||
if (ImGui::Button("Save Its Sound\u2026")) saveSoundAs(scene->ambientOgg, (scene->ambientName.empty() ? shownName() + " ambience" : scene->ambientName) + ".ogg");
|
||
ImGui::EndDisabled();
|
||
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled))
|
||
ImGui::SetTooltip("%s", own ? "The home's own ambience as it plays (Ogg Vorbis)" : "This home has no sound of its own");
|
||
if (sound.bytes) {
|
||
ImGui::SameLine();
|
||
if (ImGui::Button("Save the Added Sound\u2026")) saveSoundAs(*sound.bytes, sound.name.empty() ? sound.s.file : sound.name);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Headset: send the environment to the Steam Frame, or install Frame Home there.
|
||
void headsetTab() {
|
||
section("STEAM FRAME");
|
||
ImGui::TextDisabled("ssh %s \u2192 ~/%s", frameHost, kFrameEnvDir);
|
||
ImGui::Spacing();
|
||
ImGui::BeginDisabled(!scene || sending);
|
||
pushPrimary();
|
||
if (ImGui::Button(sending ? "Sending\u2026" : "Send to Frame", ImVec2(-1, ImGui::GetFrameHeight() * 1.4f))) sendToFrame();
|
||
ImGui::PopStyleColor(3);
|
||
ImGui::EndDisabled();
|
||
ImGui::PushTextWrapPos(0);
|
||
ImGui::TextDisabled("%s", status().c_str());
|
||
ImGui::PopTextWrapPos();
|
||
if (ImGui::Button("Install on Steam Frame\u2026", ImVec2(-1, 0))) openInstall();
|
||
}
|
||
|
||
|
||
// Vertical splitter between panels; dragging changes `width` (sign: +1 grows with the mouse, -1 shrinks).
|
||
void splitter(const char* id, float& width, float sign, float height) {
|
||
ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(0, 0));
|
||
ImGui::InvisibleButton(id, ImVec2(4 * scale, height));
|
||
if (ImGui::IsItemHovered() || ImGui::IsItemActive()) ImGui::SetMouseCursor(ImGuiMouseCursor_ResizeEW);
|
||
if (ImGui::IsItemActive()) width = std::clamp(width + sign * ImGui::GetIO().MouseDelta.x, 180 * scale, 600 * scale);
|
||
ImVec2 a = ImGui::GetItemRectMin(), b = ImGui::GetItemRectMax();
|
||
ImGui::GetWindowDrawList()->AddRectFilled(a, b, col(ImGui::IsItemActive() ? kBlue : kSplit));
|
||
ImGui::PopStyleVar();
|
||
}
|
||
|
||
// The selected object's own mesh: every triangle edge, Blender orange, plus a soft fill of its faces.
|
||
void drawSelection(ImDrawList* dl, const float* viewProj) {
|
||
if (selected < 0 || !scene) return;
|
||
const Object& o = objects[selected];
|
||
if (o.hidden || o.deleted) return;
|
||
const MeshData& md = scene->meshes[o.scene];
|
||
float w[16], mvp[16];
|
||
mul(o.pivot, o.offset, w);
|
||
mul(viewProj, w, mvp);
|
||
const size_t nv = md.positions.size() / 3;
|
||
clip.resize(nv * 4);
|
||
for (size_t v = 0; v < nv; ++v) {
|
||
const float* p = &md.positions[v * 3];
|
||
for (int k = 0; k < 4; ++k) clip[v * 4 + k] = mvp[k] * p[0] + mvp[4 + k] * p[1] + mvp[8 + k] * p[2] + mvp[12 + k];
|
||
}
|
||
auto screen = [&](const float* c) {
|
||
return ImVec2(viewPos.x + (c[0] / c[3] * 0.5f + 0.5f) * viewSize.x, viewPos.y + (0.5f - c[1] / c[3] * 0.5f) * viewSize.y);
|
||
};
|
||
dl->PushClipRect(viewPos, ImVec2(viewPos.x + viewSize.x, viewPos.y + viewSize.y), true);
|
||
const size_t tris = md.indices.size() / 3;
|
||
const size_t step = tris > 6000 ? tris / 6000 + 1 : 1; // dense meshes: a lighter wire (keeps the frame fast)
|
||
// Faces first (a faint tint), then the wire on top.
|
||
for (size_t t = 0; t < tris; t += step) {
|
||
const u32 a = md.indices[t * 3], b = md.indices[t * 3 + 1], c = md.indices[t * 3 + 2];
|
||
if (a >= nv || b >= nv || c >= nv) continue;
|
||
if (clip[a * 4 + 3] < kNear || clip[b * 4 + 3] < kNear || clip[c * 4 + 3] < kNear) continue;
|
||
dl->AddTriangleFilled(screen(&clip[a * 4]), screen(&clip[b * 4]), screen(&clip[c * 4]), col(kOrange, 0.10f));
|
||
}
|
||
const ImU32 wire = col(kOrange, 0.85f);
|
||
const float thick = 1.0f * scale;
|
||
for (size_t t = 0; t < tris; t += step) {
|
||
const u32 idx[3] = {md.indices[t * 3], md.indices[t * 3 + 1], md.indices[t * 3 + 2]};
|
||
for (int e = 0; e < 3; ++e) {
|
||
const u32 a = idx[e], b = idx[(e + 1) % 3];
|
||
if (a >= nv || b >= nv) continue;
|
||
float pa[4], pb[4];
|
||
std::memcpy(pa, &clip[a * 4], 16);
|
||
std::memcpy(pb, &clip[b * 4], 16);
|
||
if (pa[3] < kNear && pb[3] < kNear) continue;
|
||
if (pa[3] < kNear || pb[3] < kNear) { // clip at the near plane
|
||
float* in = pa[3] < kNear ? pb : pa;
|
||
float* out = pa[3] < kNear ? pa : pb;
|
||
const float k = (kNear - in[3]) / (out[3] - in[3]);
|
||
for (int q = 0; q < 4; ++q) out[q] = in[q] + (out[q] - in[q]) * k;
|
||
}
|
||
dl->AddLine(screen(pa), screen(pb), wire, thick);
|
||
}
|
||
}
|
||
dl->PopClipRect();
|
||
}
|
||
|
||
// Blender-style axis indicator in the viewport's top-right corner.
|
||
void drawAxes(ImDrawList* dl, const float* view) {
|
||
const float r = 34 * scale;
|
||
const ImVec2 c(viewPos.x + viewSize.x - r - 18 * scale, viewPos.y + r + 18 * scale);
|
||
struct Axis { float x, y, z; uint32_t color; const char* name; };
|
||
Axis axes[3];
|
||
const uint32_t colors[3] = {0xff3352, 0x8bdc00, 0x2890ff};
|
||
const char* names[3] = {"X", "Y", "Z"};
|
||
for (int a = 0; a < 3; ++a) axes[a] = {view[a * 4], view[a * 4 + 1], view[a * 4 + 2], colors[a], names[a]};
|
||
std::sort(axes, axes + 3, [](const Axis& a, const Axis& b) { return a.z < b.z; });
|
||
dl->AddCircleFilled(c, r + 12 * scale, col(0x000000, 0.18f));
|
||
for (const Axis& a : axes) {
|
||
ImVec2 e(c.x + a.x * r, c.y - a.y * r);
|
||
dl->AddLine(c, e, col(a.color), 2.0f * scale);
|
||
dl->AddCircleFilled(e, 8 * scale, col(a.color));
|
||
ImVec2 ts = ImGui::CalcTextSize(a.name);
|
||
dl->AddText(ImVec2(e.x - ts.x * 0.5f, e.y - ts.y * 0.5f), col(0x111111), a.name);
|
||
}
|
||
}
|
||
|
||
// The world layer drawn over the 3D view (x-ray: always visible): spawns cyan, seats green, hotspots yellow,
|
||
// walls red (triggers dim), wall spots blue, walkable areas green. The selected item is drawn brighter.
|
||
void drawWorld(ImDrawList* dl, const float* viewProj) {
|
||
if (!scene || !showWorld) return;
|
||
const HomeWorld& w = scene->world;
|
||
auto project = [&](const float* p, ImVec2& out) -> bool {
|
||
float c[4];
|
||
for (int k = 0; k < 4; ++k) c[k] = viewProj[k] * p[0] + viewProj[4 + k] * p[1] + viewProj[8 + k] * p[2] + viewProj[12 + k];
|
||
if (c[3] < kNear) return false;
|
||
out = ImVec2(viewPos.x + (c[0] / c[3] * 0.5f + 0.5f) * viewSize.x, viewPos.y + (0.5f - c[1] / c[3] * 0.5f) * viewSize.y);
|
||
return true;
|
||
};
|
||
// Lines are clipped at the near plane: an edge running behind the camera (a big wall you stand in) is cut
|
||
// where it leaves the view instead of being dropped or flung across the screen.
|
||
auto line = [&](const float* a, const float* b, ImU32 color, float t) {
|
||
float ca[4], cb[4];
|
||
for (int k = 0; k < 4; ++k) {
|
||
ca[k] = viewProj[k] * a[0] + viewProj[4 + k] * a[1] + viewProj[8 + k] * a[2] + viewProj[12 + k];
|
||
cb[k] = viewProj[k] * b[0] + viewProj[4 + k] * b[1] + viewProj[8 + k] * b[2] + viewProj[12 + k];
|
||
}
|
||
if (ca[3] < kNear && cb[3] < kNear) return;
|
||
if (ca[3] < kNear || cb[3] < kNear) {
|
||
float* in = ca[3] < kNear ? cb : ca;
|
||
float* out = ca[3] < kNear ? ca : cb;
|
||
const float f = (kNear - in[3]) / (out[3] - in[3]);
|
||
for (int k = 0; k < 4; ++k) out[k] = in[k] + (out[k] - in[k]) * f;
|
||
}
|
||
auto screen = [&](const float* c) { return ImVec2(viewPos.x + (c[0] / c[3] * 0.5f + 0.5f) * viewSize.x, viewPos.y + (0.5f - c[1] / c[3] * 0.5f) * viewSize.y); };
|
||
dl->AddLine(screen(ca), screen(cb), color, t);
|
||
};
|
||
auto xf = [](const float* m, float x, float y, float z, float* o) { for (int k = 0; k < 3; ++k) o[k] = m[k] * x + m[4 + k] * y + m[8 + k] * z + m[12 + k]; };
|
||
dl->PushClipRect(viewPos, ImVec2(viewPos.x + viewSize.x, viewPos.y + viewSize.y), true);
|
||
const float th = 1.5f * scale;
|
||
// Points: a floor ring, a facing arrow and the name.
|
||
auto points = [&](const std::vector<WorldPoint>& v, int kind, uint32_t color, float radius) {
|
||
for (size_t i = 0; i < v.size(); ++i) {
|
||
if (!worldShown(kind, static_cast<int>(i))) continue;
|
||
const WorldPoint& p = v[i];
|
||
const bool sel = wKind == kind && wIndex == static_cast<int>(i), hov = hoverKind == kind && hoverIndex == static_cast<int>(i);
|
||
const ImU32 c = hov && !sel ? col(0xfff2b0) : col(color, sel ? 1.0f : 0.8f);
|
||
float prev[3] = {p.pos[0] + radius, p.pos[1], p.pos[2]};
|
||
for (int k = 1; k <= 24; ++k) {
|
||
const float a = k * 2 * kPi / 24, cur[3] = {p.pos[0] + radius * std::cos(a), p.pos[1], p.pos[2] + radius * std::sin(a)};
|
||
line(prev, cur, c, sel || hov ? th * 2 : th);
|
||
std::memcpy(prev, cur, 12);
|
||
}
|
||
const float fx = -std::sin(p.yaw), fz = -std::cos(p.yaw);
|
||
const float tip[3] = {p.pos[0] + fx * radius * 1.8f, p.pos[1], p.pos[2] + fz * radius * 1.8f};
|
||
line(p.pos, tip, c, sel ? th * 2 : th);
|
||
if (kind == kSeat) { // a little chair: seat square and back
|
||
const float bx[3] = {p.pos[0] - fx * 0.25f, p.pos[1], p.pos[2] - fz * 0.25f}, top[3] = {bx[0], bx[1] + 0.5f, bx[2]};
|
||
line(bx, top, c, th);
|
||
}
|
||
ImVec2 sp;
|
||
const float lab[3] = {p.pos[0], p.pos[1] + 0.15f, p.pos[2]};
|
||
if (project(lab, sp)) dl->AddText(ImVec2(sp.x + 6 * scale, sp.y - 8 * scale), c, (p.name.empty() ? kWorldNames[kind] : p.name).c_str());
|
||
}
|
||
};
|
||
points(w.spawns, kSpawn, kShowColors[kShowSpawns], 0.3f);
|
||
points(w.seats, kSeat, kShowColors[kShowSeats], 0.25f);
|
||
points(w.hotspots, kHotspot, kShowColors[kShowHotspots], 0.4f);
|
||
if (wKind == kSeat && wIndex >= 0 && wIndex < static_cast<int>(w.seats.size())) { // where you stand up: drag it
|
||
const WorldPoint& st = w.seats[wIndex];
|
||
float ex[3], prev[3];
|
||
seatExitWorld(st, ex);
|
||
const float from[3] = {st.pos[0], ex[1] + 0.02f, st.pos[2]}, at[3] = {ex[0], ex[1] + 0.02f, ex[2]};
|
||
line(from, at, col(0x7cf0a0, 0.7f), th);
|
||
for (int k = 0; k <= 24; ++k) {
|
||
const float a = k * 2 * kPi / 24, cur[3] = {ex[0] + 0.18f * std::cos(a), ex[1] + 0.02f, ex[2] + 0.18f * std::sin(a)};
|
||
if (k) line(prev, cur, col(0x7cf0a0), th * 1.5f);
|
||
std::memcpy(prev, cur, 12);
|
||
}
|
||
ImVec2 sp;
|
||
if (project(at, sp)) dl->AddText(ImVec2(sp.x + 10 * scale, sp.y - 8 * scale), col(0x7cf0a0), "stand up here (drag)");
|
||
}
|
||
if (brushShown) { // the Draw Floor brush, flat on the object under the mouse
|
||
float prev[3];
|
||
for (int k = 0; k <= 32; ++k) {
|
||
const float a = k * 2 * kPi / 32, cur[3] = {brushAt[0] + floorBrush * std::cos(a), brushAt[1] + 0.01f, brushAt[2] + floorBrush * std::sin(a)};
|
||
if (k) line(prev, cur, col(ImGui::GetIO().KeyShift ? 0xff5050 : kFloorTint), th * 1.5f);
|
||
std::memcpy(prev, cur, 12);
|
||
}
|
||
}
|
||
// Surfaces in their role's colour. Walkable floors are filled where you can stand; the rest are wireframes.
|
||
// Big meshes are thinned to a budget of triangles per frame so the viewport stays smooth.
|
||
// The selected object's own surfaces are always drawn, filled: floor parts green, wall parts orange.
|
||
const std::string selKey = selected >= 0 && selected < static_cast<int>(objects.size()) && objectShown(objects[selected]) ? objectKey(objects[selected]) : "";
|
||
size_t meshTris = 0;
|
||
for (size_t i = 0; i < w.colliders.size(); ++i)
|
||
if (w.colliders[i].kind == WorldCollider::Mesh && worldShown(kCollider, static_cast<int>(i))) meshTris += w.colliders[i].indices.size() / 3;
|
||
const size_t budgetStep = meshTris > 30000 ? meshTris / 30000 + 1 : 1;
|
||
double legendFloor = -1, legendWall = -1;
|
||
// The selected object's parts last, its floor on top: on a sofa the seat reads green over the orange sides.
|
||
std::vector<size_t> order, selFloors;
|
||
for (size_t i = 0; i < w.colliders.size(); ++i) if (selKey.empty() || w.colliders[i].mesh != selKey) order.push_back(i);
|
||
for (size_t i = 0; i < w.colliders.size(); ++i)
|
||
if (!selKey.empty() && w.colliders[i].mesh == selKey) (w.colliders[i].role == WorldCollider::Floor ? selFloors : order).push_back(i);
|
||
order.insert(order.end(), selFloors.begin(), selFloors.end());
|
||
for (size_t i : order) {
|
||
const WorldCollider& c = w.colliders[i];
|
||
const bool ofSel = !selKey.empty() && (c.mesh == selKey || c.mesh == selKey + "#floor");
|
||
if (ofSel ? c.hidden : !worldShown(kCollider, static_cast<int>(i))) continue; // (a part's own eye still hides it)
|
||
const bool sel = wKind == kCollider && wIndex == static_cast<int>(i), hov = hoverKind == kCollider && hoverIndex == static_cast<int>(i);
|
||
const uint32_t rc = kRoleColors[std::clamp(c.role, 0, 5)];
|
||
const ImU32 color = col(sel ? 0xffffff : hov ? 0xfff2b0 : rc, sel || hov ? 1.0f : c.role == WorldCollider::Trigger ? 0.45f : 0.8f);
|
||
const float t = sel || hov ? th * 2 : th;
|
||
if (c.kind == WorldCollider::Box) {
|
||
float v[8][3];
|
||
for (int k = 0; k < 8; ++k) xf(c.world, (k & 1 ? 1 : -1) * c.half[0], (k & 2 ? 1 : -1) * c.half[1], (k & 4 ? 1 : -1) * c.half[2], v[k]);
|
||
static const int e[12][2] = {{0, 1}, {2, 3}, {4, 5}, {6, 7}, {0, 2}, {1, 3}, {4, 6}, {5, 7}, {0, 4}, {1, 5}, {2, 6}, {3, 7}};
|
||
for (auto& ed : e) line(v[ed[0]], v[ed[1]], color, t);
|
||
if (c.role == WorldCollider::Floor || c.role == WorldCollider::Solid) { // its top is floor
|
||
ImVec2 a, b, cc, d;
|
||
if (project(v[2], a) && project(v[3], b) && project(v[7], cc) && project(v[6], d))
|
||
dl->AddQuadFilled(a, b, cc, d, col(kRoleColors[WorldCollider::Floor], 0.18f));
|
||
}
|
||
} else if (c.kind == WorldCollider::Sphere || c.kind == WorldCollider::Capsule) {
|
||
const float hl = c.kind == WorldCollider::Capsule ? c.length * 0.5f : 0.0f;
|
||
for (float yy : {-hl, hl}) {
|
||
float prev[3];
|
||
xf(c.world, c.radius, yy, 0, prev);
|
||
for (int k = 1; k <= 20; ++k) {
|
||
const float a = k * 2 * kPi / 20;
|
||
float cur[3];
|
||
xf(c.world, c.radius * std::cos(a), yy, c.radius * std::sin(a), cur);
|
||
line(prev, cur, color, t);
|
||
std::memcpy(prev, cur, 12);
|
||
}
|
||
}
|
||
for (float sx : {-1.0f, 1.0f}) { float a[3], b[3]; xf(c.world, sx * c.radius, -hl, 0, a); xf(c.world, sx * c.radius, hl, 0, b); line(a, b, color, t); }
|
||
} else if (c.kind == WorldCollider::Mesh) {
|
||
// Each face as Frame Home takes it (WorldRuntime): a floor surface stands you on faces over ~60 deg
|
||
// up, a wall blocks with faces under ~45, Floor + walls does both by slope; triggers are wireframes.
|
||
const size_t nv = c.positions.size() / 3, tris = c.indices.size() / 3;
|
||
const size_t step = sel || ofSel ? std::max<size_t>(1, tris / 30000 + 1) : budgetStep;
|
||
const bool hot = sel || ofSel;
|
||
const bool edges = tris / step < 4000; // a dense mesh's edges would paint it solid
|
||
for (size_t q = 0; q < tris; q += step) {
|
||
const u32 ia = c.indices[q * 3], ib = c.indices[q * 3 + 1], ic = c.indices[q * 3 + 2];
|
||
if (ia >= nv || ib >= nv || ic >= nv) continue;
|
||
float a[3], b[3], d[3];
|
||
xf(c.world, c.positions[ia * 3], c.positions[ia * 3 + 1], c.positions[ia * 3 + 2], a);
|
||
xf(c.world, c.positions[ib * 3], c.positions[ib * 3 + 1], c.positions[ib * 3 + 2], b);
|
||
xf(c.world, c.positions[ic * 3], c.positions[ic * 3 + 1], c.positions[ic * 3 + 2], d);
|
||
if (c.role == WorldCollider::Trigger) { line(a, b, color, 1.0f); line(b, d, color, 1.0f); line(d, a, color, 1.0f); continue; }
|
||
const float e1[3] = {b[0] - a[0], b[1] - a[1], b[2] - a[2]}, e2[3] = {d[0] - a[0], d[1] - a[1], d[2] - a[2]};
|
||
const float n[3] = {e1[1] * e2[2] - e1[2] * e2[1], e1[2] * e2[0] - e1[0] * e2[2], e1[0] * e2[1] - e1[1] * e2[0]};
|
||
const float L = std::sqrt(n[0] * n[0] + n[1] * n[1] + n[2] * n[2]);
|
||
if (L < 1e-9f) continue;
|
||
const float up = std::fabs(n[1]) / L;
|
||
const bool obj = c.role == WorldCollider::Object; // a collider: all of it stops you
|
||
const bool drawn = c.role == WorldCollider::Floor || c.role == WorldCollider::Walkable;
|
||
const bool isFloor = !obj && (drawn ? up > 0.5f : c.role == WorldCollider::Solid && up > 0.7f);
|
||
const bool isWall = obj || (!isFloor && !drawn && up < 0.7f);
|
||
if (!isFloor && !isWall) continue;
|
||
ImVec2 pa, pb, pc;
|
||
if (!project(a, pa) || !project(b, pb) || !project(d, pc)) continue;
|
||
const uint32_t tint = obj ? kRoleColors[WorldCollider::Object] : isFloor ? kFloorTint : kWallTint;
|
||
dl->AddTriangleFilled(pa, pb, pc, col(tint, hot ? (isFloor ? 0.55f : 0.32f) : isFloor ? 0.22f : 0.13f));
|
||
if (edges || sel) dl->AddTriangle(pa, pb, pc, col(sel ? 0xffffff : tint, hot ? 0.85f : isFloor ? 0.5f : 0.22f), 1.0f);
|
||
}
|
||
if (ofSel) legendFloor = 0;
|
||
ImVec2 cp; // the pivot: click it to select
|
||
if (c.mesh.empty() && project(&c.world[12], cp)) {
|
||
const float r = (sel ? 6.0f : 4.5f) * scale;
|
||
dl->AddQuadFilled(ImVec2(cp.x, cp.y - r), ImVec2(cp.x + r, cp.y), ImVec2(cp.x, cp.y + r), ImVec2(cp.x - r, cp.y), col(sel ? 0xffffff : rc));
|
||
}
|
||
}
|
||
}
|
||
if (legendFloor < 0 && walkGuideOn && walkShown) { // the walkable area's colours, above the timeline
|
||
char a[64];
|
||
std::snprintf(a, sizeof a, "Walkable %.0f m\u00b2", walkReport.walkArea);
|
||
const char* items[4] = {a, "Bridged seam", "Edge", "Out of reach"};
|
||
const uint32_t cols[4] = {0x2ec756, 0x1a8cff, 0xff7314, 0x808080};
|
||
const float lh = ImGui::GetTextLineHeight(), sw = lh * 0.7f, gap = 8 * scale;
|
||
float wsum = 0;
|
||
for (const char* t : items) wsum += ImGui::CalcTextSize(t).x + sw + gap * 2;
|
||
const ImVec2 p0(viewPos.x + 10 * scale, viewPos.y + viewSize.y - 34 * scale - lh - 22 * scale);
|
||
dl->AddRectFilled(p0, ImVec2(p0.x + wsum + 10 * scale, p0.y + lh + 12 * scale), col(kBar, 0.9f), 6 * scale);
|
||
float x = p0.x + 8 * scale;
|
||
const float y = p0.y + 6 * scale;
|
||
for (int k = 0; k < 4; ++k) {
|
||
dl->AddRectFilled(ImVec2(x, y + lh * 0.15f), ImVec2(x + sw, y + lh * 0.85f), col(cols[k]), 2 * scale);
|
||
x += sw + gap * 0.6f;
|
||
dl->AddText(ImVec2(x, y), col(0xc8c8c8), items[k]);
|
||
x += ImGui::CalcTextSize(items[k]).x + gap * 1.4f;
|
||
}
|
||
}
|
||
if (legendFloor >= 0) { // what the tint means, above the timeline
|
||
surfaceAreas(static_cast<size_t>(selected), legendFloor, legendWall);
|
||
char ft[64], wt[64];
|
||
std::snprintf(ft, sizeof ft, "Floor %.1f m\u00b2", legendFloor);
|
||
std::snprintf(wt, sizeof wt, "Wall %.1f m\u00b2", legendWall);
|
||
const float lh = ImGui::GetTextLineHeight(), sw = lh * 0.7f, gap = 8 * scale;
|
||
const char* head = kSurfNames[surfaceMode(objects[selected])];
|
||
const float wHead = ImGui::CalcTextSize(head).x, wf = ImGui::CalcTextSize(ft).x, ww = ImGui::CalcTextSize(wt).x;
|
||
const ImVec2 p0(viewPos.x + 10 * scale, viewPos.y + viewSize.y - 34 * scale - lh - 22 * scale);
|
||
const ImVec2 p1(p0.x + wHead + wf + ww + 2 * sw + 6 * gap + 10 * scale, p0.y + lh + 12 * scale);
|
||
dl->AddRectFilled(p0, p1, col(kBar, 0.9f), 6 * scale);
|
||
float x = p0.x + 8 * scale;
|
||
const float y = p0.y + 6 * scale;
|
||
dl->AddText(ImVec2(x, y), col(kText), head);
|
||
x += wHead + 2 * gap;
|
||
for (int k = 0; k < 2; ++k) {
|
||
dl->AddRectFilled(ImVec2(x, y + lh * 0.15f), ImVec2(x + sw, y + lh * 0.85f), col(k ? kWallTint : kFloorTint), 2 * scale);
|
||
x += sw + gap * 0.6f;
|
||
dl->AddText(ImVec2(x, y), col(0xc8c8c8), k ? wt : ft);
|
||
x += (k ? ww : wf) + gap * 1.4f;
|
||
}
|
||
}
|
||
for (size_t i = 0; i < w.wallSpots.size(); ++i) {
|
||
if (!worldShown(kWallSpot, static_cast<int>(i))) continue;
|
||
const WorldWallSpot& ws = w.wallSpots[i];
|
||
const bool sel = wKind == kWallSpot && wIndex == static_cast<int>(i), hov = hoverKind == kWallSpot && hoverIndex == static_cast<int>(i);
|
||
float v[4][3];
|
||
const float hw = ws.width * 0.5f, hh = ws.height * 0.5f;
|
||
xf(ws.world, -hw, -hh, 0, v[0]); xf(ws.world, hw, -hh, 0, v[1]); xf(ws.world, hw, hh, 0, v[2]); xf(ws.world, -hw, hh, 0, v[3]);
|
||
for (int k = 0; k < 4; ++k) line(v[k], v[(k + 1) % 4], hov && !sel ? col(0xfff2b0) : col(0x5b8def, sel ? 1.0f : 0.8f), sel || hov ? th * 2 : th);
|
||
}
|
||
// Gravity zones: the region, and arrows showing which way is down.
|
||
for (size_t i = 0; i < w.gravity.size(); ++i) {
|
||
if (!worldShown(kGravity, static_cast<int>(i))) continue;
|
||
const WorldGravity& g = w.gravity[i];
|
||
const bool sel = wKind == kGravity && wIndex == static_cast<int>(i);
|
||
const ImU32 color = col(sel ? 0xffffff : kShowColors[kShowGravity], sel ? 1.0f : 0.75f);
|
||
const float t = sel ? th * 2 : th;
|
||
auto arrow = [&](const float* from, const float* to) {
|
||
line(from, to, color, t);
|
||
const float d[3] = {to[0] - from[0], to[1] - from[1], to[2] - from[2]};
|
||
const float L = std::sqrt(d[0] * d[0] + d[1] * d[1] + d[2] * d[2]);
|
||
if (L < 1e-4f) return;
|
||
float sd[3] = {-d[2], 0, d[0]}; // a side direction for the head
|
||
const float sl = std::sqrt(sd[0] * sd[0] + sd[2] * sd[2]);
|
||
if (sl < 1e-4f) { sd[0] = 1; sd[2] = 0; } else { sd[0] /= sl; sd[2] /= sl; }
|
||
for (float sgn : {-1.0f, 1.0f}) {
|
||
const float h[3] = {to[0] - d[0] / L * L * 0.2f + sd[0] * L * 0.1f * sgn, to[1] - d[1] / L * L * 0.2f, to[2] - d[2] / L * L * 0.2f + sd[2] * L * 0.1f * sgn};
|
||
line(to, h, color, t);
|
||
}
|
||
};
|
||
if (g.kind == WorldGravity::Planet) {
|
||
const float* c = &g.world[12];
|
||
for (int axis = 0; axis < 3; ++axis) { // three great circles of the pull radius
|
||
float prev[3];
|
||
for (int k = 0; k <= 48; ++k) {
|
||
const float a = k * 2 * kPi / 48, cs = std::cos(a) * g.radius, sn = std::sin(a) * g.radius;
|
||
const float p[3] = {c[0] + (axis == 0 ? 0 : cs), c[1] + (axis == 1 ? 0 : axis == 0 ? cs : sn), c[2] + (axis == 2 ? 0 : sn)};
|
||
if (k) line(prev, p, col(sel ? 0xffffff : kShowColors[kShowGravity], sel ? 0.8f : 0.35f), 1.0f);
|
||
std::memcpy(prev, p, 12);
|
||
}
|
||
}
|
||
for (int k = 0; k < 6; ++k) { // arrows pointing in at the centre
|
||
const float d[6][3] = {{1, 0, 0}, {-1, 0, 0}, {0, 1, 0}, {0, -1, 0}, {0, 0, 1}, {0, 0, -1}};
|
||
const float from[3] = {c[0] + d[k][0] * g.radius, c[1] + d[k][1] * g.radius, c[2] + d[k][2] * g.radius};
|
||
const float to[3] = {c[0] + d[k][0] * g.radius * 0.7f, c[1] + d[k][1] * g.radius * 0.7f, c[2] + d[k][2] * g.radius * 0.7f};
|
||
arrow(from, to);
|
||
}
|
||
} else {
|
||
float v[8][3];
|
||
for (int k = 0; k < 8; ++k) xf(g.world, (k & 1 ? 1 : -1) * g.half[0], (k & 2 ? 1 : -1) * g.half[1], (k & 4 ? 1 : -1) * g.half[2], v[k]);
|
||
static const int e[12][2] = {{0, 1}, {2, 3}, {4, 5}, {6, 7}, {0, 2}, {1, 3}, {4, 6}, {5, 7}, {0, 4}, {1, 5}, {2, 6}, {3, 7}};
|
||
for (auto& ed : e) line(v[ed[0]], v[ed[1]], color, t);
|
||
if (g.kind == WorldGravity::Direction) { // down: the frame's -Y
|
||
const float hs = std::min({g.half[0], g.half[1], g.half[2]}) * 0.8f;
|
||
float from[3], to[3];
|
||
xf(g.world, 0, hs, 0, from);
|
||
xf(g.world, 0, -hs, 0, to);
|
||
arrow(from, to);
|
||
}
|
||
}
|
||
ImVec2 sp;
|
||
if (project(&g.world[12], sp)) {
|
||
const float r = (sel ? 6.0f : 4.5f) * scale;
|
||
dl->AddQuadFilled(ImVec2(sp.x, sp.y - r), ImVec2(sp.x + r, sp.y), ImVec2(sp.x, sp.y + r), ImVec2(sp.x - r, sp.y), color);
|
||
dl->AddText(ImVec2(sp.x + 8 * scale, sp.y - 8 * scale), color, (g.name.empty() ? std::string(kGravityNames[std::clamp(g.kind, 0, 2)]) : g.name).c_str());
|
||
}
|
||
}
|
||
dl->PopClipRect();
|
||
}
|
||
|
||
// The world item under a click: points and shape centres within a few pixels.
|
||
// The world item under a viewport pixel: a wall or surface box anywhere on it (a placed mesh on its triangles, a
|
||
// wall spot on its panel), a spawn, seat or hotspot within a finger's width of its marker (seats: the chair drawn),
|
||
// a gravity zone at its centre. Markers win over a box they're in front of. hit: the point grabbed (dragging keeps
|
||
// it under the mouse).
|
||
bool worldUnder(float mx, float my, const float* viewProj, int& kind, int& index, float hit[3]) {
|
||
if (!scene || !showWorld) return false;
|
||
const HomeWorld& w = scene->world;
|
||
float o[3], d[3];
|
||
mouseRay(mx, my, o, d);
|
||
const float dl = std::sqrt(d[0] * d[0] + d[1] * d[1] + d[2] * d[2]);
|
||
for (float& v : d) v /= dl;
|
||
// shapes: the nearest along the ray
|
||
float bestT = 1e30f;
|
||
int vk = -1, vi = -1;
|
||
auto boxHit = [&](const float* world, const float* half, float& t) { // ray against a box in its frame
|
||
float inv[16], lo[3], ld[3];
|
||
if (!invert(world, inv)) return false;
|
||
point(inv, o, lo);
|
||
direction(inv, d, ld);
|
||
float t0 = 0, t1 = 1e30f;
|
||
for (int k = 0; k < 3; ++k) {
|
||
if (std::fabs(ld[k]) < 1e-9f) { if (std::fabs(lo[k]) > half[k]) return false; continue; }
|
||
float a = (-half[k] - lo[k]) / ld[k], b = (half[k] - lo[k]) / ld[k];
|
||
if (a > b) std::swap(a, b);
|
||
t0 = std::max(t0, a); t1 = std::min(t1, b);
|
||
if (t0 > t1) return false;
|
||
}
|
||
if (t0 <= 0) return false; // inside it (a room-sized volume): not a click on it
|
||
t = t0;
|
||
return true;
|
||
};
|
||
for (size_t i = 0; i < w.colliders.size(); ++i) {
|
||
const WorldCollider& c = w.colliders[i];
|
||
if (!c.mesh.empty() || !worldShown(kCollider, static_cast<int>(i))) continue; // (object surfaces: click the object)
|
||
// anywhere on it: boxes and what you placed; the home's triggers and big collision meshes by their centre
|
||
if (c.role == WorldCollider::Trigger || (c.kind == WorldCollider::Mesh && !c.placed)) continue;
|
||
float t = 1e30f;
|
||
bool ok = false;
|
||
if (c.kind == WorldCollider::Box) ok = boxHit(c.world, c.half, t);
|
||
else if (c.kind == WorldCollider::Sphere || c.kind == WorldCollider::Capsule) {
|
||
const float h[3] = {c.radius, c.radius + (c.kind == WorldCollider::Capsule ? c.length * 0.5f : 0.0f), c.radius};
|
||
ok = boxHit(c.world, h, t);
|
||
} else if (c.kind == WorldCollider::Mesh && c.indices.size() <= 60000) {
|
||
const std::vector<float> tris = colliderTriangles(c);
|
||
for (size_t k = 0; k + 8 < tris.size(); k += 9) {
|
||
const float* A = &tris[k]; const float* B = &tris[k + 3]; const float* C = &tris[k + 6];
|
||
const float e1[3] = {B[0] - A[0], B[1] - A[1], B[2] - A[2]}, e2[3] = {C[0] - A[0], C[1] - A[1], C[2] - A[2]};
|
||
const float pv[3] = {d[1] * e2[2] - d[2] * e2[1], d[2] * e2[0] - d[0] * e2[2], d[0] * e2[1] - d[1] * e2[0]};
|
||
const float det = e1[0] * pv[0] + e1[1] * pv[1] + e1[2] * pv[2];
|
||
if (std::fabs(det) < 1e-12f) continue;
|
||
const float id = 1 / det, tv[3] = {o[0] - A[0], o[1] - A[1], o[2] - A[2]};
|
||
const float u = (tv[0] * pv[0] + tv[1] * pv[1] + tv[2] * pv[2]) * id;
|
||
if (u < 0 || u > 1) continue;
|
||
const float qv[3] = {tv[1] * e1[2] - tv[2] * e1[1], tv[2] * e1[0] - tv[0] * e1[2], tv[0] * e1[1] - tv[1] * e1[0]};
|
||
const float v = (d[0] * qv[0] + d[1] * qv[1] + d[2] * qv[2]) * id;
|
||
if (v < 0 || u + v > 1) continue;
|
||
const float tt = (e2[0] * qv[0] + e2[1] * qv[1] + e2[2] * qv[2]) * id;
|
||
if (tt > 0 && tt < t) { t = tt; ok = true; }
|
||
}
|
||
}
|
||
if (ok && t < bestT) { bestT = t; vk = kCollider; vi = static_cast<int>(i); }
|
||
}
|
||
for (size_t i = 0; i < w.wallSpots.size(); ++i) { // the panel's rectangle
|
||
const WorldWallSpot& ws = w.wallSpots[i];
|
||
if (!worldShown(kWallSpot, static_cast<int>(i))) continue;
|
||
const float h[3] = {ws.width * 0.5f, ws.height * 0.5f, 0.05f};
|
||
float t;
|
||
if (boxHit(ws.world, h, t) && t < bestT) { bestT = t; vk = kWallSpot; vi = static_cast<int>(i); }
|
||
}
|
||
// markers: the nearest on screen within reach, unless a shape is well in front of it
|
||
float bestPx = 20 * scale, depth = 0;
|
||
int pk = -1, pi = -1;
|
||
auto marker = [&](int k, int i, const float* p, float reach) {
|
||
float c[4];
|
||
for (int j = 0; j < 4; ++j) c[j] = viewProj[j] * p[0] + viewProj[4 + j] * p[1] + viewProj[8 + j] * p[2] + viewProj[12 + j];
|
||
if (c[3] < kNear) return;
|
||
const float sx = viewPos.x + (c[0] / c[3] * 0.5f + 0.5f) * viewSize.x, sy = viewPos.y + (0.5f - c[1] / c[3] * 0.5f) * viewSize.y;
|
||
const float dd = std::hypot(sx - mx, sy - my);
|
||
if (dd < bestPx && dd < reach) { bestPx = dd; pk = k; pi = i; depth = (p[0] - o[0]) * d[0] + (p[1] - o[1]) * d[1] + (p[2] - o[2]) * d[2]; }
|
||
};
|
||
auto pointsOf = [&](const std::vector<WorldPoint>& v, int k, float r) {
|
||
for (size_t i = 0; i < v.size(); ++i) {
|
||
if (!worldShown(k, static_cast<int>(i))) continue;
|
||
const WorldPoint& q = v[i];
|
||
marker(k, static_cast<int>(i), q.pos, 20 * scale);
|
||
const float fx = -std::sin(q.yaw), fz = -std::cos(q.yaw);
|
||
const float tip[3] = {q.pos[0] + fx * r * 1.8f, q.pos[1], q.pos[2] + fz * r * 1.8f};
|
||
marker(k, static_cast<int>(i), tip, 14 * scale);
|
||
if (k == kSeat) { // the chair's back
|
||
const float back[3] = {q.pos[0] - fx * 0.25f, q.pos[1] + 0.3f, q.pos[2] - fz * 0.25f};
|
||
marker(k, static_cast<int>(i), back, 16 * scale);
|
||
}
|
||
}
|
||
};
|
||
pointsOf(w.spawns, kSpawn, 0.3f);
|
||
pointsOf(w.seats, kSeat, 0.25f);
|
||
pointsOf(w.hotspots, kHotspot, 0.4f);
|
||
for (size_t i = 0; i < w.gravity.size(); ++i) if (worldShown(kGravity, static_cast<int>(i))) marker(kGravity, static_cast<int>(i), &w.gravity[i].world[12], 14 * scale);
|
||
for (size_t i = 0; i < w.colliders.size(); ++i) { // the rest by their centre
|
||
const WorldCollider& c = w.colliders[i];
|
||
if (c.mesh.empty() && worldShown(kCollider, static_cast<int>(i)) && (c.role == WorldCollider::Trigger || (c.kind == WorldCollider::Mesh && !c.placed)))
|
||
marker(kCollider, static_cast<int>(i), &c.world[12], 14 * scale);
|
||
}
|
||
if (pk >= 0 && (vk < 0 || depth <= bestT + 0.5f)) {
|
||
kind = pk; index = pi;
|
||
float m[16];
|
||
if (worldMatrix(pk, pi, m)) { hit[0] = m[12]; hit[1] = m[13]; hit[2] = m[14]; }
|
||
return true;
|
||
}
|
||
if (vk < 0) return false;
|
||
kind = vk; index = vi;
|
||
for (int k = 0; k < 3; ++k) hit[k] = o[k] + d[k] * bestT;
|
||
return true;
|
||
}
|
||
bool pickWorld(float mx, float my, const float* viewProj) {
|
||
int k, i;
|
||
float hit[3];
|
||
if (!worldUnder(mx, my, viewProj, k, i, hit)) return false;
|
||
wKind = k; wIndex = i; selected = -1;
|
||
return true;
|
||
}
|
||
int hoverKind = -1, hoverIndex = -1; // the world item under the mouse (highlighted: click or drag it)
|
||
|
||
void viewport(float width, float height) {
|
||
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0, 0));
|
||
ImGui::BeginChild("viewport", ImVec2(width, height), ImGuiChildFlags_None, ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse);
|
||
ImGui::PopStyleVar();
|
||
viewPos = ImGui::GetCursorScreenPos();
|
||
viewSize = ImGui::GetContentRegionAvail();
|
||
ImDrawList* dl = ImGui::GetWindowDrawList();
|
||
ImGuiIO& io = ImGui::GetIO();
|
||
|
||
// ImGuizmo only grabs when no ImGui item is hovered, and this full-viewport button always is: while the
|
||
// mouse is over the gizmo (as of last frame) the button steps aside so the gizmo can be dragged.
|
||
const ImVec2 end(viewPos.x + viewSize.x, viewPos.y + viewSize.y);
|
||
bool overView;
|
||
if (gizmoHot) {
|
||
ImGui::Dummy(ImVec2(std::max(1.0f, viewSize.x), std::max(1.0f, viewSize.y)));
|
||
overView = ImGui::IsWindowHovered() && ImGui::IsMouseHoveringRect(viewPos, end);
|
||
} else {
|
||
ImGui::SetNextItemAllowOverlap();
|
||
ImGui::InvisibleButton("##view", ImVec2(std::max(1.0f, viewSize.x), std::max(1.0f, viewSize.y)),
|
||
ImGuiButtonFlags_MouseButtonLeft | ImGuiButtonFlags_MouseButtonRight | ImGuiButtonFlags_MouseButtonMiddle);
|
||
overView = ImGui::IsItemHovered();
|
||
}
|
||
const float tlH = 0; // (the timeline has its own row under the view)
|
||
const bool overTimeline = scene && io.MousePos.y >= viewPos.y + viewSize.y - tlH && io.MousePos.y <= viewPos.y + viewSize.y &&
|
||
io.MousePos.x >= viewPos.x && io.MousePos.x <= viewPos.x + viewSize.x;
|
||
const bool hovered = overView && !overTimeline;
|
||
dl->AddRectFilled(viewPos, end, col(0x393939));
|
||
if (rendered && viewTex) dl->AddImage((ImTextureID)viewTex, viewPos, end); // (a pointer on 64-bit Vulkan, an integer on 32-bit: as ImGui's backend casts it)
|
||
|
||
const float ty = std::tan(kFovY * 0.5f), tx = ty * std::max(1.0f, viewSize.x) / std::max(1.0f, viewSize.y);
|
||
float pose[16], view[16], proj[16], viewProj[16];
|
||
cam.pose(pose);
|
||
invert(pose, view);
|
||
glProjection(tx, ty, proj);
|
||
mul(proj, view, viewProj);
|
||
|
||
// Gizmo (not while the camera is being dragged or Alt is held: Alt + drag orbits)
|
||
bool overGizmo = false, gizmoShown = false;
|
||
const bool gizmoAllowed = !io.KeyAlt && drag < 0;
|
||
float worldM[16];
|
||
if (scene && wKind >= 0 && wIndex >= 0 && wIndex < worldCount(wKind) && worldShown(wKind, wIndex) && worldMatrix(wKind, wIndex, worldM)) {
|
||
ImGuizmo::SetOrthographic(false);
|
||
ImGuizmo::SetGizmoSizeClipSpace(0.14f); // a bit bigger than ImGuizmo's default: easier to grab
|
||
ImGuizmo::SetDrawlist(dl);
|
||
ImGuizmo::SetRect(viewPos.x, viewPos.y, viewSize.x, viewSize.y);
|
||
const bool point = wKind == kSpawn || wKind == kSeat || wKind == kHotspot;
|
||
ImGuizmo::OPERATION op = tool == Tool::Move ? ImGuizmo::TRANSLATE : tool == Tool::Rotate ? (point ? ImGuizmo::ROTATE_Y : ImGuizmo::ROTATE) : ImGuizmo::SCALE;
|
||
if (point && op == ImGuizmo::SCALE) op = ImGuizmo::TRANSLATE;
|
||
const bool snap = io.KeyCtrl;
|
||
const float snapValues[3] = {tool == Tool::Rotate ? 15.0f : 0.25f, tool == Tool::Rotate ? 15.0f : 0.25f, tool == Tool::Rotate ? 15.0f : 0.25f};
|
||
ImGuizmo::Enable(gizmoAllowed || ImGuizmo::IsUsing());
|
||
gizmoShown = true;
|
||
const bool changed = ImGuizmo::Manipulate(view, proj, op, localSpace ? ImGuizmo::LOCAL : ImGuizmo::WORLD, worldM, nullptr, snap ? snapValues : nullptr);
|
||
const bool using_ = ImGuizmo::IsUsing();
|
||
if (using_ && !gizmoWasUsing) { pushWorldUndo(); dragBase = scene->world; } // one undo step per drag, taken before it moves
|
||
if (changed) setWorldMatrix(wKind, wIndex, worldM);
|
||
if (!using_ && gizmoWasUsing && wKind == kCollider) { // let go: overlapping placed surfaces become one (same undo step)
|
||
const int was = static_cast<int>(scene->world.colliders.size());
|
||
wIndex = combineTouching(wIndex);
|
||
if (static_cast<int>(scene->world.colliders.size()) != was) worldReplaced();
|
||
}
|
||
gizmoWasUsing = using_;
|
||
overGizmo = ImGuizmo::IsOver() || using_;
|
||
} else if (scene && selected >= 0 && objectShown(objects[selected]) && tool != Tool::DrawFloor) {
|
||
ImGuizmo::SetOrthographic(false);
|
||
ImGuizmo::SetGizmoSizeClipSpace(0.14f); // a bit bigger than ImGuizmo's default: easier to grab
|
||
ImGuizmo::SetDrawlist(dl);
|
||
ImGuizmo::SetRect(viewPos.x, viewPos.y, viewSize.x, viewSize.y);
|
||
const ImGuizmo::OPERATION op = tool == Tool::Move ? ImGuizmo::TRANSLATE : tool == Tool::Rotate ? ImGuizmo::ROTATE : ImGuizmo::SCALE;
|
||
Object& o = objects[selected];
|
||
float before[16];
|
||
std::memcpy(before, o.pivot, 64);
|
||
const bool snap = io.KeyCtrl;
|
||
const float snapValues[3] = {tool == Tool::Rotate ? 15.0f : tool == Tool::Scale ? 0.1f : 0.25f,
|
||
tool == Tool::Rotate ? 15.0f : tool == Tool::Scale ? 0.1f : 0.25f,
|
||
tool == Tool::Rotate ? 15.0f : tool == Tool::Scale ? 0.1f : 0.25f};
|
||
ImGuizmo::Enable(gizmoAllowed || ImGuizmo::IsUsing());
|
||
gizmoShown = true;
|
||
if (ImGuizmo::Manipulate(view, proj, op, localSpace ? ImGuizmo::LOCAL : ImGuizmo::WORLD, o.pivot, nullptr, snap ? snapValues : nullptr))
|
||
apply(o);
|
||
const bool using_ = ImGuizmo::IsUsing();
|
||
if (using_ && !gizmoWasUsing) pushUndo(selected, before); // one undo step per drag
|
||
if (!using_ && gizmoWasUsing) transformDone(static_cast<size_t>(selected));
|
||
gizmoWasUsing = using_;
|
||
overGizmo = ImGuizmo::IsOver() || using_;
|
||
}
|
||
if (!gizmoShown && gizmoWasUsing) { // the selection went away mid-drag: end the drag, don't carry it over
|
||
ImGuizmo::Enable(false);
|
||
ImGuizmo::Enable(true);
|
||
gizmoWasUsing = false;
|
||
}
|
||
gizmoHot = overGizmo;
|
||
// The highlight is drawn by the renderer on the GPU (built once per selection; the old per-frame CPU
|
||
// wireframe made the whole app lag on big meshes like the floor).
|
||
{
|
||
const bool show = selected >= 0 && objectShown(objects[selected]);
|
||
renderer.setSelection(show ? static_cast<int>(objects[selected].mesh) : -1);
|
||
}
|
||
if (scene) drawWorld(dl, viewProj);
|
||
if (scene) drawAxes(dl, view);
|
||
if (scene && isolating) { // say why everything else vanished, and how to get it back
|
||
const char* msg = "Showing only the selection \u00b7 I or Esc shows everything";
|
||
const ImVec2 ts = ImGui::CalcTextSize(msg);
|
||
const ImVec2 a(viewPos.x + (viewSize.x - ts.x) * 0.5f - 10 * scale, viewPos.y + 46 * scale);
|
||
dl->AddRectFilled(a, ImVec2(a.x + ts.x + 20 * scale, a.y + ts.y + 10 * scale), col(0x1d2330, 0.92f), 6 * scale);
|
||
dl->AddText(ImVec2(a.x + 10 * scale, a.y + 5 * scale), col(0x8fb4ff), msg);
|
||
}
|
||
|
||
// Camera navigation
|
||
const ImVec2 md = io.MouseDelta;
|
||
if (hovered && drag < 0) {
|
||
if (ImGui::IsMouseClicked(ImGuiMouseButton_Middle)) drag = 2;
|
||
else if (ImGui::IsMouseClicked(ImGuiMouseButton_Right)) drag = 1;
|
||
else if (io.KeyAlt && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) drag = 0;
|
||
}
|
||
if (drag >= 0 && !ImGui::IsMouseDown(drag == 2 ? ImGuiMouseButton_Middle : drag == 1 ? ImGuiMouseButton_Right : ImGuiMouseButton_Left)) drag = -1;
|
||
flying = drag == 1;
|
||
float fwd[3], right[3] = {pose[0], pose[1], pose[2]}, up[3] = {pose[4], pose[5], pose[6]};
|
||
cam.forward(fwd);
|
||
if (drag == 2 && io.KeyShift) { // pan
|
||
const float k = cam.dist * 2 * ty / std::max(1.0f, viewSize.y);
|
||
for (int i = 0; i < 3; ++i) cam.pivot[i] += (-md.x * right[i] + md.y * up[i]) * k;
|
||
} else if (drag == 2 && io.KeyCtrl) { // zoom
|
||
cam.dist = std::clamp(cam.dist * std::pow(1.01f, md.y), 0.05f, 5000.0f);
|
||
} else if (drag == 2 || drag == 0) { // orbit
|
||
cam.yaw -= md.x * 0.006f;
|
||
cam.pitch = std::clamp(cam.pitch - md.y * 0.006f, -1.55f, 1.55f);
|
||
} else if (drag == 1) { // fly: look + WASD
|
||
float e[3];
|
||
cam.eye(e);
|
||
cam.yaw -= md.x * 0.004f;
|
||
cam.pitch = std::clamp(cam.pitch - md.y * 0.004f, -1.55f, 1.55f);
|
||
cam.forward(fwd);
|
||
const float sy = std::sin(cam.yaw), cy = std::cos(cam.yaw);
|
||
float r2[3] = {cy, 0, -sy};
|
||
float move[3] = {0, 0, 0};
|
||
auto add = [&](const float* v, float s) { for (int i = 0; i < 3; ++i) move[i] += v[i] * s; };
|
||
if (ImGui::IsKeyDown(ImGuiKey_W)) add(fwd, 1);
|
||
if (ImGui::IsKeyDown(ImGuiKey_S)) add(fwd, -1);
|
||
if (ImGui::IsKeyDown(ImGuiKey_D)) add(r2, 1);
|
||
if (ImGui::IsKeyDown(ImGuiKey_A)) add(r2, -1);
|
||
if (ImGui::IsKeyDown(ImGuiKey_E)) move[1] += 1;
|
||
if (ImGui::IsKeyDown(ImGuiKey_Q)) move[1] -= 1;
|
||
if (io.MouseWheel != 0) flySpeed = std::clamp(flySpeed * std::pow(1.2f, io.MouseWheel), 0.1f, 100.0f);
|
||
const float sp = flySpeed * io.DeltaTime * (io.KeyShift ? 4.0f : 1.0f);
|
||
for (int i = 0; i < 3; ++i) cam.pivot[i] = e[i] + move[i] * sp + fwd[i] * cam.dist;
|
||
}
|
||
if (hovered && drag != 1 && io.MouseWheel != 0)
|
||
cam.dist = std::clamp(cam.dist * std::pow(0.85f, io.MouseWheel), 0.05f, 5000.0f);
|
||
|
||
// A right click that didn't fly the camera (no drag, no WASD): the object menu for what's under the mouse.
|
||
if (hovered && ImGui::IsMouseClicked(ImGuiMouseButton_Right)) { rArmed = true; rMoved = 0; }
|
||
if (rArmed && ImGui::IsMouseDown(ImGuiMouseButton_Right)) {
|
||
rMoved += std::fabs(md.x) + std::fabs(md.y);
|
||
for (ImGuiKey k : {ImGuiKey_W, ImGuiKey_A, ImGuiKey_S, ImGuiKey_D, ImGuiKey_Q, ImGuiKey_E}) if (ImGui::IsKeyDown(k)) rMoved += 100;
|
||
}
|
||
if (rArmed && ImGui::IsMouseReleased(ImGuiMouseButton_Right)) {
|
||
rArmed = false;
|
||
if (rMoved < 5 * scale) {
|
||
const int hit = pick(io.MousePos.x, io.MousePos.y);
|
||
if (hit >= 0) { selected = menuObject = hit; wKind = wIndex = -1; ImGui::OpenPopup("##objmenu"); }
|
||
}
|
||
}
|
||
objMenuOpen = false;
|
||
if (ImGui::BeginPopup("##objmenu")) {
|
||
objMenuOpen = true;
|
||
if (menuObject >= 0 && menuObject < static_cast<int>(objects.size())) objectMenu(static_cast<size_t>(menuObject));
|
||
ImGui::EndPopup();
|
||
}
|
||
|
||
// What's under the mouse: highlighted, with a hand to say it can be clicked or dragged.
|
||
hoverKind = hoverIndex = -1;
|
||
if (hovered && itemDrag == kDragNone && !overGizmo && tool != Tool::DrawFloor && drag < 0 && scene) {
|
||
float hh[3];
|
||
int k, i;
|
||
if (worldUnder(io.MousePos.x, io.MousePos.y, viewProj, k, i, hh)) { hoverKind = k; hoverIndex = i; ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); }
|
||
else if (wKind == kSeat) { // the selected seat's stand-up ring
|
||
const WorldPoint* st = worldPoint(kSeat, wIndex);
|
||
float ex[3];
|
||
ImVec2 hs;
|
||
if (st) { seatExitWorld(*st, ex); if (screenOf(ex, hs) && std::hypot(hs.x - io.MousePos.x, hs.y - io.MousePos.y) < 14 * scale) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); }
|
||
}
|
||
}
|
||
if (itemDrag != kDragNone && dragMoved) ImGui::SetMouseCursor(ImGuiMouseCursor_ResizeAll);
|
||
// Dragging an item: pressed on it, then moved more than a few pixels (a click alone selects, below).
|
||
if (hovered && !io.KeyAlt && ImGui::IsMouseClicked(ImGuiMouseButton_Left) && !overGizmo && tool != Tool::DrawFloor && scene) {
|
||
itemDrag = kDragNone;
|
||
dragMoved = false;
|
||
dragFrom = io.MousePos;
|
||
ImVec2 hs;
|
||
float ex[3];
|
||
const WorldPoint* seat = wKind == kSeat ? worldPoint(kSeat, wIndex) : nullptr;
|
||
if (seat) seatExitWorld(*seat, ex);
|
||
float hit[3];
|
||
if (seat && screenOf(ex, hs) && std::hypot(hs.x - io.MousePos.x, hs.y - io.MousePos.y) < 12 * scale) {
|
||
itemDrag = kDragExit; dragIndex = wIndex; dragY = ex[1];
|
||
} else {
|
||
int k, i;
|
||
float grab[3];
|
||
if (worldUnder(io.MousePos.x, io.MousePos.y, viewProj, k, i, grab)) { // (the click below selects; this arms a drag)
|
||
float m[16];
|
||
if (worldMatrix(k, i, m)) { // the point grabbed stays under the mouse
|
||
itemDrag = kDragWorld; dragKindW = k; dragIndex = i; dragY = grab[1];
|
||
dragGrab[0] = m[12] - grab[0]; dragGrab[1] = m[14] - grab[2];
|
||
}
|
||
} else {
|
||
long tri = -1;
|
||
float on[3];
|
||
const int o = pick(io.MousePos.x, io.MousePos.y);
|
||
if (o >= 0 && pickOn(o, io.MousePos.x, io.MousePos.y, on, &tri)) {
|
||
itemDrag = kDragObject; dragIndex = o; dragY = on[1];
|
||
dragGrab[0] = objects[o].pivot[12] - on[0]; dragGrab[1] = objects[o].pivot[14] - on[2];
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if (itemDrag != kDragNone && ImGui::IsMouseDown(ImGuiMouseButton_Left)) {
|
||
if (!dragMoved && std::hypot(io.MousePos.x - dragFrom.x, io.MousePos.y - dragFrom.y) > 5 * scale) {
|
||
dragMoved = true;
|
||
clickArmed = false; // a drag, not a click
|
||
if (itemDrag == kDragObject) { pushUndo(dragIndex); selected = dragIndex; wKind = wIndex = -1; }
|
||
else { pushWorldUndo(); dragBase = scene->world; wKind = itemDrag == kDragExit ? kSeat : dragKindW; wIndex = dragIndex; selected = -1; }
|
||
}
|
||
float hit[3];
|
||
if (dragMoved && mouseOnLevel(io.MousePos.x, io.MousePos.y, dragY, hit)) {
|
||
if (itemDrag == kDragWorld && dragIndex < worldCount(dragKindW)) {
|
||
float m[16];
|
||
if (worldMatrix(dragKindW, dragIndex, m)) {
|
||
m[12] = hit[0] + dragGrab[0]; m[14] = hit[2] + dragGrab[1];
|
||
float f; // spawns and hotspots stay on the floor (up and down steps)
|
||
if ((dragKindW == kSpawn || dragKindW == kHotspot) && walkShown && walkShown->floorAt(m[12], m[14], m[13], 0.5f, 1.0f, f)) m[13] = f;
|
||
setWorldMatrix(dragKindW, dragIndex, m);
|
||
}
|
||
} else if (itemDrag == kDragExit && dragIndex < static_cast<int>(scene->world.seats.size())) {
|
||
WorldPoint& st = scene->world.seats[dragIndex]; // back into the seat's own frame (exit is seat-local)
|
||
const float c = std::cos(st.yaw), sn = std::sin(st.yaw), dx = hit[0] - st.pos[0], dz = hit[2] - st.pos[2];
|
||
st.exit[0] = c * dx - sn * dz;
|
||
st.exit[2] = sn * dx + c * dz;
|
||
} else if (itemDrag == kDragObject && dragIndex < static_cast<int>(objects.size())) {
|
||
Object& ob = objects[dragIndex];
|
||
ob.pivot[12] = hit[0] + dragGrab[0]; ob.pivot[14] = hit[2] + dragGrab[1];
|
||
apply(ob);
|
||
}
|
||
dirty = worldEdited = true;
|
||
updateTitle();
|
||
}
|
||
}
|
||
if (itemDrag != kDragNone && !ImGui::IsMouseDown(ImGuiMouseButton_Left)) {
|
||
if (dragMoved) {
|
||
if (itemDrag == kDragObject) transformDone(static_cast<size_t>(dragIndex));
|
||
else if (itemDrag == kDragWorld && dragKindW == kCollider) {
|
||
const int was = static_cast<int>(scene->world.colliders.size());
|
||
wIndex = combineTouching(dragIndex);
|
||
if (static_cast<int>(scene->world.colliders.size()) != was) worldReplaced();
|
||
}
|
||
walkPending = true; // the area follows what moved
|
||
}
|
||
itemDrag = kDragNone;
|
||
}
|
||
// Draw Floor: drag over the selected object (Shift: erase); a click off it selects as usual.
|
||
brushShown = false;
|
||
if (tool == Tool::DrawFloor && scene && selected >= 0 && hovered && !io.KeyAlt && drag < 0 && objectShown(objects[selected])) {
|
||
long tri = -1;
|
||
const bool on = pickOn(selected, io.MousePos.x, io.MousePos.y, brushAt, &tri);
|
||
brushShown = on;
|
||
if (on && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) { painting = true; pushWorldUndo(); }
|
||
if (on && painting && ImGui::IsMouseDown(ImGuiMouseButton_Left)) paintFloor(static_cast<size_t>(selected), brushAt, floorBrush, io.KeyShift, tri);
|
||
}
|
||
if (painting && !ImGui::IsMouseDown(ImGuiMouseButton_Left)) painting = false;
|
||
// Selection: a left click that did not drag and was not on the gizmo.
|
||
if (hovered && !io.KeyAlt && ImGui::IsMouseClicked(ImGuiMouseButton_Left) && !overGizmo && !painting) { clickArmed = true; clickPos = io.MousePos; }
|
||
if (clickArmed && ImGui::IsMouseReleased(ImGuiMouseButton_Left)) {
|
||
clickArmed = false;
|
||
const float dx = io.MousePos.x - clickPos.x, dy = io.MousePos.y - clickPos.y;
|
||
if (dx * dx + dy * dy < 25 * scale * scale) {
|
||
const auto p0 = std::chrono::steady_clock::now();
|
||
if (!pickWorld(io.MousePos.x, io.MousePos.y, viewProj)) { wKind = wIndex = -1; selected = pick(io.MousePos.x, io.MousePos.y); }
|
||
const double pms = std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - p0).count();
|
||
if (pms > 3.0) std::fprintf(stderr, "[studio] pick took %.1f ms\n", pms);
|
||
}
|
||
}
|
||
|
||
// Draw Floor's brush size, over the view's top-left corner while that tool is on.
|
||
if (scene && tool == Tool::DrawFloor) {
|
||
const ImVec2 at(viewPos.x + 10 * scale, viewPos.y + 10 * scale);
|
||
const float w = 180 * scale;
|
||
dl->AddRectFilled(at, ImVec2(at.x + w + 8 * scale, at.y + ImGui::GetFrameHeight() + 8 * scale), col(kBar, 0.85f), 6 * scale);
|
||
ImGui::SetCursorScreenPos(ImVec2(at.x + 4 * scale, at.y + 4 * scale));
|
||
ImGui::SetNextItemWidth(w);
|
||
ImGui::SliderFloat("##brush", &floorBrush, 0.05f, 3.0f, "brush %.2f m", ImGuiSliderFlags_Logarithmic);
|
||
if (ImGui::IsItemHovered()) ImGui::SetTooltip("Brush size \u00b7 [ and ] change it \u00b7 Shift+drag erases");
|
||
}
|
||
if (scene) {
|
||
if (flying) {
|
||
char b[64];
|
||
std::snprintf(b, sizeof b, "Fly \u00b7 %.1f m/s (wheel)", flySpeed);
|
||
dl->AddText(ImVec2(viewPos.x + 12 * scale, end.y - tlH - ImGui::GetTextLineHeight() - 10 * scale), col(kText), b);
|
||
}
|
||
// A footprint on its own has no baked lighting, sky or outside world: those ship in a vista.
|
||
if (footprintAlone() && !uploading) {
|
||
const char* msg = "This footprint gets its lighting, sky and outside world from a vista.";
|
||
const ImVec2 ts = ImGui::CalcTextSize(msg);
|
||
const float bw = ImGui::CalcTextSize("Add Vista\u2026").x + 24 * scale;
|
||
const float total = ts.x + 12 * scale + bw;
|
||
const ImVec2 p(viewPos.x + (viewSize.x - total) * 0.5f, viewPos.y + 48 * scale);
|
||
dl->AddRectFilled(ImVec2(p.x - 14 * scale, p.y - 8 * scale), ImVec2(p.x + total + 14 * scale, p.y + ImGui::GetFrameHeight() + 8 * scale),
|
||
col(kBar, 0.94f), 6 * scale);
|
||
dl->AddText(ImVec2(p.x, p.y + (ImGui::GetFrameHeight() - ts.y) * 0.5f), col(kText), msg);
|
||
ImGui::SetCursorScreenPos(ImVec2(p.x + ts.x + 12 * scale, p.y));
|
||
pushPrimary();
|
||
if (ImGui::Button("Add Vista\u2026", ImVec2(bw, 0))) addVistaDialog();
|
||
ImGui::PopStyleColor(3);
|
||
}
|
||
// Textures still streaming in (decoded in the background as their objects come into view).
|
||
if (const size_t n = renderer.texturesPending()) {
|
||
char b[96];
|
||
std::snprintf(b, sizeof b, "Streaming %zu textures \u00b7 %zu objects in view", n, renderer.drawnCount());
|
||
const ImVec2 ts = ImGui::CalcTextSize(b);
|
||
dl->AddText(ImVec2(end.x - ts.x - 12 * scale, end.y - tlH - ts.y - 10 * scale), col(kDim), b);
|
||
}
|
||
} else if (loadState != 1) {
|
||
startScreen();
|
||
}
|
||
if (loadState == 1 || uploading || texLoading) {
|
||
// Bottom-centre pill: reading the APKs, then a progress bar while meshes and textures go to the GPU.
|
||
const std::string msg = texLoading ? "Loading textures\u2026" : uploading ? "Uploading to the GPU\u2026"
|
||
: "Reading " + displayName(loadingPaths) + "\u2026";
|
||
const ImVec2 ts = ImGui::CalcTextSize(msg.c_str());
|
||
const float w = std::max(ts.x, 260 * scale), barH = 4 * scale;
|
||
const ImVec2 p(viewPos.x + (viewSize.x - w) * 0.5f, end.y - ts.y - barH - 40 * scale);
|
||
dl->AddRectFilled(ImVec2(p.x - 16 * scale, p.y - 10 * scale), ImVec2(p.x + w + 16 * scale, p.y + ts.y + barH + 18 * scale),
|
||
col(kBar, 0.92f), 6 * scale);
|
||
dl->AddText(p, col(kText), msg.c_str());
|
||
const ImVec2 b0(p.x, p.y + ts.y + 8 * scale), b1(p.x + w, b0.y + barH);
|
||
dl->AddRectFilled(b0, b1, col(kWidget), barH);
|
||
const bool det = uploading || texLoading;
|
||
const float f = texLoading ? renderer.textureProgress() : uploading ? renderer.loadProgress()
|
||
: 0.5f + 0.5f * std::sin(static_cast<float>(ImGui::GetTime()) * 3.0f); // indeterminate
|
||
const float x0 = det ? b0.x : b0.x + (w * 0.7f) * (0.5f - 0.5f * std::cos(static_cast<float>(ImGui::GetTime()) * 2.0f));
|
||
dl->AddRectFilled(ImVec2(x0, b0.y), ImVec2(det ? b0.x + w * f : x0 + w * 0.3f, b1.y), col(kBlue), barH);
|
||
}
|
||
ImGui::EndChild();
|
||
}
|
||
|
||
void statusBar(float y, float h) {
|
||
ImGui::SetNextWindowPos(ImVec2(0, y));
|
||
ImGui::SetNextWindowSize(ImVec2(ImGui::GetIO().DisplaySize.x, h));
|
||
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(10 * scale, (h - ImGui::GetTextLineHeight()) * 0.5f));
|
||
ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 0);
|
||
ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0);
|
||
ImGui::PushStyleColor(ImGuiCol_WindowBg, hex(kBar));
|
||
ImGui::Begin("##status", nullptr, ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoBringToFrontOnFocus | ImGuiWindowFlags_NoNav);
|
||
ImDrawList* dl = ImGui::GetWindowDrawList();
|
||
dl->AddLine(ImVec2(0, y), ImVec2(ImGui::GetIO().DisplaySize.x, y), col(kLine, 0.7f), 1.0f);
|
||
auto divider = [&]() { // a short hairline between the bar's parts
|
||
ImGui::SameLine(0, 10 * scale);
|
||
const ImVec2 c = ImGui::GetCursorScreenPos();
|
||
dl->AddLine(ImVec2(c.x, c.y + 2 * scale), ImVec2(c.x, c.y + ImGui::GetTextLineHeight() - 2 * scale), col(kLine), 1.0f);
|
||
ImGui::Dummy(ImVec2(1, 0));
|
||
ImGui::SameLine(0, 10 * scale);
|
||
};
|
||
if (scene) {
|
||
ImGui::TextUnformatted(homeName.c_str());
|
||
ImGui::SameLine(0, 10 * scale);
|
||
if (!scene->sources.empty()) { // e.g. "HSL · Home · v210", as a small orange pill
|
||
const HslSourceInfo& s0 = scene->sources[0];
|
||
std::string badge = s0.format.substr(0, s0.format.find(' ')) + " \u00b7 " + (scene->sources.size() > 1 ? std::string("Footprint + Vista") : s0.role);
|
||
if (!s0.version.empty() && std::isdigit(static_cast<unsigned char>(s0.version[0]))) badge += " \u00b7 v" + s0.version.substr(0, s0.version.find('.'));
|
||
const ImVec2 at = ImGui::GetCursorScreenPos(), ts = ImGui::CalcTextSize(badge.c_str());
|
||
const float px = 7 * scale;
|
||
dl->AddRectFilled(ImVec2(at.x, at.y - 1 * scale), ImVec2(at.x + ts.x + 2 * px, at.y + ts.y + 1 * scale), col(kOrange, 0.14f), ts.y);
|
||
ImGui::SetCursorScreenPos(ImVec2(at.x + px, at.y));
|
||
ImGui::PushStyleColor(ImGuiCol_Text, hex(kOrange));
|
||
ImGui::TextUnformatted(badge.c_str());
|
||
ImGui::PopStyleColor();
|
||
ImGui::SameLine(0, px);
|
||
ImGui::Dummy(ImVec2(0, 0));
|
||
}
|
||
divider();
|
||
ImGui::TextDisabled("%s", selected >= 0 ? objects[selected].name.c_str() : "nothing selected");
|
||
divider();
|
||
const float fps = ImGui::GetIO().Framerate;
|
||
ImGui::TextDisabled("%.0f fps · %.1f ms · 3D %.1f ms · %zu drawn", fps, fps > 0 ? 1000.0f / fps : 0.0f,
|
||
renderMs, renderer.drawnCount());
|
||
} else {
|
||
ImGui::TextDisabled("Frame Studio");
|
||
}
|
||
std::string saveMsg;
|
||
{ std::lock_guard<std::mutex> l(statusMutex); saveMsg = saveStatus; }
|
||
const std::string s = (saveMsg.empty() ? "" : saveMsg + " \u00b7 ") + "Frame: " + status();
|
||
ImGui::SameLine(std::max(0.0f, ImGui::GetWindowWidth() - ImGui::CalcTextSize(s.c_str()).x - 12 * scale));
|
||
ImGui::TextDisabled("%s", s.c_str());
|
||
ImGui::End();
|
||
ImGui::PopStyleColor();
|
||
ImGui::PopStyleVar(3);
|
||
}
|
||
|
||
// The window, as an editor dock: the menu bar; the tool strip, the Explorer, the 3D view and the Inspector side by
|
||
// side; the timeline and the status bar along the bottom.
|
||
void ui() {
|
||
if (outlinerW == 0) { outlinerW = 270 * scale; propertiesW = 360 * scale; }
|
||
shortcuts();
|
||
menuBar();
|
||
ImGuiIO& io = ImGui::GetIO();
|
||
const float top = ImGui::GetFrameHeight(), barH = ImGui::GetFrameHeight();
|
||
const float timelineH = scene ? 34 * scale : 0.0f;
|
||
const float h = io.DisplaySize.y - top - barH - timelineH;
|
||
ImGui::SetNextWindowPos(ImVec2(0, top));
|
||
ImGui::SetNextWindowSize(ImVec2(io.DisplaySize.x, h + timelineH));
|
||
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0, 0));
|
||
ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(0, 0));
|
||
ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 0);
|
||
ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0);
|
||
ImGui::Begin("##main", nullptr, ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoSavedSettings |
|
||
ImGuiWindowFlags_NoBringToFrontOnFocus | ImGuiWindowFlags_NoScrollWithMouse | ImGuiWindowFlags_NoNav);
|
||
ImGui::PopStyleVar(4);
|
||
const float split = 4 * scale, stripW = 44 * scale;
|
||
// never narrower than this, whatever the splitters say
|
||
outlinerW = std::clamp(outlinerW, 180 * scale, std::max(180 * scale, io.DisplaySize.x * 0.35f));
|
||
propertiesW = std::clamp(propertiesW, 260 * scale, std::max(260 * scale, io.DisplaySize.x * 0.4f));
|
||
const float viewW = std::max(64.0f, io.DisplaySize.x - stripW - outlinerW - propertiesW - 2 * split);
|
||
toolStrip(stripW, h);
|
||
ImGui::SameLine(0, 0);
|
||
ImGui::BeginChild("explorerColumn", ImVec2(outlinerW, h), ImGuiChildFlags_None, ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse);
|
||
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(8 * scale, 8 * scale));
|
||
outliner(h);
|
||
ImGui::PopStyleVar();
|
||
ImGui::EndChild();
|
||
ImGui::SameLine(0, 0);
|
||
splitter("##s1", outlinerW, +1, h);
|
||
ImGui::SameLine(0, 0);
|
||
viewport(viewW, h);
|
||
ImGui::SameLine(0, 0);
|
||
splitter("##s2", propertiesW, -1, h);
|
||
ImGui::SameLine(0, 0);
|
||
ImGui::BeginChild("inspectorColumn", ImVec2(0, h), ImGuiChildFlags_None, ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse);
|
||
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(10 * scale, 8 * scale));
|
||
ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(6 * scale, 5 * scale));
|
||
inspector(h);
|
||
ImGui::PopStyleVar(2);
|
||
ImGui::EndChild();
|
||
if (scene) timelineRow(ImVec2(0, top + h), ImVec2(io.DisplaySize.x, timelineH));
|
||
ImGui::End();
|
||
statusBar(top + h + timelineH, barH);
|
||
controlsWindow();
|
||
installWindow();
|
||
settingsWindow();
|
||
reportWindow();
|
||
headsetDebugWindow();
|
||
loadPrompt();
|
||
vistaPicker();
|
||
autoSummaryWindow();
|
||
}
|
||
|
||
// ── Headset > Install on Steam Frame ──
|
||
// Puts Frame Home on the headset as its environment: SteamVR's home-app slot, which SteamVR itself runs whenever
|
||
// no game is running (in place of the default environment, not as a game or an extra app), plus the chosen
|
||
// environments. The headset side is frame\install.sh beside this exe, with Frame Home's arm64 binaries.
|
||
|
||
void openInstall() {
|
||
showInstall = true;
|
||
if (installEnvs.empty()) // start from the environments saved recently
|
||
for (const auto& r : recent)
|
||
if (r.size() == 1 && isFenvPath(r[0]) && fs::exists(fs::path(widen(r[0])))) installEnvs.push_back(r[0]);
|
||
}
|
||
|
||
bool openHomeIsFenv() const { return homePaths.size() == 1 && isFenvPath(homePaths[0]); }
|
||
|
||
void addInstallEnvDialog() {
|
||
const std::vector<std::string> picked = platform::openFiles("Environments to install on the Steam Frame", {{"Frame environments", {"*.fenv"}}}, true);
|
||
for (const auto& f : picked)
|
||
if (std::find(installEnvs.begin(), installEnvs.end(), f) == installEnvs.end()) installEnvs.push_back(f);
|
||
}
|
||
|
||
void startInstall(bool uninstall) {
|
||
if (installing) return;
|
||
if (saving) { setInstallStatus("Wait for the environment being saved to finish, then install."); return; }
|
||
saveHost();
|
||
std::vector<std::string> envs;
|
||
std::string openFenv;
|
||
if (!uninstall) {
|
||
for (const auto& e : installEnvs) if (fs::exists(fs::path(widen(e)))) envs.push_back(e);
|
||
if (installOpenHome && scene) {
|
||
if (openHomeIsFenv()) {
|
||
if (std::find(envs.begin(), envs.end(), homePaths[0]) == envs.end()) envs.insert(envs.begin(), homePaths[0]);
|
||
} else if (!uploading) { // the open home, saved as a .fenv for the headset
|
||
std::error_code ec;
|
||
fs::create_directories(dataDir() / L"install", ec);
|
||
openFenv = narrow((dataDir() / L"install" / widen(frameName(homeName + ".fenv"))).wstring());
|
||
fs::remove(fs::path(widen(openFenv)), ec); // a failed save must not ship the last install's copy
|
||
saveEnvironment(openFenv, false);
|
||
}
|
||
}
|
||
}
|
||
if (installer.joinable()) installer.join();
|
||
installing = true;
|
||
const std::string host8 = hostArg();
|
||
installer = std::thread([this, uninstall, envs, openFenv, host8]() mutable {
|
||
auto done = [&](const std::string& m) { setInstallStatus(m); installing = false; };
|
||
const std::wstring host = widen(host8);
|
||
const std::wstring ssh = sshTool(L"ssh") + L"-o BatchMode=yes -o ConnectTimeout=8 " + host + L" ";
|
||
const std::wstring scp = sshTool(L"scp") + L"-q -o BatchMode=yes -o ConnectTimeout=8 ";
|
||
const fs::path payload = exeDir() / L"frame";
|
||
if (!uninstall && !fs::exists(payload / L"install.sh"))
|
||
return done("Frame Home's headset files are missing: expected " + narrow((payload / L"install.sh").wstring()));
|
||
if (!openFenv.empty()) {
|
||
setInstallStatus("Saving the open home as an environment\u2026");
|
||
while (saving) std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||
if (!fs::exists(fs::path(widen(openFenv)))) return done("Couldn't save the open home as an environment");
|
||
envs.insert(envs.begin(), openFenv);
|
||
}
|
||
std::string out;
|
||
setInstallStatus("Connecting to " + host8 + "\u2026");
|
||
int rc = run(ssh + L"\"mkdir -p frame-home/envs frame-home/.install && uname -m\"", L"", out);
|
||
if (rc != 0) return done("Can't reach the headset (" + host8 + "): " + lastLine(out) +
|
||
(out.find("could not start") != std::string::npos ? " (install OpenSSH or Git for Windows)" : ""));
|
||
if (lastLine(out) != "aarch64") return done(host8 + " isn't a Steam Frame (it reports " + lastLine(out) + ")");
|
||
if (!uninstall) {
|
||
setInstallStatus("Copying Frame Home\u2026");
|
||
out.clear();
|
||
rc = run(ssh + L"\"rm -rf frame-home/.install/frame\"", L"", out);
|
||
// Relative source from its own folder: scp would read "C:\\..." as a host named C.
|
||
if (rc == 0) rc = run(scp + L"-r frame " + host + L":frame-home/.install/", exeDir().wstring(), out);
|
||
if (rc != 0) return done("Copying Frame Home failed: " + lastLine(out));
|
||
for (size_t i = 0; i < envs.size(); ++i) {
|
||
const fs::path f = fs::path(widen(envs[i]));
|
||
const std::wstring name = widen(frameName(envs[i]));
|
||
out.clear();
|
||
rc = run(ssh + L"\"stat -c %s frame-home/envs/" + name + L" 2>/dev/null || echo 0\"", L"", out);
|
||
std::error_code ec;
|
||
const unsigned long long local = fs::file_size(f, ec);
|
||
if (rc == 0 && std::strtoull(lastLine(out).c_str(), nullptr, 10) == local) continue; // already there
|
||
char msg[256];
|
||
std::snprintf(msg, sizeof msg, "Copying %s (%zu of %zu, %.0f MB)\u2026", narrow(f.filename().wstring()).c_str(), i + 1, envs.size(), local / 1048576.0);
|
||
setInstallStatus(msg);
|
||
out.clear();
|
||
rc = run(scp + L"\"" + f.filename().wstring() + L"\" " + host + L":frame-home/envs/" + name + L".part", f.parent_path().wstring(), out);
|
||
if (rc == 0) rc = run(ssh + L"\"mv -f frame-home/envs/" + name + L".part frame-home/envs/" + name + L"\"", L"", out);
|
||
if (rc != 0) return done("Copying " + narrow(f.filename().wstring()) + " failed: " + lastLine(out));
|
||
}
|
||
}
|
||
setInstallStatus(uninstall ? "Restoring SteamVR's environment\u2026" : "Installing on the headset\u2026");
|
||
out.clear();
|
||
rc = run(ssh + L"\"sed -i 's/\\r$//' frame-home/.install/frame/install.sh 2>/dev/null; sh frame-home/.install/frame/install.sh" +
|
||
(uninstall ? L" uninstall" : L"") + L"\"", L"", out);
|
||
if (uninstall && rc != 0 && out.find("No such file") != std::string::npos) // never installed from here
|
||
rc = run(ssh + L"\"frame-home/bin/frame-home --uninstall\"", L"", out);
|
||
// The installer's own verdict (its tools' log lines can arrive after it).
|
||
std::string msg = lastLine(out);
|
||
if (const size_t at = out.rfind("frame-install: "); at != std::string::npos)
|
||
msg = lastLine(out.substr(at + 15, out.find('\n', at) == std::string::npos ? std::string::npos : out.find('\n', at) - at - 15));
|
||
if (rc != 0) return done("Failed: " + msg);
|
||
if (!uninstall && !envs.empty()) { // the first environment is the one shown
|
||
out.clear();
|
||
if (run(ssh + L"\"printf '" + widen(frameName(envs.front())) + L"\\n' > frame-home/current\"", L"", out) != 0)
|
||
return done("Installed, but couldn't choose the first environment: " + lastLine(out));
|
||
}
|
||
if (msg.empty()) msg = uninstall ? "SteamVR's environment restored" : "Installed";
|
||
msg[0] = static_cast<char>(std::toupper(static_cast<unsigned char>(msg[0])));
|
||
done(msg + (uninstall || envs.empty() ? "" : " (" + std::to_string(envs.size()) + (envs.size() == 1 ? " environment)" : " environments)")));
|
||
});
|
||
}
|
||
|
||
void installWindow() {
|
||
if (!showInstall) return;
|
||
ImGui::SetNextWindowSize(ImVec2(480 * scale, 0), ImGuiCond_Appearing);
|
||
ImGui::SetNextWindowPos(ImVec2(ImGui::GetIO().DisplaySize.x * 0.5f, ImGui::GetIO().DisplaySize.y * 0.5f), ImGuiCond_Appearing, ImVec2(0.5f, 0.5f));
|
||
if (!ImGui::Begin("Install on Steam Frame", &showInstall, ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_AlwaysAutoResize)) { ImGui::End(); return; }
|
||
ImGui::PushTextWrapPos(460 * scale);
|
||
ImGui::TextWrapped("Installs Frame Home on the headset as its environment. SteamVR runs it natively in place of the default "
|
||
"environment whenever no game is running, and brings it back when a game quits. It is not a game or an extra app.");
|
||
ImGui::Spacing();
|
||
ImGui::BeginDisabled(installing);
|
||
ImGui::SetNextItemWidth(220 * scale);
|
||
ImGui::InputText("Headset", frameHost, sizeof frameHost);
|
||
ImGui::SameLine();
|
||
ImGui::TextDisabled("ssh alias or steamos@<address>");
|
||
ImGui::Spacing();
|
||
ImGui::SeparatorText("Environments");
|
||
if (scene) {
|
||
const std::string label = "The open home (" + homeName + ")" + (openHomeIsFenv() ? "" : ", saved as .fenv");
|
||
ImGui::Checkbox(label.c_str(), &installOpenHome);
|
||
}
|
||
int remove = -1;
|
||
for (size_t i = 0; i < installEnvs.size(); ++i) {
|
||
ImGui::PushID(static_cast<int>(i));
|
||
if (ImGui::SmallButton("\u2715")) remove = static_cast<int>(i);
|
||
ImGui::SameLine();
|
||
ImGui::TextUnformatted(narrow(fs::path(widen(installEnvs[i])).filename().wstring()).c_str());
|
||
ImGui::PopID();
|
||
}
|
||
if (remove >= 0) installEnvs.erase(installEnvs.begin() + remove);
|
||
if (installEnvs.empty() && !scene) ImGui::TextDisabled("None yet: Frame Home alone (it keeps the environments already on the headset).");
|
||
if (ImGui::Button("Add .fenv\u2026")) addInstallEnvDialog();
|
||
ImGui::Spacing();
|
||
ImGui::Separator();
|
||
pushPrimary();
|
||
if (ImGui::Button(installing ? "Installing\u2026" : "Install", ImVec2(140 * scale, ImGui::GetFrameHeight() * 1.4f))) startInstall(false);
|
||
ImGui::PopStyleColor(3);
|
||
ImGui::SameLine();
|
||
if (ImGui::Button("Restore SteamVR's environment", ImVec2(0, ImGui::GetFrameHeight() * 1.4f))) startInstall(true);
|
||
ImGui::EndDisabled();
|
||
ImGui::Spacing();
|
||
ImGui::TextWrapped("%s", getInstallStatus().c_str());
|
||
ImGui::PopTextWrapPos();
|
||
ImGui::End();
|
||
}
|
||
|
||
// A world point on the viewport (false behind the camera).
|
||
bool screenOf(const float* p, ImVec2& out) const {
|
||
const float ty = std::tan(kFovY * 0.5f), tx = ty * std::max(1.0f, viewSize.x) / std::max(1.0f, viewSize.y);
|
||
float pose[16], view[16], proj[16], vp[16];
|
||
cam.pose(pose);
|
||
invert(pose, view);
|
||
glProjection(tx, ty, proj);
|
||
mul(proj, view, vp);
|
||
float c[4];
|
||
for (int k = 0; k < 4; ++k) c[k] = vp[k] * p[0] + vp[4 + k] * p[1] + vp[8 + k] * p[2] + vp[12 + k];
|
||
if (c[3] < kNear) return false;
|
||
out = ImVec2(viewPos.x + (c[0] / c[3] * 0.5f + 0.5f) * viewSize.x, viewPos.y + (0.5f - c[1] / c[3] * 0.5f) * viewSize.y);
|
||
return true;
|
||
}
|
||
|
||
// Help > Controls (F1).
|
||
void controlsWindow() {
|
||
if (!showControls) return;
|
||
ImGui::SetNextWindowSize(ImVec2(420 * scale, 0), ImGuiCond_Appearing);
|
||
ImGui::SetNextWindowPos(ImVec2(ImGui::GetIO().DisplaySize.x * 0.5f, ImGui::GetIO().DisplaySize.y * 0.5f), ImGuiCond_Appearing, ImVec2(0.5f, 0.5f));
|
||
if (!ImGui::Begin("Controls", &showControls, ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_AlwaysAutoResize)) { ImGui::End(); return; }
|
||
static const char* rows[][2] = {
|
||
{"Orbit", "Middle drag, or Alt + left drag"}, {"Pan", "Shift + middle drag"}, {"Zoom", "Wheel, or Ctrl + middle drag"},
|
||
{"Fly", "Hold right mouse + W A S D, Q / E down / up"}, {"Fly speed", "Wheel while flying, Shift = 4x"},
|
||
{"Select", "Left click (viewport or explorer)"}, {"Move / Rotate / Scale", "G / R / S, hold Ctrl to snap"},
|
||
{"Frame selected / all", "F / Home (or double-click a row)"}, {"Player view", "Numpad 0 or P"},
|
||
{"Hide / unhide all", "H / Alt + H"}, {"Delete", "X or Delete"}, {"Undo / redo", "Ctrl + Z / Ctrl + Shift + Z"},
|
||
{"Open / save edits", "Ctrl + O / Ctrl + S"}, {"Show only selected", "I (I or Esc shows everything)"},
|
||
{"Show / hide world layer", "W (each kind: View \u203a Show, or the eyes in the explorer)"},
|
||
{"Floor / wall from a mesh", "Object panel \u203a Surface, or right-click it"},
|
||
{"Every mesh walkable where flat, blocking where steep", "World \u203a Auto-generate Walkable Surfaces"},
|
||
{"Deselect", "Esc"}};
|
||
if (ImGui::BeginTable("keys", 2, ImGuiTableFlags_RowBg | ImGuiTableFlags_SizingFixedFit)) {
|
||
for (const auto& r : rows) {
|
||
ImGui::TableNextRow();
|
||
ImGui::TableNextColumn();
|
||
ImGui::TextDisabled("%s", r[0]);
|
||
ImGui::TableNextColumn();
|
||
ImGui::TextUnformatted(r[1]);
|
||
}
|
||
ImGui::EndTable();
|
||
}
|
||
ImGui::End();
|
||
}
|
||
};
|
||
|
||
|
||
// ── Self-test (Frame Studio.exe --selftest <home files>) ────────────────────────────────────────────
|
||
// Drives the real UI frame by frame — mouse moves, presses and drags fed to ImGui exactly like a user's, on the
|
||
// actual gizmo and viewport — and checks what changed: moving, rotating and scaling objects and world items,
|
||
// surfaces made from meshes, what is shown, undo. Writes selftest\report.txt (+ screenshots) next to the edits
|
||
// folder and quits. Nothing is saved to the home's edits.
|
||
|
||
struct SelfTest {
|
||
Studio& a;
|
||
std::vector<std::string> paths;
|
||
fs::path dir;
|
||
FILE* report = nullptr;
|
||
int passes = 0, fails = 0;
|
||
|
||
// Input for the coming frames: one batch per frame.
|
||
struct In { bool move = false; ImVec2 pos; int button = -1; bool down = false; ImGuiKey key = ImGuiKey_None; bool ctrl = false; };
|
||
std::deque<In> queue;
|
||
ImVec2 mouse{-1, -1};
|
||
|
||
// The script: each stage runs once its wait is over; it returns the frames to wait before the next one,
|
||
// or -1 to run again next frame (polling).
|
||
std::vector<std::function<int()>> stages;
|
||
size_t stage = 0;
|
||
int wait = 30;
|
||
int polls = 0;
|
||
std::chrono::steady_clock::time_point started = std::chrono::steady_clock::now();
|
||
|
||
// Scratch shared between stages.
|
||
std::vector<Studio::TestEye> eyes;
|
||
int folderEye = -1, single = -1;
|
||
int obj = -1;
|
||
float before[16] = {}, objWorld[16] = {};
|
||
ImVec2 probeHit{-1, -1};
|
||
size_t collidersBefore = 0, spawnsBefore = 0, splitBefore = 0;
|
||
int autoCount = 0;
|
||
std::vector<std::string> autoNames;
|
||
std::string firstGrid;
|
||
float wallAt[3] = {};
|
||
std::shared_ptr<HomeWorld> worldBefore;
|
||
|
||
SelfTest(Studio& app, std::vector<std::string> p) : a(app), paths(std::move(p)) {
|
||
dir = dataDir() / L"selftest";
|
||
std::error_code ec;
|
||
fs::create_directories(dir, ec);
|
||
report = platform::openFile((dir / L"report.txt").c_str(), "wb");
|
||
a.testing = true;
|
||
log("Frame Studio self-test");
|
||
for (const auto& h : paths) log("home: %s", h.c_str());
|
||
build();
|
||
}
|
||
|
||
void log(const char* fmt, ...) {
|
||
char b[2048];
|
||
va_list ap;
|
||
va_start(ap, fmt);
|
||
std::vsnprintf(b, sizeof b, fmt, ap);
|
||
va_end(ap);
|
||
if (report) { std::fprintf(report, "%s\n", b); std::fflush(report); }
|
||
std::fprintf(stderr, "[selftest] %s\n", b);
|
||
}
|
||
void check(bool ok, const char* what, const std::string& detail = {}) {
|
||
(ok ? passes : fails)++;
|
||
log("%s %s%s%s", ok ? "PASS" : "FAIL", what, detail.empty() ? "" : " — ", detail.c_str());
|
||
}
|
||
static std::string f3(const float* v) { char b[96]; std::snprintf(b, sizeof b, "(%.3f %.3f %.3f)", v[0], v[1], v[2]); return b; }
|
||
static float colLen(const float* m, int c) { return std::sqrt(m[c * 4] * m[c * 4] + m[c * 4 + 1] * m[c * 4 + 1] + m[c * 4 + 2] * m[c * 4 + 2]); }
|
||
static float dist3(const float* p, const float* q) { return std::sqrt((p[0] - q[0]) * (p[0] - q[0]) + (p[1] - q[1]) * (p[1] - q[1]) + (p[2] - q[2]) * (p[2] - q[2])); }
|
||
|
||
void moveTo(ImVec2 p) { In i; i.move = true; i.pos = p; queue.push_back(i); }
|
||
void press(bool down) { In i; i.button = 0; i.down = down; queue.push_back(i); }
|
||
void key(ImGuiKey k, bool ctrl = false) { In i; i.key = k; i.ctrl = ctrl; queue.push_back(i); }
|
||
void pressB(int b, bool down) { In i; i.button = b; i.down = down; queue.push_back(i); }
|
||
void click(ImVec2 p) { moveTo(p); moveTo(p); press(true); queue.push_back(In{}); press(false); queue.push_back(In{}); }
|
||
// A left-button drag in small steps, the way a hand does it.
|
||
void drag(ImVec2 from, ImVec2 to, int steps = 12) {
|
||
moveTo(from); moveTo(from);
|
||
press(true);
|
||
In hold; queue.push_back(hold);
|
||
for (int k = 1; k <= steps; ++k) moveTo(ImVec2(from.x + (to.x - from.x) * k / steps, from.y + (to.y - from.y) * k / steps));
|
||
moveTo(to);
|
||
press(false);
|
||
queue.push_back(In{}); queue.push_back(In{});
|
||
}
|
||
|
||
// Called between the backend's NewFrame and ImGui::NewFrame: these events land last, so they win.
|
||
void inject(ImGuiIO& io) {
|
||
io.ConfigInputTrickleEventQueue = false;
|
||
if (mouse.x >= 0) io.AddMousePosEvent(mouse.x, mouse.y);
|
||
if (queue.empty()) return;
|
||
const In i = queue.front();
|
||
queue.pop_front();
|
||
if (i.move) { mouse = i.pos; io.AddMousePosEvent(mouse.x, mouse.y); }
|
||
if (i.button >= 0) io.AddMouseButtonEvent(i.button, i.down);
|
||
if (i.key != ImGuiKey_None) {
|
||
if (i.ctrl) io.AddKeyEvent(ImGuiMod_Ctrl, true);
|
||
io.AddKeyEvent(i.key, true);
|
||
pendingUp.push_back({i.key, i.ctrl});
|
||
} else {
|
||
for (auto& u : pendingUp) { io.AddKeyEvent(u.first, false); if (u.second) io.AddKeyEvent(ImGuiMod_Ctrl, false); }
|
||
pendingUp.clear();
|
||
}
|
||
}
|
||
std::vector<std::pair<ImGuiKey, bool>> pendingUp;
|
||
|
||
int idConflictFrames = 0;
|
||
std::string idConflictWhere;
|
||
std::chrono::steady_clock::time_point pollStart; // when the stage being polled started waiting
|
||
|
||
// After the UI of a frame.
|
||
void afterUi() {
|
||
if (ImGui::GetCurrentContext()->DebugDrawIdConflicts != 0) { // ImGui's "conflicting ID" error, anywhere
|
||
if (!idConflictFrames++) { char b[96]; std::snprintf(b, sizeof b, "first at mouse (%.0f, %.0f), stage %zu", mouse.x, mouse.y, stage); idConflictWhere = b; }
|
||
}
|
||
if (!queue.empty()) return; // input still playing
|
||
if (wait > 0) { --wait; return; }
|
||
if (stage >= stages.size()) return;
|
||
if (std::chrono::steady_clock::now() - started > std::chrono::minutes(6)) { check(false, "finished in time"); stage = stages.size(); finish(); return; }
|
||
const int w = stages[stage]();
|
||
if (w < 0) { // still waiting: a minute, but at least 3000 frames (a software GPU draws ~30 a second, and a gizmo
|
||
// probe takes a frame a step); 5 minutes at most whatever the frame rate
|
||
if (polls++ == 0) pollStart = std::chrono::steady_clock::now();
|
||
const auto waited = std::chrono::steady_clock::now() - pollStart;
|
||
if ((waited > std::chrono::seconds(60) && polls > 3000) || waited > std::chrono::minutes(5)) {
|
||
check(false, "a stage timed out", std::to_string(stage)); polls = 0; ++stage;
|
||
}
|
||
return;
|
||
}
|
||
polls = 0;
|
||
wait = w;
|
||
++stage;
|
||
}
|
||
|
||
// The whole window as shown (UI, gizmo and world overlays included).
|
||
void screenshot(const char* name) {
|
||
std::vector<u8> rgba;
|
||
int w = 0, h = 0;
|
||
if (!a.window.capture || !a.window.capture(rgba, w, h) || w < 8 || h < 8) { log("(no screenshot %s)", name); return; }
|
||
const fs::path file = dir / widen(std::string(name) + ".png");
|
||
const std::string path = narrow(file.wstring());
|
||
if (FILE* f = platform::openFile(file.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, rgba.data(), w * 4);
|
||
std::fclose(f);
|
||
}
|
||
log("screenshot %s", path.c_str());
|
||
}
|
||
|
||
// Walks the mouse outwards from the gizmo centre along `dir` until ImGuizmo reports a handle under it.
|
||
// Stage helper: returns -1 while probing; sets probeHit (x < 0 = none) when done.
|
||
float probeR = 0;
|
||
ImVec2 probeC, probeD;
|
||
void startProbe(ImVec2 c, ImVec2 d, float r0) {
|
||
const float L = std::max(1e-3f, std::sqrt(d.x * d.x + d.y * d.y));
|
||
probeC = c; probeD = ImVec2(d.x / L, d.y / L); probeR = r0; probeHit = ImVec2(-1, -1);
|
||
moveTo(ImVec2(c.x + probeD.x * r0, c.y + probeD.y * r0));
|
||
queue.push_back(In{}); queue.push_back(In{});
|
||
}
|
||
std::vector<ImVec2> probeDirs; // further directions to try when one finds nothing
|
||
float probeR0 = 0;
|
||
int probe() {
|
||
if (a.gizmoHot) { probeHit = mouse; return 0; }
|
||
probeR += 4 * a.scale;
|
||
if (probeR > 320 * a.scale) {
|
||
if (probeDirs.empty()) return 0;
|
||
const ImVec2 d = probeDirs.back();
|
||
probeDirs.pop_back();
|
||
startProbe(probeC, d, probeR0);
|
||
return -1;
|
||
}
|
||
moveTo(ImVec2(probeC.x + probeD.x * probeR, probeC.y + probeD.y * probeR));
|
||
queue.push_back(In{}); queue.push_back(In{}); // ImGuizmo's hover is a frame behind
|
||
return -1;
|
||
}
|
||
|
||
bool gizmoCentre(ImVec2& c, ImVec2& xDir, ImVec2& yDir, ImVec2& zDir) {
|
||
float m[16];
|
||
if (a.wKind >= 0) { if (!a.worldMatrix(a.wKind, a.wIndex, m)) return false; }
|
||
else if (a.selected >= 0) std::memcpy(m, a.objects[a.selected].pivot, 64);
|
||
else return false;
|
||
const float o[3] = {m[12], m[13], m[14]};
|
||
if (!a.screenOf(o, c)) return false;
|
||
ImVec2 e;
|
||
const float px[3] = {o[0] + 0.3f, o[1], o[2]}, py[3] = {o[0], o[1] + 0.3f, o[2]}, pz[3] = {o[0], o[1], o[2] + 0.3f};
|
||
if (a.screenOf(px, e)) xDir = ImVec2(e.x - c.x, e.y - c.y);
|
||
if (a.screenOf(py, e)) yDir = ImVec2(e.x - c.x, e.y - c.y);
|
||
if (a.screenOf(pz, e)) zDir = ImVec2(e.x - c.x, e.y - c.y);
|
||
return true;
|
||
}
|
||
|
||
// Probe for a handle along a screen direction, then drag it by `delta` pixels. Adds the stages.
|
||
void gizmoDrag(const char* what, Tool tool, int axis /* 0 x, 1 y, 2 z, 3 centre, 4 rotate ring */, ImVec2 delta) {
|
||
stages.push_back([this, tool] { a.tool = tool; a.localSpace = false; return 3; });
|
||
stages.push_back([this, axis] {
|
||
ImVec2 c, dx, dy, dz;
|
||
if (!gizmoCentre(c, dx, dy, dz)) { probeHit = ImVec2(-1, -1); probeR = 1e9f; return 0; }
|
||
const ImVec2 d = axis == 0 ? dx : axis == 1 ? dy : axis == 2 ? dz : axis == 4 ? ImVec2(0.7f, -0.7f) : ImVec2(1, 0);
|
||
probeR0 = axis == 3 ? 0.0f : 14 * a.scale;
|
||
probeDirs.clear();
|
||
if (axis == 4) for (int k = 0; k < 8; ++k) probeDirs.push_back(ImVec2(std::cos(k * kPi / 4 + 0.3f), std::sin(k * kPi / 4 + 0.3f)));
|
||
else if (axis < 3) probeDirs.push_back(ImVec2(-d.x, -d.y));
|
||
log(" gizmo at (%.0f, %.0f), probing towards (%.1f, %.1f); view (%.0f, %.0f) %.0f x %.0f", c.x, c.y, d.x, d.y, a.viewPos.x, a.viewPos.y, a.viewSize.x, a.viewSize.y);
|
||
startProbe(c, d, probeR0);
|
||
return 0;
|
||
});
|
||
stages.push_back([this] { return probe(); });
|
||
stages.push_back([this, what, delta] {
|
||
if (probeHit.x < 0) {
|
||
char st[200];
|
||
std::snprintf(st, sizeof st, "no gizmo handle found under the mouse (world item %d #%d, object %d, tool %d, popup %s, %zu zones)", a.wKind, a.wIndex,
|
||
a.selected, static_cast<int>(a.tool), ImGui::IsPopupOpen(nullptr, ImGuiPopupFlags_AnyPopupId) ? "open" : "none",
|
||
a.scene ? a.scene->world.gravity.size() : 0);
|
||
check(false, what, st);
|
||
screenshot("gizmo_not_found");
|
||
return 0;
|
||
}
|
||
if (a.wKind >= 0) { float m[16]; a.worldMatrix(a.wKind, a.wIndex, m); std::memcpy(before, m, 64); }
|
||
else std::memcpy(before, a.objects[a.selected].pivot, 64);
|
||
drag(probeHit, ImVec2(probeHit.x + delta.x, probeHit.y + delta.y));
|
||
return 4;
|
||
});
|
||
}
|
||
|
||
float* current(float* m) {
|
||
if (a.wKind >= 0) { if (!a.worldMatrix(a.wKind, a.wIndex, m)) std::memcpy(m, before, 64); }
|
||
else if (a.selected >= 0) std::memcpy(m, a.objects[a.selected].pivot, 64);
|
||
return m;
|
||
}
|
||
|
||
void build() {
|
||
// ── A slow load asks; Stop Loading stops it, and the stopped load's result never shows up
|
||
stages.push_back([this] { a.open(paths, false); a.loadPromptAfter = 0.05f; return 1; });
|
||
stages.push_back([this] {
|
||
if (!a.loadPromptOpen && a.isLoading()) return -1;
|
||
check(a.loadPromptOpen, "a load that takes too long asks whether to keep waiting");
|
||
return 3; // drawn from the next frame
|
||
});
|
||
stages.push_back([this] {
|
||
screenshot("00_still_loading");
|
||
a.stopLoading();
|
||
check(a.loadState == 0 && !a.scene && !a.isLoading(), "Stop Loading stops it");
|
||
return 270; // 3 s: long enough for the stopped load to have finished
|
||
});
|
||
stages.push_back([this] {
|
||
check(a.loadState == 0 && !a.scene, "the stopped load doesn't appear later");
|
||
return 1;
|
||
});
|
||
// ── Load
|
||
stages.push_back([this] {
|
||
if (isFootprintApk(paths[0])) { // a footprint is paired with a vista beside it (the test opens the room alone)
|
||
const std::vector<std::string> v = vistasBeside(paths[0]);
|
||
check(!v.empty() && isVistaApk(v[0]), "the vistas beside a footprint are found to pair it with", std::to_string(v.size()) + (v.empty() ? "" : ", e.g. " + prettyHome(v[0])));
|
||
}
|
||
a.open(paths, false);
|
||
return 5;
|
||
});
|
||
stages.push_back([this] {
|
||
if (a.loadState != 0 || !a.scene || a.uploading || a.texLoading || a.objects.empty() || !a.rendered) return -1;
|
||
check(true, "home loaded", std::to_string(a.objects.size()) + " objects, " + std::to_string(a.scene->world.colliders.size()) + " surfaces");
|
||
for (const fs::path& f : {a.worldFile(), a.editsFile()}) { // put back as found when the test ends
|
||
std::ifstream in(f, std::ios::binary);
|
||
saved.push_back({f, static_cast<bool>(in), std::string((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>())});
|
||
}
|
||
a.frameAll();
|
||
return 20;
|
||
});
|
||
stages.push_back([this] { screenshot("01_loaded"); return 1; });
|
||
|
||
// ── Pick a mid-sized object, centred in view
|
||
stages.push_back([this] {
|
||
float best = 1e30f;
|
||
for (size_t i = 0; i < a.objects.size(); ++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);
|
||
if (d <= 0.4f || std::fabs(d - 2.0f) >= best) continue;
|
||
std::vector<u32> fl, wl; // furniture-like: something to stand on and sides
|
||
a.classifyObject(o, fl, wl);
|
||
if (fl.size() < 4 || wl.size() < 4) continue;
|
||
best = std::fabs(d - 2.0f);
|
||
obj = static_cast<int>(i);
|
||
}
|
||
check(obj >= 0, "found an object to edit", obj >= 0 ? a.objects[obj].label : "");
|
||
if (obj < 0) { stage = stages.size() - 2; return 0; }
|
||
a.selected = obj; a.wKind = a.wIndex = -1;
|
||
a.frameSelected();
|
||
a.cam.dist *= 1.6f;
|
||
return 15;
|
||
});
|
||
|
||
// ── Objects: move (centre), move on X, rotate, scale — through the gizmo, with the mouse
|
||
gizmoDrag("object: drag the gizmo centre", Tool::Move, 3, ImVec2(70, 30));
|
||
stages.push_back([this] {
|
||
float m[16]; current(m);
|
||
const float d = dist3(&m[12], &before[12]);
|
||
check(d > 0.02f, "object moves with the gizmo (screen drag)", f3(&before[12]) + " -> " + f3(&m[12]));
|
||
return 2;
|
||
});
|
||
gizmoDrag("object: drag the X arrow", Tool::Move, 0, ImVec2(80, 0));
|
||
stages.push_back([this] {
|
||
float m[16]; current(m);
|
||
const float dx = m[12] - before[12], dy = m[13] - before[13], dz = m[14] - before[14];
|
||
check(std::fabs(dx) > 0.02f && std::fabs(dy) < 1e-3f && std::fabs(dz) < 1e-3f, "object moves along X only on the X arrow",
|
||
f3(&before[12]) + " -> " + f3(&m[12]));
|
||
return 2;
|
||
});
|
||
gizmoDrag("object: drag the rotate ring", Tool::Rotate, 4, ImVec2(40, 60));
|
||
stages.push_back([this] {
|
||
float m[16]; current(m);
|
||
float rot = 0;
|
||
for (int k = 0; k < 11; ++k) if (k % 4 != 3) rot += std::fabs(m[k] - before[k]);
|
||
check(rot > 0.05f && dist3(&m[12], &before[12]) < 1e-3f, "object rotates with the gizmo (and stays put)", "change " + std::to_string(rot));
|
||
return 2;
|
||
});
|
||
gizmoDrag("object: drag the X scale handle", Tool::Scale, 0, ImVec2(60, 0));
|
||
stages.push_back([this] {
|
||
float m[16]; current(m);
|
||
check(std::fabs(colLen(m, 0) - colLen(before, 0)) > 0.05f, "object scales with the gizmo",
|
||
std::to_string(colLen(before, 0)) + " -> " + std::to_string(colLen(m, 0)));
|
||
return 2;
|
||
});
|
||
stages.push_back([this] { screenshot("02_object_edited"); return 1; });
|
||
// Each drag is one undo step: four Ctrl+Z bring it back.
|
||
stages.push_back([this] { for (int k = 0; k < 4; ++k) { key(ImGuiKey_Z, true); queue.push_back(In{}); } return 3; });
|
||
stages.push_back([this] {
|
||
const Object& o = a.objects[obj];
|
||
float d = 0;
|
||
for (int k = 0; k < 16; ++k) d += std::fabs(o.pivot[k] - o.pivot0[k]);
|
||
check(d < 1e-3f, "Ctrl+Z x4 undoes the four drags", "difference " + std::to_string(d));
|
||
return 2;
|
||
});
|
||
// Keyboard tools
|
||
stages.push_back([this] { key(ImGuiKey_R); queue.push_back(In{}); return 1; });
|
||
stages.push_back([this] { check(a.tool == Tool::Rotate, "R picks Rotate"); key(ImGuiKey_S); queue.push_back(In{}); return 1; });
|
||
stages.push_back([this] { check(a.tool == Tool::Scale, "S picks Scale"); key(ImGuiKey_G); queue.push_back(In{}); return 1; });
|
||
stages.push_back([this] { check(a.tool == Tool::Move, "G picks Move"); return 1; });
|
||
// Clicking the object in the viewport selects it (and empty space doesn't keep the gizmo stuck)
|
||
stages.push_back([this] {
|
||
a.selected = -1;
|
||
float mn[3], mx[3];
|
||
a.worldBounds(a.objects[obj], mn, mx);
|
||
const float c[3] = {(mn[0] + mx[0]) / 2, (mn[1] + mx[1]) / 2, (mn[2] + mx[2]) / 2};
|
||
ImVec2 sp;
|
||
if (a.screenOf(c, sp)) { moveTo(sp); moveTo(sp); press(true); queue.push_back(In{}); press(false); queue.push_back(In{}); }
|
||
return 3;
|
||
});
|
||
stages.push_back([this] {
|
||
check(a.selected >= 0, "clicking in the viewport selects an object", a.selected >= 0 ? a.objects[a.selected].label : "nothing");
|
||
a.selected = obj;
|
||
return 2;
|
||
});
|
||
|
||
// ── The explorer: every eye button hovered (no ImGui ID conflicts), and folders hide what's in them
|
||
stages.push_back([this] {
|
||
eyes = a.testEyes;
|
||
check(!eyes.empty(), "the explorer shows eye buttons", std::to_string(eyes.size()) + " visible");
|
||
for (const auto& e : eyes) { moveTo(e.at); queue.push_back(In{}); queue.push_back(In{}); }
|
||
return 2;
|
||
});
|
||
stages.push_back([this] {
|
||
check(idConflictFrames == 0, "no ImGui ID conflicts while hovering every eye", idConflictWhere);
|
||
folderEye = -1;
|
||
for (size_t k = 0; k < eyes.size(); ++k) if (eyes[k].members.size() > 1) { folderEye = static_cast<int>(k); break; }
|
||
check(folderEye >= 0, "found a folder to hide");
|
||
if (folderEye < 0) return 0;
|
||
const ImVec2 p = eyes[folderEye].at;
|
||
moveTo(p); moveTo(p); press(true); queue.push_back(In{}); press(false); queue.push_back(In{}); queue.push_back(In{});
|
||
return 3;
|
||
});
|
||
stages.push_back([this] {
|
||
if (folderEye < 0) return 0;
|
||
int hidden = 0;
|
||
for (size_t i : eyes[folderEye].members) hidden += a.objects[i].hidden;
|
||
check(hidden == static_cast<int>(eyes[folderEye].members.size()), "clicking a folder's eye hides everything in it",
|
||
std::to_string(hidden) + " of " + std::to_string(eyes[folderEye].members.size()));
|
||
const ImVec2 p = eyes[folderEye].at;
|
||
moveTo(p); press(true); queue.push_back(In{}); press(false); queue.push_back(In{}); queue.push_back(In{});
|
||
return 3;
|
||
});
|
||
stages.push_back([this] {
|
||
if (folderEye < 0) return 0;
|
||
int shown = 0;
|
||
for (size_t i : eyes[folderEye].members) shown += !a.objects[i].hidden;
|
||
check(shown == static_cast<int>(eyes[folderEye].members.size()), "clicking it again shows them all");
|
||
// a single row's eye
|
||
for (const auto& e : eyes)
|
||
if (e.members.size() == 1) {
|
||
single = static_cast<int>(e.members[0]);
|
||
moveTo(e.at); moveTo(e.at); press(true); queue.push_back(In{}); press(false); queue.push_back(In{}); queue.push_back(In{});
|
||
break;
|
||
}
|
||
return 3;
|
||
});
|
||
stages.push_back([this] {
|
||
if (single >= 0) {
|
||
check(a.objects[single].hidden, "an object's eye hides just it", a.objects[single].label);
|
||
a.toggleHidden(static_cast<size_t>(single));
|
||
}
|
||
return 1;
|
||
});
|
||
|
||
// ── Surfaces from meshes
|
||
stages.push_back([this] {
|
||
collidersBefore = a.scene->world.colliders.size();
|
||
a.setSurfaces({static_cast<size_t>(obj)}, kSurfSolid);
|
||
const int at = a.surfaceIndex(a.objects[obj]);
|
||
check(at >= 0 && a.scene->world.colliders[at].role == WorldCollider::Solid && a.surfaceParts(a.objects[obj]).size() == 1, "object becomes a surface (one)");
|
||
return 2;
|
||
});
|
||
gizmoDrag("surface object: move", Tool::Move, 3, ImVec2(-60, 20));
|
||
stages.push_back([this] {
|
||
const int at = a.surfaceIndex(a.objects[obj]);
|
||
float w[16];
|
||
mul(a.objects[obj].pivot, a.objects[obj].offset, w);
|
||
float d = 0;
|
||
if (at >= 0) for (int k = 0; k < 16; ++k) d += std::fabs(a.scene->world.colliders[at].world[k] - w[k]);
|
||
check(at >= 0 && d < 1e-4f, "its surface follows the object when moved");
|
||
WorldRuntime rt;
|
||
rt.build(walkWorld(*a.scene));
|
||
check(rt.hasFloor(), "Frame Home's walker finds floor");
|
||
return 2;
|
||
});
|
||
stages.push_back([this] {
|
||
a.deleteSelected();
|
||
check(a.surfaceIndex(a.objects[obj]) < 0 && a.objects[obj].deleted, "deleting the object removes its surface");
|
||
key(ImGuiKey_Z, true); queue.push_back(In{});
|
||
return 3;
|
||
});
|
||
stages.push_back([this] {
|
||
check(!a.objects[obj].deleted && a.surfaceIndex(a.objects[obj]) >= 0, "one Ctrl+Z brings back both");
|
||
return 1;
|
||
});
|
||
stages.push_back([this] {
|
||
const std::vector<size_t> all = a.surfaceCandidates();
|
||
const size_t n0 = a.scene->world.colliders.size();
|
||
linkedBefore = 0;
|
||
for (const WorldCollider& c : a.scene->world.colliders) linkedBefore += !c.mesh.empty();
|
||
a.setSurfaces(all, kSurfSolid);
|
||
size_t linked = 0;
|
||
for (const WorldCollider& c : a.scene->world.colliders) linked += !c.mesh.empty() && c.role == WorldCollider::Solid;
|
||
check(linked == all.size() && !all.empty(), "All Visible Meshes > Make Surfaces", std::to_string(all.size()) + " meshes, " + std::to_string(n0) + " -> " + std::to_string(a.scene->world.colliders.size()) + " surfaces");
|
||
a.selected = -1; a.wKind = a.wIndex = -1;
|
||
a.frameAll();
|
||
return 15;
|
||
});
|
||
stages.push_back([this] { screenshot("03_all_walkable"); return 1; });
|
||
stages.push_back([this] {
|
||
key(ImGuiKey_Z, true); queue.push_back(In{});
|
||
return 3;
|
||
});
|
||
stages.push_back([this] {
|
||
size_t linked = 0;
|
||
std::string which;
|
||
for (const WorldCollider& c : a.scene->world.colliders)
|
||
if (!c.mesh.empty()) { ++linked; which += (which.empty() ? "" : ", ") + c.name + " [" + c.mesh + "]"; }
|
||
check(linked == linkedBefore, "Ctrl+Z undoes making them all surfaces",
|
||
std::to_string(linked) + " mesh surfaces, " + std::to_string(linkedBefore) + " before: " + which);
|
||
return 1;
|
||
});
|
||
|
||
// ── Surface from the object panel: walk on its flat parts, its steep parts stop you; the right-click menu; a turn
|
||
stages.push_back([this] {
|
||
a.selected = obj; a.wKind = a.wIndex = -1;
|
||
a.frameSelected();
|
||
a.cam.dist *= 1.6f;
|
||
a.cam.pitch = -0.6f; // from above: the floor part shows
|
||
for (ImVec2& p : a.surfButtonAt) p = ImVec2(-1, -1);
|
||
a.surfScroll = true; // in view, however small the window
|
||
return 10;
|
||
});
|
||
stages.push_back([this] {
|
||
a.setSurfaces({static_cast<size_t>(obj)}, kSurfNone);
|
||
if (a.surfButtonAt[kSurfSolid].x < 0) { check(false, "the object panel shows the Surface buttons"); return 0; }
|
||
click(a.surfButtonAt[kSurfSolid]);
|
||
return 4;
|
||
});
|
||
stages.push_back([this] {
|
||
const std::vector<int> parts = a.surfaceParts(a.objects[obj]);
|
||
const size_t total = a.scene->meshes[a.objects[obj].scene].indices.size() / 3;
|
||
const size_t got = parts.size() == 1 ? a.scene->world.colliders[parts[0]].indices.size() / 3 : 0;
|
||
check(parts.size() == 1 && a.scene->world.colliders[parts[0]].role == WorldCollider::Solid && a.surfaceMode(a.objects[obj]) == kSurfSolid && got > 0 && got <= total,
|
||
"Surface makes it one surface, simplified for walking", std::to_string(got) + " of " + std::to_string(total) + " triangles");
|
||
// Frame Home's walker: stands on its flat top, is stopped by its steep side.
|
||
HomeWorld hw;
|
||
for (int p : parts) hw.colliders.push_back(a.scene->world.colliders[p]);
|
||
WorldRuntime rt;
|
||
rt.build(hw);
|
||
float bestF = 0, bestW = 0, fc[3] = {}, wc[3] = {}, wn[3] = {};
|
||
for (const WorldCollider& c : hw.colliders) {
|
||
const std::vector<float> wp = worldMeshPositions(c);
|
||
for (size_t t = 0; t + 2 < c.indices.size(); t += 3) {
|
||
const float* p0 = &wp[c.indices[t] * 3]; const float* p1 = &wp[c.indices[t + 1] * 3]; const float* p2 = &wp[c.indices[t + 2] * 3];
|
||
const float e1[3] = {p1[0] - p0[0], p1[1] - p0[1], p1[2] - p0[2]}, e2[3] = {p2[0] - p0[0], p2[1] - p0[1], p2[2] - p0[2]};
|
||
float n[3] = {e1[1] * e2[2] - e1[2] * e2[1], e1[2] * e2[0] - e1[0] * e2[2], e1[0] * e2[1] - e1[1] * e2[0]};
|
||
const float L = std::sqrt(n[0] * n[0] + n[1] * n[1] + n[2] * n[2]);
|
||
if (L < 1e-9f) continue;
|
||
const bool fl = n[1] / L > 0.9f; // flat enough to stand on
|
||
if (!fl && std::fabs(n[1]) / L > 0.2f) continue; // else a near-vertical one to walk into
|
||
float& best = fl ? bestF : bestW;
|
||
if (L * 0.5f <= best) continue;
|
||
best = L * 0.5f;
|
||
float* cc = fl ? fc : wc;
|
||
for (int k = 0; k < 3; ++k) cc[k] = (p0[k] + p1[k] + p2[k]) / 3;
|
||
if (!fl) for (int k = 0; k < 3; ++k) wn[k] = n[k] / L;
|
||
}
|
||
}
|
||
float h = 0;
|
||
check(bestF > 0 && rt.floorAt(fc[0], fc[2], fc[1] + 0.05f, 0.1f, 0.2f, h) && std::fabs(h - fc[1]) < 0.02f, "Frame Home stands on its flat part", f3(fc));
|
||
if (bestW > 0) {
|
||
const float hl = std::sqrt(wn[0] * wn[0] + wn[2] * wn[2]);
|
||
const float nx = wn[0] / hl, nz = wn[2] / hl;
|
||
float x = wc[0] + nx * 0.5f, z = wc[2] + nz * 0.5f;
|
||
const float x0 = x, z0 = z;
|
||
for (int k = 0; k < 20; ++k) rt.move(x, z, wc[1] - 0.935f, -nx * 0.05f, -nz * 0.05f); // frame-sized steps, as walking does
|
||
const float went = (x0 - x) * nx + (z0 - z) * nz;
|
||
check(went < 0.4f, "...and its too-steep part stops you", "moved " + std::to_string(went) + " m of 1 m towards it");
|
||
} else check(false, "...and its too-steep part stops you", "no upright face");
|
||
screenshot("09_wall_floor");
|
||
return 1;
|
||
});
|
||
// A right click on the object (no drag) opens its menu.
|
||
stages.push_back([this] {
|
||
float mn[3], mx[3];
|
||
a.worldBounds(a.objects[obj], mn, mx);
|
||
const float c[3] = {(mn[0] + mx[0]) / 2, (mn[1] + mx[1]) / 2, (mn[2] + mx[2]) / 2};
|
||
ImVec2 sp;
|
||
if (!a.screenOf(c, sp)) { check(false, "right-click opens the object menu", "object off screen"); return 0; }
|
||
moveTo(sp); moveTo(sp); pressB(1, true); queue.push_back(In{}); pressB(1, false); queue.push_back(In{});
|
||
return 4;
|
||
});
|
||
stages.push_back([this] {
|
||
check(a.objMenuOpen && a.menuObject >= 0 && a.selected == a.menuObject, "right-clicking an object opens its menu (Auto / Surface / Walk through)",
|
||
a.menuObject >= 0 ? a.objects[a.menuObject].label : "");
|
||
screenshot("10_object_menu");
|
||
key(ImGuiKey_Escape); queue.push_back(In{});
|
||
return 3;
|
||
});
|
||
// Turning it 90 deg: its surface turns with it (still one, every triangle), and one Ctrl+Z undoes the turn.
|
||
stages.push_back([this] {
|
||
a.selected = obj;
|
||
Object& o = a.objects[obj];
|
||
std::memcpy(before, o.pivot, 64);
|
||
a.pushUndo(obj);
|
||
float rx[16] = {1, 0, 0, 0, 0, 0, 1, 0, 0, -1, 0, 0, 0, 0, 0, 1}, t[16], ti[16], m[16];
|
||
translation(t, o.pivot[12], o.pivot[13], o.pivot[14]);
|
||
translation(ti, -o.pivot[12], -o.pivot[13], -o.pivot[14]);
|
||
mul(rx, ti, m); mul(t, m, m); mul(m, o.pivot, o.pivot);
|
||
a.apply(o);
|
||
a.transformDone(obj);
|
||
const std::vector<int> parts = a.surfaceParts(a.objects[obj]);
|
||
float w[16];
|
||
mul(o.pivot, o.offset, w);
|
||
float d = 0;
|
||
if (parts.size() == 1) for (int k = 0; k < 16; ++k) d += std::fabs(a.scene->world.colliders[parts[0]].world[k] - w[k]);
|
||
check(parts.size() == 1 && d < 1e-4f, "turning it on its side turns its surface with it (still one)");
|
||
key(ImGuiKey_Z, true); queue.push_back(In{});
|
||
return 3;
|
||
});
|
||
stages.push_back([this] {
|
||
check(std::memcmp(a.objects[obj].pivot, before, 64) == 0 && a.surfaceParts(a.objects[obj]).size() == 1, "one Ctrl+Z undoes the turn");
|
||
a.deleteSelected();
|
||
check(a.surfaceParts(a.objects[obj]).empty(), "deleting a Surface object removes its surface");
|
||
key(ImGuiKey_Z, true); queue.push_back(In{});
|
||
return 3;
|
||
});
|
||
stages.push_back([this] {
|
||
check(!a.objects[obj].deleted && a.surfaceParts(a.objects[obj]).size() == 1, "Ctrl+Z brings the object and its surface back");
|
||
return 1;
|
||
});
|
||
|
||
// ── 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,
|
||
"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();
|
||
int faces = 0, walk = 0;
|
||
float at[3] = {};
|
||
if (f >= 0 && a.walkShown) {
|
||
const WorldCollider& c = a.scene->world.colliders[f];
|
||
const std::vector<float> wp = worldMeshPositions(c);
|
||
for (size_t t = 0; t + 2 < c.indices.size(); t += 3) {
|
||
const float* p0 = &wp[c.indices[t] * 3]; const float* p1 = &wp[c.indices[t + 1] * 3]; const float* p2 = &wp[c.indices[t + 2] * 3];
|
||
const float m[3] = {(p0[0] + p1[0] + p2[0]) / 3, (p0[1] + p1[1] + p2[1]) / 3, (p0[2] + p1[2] + p2[2]) / 3};
|
||
float h = 0;
|
||
++faces;
|
||
if (a.walkShown->floorAt(m[0], m[2], m[1] + 0.05f, 0.1f, 0.1f, h)) { if (!walk++) std::copy_n(m, 3, at); }
|
||
else if (!faces || at[1] == 0) std::copy_n(m, 3, at);
|
||
}
|
||
}
|
||
check(walk > 0, "...and you can stand on it", std::to_string(walk) + " of " + std::to_string(faces) + " faces, e.g. " + f3(at));
|
||
std::copy_n(at, 3, wallAt);
|
||
return 2;
|
||
});
|
||
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");
|
||
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");
|
||
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.rebakeWalk();
|
||
return 2;
|
||
});
|
||
|
||
// ── World > Auto-generate Walkable Area: one step, a summary, saved for Frame Home, a re-run replaces it
|
||
stages.push_back([this] {
|
||
collidersBefore = a.scene->world.colliders.size();
|
||
spawnsBefore = a.scene->world.spawns.size();
|
||
check(!a.walkSaved() && a.walkShown && a.walkShown->any(), "before any edit, the viewport previews the area Frame Home would bake",
|
||
a.walkShown ? std::to_string(static_cast<int>(a.walkReport.walkArea)) + " m2" : "none");
|
||
a.selected = -1; a.wKind = a.wIndex = -1;
|
||
a.frameAll();
|
||
a.autoGenerate();
|
||
return 6;
|
||
});
|
||
stages.push_back([this] {
|
||
const auto& r = a.autoReport;
|
||
int autos = 0;
|
||
for (const WorldCollider& c : a.scene->world.colliders) // the meshes' surfaces it made: Solid, one per mesh
|
||
autos += Studio::isAuto(c.name) && !c.mesh.empty() && (c.role == WorldCollider::Solid || c.role == WorldCollider::Object) && c.kind == WorldCollider::Mesh;
|
||
char d[240];
|
||
std::snprintf(d, sizeof d, "%.1f m2 in %d areas, %d out of reach (%.0f m2), %.1f m2 bridged, %zu blockers, %d ledge cells, %.0f ms, spawn %s",
|
||
r.walk.walkArea, r.walk.areas, r.walk.unreached, r.walk.unreachedArea, r.walk.bridgedArea, r.walk.blockers.size(), r.walk.ledgeCells, r.ms,
|
||
r.spawnAdded ? "added" : "kept");
|
||
check(r.ok && r.walk.walkArea > 1 && a.walkSaved() && autos == r.objects + r.colliders && r.objects > 0 && r.colliders > 0, "Auto-generate bakes the walkable area and saves it with the home", d);
|
||
check(a.surfaceMode(a.objects[obj]) == kSurfSolid && !a.autoMade(a.objects[obj]), "...and keeps the surface set by hand");
|
||
{ // the world items: walls (Wall-role boxes), objects' collision (meshes) and wall spots; no seats or hotspots
|
||
int walls = 0, objs = 0, seats = 0, spots = 0, hots = 0;
|
||
for (const WorldCollider& c : a.scene->world.colliders) {
|
||
walls += c.name == "Auto wall" && c.role == WorldCollider::Wall && c.kind == WorldCollider::Box;
|
||
objs += (c.name.rfind("Auto surface", 0) == 0 && c.role == WorldCollider::Solid) || (c.name.rfind("Auto collider", 0) == 0 && c.role == WorldCollider::Object);
|
||
}
|
||
for (const WorldPoint& p : a.scene->world.seats) seats += p.name == "Auto seat";
|
||
for (const WorldWallSpot& p : a.scene->world.wallSpots) spots += p.name == "Auto wall spot";
|
||
for (const WorldPoint& p : a.scene->world.hotspots) hots += p.name == "Auto hotspot";
|
||
check(walls == 0 && r.walls == 0 && objs == r.objects + r.colliders && r.objects > 0 && spots == 0 && seats == 0 && hots == 0,
|
||
"...and makes floor meshes surfaces and the other objects colliders (no walls, wall spots, seats or hotspots)",
|
||
std::to_string(r.objects) + " surfaces, " + std::to_string(r.colliders) + " colliders, " + std::to_string(spots) + " wall spots, " + std::to_string(seats) +
|
||
" seats, " + std::to_string(hots) + " hotspots");
|
||
genCounts = {walls, objs, spots, seats + hots}; // (objs: surfaces + colliders)
|
||
collidersAfter = a.scene->world.colliders.size();
|
||
}
|
||
firstGrid = a.scene->world.walkGrid;
|
||
// Frame Home's side: world.json as written, read back, walked on
|
||
HomeWorld back;
|
||
const bool read = worldFromJson(worldToJson(a.scene->world), back);
|
||
WorldRuntime rt;
|
||
rt.build(back);
|
||
const WorldPoint sp = back.spawns.empty() ? WorldPoint{} : back.spawns[0];
|
||
float h = 0;
|
||
check(read && rt.grid() && !back.spawns.empty() && rt.floorAt(sp.pos[0], sp.pos[2], sp.pos[1] + 0.3f, 0.45f, 1.2f, h),
|
||
"Frame Home reads the saved area from world.json and finds floor at the spawn", f3(sp.pos) + ", " + std::to_string(a.scene->world.walkGrid.size()) + " bytes");
|
||
check(a.autoDoneAt.x >= 0, "the summary shows");
|
||
screenshot("11_walk_summary");
|
||
if (a.autoDoneAt.x >= 0) click(a.autoDoneAt);
|
||
return 4;
|
||
});
|
||
stages.push_back([this] { check(a.autoDoneAt.x < 0, "Done closes the summary"); return 10; });
|
||
stages.push_back([this] {
|
||
check(a.walkGuideOn && a.walkGuideVerts > 0, "the viewport shows the area (walkable, edges, out of reach)", std::to_string(a.walkGuideVerts) + " vertices");
|
||
screenshot("12_walk_area");
|
||
return 1;
|
||
});
|
||
// A Wall surface always blocks: a small one dropped in the middle of the floor cuts the area there.
|
||
stages.push_back([this] {
|
||
float m[3];
|
||
if (!a.walkShown || !Studio::walkMiddle(*a.walkShown, m)) { check(false, "a surface box placed on the floor blocks there", "no middle"); return 1; }
|
||
std::copy_n(m, 3, wallAt);
|
||
const bool before = a.walkShown->walkable(m[0], m[2], m[1]);
|
||
WorldCollider c;
|
||
c.name = "selftest surface"; c.kind = WorldCollider::Box; c.role = WorldCollider::Solid;
|
||
c.half[0] = c.half[2] = 0.3f; c.half[1] = 0.6f; // 1.2 m high: its sides are too steep and too tall to step up
|
||
translation(c.world, m[0], m[1] + 0.6f, m[2]);
|
||
a.scene->world.colliders.push_back(c);
|
||
a.rebakeWalk();
|
||
const bool after = a.walkShown->walkable(m[0], m[2], m[1]), beside = a.walkShown->walkable(m[0] + 0.9f, m[2], m[1]) || a.walkShown->walkable(m[0] - 0.9f, m[2], m[1]);
|
||
check(before && !after && beside, "a 1.2 m surface box blocks where it stands (only there)", f3(m));
|
||
a.scene->world.colliders.pop_back();
|
||
a.rebakeWalk();
|
||
check(a.walkShown->walkable(m[0], m[2], m[1]) && a.scene->world.walkGrid == firstGrid, "...and taking it away gives the same area back");
|
||
return 2;
|
||
});
|
||
stages.push_back([this] {
|
||
a.autoGenerate();
|
||
return 6;
|
||
});
|
||
stages.push_back([this] {
|
||
int autoSpawns = 0;
|
||
for (const WorldPoint& p : a.scene->world.spawns) autoSpawns += p.name == "Auto spawn";
|
||
const auto& r = a.autoReport;
|
||
check(r.ok && a.scene->world.walkGrid == firstGrid && autoSpawns <= 1 && a.scene->world.colliders.size() == collidersAfter &&
|
||
std::array<int, 4>{r.walls, r.objects + r.colliders, r.spots, r.seats + r.hotspots} == genCounts,
|
||
"re-running gives the same area and items (no duplicate spawns, surfaces, walls or objects)", std::to_string(a.scene->world.spawns.size()) + " spawns");
|
||
if (a.autoUndoAt.x >= 0) click(a.autoUndoAt); // the summary's Undo: just this run
|
||
return 4;
|
||
});
|
||
stages.push_back([this] {
|
||
check(a.walkSaved() && a.scene->world.walkGrid == firstGrid && a.autoDoneAt.x < 0, "the summary's Undo takes back the re-run alone");
|
||
key(ImGuiKey_Z, true); queue.push_back(In{});
|
||
return 3;
|
||
});
|
||
stages.push_back([this] {
|
||
check(!a.walkSaved() && a.scene->world.colliders.size() == collidersBefore && a.scene->world.spawns.size() == spawnsBefore,
|
||
"one Ctrl+Z removes the saved area (and its spawn)", std::to_string(a.scene->world.colliders.size()) + " surfaces, " + std::to_string(collidersBefore) + " before");
|
||
return 15;
|
||
});
|
||
stages.push_back([this] {
|
||
check(a.walkShown && !a.walkSaved(), "...and the viewport goes back to the preview");
|
||
return 1;
|
||
});
|
||
|
||
// ── World items: surface boxes, moved / scaled / rotated with the gizmo
|
||
stages.push_back([this] {
|
||
a.selected = obj;
|
||
a.frameSelected();
|
||
a.cam.dist *= 2.0f;
|
||
a.addWorld(kCollider);
|
||
check(a.wKind == kCollider && a.scene->world.colliders[a.wIndex].role == WorldCollider::Solid, "World > Add > Surface selects it");
|
||
return 10;
|
||
});
|
||
gizmoDrag("surface: move", Tool::Move, 3, ImVec2(50, 25));
|
||
stages.push_back([this] {
|
||
float m[16]; current(m);
|
||
check(dist3(&m[12], &before[12]) > 0.02f, "surface moves with the gizmo", f3(&before[12]) + " -> " + f3(&m[12]));
|
||
return 2;
|
||
});
|
||
stages.push_back([this] {
|
||
if (a.wKind == kCollider) a.scene->world.colliders[a.wIndex].world[14] += 60; // the first one out of the way (else they'd become one)
|
||
a.addWorld(kCollider);
|
||
check(a.wKind == kCollider && a.scene->world.colliders[a.wIndex].role == WorldCollider::Solid, "World > Add > Surface makes a surface box");
|
||
std::memcpy(before, a.scene->world.colliders[a.wIndex].half, 12);
|
||
return 6;
|
||
});
|
||
gizmoDrag("wall: scale X", Tool::Scale, 0, ImVec2(70, 0));
|
||
stages.push_back([this] {
|
||
const WorldCollider& c = a.scene->world.colliders[a.wIndex];
|
||
check(std::fabs(c.half[0] - 1.0f) > 0.05f && std::fabs(colLen(c.world, 0) - 1) < 1e-3f, "wall resizes with the scale gizmo (frame stays unscaled)",
|
||
"half x " + std::to_string(c.half[0]) + ", frame scale " + std::to_string(colLen(c.world, 0)));
|
||
return 2;
|
||
});
|
||
gizmoDrag("wall: rotate", Tool::Rotate, 4, ImVec2(40, 60));
|
||
stages.push_back([this] {
|
||
float m[16]; current(m);
|
||
float rot = 0;
|
||
for (int k = 0; k < 11; ++k) if (k % 4 != 3) rot += std::fabs(m[k] - before[k]);
|
||
check(rot > 0.05f, "wall rotates with the gizmo", "change " + std::to_string(rot));
|
||
return 2;
|
||
});
|
||
gizmoDrag("wall: move", Tool::Move, 3, ImVec2(-40, -20));
|
||
stages.push_back([this] {
|
||
float m[16]; current(m);
|
||
check(dist3(&m[12], &before[12]) > 0.02f, "wall moves with the gizmo");
|
||
screenshot("04_world_items");
|
||
return 2;
|
||
});
|
||
stages.push_back([this] {
|
||
a.addWorld(kSpawn);
|
||
return 6;
|
||
});
|
||
gizmoDrag("spawn: move", Tool::Move, 3, ImVec2(40, 20));
|
||
stages.push_back([this] {
|
||
float m[16]; current(m);
|
||
check(dist3(&m[12], &before[12]) > 0.02f, "spawn point moves with the gizmo");
|
||
return 2;
|
||
});
|
||
gizmoDrag("spawn: turn", Tool::Rotate, 4, ImVec2(40, 60));
|
||
stages.push_back([this] {
|
||
const WorldPoint* p = a.worldPoint(a.wKind, a.wIndex);
|
||
float m[16]; current(m);
|
||
check(p && std::fabs(m[0] - before[0]) + std::fabs(m[8] - before[8]) > 0.02f, "spawn point turns with the gizmo");
|
||
return 2;
|
||
});
|
||
|
||
// ── Placed surfaces that overlap become one (walls crossing in a T); one Ctrl+Z splits them again
|
||
stages.push_back([this] {
|
||
auto& cs = a.scene->world.colliders;
|
||
const size_t n0 = cs.size();
|
||
float m[3] = {0, 0, 0};
|
||
if (a.walkShown) Studio::walkMiddle(*a.walkShown, m);
|
||
auto wall = [&](float x, float z, float hx, float hz) {
|
||
WorldCollider c;
|
||
c.name = "selftest wall"; c.kind = WorldCollider::Box; c.role = WorldCollider::Solid; c.placed = true;
|
||
c.half[0] = hx; c.half[1] = 1.25f; c.half[2] = hz;
|
||
translation(c.world, m[0] + x, m[1] + 1.25f, m[2] + z);
|
||
return c;
|
||
};
|
||
a.pushWorldUndo();
|
||
cs.push_back(wall(0, 0, 2, 0.1f));
|
||
cs.push_back(wall(1, 1, 0.1f, 2));
|
||
const int at = a.combineTouching(static_cast<int>(cs.size()) - 1);
|
||
const WorldCollider* c = at >= 0 && at < static_cast<int>(cs.size()) ? &cs[at] : nullptr;
|
||
const std::vector<float> tris = c ? colliderTriangles(*c) : std::vector<float>();
|
||
const float vol = surface_detail::volume(tris);
|
||
check(cs.size() == n0 + 1 && c && c->kind == WorldCollider::Mesh && c->boxes.size() == 38 && std::fabs(vol - 3.9f) < 0.01f,
|
||
"two placed walls crossing become one surface (their union)", std::to_string(vol) + " m3, " + std::to_string(tris.size() / 9) + " triangles");
|
||
cs.push_back(wall(20, 0, 0.5f, 0.5f));
|
||
const size_t n1 = cs.size();
|
||
a.combineTouching(static_cast<int>(cs.size()) - 1);
|
||
check(cs.size() == n1, "...one placed apart from them stays its own");
|
||
cs.pop_back();
|
||
key(ImGuiKey_Z, true); queue.push_back(In{});
|
||
return 3;
|
||
});
|
||
stages.push_back([this] {
|
||
int walls = 0;
|
||
for (const WorldCollider& c : a.scene->world.colliders) walls += c.name == "selftest wall";
|
||
check(walls == 0, "Ctrl+Z takes them away");
|
||
return 1;
|
||
});
|
||
|
||
// ── What is shown
|
||
stages.push_back([this] {
|
||
const int walk = static_cast<int>(a.scene->world.colliders.size()) - 2; // the walkable area added above
|
||
a.showCat[kShowWalls] = false;
|
||
const bool hid = !a.worldShown(kCollider, walk);
|
||
a.showCat[kShowWalls] = true;
|
||
const bool back = a.worldShown(kCollider, walk);
|
||
check(hid && back, "hiding Surface shapes hides surface boxes, showing brings them back");
|
||
a.scene->world.colliders[walk].hidden = true;
|
||
const bool itemHid = !a.worldShown(kCollider, walk);
|
||
a.scene->world.colliders[walk].hidden = false;
|
||
check(itemHid, "an item's eye hides just that item");
|
||
a.wKind = a.wIndex = -1;
|
||
a.selected = obj;
|
||
key(ImGuiKey_I); queue.push_back(In{});
|
||
return 3;
|
||
});
|
||
stages.push_back([this] {
|
||
int shownOthers = 0;
|
||
for (size_t i = 0; i < a.objects.size(); ++i) if (static_cast<int>(i) != obj && a.objectShown(a.objects[i])) ++shownOthers;
|
||
check(a.isolating && a.objectShown(a.objects[obj]) && shownOthers == 0, "I shows only the selected object", std::to_string(shownOthers) + " others still shown");
|
||
screenshot("05_only_selected");
|
||
key(ImGuiKey_Escape); queue.push_back(In{});
|
||
return 3;
|
||
});
|
||
stages.push_back([this] {
|
||
int shown = 0;
|
||
for (const Object& o : a.objects) shown += a.objectShown(o);
|
||
check(!a.isolating && shown > 1, "Esc shows everything again", std::to_string(shown) + " objects shown");
|
||
a.showMeshes = false; a.refreshVisibility();
|
||
int s2 = 0;
|
||
for (const Object& o : a.objects) s2 += a.objectShown(o);
|
||
a.showMeshes = true; a.refreshVisibility();
|
||
check(s2 == 0, "View > The Home's Meshes hides them all (world layer alone)");
|
||
return 2;
|
||
});
|
||
// ── The world layer survives a save / load round trip (JSON and the baked .fenv form)
|
||
stages.push_back([this] {
|
||
const std::string j = worldToJson(a.scene->world);
|
||
HomeWorld w2;
|
||
const bool ok = worldFromJson(j, w2);
|
||
size_t same = 0, surfaces = 0, linked = 0, linkedNow = 0;
|
||
for (size_t i = 0; i < w2.colliders.size() && i < a.scene->world.colliders.size(); ++i) same += w2.colliders[i].role == a.scene->world.colliders[i].role;
|
||
for (const WorldCollider& c : w2.colliders) { surfaces += c.role == WorldCollider::Solid; linked += !c.mesh.empty(); }
|
||
for (const WorldCollider& c : a.scene->world.colliders) linkedNow += !c.mesh.empty();
|
||
check(ok && w2.colliders.size() == a.scene->world.colliders.size() && same == w2.colliders.size() && surfaces >= 2 && linked == linkedNow && linked > 0,
|
||
"world.json keeps roles and links", std::to_string(surfaces) + " surfaces, " + std::to_string(linked) + " linked");
|
||
return 1;
|
||
});
|
||
// ── Gravity zones
|
||
stages.push_back([this] {
|
||
a.selected = obj;
|
||
a.frameSelected();
|
||
a.cam.dist *= 2.0f;
|
||
a.addGravity(WorldGravity::Direction);
|
||
check(a.wKind == kGravity && a.scene->world.gravity.size() == 1, "World > Add > Gravity Zone adds and selects it");
|
||
return 8;
|
||
});
|
||
gizmoDrag("gravity zone: move", Tool::Move, 3, ImVec2(50, 25));
|
||
stages.push_back([this] {
|
||
float m[16]; current(m);
|
||
check(dist3(&m[12], &before[12]) > 0.02f, "gravity zone moves with the gizmo");
|
||
return 2;
|
||
});
|
||
gizmoDrag("gravity zone: scale X", Tool::Scale, 0, ImVec2(60, 0));
|
||
stages.push_back([this] {
|
||
if (a.scene->world.gravity.empty()) { check(false, "gravity zone resizes with the scale gizmo", "the zone is gone"); return 1; }
|
||
const WorldGravity& g = a.scene->world.gravity[0];
|
||
check(std::fabs(g.half[0] - 5) > 0.1f && std::fabs(colLen(g.world, 0) - 1) < 1e-3f, "gravity zone resizes with the scale gizmo", "half x " + std::to_string(g.half[0]));
|
||
return 2;
|
||
});
|
||
gizmoDrag("gravity zone: rotate (which way is down)", Tool::Rotate, 4, ImVec2(40, 60));
|
||
stages.push_back([this] {
|
||
if (a.scene->world.gravity.empty()) { check(false, "rotating the zone turns its down direction", "the zone is gone"); return 1; }
|
||
const WorldGravity& g = a.scene->world.gravity[0];
|
||
check(std::fabs(g.world[5] - 1) > 0.01f, "rotating the zone turns its down direction", "up y " + std::to_string(g.world[5]));
|
||
a.scene->world.fallLimit = 12;
|
||
const std::string j = worldToJson(a.scene->world);
|
||
HomeWorld w2;
|
||
check(worldFromJson(j, w2) && w2.gravity.size() == 1 && std::fabs(w2.fallLimit - 12) < 1e-3f && w2.gravity[0].kind == WorldGravity::Direction,
|
||
"world.json keeps gravity zones and the respawn distance");
|
||
screenshot("06_gravity_zone");
|
||
return 1;
|
||
});
|
||
// ── Planet: a sphere you can see and walk round (reloads the home with the generated model)
|
||
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;
|
||
});
|
||
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;
|
||
const fs::path dir = dataDir() / L"selftest";
|
||
fs::create_directories(dir, ec);
|
||
soundWav = dir / L"selftest tone.wav";
|
||
{ // 2 s of a stereo chord, 16-bit 44.1 kHz
|
||
const int sr = 44100, n = sr * 2;
|
||
std::vector<int16_t> pcm(static_cast<size_t>(n) * 2);
|
||
for (int i = 0; i < n; ++i) {
|
||
pcm[i * 2] = static_cast<int16_t>(9000 * std::sin(2 * kPi * 220 * i / sr));
|
||
pcm[i * 2 + 1] = static_cast<int16_t>(9000 * std::sin(2 * kPi * 330 * i / sr));
|
||
}
|
||
auto u32 = [](std::ofstream& f, uint32_t v) { f.write(reinterpret_cast<const char*>(&v), 4); };
|
||
auto u16 = [](std::ofstream& f, uint16_t v) { f.write(reinterpret_cast<const char*>(&v), 2); };
|
||
std::ofstream f(soundWav, std::ios::binary);
|
||
const uint32_t data = static_cast<uint32_t>(pcm.size() * 2);
|
||
f.write("RIFF", 4); u32(f, 36 + data); f.write("WAVEfmt ", 8); u32(f, 16); u16(f, 1); u16(f, 2); u32(f, sr); u32(f, sr * 4); u16(f, 4); u16(f, 16);
|
||
f.write("data", 4); u32(f, data); f.write(reinterpret_cast<const char*>(pcm.data()), data);
|
||
}
|
||
// the user's own sound for this home (if any) is put back as found
|
||
const std::wstring prefix = widen(frameName(a.homePaths.front()) + ".sound-");
|
||
for (const auto& e : fs::directory_iterator(a.editsFile().parent_path(), ec))
|
||
if (e.path().filename().wstring().rfind(prefix, 0) == 0) {
|
||
std::ifstream in(e.path(), std::ios::binary);
|
||
saved.push_back({e.path(), true, std::string((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>())});
|
||
}
|
||
a.addSoundFile(narrow(soundWav.wstring()));
|
||
check(a.sound.bytes && a.sound.name == "selftest tone.wav" && a.sound.s.file.find(".sound-") != std::string::npos && a.sound.s.file.size() > 4 &&
|
||
a.sound.s.file.compare(a.sound.s.file.size() - 4, 4, ".wav") == 0 && a.dirty, "a background sound can be added", a.sound.s.file);
|
||
if (a.addedSound.ok) check(true, "...and plays in the preview");
|
||
else log(" (the background sound isn't playing here: no audio device)");
|
||
return 3;
|
||
});
|
||
stages.push_back([this] {
|
||
const SoundState before = a.sound;
|
||
a.pushSoundUndo(before);
|
||
a.sound.s.volume = 0.3f;
|
||
a.sound.s.homeVolume = 0;
|
||
a.step(a.undo, a.redo);
|
||
check(std::fabs(a.sound.s.volume - 0.8f) < 1e-4f && a.sound.s.homeVolume == 1 && a.sound.bytes == before.bytes, "Ctrl+Z puts the volumes back");
|
||
a.pushSoundUndo(a.sound);
|
||
a.sound = SoundState{HomeSound{}, nullptr, ""};
|
||
a.addedSound.stop();
|
||
a.step(a.undo, a.redo);
|
||
check(a.sound.bytes == before.bytes && a.sound.name == before.name, "Ctrl+Z brings a removed sound back");
|
||
a.sound.s.volume = 0.6f;
|
||
a.sound.s.homeVolume = 0.25f;
|
||
check(a.saveEdits(), "Save Edits saves it"); // (Ctrl+S: into the library's .fenv, in the background)
|
||
return 2;
|
||
});
|
||
stages.push_back([this] {
|
||
if (a.saving) return -1;
|
||
// The saved environment's edits, as Frame Home and a reopen read them.
|
||
FenvManifest m;
|
||
std::vector<std::string> sources, models;
|
||
std::string edits, err;
|
||
const fs::path cache = dataDir() / L"selftest" / L"fenv-cache-save";
|
||
const bool ok = !a.envFile.empty() && fenvExtract(a.envFile, narrow(cache.wstring()), m, sources, models, edits, err);
|
||
HomeEdits e;
|
||
const bool read = ok && loadHomeEdits(edits, e);
|
||
std::error_code ec;
|
||
const fs::path file = fs::path(widen(read ? soundPathFor(edits, e.sound) : std::string()));
|
||
check(read && e.sound.name == "selftest tone.wav" && std::fabs(e.sound.volume - 0.6f) < 1e-4f &&
|
||
std::fabs(e.sound.homeVolume - 0.25f) < 1e-4f && a.sound.bytes && fs::file_size(file, ec) == a.sound.bytes->size(),
|
||
"...in the saved environment, with the file beside its edits", ok ? narrow(file.filename().wstring()) : err);
|
||
a.sound = SoundState();
|
||
a.loadSound(e.sound, edits);
|
||
check(a.sound.bytes && a.sound.bytes->size() == fs::file_size(soundWav, ec) && a.sound.name == "selftest tone.wav" && std::fabs(a.sound.s.volume - 0.6f) < 1e-4f,
|
||
"reopening the home brings it back");
|
||
soundFenv = dataDir() / L"selftest" / L"selftest-sound.fenv";
|
||
a.saveEnvironment(narrow(soundFenv.wstring()), false);
|
||
return 2;
|
||
});
|
||
stages.push_back([this] {
|
||
if (a.saving) return -1;
|
||
FenvManifest m;
|
||
std::vector<std::string> sources, models;
|
||
std::string edits, err;
|
||
const fs::path cache = dataDir() / L"selftest" / L"fenv-cache";
|
||
const bool ok = fenvExtract(narrow(soundFenv.wstring()), narrow(cache.wstring()), m, sources, models, edits, err);
|
||
HomeEdits e;
|
||
const bool read = ok && loadHomeEdits(edits, e);
|
||
const std::string snd = read ? soundPathFor(edits, e.sound) : std::string();
|
||
std::vector<uint8_t> bytes;
|
||
{ // (closed before the folder is removed: Windows keeps an open file)
|
||
std::ifstream in(fs::path(widen(snd)), std::ios::binary);
|
||
bytes.assign(std::istreambuf_iterator<char>(in), std::istreambuf_iterator<char>());
|
||
}
|
||
check(read && !snd.empty() && a.sound.bytes && bytes == *a.sound.bytes && std::fabs(e.sound.homeVolume - 0.25f) < 1e-4f,
|
||
"a saved environment (.fenv) carries it as Frame Home reads it", ok ? e.sound.file : err);
|
||
std::error_code ec;
|
||
fs::remove(soundFenv, ec);
|
||
fs::remove_all(cache, ec);
|
||
fs::remove_all(dataDir() / L"selftest" / L"fenv-cache-save", ec);
|
||
fs::remove(soundWav, ec);
|
||
if (!soundSaved.empty()) { bool kept = false; for (const SavedFile& f : saved) kept |= f.path == soundSaved; if (!kept) fs::remove(soundSaved, ec); }
|
||
a.addedSound.stop();
|
||
a.sound = SoundState();
|
||
return 1;
|
||
});
|
||
// ── Edit > Settings: this PC's options, and the headset's read over ssh (nothing is written there)
|
||
stages.push_back([this] { a.showSettings = true; a.headsetSettings(false); return 5; });
|
||
stages.push_back([this] {
|
||
if (a.headsetBusy) return -1;
|
||
std::string status;
|
||
bool loaded;
|
||
{ std::lock_guard<std::mutex> l(a.statusMutex); status = a.headsetStatus; loaded = a.headsetLoaded; }
|
||
check(loaded, "Settings reads the headset's settings", status);
|
||
return 10;
|
||
});
|
||
stages.push_back([this] { screenshot("08_settings"); a.showSettings = false; return 2; });
|
||
stages.push_back([this] {
|
||
check(idConflictFrames == 0, "no ImGui ID conflicts anywhere during the test", idConflictWhere);
|
||
log("done: %d passed, %d failed", passes, fails);
|
||
finish();
|
||
return 0;
|
||
});
|
||
}
|
||
|
||
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;
|
||
std::string titleWas, imageWas;
|
||
std::array<int, 4> genCounts{}; // Auto-generate's walls, objects, wall spots, seats + hotspots (first run)
|
||
size_t collidersAfter = 0;
|
||
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;
|
||
else fs::remove(f.path, ec);
|
||
}
|
||
if (report) { std::fclose(report); report = nullptr; }
|
||
a.dirty = false; // nothing of the test is kept
|
||
if (a.window.requestClose) a.window.requestClose();
|
||
}
|
||
};
|
||
SelfTest* g_test = nullptr;
|
||
|
||
// ── The window: Vulkan swapchain (ImGui's helper), the frame loop, and the system's window ──────────────────
|
||
// runStudio() is the same everywhere; a StudioWindow is the system's window behind it: Win32Window on Windows,
|
||
// GlfwWindow on Linux and macOS.
|
||
|
||
Studio* g_app = nullptr;
|
||
FramePacer* g_pacer = nullptr; // the frame loop's, while it runs (the window reports input and changes to it)
|
||
ImGui_ImplVulkanH_Window g_wd;
|
||
bool g_rebuild = false;
|
||
uint32_t g_resizeW = 0, g_resizeH = 0; // a new size for the swapchain, in pixels (0: none pending)
|
||
|
||
void frameRender(Studio& app, ImDrawData* draw) {
|
||
VkDevice dev = app.renderer.device();
|
||
VkSemaphore acquired = g_wd.FrameSemaphores[g_wd.SemaphoreIndex].ImageAcquiredSemaphore;
|
||
VkSemaphore done = g_wd.FrameSemaphores[g_wd.SemaphoreIndex].RenderCompleteSemaphore;
|
||
const auto a0 = std::chrono::steady_clock::now();
|
||
VkResult err = vkAcquireNextImageKHR(dev, g_wd.Swapchain, UINT64_MAX, acquired, VK_NULL_HANDLE, &g_wd.FrameIndex);
|
||
if (err == VK_ERROR_OUT_OF_DATE_KHR || err == VK_SUBOPTIMAL_KHR) g_rebuild = true;
|
||
if (err == VK_ERROR_OUT_OF_DATE_KHR) return;
|
||
ImGui_ImplVulkanH_Frame* fd = &g_wd.Frames[g_wd.FrameIndex];
|
||
const auto a1 = std::chrono::steady_clock::now();
|
||
vkWaitForFences(dev, 1, &fd->Fence, VK_TRUE, UINT64_MAX);
|
||
const auto a2 = std::chrono::steady_clock::now();
|
||
const double acqMs = std::chrono::duration<double, std::milli>(a1 - a0).count(), fenMs = std::chrono::duration<double, std::milli>(a2 - a1).count();
|
||
if (acqMs + fenMs > 30.0) std::fprintf(stderr, "[studio] slow UI frame: acquire %.1f ms, UI fence %.1f ms\n", acqMs, fenMs);
|
||
vkResetFences(dev, 1, &fd->Fence);
|
||
vkResetCommandPool(dev, fd->CommandPool, 0);
|
||
VkCommandBufferBeginInfo bi = {VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO};
|
||
bi.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT;
|
||
vkBeginCommandBuffer(fd->CommandBuffer, &bi);
|
||
|
||
// The 3D frame (left the renderer in TRANSFER_SRC) is sampled by the UI, then handed back.
|
||
VkImageMemoryBarrier b = {VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER};
|
||
b.srcQueueFamilyIndex = b.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
|
||
b.image = app.renderer.colorImage();
|
||
b.subresourceRange = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, app.renderer.viewCount};
|
||
if (app.rendered) {
|
||
b.oldLayout = VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL;
|
||
b.newLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL;
|
||
b.srcAccessMask = VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT;
|
||
b.dstAccessMask = VK_ACCESS_SHADER_READ_BIT;
|
||
vkCmdPipelineBarrier(fd->CommandBuffer, VK_PIPELINE_STAGE_ALL_COMMANDS_BIT, VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT,
|
||
0, 0, nullptr, 0, nullptr, 1, &b);
|
||
}
|
||
VkRenderPassBeginInfo rp = {VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO};
|
||
rp.renderPass = g_wd.RenderPass;
|
||
rp.framebuffer = fd->Framebuffer;
|
||
rp.renderArea.extent.width = g_wd.Width;
|
||
rp.renderArea.extent.height = g_wd.Height;
|
||
rp.clearValueCount = 1;
|
||
rp.pClearValues = &g_wd.ClearValue;
|
||
vkCmdBeginRenderPass(fd->CommandBuffer, &rp, VK_SUBPASS_CONTENTS_INLINE);
|
||
ImGui_ImplVulkan_RenderDrawData(draw, fd->CommandBuffer);
|
||
vkCmdEndRenderPass(fd->CommandBuffer);
|
||
if (app.rendered) {
|
||
b.oldLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL;
|
||
b.newLayout = VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL;
|
||
b.srcAccessMask = 0;
|
||
b.dstAccessMask = VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT;
|
||
vkCmdPipelineBarrier(fd->CommandBuffer, VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT, VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT,
|
||
0, 0, nullptr, 0, nullptr, 1, &b);
|
||
}
|
||
vkEndCommandBuffer(fd->CommandBuffer);
|
||
|
||
VkPipelineStageFlags wait = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT;
|
||
VkSubmitInfo si = {VK_STRUCTURE_TYPE_SUBMIT_INFO};
|
||
si.waitSemaphoreCount = 1;
|
||
si.pWaitSemaphores = &acquired;
|
||
si.pWaitDstStageMask = &wait;
|
||
si.commandBufferCount = 1;
|
||
si.pCommandBuffers = &fd->CommandBuffer;
|
||
si.signalSemaphoreCount = 1;
|
||
si.pSignalSemaphores = &done;
|
||
vkQueueSubmit(app.renderer.queue(), 1, &si, fd->Fence);
|
||
}
|
||
|
||
void framePresent(Studio& app) {
|
||
if (g_rebuild) return;
|
||
VkSemaphore done = g_wd.FrameSemaphores[g_wd.SemaphoreIndex].RenderCompleteSemaphore;
|
||
VkPresentInfoKHR pi = {VK_STRUCTURE_TYPE_PRESENT_INFO_KHR};
|
||
pi.waitSemaphoreCount = 1;
|
||
pi.pWaitSemaphores = &done;
|
||
pi.swapchainCount = 1;
|
||
pi.pSwapchains = &g_wd.Swapchain;
|
||
pi.pImageIndices = &g_wd.FrameIndex;
|
||
VkResult err = vkQueuePresentKHR(app.renderer.queue(), &pi);
|
||
if (err == VK_ERROR_OUT_OF_DATE_KHR || err == VK_SUBOPTIMAL_KHR) g_rebuild = true;
|
||
if (err == VK_ERROR_OUT_OF_DATE_KHR) return;
|
||
g_wd.SemaphoreIndex = (g_wd.SemaphoreIndex + 1) % g_wd.SemaphoreCount;
|
||
}
|
||
|
||
// The window's close box (or File > Quit): false when the user chose to stay (unsaved changes, Cancel).
|
||
bool allowClose() {
|
||
if (!g_app) return true;
|
||
if (!g_app->confirmDiscard()) return false;
|
||
if (g_app->installing)
|
||
platform::message("Frame Studio", "Frame Home is still being installed on the headset. Frame Studio will close when the copy finishes.");
|
||
if (g_app->saving) // closing waits for it (an unfinished .fenv would be lost)
|
||
platform::message("Frame Studio", "An environment is still being saved. Frame Studio will close as soon as it's written.");
|
||
return true;
|
||
}
|
||
|
||
// Files dropped on the window: a sound file becomes the background sound, anything else opens.
|
||
void filesDropped(const std::vector<std::string>& files) {
|
||
if (!g_app || files.empty()) return;
|
||
if (files.size() == 1 && isSoundFile(files[0]) && g_app->scene) g_app->addSoundFile(files[0]);
|
||
else g_app->open(files);
|
||
}
|
||
|
||
// The system's window, as runStudio() uses it.
|
||
class StudioWindow {
|
||
public:
|
||
virtual ~StudioWindow() = default;
|
||
virtual void* nativeHandle() = 0; // dialogs' owner (HWND on Windows)
|
||
virtual float uiScale() = 0; // the display's scale for the UI (1 = 96 dpi)
|
||
virtual float pixelDensity() { return 1.0f; } // pixels per UI unit (2 on a Mac's Retina display)
|
||
virtual std::vector<std::string> vulkanInstanceExtensions() = 0;
|
||
virtual VkSurfaceKHR createSurface(VkInstance instance) = 0;
|
||
virtual void pixelSize(int& width, int& height) = 0; // the swapchain's size
|
||
virtual void show() = 0;
|
||
virtual void initImGui() = 0;
|
||
virtual void newImGuiFrame() = 0;
|
||
virtual void shutdownImGui() = 0;
|
||
virtual bool pumpEvents() = 0; // handles what arrived; false once the window has closed
|
||
virtual bool minimized() = 0;
|
||
virtual bool focused() = 0;
|
||
virtual void waitForInput(double seconds) = 0;
|
||
virtual double displayFps() = 0; // the refresh rate of the display it's on
|
||
virtual Studio::WindowHooks hooks() = 0;
|
||
virtual void startHangWatchdog() {} // (Windows: asks to force-close a window hung for 20 s)
|
||
virtual void stopHangWatchdog() {}
|
||
};
|
||
|
||
// The UI fonts: the platform's own UI typeface when it's there (Segoe UI, the SF / Helvetica family, Noto / DejaVu),
|
||
// in regular and bold, plus one with the arrows and symbols the explorer uses.
|
||
struct FontFiles { std::vector<const char*> regular, bold, symbols; };
|
||
FontFiles fontFiles() {
|
||
#if defined(_WIN32)
|
||
return {{"C:\\Windows\\Fonts\\segoeui.ttf"}, {"C:\\Windows\\Fonts\\seguisb.ttf"}, {"C:\\Windows\\Fonts\\seguisym.ttf"}};
|
||
#elif defined(__APPLE__)
|
||
return {{"/System/Library/Fonts/Supplemental/Arial.ttf", "/Library/Fonts/Arial.ttf"},
|
||
{"/System/Library/Fonts/Supplemental/Arial Bold.ttf", "/Library/Fonts/Arial Bold.ttf"},
|
||
{"/System/Library/Fonts/Supplemental/Arial Unicode.ttf", "/Library/Fonts/Arial Unicode.ttf"}};
|
||
#else
|
||
// Noto Sans (most desktops), else Liberation Sans (Arial's metrics), else DejaVu Sans (wide, but everywhere). Ubuntu's
|
||
// own fonts ship as variable fonts only, which draw at one weight here.
|
||
return {{"/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf", "/usr/share/fonts/noto/NotoSans-Regular.ttf",
|
||
"/usr/share/fonts/google-noto/NotoSans-Regular.ttf",
|
||
"/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf", "/usr/share/fonts/liberation-sans/LiberationSans-Regular.ttf",
|
||
"/usr/share/fonts/TTF/LiberationSans-Regular.ttf",
|
||
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", "/usr/share/fonts/TTF/DejaVuSans.ttf"},
|
||
{"/usr/share/fonts/truetype/noto/NotoSans-SemiBold.ttf", "/usr/share/fonts/noto/NotoSans-SemiBold.ttf",
|
||
"/usr/share/fonts/google-noto/NotoSans-SemiBold.ttf",
|
||
"/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf", "/usr/share/fonts/liberation-sans/LiberationSans-Bold.ttf",
|
||
"/usr/share/fonts/TTF/LiberationSans-Bold.ttf",
|
||
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", "/usr/share/fonts/TTF/DejaVuSans-Bold.ttf"},
|
||
{"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", "/usr/share/fonts/TTF/DejaVuSans.ttf",
|
||
"/usr/share/fonts/truetype/noto/NotoSansSymbols2-Regular.ttf"}};
|
||
#endif
|
||
}
|
||
const char* firstExisting(const std::vector<const char*>& files) {
|
||
std::error_code ec;
|
||
for (const char* f : files) if (fs::exists(f, ec)) return f;
|
||
return nullptr;
|
||
}
|
||
|
||
// `density`: pixels per UI unit. Fonts are made at that many pixels and drawn back at UI size, so text stays sharp
|
||
// on a Retina display.
|
||
void loadFonts(float scale, float density) {
|
||
ImGuiIO& io = ImGui::GetIO();
|
||
scale *= density;
|
||
io.FontGlobalScale = 1.0f / density;
|
||
const FontFiles files = fontFiles();
|
||
const char* font = firstExisting(files.regular);
|
||
const char* bold = firstExisting(files.bold);
|
||
const char* symbols = firstExisting(files.symbols);
|
||
static const ImWchar ranges[] = {0x0020, 0x00FF, 0x2010, 0x2027, 0x2190, 0x2193, 0x203A, 0x203A, 0};
|
||
static const ImWchar symbolRanges[] = {0x2190, 0x21FF, 0x2300, 0x23FF, 0x25A0, 0x25FF, 0x2600, 0x27BF, 0};
|
||
auto addFont = [&](const char* file, float px) -> ImFont* {
|
||
ImFontConfig fc;
|
||
fc.OversampleH = 2;
|
||
ImFont* f = io.Fonts->AddFontFromFileTTF(file, px, &fc, ranges);
|
||
if (f && symbols) { // the few glyphs the UI font lacks (the explorer's arrows, the note, the cross), merged in
|
||
ImFontConfig mc;
|
||
mc.MergeMode = true;
|
||
mc.OversampleH = 2;
|
||
mc.GlyphOffset = ImVec2(0, 1.0f * scale);
|
||
io.Fonts->AddFontFromFileTTF(symbols, px, &mc, symbolRanges);
|
||
}
|
||
return f;
|
||
};
|
||
if (!font || !addFont(font, 16.0f * scale)) io.Fonts->AddFontDefault();
|
||
if (bold) {
|
||
g_fontBold = addFont(bold, 15.0f * scale);
|
||
g_fontTitle = addFont(bold, 30.0f * scale);
|
||
}
|
||
}
|
||
|
||
int fail(const std::string& what) {
|
||
platform::message("Frame Studio", what, platform::Icon::Error);
|
||
return 1;
|
||
}
|
||
|
||
// Everything between the window opening and closing, the same on every platform. `args`: the command line after the
|
||
// program's name (homes to open, or --selftest and its homes).
|
||
int runStudio(StudioWindow& window, const std::vector<std::string>& args) {
|
||
const fs::path data = dataDir();
|
||
const float scale = window.uiScale();
|
||
platform::setDialogOwner(window.nativeHandle());
|
||
g_selfTestRun = !args.empty() && args[0] == "--selftest"; // (before anything saves)
|
||
auto app = std::make_unique<Studio>();
|
||
g_app = app.get();
|
||
app->window = window.hooks();
|
||
app->scale = scale;
|
||
app->loadRecent();
|
||
app->loadSettings();
|
||
HslRenderer& r = app->renderer;
|
||
r.instanceExtensions = window.vulkanInstanceExtensions();
|
||
r.deviceExtensions = {"VK_KHR_swapchain"};
|
||
r.clearColor[0] = r.clearColor[1] = r.clearColor[2] = 0.041f; // Blender viewport grey (#393939)
|
||
r.pipelineCachePath = narrow((data / L"pipelines.bin").wstring()); // shaders compile once, then load from here
|
||
r.viewCount = 1; // the editor shows one view: draw it once, not per eye
|
||
r.msaa = app->studioPerf.msaa; // Edit > Settings
|
||
r.anisotropy = static_cast<float>(app->studioPerf.anisotropy);
|
||
if (!r.init(64, 64)) return fail("Couldn't start Vulkan on this GPU. See " + narrow((data / L"studio.log").wstring()) + ".");
|
||
r.costScaleMs = 0.05f; // Debug > GPU Cost: a desktop GPU is several times the Frame's
|
||
|
||
VkSurfaceKHR surface = window.createSurface(r.instance());
|
||
if (!surface) return fail("Couldn't create a Vulkan surface.");
|
||
VkBool32 present = VK_FALSE;
|
||
vkGetPhysicalDeviceSurfaceSupportKHR(r.physicalDevice(), r.queueFamily(), surface, &present);
|
||
if (!present) return fail("This GPU's graphics queue can't present to a window.");
|
||
|
||
int pixelW = 0, pixelH = 0;
|
||
window.pixelSize(pixelW, pixelH);
|
||
g_wd.Surface = surface;
|
||
const VkFormat formats[] = {VK_FORMAT_B8G8R8A8_UNORM, VK_FORMAT_R8G8B8A8_UNORM};
|
||
g_wd.SurfaceFormat = ImGui_ImplVulkanH_SelectSurfaceFormat(r.physicalDevice(), surface, formats, 2, VK_COLORSPACE_SRGB_NONLINEAR_KHR);
|
||
// MAILBOX (3 images): the newest frame replaces a waiting one, so a late compositor never blocks the queue
|
||
// the 3D renderer shares. FIFO is the fallback every driver has. FS_PRESENT=fifo forces vsync-blocking.
|
||
const char* pm = std::getenv("FS_PRESENT");
|
||
const bool fifo = pm && std::string(pm) == "fifo";
|
||
const VkPresentModeKHR modes[] = {VK_PRESENT_MODE_MAILBOX_KHR, VK_PRESENT_MODE_IMMEDIATE_KHR, VK_PRESENT_MODE_FIFO_KHR};
|
||
g_wd.PresentMode = fifo ? VK_PRESENT_MODE_FIFO_KHR : ImGui_ImplVulkanH_SelectPresentMode(r.physicalDevice(), surface, modes, 3);
|
||
std::fprintf(stderr, "[studio] present mode %d\n", static_cast<int>(g_wd.PresentMode));
|
||
g_wd.ClearEnable = true;
|
||
g_wd.ClearValue.color = {{0.11f, 0.11f, 0.11f, 1}};
|
||
ImGui_ImplVulkanH_CreateOrResizeWindow(r.instance(), r.physicalDevice(), r.device(), &g_wd, r.queueFamily(), nullptr, pixelW, pixelH, 3);
|
||
|
||
VkDescriptorPoolSize ps = {VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, 64};
|
||
VkDescriptorPoolCreateInfo dpci = {VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO};
|
||
dpci.flags = VK_DESCRIPTOR_POOL_CREATE_FREE_DESCRIPTOR_SET_BIT;
|
||
dpci.maxSets = 64;
|
||
dpci.poolSizeCount = 1;
|
||
dpci.pPoolSizes = &ps;
|
||
VkDescriptorPool uiPool = VK_NULL_HANDLE;
|
||
vkCreateDescriptorPool(r.device(), &dpci, nullptr, &uiPool);
|
||
VkSamplerCreateInfo smp = {VK_STRUCTURE_TYPE_SAMPLER_CREATE_INFO};
|
||
smp.magFilter = smp.minFilter = VK_FILTER_LINEAR;
|
||
smp.addressModeU = smp.addressModeV = smp.addressModeW = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE;
|
||
vkCreateSampler(r.device(), &smp, nullptr, &app->viewSampler);
|
||
|
||
window.show();
|
||
|
||
IMGUI_CHECKVERSION();
|
||
ImGui::CreateContext();
|
||
ImGuiIO& io = ImGui::GetIO();
|
||
static std::string ini = narrow((data / L"layout.ini").wstring());
|
||
io.IniFilename = ini.c_str();
|
||
applyTheme(scale);
|
||
loadFonts(scale, window.pixelDensity());
|
||
window.initImGui();
|
||
ImGui_ImplVulkan_InitInfo ii = {};
|
||
ii.Instance = r.instance();
|
||
ii.PhysicalDevice = r.physicalDevice();
|
||
ii.Device = r.device();
|
||
ii.QueueFamily = r.queueFamily();
|
||
ii.Queue = r.queue();
|
||
ii.DescriptorPool = uiPool;
|
||
ii.RenderPass = g_wd.RenderPass;
|
||
ii.MinImageCount = 2;
|
||
ii.ImageCount = g_wd.ImageCount;
|
||
ii.MSAASamples = VK_SAMPLE_COUNT_1_BIT;
|
||
ImGui_ImplVulkan_Init(&ii);
|
||
|
||
std::unique_ptr<SelfTest> selftest;
|
||
if (args.size() > 1 && args[0] == "--selftest") {
|
||
selftest = std::make_unique<SelfTest>(*app, std::vector<std::string>(args.begin() + 1, args.end()));
|
||
g_test = selftest.get();
|
||
} else if (!args.empty()) {
|
||
app->open(args);
|
||
}
|
||
|
||
window.startHangWatchdog();
|
||
FramePacer pacer([&window] { return window.focused(); }, [&window](double seconds) { window.waitForInput(seconds); });
|
||
pacer.setDisplayFps(window.displayFps());
|
||
if (const char* fps = std::getenv("FS_FPS")) pacer.setFpsOverride(std::atof(fps));
|
||
g_pacer = &pacer;
|
||
std::fprintf(stderr, "[studio] display %.0f Hz\n", pacer.displayFps());
|
||
const bool logFrameRate = std::getenv("FS_STATS") != nullptr;
|
||
|
||
bool waitingForGpu = false; // the last pass found the GPU still busy: this one is the same frame, already paced
|
||
for (;;) {
|
||
if (!waitingForGpu) pacer.waitForNextFrame(app->viewChangesByItself() || g_test != nullptr);
|
||
waitingForGpu = false;
|
||
if (!window.pumpEvents()) break;
|
||
if (window.minimized()) { std::this_thread::sleep_for(std::chrono::milliseconds(10)); continue; }
|
||
if (g_resizeW && g_resizeH) {
|
||
ImGui_ImplVulkan_SetMinImageCount(3);
|
||
ImGui_ImplVulkanH_CreateOrResizeWindow(r.instance(), r.physicalDevice(), r.device(), &g_wd, r.queueFamily(), nullptr,
|
||
static_cast<int>(g_resizeW), static_cast<int>(g_resizeH), 3); // as at startup
|
||
g_wd.FrameIndex = 0;
|
||
g_wd.SemaphoreIndex = 0; // the new swapchain may have fewer semaphores
|
||
g_resizeW = g_resizeH = 0;
|
||
g_rebuild = false;
|
||
} else if (g_rebuild) {
|
||
int w = 0, h = 0;
|
||
window.pixelSize(w, h);
|
||
g_resizeW = static_cast<uint32_t>(w);
|
||
g_resizeH = static_cast<uint32_t>(h);
|
||
continue;
|
||
}
|
||
|
||
// Never wait on the GPU here: while it is still busy with the last frame (the system paging in a fresh
|
||
// texture block, say), keep handling input and try again in a moment.
|
||
if (r.gpuBusy()) { std::this_thread::sleep_for(std::chrono::milliseconds(1)); waitingForGpu = true; continue; }
|
||
|
||
using clk = std::chrono::steady_clock;
|
||
const auto f0 = clk::now();
|
||
app->finishLoad();
|
||
app->updateWalk();
|
||
const auto f1 = clk::now();
|
||
app->renderViewport();
|
||
const auto f2 = clk::now();
|
||
|
||
ImGui_ImplVulkan_NewFrame();
|
||
window.newImGuiFrame();
|
||
if (g_test) g_test->inject(ImGui::GetIO());
|
||
ImGui::NewFrame();
|
||
ImGuizmo::BeginFrame();
|
||
app->ui();
|
||
if (g_test) g_test->afterUi();
|
||
ImGui::Render();
|
||
const auto f3 = clk::now();
|
||
ImDrawData* draw = ImGui::GetDrawData();
|
||
if (draw->DisplaySize.x > 0 && draw->DisplaySize.y > 0) {
|
||
frameRender(*app, draw);
|
||
framePresent(*app);
|
||
}
|
||
const auto f4 = clk::now();
|
||
if (const char* hang = std::getenv("FS_TEST_HANG")) { // diagnostics: freeze the UI thread once (tests the watchdog)
|
||
static bool hung = false;
|
||
if (!hung) { hung = true; std::this_thread::sleep_for(std::chrono::seconds(std::atoi(hang))); }
|
||
}
|
||
auto ms = [](clk::time_point a, clk::time_point b) { return std::chrono::duration<double, std::milli>(b - a).count(); };
|
||
if (logFrameRate) { // FS_STATS=1: the frame rate and where a frame's CPU time goes, every 5 s
|
||
static int framesCounted = 0;
|
||
static double viewMsSum = 0, uiMsSum = 0, presentMsSum = 0, gpuMsSum = 0;
|
||
static clk::time_point windowStart = f0;
|
||
++framesCounted; gpuMsSum += app->renderer.lastGpuMs(); viewMsSum += ms(f1, f2); uiMsSum += ms(f2, f3); presentMsSum += ms(f3, f4);
|
||
if (const double secs = ms(windowStart, f4) / 1000; secs >= 5) {
|
||
std::fprintf(stderr, "[studio] %.1f fps (display %.0f Hz), 3D view %ux%u: 3D %.2f ms (GPU %.2f ms), UI %.2f ms, present %.2f ms a frame\n",
|
||
framesCounted / secs, pacer.displayFps(), app->viewW, app->viewH, viewMsSum / framesCounted, gpuMsSum / framesCounted,
|
||
uiMsSum / framesCounted, presentMsSum / framesCounted);
|
||
std::fflush(stderr);
|
||
framesCounted = 0; viewMsSum = uiMsSum = presentMsSum = gpuMsSum = 0; windowStart = f4;
|
||
}
|
||
}
|
||
if (ms(f0, f4) > 40.0)
|
||
std::fprintf(stderr, "[studio] slow frame %.1f ms: load step %.1f, 3D %.1f, UI %.1f, present %.1f\n",
|
||
ms(f0, f4), ms(f0, f1), ms(f1, f2), ms(f2, f3), ms(f3, f4));
|
||
}
|
||
|
||
g_pacer = nullptr;
|
||
vkDeviceWaitIdle(r.device());
|
||
window.stopHangWatchdog();
|
||
if (app->saver.joinable()) app->saver.join();
|
||
if (app->loadState == 1 || app->loadAbandoned) { // a home still being read: nothing to lose, and waiting for it is what stalls
|
||
std::fflush(stderr);
|
||
std::_Exit(0);
|
||
}
|
||
if (app->loader.joinable()) app->loader.join();
|
||
if (app->sender.joinable()) app->sender.join();
|
||
app->stopTune = true;
|
||
if (app->tuner.joinable()) app->tuner.join();
|
||
if (app->filesReader.joinable()) app->filesReader.join();
|
||
if (app->installer.joinable()) app->installer.join();
|
||
if (app->headsetThread.joinable()) app->headsetThread.join();
|
||
if (app->headsetDebugThread.joinable()) app->headsetDebugThread.join();
|
||
if (app->loadAbandoned) { // a stopped loader may still be running and would touch the app as it's torn down
|
||
std::fflush(stderr);
|
||
std::_Exit(0);
|
||
}
|
||
ImGui_ImplVulkan_Shutdown();
|
||
window.shutdownImGui();
|
||
ImGui::DestroyContext();
|
||
ImGui_ImplVulkanH_DestroyWindow(r.instance(), r.device(), &g_wd, nullptr);
|
||
vkDestroySampler(r.device(), app->viewSampler, nullptr);
|
||
vkDestroyDescriptorPool(r.device(), uiPool, nullptr);
|
||
r.shutdown();
|
||
g_app = nullptr;
|
||
return 0;
|
||
}
|
||
|
||
#ifdef _WIN32
|
||
// ── Windows: a Win32 window ──────────────────────────────────────────────────────────────────────────────────
|
||
|
||
// The refresh rate of the display a window is on.
|
||
double displayFpsOf(HWND hwnd) {
|
||
MONITORINFOEXW info = {};
|
||
info.cbSize = sizeof info;
|
||
if (GetMonitorInfoW(MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST), &info)) {
|
||
DEVMODEW mode = {};
|
||
mode.dmSize = sizeof mode;
|
||
if (EnumDisplaySettingsW(info.szDevice, ENUM_CURRENT_SETTINGS, &mode) && mode.dmDisplayFrequency > 1)
|
||
return static_cast<double>(mode.dmDisplayFrequency);
|
||
}
|
||
return 60.0;
|
||
}
|
||
|
||
LRESULT WINAPI wndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) {
|
||
if (g_pacer) { // window changes count as input (the picture must follow), and may move it to another display
|
||
if (msg == WM_SIZE || msg == WM_ACTIVATE || msg == WM_DROPFILES || msg == WM_MOUSELEAVE) g_pacer->noteInput();
|
||
if (msg == WM_DISPLAYCHANGE || msg == WM_EXITSIZEMOVE || msg == WM_DPICHANGED) { g_pacer->noteInput(); g_pacer->setDisplayFps(displayFpsOf(hWnd)); }
|
||
}
|
||
if (ImGui_ImplWin32_WndProcHandler(hWnd, msg, wParam, lParam)) return true;
|
||
switch (msg) {
|
||
case WM_SIZE:
|
||
if (wParam != SIZE_MINIMIZED) { g_resizeW = LOWORD(lParam); g_resizeH = HIWORD(lParam); }
|
||
return 0;
|
||
case WM_GETMINMAXINFO:
|
||
reinterpret_cast<MINMAXINFO*>(lParam)->ptMinTrackSize = {900, 560};
|
||
return 0;
|
||
case WM_DROPFILES: {
|
||
HDROP drop = reinterpret_cast<HDROP>(wParam);
|
||
std::vector<std::string> files;
|
||
const UINT n = DragQueryFileW(drop, 0xFFFFFFFF, nullptr, 0);
|
||
for (UINT i = 0; i < n; ++i) {
|
||
std::wstring file(DragQueryFileW(drop, i, nullptr, 0) + 1, L'\0'); // long paths: no MAX_PATH cut
|
||
if (DragQueryFileW(drop, i, file.data(), static_cast<UINT>(file.size()))) files.push_back(narrow(file.c_str()));
|
||
}
|
||
DragFinish(drop);
|
||
filesDropped(files);
|
||
return 0;
|
||
}
|
||
case WM_CLOSE:
|
||
if (allowClose()) DestroyWindow(hWnd);
|
||
return 0;
|
||
case WM_SYSCOMMAND:
|
||
if ((wParam & 0xfff0) == SC_KEYMENU) return 0; // Alt alone must not open the system menu
|
||
break;
|
||
case WM_DESTROY:
|
||
PostQuitMessage(0);
|
||
return 0;
|
||
}
|
||
return DefWindowProcW(hWnd, msg, wParam, lParam);
|
||
}
|
||
|
||
class Win32Window : public StudioWindow {
|
||
public:
|
||
explicit Win32Window(HINSTANCE inst) : inst_(inst) {
|
||
ImGui_ImplWin32_EnableDpiAwareness();
|
||
scale_ = ImGui_ImplWin32_GetDpiScaleForMonitor(MonitorFromPoint(POINT{0, 0}, MONITOR_DEFAULTTOPRIMARY));
|
||
wc_ = {sizeof wc_, CS_CLASSDC, wndProc, 0, 0, inst, LoadIconW(nullptr, MAKEINTRESOURCEW(32512) /* IDI_APPLICATION */),
|
||
LoadCursorW(nullptr, MAKEINTRESOURCEW(32512) /* IDC_ARROW */), nullptr, nullptr, L"FrameStudio", nullptr};
|
||
RegisterClassExW(&wc_);
|
||
// 1600 x 960, but never bigger than the screen's free area (so the taskbar can't cover the status bar), centred on it.
|
||
RECT work = {0, 0, static_cast<LONG>(1600 * scale_), static_cast<LONG>(960 * scale_)};
|
||
SystemParametersInfoW(SPI_GETWORKAREA, 0, &work, 0);
|
||
const int winW = std::min(static_cast<int>(1600 * scale_), static_cast<int>(work.right - work.left));
|
||
const int winH = std::min(static_cast<int>(960 * scale_), static_cast<int>(work.bottom - work.top));
|
||
hwnd_ = CreateWindowW(wc_.lpszClassName, L"Frame Studio", WS_OVERLAPPEDWINDOW,
|
||
work.left + (work.right - work.left - winW) / 2, work.top + (work.bottom - work.top - winH) / 2,
|
||
winW, winH, nullptr, nullptr, inst, nullptr);
|
||
BOOL dark = TRUE;
|
||
DwmSetWindowAttribute(hwnd_, 20 /* DWMWA_USE_IMMERSIVE_DARK_MODE */, &dark, sizeof dark);
|
||
DragAcceptFiles(hwnd_, TRUE);
|
||
}
|
||
~Win32Window() override {
|
||
if (IsWindow(hwnd_)) DestroyWindow(hwnd_);
|
||
UnregisterClassW(wc_.lpszClassName, wc_.hInstance);
|
||
}
|
||
|
||
void* nativeHandle() override { return hwnd_; }
|
||
float uiScale() override { return scale_; }
|
||
std::vector<std::string> vulkanInstanceExtensions() override { return {"VK_KHR_surface", "VK_KHR_win32_surface"}; }
|
||
VkSurfaceKHR createSurface(VkInstance instance) override {
|
||
VkWin32SurfaceCreateInfoKHR sci = {VK_STRUCTURE_TYPE_WIN32_SURFACE_CREATE_INFO_KHR};
|
||
sci.hinstance = inst_;
|
||
sci.hwnd = hwnd_;
|
||
VkSurfaceKHR surface = VK_NULL_HANDLE;
|
||
return vkCreateWin32SurfaceKHR(instance, &sci, nullptr, &surface) == VK_SUCCESS ? surface : VK_NULL_HANDLE;
|
||
}
|
||
void pixelSize(int& width, int& height) override {
|
||
RECT rc;
|
||
GetClientRect(hwnd_, &rc);
|
||
width = rc.right - rc.left;
|
||
height = rc.bottom - rc.top;
|
||
}
|
||
void show() override { ShowWindow(hwnd_, SW_SHOWDEFAULT); UpdateWindow(hwnd_); }
|
||
void initImGui() override { ImGui_ImplWin32_Init(hwnd_); }
|
||
void newImGuiFrame() override { ImGui_ImplWin32_NewFrame(); }
|
||
void shutdownImGui() override { ImGui_ImplWin32_Shutdown(); }
|
||
bool pumpEvents() override {
|
||
MSG msg;
|
||
bool quit = false;
|
||
while (PeekMessageW(&msg, nullptr, 0, 0, PM_REMOVE)) {
|
||
const bool fromUser = (msg.message >= WM_KEYFIRST && msg.message <= WM_KEYLAST) ||
|
||
(msg.message >= WM_MOUSEFIRST && msg.message <= WM_MOUSELAST) ||
|
||
(msg.message >= WM_NCMOUSEMOVE && msg.message <= WM_NCXBUTTONDBLCLK);
|
||
if (fromUser && g_pacer) g_pacer->noteInput();
|
||
TranslateMessage(&msg);
|
||
DispatchMessageW(&msg);
|
||
if (msg.message == WM_QUIT) quit = true;
|
||
}
|
||
return !quit;
|
||
}
|
||
bool minimized() override { return IsIconic(hwnd_); }
|
||
bool focused() override { return GetForegroundWindow() == hwnd_; }
|
||
void waitForInput(double seconds) override {
|
||
MsgWaitForMultipleObjects(0, nullptr, FALSE, static_cast<DWORD>(seconds * 1000), QS_ALLINPUT);
|
||
}
|
||
double displayFps() override { return displayFpsOf(hwnd_); }
|
||
Studio::WindowHooks hooks() override {
|
||
Studio::WindowHooks h;
|
||
h.setTitle = [this](const std::string& title) { SetWindowTextW(hwnd_, widen(title).c_str()); };
|
||
h.requestClose = [this] { PostMessageW(hwnd_, WM_CLOSE, 0, 0); };
|
||
h.capture = [this](std::vector<u8>& rgba, int& w, int& h) { return capture(rgba, w, h); };
|
||
return h;
|
||
}
|
||
|
||
// When the window stops responding (Windows calls it hung after 5 s without handling messages) for 20 s, ask
|
||
// from this thread — the UI thread can't — whether to force Frame Studio closed. "No": not asked again for a minute.
|
||
void startHangWatchdog() override {
|
||
watchdog_ = std::thread([this] {
|
||
int hungFor = 0;
|
||
auto quietUntil = std::chrono::steady_clock::now();
|
||
while (!watchdogStop_) {
|
||
std::this_thread::sleep_for(std::chrono::seconds(1));
|
||
if (!IsWindow(hwnd_)) continue;
|
||
hungFor = IsHungAppWindow(hwnd_) ? hungFor + 1 : 0;
|
||
if (hungFor < 15 || std::chrono::steady_clock::now() < quietUntil) continue;
|
||
const int r = MessageBoxW(nullptr,
|
||
L"Frame Studio hasn't responded for 20 seconds. It may still be working on something big "
|
||
L"(opening, saving or uploading a home).\n\nForce it closed? Unsaved edits will be lost.",
|
||
L"Frame Studio isn't responding", MB_YESNO | MB_ICONWARNING | MB_TOPMOST | MB_SETFOREGROUND);
|
||
if (r == IDYES && IsHungAppWindow(hwnd_)) { // not if it came back while the box was up
|
||
std::fprintf(stderr, "[studio] force closed (not responding)\n");
|
||
std::fflush(stderr);
|
||
std::_Exit(1);
|
||
}
|
||
quietUntil = std::chrono::steady_clock::now() + std::chrono::seconds(60);
|
||
hungFor = 0;
|
||
}
|
||
});
|
||
}
|
||
void stopHangWatchdog() override {
|
||
watchdogStop_ = true;
|
||
if (watchdog_.joinable()) watchdog_.join();
|
||
}
|
||
|
||
private:
|
||
// The whole window as shown (UI, gizmo and world overlays included), for the self-test's screenshots.
|
||
bool capture(std::vector<u8>& rgba, int& w, int& h) {
|
||
RECT rc;
|
||
GetClientRect(hwnd_, &rc);
|
||
w = rc.right - rc.left;
|
||
h = rc.bottom - rc.top;
|
||
if (w < 1 || h < 1) return false;
|
||
HDC wdc = GetDC(hwnd_), mdc = CreateCompatibleDC(wdc);
|
||
HBITMAP bmp = CreateCompatibleBitmap(wdc, w, h);
|
||
HGDIOBJ old = SelectObject(mdc, bmp);
|
||
PrintWindow(hwnd_, mdc, 0x2 /* PW_RENDERFULLCONTENT */ | PW_CLIENTONLY);
|
||
BITMAPINFO bi = {};
|
||
bi.bmiHeader.biSize = sizeof bi.bmiHeader;
|
||
bi.bmiHeader.biWidth = w;
|
||
bi.bmiHeader.biHeight = -h;
|
||
bi.bmiHeader.biPlanes = 1;
|
||
bi.bmiHeader.biBitCount = 32;
|
||
rgba.assign(static_cast<size_t>(w) * h * 4, 0);
|
||
GetDIBits(mdc, bmp, 0, h, rgba.data(), &bi, DIB_RGB_COLORS);
|
||
SelectObject(mdc, old);
|
||
DeleteObject(bmp);
|
||
DeleteDC(mdc);
|
||
ReleaseDC(hwnd_, wdc);
|
||
for (size_t q = 0; q + 3 < rgba.size(); q += 4) { std::swap(rgba[q], rgba[q + 2]); rgba[q + 3] = 255; } // BGRA -> RGBA
|
||
return true;
|
||
}
|
||
|
||
HINSTANCE inst_;
|
||
WNDCLASSEXW wc_ = {};
|
||
HWND hwnd_ = nullptr;
|
||
float scale_ = 1;
|
||
std::thread watchdog_;
|
||
std::atomic<bool> watchdogStop_{false};
|
||
};
|
||
|
||
#else
|
||
// ── Linux and macOS: a GLFW window ───────────────────────────────────────────────────────────────────────────
|
||
|
||
class GlfwWindow : public StudioWindow {
|
||
public:
|
||
GlfwWindow() {
|
||
glfwInitVulkanLoader(vkGetInstanceProcAddr); // (volk's: GLFW then needs no Vulkan library of its own)
|
||
if (!glfwInit()) return;
|
||
glfwWindowHint(GLFW_CLIENT_API, GLFW_NO_API);
|
||
glfwWindowHint(GLFW_VISIBLE, GLFW_FALSE);
|
||
#ifdef __APPLE__
|
||
glfwWindowHint(GLFW_COCOA_RETINA_FRAMEBUFFER, GLFW_TRUE);
|
||
#endif
|
||
// 1600 x 960 (in the display's own units), never bigger than the screen's free area, centred on it.
|
||
GLFWmonitor* monitor = glfwGetPrimaryMonitor();
|
||
int wx = 0, wy = 0, ww = 1600, wh = 960;
|
||
if (monitor) glfwGetMonitorWorkarea(monitor, &wx, &wy, &ww, &wh);
|
||
const float s = contentScale(monitor);
|
||
const int winW = std::min(static_cast<int>(1600 * s), ww), winH = std::min(static_cast<int>(960 * s), wh);
|
||
window_ = glfwCreateWindow(winW, winH, "Frame Studio", nullptr, nullptr);
|
||
if (!window_) return;
|
||
glfwSetWindowPos(window_, wx + (ww - winW) / 2, wy + (wh - winH) / 2);
|
||
glfwSetWindowSizeLimits(window_, 900, 560, GLFW_DONT_CARE, GLFW_DONT_CARE);
|
||
// Ours first: ImGui's backend (initImGui) chains to them. Every input counts for the frame pacer.
|
||
glfwSetKeyCallback(window_, [](GLFWwindow*, int, int, int, int) { noteInput(); });
|
||
glfwSetCharCallback(window_, [](GLFWwindow*, unsigned) { noteInput(); });
|
||
glfwSetMouseButtonCallback(window_, [](GLFWwindow*, int, int, int) { noteInput(); });
|
||
glfwSetCursorPosCallback(window_, [](GLFWwindow*, double, double) { noteInput(); });
|
||
glfwSetCursorEnterCallback(window_, [](GLFWwindow*, int) { noteInput(); });
|
||
glfwSetScrollCallback(window_, [](GLFWwindow*, double, double) { noteInput(); });
|
||
glfwSetWindowFocusCallback(window_, [](GLFWwindow*, int) { noteInput(); });
|
||
glfwSetFramebufferSizeCallback(window_, [](GLFWwindow*, int w, int h) {
|
||
noteInput();
|
||
if (w > 0 && h > 0) { g_resizeW = static_cast<uint32_t>(w); g_resizeH = static_cast<uint32_t>(h); }
|
||
});
|
||
glfwSetWindowPosCallback(window_, [](GLFWwindow* w, int, int) { // moved, maybe to another display
|
||
if (auto* self = static_cast<GlfwWindow*>(glfwGetWindowUserPointer(w)); self && g_pacer) g_pacer->setDisplayFps(self->displayFps());
|
||
});
|
||
glfwSetDropCallback(window_, [](GLFWwindow*, int count, const char** paths) {
|
||
noteInput();
|
||
filesDropped(std::vector<std::string>(paths, paths + count));
|
||
});
|
||
glfwSetWindowCloseCallback(window_, [](GLFWwindow* w) { if (!allowClose()) glfwSetWindowShouldClose(w, GLFW_FALSE); });
|
||
glfwSetWindowUserPointer(window_, this);
|
||
}
|
||
~GlfwWindow() override {
|
||
if (window_) glfwDestroyWindow(window_);
|
||
glfwTerminate();
|
||
}
|
||
bool ok() const { return window_ != nullptr; }
|
||
|
||
void* nativeHandle() override { return window_; }
|
||
float uiScale() override {
|
||
#ifdef __APPLE__
|
||
return 1.0f; // macOS works in points and draws them at the display's density itself
|
||
#else
|
||
return contentScale(glfwGetPrimaryMonitor());
|
||
#endif
|
||
}
|
||
float pixelDensity() override {
|
||
#ifdef __APPLE__
|
||
return contentScale(glfwGetPrimaryMonitor()); // 2 on Retina
|
||
#else
|
||
return 1.0f;
|
||
#endif
|
||
}
|
||
std::vector<std::string> vulkanInstanceExtensions() override {
|
||
uint32_t n = 0;
|
||
const char** names = glfwGetRequiredInstanceExtensions(&n);
|
||
return names ? std::vector<std::string>(names, names + n) : std::vector<std::string>();
|
||
}
|
||
VkSurfaceKHR createSurface(VkInstance instance) override {
|
||
VkSurfaceKHR surface = VK_NULL_HANDLE;
|
||
return glfwCreateWindowSurface(instance, window_, nullptr, &surface) == VK_SUCCESS ? surface : VK_NULL_HANDLE;
|
||
}
|
||
void pixelSize(int& width, int& height) override { glfwGetFramebufferSize(window_, &width, &height); }
|
||
void show() override { glfwShowWindow(window_); }
|
||
void initImGui() override { ImGui_ImplGlfw_InitForVulkan(window_, true); }
|
||
void newImGuiFrame() override { ImGui_ImplGlfw_NewFrame(); }
|
||
void shutdownImGui() override { ImGui_ImplGlfw_Shutdown(); }
|
||
bool pumpEvents() override {
|
||
glfwPollEvents();
|
||
return !glfwWindowShouldClose(window_);
|
||
}
|
||
bool minimized() override { return glfwGetWindowAttrib(window_, GLFW_ICONIFIED) != 0; }
|
||
bool focused() override { return glfwGetWindowAttrib(window_, GLFW_FOCUSED) != 0; }
|
||
void waitForInput(double seconds) override { glfwWaitEventsTimeout(seconds); }
|
||
double displayFps() override {
|
||
// the display holding the window's centre
|
||
int x = 0, y = 0, w = 0, h = 0, count = 0;
|
||
glfwGetWindowPos(window_, &x, &y);
|
||
glfwGetWindowSize(window_, &w, &h);
|
||
GLFWmonitor** monitors = glfwGetMonitors(&count);
|
||
for (int i = 0; i < count; ++i) {
|
||
int mx = 0, my = 0;
|
||
glfwGetMonitorPos(monitors[i], &mx, &my);
|
||
const GLFWvidmode* mode = glfwGetVideoMode(monitors[i]);
|
||
if (mode && x + w / 2 >= mx && x + w / 2 < mx + mode->width && y + h / 2 >= my && y + h / 2 < my + mode->height && mode->refreshRate > 1)
|
||
return mode->refreshRate;
|
||
}
|
||
const GLFWvidmode* primary = glfwGetPrimaryMonitor() ? glfwGetVideoMode(glfwGetPrimaryMonitor()) : nullptr;
|
||
return primary && primary->refreshRate > 1 ? primary->refreshRate : 60.0;
|
||
}
|
||
Studio::WindowHooks hooks() override {
|
||
Studio::WindowHooks h;
|
||
h.setTitle = [this](const std::string& title) { glfwSetWindowTitle(window_, title.c_str()); };
|
||
h.requestClose = [this] { if (allowClose()) glfwSetWindowShouldClose(window_, GLFW_TRUE); };
|
||
// (no capture: the self-test notes its screenshots as skipped)
|
||
return h;
|
||
}
|
||
|
||
private:
|
||
static void noteInput() { if (g_pacer) g_pacer->noteInput(); }
|
||
static float contentScale(GLFWmonitor* monitor) {
|
||
float sx = 1, sy = 1;
|
||
if (monitor) glfwGetMonitorContentScale(monitor, &sx, &sy);
|
||
return std::max(1.0f, sx);
|
||
}
|
||
|
||
GLFWwindow* window_ = nullptr;
|
||
};
|
||
#endif
|
||
|
||
} // namespace
|
||
|
||
#ifdef _WIN32
|
||
int WINAPI wWinMain(HINSTANCE inst, HINSTANCE, PWSTR, int) {
|
||
_wfreopen((dataDir() / L"studio.log").c_str(), L"w", stderr);
|
||
std::vector<std::string> args;
|
||
int argc = 0;
|
||
if (LPWSTR* argv = CommandLineToArgvW(GetCommandLineW(), &argc)) {
|
||
for (int a = 1; a < argc; ++a) args.push_back(narrow(argv[a]));
|
||
LocalFree(argv);
|
||
}
|
||
if (volkInitialize() != VK_SUCCESS) return fail("Vulkan isn't available on this PC. Update your graphics driver.");
|
||
Win32Window window(inst);
|
||
return runStudio(window, args);
|
||
}
|
||
#else
|
||
// Vulkan: the system's loader, else (macOS) the MoltenVK the app bundle carries in Contents/Frameworks, used directly.
|
||
bool loadVulkan() {
|
||
if (volkInitialize() == VK_SUCCESS) return true;
|
||
#ifdef __APPLE__
|
||
const fs::path moltenVk = exeDir().parent_path() / "Frameworks" / "libMoltenVK.dylib";
|
||
if (void* lib = dlopen(moltenVk.c_str(), RTLD_NOW | RTLD_LOCAL))
|
||
if (auto gipa = reinterpret_cast<PFN_vkGetInstanceProcAddr>(dlsym(lib, "vkGetInstanceProcAddr"))) {
|
||
volkInitializeCustom(gipa);
|
||
return true;
|
||
}
|
||
#endif
|
||
return false;
|
||
}
|
||
|
||
int main(int argc, char** argv) {
|
||
std::freopen((dataDir() / "studio.log").c_str(), "w", stderr);
|
||
std::vector<std::string> args(argv + 1, argv + argc);
|
||
#ifdef __APPLE__
|
||
// Started from Finder, macOS may add a "-psn_..." process serial number argument: not a home.
|
||
args.erase(std::remove_if(args.begin(), args.end(), [](const std::string& a) { return a.rfind("-psn_", 0) == 0; }), args.end());
|
||
#endif
|
||
if (!loadVulkan()) return fail("Vulkan isn't available on this computer. Install your graphics driver's Vulkan support.");
|
||
GlfwWindow window;
|
||
if (!window.ok()) return fail("Couldn't open a window (GLFW).");
|
||
return runStudio(window, args);
|
||
}
|
||
#endif
|