854 lines
45 KiB
C++
854 lines
45 KiB
C++
#include "MetaCoreRuntimeUi/MetaCoreRuntimeUiSystem.h"
|
|
|
|
#include <algorithm>
|
|
#include <cctype>
|
|
#include <cmath>
|
|
#include <functional>
|
|
#include <iomanip>
|
|
#include <sstream>
|
|
#include <unordered_map>
|
|
#include <unordered_set>
|
|
#include <glm/gtc/matrix_inverse.hpp>
|
|
|
|
namespace MetaCore {
|
|
namespace {
|
|
|
|
std::string Escape(std::string_view value) {
|
|
std::string result;
|
|
result.reserve(value.size());
|
|
for (char c : value) {
|
|
switch (c) {
|
|
case '&': result += "&"; break;
|
|
case '<': result += "<"; break;
|
|
case '>': result += ">"; break;
|
|
case '"': result += """; break;
|
|
case '\'': result += "'"; break;
|
|
default: result += c; break;
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
std::string EscapeJson(std::string_view value) {
|
|
std::string result;
|
|
result.reserve(value.size());
|
|
for (const unsigned char c : value) {
|
|
switch (c) {
|
|
case '\\': result += "\\\\"; break;
|
|
case '"': result += "\\\""; break;
|
|
case '\n': result += "\\n"; break;
|
|
case '\r': result += "\\r"; break;
|
|
case '\t': result += "\\t"; break;
|
|
default:
|
|
if (c < 0x20U) {
|
|
constexpr char digits[] = "0123456789abcdef";
|
|
result += "\\u00";
|
|
result += digits[c >> 4U];
|
|
result += digits[c & 0x0fU];
|
|
} else {
|
|
result += static_cast<char>(c);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
const char* NodeClass(MetaCoreUiNodeType type) {
|
|
switch (type) {
|
|
case MetaCoreUiNodeType::Panel: return "panel";
|
|
case MetaCoreUiNodeType::Text: return "text";
|
|
case MetaCoreUiNodeType::Image: return "image";
|
|
case MetaCoreUiNodeType::Button: return "button";
|
|
case MetaCoreUiNodeType::Input: return "input";
|
|
case MetaCoreUiNodeType::List: return "list";
|
|
case MetaCoreUiNodeType::Scroll: return "scroll";
|
|
}
|
|
return "invalid";
|
|
}
|
|
|
|
const char* BindingTargetName(MetaCoreUiBindingTarget target) {
|
|
switch (target) {
|
|
case MetaCoreUiBindingTarget::Text: return "text";
|
|
case MetaCoreUiBindingTarget::Value: return "value";
|
|
case MetaCoreUiBindingTarget::Visible: return "visible";
|
|
case MetaCoreUiBindingTarget::Enabled: return "enabled";
|
|
case MetaCoreUiBindingTarget::Items: return "items";
|
|
case MetaCoreUiBindingTarget::SelectedIndex: return "selected-index";
|
|
}
|
|
return "text";
|
|
}
|
|
|
|
std::string Color(const glm::vec3& value, float alpha = 1.0F) {
|
|
std::ostringstream out;
|
|
out << "rgba(" << static_cast<int>(std::clamp(value.x, 0.0F, 1.0F) * 255.0F) << ','
|
|
<< static_cast<int>(std::clamp(value.y, 0.0F, 1.0F) * 255.0F) << ','
|
|
<< static_cast<int>(std::clamp(value.z, 0.0F, 1.0F) * 255.0F) << ','
|
|
<< std::fixed << std::setprecision(3) << std::clamp(alpha, 0.0F, 1.0F) << ')';
|
|
return out.str();
|
|
}
|
|
|
|
bool IsBindingPathValid(std::string_view path) {
|
|
return path.starts_with("runtime.") || path.starts_with("script.") || path.starts_with("scene.");
|
|
}
|
|
|
|
const char* EasingName(MetaCoreUiEasing easing) {
|
|
switch (easing) {
|
|
case MetaCoreUiEasing::Linear: return "linear";
|
|
case MetaCoreUiEasing::EaseIn: return "ease-in";
|
|
case MetaCoreUiEasing::EaseOut: return "ease-out";
|
|
case MetaCoreUiEasing::EaseInOut: return "ease-in-out";
|
|
}
|
|
return "linear";
|
|
}
|
|
|
|
const char* ThemeStateSelector(MetaCoreUiVisualState state) {
|
|
switch (state) {
|
|
case MetaCoreUiVisualState::Normal: return "";
|
|
case MetaCoreUiVisualState::Hover: return ":hover";
|
|
case MetaCoreUiVisualState::Pressed: return ":active";
|
|
case MetaCoreUiVisualState::Focused: return ":focus";
|
|
case MetaCoreUiVisualState::Disabled: return ":disabled";
|
|
}
|
|
return "";
|
|
}
|
|
|
|
std::string AnimationValue(MetaCoreUiAnimationProperty property, const glm::vec3& value) {
|
|
std::ostringstream out;
|
|
switch (property) {
|
|
case MetaCoreUiAnimationProperty::Opacity:
|
|
out << std::clamp(value.x, 0.0F, 1.0F);
|
|
break;
|
|
case MetaCoreUiAnimationProperty::Translation:
|
|
out << "translate(" << value.x << "px," << value.y << "px)";
|
|
break;
|
|
case MetaCoreUiAnimationProperty::Scale:
|
|
out << "scale(" << value.x << ',' << value.y << ')';
|
|
break;
|
|
case MetaCoreUiAnimationProperty::Color:
|
|
return Color(value);
|
|
}
|
|
return out.str();
|
|
}
|
|
|
|
const char* AnimationPropertyName(MetaCoreUiAnimationProperty property) {
|
|
switch (property) {
|
|
case MetaCoreUiAnimationProperty::Opacity: return "opacity";
|
|
case MetaCoreUiAnimationProperty::Translation:
|
|
case MetaCoreUiAnimationProperty::Scale: return "transform";
|
|
case MetaCoreUiAnimationProperty::Color: return "color";
|
|
}
|
|
return "opacity";
|
|
}
|
|
|
|
std::string Trim(std::string_view value) {
|
|
std::size_t first = 0;
|
|
while (first < value.size() && std::isspace(static_cast<unsigned char>(value[first]))) ++first;
|
|
std::size_t last = value.size();
|
|
while (last > first && std::isspace(static_cast<unsigned char>(value[last - 1]))) --last;
|
|
return std::string(value.substr(first, last - first));
|
|
}
|
|
|
|
std::string Lower(std::string_view value) {
|
|
std::string result(value);
|
|
std::transform(result.begin(), result.end(), result.begin(), [](unsigned char c) {
|
|
return static_cast<char>(std::tolower(c));
|
|
});
|
|
return result;
|
|
}
|
|
|
|
std::string DecodeLegacyText(std::string_view value) {
|
|
std::string result(value);
|
|
const std::pair<std::string_view, std::string_view> entities[] = {
|
|
{"<", "<"}, {">", ">"}, {""", "\""}, {"'", "'"}, {"&", "&"}
|
|
};
|
|
for (const auto& [encoded, decoded] : entities) {
|
|
std::size_t position = 0;
|
|
while ((position = result.find(encoded, position)) != std::string::npos) {
|
|
result.replace(position, encoded.size(), decoded);
|
|
position += decoded.size();
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
std::string LegacyAttribute(std::string_view tag, std::string_view requestedName) {
|
|
const std::string name = Lower(requestedName);
|
|
std::size_t position = 0;
|
|
while (position < tag.size()) {
|
|
while (position < tag.size() &&
|
|
(std::isspace(static_cast<unsigned char>(tag[position])) || tag[position] == '<')) ++position;
|
|
const std::size_t keyStart = position;
|
|
while (position < tag.size() &&
|
|
(std::isalnum(static_cast<unsigned char>(tag[position])) || tag[position] == '-' ||
|
|
tag[position] == '_' || tag[position] == ':')) ++position;
|
|
if (position == keyStart) {
|
|
++position;
|
|
continue;
|
|
}
|
|
const std::string key = Lower(tag.substr(keyStart, position - keyStart));
|
|
while (position < tag.size() && std::isspace(static_cast<unsigned char>(tag[position]))) ++position;
|
|
if (position >= tag.size() || tag[position] != '=') continue;
|
|
++position;
|
|
while (position < tag.size() && std::isspace(static_cast<unsigned char>(tag[position]))) ++position;
|
|
if (position >= tag.size()) break;
|
|
const char quote = tag[position];
|
|
if (quote != '"' && quote != '\'') continue;
|
|
const std::size_t valueStart = ++position;
|
|
const std::size_t valueEnd = tag.find(quote, valueStart);
|
|
if (valueEnd == std::string_view::npos) break;
|
|
if (key == name) return DecodeLegacyText(tag.substr(valueStart, valueEnd - valueStart));
|
|
position = valueEnd + 1;
|
|
}
|
|
return {};
|
|
}
|
|
|
|
std::optional<MetaCoreUiNodeType> LegacyNodeType(std::string_view tag, std::string_view fullTag) {
|
|
const std::string explicitType = Lower(LegacyAttribute(fullTag, "data-metacore-type"));
|
|
const std::string name = explicitType.empty() ? Lower(tag) : explicitType;
|
|
if (name == "div" || name == "section" || name == "panel") return MetaCoreUiNodeType::Panel;
|
|
if (name == "p" || name == "span" || name == "label" || name == "text") return MetaCoreUiNodeType::Text;
|
|
if (name == "img" || name == "image") return MetaCoreUiNodeType::Image;
|
|
if (name == "button") return MetaCoreUiNodeType::Button;
|
|
if (name == "input" || name == "textarea") return MetaCoreUiNodeType::Input;
|
|
if (name == "select" || name == "ul" || name == "ol" || name == "list") return MetaCoreUiNodeType::List;
|
|
if (name == "scroll") return MetaCoreUiNodeType::Scroll;
|
|
return std::nullopt;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
MetaCoreLegacyUiImportResult MetaCoreImportLegacyUiDocument(
|
|
std::string_view rml,
|
|
std::string_view rcss,
|
|
std::string_view documentName
|
|
) {
|
|
MetaCoreLegacyUiImportResult result;
|
|
result.Document.Name = Trim(documentName).empty() ? "ImportedUi" : Trim(documentName);
|
|
result.Document.SchemaVersion = METACORE_UI_SCHEMA_VERSION;
|
|
result.Document.GeneratorVersion = METACORE_UI_GENERATOR_VERSION;
|
|
|
|
MetaCoreUiNodeDocument root;
|
|
root.Id = "LegacyRoot";
|
|
root.Name = "LegacyRoot";
|
|
root.Type = MetaCoreUiNodeType::Panel;
|
|
root.RectTransform.AnchorMax = {1.0F, 1.0F, 0.0F};
|
|
root.RectTransform.Size = {0.0F, 0.0F, 0.0F};
|
|
result.Document.Nodes.push_back(root);
|
|
result.Document.RootNodeIds.push_back(root.Id);
|
|
|
|
struct OpenNode {
|
|
std::string Tag{};
|
|
std::string NodeId{};
|
|
};
|
|
std::vector<OpenNode> stack{{"__root", root.Id}};
|
|
std::unordered_set<std::string> ids{root.Id};
|
|
std::size_t generatedId = 0;
|
|
auto diagnostic = [&](std::string node, std::string message, bool error = false) {
|
|
result.Diagnostics.push_back({result.Document.Name, std::move(node), {}, std::move(message), error});
|
|
};
|
|
auto appendText = [&](std::string_view rawText) {
|
|
const std::string text = Trim(DecodeLegacyText(rawText));
|
|
if (text.empty() || stack.size() <= 1) return;
|
|
const std::string& nodeId = stack.back().NodeId;
|
|
const auto node = std::find_if(result.Document.Nodes.begin(), result.Document.Nodes.end(),
|
|
[&](const MetaCoreUiNodeDocument& candidate) { return candidate.Id == nodeId; });
|
|
if (node != result.Document.Nodes.end() &&
|
|
(node->Type == MetaCoreUiNodeType::Text || node->Type == MetaCoreUiNodeType::Button ||
|
|
node->Type == MetaCoreUiNodeType::Input)) {
|
|
if (!node->Text.empty()) node->Text += ' ';
|
|
node->Text += text;
|
|
}
|
|
};
|
|
|
|
std::size_t position = 0;
|
|
while (position < rml.size()) {
|
|
const std::size_t tagStart = rml.find('<', position);
|
|
if (tagStart == std::string_view::npos) {
|
|
appendText(rml.substr(position));
|
|
break;
|
|
}
|
|
appendText(rml.substr(position, tagStart - position));
|
|
if (rml.substr(tagStart).starts_with("<!--")) {
|
|
const std::size_t commentEnd = rml.find("-->", tagStart + 4);
|
|
if (commentEnd == std::string_view::npos) {
|
|
diagnostic({}, "Legacy RML contains an unterminated comment", true);
|
|
break;
|
|
}
|
|
position = commentEnd + 3;
|
|
continue;
|
|
}
|
|
const std::size_t tagEnd = rml.find('>', tagStart + 1);
|
|
if (tagEnd == std::string_view::npos) {
|
|
diagnostic({}, "Legacy RML contains an unterminated tag", true);
|
|
break;
|
|
}
|
|
const std::string_view fullTag = rml.substr(tagStart, tagEnd - tagStart + 1);
|
|
std::string_view body = fullTag.substr(1, fullTag.size() - 2);
|
|
const bool closing = !body.empty() && body.front() == '/';
|
|
if (closing) body.remove_prefix(1);
|
|
body = std::string_view(body.data(), body.size());
|
|
std::size_t nameLength = 0;
|
|
while (nameLength < body.size() &&
|
|
(std::isalnum(static_cast<unsigned char>(body[nameLength])) || body[nameLength] == '-')) ++nameLength;
|
|
const std::string tagName = Lower(body.substr(0, nameLength));
|
|
const bool selfClosing = (!body.empty() && body.back() == '/') ||
|
|
tagName == "input" || tagName == "img" || tagName == "br" || tagName == "link" || tagName == "meta";
|
|
position = tagEnd + 1;
|
|
if (tagName.empty() || tagName == "!doctype" || tagName == "rml" || tagName == "html" ||
|
|
tagName == "head" || tagName == "body" || tagName == "title" || tagName == "style" ||
|
|
tagName == "link" || tagName == "meta") {
|
|
continue;
|
|
}
|
|
if (closing) {
|
|
const auto open = std::find_if(stack.rbegin(), stack.rend(),
|
|
[&](const OpenNode& candidate) { return candidate.Tag == tagName; });
|
|
if (open == stack.rend()) {
|
|
diagnostic({}, "Ignored unmatched closing tag </" + tagName + ">");
|
|
} else {
|
|
stack.erase(open.base() - 1, stack.end());
|
|
}
|
|
continue;
|
|
}
|
|
const std::optional<MetaCoreUiNodeType> type = LegacyNodeType(tagName, fullTag);
|
|
if (!type.has_value()) {
|
|
diagnostic({}, "Unsupported legacy tag <" + tagName + "> was skipped");
|
|
continue;
|
|
}
|
|
|
|
std::string id = LegacyAttribute(fullTag, "id");
|
|
if (id.empty() || ids.contains(id)) {
|
|
if (!id.empty()) diagnostic(id, "Duplicate legacy id was replaced with a deterministic generated id");
|
|
do id = "LegacyNode" + std::to_string(++generatedId); while (ids.contains(id));
|
|
}
|
|
ids.insert(id);
|
|
MetaCoreUiNodeDocument node;
|
|
node.Id = id;
|
|
node.Name = LegacyAttribute(fullTag, "name");
|
|
if (node.Name.empty()) node.Name = id;
|
|
node.ParentId = stack.back().NodeId;
|
|
node.Type = *type;
|
|
node.Placeholder = LegacyAttribute(fullTag, "placeholder");
|
|
node.Interactable = *type == MetaCoreUiNodeType::Button || *type == MetaCoreUiNodeType::Input ||
|
|
*type == MetaCoreUiNodeType::List || *type == MetaCoreUiNodeType::Scroll;
|
|
if (*type == MetaCoreUiNodeType::Image) {
|
|
const std::string source = LegacyAttribute(fullTag, "src");
|
|
if (!source.empty()) diagnostic(id, "Image src '" + source + "' requires manual Asset GUID assignment");
|
|
}
|
|
const auto parent = std::find_if(result.Document.Nodes.begin(), result.Document.Nodes.end(),
|
|
[&](const MetaCoreUiNodeDocument& candidate) { return candidate.Id == node.ParentId; });
|
|
if (parent != result.Document.Nodes.end()) parent->Children.push_back(id);
|
|
result.Document.Nodes.push_back(std::move(node));
|
|
if (!selfClosing) stack.push_back({tagName, id});
|
|
}
|
|
if (!rcss.empty()) {
|
|
diagnostic({}, "Legacy RCSS was diagnosed but not copied; migrate declarations to UiTheme tokens and node styles");
|
|
}
|
|
const std::vector<MetaCoreUiDiagnostic> validation = MetaCoreValidateUiDocument(result.Document);
|
|
result.Diagnostics.insert(result.Diagnostics.end(), validation.begin(), validation.end());
|
|
result.Succeeded = std::none_of(result.Diagnostics.begin(), result.Diagnostics.end(),
|
|
[](const MetaCoreUiDiagnostic& item) { return item.IsError; });
|
|
return result;
|
|
}
|
|
|
|
MetaCoreUiDocument MetaCoreMigrateUiDocument(MetaCoreUiDocument document) {
|
|
if (document.SchemaVersion >= METACORE_UI_SCHEMA_VERSION) {
|
|
return document;
|
|
}
|
|
for (MetaCoreUiNodeDocument& node : document.Nodes) {
|
|
if (!node.DataBinding.empty() && node.Bindings.empty()) {
|
|
node.Bindings.push_back({
|
|
node.DataBinding,
|
|
node.Type == MetaCoreUiNodeType::Input ? MetaCoreUiBindingTarget::Value : MetaCoreUiBindingTarget::Text,
|
|
node.Type == MetaCoreUiNodeType::Input ? MetaCoreUiBindingMode::TwoWay : MetaCoreUiBindingMode::OneWay,
|
|
node.DataFormat,
|
|
{},
|
|
{}
|
|
});
|
|
}
|
|
}
|
|
document.SchemaVersion = METACORE_UI_SCHEMA_VERSION;
|
|
document.GeneratorVersion = METACORE_UI_GENERATOR_VERSION;
|
|
return document;
|
|
}
|
|
|
|
std::vector<MetaCoreUiDiagnostic> MetaCoreValidateUiDocument(const MetaCoreUiDocument& document) {
|
|
std::vector<MetaCoreUiDiagnostic> diagnostics;
|
|
auto issue = [&](std::string node, std::string binding, std::string message) {
|
|
diagnostics.push_back({document.Name, std::move(node), std::move(binding), std::move(message), true});
|
|
};
|
|
if (document.SchemaVersion > METACORE_UI_SCHEMA_VERSION) issue({}, {}, "UI document schema is newer than this engine");
|
|
if (document.GeneratorVersion > METACORE_UI_GENERATOR_VERSION) issue({}, {}, "UI document generator is newer than this engine");
|
|
if (document.ReferenceWidth <= 0 || document.ReferenceHeight <= 0) issue({}, {}, "Reference resolution must be positive");
|
|
if (!std::isfinite(document.MatchWidthOrHeight) || document.MatchWidthOrHeight < 0.0F || document.MatchWidthOrHeight > 1.0F)
|
|
issue({}, {}, "MatchWidthOrHeight must be in [0, 1]");
|
|
if (document.ReferenceDpi <= 0.0F || document.MinScale <= 0.0F || document.MaxScale < document.MinScale)
|
|
issue({}, {}, "Canvas DPI or scale limits are invalid");
|
|
if (document.MinAspectRatio < 0.0F || document.MaxAspectRatio < 0.0F ||
|
|
(document.MinAspectRatio > 0.0F && document.MaxAspectRatio > 0.0F &&
|
|
document.MaxAspectRatio < document.MinAspectRatio))
|
|
issue({}, {}, "Canvas aspect ratio limits are invalid");
|
|
|
|
std::unordered_map<std::string, const MetaCoreUiNodeDocument*> nodes;
|
|
for (const MetaCoreUiNodeDocument& node : document.Nodes) {
|
|
if (node.Id.empty()) issue({}, {}, "Every UI node requires a stable ID");
|
|
else if (!nodes.emplace(node.Id, &node).second) issue(node.Id, {}, "Duplicate UI node ID");
|
|
if (node.RectTransform.MinSize.x < 0.0F || node.RectTransform.MinSize.y < 0.0F ||
|
|
node.RectTransform.MaxSize.x < node.RectTransform.MinSize.x ||
|
|
node.RectTransform.MaxSize.y < node.RectTransform.MinSize.y)
|
|
issue(node.Id, {}, "Node minimum/maximum size is invalid");
|
|
if (node.Type == MetaCoreUiNodeType::Input && node.MaxLength < 0) issue(node.Id, {}, "Input MaxLength cannot be negative");
|
|
if (node.Type != MetaCoreUiNodeType::List && (!node.StaticItems.empty() || !node.ItemTemplateNodeId.empty()))
|
|
issue(node.Id, {}, "Static items and row templates are only valid on List nodes");
|
|
std::unordered_set<std::string> listItemIds;
|
|
for (const MetaCoreUiListItemDocument& item : node.StaticItems) {
|
|
if (item.Id.empty()) issue(node.Id, {}, "Static list item requires a stable ID");
|
|
else if (!listItemIds.insert(item.Id).second) issue(node.Id, {}, "Duplicate static list item ID");
|
|
}
|
|
std::unordered_set<std::string> animationIds;
|
|
for (const MetaCoreUiAnimationDocument& animation : node.Animations) {
|
|
if (animation.Id.empty()) issue(node.Id, {}, "Animation requires a stable ID");
|
|
else if (!animationIds.insert(animation.Id).second) issue(node.Id, {}, "Duplicate animation ID");
|
|
if (!std::isfinite(animation.DurationSeconds) || animation.DurationSeconds < 0.0F ||
|
|
!std::isfinite(animation.DelaySeconds) || animation.DelaySeconds < 0.0F)
|
|
issue(node.Id, {}, "Animation duration and delay must be finite and non-negative");
|
|
}
|
|
for (const MetaCoreUiBindingDocument& binding : node.Bindings) {
|
|
if (!IsBindingPathValid(binding.SourcePath)) issue(node.Id, binding.SourcePath, "Binding path must use runtime., script. or scene.");
|
|
static const std::unordered_set<std::string> converters{
|
|
"", "not", "to_string", "uppercase", "lowercase", "to_bool", "to_int", "to_double"
|
|
};
|
|
if (!converters.contains(binding.Converter))
|
|
issue(node.Id, binding.SourcePath, "Binding converter is not supported: " + binding.Converter);
|
|
if (binding.Mode == MetaCoreUiBindingMode::TwoWay &&
|
|
binding.Target != MetaCoreUiBindingTarget::Value &&
|
|
binding.Target != MetaCoreUiBindingTarget::SelectedIndex)
|
|
issue(node.Id, binding.SourcePath, "Two-way binding is only valid for Value or SelectedIndex");
|
|
}
|
|
}
|
|
for (const std::string& root : document.RootNodeIds) {
|
|
if (!nodes.contains(root)) issue(root, {}, "Root node does not exist");
|
|
else if (!nodes.at(root)->ParentId.empty()) issue(root, {}, "Root node has a parent");
|
|
}
|
|
for (const auto& node : document.Nodes)
|
|
if (!node.ItemTemplateNodeId.empty() && !nodes.contains(node.ItemTemplateNodeId))
|
|
issue(node.Id, {}, "List row template node does not exist");
|
|
for (const MetaCoreUiNodeDocument& node : document.Nodes) {
|
|
if (!node.ParentId.empty() && !nodes.contains(node.ParentId)) issue(node.Id, {}, "Parent node does not exist");
|
|
std::unordered_set<std::string> children;
|
|
for (const std::string& child : node.Children) {
|
|
if (!children.insert(child).second) issue(node.Id, {}, "Child ID is duplicated");
|
|
if (!nodes.contains(child)) issue(node.Id, {}, "Child node does not exist: " + child);
|
|
else if (nodes.at(child)->ParentId != node.Id) issue(child, {}, "Parent/child relationship is inconsistent");
|
|
}
|
|
if (!node.ItemTemplateNodeId.empty() && !nodes.contains(node.ItemTemplateNodeId))
|
|
issue(node.Id, {}, "List item template node does not exist");
|
|
}
|
|
enum class Visit { Visiting, Done };
|
|
std::unordered_map<std::string, Visit> visited;
|
|
std::function<void(const std::string&)> walk = [&](const std::string& id) {
|
|
const auto state = visited.find(id);
|
|
if (state != visited.end()) {
|
|
if (state->second == Visit::Visiting) issue(id, {}, "UI hierarchy contains a cycle");
|
|
return;
|
|
}
|
|
visited[id] = Visit::Visiting;
|
|
const auto found = nodes.find(id);
|
|
if (found != nodes.end()) for (const std::string& child : found->second->Children) walk(child);
|
|
visited[id] = Visit::Done;
|
|
};
|
|
for (const std::string& root : document.RootNodeIds) walk(root);
|
|
for (const auto& [id, _] : nodes) if (!visited.contains(id)) issue(id, {}, "Node is unreachable from RootNodeIds");
|
|
return diagnostics;
|
|
}
|
|
|
|
MetaCoreCompiledUiDocument MetaCoreCompileUiDocument(const MetaCoreUiDocument& source) {
|
|
return MetaCoreCompileUiDocument(source, {});
|
|
}
|
|
|
|
MetaCoreCompiledUiDocument MetaCoreCompileUiDocument(
|
|
const MetaCoreUiDocument& source,
|
|
const MetaCoreUiCompileResources& resources
|
|
) {
|
|
MetaCoreCompiledUiDocument result;
|
|
const MetaCoreUiDocument document = MetaCoreMigrateUiDocument(source);
|
|
result.Diagnostics = MetaCoreValidateUiDocument(document);
|
|
const auto resourceIssue = [&](std::string node, std::string message) {
|
|
result.Diagnostics.push_back({document.Name, std::move(node), {}, std::move(message), true});
|
|
};
|
|
if (resources.Theme.has_value()) {
|
|
std::unordered_set<std::string> stateKeys;
|
|
for (const auto& style : resources.Theme->Styles) {
|
|
const std::string key = style.Token + ':' + std::to_string(static_cast<int>(style.State));
|
|
if (style.Token.empty()) resourceIssue({}, "Theme style requires a token");
|
|
else if (!stateKeys.insert(key).second) resourceIssue({}, "Duplicate theme token state: " + key);
|
|
}
|
|
}
|
|
if (resources.FontFamily.has_value()) {
|
|
for (const auto& face : resources.FontFamily->Faces)
|
|
if (!face.FontAssetGuid.IsValid()) resourceIssue({}, "Font face requires an asset GUID");
|
|
}
|
|
if (resources.Localization.has_value()) {
|
|
std::unordered_set<std::string> localeIds;
|
|
for (const auto& locale : resources.Localization->Locales) {
|
|
if (locale.Locale.empty() || !localeIds.insert(locale.Locale).second)
|
|
resourceIssue({}, "Localization locale is empty or duplicated");
|
|
std::unordered_set<std::string> keys;
|
|
for (const auto& entry : locale.Entries)
|
|
if (entry.Key.empty() || !keys.insert(entry.Key).second)
|
|
resourceIssue({}, "Localization key is empty or duplicated in " + locale.Locale);
|
|
}
|
|
for (const auto& node : document.Nodes) {
|
|
if (!node.LocalizationKey.empty() &&
|
|
!MetaCoreResolveUiLocalizedText(*resources.Localization, node.LocalizationKey,
|
|
document.DefaultLocale, document.FallbackLocale).has_value())
|
|
resourceIssue(node.Id, "Localization key cannot be resolved: " + node.LocalizationKey);
|
|
}
|
|
}
|
|
if (std::any_of(result.Diagnostics.begin(), result.Diagnostics.end(), [](const auto& value) { return value.IsError; })) return result;
|
|
|
|
std::unordered_map<std::string, const MetaCoreUiNodeDocument*> nodes;
|
|
for (const auto& node : document.Nodes) {
|
|
nodes[node.Id] = &node;
|
|
if (node.Style.ImageAssetGuid.IsValid()) result.Dependencies.push_back(node.Style.ImageAssetGuid);
|
|
}
|
|
for (MetaCoreAssetGuid guid : {document.ThemeAssetGuid, document.FontFamilyAssetGuid, document.LocalizationAssetGuid})
|
|
if (guid.IsValid()) result.Dependencies.push_back(guid);
|
|
if (resources.FontFamily.has_value())
|
|
for (const auto& face : resources.FontFamily->Faces)
|
|
if (face.FontAssetGuid.IsValid()) result.Dependencies.push_back(face.FontAssetGuid);
|
|
std::sort(result.Dependencies.begin(), result.Dependencies.end(), [](const auto& a, const auto& b) { return a.ToString() < b.ToString(); });
|
|
result.Dependencies.erase(std::unique(result.Dependencies.begin(), result.Dependencies.end()), result.Dependencies.end());
|
|
|
|
std::ostringstream rml;
|
|
rml << "<rml><head><link type=\"text/rcss\" href=\"document.rcss\"/></head><body"
|
|
<< " data-metacore-schema=\"" << METACORE_UI_SCHEMA_VERSION << "\""
|
|
<< " data-metacore-scale-mode=\"" << static_cast<int>(document.ScaleMode) << "\""
|
|
<< " data-metacore-reference=\"" << document.ReferenceWidth << 'x' << document.ReferenceHeight << "\""
|
|
<< " data-metacore-match=\"" << document.MatchWidthOrHeight << "\""
|
|
<< " data-metacore-reference-dpi=\"" << document.ReferenceDpi << "\""
|
|
<< " data-metacore-min-scale=\"" << document.MinScale << "\""
|
|
<< " data-metacore-max-scale=\"" << document.MaxScale << "\""
|
|
<< " data-metacore-min-aspect=\"" << document.MinAspectRatio << "\""
|
|
<< " data-metacore-max-aspect=\"" << document.MaxAspectRatio << "\""
|
|
<< " data-metacore-safe-area=\"" << (document.UseSafeArea ? "true" : "false") << "\"";
|
|
if (resources.Theme.has_value()) rml << " data-metacore-theme=\"" << Escape(resources.Theme->Name) << "\"";
|
|
rml << ">\n";
|
|
if (resources.FontFamily.has_value()) {
|
|
std::string requiredCharacters;
|
|
for (const auto& face : resources.FontFamily->Faces) requiredCharacters += face.RequiredCharacters;
|
|
if (!requiredCharacters.empty())
|
|
rml << " <span id=\"mcui-font-prewarm\" aria-hidden=\"true\">" << Escape(requiredCharacters) << "</span>\n";
|
|
}
|
|
|
|
std::int32_t documentOrder = 0;
|
|
std::function<void(const std::string&, int)> emit = [&](const std::string& id, int depth) {
|
|
const MetaCoreUiNodeDocument& node = *nodes.at(id);
|
|
const std::int32_t currentDocumentOrder = documentOrder++;
|
|
const std::string indent(static_cast<std::size_t>(depth), ' ');
|
|
const char* tag = "div";
|
|
if (node.Type == MetaCoreUiNodeType::Button) tag = "button";
|
|
else if (node.Type == MetaCoreUiNodeType::Input) tag = "input";
|
|
else if (node.Type == MetaCoreUiNodeType::List) tag = "select";
|
|
else if (node.Type == MetaCoreUiNodeType::Image) {
|
|
const bool isAtlased = std::any_of(resources.AtlasEntries.begin(), resources.AtlasEntries.end(),
|
|
[&](const MetaCoreUiCompileResources::AtlasEntry& entry) {
|
|
return entry.AssetGuid == node.Style.ImageAssetGuid;
|
|
});
|
|
tag = isAtlased ? "div" : "img";
|
|
}
|
|
rml << indent << '<' << tag << " id=\"" << Escape(node.Id) << "\" class=\"mcui mcui-" << NodeClass(node.Type);
|
|
if (!node.Style.ThemeToken.empty()) rml << " theme-" << Escape(node.Style.ThemeToken);
|
|
rml << "\" data-metacore-kind=\"" << NodeClass(node.Type) << "\"";
|
|
if (!node.Action.empty()) rml << " data-metacore-action=\"" << Escape(node.Action) << "\"";
|
|
if (node.NavigationOrder >= 0) rml << " tabindex=\"" << node.NavigationOrder << "\"";
|
|
else if (node.Type == MetaCoreUiNodeType::Button || node.Type == MetaCoreUiNodeType::Input ||
|
|
node.Type == MetaCoreUiNodeType::List)
|
|
rml << " tabindex=\"" << (100000 + currentDocumentOrder) << "\"";
|
|
if (!node.LocalizationKey.empty()) rml << " data-metacore-loc=\"" << Escape(node.LocalizationKey) << "\"";
|
|
if (!node.ShowAnimation.empty()) rml << " data-metacore-show-animation=\"" << Escape(node.ShowAnimation) << "\"";
|
|
if (!node.HideAnimation.empty()) rml << " data-metacore-hide-animation=\"" << Escape(node.HideAnimation) << "\"";
|
|
for (const auto& binding : node.Bindings) {
|
|
rml << " data-metacore-bind-" << BindingTargetName(binding.Target) << "=\"" << Escape(binding.SourcePath) << "\""
|
|
<< " data-metacore-bind-mode-" << BindingTargetName(binding.Target) << "=\""
|
|
<< (binding.Mode == MetaCoreUiBindingMode::TwoWay ? "two-way" : "one-way") << "\"";
|
|
if (!binding.Format.empty()) rml << " data-metacore-format-" << BindingTargetName(binding.Target) << "=\"" << Escape(binding.Format) << "\"";
|
|
if (!binding.Converter.empty()) rml << " data-metacore-converter-" << BindingTargetName(binding.Target) << "=\"" << Escape(binding.Converter) << "\"";
|
|
if (!binding.Fallback.empty()) rml << " data-metacore-fallback-" << BindingTargetName(binding.Target) << "=\"" << Escape(binding.Fallback) << "\"";
|
|
}
|
|
if (node.Bindings.empty() && !node.DataBinding.empty()) {
|
|
rml << " data-metacore-bind-text=\"" << Escape(node.DataBinding) << "\" data-metacore-format-text=\"" << Escape(node.DataFormat) << "\"";
|
|
}
|
|
if (!node.Visible) rml << " hidden=\"true\"";
|
|
if (!node.Interactable && (node.Type == MetaCoreUiNodeType::Button || node.Type == MetaCoreUiNodeType::Input)) rml << " disabled=\"true\"";
|
|
if (node.Type == MetaCoreUiNodeType::Input) {
|
|
rml << " value=\"" << Escape(node.Text) << "\" placeholder=\"" << Escape(node.Placeholder) << "\"";
|
|
if (node.ReadOnly) rml << " readonly=\"true\"";
|
|
if (node.MaxLength > 0) rml << " maxlength=\"" << node.MaxLength << "\"";
|
|
rml << " data-metacore-input-type=\"" << static_cast<int>(node.InputValueType) << "\""
|
|
<< " type=\"" << (node.InputValueType == MetaCoreUiInputValueType::Text ? "text" : "number") << "\"";
|
|
if (node.InputValueType == MetaCoreUiInputValueType::Integer) rml << " step=\"1\"";
|
|
else if (node.InputValueType == MetaCoreUiInputValueType::Number) rml << " step=\"any\"";
|
|
rml << "/>\n";
|
|
return;
|
|
}
|
|
if (node.Type == MetaCoreUiNodeType::Image && node.Style.ImageAssetGuid.IsValid() &&
|
|
std::none_of(resources.AtlasEntries.begin(), resources.AtlasEntries.end(),
|
|
[&](const MetaCoreUiCompileResources::AtlasEntry& entry) {
|
|
return entry.AssetGuid == node.Style.ImageAssetGuid;
|
|
}))
|
|
rml << " src=\"Assets/" << node.Style.ImageAssetGuid.ToString() << ".png\"";
|
|
if (node.Type == MetaCoreUiNodeType::List) rml << " role=\"listbox\" data-metacore-selected-index=\"" << node.SelectedIndex << "\"";
|
|
rml << '>';
|
|
if (node.Type == MetaCoreUiNodeType::Text || node.Type == MetaCoreUiNodeType::Button) {
|
|
std::string text = node.Text;
|
|
if (resources.Localization.has_value() && !node.LocalizationKey.empty()) {
|
|
text = MetaCoreResolveUiLocalizedText(
|
|
*resources.Localization,
|
|
node.LocalizationKey,
|
|
document.DefaultLocale,
|
|
document.FallbackLocale
|
|
).value_or(node.Text);
|
|
}
|
|
rml << Escape(text);
|
|
}
|
|
if (node.Type == MetaCoreUiNodeType::List) {
|
|
for (std::size_t index = 0; index < node.StaticItems.size(); ++index) {
|
|
const auto& item = node.StaticItems[index];
|
|
std::string text = item.Text;
|
|
if (resources.Localization.has_value() && !item.LocalizationKey.empty())
|
|
text = MetaCoreResolveUiLocalizedText(*resources.Localization, item.LocalizationKey,
|
|
document.DefaultLocale, document.FallbackLocale).value_or(item.Text);
|
|
rml << "<option id=\"" << Escape(node.Id + "-item-" + item.Id) << "\" value=\""
|
|
<< Escape(item.Value) << "\" data-metacore-index=\"" << index << "\"";
|
|
if (static_cast<std::int32_t>(index) == node.SelectedIndex) rml << " selected=\"true\"";
|
|
if (!item.LocalizationKey.empty()) rml << " data-metacore-loc=\"" << Escape(item.LocalizationKey) << "\"";
|
|
rml << '>' << Escape(text) << "</option>";
|
|
}
|
|
}
|
|
if (!node.Children.empty()) rml << '\n';
|
|
for (const std::string& child : node.Children) emit(child, depth + 2);
|
|
if (!node.Children.empty()) rml << indent;
|
|
rml << "</" << tag << ">\n";
|
|
};
|
|
for (const std::string& root : document.RootNodeIds) emit(root, 2);
|
|
rml << "</body></rml>\n";
|
|
|
|
std::ostringstream css;
|
|
if (!resources.AtlasFilename.empty() && !resources.AtlasEntries.empty()) {
|
|
css << "@spritesheet metacore-ui-atlas { src:url(\"Assets/" << Escape(resources.AtlasFilename) << "\");";
|
|
for (const auto& entry : resources.AtlasEntries) {
|
|
css << " sprite:" << entry.AssetGuid.ToString() << ' ' << entry.X << "px " << entry.Y << "px "
|
|
<< entry.Width << "px " << entry.Height << "px;";
|
|
}
|
|
css << " }\n";
|
|
}
|
|
if (resources.FontFamily.has_value()) {
|
|
const auto& family = *resources.FontFamily;
|
|
for (const auto& face : family.Faces) {
|
|
if (!face.FontAssetGuid.IsValid()) continue;
|
|
css << "@font-face { font-family:\"" << Escape(family.FamilyName) << "\"; src:url(\"Assets/"
|
|
<< face.FontAssetGuid.ToString() << ".font\"); font-weight:" << face.Weight
|
|
<< "; font-style:" << (face.Italic ? "italic" : "normal") << "; }\n";
|
|
}
|
|
}
|
|
css << "body { margin:0; width:100%; height:100%; background-color:" << Color(document.BackgroundColor, document.BackgroundAlpha) << "; }\n"
|
|
<< ".mcui { position:absolute; box-sizing:border-box; }\n"
|
|
<< ".mcui-button:focus, .mcui-input:focus, .mcui-list:focus { outline:2px #4da3ff; }\n"
|
|
<< ".mcui-button:disabled, .mcui-input:disabled { opacity:0.45; }\n"
|
|
<< "#mcui-font-prewarm { position:absolute; left:-10000px; top:-10000px; opacity:0; }\n"
|
|
<< ".mcui-scroll { overflow:auto; }\n.mcui-list { overflow:auto; }\n";
|
|
for (const auto& node : document.Nodes) {
|
|
const auto& rt = node.RectTransform;
|
|
const float leftOffset = rt.Position.x - rt.Pivot.x * rt.Size.x;
|
|
const float topOffset = rt.Position.y - rt.Pivot.y * rt.Size.y;
|
|
const float anchorWidth = (rt.AnchorMax.x - rt.AnchorMin.x) * 100.0F;
|
|
const float anchorHeight = (rt.AnchorMax.y - rt.AnchorMin.y) * 100.0F;
|
|
css << '#' << node.Id << " { left:calc(" << rt.AnchorMin.x * 100.0F << "% + " << leftOffset
|
|
<< "px); top:calc(" << rt.AnchorMin.y * 100.0F << "% + " << topOffset << "px); width:calc("
|
|
<< anchorWidth << "% + " << rt.Size.x << "px); height:calc(" << anchorHeight << "% + " << rt.Size.y
|
|
<< "px); min-width:" << rt.MinSize.x << "px; min-height:" << rt.MinSize.y << "px; max-width:"
|
|
<< rt.MaxSize.x << "px; max-height:" << rt.MaxSize.y << "px; color:" << Color(node.Style.TextColor)
|
|
<< "; background-color:" << Color(node.Style.BackgroundColor, node.Style.Opacity) << "; font-size:"
|
|
<< node.Style.FontSize << "px; padding:" << node.Style.Padding.y << "px " << node.Style.Padding.x
|
|
<< "px; border-radius:" << node.Style.BorderRadius << "px; overflow:"
|
|
<< (rt.ClipChildren ? "hidden" : (node.Type == MetaCoreUiNodeType::Scroll || node.Type == MetaCoreUiNodeType::List ? "auto" : "visible"))
|
|
<< ';';
|
|
if (const auto atlas = std::find_if(resources.AtlasEntries.begin(), resources.AtlasEntries.end(),
|
|
[&](const MetaCoreUiCompileResources::AtlasEntry& entry) {
|
|
return node.Type == MetaCoreUiNodeType::Image && entry.AssetGuid == node.Style.ImageAssetGuid;
|
|
}); atlas != resources.AtlasEntries.end())
|
|
css << " decorator:image(" << atlas->AssetGuid.ToString() << ");";
|
|
css << " }\n";
|
|
for (const MetaCoreUiAnimationDocument& animation : node.Animations) {
|
|
const std::string animationName = "mcui-" + node.Id + '-' + animation.Id;
|
|
css << "@keyframes " << animationName << " { from { " << AnimationPropertyName(animation.Property)
|
|
<< ':' << AnimationValue(animation.Property, animation.From) << "; } to { "
|
|
<< AnimationPropertyName(animation.Property) << ':' << AnimationValue(animation.Property, animation.To)
|
|
<< "; } }\n";
|
|
std::string selector = "#" + node.Id;
|
|
switch (animation.Trigger) {
|
|
case MetaCoreUiAnimationTrigger::Show: selector += ":not([hidden])"; break;
|
|
case MetaCoreUiAnimationTrigger::Hide: selector += "[hidden]"; break;
|
|
case MetaCoreUiAnimationTrigger::Hover: selector += ":hover"; break;
|
|
case MetaCoreUiAnimationTrigger::Pressed: selector += ":active"; break;
|
|
case MetaCoreUiAnimationTrigger::Focus: selector += ":focus"; break;
|
|
case MetaCoreUiAnimationTrigger::Custom: selector += ".animate-" + animation.Id; break;
|
|
}
|
|
css << selector << " { animation:" << animationName << ' ' << animation.DurationSeconds << "s "
|
|
<< EasingName(animation.Easing) << ' ' << animation.DelaySeconds << "s both; }\n";
|
|
}
|
|
}
|
|
if (resources.Theme.has_value()) {
|
|
const auto& theme = *resources.Theme;
|
|
for (const auto& style : theme.Styles) {
|
|
if (style.Token.empty()) continue;
|
|
css << "body[data-metacore-theme=\"" << Escape(theme.Name) << "\"] .theme-" << style.Token
|
|
<< ThemeStateSelector(style.State) << " { background-color:" << Color(style.BackgroundColor, style.Opacity)
|
|
<< "; color:" << Color(style.TextColor) << "; border-radius:" << style.BorderRadius
|
|
<< "px; font-size:" << style.FontSize << "px; }\n";
|
|
}
|
|
}
|
|
if (resources.FontFamily.has_value()) {
|
|
css << "body { font-family:\"" << Escape(resources.FontFamily->FamilyName) << "\"";
|
|
for (const std::string& fallback : resources.FontFamily->FallbackFamilies)
|
|
css << ",\"" << Escape(fallback) << "\"";
|
|
css << "; }\n";
|
|
}
|
|
if (resources.Localization.has_value()) {
|
|
std::vector<const MetaCoreUiLocalizationLocaleDocument*> locales;
|
|
for (const auto& locale : resources.Localization->Locales) locales.push_back(&locale);
|
|
std::sort(locales.begin(), locales.end(), [](const auto* lhs, const auto* rhs) { return lhs->Locale < rhs->Locale; });
|
|
std::ostringstream localization;
|
|
localization << "{\"default\":\"" << EscapeJson(resources.Localization->DefaultLocale)
|
|
<< "\",\"fallback\":\"" << EscapeJson(resources.Localization->FallbackLocale)
|
|
<< "\",\"locales\":{";
|
|
bool firstLocale = true;
|
|
for (const auto* locale : locales) {
|
|
if (!firstLocale) localization << ',';
|
|
firstLocale = false;
|
|
localization << '"' << EscapeJson(locale->Locale) << "\":{";
|
|
std::vector<const MetaCoreUiLocalizationEntryDocument*> entries;
|
|
for (const auto& entry : locale->Entries) entries.push_back(&entry);
|
|
std::sort(entries.begin(), entries.end(), [](const auto* lhs, const auto* rhs) { return lhs->Key < rhs->Key; });
|
|
bool firstEntry = true;
|
|
for (const auto* entry : entries) {
|
|
if (!firstEntry) localization << ',';
|
|
firstEntry = false;
|
|
localization << '"' << EscapeJson(entry->Key) << "\":\"" << EscapeJson(entry->Value) << '"';
|
|
}
|
|
localization << '}';
|
|
}
|
|
localization << "}}";
|
|
result.LocalizationJson = localization.str();
|
|
}
|
|
result.Rml = rml.str();
|
|
result.Rcss = css.str();
|
|
result.Succeeded = true;
|
|
return result;
|
|
}
|
|
|
|
MetaCoreUiCanvasLayout MetaCoreComputeUiCanvasLayout(
|
|
const MetaCoreUiDocument& document,
|
|
const MetaCoreUiCanvasEnvironment& environment
|
|
) {
|
|
MetaCoreUiCanvasLayout result;
|
|
const float fullWidth = std::max(1.0F, environment.Width);
|
|
const float fullHeight = std::max(1.0F, environment.Height);
|
|
result.ViewportLeft = document.UseSafeArea ? std::max(0.0F, environment.SafeInsetLeft) : 0.0F;
|
|
result.ViewportTop = document.UseSafeArea ? std::max(0.0F, environment.SafeInsetTop) : 0.0F;
|
|
result.ViewportWidth = std::max(1.0F, fullWidth - result.ViewportLeft -
|
|
(document.UseSafeArea ? std::max(0.0F, environment.SafeInsetRight) : 0.0F));
|
|
result.ViewportHeight = std::max(1.0F, fullHeight - result.ViewportTop -
|
|
(document.UseSafeArea ? std::max(0.0F, environment.SafeInsetBottom) : 0.0F));
|
|
|
|
float aspect = result.ViewportWidth / result.ViewportHeight;
|
|
if (document.MinAspectRatio > 0.0F && aspect < document.MinAspectRatio) {
|
|
const float constrainedHeight = result.ViewportWidth / document.MinAspectRatio;
|
|
result.ViewportTop += (result.ViewportHeight - constrainedHeight) * 0.5F;
|
|
result.ViewportHeight = constrainedHeight;
|
|
} else if (document.MaxAspectRatio > 0.0F && aspect > document.MaxAspectRatio) {
|
|
const float constrainedWidth = result.ViewportHeight * document.MaxAspectRatio;
|
|
result.ViewportLeft += (result.ViewportWidth - constrainedWidth) * 0.5F;
|
|
result.ViewportWidth = constrainedWidth;
|
|
}
|
|
|
|
switch (document.ScaleMode) {
|
|
case MetaCoreUiCanvasScaleMode::ConstantPixelSize:
|
|
result.Scale = 1.0F;
|
|
break;
|
|
case MetaCoreUiCanvasScaleMode::ScaleWithScreenSize: {
|
|
const float widthScale = result.ViewportWidth / static_cast<float>(std::max(1, document.ReferenceWidth));
|
|
const float heightScale = result.ViewportHeight / static_cast<float>(std::max(1, document.ReferenceHeight));
|
|
const float match = std::clamp(document.MatchWidthOrHeight, 0.0F, 1.0F);
|
|
result.Scale = std::exp2(std::log2(std::max(widthScale, 0.0001F)) * (1.0F - match) +
|
|
std::log2(std::max(heightScale, 0.0001F)) * match);
|
|
break;
|
|
}
|
|
case MetaCoreUiCanvasScaleMode::ConstantPhysicalSize:
|
|
result.Scale = std::max(1.0F, environment.Dpi) / std::max(1.0F, document.ReferenceDpi);
|
|
break;
|
|
}
|
|
result.Scale = std::clamp(result.Scale, std::max(0.0001F, document.MinScale), std::max(document.MinScale, document.MaxScale));
|
|
result.LogicalWidth = result.ViewportWidth / result.Scale;
|
|
result.LogicalHeight = result.ViewportHeight / result.Scale;
|
|
return result;
|
|
}
|
|
|
|
std::optional<std::string> MetaCoreResolveUiLocalizedText(
|
|
const MetaCoreUiLocalizationTableDocument& table,
|
|
std::string_view key,
|
|
std::string_view locale,
|
|
std::string_view explicitFallbackLocale
|
|
) {
|
|
const auto lookup = [&](std::string_view requested) -> std::optional<std::string> {
|
|
if (requested.empty()) return std::nullopt;
|
|
const auto foundLocale = std::find_if(table.Locales.begin(), table.Locales.end(), [&](const auto& value) {
|
|
return value.Locale == requested;
|
|
});
|
|
if (foundLocale == table.Locales.end()) return std::nullopt;
|
|
const auto foundEntry = std::find_if(foundLocale->Entries.begin(), foundLocale->Entries.end(), [&](const auto& value) {
|
|
return value.Key == key;
|
|
});
|
|
return foundEntry == foundLocale->Entries.end() ? std::nullopt : std::optional<std::string>(foundEntry->Value);
|
|
};
|
|
std::vector<std::string_view> chain{locale, explicitFallbackLocale, table.FallbackLocale, table.DefaultLocale};
|
|
for (const std::string_view candidate : chain)
|
|
if (const auto value = lookup(candidate); value.has_value()) return value;
|
|
return std::nullopt;
|
|
}
|
|
|
|
MetaCoreUiWorldSurfaceLayout MetaCoreComputeUiWorldSurfaceLayout(
|
|
glm::vec3 worldSize,
|
|
float pixelsPerUnit,
|
|
std::int32_t maximumTextureSize
|
|
) {
|
|
MetaCoreUiWorldSurfaceLayout result;
|
|
result.PixelsPerUnit = std::max(1.0F, pixelsPerUnit);
|
|
const auto maximum = std::max(1, maximumTextureSize);
|
|
result.TargetWidth = static_cast<std::int32_t>(std::clamp(
|
|
std::round(std::max(0.001F, worldSize.x) * result.PixelsPerUnit), 1.0F, static_cast<float>(maximum)));
|
|
result.TargetHeight = static_cast<std::int32_t>(std::clamp(
|
|
std::round(std::max(0.001F, worldSize.y) * result.PixelsPerUnit), 1.0F, static_cast<float>(maximum)));
|
|
return result;
|
|
}
|
|
|
|
std::optional<glm::vec2> MetaCoreMapWorldUiHitToUv(
|
|
const glm::mat4& worldMatrix,
|
|
glm::vec3 worldSize,
|
|
glm::vec3 worldHitPosition
|
|
) {
|
|
if (worldSize.x <= 0.0F || worldSize.y <= 0.0F) return std::nullopt;
|
|
const glm::vec4 local = glm::inverse(worldMatrix) * glm::vec4(worldHitPosition, 1.0F);
|
|
const glm::vec2 uv{
|
|
local.x / worldSize.x + 0.5F,
|
|
0.5F - local.y / worldSize.y
|
|
};
|
|
constexpr float tolerance = 0.0001F;
|
|
if (uv.x < -tolerance || uv.y < -tolerance || uv.x > 1.0F + tolerance || uv.y > 1.0F + tolerance)
|
|
return std::nullopt;
|
|
return glm::clamp(uv, glm::vec2(0.0F), glm::vec2(1.0F));
|
|
}
|
|
|
|
} // namespace MetaCore
|