Changes
diff --git a/prd.md b/prd.md
index 03fa7c5..ba57ddf 100644
--- a/prd.md
+++ b/prd.md
@@ -43,6 +43,7 @@ platform.
system. The operator must install ImageMagick with Magick++ and JPEG, PNG,
WebP, and AVIF support.
- HTML is rendered on the server with Inja templates.
+- Refer to `styling.md` for design directions.
## Games and series
diff --git a/src/app.cpp b/src/app.cpp
index bb5cb91..696e5c0 100644
--- a/src/app.cpp
+++ b/src/app.cpp
@@ -174,6 +174,31 @@ void respondInternalError(App::Response& response)
"text/html; charset=utf-8");
}
+void respondNotFound(App::Response& response)
+{
+ response.status = 404;
+ response.set_content(
+ "<!doctype html><title>Card not found</title>"
+ "<h1>Card not found</h1>",
+ "text/html; charset=utf-8");
+}
+
+bool isRegularFile(const std::filesystem::path& path, std::int64_t card_id)
+{
+ std::error_code filesystem_error;
+ const bool exists = std::filesystem::is_regular_file(
+ path, filesystem_error);
+ if(filesystem_error &&
+ filesystem_error != std::errc::no_such_file_or_directory)
+ {
+ spdlog::warn(
+ "Failed to inspect an asset for card {}: {}",
+ card_id,
+ filesystem_error.message());
+ }
+ return exists;
+}
+
} // namespace
App::App(
@@ -221,6 +246,163 @@ App::App(
arguments);
});
card_index_template_ = templates_.parse_template("card_index.html");
+ card_view_template_ = templates_.parse_template("card_view.html");
+}
+
+void App::handleCardView(
+ const Request& request,
+ Response& response)
+{
+ const auto id_parameter = request.path_params.find("id");
+ if(id_parameter == request.path_params.end())
+ {
+ respondNotFound(response);
+ return;
+ }
+
+ auto identity = parsePublicId(id_parameter->second);
+ if(!identity)
+ {
+ respondNotFound(response);
+ return;
+ }
+ auto card_result = data_source_->getCard(*identity);
+ if(!card_result)
+ {
+ spdlog::error(
+ "Failed to load card {}: {}",
+ id_parameter->second,
+ card_result.error().msg());
+ respondInternalError(response);
+ return;
+ }
+ if(!*card_result)
+ {
+ respondNotFound(response);
+ return;
+ }
+ const Card& card = **card_result;
+
+ auto public_id_result = formatPublicId(card.identity);
+ if(!public_id_result)
+ {
+ spdlog::error(
+ "Failed to format card {}: {}",
+ card.id,
+ public_id_result.error().msg());
+ respondInternalError(response);
+ return;
+ }
+ const std::string& public_id = *public_id_result;
+ const std::filesystem::path asset_root =
+ config_.card_storage_root / "published" / public_id;
+ const std::string front_name = "front." + card.front_extension;
+ const bool front_exists = isRegularFile(
+ asset_root / front_name, card.id);
+ const std::string front_url = front_exists
+ ? urlFor(
+ "card-asset",
+ {public_id + "/" + front_name},
+ {{"v", std::to_string(card.revision)}})
+ : urlFor("static", {"card_placeholder.svg"});
+
+ std::optional<std::string> foil_url;
+ bool foil_exists = false;
+ if(card.foil_extension)
+ {
+ const std::string foil_name = "foil." + *card.foil_extension;
+ foil_exists = isRegularFile(asset_root / foil_name, card.id);
+ if(foil_exists)
+ {
+ foil_url = urlFor(
+ "card-asset",
+ {public_id + "/" + foil_name},
+ {{"v", std::to_string(card.revision)}});
+ }
+ }
+
+ auto membership_result = data_source_->getCardSeries(card.id);
+ if(!membership_result)
+ {
+ spdlog::error(
+ "Failed to load series for card {}: {}",
+ card.id,
+ membership_result.error().msg());
+ respondInternalError(response);
+ return;
+ }
+ inja::json series = inja::json::array();
+ for(std::int64_t series_id : *membership_result)
+ {
+ auto series_result = data_source_->getSeries(series_id);
+ if(!series_result)
+ {
+ spdlog::error(
+ "Failed to load series {} for card {}: {}",
+ series_id,
+ card.id,
+ series_result.error().msg());
+ respondInternalError(response);
+ return;
+ }
+ if(*series_result)
+ {
+ series.push_back((**series_result).name);
+ }
+ }
+
+ std::vector<std::string> missing_assets;
+ if(!front_exists)
+ {
+ missing_assets.emplace_back("front artwork");
+ }
+ if(card.foil_extension && !foil_exists)
+ {
+ missing_assets.emplace_back("foil control");
+ }
+
+ const inja::json template_data = {
+ {"asset_warning", !missing_assets.empty()},
+ {"back_url", urlFor("card-index")},
+ {"display_id", uppercaseAscii(public_id)},
+ {"foil_url", foil_url.value_or("")},
+ {"front_url", front_url},
+ {"game", card.identity.game_short_name
+ ? uppercaseAscii(*card.identity.game_short_name)
+ : "Loose card"},
+ {"has_foil", card.foil_extension.has_value()},
+ {"has_long_description", card.long_description.has_value()},
+ {"has_short_description", card.short_description.has_value()},
+ {"long_description", card.long_description.value_or("")},
+ {"missing_assets", missing_assets},
+ {"model_url", urlFor("static", {"foil/model/card.obj"})},
+ {"name", card.name},
+ {"preview_script_url",
+ urlFor("static", {"foil/card_preview.js"})},
+ {"rarity", card.rarity},
+ {"series", std::move(series)},
+ {"shader_fragment_url",
+ urlFor("static", {"foil/frag-shader.glsl"})},
+ {"shader_vertex_url",
+ urlFor("static", {"foil/vert-shader.glsl"})},
+ {"short_description", card.short_description.value_or("")},
+ {"spectral_lut_url",
+ urlFor("static", {"foil/spectral_xyz.bin"})},
+ {"title", card.name + " · Card Collection"},
+ };
+
+ try
+ {
+ response.status = 200;
+ response.set_content(
+ templates_.render(card_view_template_, template_data),
+ "text/html; charset=utf-8");
+ }
+ catch(const std::exception& error)
+ {
+ spdlog::error("Failed to render card {}: {}", card.id, error.what());
+ respondInternalError(response);
+ }
}
std::string App::urlFor(
@@ -365,6 +547,9 @@ void App::setup()
server.Get(
getPath("card-index"),
std::bind_front(&App::handleCardIndex, this));
+ server.Get(
+ getPath("card", {"id"}),
+ std::bind_front(&App::handleCardView, this));
}
std::string App::getPath(
diff --git a/src/app.h b/src/app.h
index 1df9ccb..8b19d7c 100644
--- a/src/app.h
+++ b/src/app.h
@@ -38,6 +38,9 @@ public:
/// Render the card index.
void handleCardIndex(const Request& request, Response& response);
+ /// Render one read-only card page.
+ void handleCardView(const Request& request, Response& response);
+
private:
/// Register implemented handlers and static mounts.
void setup() override;
@@ -55,4 +58,5 @@ private:
UrlBuilder url_builder_;
inja::Environment templates_;
inja::Template card_index_template_;
+ inja::Template card_view_template_;
};
diff --git a/src/public_id.cpp b/src/public_id.cpp
index eeaf9df..bdc21a7 100644
--- a/src/public_id.cpp
+++ b/src/public_id.cpp
@@ -1,5 +1,6 @@
#include "public_id.h"
+#include <algorithm>
#include <array>
#include <charconv>
#include <cctype>
@@ -32,6 +33,31 @@ bool isDigit(char character)
return std::isdigit(static_cast<unsigned char>(character)) != 0;
}
+bool isLowercaseAlphaNumeric(std::string_view value)
+{
+ return !value.empty() && std::ranges::all_of(
+ value,
+ [](char character)
+ {
+ return (character >= 'a' && character <= 'z') ||
+ (character >= '0' && character <= '9');
+ });
+}
+
+template<typename Number>
+mw::E<Number> parseNumber(std::string_view value, int base)
+{
+ Number number = 0;
+ const auto result = std::from_chars(
+ value.data(), value.data() + value.size(), number, base);
+ if(value.empty() || result.ec != std::errc{} ||
+ result.ptr != value.data() + value.size())
+ {
+ return std::unexpected(mw::runtimeError("Invalid public card ID"));
+ }
+ return number;
+}
+
std::size_t runEnd(std::string_view value, std::size_t begin, bool digit)
{
std::size_t end = begin;
@@ -98,6 +124,57 @@ mw::E<std::string> formatPublicId(const CardIdentity& identity)
return formatNumber(identity.card_number, 36);
}
+mw::E<CardIdentity> parsePublicId(std::string_view public_id)
+{
+ const std::size_t separator = public_id.find('-');
+ CardIdentity identity;
+ if(separator == std::string_view::npos)
+ {
+ if(!isLowercaseAlphaNumeric(public_id))
+ {
+ return std::unexpected(
+ mw::runtimeError("Invalid public card ID"));
+ }
+ auto number = parseNumber<std::uint32_t>(public_id, 36);
+ if(!number)
+ {
+ return std::unexpected(std::move(number.error()));
+ }
+ identity = {std::nullopt, *number};
+ }
+ else
+ {
+ const std::string_view game = public_id.substr(0, separator);
+ const std::string_view number_text = public_id.substr(separator + 1);
+ if(!isLowercaseAlphaNumeric(game) ||
+ number_text.empty() ||
+ number_text.front() == '0' ||
+ !std::ranges::all_of(number_text, isDigit) ||
+ number_text.find('-') != std::string_view::npos)
+ {
+ return std::unexpected(
+ mw::runtimeError("Invalid public card ID"));
+ }
+ auto number = parseNumber<std::int64_t>(number_text, 10);
+ if(!number || *number <= 0)
+ {
+ return std::unexpected(
+ mw::runtimeError("Invalid public card ID"));
+ }
+ identity = {
+ std::string(game),
+ static_cast<std::uint64_t>(*number),
+ };
+ }
+
+ auto canonical_id = formatPublicId(identity);
+ if(!canonical_id || *canonical_id != public_id)
+ {
+ return std::unexpected(mw::runtimeError("Invalid public card ID"));
+ }
+ return identity;
+}
+
bool naturalPublicIdLess(std::string_view left, std::string_view right)
{
std::size_t left_index = 0;
diff --git a/src/public_id.h b/src/public_id.h
index f10067e..552f02a 100644
--- a/src/public_id.h
+++ b/src/public_id.h
@@ -10,5 +10,8 @@
/// Format the canonical lowercase public ID derived from a card identity.
mw::E<std::string> formatPublicId(const CardIdentity& identity);
+/// Parse a canonical lowercase public ID into its persisted identity.
+mw::E<CardIdentity> parsePublicId(std::string_view public_id);
+
/// Compare canonical public IDs using numeric runs as integers.
bool naturalPublicIdLess(std::string_view left, std::string_view right);
diff --git a/static/card_placeholder.svg b/static/card_placeholder.svg
index d049c8f..f58fe7e 100644
--- a/static/card_placeholder.svg
+++ b/static/card_placeholder.svg
@@ -1,18 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 700">
<defs>
<linearGradient id="background" x1="0" y1="0" x2="1" y2="1">
- <stop stop-color="#322a43"/>
- <stop offset="1" stop-color="#17181d"/>
+ <stop stop-color="#ddd0fb"/>
+ <stop offset=".52" stop-color="#f5d5e7"/>
+ <stop offset="1" stop-color="#ccecf9"/>
</linearGradient>
</defs>
<rect width="500" height="700" fill="url(#background)"/>
<rect x="42" y="42" width="416" height="616" rx="22"
- fill="none" stroke="#ffffff" stroke-opacity=".13" stroke-width="3"/>
- <circle cx="250" cy="320" r="72" fill="#ffffff" fill-opacity=".08"/>
+ fill="none" stroke="#7c3aed" stroke-opacity=".16" stroke-width="3"/>
+ <circle cx="250" cy="320" r="72" fill="#ffffff" fill-opacity=".42"/>
<path d="M210 335l28-30 24 24 20-16 32 38H190z"
- fill="#ffffff" fill-opacity=".28"/>
- <text x="250" y="440" text-anchor="middle" fill="#ffffff"
- fill-opacity=".52" font-family="system-ui,sans-serif" font-size="24">
+ fill="#7c3aed" fill-opacity=".42"/>
+ <text x="250" y="440" text-anchor="middle" fill="#332f3a"
+ fill-opacity=".62" font-family="system-ui,sans-serif" font-size="24">
CARD ART
</text>
</svg>
diff --git a/static/css/styles.css b/static/css/styles.css
index ef9c36e..4a989d9 100644
--- a/static/css/styles.css
+++ b/static/css/styles.css
@@ -1,100 +1,274 @@
:root {
- color-scheme: dark;
- font-family: Inter, ui-sans-serif, system-ui, sans-serif;
- background: #101114;
- color: #f4f1ea;
+ color-scheme: light;
+ font-family: "DM Sans", ui-sans-serif, system-ui, sans-serif;
+ color: #332f3a;
+ background: #f4f1fa;
+ --canvas: #f4f1fa;
+ --foreground: #332f3a;
+ --muted: #635f69;
+ --violet: #7c3aed;
+ --violet-light: #a78bfa;
+ --pink: #db2777;
+ --blue: #0ea5e9;
+ --page-gutter: clamp(1.25rem, 3vw, 3rem);
+ --main-top: 9rem;
+ --main-bottom: 7rem;
+ --clay-card-shadow:
+ 16px 16px 32px rgb(160 150 180 / 20%),
+ -10px -10px 24px rgb(255 255 255 / 90%),
+ inset 6px 6px 12px rgb(139 92 246 / 3%),
+ inset -6px -6px 12px rgb(255 255 255 / 100%);
+ --clay-card-shadow-hover:
+ 22px 24px 38px rgb(139 92 246 / 24%),
+ -12px -12px 28px rgb(255 255 255 / 95%),
+ inset 6px 6px 12px rgb(139 92 246 / 5%),
+ inset -6px -6px 12px rgb(255 255 255 / 100%);
+ --clay-button-shadow:
+ 12px 12px 24px rgb(139 92 246 / 30%),
+ -8px -8px 16px rgb(255 255 255 / 40%),
+ inset 4px 4px 8px rgb(255 255 255 / 40%),
+ inset -4px -4px 8px rgb(0 0 0 / 10%);
+ --clay-pressed-shadow:
+ inset 10px 10px 20px #d9d4e3,
+ inset -10px -10px 20px #ffffff;
}
* {
box-sizing: border-box;
}
+html {
+ min-width: 20rem;
+}
+
body {
margin: 0;
min-height: 100vh;
- background:
- radial-gradient(circle at top left, #2e263d 0, transparent 32rem),
- #101114;
+ overflow-x: hidden;
+ background: var(--canvas);
+ color: var(--foreground);
+ font-weight: 500;
+ line-height: 1.625;
}
a {
color: inherit;
}
+.ambient-light {
+ position: fixed;
+ z-index: -1;
+ inset: 0;
+ overflow: hidden;
+ pointer-events: none;
+}
+
+.ambient-blob {
+ position: absolute;
+ width: min(60vh, 42rem);
+ height: min(60vh, 42rem);
+ border-radius: 50%;
+ opacity: 0.11;
+ filter: blur(4rem);
+ animation: clay-float 10s ease-in-out infinite;
+}
+
+.ambient-blob-violet {
+ top: -18%;
+ left: -12%;
+ background: var(--violet);
+}
+
+.ambient-blob-pink {
+ top: 22%;
+ right: -14%;
+ background: var(--pink);
+ animation-name: clay-float-delayed;
+ animation-delay: -3s;
+}
+
+.ambient-blob-blue {
+ bottom: -24%;
+ left: 28%;
+ background: var(--blue);
+ animation-duration: 12s;
+ animation-delay: -6s;
+}
+
.site-header {
+ position: fixed;
+ z-index: 100;
+ top: 1.5rem;
+ right: var(--page-gutter);
+ left: var(--page-gutter);
display: flex;
align-items: center;
justify-content: space-between;
- padding: 1.25rem clamp(1.25rem, 4vw, 4rem);
- border-bottom: 1px solid #ffffff1f;
+ width: auto;
+ min-height: 5rem;
+ margin: 0;
+ padding: 0.8rem 1rem 0.8rem 1.6rem;
+ border: 1px solid rgb(255 255 255 / 75%);
+ border-radius: 2.5rem;
+ background: rgb(255 255 255 / 68%);
+ box-shadow:
+ 18px 18px 36px rgb(160 150 180 / 18%),
+ -12px -12px 30px rgb(255 255 255 / 90%),
+ inset 5px 5px 10px rgb(139 92 246 / 3%),
+ inset -5px -5px 10px rgb(255 255 255 / 90%);
+ backdrop-filter: blur(1.25rem);
}
.site-title {
- font-size: 1.1rem;
- font-weight: 750;
+ font-family: Nunito, ui-rounded, sans-serif;
+ font-size: 1.2rem;
+ font-weight: 900;
+ letter-spacing: -0.025em;
text-decoration: none;
}
nav {
display: flex;
- gap: 1.25rem;
+ align-items: center;
+ gap: 0.25rem;
+}
+
+.nav-link {
+ display: inline-flex;
+ min-height: 2.75rem;
+ align-items: center;
+ justify-content: center;
+ padding: 0.65rem 1rem;
+ border-radius: 1.25rem;
+ color: var(--muted);
+ font-weight: 700;
+ text-decoration: none;
+ transition:
+ color 200ms ease,
+ background 200ms ease,
+ box-shadow 200ms ease,
+ transform 200ms ease;
+}
+
+.nav-link:hover {
+ color: var(--violet);
+ background: rgb(124 58 237 / 8%);
+ transform: translateY(-0.15rem);
}
-nav a,
-.sort-controls a {
- color: #cbc5d6;
- text-underline-offset: 0.3rem;
+.nav-link:active {
+ transform: scale(0.92);
+ box-shadow: var(--clay-pressed-shadow);
+}
+
+.nav-link-primary {
+ margin-left: 0.35rem;
+ padding-inline: 1.25rem;
+ background: linear-gradient(145deg, var(--violet-light), var(--violet));
+ color: #ffffff;
+ box-shadow: var(--clay-button-shadow);
+}
+
+.nav-link-primary:hover {
+ background: linear-gradient(145deg, #b7a1fb, #6d28d9);
+ color: #ffffff;
+ transform: translateY(-0.25rem);
}
main {
- width: min(90rem, 100%);
- margin: 0 auto;
- padding: 3rem clamp(1.25rem, 4vw, 4rem) 5rem;
+ width: 100%;
+ min-height: 100vh;
+ min-height: 100svh;
+ margin: 0;
+ padding:
+ var(--main-top)
+ var(--page-gutter)
+ var(--main-bottom);
}
.page-heading {
display: flex;
align-items: end;
justify-content: space-between;
- gap: 2rem;
- margin-bottom: 2rem;
+ gap: 2.5rem;
+ margin-bottom: 3.25rem;
}
.eyebrow {
- margin: 0 0 0.4rem;
- color: #b9a5db;
+ margin: 0 0 0.25rem;
+ color: var(--violet);
font-size: 0.75rem;
- font-weight: 750;
- letter-spacing: 0.14em;
+ font-weight: 700;
+ letter-spacing: 0.16em;
text-transform: uppercase;
}
h1 {
margin: 0;
- font-size: clamp(2.25rem, 6vw, 4.5rem);
- letter-spacing: -0.055em;
+ font-family: Nunito, ui-rounded, sans-serif;
+ font-size: clamp(3rem, 8vw, 5.5rem);
+ font-weight: 900;
+ line-height: 1.05;
+ letter-spacing: -0.06em;
}
.sort-controls {
display: flex;
flex-wrap: wrap;
- gap: 0.75rem;
+ align-items: center;
+ gap: 0.4rem;
+ padding: 0.55rem;
+ border-radius: 1.5rem;
+ background: #efebf5;
+ box-shadow: var(--clay-pressed-shadow);
font-size: 0.9rem;
}
-.sort-controls span {
- color: #77727f;
+.sort-label {
+ padding: 0 0.65rem;
+ color: var(--muted);
+ font-size: 0.78rem;
+ font-weight: 700;
+}
+
+.sort-button {
+ display: inline-flex;
+ min-height: 2.75rem;
+ align-items: center;
+ padding: 0.55rem 0.9rem;
+ border-radius: 1.1rem;
+ color: var(--muted);
+ font-weight: 700;
+ text-decoration: none;
+ transition:
+ color 200ms ease,
+ box-shadow 200ms ease,
+ transform 200ms ease;
+}
+
+.sort-button:hover {
+ color: var(--violet);
+ transform: translateY(-0.12rem);
+}
+
+.sort-button:active {
+ transform: scale(0.92);
}
-.sort-controls [aria-current="page"] {
+.sort-button[aria-current="page"] {
+ background: linear-gradient(145deg, var(--violet-light), var(--violet));
color: #ffffff;
- font-weight: 700;
+ box-shadow:
+ 7px 7px 14px rgb(139 92 246 / 24%),
+ -5px -5px 12px rgb(255 255 255 / 60%),
+ inset 3px 3px 6px rgb(255 255 255 / 35%),
+ inset -3px -3px 6px rgb(0 0 0 / 8%);
}
.card-grid {
display: grid;
- grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr));
- gap: clamp(1rem, 2.5vw, 2rem);
+ grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr));
+ gap: clamp(1.5rem, 3vw, 2.5rem);
margin: 0;
padding: 0;
list-style: none;
@@ -102,16 +276,33 @@ h1 {
.card-tile {
overflow: hidden;
- border: 1px solid #ffffff1a;
- border-radius: 0.85rem;
- background: #191a1f;
- box-shadow: 0 1rem 2.5rem #00000035;
+ border: 1px solid rgb(255 255 255 / 85%);
+ border-radius: 2rem;
+ background: rgb(255 255 255 / 72%);
+ box-shadow: var(--clay-card-shadow);
+ backdrop-filter: blur(1.25rem);
+ transition:
+ box-shadow 500ms ease,
+ transform 500ms cubic-bezier(0.2, 0.8, 0.2, 1);
+}
+
+.card-tile:hover {
+ box-shadow: var(--clay-card-shadow-hover);
+ transform: translateY(-0.5rem) scale(1.01);
}
.card-image-link {
display: block;
aspect-ratio: 5 / 7;
- background: #24212b;
+ margin: 0.75rem;
+ overflow: hidden;
+ border-radius: 1.5rem;
+ background: #e9e2f2;
+ box-shadow:
+ inset 7px 7px 14px rgb(174 164 190 / 20%),
+ inset -7px -7px 14px rgb(255 255 255 / 85%),
+ 4px 4px 10px rgb(139 92 246 / 8%),
+ -3px -3px 8px rgb(255 255 255 / 70%);
}
.card-image-link img {
@@ -119,39 +310,439 @@ h1 {
width: 100%;
height: 100%;
object-fit: cover;
+ transition: transform 500ms cubic-bezier(0.2, 0.8, 0.2, 1);
+}
+
+.card-tile:hover .card-image-link img {
+ transform: scale(1.025);
}
.card-details {
- padding: 1rem;
+ padding: 0.2rem 1.35rem 1.5rem;
}
.card-id {
- color: #b9a5db;
+ display: inline-flex;
+ min-height: 2.75rem;
+ align-items: center;
+ margin-left: -0.5rem;
+ padding: 0.35rem 0.6rem;
+ border-radius: 1rem;
+ color: var(--violet);
font-family: ui-monospace, monospace;
font-size: 0.8rem;
font-weight: 700;
letter-spacing: 0.06em;
text-decoration: none;
+ transition:
+ background 200ms ease,
+ transform 200ms ease;
+}
+
+.card-id:hover {
+ background: rgb(124 58 237 / 8%);
+ transform: translateY(-0.1rem);
+}
+
+.card-id:active {
+ transform: scale(0.92);
}
.card-details h2 {
- margin: 0.35rem 0 0;
- font-size: 1rem;
+ margin: 0.1rem 0 0;
+ font-family: Nunito, ui-rounded, sans-serif;
+ font-size: 1.15rem;
+ font-weight: 800;
+ line-height: 1.3;
}
.empty-state {
- min-height: 14rem;
display: grid;
+ min-height: 16rem;
place-items: center;
- border: 1px dashed #ffffff26;
- border-radius: 0.85rem;
- color: #918b98;
+ padding: 2rem;
+ border-radius: 2rem;
+ background: #efebf5;
+ box-shadow: var(--clay-pressed-shadow);
+ color: var(--muted);
+ font-weight: 700;
+}
+
+.card-view {
+ position: relative;
+ min-height: 100vh;
+ min-height: 100svh;
+ margin:
+ calc(0rem - var(--main-top))
+ calc(0rem - var(--page-gutter))
+ calc(0rem - var(--main-bottom));
+ overflow: hidden;
+}
+
+.card-preview-stage {
+ position: absolute;
+ inset: 0;
+ display: grid;
+ min-height: 100%;
+ overflow: hidden;
+ background:
+ radial-gradient(circle at 22% 16%,
+ rgb(255 255 255 / 80%), transparent 35%),
+ linear-gradient(145deg, #ede7f8, #e2d9f2);
+ isolation: isolate;
+}
+
+.card-preview-stage::before,
+.card-preview-stage::after {
+ position: absolute;
+ z-index: -1;
+ width: 16rem;
+ height: 16rem;
+ border-radius: 50%;
+ content: "";
+ filter: blur(3.5rem);
+ opacity: 0.22;
+}
+
+.card-preview-stage::before {
+ top: -5rem;
+ right: -4rem;
+ background: var(--pink);
+}
+
+.card-preview-stage::after {
+ bottom: -5rem;
+ left: -4rem;
+ background: var(--blue);
+}
+
+.preview-heading {
+ position: absolute;
+ z-index: 2;
+ top: var(--main-top);
+ right: calc(var(--page-gutter) + 31rem);
+ left: var(--page-gutter);
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 1rem;
+ pointer-events: none;
+}
+
+.preview-heading .eyebrow {
+ margin: 0;
+}
+
+.preview-status,
+.preview-hint {
+ margin: 0;
+ border: 1px solid rgb(255 255 255 / 70%);
+ border-radius: 1rem;
+ background: rgb(255 255 255 / 64%);
+ box-shadow:
+ 7px 7px 16px rgb(134 119 158 / 16%),
+ -5px -5px 14px rgb(255 255 255 / 70%),
+ inset 3px 3px 7px rgb(139 92 246 / 3%),
+ inset -3px -3px 7px rgb(255 255 255 / 75%);
+ color: var(--muted);
+ font-size: 0.78rem;
+ font-weight: 700;
+}
+
+.preview-status {
+ padding: 0.35rem 0.65rem;
+}
+
+.preview-status[data-kind="success"] {
+ color: var(--violet);
+}
+
+.preview-status[data-kind="error"] {
+ color: #a21d53;
+}
+
+#GLCanvas {
+ display: block;
+ width: calc(100% - 30rem);
+ height: 100%;
+ min-height: 100vh;
+ min-height: 100svh;
+ cursor: move;
+ touch-action: none;
+}
+
+.preview-hint {
+ position: absolute;
+ right: calc(var(--page-gutter) + 31rem);
+ bottom: 1.5rem;
+ left: var(--page-gutter);
+ width: fit-content;
+ max-width: calc(100% - 3rem);
+ margin-inline: auto;
+ padding: 0.5rem 0.8rem;
+ text-align: center;
+ pointer-events: none;
+}
+
+.card-info-panel {
+ position: fixed;
+ z-index: 40;
+ top: var(--main-top);
+ right: var(--page-gutter);
+ bottom: 1.5rem;
+ width: min(
+ 29rem,
+ calc(100% - var(--page-gutter) - var(--page-gutter)));
+ overflow: auto;
+ padding: clamp(1.5rem, 4vw, 2.5rem);
+ border: 1px solid rgb(255 255 255 / 82%);
+ border-radius: 2.5rem;
+ background: rgb(255 255 255 / 70%);
+ box-shadow: var(--clay-card-shadow);
+ backdrop-filter: blur(1.25rem);
+}
+
+.back-link {
+ display: inline-flex;
+ min-height: 2.75rem;
+ align-items: center;
+ margin: -0.5rem 0 1.5rem -0.65rem;
+ padding: 0.4rem 0.65rem;
+ border-radius: 1rem;
+ color: var(--muted);
+ font-weight: 700;
+ text-decoration: none;
+ transition: color 200ms ease, transform 200ms ease;
+}
+
+.back-link:hover {
+ color: var(--violet);
+ transform: translateX(-0.2rem);
+}
+
+.card-view-id {
+ margin: 0 0 0.4rem;
+ color: var(--violet);
+ font-family: ui-monospace, monospace;
+ font-size: 0.82rem;
+ font-weight: 800;
+ letter-spacing: 0.08em;
+}
+
+.card-info-panel h1 {
+ overflow-wrap: anywhere;
+ font-size: clamp(2.6rem, 6vw, 4.5rem);
+}
+
+.asset-warning {
+ display: grid;
+ gap: 0.2rem;
+ margin-top: 1.75rem;
+ padding: 1rem 1.1rem;
+ border-radius: 1.35rem;
+ background: #fde7f0;
+ box-shadow:
+ 8px 8px 18px rgb(190 90 130 / 13%),
+ -6px -6px 15px rgb(255 255 255 / 72%),
+ inset 3px 3px 8px rgb(190 90 130 / 5%),
+ inset -3px -3px 8px rgb(255 255 255 / 65%);
+ color: #812044;
+ font-size: 0.88rem;
+}
+
+.card-metadata {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 0.75rem;
+ margin: 2rem 0 0;
+}
+
+.card-metadata div {
+ min-width: 0;
+ padding: 0.9rem;
+ border-radius: 1.25rem;
+ background: #efebf5;
+ box-shadow: var(--clay-pressed-shadow);
+}
+
+.card-metadata dt {
+ color: var(--muted);
+ font-size: 0.68rem;
+ font-weight: 800;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+.card-metadata dd {
+ margin: 0.2rem 0 0;
+ overflow-wrap: anywhere;
+ font-family: Nunito, ui-rounded, sans-serif;
+ font-size: 0.95rem;
+ font-weight: 800;
+}
+
+.card-info-section {
+ margin-top: 2rem;
+ padding-top: 1.75rem;
+ border-top: 1px solid rgb(99 95 105 / 12%);
+}
+
+.card-info-section h2 {
+ margin: 0 0 0.75rem;
+ font-family: Nunito, ui-rounded, sans-serif;
+ font-size: 1rem;
+ font-weight: 900;
+}
+
+.muted-copy,
+.description-copy {
+ margin: 0;
+}
+
+.muted-copy {
+ color: var(--muted);
+}
+
+.description-copy {
+ white-space: pre-wrap;
+}
+
+.series-list {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.55rem;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.series-list li {
+ padding: 0.45rem 0.75rem;
+ border-radius: 1rem;
+ background: rgb(124 58 237 / 9%);
+ color: var(--violet);
+ font-size: 0.82rem;
+ font-weight: 800;
+}
+
+:focus-visible {
+ outline: 0.25rem solid rgb(124 58 237 / 30%);
+ outline-offset: 0.2rem;
+}
+
+@keyframes clay-float {
+ 0%,
+ 100% {
+ transform: translateY(0) rotate(0deg);
+ }
+
+ 50% {
+ transform: translateY(-1.25rem) rotate(2deg);
+ }
+}
+
+@keyframes clay-float-delayed {
+ 0%,
+ 100% {
+ transform: translateY(0) rotate(0deg);
+ }
+
+ 50% {
+ transform: translateY(-0.95rem) rotate(-2deg);
+ }
}
@media(max-width: 42rem) {
+ :root {
+ --main-top: 13rem;
+ }
+
+ .site-header {
+ min-height: auto;
+ padding: 1rem;
+ border-radius: 2rem;
+ }
+
.site-header,
.page-heading {
align-items: flex-start;
flex-direction: column;
}
+
+ nav {
+ width: 100%;
+ flex-wrap: wrap;
+ }
+
+ .nav-link {
+ flex: 1;
+ padding-inline: 0.7rem;
+ }
+
+ .nav-link-primary {
+ flex-basis: 100%;
+ margin: 0.35rem 0 0;
+ }
+
+ .page-heading {
+ margin-bottom: 2.5rem;
+ }
+
+ .sort-controls {
+ width: 100%;
+ }
+
+ .sort-label {
+ flex-basis: 100%;
+ padding-bottom: 0.2rem;
+ }
+
+ .sort-button {
+ flex: 1;
+ justify-content: center;
+ }
+
+ .card-grid {
+ grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
+ }
+
+ .card-metadata {
+ grid-template-columns: 1fr;
+ }
+}
+
+@media(max-width: 62rem) {
+ .preview-heading {
+ right: var(--page-gutter);
+ }
+
+ #GLCanvas {
+ width: 100%;
+ }
+
+ .preview-hint {
+ right: var(--page-gutter);
+ bottom: calc(44svh + 2rem);
+ }
+
+ .card-info-panel {
+ top: auto;
+ right: 1rem;
+ bottom: 1rem;
+ left: 1rem;
+ width: auto;
+ max-height: 44vh;
+ max-height: 44svh;
+ }
+}
+
+@media(prefers-reduced-motion: reduce) {
+ *,
+ *::before,
+ *::after {
+ scroll-behavior: auto !important;
+ animation-duration: 0.01ms !important;
+ animation-iteration-count: 1 !important;
+ transition-duration: 0.01ms !important;
+ }
}
diff --git a/static/foil/card_preview.js b/static/foil/card_preview.js
new file mode 100644
index 0000000..0beb3bd
--- /dev/null
+++ b/static/foil/card_preview.js
@@ -0,0 +1,241 @@
+/** Material mode added by Card Collection for artwork without foil. */
+const CARD_ARTWORK_MATERIAL_KIND = 2;
+
+/** Load and parse one OBJ resource during preview initialization. */
+function loadModel(url)
+{
+ const request = new XMLHttpRequest();
+ request.open("GET", url, false);
+ request.send(null);
+ if(request.status != 200 && request.status != 0)
+ {
+ throw(new Error(`Failed to load ${url}: HTTP ${request.status}`));
+ }
+ return parseOBJ(request.responseText);
+}
+
+/** Shade a card front with its artwork and no foil contribution. */
+class ArtworkMaterial
+{
+ /** Load the front artwork used by a plain card. */
+ constructor(gl, artwork_url)
+ {
+ this.gl = gl;
+ this.artwork = new Texture(gl, artwork_url, {flip_y: true});
+ this.ready = this.artwork.ready;
+ }
+
+ /** Select plain artwork shading and bind the artwork texture. */
+ use(program)
+ {
+ this.gl.uniform1i(
+ program.uniform("u_material_kind"),
+ CARD_ARTWORK_MATERIAL_KIND);
+ this.artwork.use(program, "u_artwork", 0);
+ }
+
+ /** Release the artwork texture. */
+ dispose()
+ {
+ this.artwork.dispose();
+ }
+}
+
+/** Construct shared front and shell materials and partitioned models. */
+function initScene(gl, program, obj_model, page_data)
+{
+ const front_material = page_data.foil_url == null
+ ? new ArtworkMaterial(gl, page_data.front_url)
+ : new PhysicalFoilMaterial(
+ gl,
+ page_data.front_url,
+ page_data.foil_url,
+ page_data.spectral_lut_url);
+ const shell_material = new SolidColorMaterial(gl, [0.5, 0.5, 0.5]);
+ const models = [];
+ let front_triangle_count = 0;
+ let shell_triangle_count = 0;
+ for(const geometry of obj_model.geometries)
+ {
+ const partition = partitionCardGeometry(geometry, CARD_MESH_LAYOUT);
+ const front_vertices = partition.front.data.position == null
+ ? 0
+ : partition.front.data.position.length / 3;
+ const shell_vertices = partition.shell.data.position == null
+ ? 0
+ : partition.shell.data.position.length / 3;
+ if(front_vertices > 0)
+ {
+ models.push(new Model(
+ gl, program, partition.front, front_material));
+ front_triangle_count += front_vertices / 3;
+ }
+ if(shell_vertices > 0)
+ {
+ models.push(new Model(
+ gl, program, partition.shell, shell_material));
+ shell_triangle_count += shell_vertices / 3;
+ }
+ }
+ if(front_triangle_count == 0 || shell_triangle_count == 0)
+ {
+ throw(new Error(
+ "Card model must contain both front and shell triangles."));
+ }
+ return {
+ scene: new Scene(models),
+ front_material,
+ };
+}
+
+/** Build the projection, view, and interactive card-model matrices. */
+function calculateMatrices(canvas, camera_rotation)
+{
+ const mat4 = glMatrix.mat4;
+ const aspect = canvas.clientWidth / canvas.clientHeight;
+ const projection_matrix = mat4.perspective(
+ mat4.create(), Math.PI / 8.0, aspect, 0.1, 100.0);
+ const view_matrix = mat4.translate(
+ mat4.create(), mat4.create(), [0.0, 0.0, -5.0]);
+ const model_matrix = mat4.create();
+ mat4.rotateX(model_matrix, model_matrix, Math.PI / 2.0);
+ mat4.rotateX(model_matrix, model_matrix, camera_rotation[0]);
+ mat4.rotateZ(model_matrix, model_matrix, camera_rotation[1]);
+ return {projection_matrix, view_matrix, model_matrix};
+}
+
+/** Normalize a pointer position around the center of the short canvas axis. */
+function getNormalizedMousePos(move_event, canvas)
+{
+ const rect = canvas.getBoundingClientRect();
+ const size = Math.min(rect.width, rect.height);
+ const mouse_x = (move_event.clientX - rect.left
+ - (rect.width - size) * 0.5) / size - 0.5;
+ const mouse_y = (rect.height - (move_event.clientY - rect.top)
+ - (rect.height - size) * 0.5) / size - 0.5;
+ return [mouse_x, mouse_y];
+}
+
+/** Smoothly bound an unbounded pointer coordinate to a half turn. */
+function asymptoticBound(value)
+{
+ return Math.atan(value) / Math.PI;
+}
+
+/** Internal compile-time shader variants for renderer quality. */
+const RENDER_QUALITY = Object.freeze({
+ LOW: Object.freeze({
+ LIGHT_SAMPLE_COUNT: 2,
+ ORDER_COUNT: 3,
+ SPECTRAL_TAP_COUNT: 1,
+ }),
+ DEFAULT: Object.freeze({
+ LIGHT_SAMPLE_COUNT: 4,
+ ORDER_COUNT: 4,
+ SPECTRAL_TAP_COUNT: 9,
+ }),
+ HIGH: Object.freeze({
+ LIGHT_SAMPLE_COUNT: 8,
+ ORDER_COUNT: 8,
+ SPECTRAL_TAP_COUNT: 9,
+ }),
+});
+
+const FOIL_CALIBRATION = Object.freeze({
+ FOIL_INTENSITY: 0.5,
+});
+
+const OUTPUT_CALIBRATION = Object.freeze({
+ LEVELS_BLACK_POINT: 0.0,
+ LEVELS_WHITE_POINT: 0.88,
+ LEVELS_MIDTONE: 1.0,
+});
+
+/** Read the non-executable page-data block emitted by the server. */
+function readCardPageData()
+{
+ return JSON.parse(document.getElementById("CardPageData").textContent);
+}
+
+/** Update the accessible card-preview status. */
+function showPreviewStatus(message, kind)
+{
+ const status = document.getElementById("PreviewStatus");
+ if(status != null)
+ {
+ status.textContent = message;
+ status.dataset.kind = kind;
+ }
+}
+
+/** Initialize and run the physical card preview copied from foil. */
+function main()
+{
+ try
+ {
+ const page_data = readCardPageData();
+ const canvas = document.getElementById("GLCanvas");
+ const gl = canvas.getContext("webgl2", {alpha: true});
+ if(!gl)
+ {
+ throw(new Error("Failed to initialize WebGL 2."));
+ }
+
+ const shader_definitions = Object.assign(
+ {}, RENDER_QUALITY.DEFAULT,
+ FOIL_CALIBRATION, OUTPUT_CALIBRATION);
+ const program = new ShaderProgram(
+ gl,
+ page_data.vertex_shader_url,
+ page_data.fragment_shader_url,
+ shader_definitions);
+ const renderer = new Renderer(gl, program);
+ const card = initScene(
+ gl,
+ program,
+ loadModel(page_data.model_url),
+ page_data);
+ card.front_material.ready.then(function reportReady()
+ {
+ showPreviewStatus(
+ page_data.foil_url == null
+ ? "Standard finish"
+ : "Foil finish",
+ "success");
+ }).catch(function reportTextureFailure(error)
+ {
+ console.error(error);
+ showPreviewStatus(error.message, "error");
+ });
+
+ const light = new DiskLight(
+ [0, 0, 2.0],
+ [0, 0, -2.0],
+ 1,
+ 3.6);
+ const camera_rotation = [0.0, 0.0];
+
+ /** Draw one animation frame using the latest pointer rotation. */
+ function render()
+ {
+ const matrices = calculateMatrices(canvas, camera_rotation);
+ renderer.draw(card.scene, matrices, light);
+ requestAnimationFrame(render);
+ }
+
+ canvas.addEventListener("mousemove", function rotateCard(event)
+ {
+ const position = getNormalizedMousePos(event, canvas);
+ camera_rotation[0] = -asymptoticBound(position[1] * 10.0);
+ camera_rotation[1] = -asymptoticBound(position[0] * 10.0);
+ });
+ requestAnimationFrame(render);
+ }
+ catch(error)
+ {
+ console.error(error);
+ showPreviewStatus(error.message, "error");
+ }
+}
+
+window.addEventListener("load", main);
diff --git a/static/foil/foil_math.js b/static/foil/foil_math.js
new file mode 100644
index 0000000..22e15b4
--- /dev/null
+++ b/static/foil/foil_math.js
@@ -0,0 +1,473 @@
+// CPU reference equations for the physically based foil shader.
+
+const GROOVE_SPACING_MIN_UM = 0.55;
+const GROOVE_SPACING_MAX_UM = 3.20;
+const VISIBLE_WAVELENGTH_MIN_UM = 0.380;
+const VISIBLE_WAVELENGTH_MAX_UM = 0.780;
+const ZERO_ORDER_ENERGY = 0.20;
+const TOTAL_DIFFRACTION_ENERGY = 0.12;
+const TRANSMITTED_PRINT_ENERGY = 0.60;
+const ABSORBED_ENERGY = 0.08;
+const ORDER_DECAY = 3.00;
+const SPECTRAL_SAMPLE_COUNT = 401;
+const MAX_GRATING_TILT_RADIANS = Math.PI / 12.0;
+const FOIL_DISORDER = 1.0;
+
+/** Decode one sRGB component into linear light. */
+function srgbToLinear(value)
+{
+ if(value <= 0.04045)
+ {
+ return value / 12.92;
+ }
+ return Math.pow((value + 0.055) / 1.055, 2.4);
+}
+
+/** Encode one nonnegative linear-light component as sRGB. */
+function linearToSrgb(value)
+{
+ if(value <= 0.0031308)
+ {
+ return 12.92 * value;
+ }
+ return 1.055 * Math.pow(value, 1.0 / 2.4) - 0.055;
+}
+
+/** Apply display-referred input Levels to one encoded component. */
+function applyLevels(value, black_point, white_point, midtone)
+{
+ if(!Number.isFinite(black_point) || !Number.isFinite(white_point)
+ || !Number.isFinite(midtone) || black_point < 0.0
+ || white_point > 1.0 || black_point >= white_point
+ || midtone <= 0.0)
+ {
+ throw(new Error("Invalid output Levels calibration."));
+ }
+ const normalized = Math.min(Math.max(
+ (value - black_point) / (white_point - black_point), 0.0), 1.0);
+ return Math.pow(normalized, 1.0 / midtone);
+}
+
+/** Convert D65 CIE XYZ values to linear sRGB. */
+function xyzToLinearSrgb(xyz)
+{
+ return [
+ 3.24096994 * xyz[0] - 1.53738318 * xyz[1]
+ - 0.49861076 * xyz[2],
+ -0.96924364 * xyz[0] + 1.87596750 * xyz[1]
+ + 0.04155506 * xyz[2],
+ 0.05563008 * xyz[0] - 0.20397696 * xyz[1]
+ + 1.05697151 * xyz[2],
+ ];
+}
+
+/** Return the real cube root of a possibly negative scalar. */
+function signedCubeRoot(value)
+{
+ return Math.sign(value) * Math.pow(Math.abs(value), 1.0 / 3.0);
+}
+
+/** Convert linear sRGB components to OKLab. */
+function linearSrgbToOklab(linear_rgb)
+{
+ const lms = [
+ 0.4122214708 * linear_rgb[0]
+ + 0.5363325363 * linear_rgb[1]
+ + 0.0514459929 * linear_rgb[2],
+ 0.2119034982 * linear_rgb[0]
+ + 0.6806995451 * linear_rgb[1]
+ + 0.1073969566 * linear_rgb[2],
+ 0.0883024619 * linear_rgb[0]
+ + 0.2817188376 * linear_rgb[1]
+ + 0.6299787005 * linear_rgb[2],
+ ];
+ const root_lms = lms.map(signedCubeRoot);
+ return [
+ 0.2104542553 * root_lms[0] + 0.7936177850 * root_lms[1]
+ - 0.0040720468 * root_lms[2],
+ 1.9779984951 * root_lms[0] - 2.4285922050 * root_lms[1]
+ + 0.4505937099 * root_lms[2],
+ 0.0259040371 * root_lms[0] + 0.7827717662 * root_lms[1]
+ - 0.8086757660 * root_lms[2],
+ ];
+}
+
+/** Convert OKLab components to linear sRGB without gamut clipping. */
+function oklabToLinearSrgb(lab)
+{
+ const root_lms = [
+ lab[0] + 0.3963377774 * lab[1] + 0.2158037573 * lab[2],
+ lab[0] - 0.1055613458 * lab[1] - 0.0638541728 * lab[2],
+ lab[0] - 0.0894841775 * lab[1] - 1.2914855480 * lab[2],
+ ];
+ const lms = root_lms.map(function cube(value)
+ {
+ return value * value * value;
+ });
+ return [
+ 4.0767416621 * lms[0] - 3.3077115913 * lms[1]
+ + 0.2309699292 * lms[2],
+ -1.2684380046 * lms[0] + 2.6097574011 * lms[1]
+ - 0.3413193965 * lms[2],
+ -0.0041960863 * lms[0] - 0.7034186147 * lms[1]
+ + 1.7076147010 * lms[2],
+ ];
+}
+
+/** Return whether a linear RGB color lies inside the sRGB display cube. */
+function isInSrgbGamut(linear_rgb)
+{
+ return linear_rgb.every(function testChannel(value)
+ {
+ return value >= 0.0 && value <= 1.0;
+ });
+}
+
+/** Apply the shader's fixed-lightness OKLab chroma compression. */
+function perceptualGamutMap(linear_rgb)
+{
+ const lab = linearSrgbToOklab(linear_rgb);
+ lab[0] = Math.min(Math.max(lab[0], 0.0), 1.0);
+ const chroma = Math.hypot(lab[1], lab[2]);
+ if(chroma < 1e-5)
+ {
+ return oklabToLinearSrgb([lab[0], 0.0, 0.0]);
+ }
+
+ const hue = [lab[1] / chroma, lab[2] / chroma];
+ let lower_chroma = 0.0;
+ let upper_chroma = 0.5;
+ for(let iteration = 0; iteration < 10; ++iteration)
+ {
+ const candidate_chroma = 0.5 * (lower_chroma + upper_chroma);
+ const candidate = oklabToLinearSrgb([
+ lab[0], hue[0] * candidate_chroma,
+ hue[1] * candidate_chroma,
+ ]);
+ if(isInSrgbGamut(candidate))
+ {
+ lower_chroma = candidate_chroma;
+ }
+ else
+ {
+ upper_chroma = candidate_chroma;
+ }
+ }
+
+ const knee_chroma = 0.55 * lower_chroma;
+ const target_chroma = 0.85 * lower_chroma;
+ let mapped_chroma = chroma;
+ if(chroma > knee_chroma)
+ {
+ const compression_range = Math.max(
+ target_chroma - knee_chroma, 1e-5);
+ const excess = (chroma - knee_chroma) / compression_range;
+ mapped_chroma = knee_chroma + compression_range
+ * (1.0 - Math.exp(-excess));
+ }
+ return oklabToLinearSrgb([
+ lab[0], hue[0] * mapped_chroma, hue[1] * mapped_chroma,
+ ]).map(function clampChannel(value)
+ {
+ return Math.min(Math.max(value, 0.0), 1.0);
+ });
+}
+
+/** Decode normalized red into physical groove spacing in micrometers. */
+function decodeGrooveSpacing(encoded_spacing)
+{
+ return GROOVE_SPACING_MAX_UM * Math.pow(
+ GROOVE_SPACING_MIN_UM / GROOVE_SPACING_MAX_UM,
+ encoded_spacing);
+}
+
+/** Decode normalized green into an unoriented two-dimensional axis. */
+function decodeGratingAxis(encoded_orientation)
+{
+ const angle = Math.PI * encoded_orientation;
+ return [Math.cos(angle), Math.sin(angle)];
+}
+
+/** Interpolate unoriented axes with doubled-angle circular arithmetic. */
+function interpolateGratingAxis(encoded_values, weights)
+{
+ let axis_x = 0.0;
+ let axis_y = 0.0;
+ for(let i = 0; i < encoded_values.length; ++i)
+ {
+ const doubled_angle = 2.0 * Math.PI * encoded_values[i];
+ axis_x += weights[i] * Math.cos(doubled_angle);
+ axis_y += weights[i] * Math.sin(doubled_angle);
+ }
+
+ const length = Math.hypot(axis_x, axis_y);
+ if(length < 1e-8)
+ {
+ return decodeGratingAxis(encoded_values[0]);
+ }
+
+ const angle = 0.5 * Math.atan2(axis_y / length, axis_x / length);
+ return [Math.cos(angle), Math.sin(angle)];
+}
+
+/** Decode normalized blue into signed local grating tilt in radians. */
+function decodeGratingTilt(encoded_tilt)
+{
+ return MAX_GRATING_TILT_RADIANS * (2.0 * encoded_tilt - 1.0);
+}
+
+/** Rotate a microscopic normal and grating axis around the groove axis. */
+function tiltGratingFrame(normal, grating_axis, tilt)
+{
+ const cosine = Math.cos(tilt);
+ const sine = Math.sin(tilt);
+ return {
+ normal: normal.map(function tiltNormal(value, index)
+ {
+ return cosine * value - sine * grating_axis[index];
+ }),
+ grating: grating_axis.map(function tiltGrating(value, index)
+ {
+ return cosine * value + sine * normal[index];
+ }),
+ };
+}
+
+/** Calculate the wavelength selected by one diffraction-order magnitude. */
+function diffractionWavelength(spacing_um, order_coordinate, order)
+{
+ return spacing_um * Math.abs(order_coordinate) / order;
+}
+
+/** Project incident and outgoing directions into a local grating frame. */
+function tangentProjection(light_direction, view_direction, normal,
+ grating_axis, groove_axis)
+{
+ const direction_sum = light_direction.map(
+ function addDirection(value, index)
+ {
+ return value + view_direction[index];
+ });
+ const normal_component = direction_sum.reduce(
+ function dotNormal(total, value, index)
+ {
+ return total + value * normal[index];
+ }, 0.0);
+ const tangent_sum = direction_sum.map(
+ function removeNormal(value, index)
+ {
+ return value - normal_component * normal[index];
+ });
+
+ return {
+ u: tangent_sum.reduce(function dotGrating(total, value, index)
+ {
+ return total + value * grating_axis[index];
+ }, 0.0),
+ v: tangent_sum.reduce(function dotGroove(total, value, index)
+ {
+ return total + value * groove_axis[index];
+ }, 0.0),
+ };
+}
+
+/** Return normalized positive-order weights for an active order count. */
+function orderWeights(order_count)
+{
+ const weights = [];
+ let weight_sum = 0.0;
+ for(let order = 1; order <= order_count; ++order)
+ {
+ const weight = Math.exp(-ORDER_DECAY * (order - 1));
+ weights.push(weight);
+ weight_sum += weight;
+ }
+ return weights.map(function normalizeOrderWeight(weight)
+ {
+ return weight / weight_sum;
+ });
+}
+
+/** Return the energy allocation at one internal foil-intensity setting. */
+function foilEnergyAllocation(foil_intensity = 1.0)
+{
+ if(!Number.isFinite(foil_intensity) || foil_intensity < 0.0
+ || foil_intensity > 6.0)
+ {
+ throw(new Error("Foil intensity must be between 0.0 and 6.0."));
+ }
+ const diffraction = TOTAL_DIFFRACTION_ENERGY * foil_intensity;
+ const print = TRANSMITTED_PRINT_ENERGY
+ - TOTAL_DIFFRACTION_ENERGY * (foil_intensity - 1.0);
+ return {diffraction, print};
+}
+
+/** Return the energy assigned to one sign of an order. */
+function signedOrderEfficiency(order, order_count, foil_intensity = 1.0)
+{
+ return 0.5 * foilEnergyAllocation(foil_intensity).diffraction
+ * orderWeights(order_count)[order - 1];
+}
+
+/** Return the complete material energy allocation for validation. */
+function energyBudget(order_count, foil_intensity = 1.0)
+{
+ const allocation = foilEnergyAllocation(foil_intensity);
+ let diffraction_energy = 0.0;
+ for(let order = 1; order <= order_count; ++order)
+ {
+ diffraction_energy += 2.0
+ * signedOrderEfficiency(
+ order, order_count, foil_intensity);
+ }
+ return ZERO_ORDER_ENERGY + diffraction_energy
+ + allocation.print + ABSORBED_ENERGY;
+}
+
+/** Decode blue into the cross-groove projected standard deviation. */
+function decodeCrossGrooveWidth(encoded_disorder)
+{
+ const weight = encoded_disorder * encoded_disorder;
+ return 0.008 + (0.16 - 0.008) * weight;
+}
+
+/** Decode blue into relative groove-period standard deviation. */
+function decodePeriodSpread(encoded_disorder)
+{
+ const weight = encoded_disorder * encoded_disorder;
+ return 0.003 + (0.08 - 0.003) * weight;
+}
+
+/** Approximate one-axis angular standard deviation of a distant disk. */
+function diskAngularSigma(radius, distance)
+{
+ return 0.5 * radius / distance;
+}
+
+/** Combine statistically independent material and source widths. */
+function combineWidths(material_width, source_width)
+{
+ return Math.hypot(material_width, source_width);
+}
+
+/** Return wavelength width from material disorder and disk extent. */
+function diffractionWavelengthWidth(wavelength_um, relative_spread,
+ spacing_um, order,
+ source_angular_sigma)
+{
+ return combineWidths(
+ wavelength_um * relative_spread,
+ spacing_um * source_angular_sigma / order);
+}
+
+/** Evaluate the normalized cross-groove Gaussian density. */
+function crossGrooveGaussian(value, width)
+{
+ const normalized = value / width;
+ return Math.exp(-0.5 * normalized * normalized)
+ / (Math.sqrt(2.0 * Math.PI) * width);
+}
+
+/** Manually interpolate one source-weighted XYZ spectral table. */
+function sampleSpectralXyz(table, wavelength_um)
+{
+ if(wavelength_um < VISIBLE_WAVELENGTH_MIN_UM ||
+ wavelength_um > VISIBLE_WAVELENGTH_MAX_UM)
+ {
+ return [0.0, 0.0, 0.0];
+ }
+
+ const position = (wavelength_um - VISIBLE_WAVELENGTH_MIN_UM) * 1000.0;
+ const lower_index = Math.min(Math.floor(position),
+ SPECTRAL_SAMPLE_COUNT - 1);
+ const upper_index = Math.min(lower_index + 1,
+ SPECTRAL_SAMPLE_COUNT - 1);
+ const weight = position - lower_index;
+ const result = [];
+ for(let channel = 0; channel < 3; ++channel)
+ {
+ const lower = table[4 * lower_index + channel];
+ const upper = table[4 * upper_index + channel];
+ result.push(lower + weight * (upper - lower));
+ }
+ return result;
+}
+
+/** Evaluate the isotropic GGX normal distribution. */
+function distributionGgx(normal_half, roughness)
+{
+ const alpha = Math.max(roughness, 0.04);
+ const alpha_squared = alpha * alpha;
+ const denominator = normal_half * normal_half
+ * (alpha_squared - 1.0) + 1.0;
+ return alpha_squared / (Math.PI * denominator * denominator);
+}
+
+/** Evaluate one GGX Smith masking term. */
+function geometryGgx(normal_direction, roughness)
+{
+ const cosine = Math.max(normal_direction, 0.0);
+ const alpha = Math.max(roughness, 0.04);
+ const root = Math.sqrt(alpha * alpha
+ + (1.0 - alpha * alpha) * cosine * cosine);
+ return 2.0 * cosine / Math.max(cosine + root, 1e-8);
+}
+
+/** Evaluate correlated Smith visibility including the BRDF denominator. */
+function visibilitySmithGgx(normal_light, normal_view, roughness)
+{
+ const alpha = Math.max(roughness, 0.04);
+ const alpha_squared = alpha * alpha;
+ const view_term = normal_light * Math.sqrt(
+ normal_view * normal_view * (1.0 - alpha_squared)
+ + alpha_squared);
+ const light_term = normal_view * Math.sqrt(
+ normal_light * normal_light * (1.0 - alpha_squared)
+ + alpha_squared);
+ return 0.5 / Math.max(view_term + light_term, 1e-8);
+}
+
+if(typeof module != "undefined")
+{
+ module.exports = {
+ ABSORBED_ENERGY,
+ FOIL_DISORDER,
+ GROOVE_SPACING_MAX_UM,
+ GROOVE_SPACING_MIN_UM,
+ MAX_GRATING_TILT_RADIANS,
+ TOTAL_DIFFRACTION_ENERGY,
+ TRANSMITTED_PRINT_ENERGY,
+ VISIBLE_WAVELENGTH_MAX_UM,
+ VISIBLE_WAVELENGTH_MIN_UM,
+ ZERO_ORDER_ENERGY,
+ applyLevels,
+ crossGrooveGaussian,
+ decodeCrossGrooveWidth,
+ decodeGratingAxis,
+ decodeGratingTilt,
+ decodeGrooveSpacing,
+ decodePeriodSpread,
+ diffractionWavelengthWidth,
+ diffractionWavelength,
+ diskAngularSigma,
+ distributionGgx,
+ energyBudget,
+ foilEnergyAllocation,
+ geometryGgx,
+ interpolateGratingAxis,
+ isInSrgbGamut,
+ linearToSrgb,
+ linearSrgbToOklab,
+ oklabToLinearSrgb,
+ orderWeights,
+ sampleSpectralXyz,
+ signedOrderEfficiency,
+ srgbToLinear,
+ perceptualGamutMap,
+ tangentProjection,
+ tiltGratingFrame,
+ visibilitySmithGgx,
+ combineWidths,
+ xyzToLinearSrgb,
+ };
+}
diff --git a/static/foil/frag-shader.glsl b/static/foil/frag-shader.glsl
new file mode 100644
index 0000000..99cf32f
--- /dev/null
+++ b/static/foil/frag-shader.glsl
@@ -0,0 +1,770 @@
+#version 300 es
+// -*- mode: c; -*-
+precision highp float;
+
+#ifndef LIGHT_SAMPLE_COUNT
+#define LIGHT_SAMPLE_COUNT 4
+#endif
+#ifndef ORDER_COUNT
+#define ORDER_COUNT 4
+#endif
+#ifndef SPECTRAL_TAP_COUNT
+#define SPECTRAL_TAP_COUNT 9
+#endif
+#ifndef DIFFRACTION_ORDER
+#define DIFFRACTION_ORDER 0
+#endif
+#ifndef FOIL_INTENSITY
+#define FOIL_INTENSITY 1.0
+#endif
+#ifndef LEVELS_BLACK_POINT
+#define LEVELS_BLACK_POINT 0.0
+#endif
+#ifndef LEVELS_WHITE_POINT
+#define LEVELS_WHITE_POINT 1.0
+#endif
+#ifndef LEVELS_MIDTONE
+#define LEVELS_MIDTONE 1.0
+#endif
+
+const float PI = 3.14159265359;
+const float BRDF_EPSILON = 0.00001;
+const float MAX_GRATING_TILT_RADIANS = PI / 12.0;
+const float FOIL_DISORDER = 1.0;
+const float GROOVE_SPACING_MIN_UM = 0.55;
+const float GROOVE_SPACING_MAX_UM = 3.20;
+const float VISIBLE_WAVELENGTH_MIN_UM = 0.380;
+const float VISIBLE_WAVELENGTH_MAX_UM = 0.780;
+const float SPECTRAL_JACOBIAN_SCALE = 1000.0;
+const float FOIL_GGX_ROUGHNESS = 0.16;
+const vec3 FOIL_F_0 = vec3(0.82);
+const float ZERO_ORDER_ENERGY = 0.20;
+const float BASE_TOTAL_DIFFRACTION_ENERGY = 0.12;
+const float BASE_TRANSMITTED_PRINT_ENERGY = 0.60;
+const float FOIL_INTENSITY_VALUE = float(FOIL_INTENSITY);
+const float TOTAL_DIFFRACTION_ENERGY =
+ BASE_TOTAL_DIFFRACTION_ENERGY * FOIL_INTENSITY_VALUE;
+const float SIGNED_DIFFRACTION_ENERGY =
+ 0.5 * TOTAL_DIFFRACTION_ENERGY;
+const float TRANSMITTED_PRINT_ENERGY =
+ BASE_TRANSMITTED_PRINT_ENERGY
+ - BASE_TOTAL_DIFFRACTION_ENERGY * (FOIL_INTENSITY_VALUE - 1.0);
+const float ORDER_DECAY = 3.00;
+const float AMBIENT_PRINT_IRRADIANCE = 0.08;
+const float EXPOSURE = 1.0;
+const float GAMUT_KNEE_FRACTION = 0.55;
+const float GAMUT_TARGET_FRACTION = 0.85;
+const float ARTISTIC_COVERAGE_EXPONENT = 2.2;
+const float LEVELS_BLACK_POINT_VALUE = float(LEVELS_BLACK_POINT);
+const float LEVELS_WHITE_POINT_VALUE = float(LEVELS_WHITE_POINT);
+const float LEVELS_MIDTONE_VALUE = float(LEVELS_MIDTONE);
+const int MATERIAL_PHYSICAL_FOIL = 0;
+const int MATERIAL_SOLID_COLOR = 1;
+const int MATERIAL_ARTWORK = 2;
+#if LIGHT_SAMPLE_COUNT == 2
+const vec2 LIGHT_SAMPLES[LIGHT_SAMPLE_COUNT] = vec2[](
+ vec2(-0.5, -0.5),
+ vec2(0.5, 0.5)
+);
+#elif LIGHT_SAMPLE_COUNT == 4
+const vec2 LIGHT_SAMPLES[LIGHT_SAMPLE_COUNT] = vec2[](
+ vec2(-0.5, -0.5),
+ vec2(0.5, -0.5),
+ vec2(-0.5, 0.5),
+ vec2(0.5, 0.5)
+);
+#elif LIGHT_SAMPLE_COUNT == 8
+const vec2 LIGHT_SAMPLES[LIGHT_SAMPLE_COUNT] = vec2[](
+ vec2(0.5, 0.0),
+ vec2(0.0, 0.5),
+ vec2(-0.5, 0.0),
+ vec2(0.0, -0.5),
+ vec2(0.612372436, 0.612372436),
+ vec2(-0.612372436, 0.612372436),
+ vec2(-0.612372436, -0.612372436),
+ vec2(0.612372436, -0.612372436)
+);
+#elif LIGHT_SAMPLE_COUNT == 16
+// Two staggered rings have zero centroid and mean squared radius 1/2,
+// matching the first two radial moments of a uniform unit disk.
+const vec2 LIGHT_SAMPLES[LIGHT_SAMPLE_COUNT] = vec2[](
+ vec2(0.500000000, 0.000000000),
+ vec2(0.353553391, 0.353553391),
+ vec2(0.000000000, 0.500000000),
+ vec2(-0.353553391, 0.353553391),
+ vec2(-0.500000000, 0.000000000),
+ vec2(-0.353553391, -0.353553391),
+ vec2(0.000000000, -0.500000000),
+ vec2(0.353553391, -0.353553391),
+ vec2(0.800103145, 0.331413574),
+ vec2(0.331413574, 0.800103145),
+ vec2(-0.331413574, 0.800103145),
+ vec2(-0.800103145, 0.331413574),
+ vec2(-0.800103145, -0.331413574),
+ vec2(-0.331413574, -0.800103145),
+ vec2(0.331413574, -0.800103145),
+ vec2(0.800103145, -0.331413574)
+);
+#else
+#error Unsupported LIGHT_SAMPLE_COUNT
+#endif
+
+in vec2 v_texcoord;
+in vec3 v_view_position;
+in vec3 v_view_normal;
+in vec3 v_view_tangent;
+in vec3 v_view_bitangent;
+
+uniform sampler2D u_artwork;
+uniform sampler2D u_foil_control;
+uniform sampler2D u_spectral_xyz;
+uniform int u_material_kind;
+uniform vec3 u_solid_color_srgb;
+uniform vec3 u_light_position;
+uniform vec3 u_light_normal;
+uniform vec3 u_light_axis_x;
+uniform vec3 u_light_axis_y;
+uniform float u_light_radius;
+uniform float u_light_radiance;
+
+out vec4 out_color;
+
+struct FoilControl
+{
+ float groove_spacing_um;
+ vec2 grating_axis;
+ float grating_tilt;
+ float coverage;
+};
+
+// Construct finite controls that select only the ordinary printed layer.
+FoilControl noFoilControl()
+{
+ FoilControl control;
+ control.groove_spacing_um = GROOVE_SPACING_MIN_UM;
+ control.grating_axis = vec2(1.0, 0.0);
+ control.grating_tilt = 0.0;
+ control.coverage = 0.0;
+ return control;
+}
+
+// Decode display-referred artwork into the linear-light working space.
+vec3 srgbToLinear(vec3 encoded)
+{
+ vec3 lower = encoded / 12.92;
+ vec3 upper = pow((encoded + 0.055) / 1.055, vec3(2.4));
+ return mix(upper, lower, lessThanEqual(encoded, vec3(0.04045)));
+}
+
+// Encode a tone-mapped linear-light value for the default sRGB framebuffer.
+vec3 linearToSrgb(vec3 linear_color)
+{
+ vec3 lower = 12.92 * linear_color;
+ vec3 upper = 1.055 * pow(linear_color, vec3(1.0 / 2.4)) - 0.055;
+ return mix(upper, lower,
+ lessThanEqual(linear_color, vec3(0.0031308)));
+}
+
+// Apply display-referred input Levels after the physical color pipeline.
+// Midtone follows image-editor convention: values above one brighten.
+vec3 applyLevels(vec3 encoded_color)
+{
+ float level_range = max(
+ LEVELS_WHITE_POINT_VALUE - LEVELS_BLACK_POINT_VALUE,
+ BRDF_EPSILON);
+ vec3 normalized = clamp(
+ (encoded_color - LEVELS_BLACK_POINT_VALUE) / level_range,
+ 0.0, 1.0);
+ return pow(normalized, vec3(1.0 / LEVELS_MIDTONE_VALUE));
+}
+
+// Compress HDR luminance while preserving chromaticity for gamut mapping.
+vec3 toneMap(vec3 hdr_color)
+{
+ float luminance = dot(hdr_color, vec3(0.2126, 0.7152, 0.0722));
+ if(luminance <= 0.0)
+ {
+ return vec3(0.0);
+ }
+ float mapped_luminance = 1.0 - exp(-EXPOSURE * luminance);
+ return hdr_color * mapped_luminance / luminance;
+}
+
+// Convert D65 CIE XYZ tristimulus values into linear sRGB.
+vec3 xyzToLinearSrgb(vec3 xyz)
+{
+ const mat3 XYZ_TO_SRGB = mat3(
+ 3.24096994, -0.96924364, 0.05563008,
+ -1.53738318, 1.87596750, -0.20397696,
+ -0.49861076, 0.04155506, 1.05697151
+ );
+ return XYZ_TO_SRGB * xyz;
+}
+
+// Cube root with defined behavior for out-of-gamut negative LMS values.
+float signedCubeRoot(float value)
+{
+ return sign(value) * pow(abs(value), 1.0 / 3.0);
+}
+
+// Convert linear sRGB to the perceptually uniform OKLab space.
+vec3 linearSrgbToOklab(vec3 linear_rgb)
+{
+ vec3 lms = vec3(
+ 0.4122214708 * linear_rgb.r
+ + 0.5363325363 * linear_rgb.g
+ + 0.0514459929 * linear_rgb.b,
+ 0.2119034982 * linear_rgb.r
+ + 0.6806995451 * linear_rgb.g
+ + 0.1073969566 * linear_rgb.b,
+ 0.0883024619 * linear_rgb.r
+ + 0.2817188376 * linear_rgb.g
+ + 0.6299787005 * linear_rgb.b
+ );
+ vec3 root_lms = vec3(
+ signedCubeRoot(lms.x),
+ signedCubeRoot(lms.y),
+ signedCubeRoot(lms.z));
+ return vec3(
+ 0.2104542553 * root_lms.x
+ + 0.7936177850 * root_lms.y
+ - 0.0040720468 * root_lms.z,
+ 1.9779984951 * root_lms.x
+ - 2.4285922050 * root_lms.y
+ + 0.4505937099 * root_lms.z,
+ 0.0259040371 * root_lms.x
+ + 0.7827717662 * root_lms.y
+ - 0.8086757660 * root_lms.z
+ );
+}
+
+// Convert OKLab back to linear sRGB without clipping its chroma.
+vec3 oklabToLinearSrgb(vec3 lab)
+{
+ vec3 root_lms = vec3(
+ lab.x + 0.3963377774 * lab.y + 0.2158037573 * lab.z,
+ lab.x - 0.1055613458 * lab.y - 0.0638541728 * lab.z,
+ lab.x - 0.0894841775 * lab.y - 1.2914855480 * lab.z
+ );
+ vec3 lms = root_lms * root_lms * root_lms;
+ return vec3(
+ 4.0767416621 * lms.x - 3.3077115913 * lms.y
+ + 0.2309699292 * lms.z,
+ -1.2684380046 * lms.x + 2.6097574011 * lms.y
+ - 0.3413193965 * lms.z,
+ -0.0041960863 * lms.x - 0.7034186147 * lms.y
+ + 1.7076147010 * lms.z
+ );
+}
+
+// Return whether a linear color lies inside the display cube.
+bool isInSrgbGamut(vec3 linear_rgb)
+{
+ return all(greaterThanEqual(linear_rgb, vec3(0.0)))
+ && all(lessThanEqual(linear_rgb, vec3(1.0)));
+}
+
+// Compress OKLab chroma at fixed perceived lightness and hue. A binary search
+// finds the sRGB cusp for this hue; the soft knee maps extreme spectral colors
+// to 85% of that cusp instead of clipping them to neon display primaries.
+vec3 perceptualGamutMap(vec3 linear_rgb)
+{
+ vec3 lab = linearSrgbToOklab(linear_rgb);
+ lab.x = clamp(lab.x, 0.0, 1.0);
+ float chroma = length(lab.yz);
+ if(chroma < BRDF_EPSILON)
+ {
+ return oklabToLinearSrgb(vec3(lab.x, 0.0, 0.0));
+ }
+
+ vec2 hue = lab.yz / chroma;
+ float lower_chroma = 0.0;
+ float upper_chroma = 0.5;
+ for(int iteration = 0; iteration < 10; ++iteration)
+ {
+ float candidate_chroma = 0.5 * (lower_chroma + upper_chroma);
+ vec3 candidate = oklabToLinearSrgb(
+ vec3(lab.x, hue * candidate_chroma));
+ if(isInSrgbGamut(candidate))
+ {
+ lower_chroma = candidate_chroma;
+ }
+ else
+ {
+ upper_chroma = candidate_chroma;
+ }
+ }
+
+ float gamut_chroma = lower_chroma;
+ float knee_chroma = GAMUT_KNEE_FRACTION * gamut_chroma;
+ float target_chroma = GAMUT_TARGET_FRACTION * gamut_chroma;
+ float mapped_chroma = chroma;
+ if(chroma > knee_chroma)
+ {
+ float compression_range = max(
+ target_chroma - knee_chroma, BRDF_EPSILON);
+ float excess = (chroma - knee_chroma) / compression_range;
+ mapped_chroma = knee_chroma
+ + compression_range * (1.0 - exp(-excess));
+ }
+ return clamp(oklabToLinearSrgb(
+ vec3(lab.x, hue * mapped_chroma)), 0.0, 1.0);
+}
+
+// Introduce spectral gamut compression continuously with foil coverage. The
+// early return avoids the expensive OKLab search for ordinary card regions,
+// while mix() makes its limiting value equal the non-foil rendering.
+vec3 applyFoilGamutMap(vec3 linear_rgb, float coverage)
+{
+ vec3 ordinary_rgb = clamp(linear_rgb, 0.0, 1.0);
+ if(coverage <= 0.0)
+ {
+ return ordinary_rgb;
+ }
+ vec3 mapped_rgb = perceptualGamutMap(linear_rgb);
+ return mix(ordinary_rgb, mapped_rgb, clamp(coverage, 0.0, 1.0));
+}
+
+// Normalize a vector without allowing a zero-length interpolation to produce
+// NaNs. Callers choose a fallback appropriate to their geometric role.
+vec3 safeNormalize(vec3 value, vec3 fallback)
+{
+ float length_squared = dot(value, value);
+ if(length_squared < BRDF_EPSILON)
+ {
+ return fallback;
+ }
+ return value * inversesqrt(length_squared);
+}
+
+// Construct a stable tangent for the rare case where interpolation cancels a
+// vertex tangent exactly.
+vec3 orthogonalVector(vec3 normal)
+{
+ vec3 reference = abs(normal.z) < 0.999
+ ? vec3(0.0, 0.0, 1.0)
+ : vec3(0.0, 1.0, 0.0);
+ return safeNormalize(cross(reference, normal), vec3(1.0, 0.0, 0.0));
+}
+
+// Interpolate four scalar texels with one two-dimensional weight.
+float bilinear(float value_00, float value_10, float value_01,
+ float value_11, vec2 weight)
+{
+ float lower = mix(value_00, value_10, weight.x);
+ float upper = mix(value_01, value_11, weight.x);
+ return mix(lower, upper, weight.y);
+}
+
+// Map the unoriented green value onto its doubled-angle unit circle.
+vec2 doubledOrientation(float encoded_orientation)
+{
+ float doubled_angle = 2.0 * PI * encoded_orientation;
+ return vec2(cos(doubled_angle), sin(doubled_angle));
+}
+
+// A physical area-coverage field would mix the ordinary and foil BRDFs
+// linearly, making reflected energy proportional to the stored alpha. Tone
+// mapping and sRGB encoding make small linear foil energies look much stronger
+// than artists expect. For a representative full-coverage linear foil peak of
+// 0.84, the unmodified display pipeline produces approximately:
+//
+// Coverage Linear foil sRGB display value
+// 1.00 0.840 0.78
+// 0.10 0.084 0.32
+// 0.01 0.0084 0.09
+//
+// Ten percent of the physical foil energy can therefore still appear about
+// forty percent as bright numerically. Treat alpha as an artistic control and
+// apply a display-inspired response curve so partial values fade faster. This
+// deliberately breaks energy conservation with respect to the authored
+// coverage value: the stored alpha is no longer a literal physical area
+// fraction. The endpoint BRDFs remain energy bounded because the effective
+// coverage still lies in [0, 1].
+float decodeArtisticCoverage(float encoded_coverage)
+{
+ return pow(clamp(encoded_coverage, 0.0, 1.0),
+ ARTISTIC_COVERAGE_EXPONENT);
+}
+
+// Decode the version-2 packed control texture with orientation-safe filtering.
+FoilControl sampleFoilControl(vec2 texture_coord)
+{
+ ivec2 texture_size = textureSize(u_foil_control, 0);
+ vec2 sample_position = clamp(texture_coord, 0.0, 1.0)
+ * vec2(texture_size) - 0.5;
+ ivec2 lower_coord = ivec2(floor(sample_position));
+ ivec2 upper_bound = texture_size - ivec2(1);
+ vec2 weight = fract(sample_position);
+
+ ivec2 coord_00 = clamp(lower_coord, ivec2(0), upper_bound);
+ ivec2 coord_10 = clamp(lower_coord + ivec2(1, 0),
+ ivec2(0), upper_bound);
+ ivec2 coord_01 = clamp(lower_coord + ivec2(0, 1),
+ ivec2(0), upper_bound);
+ ivec2 coord_11 = clamp(lower_coord + ivec2(1, 1),
+ ivec2(0), upper_bound);
+ vec4 value_00 = texelFetch(u_foil_control, coord_00, 0);
+ vec4 value_10 = texelFetch(u_foil_control, coord_10, 0);
+ vec4 value_01 = texelFetch(u_foil_control, coord_01, 0);
+ vec4 value_11 = texelFetch(u_foil_control, coord_11, 0);
+
+ float encoded_spacing = bilinear(
+ value_00.r, value_10.r, value_01.r, value_11.r, weight);
+ float encoded_tilt = bilinear(
+ value_00.b, value_10.b, value_01.b, value_11.b, weight);
+ float coverage = bilinear(
+ value_00.a, value_10.a, value_01.a, value_11.a, weight);
+
+ vec2 lower_axis = mix(doubledOrientation(value_00.g),
+ doubledOrientation(value_10.g), weight.x);
+ vec2 upper_axis = mix(doubledOrientation(value_01.g),
+ doubledOrientation(value_11.g), weight.x);
+ vec2 doubled_axis = mix(lower_axis, upper_axis, weight.y);
+ if(dot(doubled_axis, doubled_axis) < BRDF_EPSILON)
+ {
+ doubled_axis = doubledOrientation(value_00.g);
+ }
+ doubled_axis = normalize(doubled_axis);
+ float grating_angle = 0.5 * atan(doubled_axis.y, doubled_axis.x);
+
+ FoilControl control;
+ control.groove_spacing_um = GROOVE_SPACING_MAX_UM * pow(
+ GROOVE_SPACING_MIN_UM / GROOVE_SPACING_MAX_UM,
+ encoded_spacing);
+ control.grating_axis = vec2(cos(grating_angle), sin(grating_angle));
+ control.grating_tilt = MAX_GRATING_TILT_RADIANS
+ * (2.0 * clamp(encoded_tilt, 0.0, 1.0) - 1.0);
+ control.coverage = decodeArtisticCoverage(coverage);
+ return control;
+}
+
+// Interpolate the source-weighted CIE table without float texture filtering.
+vec3 sampleSpectralXyz(float wavelength_um)
+{
+ if(wavelength_um < VISIBLE_WAVELENGTH_MIN_UM ||
+ wavelength_um > VISIBLE_WAVELENGTH_MAX_UM)
+ {
+ return vec3(0.0);
+ }
+
+ float position = (wavelength_um - VISIBLE_WAVELENGTH_MIN_UM) * 1000.0;
+ int lower_index = min(int(floor(position)), 400);
+ int upper_index = min(lower_index + 1, 400);
+ float weight = position - float(lower_index);
+ vec3 lower = texelFetch(u_spectral_xyz, ivec2(lower_index, 0), 0).rgb;
+ vec3 upper = texelFetch(u_spectral_xyz, ivec2(upper_index, 0), 0).rgb;
+ return mix(lower, upper, weight);
+}
+
+// Evaluate the isotropic GGX normal distribution.
+float distributionGgx(float normal_half)
+{
+ float alpha_squared = FOIL_GGX_ROUGHNESS * FOIL_GGX_ROUGHNESS;
+ float denominator = normal_half * normal_half
+ * (alpha_squared - 1.0) + 1.0;
+ return alpha_squared
+ / max(PI * denominator * denominator, BRDF_EPSILON);
+}
+
+// Evaluate height-correlated Smith visibility including the BRDF denominator.
+float visibilitySmithGgx(float normal_light, float normal_view)
+{
+ float alpha_squared = FOIL_GGX_ROUGHNESS * FOIL_GGX_ROUGHNESS;
+ float view_term = normal_light * sqrt(
+ normal_view * normal_view * (1.0 - alpha_squared)
+ + alpha_squared);
+ float light_term = normal_view * sqrt(
+ normal_light * normal_light * (1.0 - alpha_squared)
+ + alpha_squared);
+ return 0.5 / max(view_term + light_term, BRDF_EPSILON);
+}
+
+// Approximate neutral coating Fresnel reflectance.
+vec3 fresnelSchlick(float view_half)
+{
+ return FOIL_F_0 + (vec3(1.0) - FOIL_F_0)
+ * pow(1.0 - view_half, 5.0);
+}
+
+// Evaluate the ordinary zeroth-order reflection BRDF.
+vec3 evaluateSpecularBrdf(vec3 light_direction, vec3 view_direction,
+ vec3 normal)
+{
+ vec3 half_sum = light_direction + view_direction;
+ if(dot(half_sum, half_sum) < BRDF_EPSILON)
+ {
+ return vec3(0.0);
+ }
+ vec3 half_vector = normalize(half_sum);
+ float normal_light = max(dot(normal, light_direction), 0.0);
+ float normal_view = max(dot(normal, view_direction), 0.0);
+ float normal_half = max(dot(normal, half_vector), 0.0);
+ float view_half = max(dot(view_direction, half_vector), 0.0);
+ return distributionGgx(normal_half)
+ * visibilitySmithGgx(normal_light, normal_view)
+ * fresnelSchlick(view_half);
+}
+
+// Evaluate the normalized cross-groove density at the fixed maximum disorder.
+float crossGrooveLobe(float cross_coordinate, float source_sigma)
+{
+ float material_width = mix(
+ 0.008, 0.16, FOIL_DISORDER * FOIL_DISORDER);
+ float width = sqrt(material_width * material_width
+ + source_sigma * source_sigma);
+ float normalized = cross_coordinate / width;
+ return exp(-0.5 * normalized * normalized)
+ / (sqrt(2.0 * PI) * width);
+}
+
+// Convolve the spectrum with the fixed maximum period disorder.
+vec3 sampleDisorderedSpectrum(float wavelength_um, float source_width_um)
+{
+#if SPECTRAL_TAP_COUNT == 1
+ return sampleSpectralXyz(wavelength_um);
+#elif SPECTRAL_TAP_COUNT == 3
+ float relative_spread = mix(
+ 0.003, 0.08, FOIL_DISORDER * FOIL_DISORDER);
+ float material_width_um = wavelength_um * relative_spread;
+ float wavelength_width = sqrt(
+ material_width_um * material_width_um
+ + source_width_um * source_width_um);
+ return 0.25 * sampleSpectralXyz(wavelength_um - wavelength_width)
+ + 0.50 * sampleSpectralXyz(wavelength_um)
+ + 0.25 * sampleSpectralXyz(wavelength_um + wavelength_width);
+#elif SPECTRAL_TAP_COUNT == 9
+ // A normalized binomial kernel approximates a Gaussian without allowing
+ // a broad source to degenerate into three isolated spectral primaries.
+ float relative_spread = mix(
+ 0.003, 0.08, FOIL_DISORDER * FOIL_DISORDER);
+ float material_width_um = wavelength_um * relative_spread;
+ float wavelength_width = sqrt(
+ material_width_um * material_width_um
+ + source_width_um * source_width_um);
+ float step_width = wavelength_width / sqrt(2.0);
+ return (sampleSpectralXyz(wavelength_um - 4.0 * step_width)
+ + 8.0 * sampleSpectralXyz(wavelength_um - 3.0 * step_width)
+ + 28.0 * sampleSpectralXyz(wavelength_um - 2.0 * step_width)
+ + 56.0 * sampleSpectralXyz(wavelength_um - step_width)
+ + 70.0 * sampleSpectralXyz(wavelength_um)
+ + 56.0 * sampleSpectralXyz(wavelength_um + step_width)
+ + 28.0 * sampleSpectralXyz(wavelength_um + 2.0 * step_width)
+ + 8.0 * sampleSpectralXyz(wavelength_um + 3.0 * step_width)
+ + sampleSpectralXyz(wavelength_um + 4.0 * step_width)) / 256.0;
+#else
+#error Unsupported SPECTRAL_TAP_COUNT
+#endif
+}
+
+// Allocate half the nonzero-order energy to the visible signed order.
+float orderEfficiency(int order_index)
+{
+#if DIFFRACTION_ORDER > 0
+ return order_index + 1 == DIFFRACTION_ORDER
+ ? SIGNED_DIFFRACTION_ENERGY
+ : 0.0;
+#else
+ float weight_sum = 0.0;
+ for(int index = 0; index < ORDER_COUNT; ++index)
+ {
+ weight_sum += exp(-ORDER_DECAY * float(index));
+ }
+ float weight = exp(-ORDER_DECAY * float(order_index));
+ return SIGNED_DIFFRACTION_ENERGY * weight / weight_sum;
+#endif
+}
+
+// Evaluate all supported nonzero diffraction orders in CIE XYZ.
+vec3 evaluateDiffractionXyz(FoilControl control,
+ vec3 light_direction, vec3 view_direction,
+ vec3 normal, vec3 tangent, vec3 bitangent,
+ float source_sigma)
+{
+ vec3 grating = control.grating_axis.x * tangent
+ + control.grating_axis.y * bitangent;
+ vec3 groove = -control.grating_axis.y * tangent
+ + control.grating_axis.x * bitangent;
+ // Tilt only the microscopic grating frame around the groove axis. The
+ // macroscopic card normal still controls print lighting and visibility.
+ float tilt_cosine = cos(control.grating_tilt);
+ float tilt_sine = sin(control.grating_tilt);
+ vec3 tilted_grating = tilt_cosine * grating + tilt_sine * normal;
+ vec3 tilted_normal = tilt_cosine * normal - tilt_sine * grating;
+ vec3 direction_sum = light_direction + view_direction;
+ vec3 tangent_sum = direction_sum
+ - tilted_normal * dot(direction_sum, tilted_normal);
+ float order_coordinate = abs(dot(tangent_sum, tilted_grating));
+ float cross_coordinate = dot(tangent_sum, groove);
+ float cross_response = crossGrooveLobe(
+ cross_coordinate, source_sigma);
+ vec3 diffraction_xyz = vec3(0.0);
+
+ for(int order_index = 0; order_index < ORDER_COUNT; ++order_index)
+ {
+#if DIFFRACTION_ORDER > 0
+ if(order_index + 1 != DIFFRACTION_ORDER)
+ {
+ continue;
+ }
+#endif
+ float order = float(order_index + 1);
+ float wavelength_um = control.groove_spacing_um
+ * order_coordinate / order;
+ float source_width_um = control.groove_spacing_um
+ * source_sigma / order;
+ vec3 spectral_xyz = sampleDisorderedSpectrum(
+ wavelength_um, source_width_um);
+ float jacobian = SPECTRAL_JACOBIAN_SCALE
+ * control.groove_spacing_um / order;
+ diffraction_xyz += orderEfficiency(order_index)
+ * cross_response * jacobian
+ * spectral_xyz;
+ }
+ return diffraction_xyz;
+}
+
+// Evaluate nonspectral card layers without radiance or cosine factors.
+vec3 evaluateOrdinaryBrdf(FoilControl control, vec3 albedo,
+ vec3 light_direction, vec3 view_direction,
+ vec3 normal)
+{
+ float print_energy = mix(1.0, TRANSMITTED_PRINT_ENERGY,
+ control.coverage);
+ vec3 print_brdf = print_energy * albedo / PI;
+ if(control.coverage > 0.0)
+ {
+ vec3 specular_brdf = ZERO_ORDER_ENERGY * evaluateSpecularBrdf(
+ light_direction, view_direction, normal);
+ print_brdf += control.coverage * specular_brdf;
+ }
+ return print_brdf;
+}
+
+// Integrate all BRDF components over the deterministic disk-light samples.
+vec3 integrateDiskLight(FoilControl control, vec3 albedo,
+ vec3 view_direction, vec3 normal,
+ vec3 tangent, vec3 bitangent)
+{
+ float light_area = PI * u_light_radius * u_light_radius;
+ vec3 direct_rgb = vec3(0.0);
+ vec3 diffraction_xyz = vec3(0.0);
+
+ for(int sample_index = 0;
+ sample_index < LIGHT_SAMPLE_COUNT; ++sample_index)
+ {
+ vec2 disk_coord = LIGHT_SAMPLES[sample_index];
+ vec3 sample_position = u_light_position
+ + u_light_radius
+ * (disk_coord.x * u_light_axis_x
+ + disk_coord.y * u_light_axis_y);
+ vec3 to_light = sample_position - v_view_position;
+ float distance_squared = dot(to_light, to_light);
+ vec3 light_direction = to_light
+ * inversesqrt(max(distance_squared,
+ BRDF_EPSILON));
+ float surface_cosine = max(dot(normal, light_direction), 0.0);
+ float emitter_cosine = max(dot(u_light_normal,
+ -light_direction), 0.0);
+ if(surface_cosine <= 0.0 || emitter_cosine <= 0.0)
+ {
+ continue;
+ }
+
+ float sample_weight = light_area / float(LIGHT_SAMPLE_COUNT)
+ * u_light_radiance * surface_cosine
+ * emitter_cosine
+ / max(distance_squared, BRDF_EPSILON);
+ direct_rgb += sample_weight * evaluateOrdinaryBrdf(
+ control, albedo, light_direction, view_direction, normal);
+ }
+
+ // A handful of point samples visibly duplicate a narrow rainbow. For the
+ // diffraction layer, convolve one center ray with the continuous angular
+ // footprint of the disk instead. A uniform disk coordinate has standard
+ // deviation R/2; the distant-light angular approximation is therefore
+ // R/(2D). It broadens both the cross-groove lobe and selected wavelength.
+ if(control.coverage > 0.0)
+ {
+ vec3 center_vector = u_light_position - v_view_position;
+ float center_distance_squared = dot(center_vector, center_vector);
+ vec3 center_direction = center_vector * inversesqrt(
+ max(center_distance_squared, BRDF_EPSILON));
+ float surface_cosine = max(dot(normal, center_direction), 0.0);
+ float emitter_cosine = max(dot(u_light_normal,
+ -center_direction), 0.0);
+ if(surface_cosine > 0.0 && emitter_cosine > 0.0)
+ {
+ float center_weight = light_area * u_light_radiance
+ * surface_cosine * emitter_cosine
+ / max(center_distance_squared,
+ BRDF_EPSILON);
+ float source_sigma = 0.5 * u_light_radius * inversesqrt(
+ max(center_distance_squared, BRDF_EPSILON));
+ diffraction_xyz = center_weight * control.coverage
+ * evaluateDiffractionXyz(
+ control, center_direction, view_direction,
+ normal, tangent, bitangent, source_sigma);
+ }
+ }
+
+ return direct_rgb + xyzToLinearSrgb(diffraction_xyz);
+}
+
+void main()
+{
+ vec4 artwork_sample;
+ FoilControl control;
+ if(u_material_kind == MATERIAL_SOLID_COLOR)
+ {
+ // The shell deliberately owns no texture. A uniform branch keeps its
+ // draw from sampling any of the front-only material resources.
+ artwork_sample = vec4(u_solid_color_srgb, 1.0);
+ control = noFoilControl();
+ }
+ else if(u_material_kind == MATERIAL_ARTWORK)
+ {
+ artwork_sample = texture(u_artwork, v_texcoord);
+ control = noFoilControl();
+ }
+ else if(u_material_kind == MATERIAL_PHYSICAL_FOIL)
+ {
+ artwork_sample = texture(u_artwork, v_texcoord);
+ control = sampleFoilControl(v_texcoord);
+ }
+ else
+ {
+ // Unknown modes are programming errors; magenta makes them obvious.
+ artwork_sample = vec4(1.0, 0.0, 1.0, 1.0);
+ control = noFoilControl();
+ }
+ vec3 albedo = srgbToLinear(artwork_sample.rgb);
+ vec3 view_direction = safeNormalize(
+ -v_view_position, vec3(0.0, 0.0, 1.0));
+ vec3 normal = safeNormalize(v_view_normal, vec3(0.0, 0.0, 1.0));
+ vec3 tangent = safeNormalize(
+ v_view_tangent - normal * dot(v_view_tangent, normal),
+ orthogonalVector(normal));
+ vec3 bitangent = safeNormalize(
+ v_view_bitangent - normal * dot(v_view_bitangent, normal),
+ cross(normal, tangent));
+
+ if(dot(normal, view_direction) < 0.0)
+ {
+ normal = -normal;
+ bitangent = -bitangent;
+ }
+
+ vec3 hdr_color = integrateDiskLight(
+ control, albedo, view_direction, normal, tangent, bitangent);
+ float ambient_energy = mix(1.0, TRANSMITTED_PRINT_ENERGY,
+ control.coverage);
+ hdr_color += AMBIENT_PRINT_IRRADIANCE * ambient_energy * albedo;
+
+ vec3 tone_mapped = toneMap(hdr_color);
+ vec3 display_linear = applyFoilGamutMap(
+ tone_mapped, control.coverage);
+ vec3 encoded_color = linearToSrgb(display_linear);
+ out_color = vec4(applyLevels(encoded_color),
+ artwork_sample.a);
+}
diff --git a/static/foil/gl-matrix-min.js b/static/foil/gl-matrix-min.js
new file mode 100644
index 0000000..7571ca0
--- /dev/null
+++ b/static/foil/gl-matrix-min.js
@@ -0,0 +1,28 @@
+/*!
+@fileoverview gl-matrix - High performance matrix and vector operations
+@author Brandon Jones
+@author Colin MacKenzie IV
+@version 3.4.1
+
+Copyright (c) 2015-2021, Brandon Jones, Colin MacKenzie IV.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+
+*/
+!function(t,n){"object"==typeof exports&&"undefined"!=typeof module?n(exports):"function"==typeof define&&define.amd?define(["exports"],n):n((t="undefined"!=typeof globalThis?globalThis:t||self).glMatrix={})}(this,(function(t){"use strict";var n=1e-6,a="undefined"!=typeof Float32Array?Float32Array:Array,r=Math.random;var u=Math.PI/180;Math.hypot||(Math.hypot=function(){for(var t=0,n=arguments.length;n--;)t+=arguments[n]*arguments[n];return Math.sqrt(t)});var e=Object.freeze({__proto__:null,EPSILON:n,get ARRAY_TYPE(){return a},RANDOM:r,ANGLE_ORDER:"zyx",setMatrixArrayType:function(t){a=t},toRadian:function(t){return t*u},equals:function(t,a){return Math.abs(t-a)<=n*Math.max(1,Math.abs(t),Math.abs(a))}});function o(t,n,a){var r=n[0],u=n[1],e=n[2],o=n[3],i=a[0],h=a[1],c=a[2],s=a[3];return t[0]=r*i+e*h,t[1]=u*i+o*h,t[2]=r*c+e*s,t[3]=u*c+o*s,t}function i(t,n,a){return t[0]=n[0]-a[0],t[1]=n[1]-a[1],t[2]=n[2]-a[2],t[3]=n[3]-a[3],t}var h=o,c=i,s=Object.freeze({__proto__:null,create:function(){var t=new a(4);return a!=Float32Array&&(t[1]=0,t[2]=0),t[0]=1,t[3]=1,t},clone:function(t){var n=new a(4);return n[0]=t[0],n[1]=t[1],n[2]=t[2],n[3]=t[3],n},copy:function(t,n){return t[0]=n[0],t[1]=n[1],t[2]=n[2],t[3]=n[3],t},identity:function(t){return t[0]=1,t[1]=0,t[2]=0,t[3]=1,t},fromValues:function(t,n,r,u){var e=new a(4);return e[0]=t,e[1]=n,e[2]=r,e[3]=u,e},set:function(t,n,a,r,u){return t[0]=n,t[1]=a,t[2]=r,t[3]=u,t},transpose:function(t,n){if(t===n){var a=n[1];t[1]=n[2],t[2]=a}else t[0]=n[0],t[1]=n[2],t[2]=n[1],t[3]=n[3];return t},invert:function(t,n){var a=n[0],r=n[1],u=n[2],e=n[3],o=a*e-u*r;return o?(o=1/o,t[0]=e*o,t[1]=-r*o,t[2]=-u*o,t[3]=a*o,t):null},adjoint:function(t,n){var a=n[0];return t[0]=n[3],t[1]=-n[1],t[2]=-n[2],t[3]=a,t},determinant:function(t){return t[0]*t[3]-t[2]*t[1]},multiply:o,rotate:function(t,n,a){var r=n[0],u=n[1],e=n[2],o=n[3],i=Math.sin(a),h=Math.cos(a);return t[0]=r*h+e*i,t[1]=u*h+o*i,t[2]=r*-i+e*h,t[3]=u*-i+o*h,t},scale:function(t,n,a){var r=n[0],u=n[1],e=n[2],o=n[3],i=a[0],h=a[1];return t[0]=r*i,t[1]=u*i,t[2]=e*h,t[3]=o*h,t},fromRotation:function(t,n){var a=Math.sin(n),r=Math.cos(n);return t[0]=r,t[1]=a,t[2]=-a,t[3]=r,t},fromScaling:function(t,n){return t[0]=n[0],t[1]=0,t[2]=0,t[3]=n[1],t},str:function(t){return"mat2("+t[0]+", "+t[1]+", "+t[2]+", "+t[3]+")"},frob:function(t){return Math.hypot(t[0],t[1],t[2],t[3])},LDU:function(t,n,a,r){return t[2]=r[2]/r[0],a[0]=r[0],a[1]=r[1],a[3]=r[3]-t[2]*a[1],[t,n,a]},add:function(t,n,a){return t[0]=n[0]+a[0],t[1]=n[1]+a[1],t[2]=n[2]+a[2],t[3]=n[3]+a[3],t},subtract:i,exactEquals:function(t,n){return t[0]===n[0]&&t[1]===n[1]&&t[2]===n[2]&&t[3]===n[3]},equals:function(t,a){var r=t[0],u=t[1],e=t[2],o=t[3],i=a[0],h=a[1],c=a[2],s=a[3];return Math.abs(r-i)<=n*Math.max(1,Math.abs(r),Math.abs(i))&&Math.abs(u-h)<=n*Math.max(1,Math.abs(u),Math.abs(h))&&Math.abs(e-c)<=n*Math.max(1,Math.abs(e),Math.abs(c))&&Math.abs(o-s)<=n*Math.max(1,Math.abs(o),Math.abs(s))},multiplyScalar:function(t,n,a){return t[0]=n[0]*a,t[1]=n[1]*a,t[2]=n[2]*a,t[3]=n[3]*a,t},multiplyScalarAndAdd:function(t,n,a,r){return t[0]=n[0]+a[0]*r,t[1]=n[1]+a[1]*r,t[2]=n[2]+a[2]*r,t[3]=n[3]+a[3]*r,t},mul:h,sub:c});function M(t,n,a){var r=n[0],u=n[1],e=n[2],o=n[3],i=n[4],h=n[5],c=a[0],s=a[1],M=a[2],f=a[3],l=a[4],v=a[5];return t[0]=r*c+e*s,t[1]=u*c+o*s,t[2]=r*M+e*f,t[3]=u*M+o*f,t[4]=r*l+e*v+i,t[5]=u*l+o*v+h,t}function f(t,n,a){return t[0]=n[0]-a[0],t[1]=n[1]-a[1],t[2]=n[2]-a[2],t[3]=n[3]-a[3],t[4]=n[4]-a[4],t[5]=n[5]-a[5],t}var l=M,v=f,b=Object.freeze({__proto__:null,create:function(){var t=new a(6);return a!=Float32Array&&(t[1]=0,t[2]=0,t[4]=0,t[5]=0),t[0]=1,t[3]=1,t},clone:function(t){var n=new a(6);return n[0]=t[0],n[1]=t[1],n[2]=t[2],n[3]=t[3],n[4]=t[4],n[5]=t[5],n},copy:function(t,n){return t[0]=n[0],t[1]=n[1],t[2]=n[2],t[3]=n[3],t[4]=n[4],t[5]=n[5],t},identity:function(t){return t[0]=1,t[1]=0,t[2]=0,t[3]=1,t[4]=0,t[5]=0,t},fromValues:function(t,n,r,u,e,o){var i=new a(6);return i[0]=t,i[1]=n,i[2]=r,i[3]=u,i[4]=e,i[5]=o,i},set:function(t,n,a,r,u,e,o){return t[0]=n,t[1]=a,t[2]=r,t[3]=u,t[4]=e,t[5]=o,t},invert:function(t,n){var a=n[0],r=n[1],u=n[2],e=n[3],o=n[4],i=n[5],h=a*e-r*u;return h?(h=1/h,t[0]=e*h,t[1]=-r*h,t[2]=-u*h,t[3]=a*h,t[4]=(u*i-e*o)*h,t[5]=(r*o-a*i)*h,t):null},determinant:function(t){return t[0]*t[3]-t[1]*t[2]},multiply:M,rotate:function(t,n,a){var r=n[0],u=n[1],e=n[2],o=n[3],i=n[4],h=n[5],c=Math.sin(a),s=Math.cos(a);return t[0]=r*s+e*c,t[1]=u*s+o*c,t[2]=r*-c+e*s,t[3]=u*-c+o*s,t[4]=i,t[5]=h,t},scale:function(t,n,a){var r=n[0],u=n[1],e=n[2],o=n[3],i=n[4],h=n[5],c=a[0],s=a[1];return t[0]=r*c,t[1]=u*c,t[2]=e*s,t[3]=o*s,t[4]=i,t[5]=h,t},translate:function(t,n,a){var r=n[0],u=n[1],e=n[2],o=n[3],i=n[4],h=n[5],c=a[0],s=a[1];return t[0]=r,t[1]=u,t[2]=e,t[3]=o,t[4]=r*c+e*s+i,t[5]=u*c+o*s+h,t},fromRotation:function(t,n){var a=Math.sin(n),r=Math.cos(n);return t[0]=r,t[1]=a,t[2]=-a,t[3]=r,t[4]=0,t[5]=0,t},fromScaling:function(t,n){return t[0]=n[0],t[1]=0,t[2]=0,t[3]=n[1],t[4]=0,t[5]=0,t},fromTranslation:function(t,n){return t[0]=1,t[1]=0,t[2]=0,t[3]=1,t[4]=n[0],t[5]=n[1],t},str:function(t){return"mat2d("+t[0]+", "+t[1]+", "+t[2]+", "+t[3]+", "+t[4]+", "+t[5]+")"},frob:function(t){return Math.hypot(t[0],t[1],t[2],t[3],t[4],t[5],1)},add:function(t,n,a){return t[0]=n[0]+a[0],t[1]=n[1]+a[1],t[2]=n[2]+a[2],t[3]=n[3]+a[3],t[4]=n[4]+a[4],t[5]=n[5]+a[5],t},subtract:f,multiplyScalar:function(t,n,a){return t[0]=n[0]*a,t[1]=n[1]*a,t[2]=n[2]*a,t[3]=n[3]*a,t[4]=n[4]*a,t[5]=n[5]*a,t},multiplyScalarAndAdd:function(t,n,a,r){return t[0]=n[0]+a[0]*r,t[1]=n[1]+a[1]*r,t[2]=n[2]+a[2]*r,t[3]=n[3]+a[3]*r,t[4]=n[4]+a[4]*r,t[5]=n[5]+a[5]*r,t},exactEquals:function(t,n){return t[0]===n[0]&&t[1]===n[1]&&t[2]===n[2]&&t[3]===n[3]&&t[4]===n[4]&&t[5]===n[5]},equals:function(t,a){var r=t[0],u=t[1],e=t[2],o=t[3],i=t[4],h=t[5],c=a[0],s=a[1],M=a[2],f=a[3],l=a[4],v=a[5];return Math.abs(r-c)<=n*Math.max(1,Math.abs(r),Math.abs(c))&&Math.abs(u-s)<=n*Math.max(1,Math.abs(u),Math.abs(s))&&Math.abs(e-M)<=n*Math.max(1,Math.abs(e),Math.abs(M))&&Math.abs(o-f)<=n*Math.max(1,Math.abs(o),Math.abs(f))&&Math.abs(i-l)<=n*Math.max(1,Math.abs(i),Math.abs(l))&&Math.abs(h-v)<=n*Math.max(1,Math.abs(h),Math.abs(v))},mul:l,sub:v});function m(){var t=new a(9);return a!=Float32Array&&(t[1]=0,t[2]=0,t[3]=0,t[5]=0,t[6]=0,t[7]=0),t[0]=1,t[4]=1,t[8]=1,t}function d(t,n,a){var r=n[0],u=n[1],e=n[2],o=n[3],i=n[4],h=n[5],c=n[6],s=n[7],M=n[8],f=a[0],l=a[1],v=a[2],b=a[3],m=a[4],d=a[5],p=a[6],x=a[7],y=a[8];return t[0]=f*r+l*o+v*c,t[1]=f*u+l*i+v*s,t[2]=f*e+l*h+v*M,t[3]=b*r+m*o+d*c,t[4]=b*u+m*i+d*s,t[5]=b*e+m*h+d*M,t[6]=p*r+x*o+y*c,t[7]=p*u+x*i+y*s,t[8]=p*e+x*h+y*M,t}function p(t,n,a){return t[0]=n[0]-a[0],t[1]=n[1]-a[1],t[2]=n[2]-a[2],t[3]=n[3]-a[3],t[4]=n[4]-a[4],t[5]=n[5]-a[5],t[6]=n[6]-a[6],t[7]=n[7]-a[7],t[8]=n[8]-a[8],t}var x=d,y=p,q=Object.freeze({__proto__:null,create:m,fromMat4:function(t,n){return t[0]=n[0],t[1]=n[1],t[2]=n[2],t[3]=n[4],t[4]=n[5],t[5]=n[6],t[6]=n[8],t[7]=n[9],t[8]=n[10],t},clone:function(t){var n=new a(9);return n[0]=t[0],n[1]=t[1],n[2]=t[2],n[3]=t[3],n[4]=t[4],n[5]=t[5],n[6]=t[6],n[7]=t[7],n[8]=t[8],n},copy:function(t,n){return t[0]=n[0],t[1]=n[1],t[2]=n[2],t[3]=n[3],t[4]=n[4],t[5]=n[5],t[6]=n[6],t[7]=n[7],t[8]=n[8],t},fromValues:function(t,n,r,u,e,o,i,h,c){var s=new a(9);return s[0]=t,s[1]=n,s[2]=r,s[3]=u,s[4]=e,s[5]=o,s[6]=i,s[7]=h,s[8]=c,s},set:function(t,n,a,r,u,e,o,i,h,c){return t[0]=n,t[1]=a,t[2]=r,t[3]=u,t[4]=e,t[5]=o,t[6]=i,t[7]=h,t[8]=c,t},identity:function(t){return t[0]=1,t[1]=0,t[2]=0,t[3]=0,t[4]=1,t[5]=0,t[6]=0,t[7]=0,t[8]=1,t},transpose:function(t,n){if(t===n){var a=n[1],r=n[2],u=n[5];t[1]=n[3],t[2]=n[6],t[3]=a,t[5]=n[7],t[6]=r,t[7]=u}else t[0]=n[0],t[1]=n[3],t[2]=n[6],t[3]=n[1],t[4]=n[4],t[5]=n[7],t[6]=n[2],t[7]=n[5],t[8]=n[8];return t},invert:function(t,n){var a=n[0],r=n[1],u=n[2],e=n[3],o=n[4],i=n[5],h=n[6],c=n[7],s=n[8],M=s*o-i*c,f=-s*e+i*h,l=c*e-o*h,v=a*M+r*f+u*l;return v?(v=1/v,t[0]=M*v,t[1]=(-s*r+u*c)*v,t[2]=(i*r-u*o)*v,t[3]=f*v,t[4]=(s*a-u*h)*v,t[5]=(-i*a+u*e)*v,t[6]=l*v,t[7]=(-c*a+r*h)*v,t[8]=(o*a-r*e)*v,t):null},adjoint:function(t,n){var a=n[0],r=n[1],u=n[2],e=n[3],o=n[4],i=n[5],h=n[6],c=n[7],s=n[8];return t[0]=o*s-i*c,t[1]=u*c-r*s,t[2]=r*i-u*o,t[3]=i*h-e*s,t[4]=a*s-u*h,t[5]=u*e-a*i,t[6]=e*c-o*h,t[7]=r*h-a*c,t[8]=a*o-r*e,t},determinant:function(t){var n=t[0],a=t[1],r=t[2],u=t[3],e=t[4],o=t[5],i=t[6],h=t[7],c=t[8];return n*(c*e-o*h)+a*(-c*u+o*i)+r*(h*u-e*i)},multiply:d,translate:function(t,n,a){var r=n[0],u=n[1],e=n[2],o=n[3],i=n[4],h=n[5],c=n[6],s=n[7],M=n[8],f=a[0],l=a[1];return t[0]=r,t[1]=u,t[2]=e,t[3]=o,t[4]=i,t[5]=h,t[6]=f*r+l*o+c,t[7]=f*u+l*i+s,t[8]=f*e+l*h+M,t},rotate:function(t,n,a){var r=n[0],u=n[1],e=n[2],o=n[3],i=n[4],h=n[5],c=n[6],s=n[7],M=n[8],f=Math.sin(a),l=Math.cos(a);return t[0]=l*r+f*o,t[1]=l*u+f*i,t[2]=l*e+f*h,t[3]=l*o-f*r,t[4]=l*i-f*u,t[5]=l*h-f*e,t[6]=c,t[7]=s,t[8]=M,t},scale:function(t,n,a){var r=a[0],u=a[1];return t[0]=r*n[0],t[1]=r*n[1],t[2]=r*n[2],t[3]=u*n[3],t[4]=u*n[4],t[5]=u*n[5],t[6]=n[6],t[7]=n[7],t[8]=n[8],t},fromTranslation:function(t,n){return t[0]=1,t[1]=0,t[2]=0,t[3]=0,t[4]=1,t[5]=0,t[6]=n[0],t[7]=n[1],t[8]=1,t},fromRotation:function(t,n){var a=Math.sin(n),r=Math.cos(n);return t[0]=r,t[1]=a,t[2]=0,t[3]=-a,t[4]=r,t[5]=0,t[6]=0,t[7]=0,t[8]=1,t},fromScaling:function(t,n){return t[0]=n[0],t[1]=0,t[2]=0,t[3]=0,t[4]=n[1],t[5]=0,t[6]=0,t[7]=0,t[8]=1,t},fromMat2d:function(t,n){return t[0]=n[0],t[1]=n[1],t[2]=0,t[3]=n[2],t[4]=n[3],t[5]=0,t[6]=n[4],t[7]=n[5],t[8]=1,t},fromQuat:function(t,n){var a=n[0],r=n[1],u=n[2],e=n[3],o=a+a,i=r+r,h=u+u,c=a*o,s=r*o,M=r*i,f=u*o,l=u*i,v=u*h,b=e*o,m=e*i,d=e*h;return t[0]=1-M-v,t[3]=s-d,t[6]=f+m,t[1]=s+d,t[4]=1-c-v,t[7]=l-b,t[2]=f-m,t[5]=l+b,t[8]=1-c-M,t},normalFromMat4:function(t,n){var a=n[0],r=n[1],u=n[2],e=n[3],o=n[4],i=n[5],h=n[6],c=n[7],s=n[8],M=n[9],f=n[10],l=n[11],v=n[12],b=n[13],m=n[14],d=n[15],p=a*i-r*o,x=a*h-u*o,y=a*c-e*o,q=r*h-u*i,g=r*c-e*i,_=u*c-e*h,A=s*b-M*v,w=s*m-f*v,z=s*d-l*v,R=M*m-f*b,O=M*d-l*b,j=f*d-l*m,E=p*j-x*O+y*R+q*z-g*w+_*A;return E?(E=1/E,t[0]=(i*j-h*O+c*R)*E,t[1]=(h*z-o*j-c*w)*E,t[2]=(o*O-i*z+c*A)*E,t[3]=(u*O-r*j-e*R)*E,t[4]=(a*j-u*z+e*w)*E,t[5]=(r*z-a*O-e*A)*E,t[6]=(b*_-m*g+d*q)*E,t[7]=(m*y-v*_-d*x)*E,t[8]=(v*g-b*y+d*p)*E,t):null},projection:function(t,n,a){return t[0]=2/n,t[1]=0,t[2]=0,t[3]=0,t[4]=-2/a,t[5]=0,t[6]=-1,t[7]=1,t[8]=1,t},str:function(t){return"mat3("+t[0]+", "+t[1]+", "+t[2]+", "+t[3]+", "+t[4]+", "+t[5]+", "+t[6]+", "+t[7]+", "+t[8]+")"},frob:function(t){return Math.hypot(t[0],t[1],t[2],t[3],t[4],t[5],t[6],t[7],t[8])},add:function(t,n,a){return t[0]=n[0]+a[0],t[1]=n[1]+a[1],t[2]=n[2]+a[2],t[3]=n[3]+a[3],t[4]=n[4]+a[4],t[5]=n[5]+a[5],t[6]=n[6]+a[6],t[7]=n[7]+a[7],t[8]=n[8]+a[8],t},subtract:p,multiplyScalar:function(t,n,a){return t[0]=n[0]*a,t[1]=n[1]*a,t[2]=n[2]*a,t[3]=n[3]*a,t[4]=n[4]*a,t[5]=n[5]*a,t[6]=n[6]*a,t[7]=n[7]*a,t[8]=n[8]*a,t},multiplyScalarAndAdd:function(t,n,a,r){return t[0]=n[0]+a[0]*r,t[1]=n[1]+a[1]*r,t[2]=n[2]+a[2]*r,t[3]=n[3]+a[3]*r,t[4]=n[4]+a[4]*r,t[5]=n[5]+a[5]*r,t[6]=n[6]+a[6]*r,t[7]=n[7]+a[7]*r,t[8]=n[8]+a[8]*r,t},exactEquals:function(t,n){return t[0]===n[0]&&t[1]===n[1]&&t[2]===n[2]&&t[3]===n[3]&&t[4]===n[4]&&t[5]===n[5]&&t[6]===n[6]&&t[7]===n[7]&&t[8]===n[8]},equals:function(t,a){var r=t[0],u=t[1],e=t[2],o=t[3],i=t[4],h=t[5],c=t[6],s=t[7],M=t[8],f=a[0],l=a[1],v=a[2],b=a[3],m=a[4],d=a[5],p=a[6],x=a[7],y=a[8];return Math.abs(r-f)<=n*Math.max(1,Math.abs(r),Math.abs(f))&&Math.abs(u-l)<=n*Math.max(1,Math.abs(u),Math.abs(l))&&Math.abs(e-v)<=n*Math.max(1,Math.abs(e),Math.abs(v))&&Math.abs(o-b)<=n*Math.max(1,Math.abs(o),Math.abs(b))&&Math.abs(i-m)<=n*Math.max(1,Math.abs(i),Math.abs(m))&&Math.abs(h-d)<=n*Math.max(1,Math.abs(h),Math.abs(d))&&Math.abs(c-p)<=n*Math.max(1,Math.abs(c),Math.abs(p))&&Math.abs(s-x)<=n*Math.max(1,Math.abs(s),Math.abs(x))&&Math.abs(M-y)<=n*Math.max(1,Math.abs(M),Math.abs(y))},mul:x,sub:y});function g(t){return t[0]=1,t[1]=0,t[2]=0,t[3]=0,t[4]=0,t[5]=1,t[6]=0,t[7]=0,t[8]=0,t[9]=0,t[10]=1,t[11]=0,t[12]=0,t[13]=0,t[14]=0,t[15]=1,t}function _(t,n,a){var r=n[0],u=n[1],e=n[2],o=n[3],i=n[4],h=n[5],c=n[6],s=n[7],M=n[8],f=n[9],l=n[10],v=n[11],b=n[12],m=n[13],d=n[14],p=n[15],x=a[0],y=a[1],q=a[2],g=a[3];return t[0]=x*r+y*i+q*M+g*b,t[1]=x*u+y*h+q*f+g*m,t[2]=x*e+y*c+q*l+g*d,t[3]=x*o+y*s+q*v+g*p,x=a[4],y=a[5],q=a[6],g=a[7],t[4]=x*r+y*i+q*M+g*b,t[5]=x*u+y*h+q*f+g*m,t[6]=x*e+y*c+q*l+g*d,t[7]=x*o+y*s+q*v+g*p,x=a[8],y=a[9],q=a[10],g=a[11],t[8]=x*r+y*i+q*M+g*b,t[9]=x*u+y*h+q*f+g*m,t[10]=x*e+y*c+q*l+g*d,t[11]=x*o+y*s+q*v+g*p,x=a[12],y=a[13],q=a[14],g=a[15],t[12]=x*r+y*i+q*M+g*b,t[13]=x*u+y*h+q*f+g*m,t[14]=x*e+y*c+q*l+g*d,t[15]=x*o+y*s+q*v+g*p,t}function A(t,n,a){var r=n[0],u=n[1],e=n[2],o=n[3],i=r+r,h=u+u,c=e+e,s=r*i,M=r*h,f=r*c,l=u*h,v=u*c,b=e*c,m=o*i,d=o*h,p=o*c;return t[0]=1-(l+b),t[1]=M+p,t[2]=f-d,t[3]=0,t[4]=M-p,t[5]=1-(s+b),t[6]=v+m,t[7]=0,t[8]=f+d,t[9]=v-m,t[10]=1-(s+l),t[11]=0,t[12]=a[0],t[13]=a[1],t[14]=a[2],t[15]=1,t}function w(t,n){return t[0]=n[12],t[1]=n[13],t[2]=n[14],t}function z(t,n){var a=n[0],r=n[1],u=n[2],e=n[4],o=n[5],i=n[6],h=n[8],c=n[9],s=n[10];return t[0]=Math.hypot(a,r,u),t[1]=Math.hypot(e,o,i),t[2]=Math.hypot(h,c,s),t}function R(t,n){var r=new a(3);z(r,n);var u=1/r[0],e=1/r[1],o=1/r[2],i=n[0]*u,h=n[1]*e,c=n[2]*o,s=n[4]*u,M=n[5]*e,f=n[6]*o,l=n[8]*u,v=n[9]*e,b=n[10]*o,m=i+M+b,d=0;return m>0?(d=2*Math.sqrt(m+1),t[3]=.25*d,t[0]=(f-v)/d,t[1]=(l-c)/d,t[2]=(h-s)/d):i>M&&i>b?(d=2*Math.sqrt(1+i-M-b),t[3]=(f-v)/d,t[0]=.25*d,t[1]=(h+s)/d,t[2]=(l+c)/d):M>b?(d=2*Math.sqrt(1+M-i-b),t[3]=(l-c)/d,t[0]=(h+s)/d,t[1]=.25*d,t[2]=(f+v)/d):(d=2*Math.sqrt(1+b-i-M),t[3]=(h-s)/d,t[0]=(l+c)/d,t[1]=(f+v)/d,t[2]=.25*d),t}function O(t,n,a,r,u){var e=1/Math.tan(n/2);if(t[0]=e/a,t[1]=0,t[2]=0,t[3]=0,t[4]=0,t[5]=e,t[6]=0,t[7]=0,t[8]=0,t[9]=0,t[11]=-1,t[12]=0,t[13]=0,t[15]=0,null!=u&&u!==1/0){var o=1/(r-u);t[10]=(u+r)*o,t[14]=2*u*r*o}else t[10]=-1,t[14]=-2*r;return t}var j=O;function E(t,n,a,r,u,e,o){var i=1/(n-a),h=1/(r-u),c=1/(e-o);return t[0]=-2*i,t[1]=0,t[2]=0,t[3]=0,t[4]=0,t[5]=-2*h,t[6]=0,t[7]=0,t[8]=0,t[9]=0,t[10]=2*c,t[11]=0,t[12]=(n+a)*i,t[13]=(u+r)*h,t[14]=(o+e)*c,t[15]=1,t}var P=E;function T(t,n,a){return t[0]=n[0]-a[0],t[1]=n[1]-a[1],t[2]=n[2]-a[2],t[3]=n[3]-a[3],t[4]=n[4]-a[4],t[5]=n[5]-a[5],t[6]=n[6]-a[6],t[7]=n[7]-a[7],t[8]=n[8]-a[8],t[9]=n[9]-a[9],t[10]=n[10]-a[10],t[11]=n[11]-a[11],t[12]=n[12]-a[12],t[13]=n[13]-a[13],t[14]=n[14]-a[14],t[15]=n[15]-a[15],t}var S=_,D=T,F=Object.freeze({__proto__:null,create:function(){var t=new a(16);return a!=Float32Array&&(t[1]=0,t[2]=0,t[3]=0,t[4]=0,t[6]=0,t[7]=0,t[8]=0,t[9]=0,t[11]=0,t[12]=0,t[13]=0,t[14]=0),t[0]=1,t[5]=1,t[10]=1,t[15]=1,t},clone:function(t){var n=new a(16);return n[0]=t[0],n[1]=t[1],n[2]=t[2],n[3]=t[3],n[4]=t[4],n[5]=t[5],n[6]=t[6],n[7]=t[7],n[8]=t[8],n[9]=t[9],n[10]=t[10],n[11]=t[11],n[12]=t[12],n[13]=t[13],n[14]=t[14],n[15]=t[15],n},copy:function(t,n){return t[0]=n[0],t[1]=n[1],t[2]=n[2],t[3]=n[3],t[4]=n[4],t[5]=n[5],t[6]=n[6],t[7]=n[7],t[8]=n[8],t[9]=n[9],t[10]=n[10],t[11]=n[11],t[12]=n[12],t[13]=n[13],t[14]=n[14],t[15]=n[15],t},fromValues:function(t,n,r,u,e,o,i,h,c,s,M,f,l,v,b,m){var d=new a(16);return d[0]=t,d[1]=n,d[2]=r,d[3]=u,d[4]=e,d[5]=o,d[6]=i,d[7]=h,d[8]=c,d[9]=s,d[10]=M,d[11]=f,d[12]=l,d[13]=v,d[14]=b,d[15]=m,d},set:function(t,n,a,r,u,e,o,i,h,c,s,M,f,l,v,b,m){return t[0]=n,t[1]=a,t[2]=r,t[3]=u,t[4]=e,t[5]=o,t[6]=i,t[7]=h,t[8]=c,t[9]=s,t[10]=M,t[11]=f,t[12]=l,t[13]=v,t[14]=b,t[15]=m,t},identity:g,transpose:function(t,n){if(t===n){var a=n[1],r=n[2],u=n[3],e=n[6],o=n[7],i=n[11];t[1]=n[4],t[2]=n[8],t[3]=n[12],t[4]=a,t[6]=n[9],t[7]=n[13],t[8]=r,t[9]=e,t[11]=n[14],t[12]=u,t[13]=o,t[14]=i}else t[0]=n[0],t[1]=n[4],t[2]=n[8],t[3]=n[12],t[4]=n[1],t[5]=n[5],t[6]=n[9],t[7]=n[13],t[8]=n[2],t[9]=n[6],t[10]=n[10],t[11]=n[14],t[12]=n[3],t[13]=n[7],t[14]=n[11],t[15]=n[15];return t},invert:function(t,n){var a=n[0],r=n[1],u=n[2],e=n[3],o=n[4],i=n[5],h=n[6],c=n[7],s=n[8],M=n[9],f=n[10],l=n[11],v=n[12],b=n[13],m=n[14],d=n[15],p=a*i-r*o,x=a*h-u*o,y=a*c-e*o,q=r*h-u*i,g=r*c-e*i,_=u*c-e*h,A=s*b-M*v,w=s*m-f*v,z=s*d-l*v,R=M*m-f*b,O=M*d-l*b,j=f*d-l*m,E=p*j-x*O+y*R+q*z-g*w+_*A;return E?(E=1/E,t[0]=(i*j-h*O+c*R)*E,t[1]=(u*O-r*j-e*R)*E,t[2]=(b*_-m*g+d*q)*E,t[3]=(f*g-M*_-l*q)*E,t[4]=(h*z-o*j-c*w)*E,t[5]=(a*j-u*z+e*w)*E,t[6]=(m*y-v*_-d*x)*E,t[7]=(s*_-f*y+l*x)*E,t[8]=(o*O-i*z+c*A)*E,t[9]=(r*z-a*O-e*A)*E,t[10]=(v*g-b*y+d*p)*E,t[11]=(M*y-s*g-l*p)*E,t[12]=(i*w-o*R-h*A)*E,t[13]=(a*R-r*w+u*A)*E,t[14]=(b*x-v*q-m*p)*E,t[15]=(s*q-M*x+f*p)*E,t):null},adjoint:function(t,n){var a=n[0],r=n[1],u=n[2],e=n[3],o=n[4],i=n[5],h=n[6],c=n[7],s=n[8],M=n[9],f=n[10],l=n[11],v=n[12],b=n[13],m=n[14],d=n[15],p=a*i-r*o,x=a*h-u*o,y=a*c-e*o,q=r*h-u*i,g=r*c-e*i,_=u*c-e*h,A=s*b-M*v,w=s*m-f*v,z=s*d-l*v,R=M*m-f*b,O=M*d-l*b,j=f*d-l*m;return t[0]=i*j-h*O+c*R,t[1]=u*O-r*j-e*R,t[2]=b*_-m*g+d*q,t[3]=f*g-M*_-l*q,t[4]=h*z-o*j-c*w,t[5]=a*j-u*z+e*w,t[6]=m*y-v*_-d*x,t[7]=s*_-f*y+l*x,t[8]=o*O-i*z+c*A,t[9]=r*z-a*O-e*A,t[10]=v*g-b*y+d*p,t[11]=M*y-s*g-l*p,t[12]=i*w-o*R-h*A,t[13]=a*R-r*w+u*A,t[14]=b*x-v*q-m*p,t[15]=s*q-M*x+f*p,t},determinant:function(t){var n=t[0],a=t[1],r=t[2],u=t[3],e=t[4],o=t[5],i=t[6],h=t[7],c=t[8],s=t[9],M=t[10],f=t[11],l=t[12],v=t[13],b=t[14],m=n*o-a*e,d=n*i-r*e,p=a*i-r*o,x=c*v-s*l,y=c*b-M*l,q=s*b-M*v;return h*(n*q-a*y+r*x)-u*(e*q-o*y+i*x)+t[15]*(c*p-s*d+M*m)-f*(l*p-v*d+b*m)},multiply:_,translate:function(t,n,a){var r,u,e,o,i,h,c,s,M,f,l,v,b=a[0],m=a[1],d=a[2];return n===t?(t[12]=n[0]*b+n[4]*m+n[8]*d+n[12],t[13]=n[1]*b+n[5]*m+n[9]*d+n[13],t[14]=n[2]*b+n[6]*m+n[10]*d+n[14],t[15]=n[3]*b+n[7]*m+n[11]*d+n[15]):(r=n[0],u=n[1],e=n[2],o=n[3],i=n[4],h=n[5],c=n[6],s=n[7],M=n[8],f=n[9],l=n[10],v=n[11],t[0]=r,t[1]=u,t[2]=e,t[3]=o,t[4]=i,t[5]=h,t[6]=c,t[7]=s,t[8]=M,t[9]=f,t[10]=l,t[11]=v,t[12]=r*b+i*m+M*d+n[12],t[13]=u*b+h*m+f*d+n[13],t[14]=e*b+c*m+l*d+n[14],t[15]=o*b+s*m+v*d+n[15]),t},scale:function(t,n,a){var r=a[0],u=a[1],e=a[2];return t[0]=n[0]*r,t[1]=n[1]*r,t[2]=n[2]*r,t[3]=n[3]*r,t[4]=n[4]*u,t[5]=n[5]*u,t[6]=n[6]*u,t[7]=n[7]*u,t[8]=n[8]*e,t[9]=n[9]*e,t[10]=n[10]*e,t[11]=n[11]*e,t[12]=n[12],t[13]=n[13],t[14]=n[14],t[15]=n[15],t},rotate:function(t,a,r,u){var e,o,i,h,c,s,M,f,l,v,b,m,d,p,x,y,q,g,_,A,w,z,R,O,j=u[0],E=u[1],P=u[2],T=Math.hypot(j,E,P);return T<n?null:(j*=T=1/T,E*=T,P*=T,e=Math.sin(r),i=1-(o=Math.cos(r)),h=a[0],c=a[1],s=a[2],M=a[3],f=a[4],l=a[5],v=a[6],b=a[7],m=a[8],d=a[9],p=a[10],x=a[11],y=j*j*i+o,q=E*j*i+P*e,g=P*j*i-E*e,_=j*E*i-P*e,A=E*E*i+o,w=P*E*i+j*e,z=j*P*i+E*e,R=E*P*i-j*e,O=P*P*i+o,t[0]=h*y+f*q+m*g,t[1]=c*y+l*q+d*g,t[2]=s*y+v*q+p*g,t[3]=M*y+b*q+x*g,t[4]=h*_+f*A+m*w,t[5]=c*_+l*A+d*w,t[6]=s*_+v*A+p*w,t[7]=M*_+b*A+x*w,t[8]=h*z+f*R+m*O,t[9]=c*z+l*R+d*O,t[10]=s*z+v*R+p*O,t[11]=M*z+b*R+x*O,a!==t&&(t[12]=a[12],t[13]=a[13],t[14]=a[14],t[15]=a[15]),t)},rotateX:function(t,n,a){var r=Math.sin(a),u=Math.cos(a),e=n[4],o=n[5],i=n[6],h=n[7],c=n[8],s=n[9],M=n[10],f=n[11];return n!==t&&(t[0]=n[0],t[1]=n[1],t[2]=n[2],t[3]=n[3],t[12]=n[12],t[13]=n[13],t[14]=n[14],t[15]=n[15]),t[4]=e*u+c*r,t[5]=o*u+s*r,t[6]=i*u+M*r,t[7]=h*u+f*r,t[8]=c*u-e*r,t[9]=s*u-o*r,t[10]=M*u-i*r,t[11]=f*u-h*r,t},rotateY:function(t,n,a){var r=Math.sin(a),u=Math.cos(a),e=n[0],o=n[1],i=n[2],h=n[3],c=n[8],s=n[9],M=n[10],f=n[11];return n!==t&&(t[4]=n[4],t[5]=n[5],t[6]=n[6],t[7]=n[7],t[12]=n[12],t[13]=n[13],t[14]=n[14],t[15]=n[15]),t[0]=e*u-c*r,t[1]=o*u-s*r,t[2]=i*u-M*r,t[3]=h*u-f*r,t[8]=e*r+c*u,t[9]=o*r+s*u,t[10]=i*r+M*u,t[11]=h*r+f*u,t},rotateZ:function(t,n,a){var r=Math.sin(a),u=Math.cos(a),e=n[0],o=n[1],i=n[2],h=n[3],c=n[4],s=n[5],M=n[6],f=n[7];return n!==t&&(t[8]=n[8],t[9]=n[9],t[10]=n[10],t[11]=n[11],t[12]=n[12],t[13]=n[13],t[14]=n[14],t[15]=n[15]),t[0]=e*u+c*r,t[1]=o*u+s*r,t[2]=i*u+M*r,t[3]=h*u+f*r,t[4]=c*u-e*r,t[5]=s*u-o*r,t[6]=M*u-i*r,t[7]=f*u-h*r,t},fromTranslation:function(t,n){return t[0]=1,t[1]=0,t[2]=0,t[3]=0,t[4]=0,t[5]=1,t[6]=0,t[7]=0,t[8]=0,t[9]=0,t[10]=1,t[11]=0,t[12]=n[0],t[13]=n[1],t[14]=n[2],t[15]=1,t},fromScaling:function(t,n){return t[0]=n[0],t[1]=0,t[2]=0,t[3]=0,t[4]=0,t[5]=n[1],t[6]=0,t[7]=0,t[8]=0,t[9]=0,t[10]=n[2],t[11]=0,t[12]=0,t[13]=0,t[14]=0,t[15]=1,t},fromRotation:function(t,a,r){var u,e,o,i=r[0],h=r[1],c=r[2],s=Math.hypot(i,h,c);return s<n?null:(i*=s=1/s,h*=s,c*=s,u=Math.sin(a),o=1-(e=Math.cos(a)),t[0]=i*i*o+e,t[1]=h*i*o+c*u,t[2]=c*i*o-h*u,t[3]=0,t[4]=i*h*o-c*u,t[5]=h*h*o+e,t[6]=c*h*o+i*u,t[7]=0,t[8]=i*c*o+h*u,t[9]=h*c*o-i*u,t[10]=c*c*o+e,t[11]=0,t[12]=0,t[13]=0,t[14]=0,t[15]=1,t)},fromXRotation:function(t,n){var a=Math.sin(n),r=Math.cos(n);return t[0]=1,t[1]=0,t[2]=0,t[3]=0,t[4]=0,t[5]=r,t[6]=a,t[7]=0,t[8]=0,t[9]=-a,t[10]=r,t[11]=0,t[12]=0,t[13]=0,t[14]=0,t[15]=1,t},fromYRotation:function(t,n){var a=Math.sin(n),r=Math.cos(n);return t[0]=r,t[1]=0,t[2]=-a,t[3]=0,t[4]=0,t[5]=1,t[6]=0,t[7]=0,t[8]=a,t[9]=0,t[10]=r,t[11]=0,t[12]=0,t[13]=0,t[14]=0,t[15]=1,t},fromZRotation:function(t,n){var a=Math.sin(n),r=Math.cos(n);return t[0]=r,t[1]=a,t[2]=0,t[3]=0,t[4]=-a,t[5]=r,t[6]=0,t[7]=0,t[8]=0,t[9]=0,t[10]=1,t[11]=0,t[12]=0,t[13]=0,t[14]=0,t[15]=1,t},fromRotationTranslation:A,fromQuat2:function(t,n){var r=new a(3),u=-n[0],e=-n[1],o=-n[2],i=n[3],h=n[4],c=n[5],s=n[6],M=n[7],f=u*u+e*e+o*o+i*i;return f>0?(r[0]=2*(h*i+M*u+c*o-s*e)/f,r[1]=2*(c*i+M*e+s*u-h*o)/f,r[2]=2*(s*i+M*o+h*e-c*u)/f):(r[0]=2*(h*i+M*u+c*o-s*e),r[1]=2*(c*i+M*e+s*u-h*o),r[2]=2*(s*i+M*o+h*e-c*u)),A(t,n,r),t},getTranslation:w,getScaling:z,getRotation:R,decompose:function(t,n,a,r){n[0]=r[12],n[1]=r[13],n[2]=r[14];var u=r[0],e=r[1],o=r[2],i=r[4],h=r[5],c=r[6],s=r[8],M=r[9],f=r[10];a[0]=Math.hypot(u,e,o),a[1]=Math.hypot(i,h,c),a[2]=Math.hypot(s,M,f);var l=1/a[0],v=1/a[1],b=1/a[2],m=u*l,d=e*v,p=o*b,x=i*l,y=h*v,q=c*b,g=s*l,_=M*v,A=f*b,w=m+y+A,z=0;return w>0?(z=2*Math.sqrt(w+1),t[3]=.25*z,t[0]=(q-_)/z,t[1]=(g-p)/z,t[2]=(d-x)/z):m>y&&m>A?(z=2*Math.sqrt(1+m-y-A),t[3]=(q-_)/z,t[0]=.25*z,t[1]=(d+x)/z,t[2]=(g+p)/z):y>A?(z=2*Math.sqrt(1+y-m-A),t[3]=(g-p)/z,t[0]=(d+x)/z,t[1]=.25*z,t[2]=(q+_)/z):(z=2*Math.sqrt(1+A-m-y),t[3]=(d-x)/z,t[0]=(g+p)/z,t[1]=(q+_)/z,t[2]=.25*z),t},fromRotationTranslationScale:function(t,n,a,r){var u=n[0],e=n[1],o=n[2],i=n[3],h=u+u,c=e+e,s=o+o,M=u*h,f=u*c,l=u*s,v=e*c,b=e*s,m=o*s,d=i*h,p=i*c,x=i*s,y=r[0],q=r[1],g=r[2];return t[0]=(1-(v+m))*y,t[1]=(f+x)*y,t[2]=(l-p)*y,t[3]=0,t[4]=(f-x)*q,t[5]=(1-(M+m))*q,t[6]=(b+d)*q,t[7]=0,t[8]=(l+p)*g,t[9]=(b-d)*g,t[10]=(1-(M+v))*g,t[11]=0,t[12]=a[0],t[13]=a[1],t[14]=a[2],t[15]=1,t},fromRotationTranslationScaleOrigin:function(t,n,a,r,u){var e=n[0],o=n[1],i=n[2],h=n[3],c=e+e,s=o+o,M=i+i,f=e*c,l=e*s,v=e*M,b=o*s,m=o*M,d=i*M,p=h*c,x=h*s,y=h*M,q=r[0],g=r[1],_=r[2],A=u[0],w=u[1],z=u[2],R=(1-(b+d))*q,O=(l+y)*q,j=(v-x)*q,E=(l-y)*g,P=(1-(f+d))*g,T=(m+p)*g,S=(v+x)*_,D=(m-p)*_,F=(1-(f+b))*_;return t[0]=R,t[1]=O,t[2]=j,t[3]=0,t[4]=E,t[5]=P,t[6]=T,t[7]=0,t[8]=S,t[9]=D,t[10]=F,t[11]=0,t[12]=a[0]+A-(R*A+E*w+S*z),t[13]=a[1]+w-(O*A+P*w+D*z),t[14]=a[2]+z-(j*A+T*w+F*z),t[15]=1,t},fromQuat:function(t,n){var a=n[0],r=n[1],u=n[2],e=n[3],o=a+a,i=r+r,h=u+u,c=a*o,s=r*o,M=r*i,f=u*o,l=u*i,v=u*h,b=e*o,m=e*i,d=e*h;return t[0]=1-M-v,t[1]=s+d,t[2]=f-m,t[3]=0,t[4]=s-d,t[5]=1-c-v,t[6]=l+b,t[7]=0,t[8]=f+m,t[9]=l-b,t[10]=1-c-M,t[11]=0,t[12]=0,t[13]=0,t[14]=0,t[15]=1,t},frustum:function(t,n,a,r,u,e,o){var i=1/(a-n),h=1/(u-r),c=1/(e-o);return t[0]=2*e*i,t[1]=0,t[2]=0,t[3]=0,t[4]=0,t[5]=2*e*h,t[6]=0,t[7]=0,t[8]=(a+n)*i,t[9]=(u+r)*h,t[10]=(o+e)*c,t[11]=-1,t[12]=0,t[13]=0,t[14]=o*e*2*c,t[15]=0,t},perspectiveNO:O,perspective:j,perspectiveZO:function(t,n,a,r,u){var e=1/Math.tan(n/2);if(t[0]=e/a,t[1]=0,t[2]=0,t[3]=0,t[4]=0,t[5]=e,t[6]=0,t[7]=0,t[8]=0,t[9]=0,t[11]=-1,t[12]=0,t[13]=0,t[15]=0,null!=u&&u!==1/0){var o=1/(r-u);t[10]=u*o,t[14]=u*r*o}else t[10]=-1,t[14]=-r;return t},perspectiveFromFieldOfView:function(t,n,a,r){var u=Math.tan(n.upDegrees*Math.PI/180),e=Math.tan(n.downDegrees*Math.PI/180),o=Math.tan(n.leftDegrees*Math.PI/180),i=Math.tan(n.rightDegrees*Math.PI/180),h=2/(o+i),c=2/(u+e);return t[0]=h,t[1]=0,t[2]=0,t[3]=0,t[4]=0,t[5]=c,t[6]=0,t[7]=0,t[8]=-(o-i)*h*.5,t[9]=(u-e)*c*.5,t[10]=r/(a-r),t[11]=-1,t[12]=0,t[13]=0,t[14]=r*a/(a-r),t[15]=0,t},orthoNO:E,ortho:P,orthoZO:function(t,n,a,r,u,e,o){var i=1/(n-a),h=1/(r-u),c=1/(e-o);return t[0]=-2*i,t[1]=0,t[2]=0,t[3]=0,t[4]=0,t[5]=-2*h,t[6]=0,t[7]=0,t[8]=0,t[9]=0,t[10]=c,t[11]=0,t[12]=(n+a)*i,t[13]=(u+r)*h,t[14]=e*c,t[15]=1,t},lookAt:function(t,a,r,u){var e,o,i,h,c,s,M,f,l,v,b=a[0],m=a[1],d=a[2],p=u[0],x=u[1],y=u[2],q=r[0],_=r[1],A=r[2];return Math.abs(b-q)<n&&Math.abs(m-_)<n&&Math.abs(d-A)<n?g(t):(M=b-q,f=m-_,l=d-A,e=x*(l*=v=1/Math.hypot(M,f,l))-y*(f*=v),o=y*(M*=v)-p*l,i=p*f-x*M,(v=Math.hypot(e,o,i))?(e*=v=1/v,o*=v,i*=v):(e=0,o=0,i=0),h=f*i-l*o,c=l*e-M*i,s=M*o-f*e,(v=Math.hypot(h,c,s))?(h*=v=1/v,c*=v,s*=v):(h=0,c=0,s=0),t[0]=e,t[1]=h,t[2]=M,t[3]=0,t[4]=o,t[5]=c,t[6]=f,t[7]=0,t[8]=i,t[9]=s,t[10]=l,t[11]=0,t[12]=-(e*b+o*m+i*d),t[13]=-(h*b+c*m+s*d),t[14]=-(M*b+f*m+l*d),t[15]=1,t)},targetTo:function(t,n,a,r){var u=n[0],e=n[1],o=n[2],i=r[0],h=r[1],c=r[2],s=u-a[0],M=e-a[1],f=o-a[2],l=s*s+M*M+f*f;l>0&&(s*=l=1/Math.sqrt(l),M*=l,f*=l);var v=h*f-c*M,b=c*s-i*f,m=i*M-h*s;return(l=v*v+b*b+m*m)>0&&(v*=l=1/Math.sqrt(l),b*=l,m*=l),t[0]=v,t[1]=b,t[2]=m,t[3]=0,t[4]=M*m-f*b,t[5]=f*v-s*m,t[6]=s*b-M*v,t[7]=0,t[8]=s,t[9]=M,t[10]=f,t[11]=0,t[12]=u,t[13]=e,t[14]=o,t[15]=1,t},str:function(t){return"mat4("+t[0]+", "+t[1]+", "+t[2]+", "+t[3]+", "+t[4]+", "+t[5]+", "+t[6]+", "+t[7]+", "+t[8]+", "+t[9]+", "+t[10]+", "+t[11]+", "+t[12]+", "+t[13]+", "+t[14]+", "+t[15]+")"},frob:function(t){return Math.hypot(t[0],t[1],t[2],t[3],t[4],t[5],t[6],t[7],t[8],t[9],t[10],t[11],t[12],t[13],t[14],t[15])},add:function(t,n,a){return t[0]=n[0]+a[0],t[1]=n[1]+a[1],t[2]=n[2]+a[2],t[3]=n[3]+a[3],t[4]=n[4]+a[4],t[5]=n[5]+a[5],t[6]=n[6]+a[6],t[7]=n[7]+a[7],t[8]=n[8]+a[8],t[9]=n[9]+a[9],t[10]=n[10]+a[10],t[11]=n[11]+a[11],t[12]=n[12]+a[12],t[13]=n[13]+a[13],t[14]=n[14]+a[14],t[15]=n[15]+a[15],t},subtract:T,multiplyScalar:function(t,n,a){return t[0]=n[0]*a,t[1]=n[1]*a,t[2]=n[2]*a,t[3]=n[3]*a,t[4]=n[4]*a,t[5]=n[5]*a,t[6]=n[6]*a,t[7]=n[7]*a,t[8]=n[8]*a,t[9]=n[9]*a,t[10]=n[10]*a,t[11]=n[11]*a,t[12]=n[12]*a,t[13]=n[13]*a,t[14]=n[14]*a,t[15]=n[15]*a,t},multiplyScalarAndAdd:function(t,n,a,r){return t[0]=n[0]+a[0]*r,t[1]=n[1]+a[1]*r,t[2]=n[2]+a[2]*r,t[3]=n[3]+a[3]*r,t[4]=n[4]+a[4]*r,t[5]=n[5]+a[5]*r,t[6]=n[6]+a[6]*r,t[7]=n[7]+a[7]*r,t[8]=n[8]+a[8]*r,t[9]=n[9]+a[9]*r,t[10]=n[10]+a[10]*r,t[11]=n[11]+a[11]*r,t[12]=n[12]+a[12]*r,t[13]=n[13]+a[13]*r,t[14]=n[14]+a[14]*r,t[15]=n[15]+a[15]*r,t},exactEquals:function(t,n){return t[0]===n[0]&&t[1]===n[1]&&t[2]===n[2]&&t[3]===n[3]&&t[4]===n[4]&&t[5]===n[5]&&t[6]===n[6]&&t[7]===n[7]&&t[8]===n[8]&&t[9]===n[9]&&t[10]===n[10]&&t[11]===n[11]&&t[12]===n[12]&&t[13]===n[13]&&t[14]===n[14]&&t[15]===n[15]},equals:function(t,a){var r=t[0],u=t[1],e=t[2],o=t[3],i=t[4],h=t[5],c=t[6],s=t[7],M=t[8],f=t[9],l=t[10],v=t[11],b=t[12],m=t[13],d=t[14],p=t[15],x=a[0],y=a[1],q=a[2],g=a[3],_=a[4],A=a[5],w=a[6],z=a[7],R=a[8],O=a[9],j=a[10],E=a[11],P=a[12],T=a[13],S=a[14],D=a[15];return Math.abs(r-x)<=n*Math.max(1,Math.abs(r),Math.abs(x))&&Math.abs(u-y)<=n*Math.max(1,Math.abs(u),Math.abs(y))&&Math.abs(e-q)<=n*Math.max(1,Math.abs(e),Math.abs(q))&&Math.abs(o-g)<=n*Math.max(1,Math.abs(o),Math.abs(g))&&Math.abs(i-_)<=n*Math.max(1,Math.abs(i),Math.abs(_))&&Math.abs(h-A)<=n*Math.max(1,Math.abs(h),Math.abs(A))&&Math.abs(c-w)<=n*Math.max(1,Math.abs(c),Math.abs(w))&&Math.abs(s-z)<=n*Math.max(1,Math.abs(s),Math.abs(z))&&Math.abs(M-R)<=n*Math.max(1,Math.abs(M),Math.abs(R))&&Math.abs(f-O)<=n*Math.max(1,Math.abs(f),Math.abs(O))&&Math.abs(l-j)<=n*Math.max(1,Math.abs(l),Math.abs(j))&&Math.abs(v-E)<=n*Math.max(1,Math.abs(v),Math.abs(E))&&Math.abs(b-P)<=n*Math.max(1,Math.abs(b),Math.abs(P))&&Math.abs(m-T)<=n*Math.max(1,Math.abs(m),Math.abs(T))&&Math.abs(d-S)<=n*Math.max(1,Math.abs(d),Math.abs(S))&&Math.abs(p-D)<=n*Math.max(1,Math.abs(p),Math.abs(D))},mul:S,sub:D});function I(){var t=new a(3);return a!=Float32Array&&(t[0]=0,t[1]=0,t[2]=0),t}function L(t){var n=t[0],a=t[1],r=t[2];return Math.hypot(n,a,r)}function V(t,n,r){var u=new a(3);return u[0]=t,u[1]=n,u[2]=r,u}function k(t,n,a){return t[0]=n[0]-a[0],t[1]=n[1]-a[1],t[2]=n[2]-a[2],t}function Q(t,n,a){return t[0]=n[0]*a[0],t[1]=n[1]*a[1],t[2]=n[2]*a[2],t}function Y(t,n,a){return t[0]=n[0]/a[0],t[1]=n[1]/a[1],t[2]=n[2]/a[2],t}function Z(t,n){var a=n[0]-t[0],r=n[1]-t[1],u=n[2]-t[2];return Math.hypot(a,r,u)}function N(t,n){var a=n[0]-t[0],r=n[1]-t[1],u=n[2]-t[2];return a*a+r*r+u*u}function X(t){var n=t[0],a=t[1],r=t[2];return n*n+a*a+r*r}function B(t,n){var a=n[0],r=n[1],u=n[2],e=a*a+r*r+u*u;return e>0&&(e=1/Math.sqrt(e)),t[0]=n[0]*e,t[1]=n[1]*e,t[2]=n[2]*e,t}function U(t,n){return t[0]*n[0]+t[1]*n[1]+t[2]*n[2]}function G(t,n,a){var r=n[0],u=n[1],e=n[2],o=a[0],i=a[1],h=a[2];return t[0]=u*h-e*i,t[1]=e*o-r*h,t[2]=r*i-u*o,t}var W,C=k,H=Q,J=Y,K=Z,$=N,tt=L,nt=X,at=(W=I(),function(t,n,a,r,u,e){var o,i;for(n||(n=3),a||(a=0),i=r?Math.min(r*n+a,t.length):t.length,o=a;o<i;o+=n)W[0]=t[o],W[1]=t[o+1],W[2]=t[o+2],u(W,W,e),t[o]=W[0],t[o+1]=W[1],t[o+2]=W[2];return t}),rt=Object.freeze({__proto__:null,create:I,clone:function(t){var n=new a(3);return n[0]=t[0],n[1]=t[1],n[2]=t[2],n},length:L,fromValues:V,copy:function(t,n){return t[0]=n[0],t[1]=n[1],t[2]=n[2],t},set:function(t,n,a,r){return t[0]=n,t[1]=a,t[2]=r,t},add:function(t,n,a){return t[0]=n[0]+a[0],t[1]=n[1]+a[1],t[2]=n[2]+a[2],t},subtract:k,multiply:Q,divide:Y,ceil:function(t,n){return t[0]=Math.ceil(n[0]),t[1]=Math.ceil(n[1]),t[2]=Math.ceil(n[2]),t},floor:function(t,n){return t[0]=Math.floor(n[0]),t[1]=Math.floor(n[1]),t[2]=Math.floor(n[2]),t},min:function(t,n,a){return t[0]=Math.min(n[0],a[0]),t[1]=Math.min(n[1],a[1]),t[2]=Math.min(n[2],a[2]),t},max:function(t,n,a){return t[0]=Math.max(n[0],a[0]),t[1]=Math.max(n[1],a[1]),t[2]=Math.max(n[2],a[2]),t},round:function(t,n){return t[0]=Math.round(n[0]),t[1]=Math.round(n[1]),t[2]=Math.round(n[2]),t},scale:function(t,n,a){return t[0]=n[0]*a,t[1]=n[1]*a,t[2]=n[2]*a,t},scaleAndAdd:function(t,n,a,r){return t[0]=n[0]+a[0]*r,t[1]=n[1]+a[1]*r,t[2]=n[2]+a[2]*r,t},distance:Z,squaredDistance:N,squaredLength:X,negate:function(t,n){return t[0]=-n[0],t[1]=-n[1],t[2]=-n[2],t},inverse:function(t,n){return t[0]=1/n[0],t[1]=1/n[1],t[2]=1/n[2],t},normalize:B,dot:U,cross:G,lerp:function(t,n,a,r){var u=n[0],e=n[1],o=n[2];return t[0]=u+r*(a[0]-u),t[1]=e+r*(a[1]-e),t[2]=o+r*(a[2]-o),t},slerp:function(t,n,a,r){var u=Math.acos(Math.min(Math.max(U(n,a),-1),1)),e=Math.sin(u),o=Math.sin((1-r)*u)/e,i=Math.sin(r*u)/e;return t[0]=o*n[0]+i*a[0],t[1]=o*n[1]+i*a[1],t[2]=o*n[2]+i*a[2],t},hermite:function(t,n,a,r,u,e){var o=e*e,i=o*(2*e-3)+1,h=o*(e-2)+e,c=o*(e-1),s=o*(3-2*e);return t[0]=n[0]*i+a[0]*h+r[0]*c+u[0]*s,t[1]=n[1]*i+a[1]*h+r[1]*c+u[1]*s,t[2]=n[2]*i+a[2]*h+r[2]*c+u[2]*s,t},bezier:function(t,n,a,r,u,e){var o=1-e,i=o*o,h=e*e,c=i*o,s=3*e*i,M=3*h*o,f=h*e;return t[0]=n[0]*c+a[0]*s+r[0]*M+u[0]*f,t[1]=n[1]*c+a[1]*s+r[1]*M+u[1]*f,t[2]=n[2]*c+a[2]*s+r[2]*M+u[2]*f,t},random:function(t,n){n=n||1;var a=2*r()*Math.PI,u=2*r()-1,e=Math.sqrt(1-u*u)*n;return t[0]=Math.cos(a)*e,t[1]=Math.sin(a)*e,t[2]=u*n,t},transformMat4:function(t,n,a){var r=n[0],u=n[1],e=n[2],o=a[3]*r+a[7]*u+a[11]*e+a[15];return o=o||1,t[0]=(a[0]*r+a[4]*u+a[8]*e+a[12])/o,t[1]=(a[1]*r+a[5]*u+a[9]*e+a[13])/o,t[2]=(a[2]*r+a[6]*u+a[10]*e+a[14])/o,t},transformMat3:function(t,n,a){var r=n[0],u=n[1],e=n[2];return t[0]=r*a[0]+u*a[3]+e*a[6],t[1]=r*a[1]+u*a[4]+e*a[7],t[2]=r*a[2]+u*a[5]+e*a[8],t},transformQuat:function(t,n,a){var r=a[0],u=a[1],e=a[2],o=a[3],i=n[0],h=n[1],c=n[2],s=u*c-e*h,M=e*i-r*c,f=r*h-u*i,l=u*f-e*M,v=e*s-r*f,b=r*M-u*s,m=2*o;return s*=m,M*=m,f*=m,l*=2,v*=2,b*=2,t[0]=i+s+l,t[1]=h+M+v,t[2]=c+f+b,t},rotateX:function(t,n,a,r){var u=[],e=[];return u[0]=n[0]-a[0],u[1]=n[1]-a[1],u[2]=n[2]-a[2],e[0]=u[0],e[1]=u[1]*Math.cos(r)-u[2]*Math.sin(r),e[2]=u[1]*Math.sin(r)+u[2]*Math.cos(r),t[0]=e[0]+a[0],t[1]=e[1]+a[1],t[2]=e[2]+a[2],t},rotateY:function(t,n,a,r){var u=[],e=[];return u[0]=n[0]-a[0],u[1]=n[1]-a[1],u[2]=n[2]-a[2],e[0]=u[2]*Math.sin(r)+u[0]*Math.cos(r),e[1]=u[1],e[2]=u[2]*Math.cos(r)-u[0]*Math.sin(r),t[0]=e[0]+a[0],t[1]=e[1]+a[1],t[2]=e[2]+a[2],t},rotateZ:function(t,n,a,r){var u=[],e=[];return u[0]=n[0]-a[0],u[1]=n[1]-a[1],u[2]=n[2]-a[2],e[0]=u[0]*Math.cos(r)-u[1]*Math.sin(r),e[1]=u[0]*Math.sin(r)+u[1]*Math.cos(r),e[2]=u[2],t[0]=e[0]+a[0],t[1]=e[1]+a[1],t[2]=e[2]+a[2],t},angle:function(t,n){var a=t[0],r=t[1],u=t[2],e=n[0],o=n[1],i=n[2],h=Math.sqrt((a*a+r*r+u*u)*(e*e+o*o+i*i)),c=h&&U(t,n)/h;return Math.acos(Math.min(Math.max(c,-1),1))},zero:function(t){return t[0]=0,t[1]=0,t[2]=0,t},str:function(t){return"vec3("+t[0]+", "+t[1]+", "+t[2]+")"},exactEquals:function(t,n){return t[0]===n[0]&&t[1]===n[1]&&t[2]===n[2]},equals:function(t,a){var r=t[0],u=t[1],e=t[2],o=a[0],i=a[1],h=a[2];return Math.abs(r-o)<=n*Math.max(1,Math.abs(r),Math.abs(o))&&Math.abs(u-i)<=n*Math.max(1,Math.abs(u),Math.abs(i))&&Math.abs(e-h)<=n*Math.max(1,Math.abs(e),Math.abs(h))},sub:C,mul:H,div:J,dist:K,sqrDist:$,len:tt,sqrLen:nt,forEach:at});function ut(){var t=new a(4);return a!=Float32Array&&(t[0]=0,t[1]=0,t[2]=0,t[3]=0),t}function et(t){var n=new a(4);return n[0]=t[0],n[1]=t[1],n[2]=t[2],n[3]=t[3],n}function ot(t,n,r,u){var e=new a(4);return e[0]=t,e[1]=n,e[2]=r,e[3]=u,e}function it(t,n){return t[0]=n[0],t[1]=n[1],t[2]=n[2],t[3]=n[3],t}function ht(t,n,a,r,u){return t[0]=n,t[1]=a,t[2]=r,t[3]=u,t}function ct(t,n,a){return t[0]=n[0]+a[0],t[1]=n[1]+a[1],t[2]=n[2]+a[2],t[3]=n[3]+a[3],t}function st(t,n,a){return t[0]=n[0]-a[0],t[1]=n[1]-a[1],t[2]=n[2]-a[2],t[3]=n[3]-a[3],t}function Mt(t,n,a){return t[0]=n[0]*a[0],t[1]=n[1]*a[1],t[2]=n[2]*a[2],t[3]=n[3]*a[3],t}function ft(t,n,a){return t[0]=n[0]/a[0],t[1]=n[1]/a[1],t[2]=n[2]/a[2],t[3]=n[3]/a[3],t}function lt(t,n,a){return t[0]=n[0]*a,t[1]=n[1]*a,t[2]=n[2]*a,t[3]=n[3]*a,t}function vt(t,n){var a=n[0]-t[0],r=n[1]-t[1],u=n[2]-t[2],e=n[3]-t[3];return Math.hypot(a,r,u,e)}function bt(t,n){var a=n[0]-t[0],r=n[1]-t[1],u=n[2]-t[2],e=n[3]-t[3];return a*a+r*r+u*u+e*e}function mt(t){var n=t[0],a=t[1],r=t[2],u=t[3];return Math.hypot(n,a,r,u)}function dt(t){var n=t[0],a=t[1],r=t[2],u=t[3];return n*n+a*a+r*r+u*u}function pt(t,n){var a=n[0],r=n[1],u=n[2],e=n[3],o=a*a+r*r+u*u+e*e;return o>0&&(o=1/Math.sqrt(o)),t[0]=a*o,t[1]=r*o,t[2]=u*o,t[3]=e*o,t}function xt(t,n){return t[0]*n[0]+t[1]*n[1]+t[2]*n[2]+t[3]*n[3]}function yt(t,n,a,r){var u=n[0],e=n[1],o=n[2],i=n[3];return t[0]=u+r*(a[0]-u),t[1]=e+r*(a[1]-e),t[2]=o+r*(a[2]-o),t[3]=i+r*(a[3]-i),t}function qt(t,n){return t[0]===n[0]&&t[1]===n[1]&&t[2]===n[2]&&t[3]===n[3]}var gt=st,_t=Mt,At=ft,wt=vt,zt=bt,Rt=mt,Ot=dt,jt=function(){var t=ut();return function(n,a,r,u,e,o){var i,h;for(a||(a=4),r||(r=0),h=u?Math.min(u*a+r,n.length):n.length,i=r;i<h;i+=a)t[0]=n[i],t[1]=n[i+1],t[2]=n[i+2],t[3]=n[i+3],e(t,t,o),n[i]=t[0],n[i+1]=t[1],n[i+2]=t[2],n[i+3]=t[3];return n}}(),Et=Object.freeze({__proto__:null,create:ut,clone:et,fromValues:ot,copy:it,set:ht,add:ct,subtract:st,multiply:Mt,divide:ft,ceil:function(t,n){return t[0]=Math.ceil(n[0]),t[1]=Math.ceil(n[1]),t[2]=Math.ceil(n[2]),t[3]=Math.ceil(n[3]),t},floor:function(t,n){return t[0]=Math.floor(n[0]),t[1]=Math.floor(n[1]),t[2]=Math.floor(n[2]),t[3]=Math.floor(n[3]),t},min:function(t,n,a){return t[0]=Math.min(n[0],a[0]),t[1]=Math.min(n[1],a[1]),t[2]=Math.min(n[2],a[2]),t[3]=Math.min(n[3],a[3]),t},max:function(t,n,a){return t[0]=Math.max(n[0],a[0]),t[1]=Math.max(n[1],a[1]),t[2]=Math.max(n[2],a[2]),t[3]=Math.max(n[3],a[3]),t},round:function(t,n){return t[0]=Math.round(n[0]),t[1]=Math.round(n[1]),t[2]=Math.round(n[2]),t[3]=Math.round(n[3]),t},scale:lt,scaleAndAdd:function(t,n,a,r){return t[0]=n[0]+a[0]*r,t[1]=n[1]+a[1]*r,t[2]=n[2]+a[2]*r,t[3]=n[3]+a[3]*r,t},distance:vt,squaredDistance:bt,length:mt,squaredLength:dt,negate:function(t,n){return t[0]=-n[0],t[1]=-n[1],t[2]=-n[2],t[3]=-n[3],t},inverse:function(t,n){return t[0]=1/n[0],t[1]=1/n[1],t[2]=1/n[2],t[3]=1/n[3],t},normalize:pt,dot:xt,cross:function(t,n,a,r){var u=a[0]*r[1]-a[1]*r[0],e=a[0]*r[2]-a[2]*r[0],o=a[0]*r[3]-a[3]*r[0],i=a[1]*r[2]-a[2]*r[1],h=a[1]*r[3]-a[3]*r[1],c=a[2]*r[3]-a[3]*r[2],s=n[0],M=n[1],f=n[2],l=n[3];return t[0]=M*c-f*h+l*i,t[1]=-s*c+f*o-l*e,t[2]=s*h-M*o+l*u,t[3]=-s*i+M*e-f*u,t},lerp:yt,random:function(t,n){var a,u,e,o,i,h;n=n||1;do{i=(a=2*r()-1)*a+(u=2*r()-1)*u}while(i>=1);do{h=(e=2*r()-1)*e+(o=2*r()-1)*o}while(h>=1);var c=Math.sqrt((1-i)/h);return t[0]=n*a,t[1]=n*u,t[2]=n*e*c,t[3]=n*o*c,t},transformMat4:function(t,n,a){var r=n[0],u=n[1],e=n[2],o=n[3];return t[0]=a[0]*r+a[4]*u+a[8]*e+a[12]*o,t[1]=a[1]*r+a[5]*u+a[9]*e+a[13]*o,t[2]=a[2]*r+a[6]*u+a[10]*e+a[14]*o,t[3]=a[3]*r+a[7]*u+a[11]*e+a[15]*o,t},transformQuat:function(t,n,a){var r=n[0],u=n[1],e=n[2],o=a[0],i=a[1],h=a[2],c=a[3],s=c*r+i*e-h*u,M=c*u+h*r-o*e,f=c*e+o*u-i*r,l=-o*r-i*u-h*e;return t[0]=s*c+l*-o+M*-h-f*-i,t[1]=M*c+l*-i+f*-o-s*-h,t[2]=f*c+l*-h+s*-i-M*-o,t[3]=n[3],t},zero:function(t){return t[0]=0,t[1]=0,t[2]=0,t[3]=0,t},str:function(t){return"vec4("+t[0]+", "+t[1]+", "+t[2]+", "+t[3]+")"},exactEquals:qt,equals:function(t,a){var r=t[0],u=t[1],e=t[2],o=t[3],i=a[0],h=a[1],c=a[2],s=a[3];return Math.abs(r-i)<=n*Math.max(1,Math.abs(r),Math.abs(i))&&Math.abs(u-h)<=n*Math.max(1,Math.abs(u),Math.abs(h))&&Math.abs(e-c)<=n*Math.max(1,Math.abs(e),Math.abs(c))&&Math.abs(o-s)<=n*Math.max(1,Math.abs(o),Math.abs(s))},sub:gt,mul:_t,div:At,dist:wt,sqrDist:zt,len:Rt,sqrLen:Ot,forEach:jt});function Pt(){var t=new a(4);return a!=Float32Array&&(t[0]=0,t[1]=0,t[2]=0),t[3]=1,t}function Tt(t,n,a){a*=.5;var r=Math.sin(a);return t[0]=r*n[0],t[1]=r*n[1],t[2]=r*n[2],t[3]=Math.cos(a),t}function St(t,n,a){var r=n[0],u=n[1],e=n[2],o=n[3],i=a[0],h=a[1],c=a[2],s=a[3];return t[0]=r*s+o*i+u*c-e*h,t[1]=u*s+o*h+e*i-r*c,t[2]=e*s+o*c+r*h-u*i,t[3]=o*s-r*i-u*h-e*c,t}function Dt(t,n,a){a*=.5;var r=n[0],u=n[1],e=n[2],o=n[3],i=Math.sin(a),h=Math.cos(a);return t[0]=r*h+o*i,t[1]=u*h+e*i,t[2]=e*h-u*i,t[3]=o*h-r*i,t}function Ft(t,n,a){a*=.5;var r=n[0],u=n[1],e=n[2],o=n[3],i=Math.sin(a),h=Math.cos(a);return t[0]=r*h-e*i,t[1]=u*h+o*i,t[2]=e*h+r*i,t[3]=o*h-u*i,t}function It(t,n,a){a*=.5;var r=n[0],u=n[1],e=n[2],o=n[3],i=Math.sin(a),h=Math.cos(a);return t[0]=r*h+u*i,t[1]=u*h-r*i,t[2]=e*h+o*i,t[3]=o*h-e*i,t}function Lt(t,n){var a=n[0],r=n[1],u=n[2],e=n[3],o=Math.sqrt(a*a+r*r+u*u),i=Math.exp(e),h=o>0?i*Math.sin(o)/o:0;return t[0]=a*h,t[1]=r*h,t[2]=u*h,t[3]=i*Math.cos(o),t}function Vt(t,n){var a=n[0],r=n[1],u=n[2],e=n[3],o=Math.sqrt(a*a+r*r+u*u),i=o>0?Math.atan2(o,e)/o:0;return t[0]=a*i,t[1]=r*i,t[2]=u*i,t[3]=.5*Math.log(a*a+r*r+u*u+e*e),t}function kt(t,a,r,u){var e,o,i,h,c,s=a[0],M=a[1],f=a[2],l=a[3],v=r[0],b=r[1],m=r[2],d=r[3];return(o=s*v+M*b+f*m+l*d)<0&&(o=-o,v=-v,b=-b,m=-m,d=-d),1-o>n?(e=Math.acos(o),i=Math.sin(e),h=Math.sin((1-u)*e)/i,c=Math.sin(u*e)/i):(h=1-u,c=u),t[0]=h*s+c*v,t[1]=h*M+c*b,t[2]=h*f+c*m,t[3]=h*l+c*d,t}function Qt(t,n){var a,r=n[0]+n[4]+n[8];if(r>0)a=Math.sqrt(r+1),t[3]=.5*a,a=.5/a,t[0]=(n[5]-n[7])*a,t[1]=(n[6]-n[2])*a,t[2]=(n[1]-n[3])*a;else{var u=0;n[4]>n[0]&&(u=1),n[8]>n[3*u+u]&&(u=2);var e=(u+1)%3,o=(u+2)%3;a=Math.sqrt(n[3*u+u]-n[3*e+e]-n[3*o+o]+1),t[u]=.5*a,a=.5/a,t[3]=(n[3*e+o]-n[3*o+e])*a,t[e]=(n[3*e+u]+n[3*u+e])*a,t[o]=(n[3*o+u]+n[3*u+o])*a}return t}var Yt=et,Zt=ot,Nt=it,Xt=ht,Bt=ct,Ut=St,Gt=lt,Wt=xt,Ct=yt,Ht=mt,Jt=Ht,Kt=dt,$t=Kt,tn=pt,nn=qt;var an,rn,un,en,on,hn,cn=(an=I(),rn=V(1,0,0),un=V(0,1,0),function(t,n,a){var r=U(n,a);return r<-.999999?(G(an,rn,n),tt(an)<1e-6&&G(an,un,n),B(an,an),Tt(t,an,Math.PI),t):r>.999999?(t[0]=0,t[1]=0,t[2]=0,t[3]=1,t):(G(an,n,a),t[0]=an[0],t[1]=an[1],t[2]=an[2],t[3]=1+r,tn(t,t))}),sn=(en=Pt(),on=Pt(),function(t,n,a,r,u,e){return kt(en,n,u,e),kt(on,a,r,e),kt(t,en,on,2*e*(1-e)),t}),Mn=(hn=m(),function(t,n,a,r){return hn[0]=a[0],hn[3]=a[1],hn[6]=a[2],hn[1]=r[0],hn[4]=r[1],hn[7]=r[2],hn[2]=-n[0],hn[5]=-n[1],hn[8]=-n[2],tn(t,Qt(t,hn))}),fn=Object.freeze({__proto__:null,create:Pt,identity:function(t){return t[0]=0,t[1]=0,t[2]=0,t[3]=1,t},setAxisAngle:Tt,getAxisAngle:function(t,a){var r=2*Math.acos(a[3]),u=Math.sin(r/2);return u>n?(t[0]=a[0]/u,t[1]=a[1]/u,t[2]=a[2]/u):(t[0]=1,t[1]=0,t[2]=0),r},getAngle:function(t,n){var a=Wt(t,n);return Math.acos(2*a*a-1)},multiply:St,rotateX:Dt,rotateY:Ft,rotateZ:It,calculateW:function(t,n){var a=n[0],r=n[1],u=n[2];return t[0]=a,t[1]=r,t[2]=u,t[3]=Math.sqrt(Math.abs(1-a*a-r*r-u*u)),t},exp:Lt,ln:Vt,pow:function(t,n,a){return Vt(t,n),Gt(t,t,a),Lt(t,t),t},slerp:kt,random:function(t){var n=r(),a=r(),u=r(),e=Math.sqrt(1-n),o=Math.sqrt(n);return t[0]=e*Math.sin(2*Math.PI*a),t[1]=e*Math.cos(2*Math.PI*a),t[2]=o*Math.sin(2*Math.PI*u),t[3]=o*Math.cos(2*Math.PI*u),t},invert:function(t,n){var a=n[0],r=n[1],u=n[2],e=n[3],o=a*a+r*r+u*u+e*e,i=o?1/o:0;return t[0]=-a*i,t[1]=-r*i,t[2]=-u*i,t[3]=e*i,t},conjugate:function(t,n){return t[0]=-n[0],t[1]=-n[1],t[2]=-n[2],t[3]=n[3],t},fromMat3:Qt,fromEuler:function(t,n,a,r){var u=arguments.length>4&&void 0!==arguments[4]?arguments[4]:"zyx",e=Math.PI/360;n*=e,r*=e,a*=e;var o=Math.sin(n),i=Math.cos(n),h=Math.sin(a),c=Math.cos(a),s=Math.sin(r),M=Math.cos(r);switch(u){case"xyz":t[0]=o*c*M+i*h*s,t[1]=i*h*M-o*c*s,t[2]=i*c*s+o*h*M,t[3]=i*c*M-o*h*s;break;case"xzy":t[0]=o*c*M-i*h*s,t[1]=i*h*M-o*c*s,t[2]=i*c*s+o*h*M,t[3]=i*c*M+o*h*s;break;case"yxz":t[0]=o*c*M+i*h*s,t[1]=i*h*M-o*c*s,t[2]=i*c*s-o*h*M,t[3]=i*c*M+o*h*s;break;case"yzx":t[0]=o*c*M+i*h*s,t[1]=i*h*M+o*c*s,t[2]=i*c*s-o*h*M,t[3]=i*c*M-o*h*s;break;case"zxy":t[0]=o*c*M-i*h*s,t[1]=i*h*M+o*c*s,t[2]=i*c*s+o*h*M,t[3]=i*c*M-o*h*s;break;case"zyx":t[0]=o*c*M-i*h*s,t[1]=i*h*M+o*c*s,t[2]=i*c*s-o*h*M,t[3]=i*c*M+o*h*s;break;default:throw new Error("Unknown angle order "+u)}return t},str:function(t){return"quat("+t[0]+", "+t[1]+", "+t[2]+", "+t[3]+")"},clone:Yt,fromValues:Zt,copy:Nt,set:Xt,add:Bt,mul:Ut,scale:Gt,dot:Wt,lerp:Ct,length:Ht,len:Jt,squaredLength:Kt,sqrLen:$t,normalize:tn,exactEquals:nn,equals:function(t,a){return Math.abs(xt(t,a))>=1-n},rotationTo:cn,sqlerp:sn,setAxes:Mn});function ln(t,n,a){var r=.5*a[0],u=.5*a[1],e=.5*a[2],o=n[0],i=n[1],h=n[2],c=n[3];return t[0]=o,t[1]=i,t[2]=h,t[3]=c,t[4]=r*c+u*h-e*i,t[5]=u*c+e*o-r*h,t[6]=e*c+r*i-u*o,t[7]=-r*o-u*i-e*h,t}function vn(t,n){return t[0]=n[0],t[1]=n[1],t[2]=n[2],t[3]=n[3],t[4]=n[4],t[5]=n[5],t[6]=n[6],t[7]=n[7],t}var bn=Nt;var mn=Nt;function dn(t,n,a){var r=n[0],u=n[1],e=n[2],o=n[3],i=a[4],h=a[5],c=a[6],s=a[7],M=n[4],f=n[5],l=n[6],v=n[7],b=a[0],m=a[1],d=a[2],p=a[3];return t[0]=r*p+o*b+u*d-e*m,t[1]=u*p+o*m+e*b-r*d,t[2]=e*p+o*d+r*m-u*b,t[3]=o*p-r*b-u*m-e*d,t[4]=r*s+o*i+u*c-e*h+M*p+v*b+f*d-l*m,t[5]=u*s+o*h+e*i-r*c+f*p+v*m+l*b-M*d,t[6]=e*s+o*c+r*h-u*i+l*p+v*d+M*m-f*b,t[7]=o*s-r*i-u*h-e*c+v*p-M*b-f*m-l*d,t}var pn=dn;var xn=Wt;var yn=Ht,qn=yn,gn=Kt,_n=gn;var An=Object.freeze({__proto__:null,create:function(){var t=new a(8);return a!=Float32Array&&(t[0]=0,t[1]=0,t[2]=0,t[4]=0,t[5]=0,t[6]=0,t[7]=0),t[3]=1,t},clone:function(t){var n=new a(8);return n[0]=t[0],n[1]=t[1],n[2]=t[2],n[3]=t[3],n[4]=t[4],n[5]=t[5],n[6]=t[6],n[7]=t[7],n},fromValues:function(t,n,r,u,e,o,i,h){var c=new a(8);return c[0]=t,c[1]=n,c[2]=r,c[3]=u,c[4]=e,c[5]=o,c[6]=i,c[7]=h,c},fromRotationTranslationValues:function(t,n,r,u,e,o,i){var h=new a(8);h[0]=t,h[1]=n,h[2]=r,h[3]=u;var c=.5*e,s=.5*o,M=.5*i;return h[4]=c*u+s*r-M*n,h[5]=s*u+M*t-c*r,h[6]=M*u+c*n-s*t,h[7]=-c*t-s*n-M*r,h},fromRotationTranslation:ln,fromTranslation:function(t,n){return t[0]=0,t[1]=0,t[2]=0,t[3]=1,t[4]=.5*n[0],t[5]=.5*n[1],t[6]=.5*n[2],t[7]=0,t},fromRotation:function(t,n){return t[0]=n[0],t[1]=n[1],t[2]=n[2],t[3]=n[3],t[4]=0,t[5]=0,t[6]=0,t[7]=0,t},fromMat4:function(t,n){var r=Pt();R(r,n);var u=new a(3);return w(u,n),ln(t,r,u),t},copy:vn,identity:function(t){return t[0]=0,t[1]=0,t[2]=0,t[3]=1,t[4]=0,t[5]=0,t[6]=0,t[7]=0,t},set:function(t,n,a,r,u,e,o,i,h){return t[0]=n,t[1]=a,t[2]=r,t[3]=u,t[4]=e,t[5]=o,t[6]=i,t[7]=h,t},getReal:bn,getDual:function(t,n){return t[0]=n[4],t[1]=n[5],t[2]=n[6],t[3]=n[7],t},setReal:mn,setDual:function(t,n){return t[4]=n[0],t[5]=n[1],t[6]=n[2],t[7]=n[3],t},getTranslation:function(t,n){var a=n[4],r=n[5],u=n[6],e=n[7],o=-n[0],i=-n[1],h=-n[2],c=n[3];return t[0]=2*(a*c+e*o+r*h-u*i),t[1]=2*(r*c+e*i+u*o-a*h),t[2]=2*(u*c+e*h+a*i-r*o),t},translate:function(t,n,a){var r=n[0],u=n[1],e=n[2],o=n[3],i=.5*a[0],h=.5*a[1],c=.5*a[2],s=n[4],M=n[5],f=n[6],l=n[7];return t[0]=r,t[1]=u,t[2]=e,t[3]=o,t[4]=o*i+u*c-e*h+s,t[5]=o*h+e*i-r*c+M,t[6]=o*c+r*h-u*i+f,t[7]=-r*i-u*h-e*c+l,t},rotateX:function(t,n,a){var r=-n[0],u=-n[1],e=-n[2],o=n[3],i=n[4],h=n[5],c=n[6],s=n[7],M=i*o+s*r+h*e-c*u,f=h*o+s*u+c*r-i*e,l=c*o+s*e+i*u-h*r,v=s*o-i*r-h*u-c*e;return Dt(t,n,a),r=t[0],u=t[1],e=t[2],o=t[3],t[4]=M*o+v*r+f*e-l*u,t[5]=f*o+v*u+l*r-M*e,t[6]=l*o+v*e+M*u-f*r,t[7]=v*o-M*r-f*u-l*e,t},rotateY:function(t,n,a){var r=-n[0],u=-n[1],e=-n[2],o=n[3],i=n[4],h=n[5],c=n[6],s=n[7],M=i*o+s*r+h*e-c*u,f=h*o+s*u+c*r-i*e,l=c*o+s*e+i*u-h*r,v=s*o-i*r-h*u-c*e;return Ft(t,n,a),r=t[0],u=t[1],e=t[2],o=t[3],t[4]=M*o+v*r+f*e-l*u,t[5]=f*o+v*u+l*r-M*e,t[6]=l*o+v*e+M*u-f*r,t[7]=v*o-M*r-f*u-l*e,t},rotateZ:function(t,n,a){var r=-n[0],u=-n[1],e=-n[2],o=n[3],i=n[4],h=n[5],c=n[6],s=n[7],M=i*o+s*r+h*e-c*u,f=h*o+s*u+c*r-i*e,l=c*o+s*e+i*u-h*r,v=s*o-i*r-h*u-c*e;return It(t,n,a),r=t[0],u=t[1],e=t[2],o=t[3],t[4]=M*o+v*r+f*e-l*u,t[5]=f*o+v*u+l*r-M*e,t[6]=l*o+v*e+M*u-f*r,t[7]=v*o-M*r-f*u-l*e,t},rotateByQuatAppend:function(t,n,a){var r=a[0],u=a[1],e=a[2],o=a[3],i=n[0],h=n[1],c=n[2],s=n[3];return t[0]=i*o+s*r+h*e-c*u,t[1]=h*o+s*u+c*r-i*e,t[2]=c*o+s*e+i*u-h*r,t[3]=s*o-i*r-h*u-c*e,i=n[4],h=n[5],c=n[6],s=n[7],t[4]=i*o+s*r+h*e-c*u,t[5]=h*o+s*u+c*r-i*e,t[6]=c*o+s*e+i*u-h*r,t[7]=s*o-i*r-h*u-c*e,t},rotateByQuatPrepend:function(t,n,a){var r=n[0],u=n[1],e=n[2],o=n[3],i=a[0],h=a[1],c=a[2],s=a[3];return t[0]=r*s+o*i+u*c-e*h,t[1]=u*s+o*h+e*i-r*c,t[2]=e*s+o*c+r*h-u*i,t[3]=o*s-r*i-u*h-e*c,i=a[4],h=a[5],c=a[6],s=a[7],t[4]=r*s+o*i+u*c-e*h,t[5]=u*s+o*h+e*i-r*c,t[6]=e*s+o*c+r*h-u*i,t[7]=o*s-r*i-u*h-e*c,t},rotateAroundAxis:function(t,a,r,u){if(Math.abs(u)<n)return vn(t,a);var e=Math.hypot(r[0],r[1],r[2]);u*=.5;var o=Math.sin(u),i=o*r[0]/e,h=o*r[1]/e,c=o*r[2]/e,s=Math.cos(u),M=a[0],f=a[1],l=a[2],v=a[3];t[0]=M*s+v*i+f*c-l*h,t[1]=f*s+v*h+l*i-M*c,t[2]=l*s+v*c+M*h-f*i,t[3]=v*s-M*i-f*h-l*c;var b=a[4],m=a[5],d=a[6],p=a[7];return t[4]=b*s+p*i+m*c-d*h,t[5]=m*s+p*h+d*i-b*c,t[6]=d*s+p*c+b*h-m*i,t[7]=p*s-b*i-m*h-d*c,t},add:function(t,n,a){return t[0]=n[0]+a[0],t[1]=n[1]+a[1],t[2]=n[2]+a[2],t[3]=n[3]+a[3],t[4]=n[4]+a[4],t[5]=n[5]+a[5],t[6]=n[6]+a[6],t[7]=n[7]+a[7],t},multiply:dn,mul:pn,scale:function(t,n,a){return t[0]=n[0]*a,t[1]=n[1]*a,t[2]=n[2]*a,t[3]=n[3]*a,t[4]=n[4]*a,t[5]=n[5]*a,t[6]=n[6]*a,t[7]=n[7]*a,t},dot:xn,lerp:function(t,n,a,r){var u=1-r;return xn(n,a)<0&&(r=-r),t[0]=n[0]*u+a[0]*r,t[1]=n[1]*u+a[1]*r,t[2]=n[2]*u+a[2]*r,t[3]=n[3]*u+a[3]*r,t[4]=n[4]*u+a[4]*r,t[5]=n[5]*u+a[5]*r,t[6]=n[6]*u+a[6]*r,t[7]=n[7]*u+a[7]*r,t},invert:function(t,n){var a=gn(n);return t[0]=-n[0]/a,t[1]=-n[1]/a,t[2]=-n[2]/a,t[3]=n[3]/a,t[4]=-n[4]/a,t[5]=-n[5]/a,t[6]=-n[6]/a,t[7]=n[7]/a,t},conjugate:function(t,n){return t[0]=-n[0],t[1]=-n[1],t[2]=-n[2],t[3]=n[3],t[4]=-n[4],t[5]=-n[5],t[6]=-n[6],t[7]=n[7],t},length:yn,len:qn,squaredLength:gn,sqrLen:_n,normalize:function(t,n){var a=gn(n);if(a>0){a=Math.sqrt(a);var r=n[0]/a,u=n[1]/a,e=n[2]/a,o=n[3]/a,i=n[4],h=n[5],c=n[6],s=n[7],M=r*i+u*h+e*c+o*s;t[0]=r,t[1]=u,t[2]=e,t[3]=o,t[4]=(i-r*M)/a,t[5]=(h-u*M)/a,t[6]=(c-e*M)/a,t[7]=(s-o*M)/a}return t},str:function(t){return"quat2("+t[0]+", "+t[1]+", "+t[2]+", "+t[3]+", "+t[4]+", "+t[5]+", "+t[6]+", "+t[7]+")"},exactEquals:function(t,n){return t[0]===n[0]&&t[1]===n[1]&&t[2]===n[2]&&t[3]===n[3]&&t[4]===n[4]&&t[5]===n[5]&&t[6]===n[6]&&t[7]===n[7]},equals:function(t,a){var r=t[0],u=t[1],e=t[2],o=t[3],i=t[4],h=t[5],c=t[6],s=t[7],M=a[0],f=a[1],l=a[2],v=a[3],b=a[4],m=a[5],d=a[6],p=a[7];return Math.abs(r-M)<=n*Math.max(1,Math.abs(r),Math.abs(M))&&Math.abs(u-f)<=n*Math.max(1,Math.abs(u),Math.abs(f))&&Math.abs(e-l)<=n*Math.max(1,Math.abs(e),Math.abs(l))&&Math.abs(o-v)<=n*Math.max(1,Math.abs(o),Math.abs(v))&&Math.abs(i-b)<=n*Math.max(1,Math.abs(i),Math.abs(b))&&Math.abs(h-m)<=n*Math.max(1,Math.abs(h),Math.abs(m))&&Math.abs(c-d)<=n*Math.max(1,Math.abs(c),Math.abs(d))&&Math.abs(s-p)<=n*Math.max(1,Math.abs(s),Math.abs(p))}});function wn(){var t=new a(2);return a!=Float32Array&&(t[0]=0,t[1]=0),t}function zn(t,n,a){return t[0]=n[0]-a[0],t[1]=n[1]-a[1],t}function Rn(t,n,a){return t[0]=n[0]*a[0],t[1]=n[1]*a[1],t}function On(t,n,a){return t[0]=n[0]/a[0],t[1]=n[1]/a[1],t}function jn(t,n){var a=n[0]-t[0],r=n[1]-t[1];return Math.hypot(a,r)}function En(t,n){var a=n[0]-t[0],r=n[1]-t[1];return a*a+r*r}function Pn(t){var n=t[0],a=t[1];return Math.hypot(n,a)}function Tn(t){var n=t[0],a=t[1];return n*n+a*a}var Sn=Pn,Dn=zn,Fn=Rn,In=On,Ln=jn,Vn=En,kn=Tn,Qn=function(){var t=wn();return function(n,a,r,u,e,o){var i,h;for(a||(a=2),r||(r=0),h=u?Math.min(u*a+r,n.length):n.length,i=r;i<h;i+=a)t[0]=n[i],t[1]=n[i+1],e(t,t,o),n[i]=t[0],n[i+1]=t[1];return n}}(),Yn=Object.freeze({__proto__:null,create:wn,clone:function(t){var n=new a(2);return n[0]=t[0],n[1]=t[1],n},fromValues:function(t,n){var r=new a(2);return r[0]=t,r[1]=n,r},copy:function(t,n){return t[0]=n[0],t[1]=n[1],t},set:function(t,n,a){return t[0]=n,t[1]=a,t},add:function(t,n,a){return t[0]=n[0]+a[0],t[1]=n[1]+a[1],t},subtract:zn,multiply:Rn,divide:On,ceil:function(t,n){return t[0]=Math.ceil(n[0]),t[1]=Math.ceil(n[1]),t},floor:function(t,n){return t[0]=Math.floor(n[0]),t[1]=Math.floor(n[1]),t},min:function(t,n,a){return t[0]=Math.min(n[0],a[0]),t[1]=Math.min(n[1],a[1]),t},max:function(t,n,a){return t[0]=Math.max(n[0],a[0]),t[1]=Math.max(n[1],a[1]),t},round:function(t,n){return t[0]=Math.round(n[0]),t[1]=Math.round(n[1]),t},scale:function(t,n,a){return t[0]=n[0]*a,t[1]=n[1]*a,t},scaleAndAdd:function(t,n,a,r){return t[0]=n[0]+a[0]*r,t[1]=n[1]+a[1]*r,t},distance:jn,squaredDistance:En,length:Pn,squaredLength:Tn,negate:function(t,n){return t[0]=-n[0],t[1]=-n[1],t},inverse:function(t,n){return t[0]=1/n[0],t[1]=1/n[1],t},normalize:function(t,n){var a=n[0],r=n[1],u=a*a+r*r;return u>0&&(u=1/Math.sqrt(u)),t[0]=n[0]*u,t[1]=n[1]*u,t},dot:function(t,n){return t[0]*n[0]+t[1]*n[1]},cross:function(t,n,a){var r=n[0]*a[1]-n[1]*a[0];return t[0]=t[1]=0,t[2]=r,t},lerp:function(t,n,a,r){var u=n[0],e=n[1];return t[0]=u+r*(a[0]-u),t[1]=e+r*(a[1]-e),t},random:function(t,n){n=n||1;var a=2*r()*Math.PI;return t[0]=Math.cos(a)*n,t[1]=Math.sin(a)*n,t},transformMat2:function(t,n,a){var r=n[0],u=n[1];return t[0]=a[0]*r+a[2]*u,t[1]=a[1]*r+a[3]*u,t},transformMat2d:function(t,n,a){var r=n[0],u=n[1];return t[0]=a[0]*r+a[2]*u+a[4],t[1]=a[1]*r+a[3]*u+a[5],t},transformMat3:function(t,n,a){var r=n[0],u=n[1];return t[0]=a[0]*r+a[3]*u+a[6],t[1]=a[1]*r+a[4]*u+a[7],t},transformMat4:function(t,n,a){var r=n[0],u=n[1];return t[0]=a[0]*r+a[4]*u+a[12],t[1]=a[1]*r+a[5]*u+a[13],t},rotate:function(t,n,a,r){var u=n[0]-a[0],e=n[1]-a[1],o=Math.sin(r),i=Math.cos(r);return t[0]=u*i-e*o+a[0],t[1]=u*o+e*i+a[1],t},angle:function(t,n){var a=t[0],r=t[1],u=n[0],e=n[1],o=Math.sqrt((a*a+r*r)*(u*u+e*e)),i=o&&(a*u+r*e)/o;return Math.acos(Math.min(Math.max(i,-1),1))},zero:function(t){return t[0]=0,t[1]=0,t},str:function(t){return"vec2("+t[0]+", "+t[1]+")"},exactEquals:function(t,n){return t[0]===n[0]&&t[1]===n[1]},equals:function(t,a){var r=t[0],u=t[1],e=a[0],o=a[1];return Math.abs(r-e)<=n*Math.max(1,Math.abs(r),Math.abs(e))&&Math.abs(u-o)<=n*Math.max(1,Math.abs(u),Math.abs(o))},len:Sn,sub:Dn,mul:Fn,div:In,dist:Ln,sqrDist:Vn,sqrLen:kn,forEach:Qn});t.glMatrix=e,t.mat2=s,t.mat2d=b,t.mat3=q,t.mat4=F,t.quat=fn,t.quat2=An,t.vec2=Yn,t.vec3=rt,t.vec4=Et,Object.defineProperty(t,"__esModule",{value:!0})}));
diff --git a/static/foil/libwebgl.js b/static/foil/libwebgl.js
new file mode 100644
index 0000000..9163324
--- /dev/null
+++ b/static/foil/libwebgl.js
@@ -0,0 +1,1064 @@
+/** Fetch one text resource synchronously during demo initialization. */
+function loadTextResource(url)
+{
+ const request = new XMLHttpRequest();
+ request.open("GET", url, false);
+ request.send(null);
+ if(request.status != 200 && request.status != 0)
+ {
+ throw(new Error(`Failed to load ${url}: HTTP ${request.status}`));
+ }
+ return request.responseText;
+}
+
+/** Insert validated compile-time numeric definitions after GLSL #version. */
+function defineShaderConstants(source, definitions)
+{
+ const version_end = source.indexOf("\n");
+ if(!source.startsWith("#version ") || version_end < 0)
+ {
+ throw(new Error("Shader source does not begin with #version."));
+ }
+
+ let definition_source = "";
+ for(const [name, value] of Object.entries(definitions))
+ {
+ if(!/^[A-Z][A-Z0-9_]*$/.test(name) ||
+ !Number.isFinite(value) || value < 0.0)
+ {
+ throw(new Error(`Invalid shader definition: ${name}=${value}`));
+ }
+ definition_source += `#define ${name} ${value}\n`;
+ }
+ return source.slice(0, version_end + 1) + definition_source
+ + source.slice(version_end + 1);
+}
+
+/** Compile one shader and include its URL in any diagnostic. */
+function compileShader(gl, source, shader_type, url)
+{
+ const shader = gl.createShader(shader_type);
+ gl.shaderSource(shader, source);
+ gl.compileShader(shader);
+ if(!gl.getShaderParameter(shader, gl.COMPILE_STATUS))
+ {
+ const message = gl.getShaderInfoLog(shader);
+ gl.deleteShader(shader);
+ throw(new Error(`Failed to compile ${url}: ${message}`));
+ }
+ return shader;
+}
+
+/** Link two compiled shaders into one WebGL program. */
+function linkProgram(gl, vertex_shader, fragment_shader,
+ vertex_url, fragment_url)
+{
+ const program = gl.createProgram();
+ gl.attachShader(program, vertex_shader);
+ gl.attachShader(program, fragment_shader);
+ gl.linkProgram(program);
+ if(!gl.getProgramParameter(program, gl.LINK_STATUS))
+ {
+ const message = gl.getProgramInfoLog(program);
+ gl.deleteProgram(program);
+ throw(new Error(
+ `Failed to link ${vertex_url} with ${fragment_url}: ${message}`));
+ }
+ return program;
+}
+
+/** Own a linked shader program and cache required variable locations. */
+class ShaderProgram
+{
+ /** Compile and link a program from vertex and fragment source URLs. */
+ constructor(gl, vertex_url, fragment_url, fragment_definitions = {})
+ {
+ const vertex_shader = compileShader(
+ gl, loadTextResource(vertex_url), gl.VERTEX_SHADER, vertex_url);
+ const fragment_source = defineShaderConstants(
+ loadTextResource(fragment_url), fragment_definitions);
+ const fragment_shader = compileShader(
+ gl, fragment_source, gl.FRAGMENT_SHADER, fragment_url);
+ this.program = linkProgram(
+ gl, vertex_shader, fragment_shader, vertex_url, fragment_url);
+ gl.deleteShader(vertex_shader);
+ gl.deleteShader(fragment_shader);
+
+ this.attribute_locations = new Map();
+ this.uniform_locations = new Map();
+ this.gl = gl;
+ }
+
+ /** Bind this program for subsequent drawing. */
+ use()
+ {
+ this.gl.useProgram(this.program);
+ }
+
+ /** Return a required cached uniform location or throw. */
+ uniform(name)
+ {
+ if(!this.uniform_locations.has(name))
+ {
+ const location = this.gl.getUniformLocation(this.program, name);
+ this.uniform_locations.set(name, location);
+ }
+ const location = this.uniform_locations.get(name);
+ if(location == null)
+ {
+ throw(new Error(`Required uniform not found: ${name}`));
+ }
+ return location;
+ }
+
+ /** Return a cached uniform location, or null when it is not active. */
+ optionalUniform(name)
+ {
+ if(!this.uniform_locations.has(name))
+ {
+ const location = this.gl.getUniformLocation(this.program, name);
+ this.uniform_locations.set(name, location);
+ }
+ return this.uniform_locations.get(name);
+ }
+
+ /** Return a required cached attribute location or throw. */
+ attribute(name)
+ {
+ if(!this.attribute_locations.has(name))
+ {
+ const location = this.gl.getAttribLocation(this.program, name);
+ if(location < 0)
+ {
+ throw(new Error(`Required attribute not found: ${name}`));
+ }
+ this.attribute_locations.set(name, location);
+ }
+ return this.attribute_locations.get(name);
+ }
+}
+
+/** Resize the drawing buffer and viewport to the displayed canvas size. */
+function resizeCanvas(canvas, gl)
+{
+ const width = canvas.clientWidth;
+ const height = canvas.clientHeight;
+ if(canvas.width != width || canvas.height != height)
+ {
+ canvas.width = width;
+ canvas.height = height;
+ }
+ gl.viewport(0, 0, width, height);
+}
+
+/** Own a complete set of buffered vertex inputs for a drawable. */
+class VertexArray
+{
+ /** Create an initially empty vertex array. */
+ constructor(gl)
+ {
+ this.vertex_array = gl.createVertexArray();
+ this.buffers = [];
+ this.gl = gl;
+ }
+
+ /** Add one buffered attribute to this vertex array. */
+ addAttribute(program, attribute_name, data, {
+ component_count = 3,
+ num_type = this.gl.FLOAT,
+ normalize = false,
+ stride = 0,
+ offset = 0,
+ })
+ {
+ const buffer = this.gl.createBuffer();
+ const attribute_ref = program.attribute(attribute_name);
+
+ this.gl.bindVertexArray(this.vertex_array);
+ this.gl.bindBuffer(this.gl.ARRAY_BUFFER, buffer);
+ this.gl.bufferData(this.gl.ARRAY_BUFFER, data, this.gl.STATIC_DRAW);
+ this.gl.enableVertexAttribArray(attribute_ref);
+ this.gl.vertexAttribPointer(attribute_ref, component_count, num_type,
+ normalize, stride, offset);
+ this.buffers.push(buffer);
+ }
+
+ /** Bind this vertex array for drawing. */
+ use()
+ {
+ this.gl.bindVertexArray(this.vertex_array);
+ }
+}
+
+/** Color-space behavior for image texture resources. */
+const TEXTURE_ROLE = Object.freeze({
+ COLOR: "color",
+ DATA: "data",
+});
+
+/** Return decode transforms for an uploaded browser image. */
+function bitmapDecodeOptions(flip_y, role)
+{
+ return {
+ imageOrientation: flip_y ? "flipY" : "none",
+ premultiplyAlpha: "none",
+ colorSpaceConversion:
+ role == TEXTURE_ROLE.DATA ? "none" : "default",
+ };
+}
+
+/** Shader material modes shared by every declarative material. */
+const MATERIAL_KIND = Object.freeze({
+ PHYSICAL_FOIL: 0,
+ SOLID_COLOR: 1,
+});
+
+/** Maximum decoded pixels accepted for one browser image texture. */
+const MAX_CARD_TEXTURE_PIXELS = 24 * 1024 * 1024;
+
+/** Own one asynchronously loaded image texture. */
+class Texture
+{
+ /** Create a texture and start loading its image. */
+ constructor(gl, url, {
+ cross_origin = null,
+ flip_y = false,
+ placeholder_color = null,
+ role = TEXTURE_ROLE.COLOR,
+ } = {})
+ {
+ if(!Object.values(TEXTURE_ROLE).includes(role))
+ {
+ throw(new Error(`Unknown texture role: ${role}`));
+ }
+ if(placeholder_color == null)
+ {
+ placeholder_color = role == TEXTURE_ROLE.DATA
+ ? [0, 0, 0, 0]
+ : [0, 0, 255, 255];
+ }
+
+ this.texture = gl.createTexture();
+ this.load_listeners = [];
+ this.width = 0;
+ this.height = 0;
+ this.flip_y = flip_y;
+ this.role = role;
+ this.url = url;
+ this.cross_origin = cross_origin;
+ this.gl = gl;
+ this.disposed = false;
+ this.is_constant = false;
+ this.ready = new Promise(function storeTextureCompletion(
+ resolve, reject)
+ {
+ this.resolve_load = resolve;
+ this.reject_load = reject;
+ }.bind(this));
+
+ gl.activeTexture(gl.TEXTURE0);
+ gl.bindTexture(gl.TEXTURE_2D, this.texture);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, 1, 1, 0, gl.RGBA,
+ gl.UNSIGNED_BYTE,
+ new Uint8Array(placeholder_color));
+
+ this.image = null;
+ if(url != null)
+ {
+ this.image = new Image();
+ this.handle_load = this.handleLoad.bind(this);
+ this.handle_error = this.handleError.bind(this);
+ this.image.addEventListener("load", this.handle_load);
+ this.image.addEventListener("error", this.handle_error);
+ if(cross_origin != null)
+ {
+ this.image.crossOrigin = cross_origin;
+ }
+ this.image.src = url;
+ }
+ }
+
+ /** Decode and upload one ephemeral browser file without creating a URL. */
+ static async fromFile(gl, file, options = {})
+ {
+ // WebGL ignores UNPACK_FLIP_Y_WEBGL for ImageBitmap sources. Apply
+ // the requested orientation while creating the bitmap, then prevent
+ // Texture.upload() from attempting the same transformation again.
+ const upload_options = Object.assign({}, options, {flip_y: false});
+ const texture = new Texture(gl, null, upload_options);
+ let bitmap = null;
+ try
+ {
+ bitmap = await createImageBitmap(
+ file, bitmapDecodeOptions(options.flip_y, options.role));
+ texture.upload(bitmap, bitmap.width, bitmap.height);
+ return texture;
+ }
+ catch(error)
+ {
+ const wrapped_error = new Error(
+ `Failed to process ${file.name}: ${error.message}`);
+ texture.fail(wrapped_error);
+ texture.ready.catch(function consumeFailedUpload() {});
+ texture.dispose();
+ throw(wrapped_error);
+ }
+ finally
+ {
+ if(bitmap != null)
+ {
+ bitmap.close();
+ }
+ }
+ }
+
+ /** Create a ready one-pixel texture from an explicit RGBA value. */
+ static constant(gl, color, options = {})
+ {
+ const is_valid = Array.isArray(color) && color.length == 4
+ && color.every(function validateTextureByte(value)
+ {
+ return Number.isInteger(value) && value >= 0 && value <= 255;
+ });
+ if(!is_valid)
+ {
+ throw(new Error(
+ "Constant texture color must contain four bytes."));
+ }
+ const texture_options = Object.assign(
+ {}, options, {placeholder_color: color});
+ const texture = new Texture(gl, null, texture_options);
+ texture.is_constant = true;
+ texture.width = 1;
+ texture.height = 1;
+ texture.resolve_load(texture);
+ return texture;
+ }
+
+ /** Replace a ready constant texture texel without reallocating it. */
+ setConstantColor(color)
+ {
+ const is_valid = Array.isArray(color) && color.length == 4
+ && color.every(function validateTextureByte(value)
+ {
+ return Number.isInteger(value)
+ && value >= 0 && value <= 255;
+ });
+ if(!this.is_constant || !is_valid || this.disposed)
+ {
+ throw(new Error(
+ "Only a live constant texture accepts four color bytes."));
+ }
+
+ const gl = this.gl;
+ const previous_active_texture = gl.getParameter(gl.ACTIVE_TEXTURE);
+ gl.activeTexture(gl.TEXTURE0);
+ const previous_texture = gl.getParameter(gl.TEXTURE_BINDING_2D);
+ try
+ {
+ gl.bindTexture(gl.TEXTURE_2D, this.texture);
+ gl.texSubImage2D(
+ gl.TEXTURE_2D, 0, 0, 0, 1, 1, gl.RGBA,
+ gl.UNSIGNED_BYTE, new Uint8Array(color));
+ }
+ finally
+ {
+ gl.bindTexture(gl.TEXTURE_2D, previous_texture);
+ gl.activeTexture(previous_active_texture);
+ }
+ }
+
+ /** Upload one decoded source while preserving global pixel-unpack state. */
+ upload(source, width, height)
+ {
+ const gl = this.gl;
+ const max_texture_size = gl.getParameter(gl.MAX_TEXTURE_SIZE);
+ if(!Number.isInteger(width) || !Number.isInteger(height)
+ || width <= 0 || height <= 0
+ || width > max_texture_size || height > max_texture_size
+ || width * height > MAX_CARD_TEXTURE_PIXELS)
+ {
+ throw(new Error(
+ `Texture ${this.url || "upload"} has invalid dimensions `
+ + `${width}x`
+ + `${height}; maximum dimension is ${max_texture_size} and `
+ + `maximum area is ${MAX_CARD_TEXTURE_PIXELS} pixels.`));
+ }
+ const previous_flip = gl.getParameter(gl.UNPACK_FLIP_Y_WEBGL);
+ const previous_premultiply = gl.getParameter(
+ gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL);
+ const previous_color_space = gl.getParameter(
+ gl.UNPACK_COLORSPACE_CONVERSION_WEBGL);
+ const previous_active_texture = gl.getParameter(gl.ACTIVE_TEXTURE);
+ gl.activeTexture(gl.TEXTURE0);
+ const previous_texture = gl.getParameter(gl.TEXTURE_BINDING_2D);
+
+ try
+ {
+ gl.bindTexture(gl.TEXTURE_2D, this.texture);
+ gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, this.flip_y);
+ gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false);
+ if(this.role == TEXTURE_ROLE.DATA)
+ {
+ gl.pixelStorei(
+ gl.UNPACK_COLORSPACE_CONVERSION_WEBGL, gl.NONE);
+ }
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, gl.RGBA,
+ gl.UNSIGNED_BYTE, source);
+
+ if(this.role == TEXTURE_ROLE.COLOR)
+ {
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER,
+ gl.LINEAR_MIPMAP_LINEAR);
+ gl.generateMipmap(gl.TEXTURE_2D);
+ }
+ }
+ finally
+ {
+ gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, previous_flip);
+ gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL,
+ previous_premultiply);
+ gl.pixelStorei(gl.UNPACK_COLORSPACE_CONVERSION_WEBGL,
+ previous_color_space);
+ gl.bindTexture(gl.TEXTURE_2D, previous_texture);
+ gl.activeTexture(previous_active_texture);
+ }
+
+ this.width = width;
+ this.height = height;
+ this.resolve_load(this);
+ for(const listener of this.load_listeners)
+ {
+ listener(this);
+ }
+ }
+
+ /** Upload a successfully loaded URL image or reject its ready promise. */
+ handleLoad(event)
+ {
+ try
+ {
+ this.upload(event.target, event.target.naturalWidth,
+ event.target.naturalHeight);
+ }
+ catch(error)
+ {
+ this.fail(error);
+ }
+ }
+
+ /** Report an image-load failure with its complete URL. */
+ handleError()
+ {
+ const cors_hint = this.cross_origin == null
+ ? ""
+ : " The image server must allow cross-origin access (CORS).";
+ this.fail(new Error(
+ `Failed to load texture: ${this.url}.${cors_hint}`));
+ }
+
+ /** Reject this texture's completion promise with one load error. */
+ fail(error)
+ {
+ this.reject_load(error);
+ }
+
+ /** Run a callback after this texture has loaded. */
+ onLoad(listener)
+ {
+ this.load_listeners.push(listener);
+ if(this.width > 0 && this.height > 0)
+ {
+ listener(this);
+ }
+ }
+
+ /** Bind this texture to a required sampler uniform. */
+ use(program, uniform_name, unit)
+ {
+ this.gl.activeTexture(this.gl.TEXTURE0 + unit);
+ this.gl.bindTexture(this.gl.TEXTURE_2D, this.texture);
+ this.gl.uniform1i(program.uniform(uniform_name), unit);
+ }
+
+ /** Release this texture's GPU allocation exactly once. */
+ dispose()
+ {
+ if(this.image != null)
+ {
+ this.image.removeEventListener("load", this.handle_load);
+ this.image.removeEventListener("error", this.handle_error);
+ }
+ if(!this.disposed)
+ {
+ this.gl.deleteTexture(this.texture);
+ this.disposed = true;
+ }
+ this.image = null;
+ }
+}
+
+/** Own the D65-weighted CIE XYZ floating-point lookup texture. */
+class SpectralLut
+{
+ /** Load, validate, and upload the binary spectral table. */
+ constructor(gl, url)
+ {
+ this.texture = gl.createTexture();
+ this.load_listeners = [];
+ this.loaded = false;
+ this.failed = false;
+ this.pending_reported = false;
+ this.url = url;
+ this.gl = gl;
+
+ gl.activeTexture(gl.TEXTURE0);
+ gl.bindTexture(gl.TEXTURE_2D, this.texture);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA32F, 1, 1, 0, gl.RGBA,
+ gl.FLOAT, new Float32Array([0.0, 0.0, 0.0, 0.0]));
+
+ const request = new XMLHttpRequest();
+ request.open("GET", url, true);
+ request.responseType = "arraybuffer";
+ request.addEventListener("load", this.handleLoad.bind(this));
+ request.addEventListener("error", this.handleError.bind(this));
+ request.send(null);
+ this.request = request;
+ }
+
+ /** Validate and upload a completed binary request. */
+ handleLoad(event)
+ {
+ const request = event.target;
+ if(request.status != 200 && request.status != 0)
+ {
+ this.failed = true;
+ throw(new Error(
+ `Failed to load ${this.url}: HTTP ${request.status}`));
+ }
+ if(request.response == null || request.response.byteLength != 6416)
+ {
+ this.failed = true;
+ const actual_size = request.response == null
+ ? 0
+ : request.response.byteLength;
+ throw(new Error(
+ `Malformed ${this.url}: expected 6416 bytes, got `
+ + actual_size));
+ }
+
+ const gl = this.gl;
+ gl.bindTexture(gl.TEXTURE_2D, this.texture);
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA32F, 401, 1, 0,
+ gl.RGBA, gl.FLOAT,
+ new Float32Array(request.response));
+ this.loaded = true;
+ for(const listener of this.load_listeners)
+ {
+ listener(this);
+ }
+ }
+
+ /** Report a binary-resource request failure. */
+ handleError()
+ {
+ this.failed = true;
+ throw(new Error(`Failed to load spectral table: ${this.url}`));
+ }
+
+ /** Run a callback after successful validation and upload. */
+ onLoad(listener)
+ {
+ this.load_listeners.push(listener);
+ if(this.loaded)
+ {
+ listener(this);
+ }
+ }
+
+ /** Bind the spectral table to a required sampler uniform. */
+ use(program, texture_unit)
+ {
+ if(!this.loaded && !this.failed && !this.pending_reported)
+ {
+ console.info(`Spectral table pending: ${this.url}`);
+ this.pending_reported = true;
+ }
+ this.gl.activeTexture(this.gl.TEXTURE0 + texture_unit);
+ this.gl.bindTexture(this.gl.TEXTURE_2D, this.texture);
+ this.gl.uniform1i(program.uniform("u_spectral_xyz"), texture_unit);
+ }
+
+ /** Release the lookup texture and any pending resource request. */
+ dispose()
+ {
+ if(this.request != null && !this.loaded && !this.failed)
+ {
+ this.request.abort();
+ }
+ this.gl.deleteTexture(this.texture);
+ this.request = null;
+ }
+}
+
+/** Own artwork, physical foil controls, and the spectral lookup. */
+class PhysicalFoilMaterial
+{
+ /** Load or adopt every resource required by a physical foil card. */
+ constructor(gl, artwork, foil_control, spectral_lut)
+ {
+ this.artwork = artwork instanceof Texture
+ ? artwork
+ : new Texture(gl, artwork, {flip_y: true});
+ this.foil_control = foil_control instanceof Texture
+ ? foil_control
+ : new Texture(gl, foil_control, {
+ flip_y: true,
+ role: TEXTURE_ROLE.DATA,
+ });
+ this.owns_spectral_lut = !(spectral_lut instanceof SpectralLut);
+ this.spectral_lut = this.owns_spectral_lut
+ ? new SpectralLut(gl, spectral_lut)
+ : spectral_lut;
+ this.gl = gl;
+ this.ready = Promise.all([
+ this.artwork.ready,
+ this.foil_control.ready,
+ ]).then(function finishLoadedMaterial()
+ {
+ return this;
+ }.bind(this));
+ }
+
+ /** Create a file material, using uniform controls when no map exists. */
+ static async fromFiles(gl, artwork_file, control_file, control_color,
+ spectral_lut)
+ {
+ let artwork = null;
+ let foil_control = null;
+ try
+ {
+ artwork = await Texture.fromFile(gl, artwork_file, {
+ flip_y: true,
+ });
+ foil_control = control_file == null
+ ? Texture.constant(gl, control_color, {
+ role: TEXTURE_ROLE.DATA,
+ })
+ : await Texture.fromFile(gl, control_file, {
+ flip_y: true,
+ role: TEXTURE_ROLE.DATA,
+ });
+ const material = new PhysicalFoilMaterial(
+ gl, artwork, foil_control, spectral_lut);
+ await material.ready;
+ return material;
+ }
+ catch(error)
+ {
+ if(artwork != null)
+ {
+ artwork.dispose();
+ }
+ if(foil_control != null)
+ {
+ foil_control.dispose();
+ }
+ throw(error);
+ }
+ }
+
+ /** Create a URL material, using uniform controls when no map exists. */
+ static async fromUrls(gl, artwork_url, control_url, control_color,
+ spectral_lut)
+ {
+ const artwork = new Texture(gl, artwork_url, {
+ cross_origin: "anonymous",
+ flip_y: true,
+ });
+ const foil_control = control_url == null
+ ? Texture.constant(gl, control_color, {
+ role: TEXTURE_ROLE.DATA,
+ })
+ : new Texture(gl, control_url, {
+ cross_origin: "anonymous",
+ flip_y: true,
+ role: TEXTURE_ROLE.DATA,
+ });
+ const material = new PhysicalFoilMaterial(
+ gl, artwork, foil_control, spectral_lut);
+ try
+ {
+ await material.ready;
+ return material;
+ }
+ catch(error)
+ {
+ material.dispose();
+ throw(error);
+ }
+ }
+
+ /** Bind all physical card material resources. */
+ use(program)
+ {
+ this.gl.uniform1i(
+ program.uniform("u_material_kind"), MATERIAL_KIND.PHYSICAL_FOIL);
+ this.artwork.use(program, "u_artwork", 0);
+ this.foil_control.use(program, "u_foil_control", 1);
+ this.spectral_lut.use(program, 2);
+ }
+
+ /** Update uniform controls, returning false for image-backed controls. */
+ setUniformControl(control_color)
+ {
+ if(!this.foil_control.is_constant)
+ {
+ return false;
+ }
+ this.foil_control.setConstantColor(control_color);
+ return true;
+ }
+
+ /** Release owned artwork, controls, and any unshared spectral table. */
+ dispose()
+ {
+ this.artwork.dispose();
+ this.foil_control.dispose();
+ if(this.owns_spectral_lut)
+ {
+ this.spectral_lut.dispose();
+ }
+ }
+}
+
+/** Shade geometry with one opaque, non-foil sRGB color. */
+class SolidColorMaterial
+{
+ /** Create a validated solid-color material. */
+ constructor(gl, color_srgb)
+ {
+ const is_valid = Array.isArray(color_srgb)
+ && color_srgb.length == 3
+ && Number.isFinite(color_srgb[0])
+ && Number.isFinite(color_srgb[1])
+ && Number.isFinite(color_srgb[2])
+ && color_srgb[0] >= 0.0 && color_srgb[0] <= 1.0
+ && color_srgb[1] >= 0.0 && color_srgb[1] <= 1.0
+ && color_srgb[2] >= 0.0 && color_srgb[2] <= 1.0;
+ if(!is_valid)
+ {
+ throw(new Error("Invalid solid material color."));
+ }
+ this.color_srgb = color_srgb.slice();
+ this.gl = gl;
+ }
+
+ /** Select solid shading and upload the encoded sRGB color. */
+ use(program)
+ {
+ this.gl.uniform1i(
+ program.uniform("u_material_kind"), MATERIAL_KIND.SOLID_COLOR);
+ this.gl.uniform3fv(
+ program.uniform("u_solid_color_srgb"), this.color_srgb);
+ }
+}
+
+/** Return the cross product of two three-dimensional arrays. */
+function crossVector(left, right)
+{
+ return [
+ left[1] * right[2] - left[2] * right[1],
+ left[2] * right[0] - left[0] * right[2],
+ left[0] * right[1] - left[1] * right[0],
+ ];
+}
+
+/** Return a normalized vector or a caller-provided fallback. */
+function normalizeVector(value, fallback)
+{
+ const length = Math.hypot(value[0], value[1], value[2]);
+ if(length < 1e-8)
+ {
+ return fallback.slice();
+ }
+ return value.map(function divideVectorComponent(component)
+ {
+ return component / length;
+ });
+}
+
+/** Return whether a value is a finite three-dimensional numeric array. */
+function isFiniteVector3(value)
+{
+ return (Array.isArray(value) || ArrayBuffer.isView(value))
+ && value.length == 3
+ && value.every(Number.isFinite);
+}
+
+/** Calculate UV-aligned normals and tangent vectors for triangle data. */
+function calculateTangentFrames(positions, texcoords, source_normals,
+ geometry_name)
+{
+ const normals = [];
+ const tangents = [];
+ let warned_degenerate_uv = false;
+
+ for(let vertex = 0; vertex < positions.length / 3; vertex += 3)
+ {
+ const position = [];
+ const uv = [];
+ const normal = [];
+ for(let local_vertex = 0; local_vertex < 3; ++local_vertex)
+ {
+ const position_offset = 3 * (vertex + local_vertex);
+ const uv_offset = 2 * (vertex + local_vertex);
+ position.push(positions.slice(position_offset,
+ position_offset + 3));
+ uv.push(texcoords.slice(uv_offset, uv_offset + 2));
+ if(source_normals != null)
+ {
+ normal.push(source_normals.slice(position_offset,
+ position_offset + 3));
+ }
+ }
+
+ const edge_1 = position[1].map(
+ function subtractFirstPosition(value, index)
+ {
+ return value - position[0][index];
+ });
+ const edge_2 = position[2].map(
+ function subtractSecondPosition(value, index)
+ {
+ return value - position[0][index];
+ });
+ const face_normal = normalizeVector(crossVector(edge_1, edge_2),
+ [0.0, 1.0, 0.0]);
+ const uv_1 = [uv[1][0] - uv[0][0], uv[1][1] - uv[0][1]];
+ const uv_2 = [uv[2][0] - uv[0][0], uv[2][1] - uv[0][1]];
+ const determinant = uv_1[0] * uv_2[1] - uv_1[1] * uv_2[0];
+
+ let raw_tangent;
+ let raw_bitangent;
+ if(Math.abs(determinant) < 1e-8)
+ {
+ if(!warned_degenerate_uv)
+ {
+ console.warn(
+ `Degenerate UV triangle in geometry ${geometry_name}`);
+ warned_degenerate_uv = true;
+ }
+ const reference = Math.abs(face_normal[2]) < 0.999
+ ? [0.0, 0.0, 1.0]
+ : [0.0, 1.0, 0.0];
+ raw_tangent = normalizeVector(
+ crossVector(reference, face_normal), [1.0, 0.0, 0.0]);
+ raw_bitangent = crossVector(face_normal, raw_tangent);
+ }
+ else
+ {
+ const inverse = 1.0 / determinant;
+ raw_tangent = edge_1.map(
+ function calculateTangent(value, index)
+ {
+ return inverse
+ * (uv_2[1] * value - uv_1[1] * edge_2[index]);
+ });
+ raw_bitangent = edge_1.map(
+ function calculateBitangent(value, index)
+ {
+ return inverse
+ * (-uv_2[0] * value + uv_1[0] * edge_2[index]);
+ });
+ }
+
+ for(let local_vertex = 0; local_vertex < 3; ++local_vertex)
+ {
+ const vertex_normal = normal.length > 0
+ ? normalizeVector(normal[local_vertex], face_normal)
+ : face_normal;
+ const normal_tangent = vertex_normal[0] * raw_tangent[0]
+ + vertex_normal[1] * raw_tangent[1]
+ + vertex_normal[2] * raw_tangent[2];
+ const orthogonal_tangent = raw_tangent.map(
+ function removeNormal(value, index)
+ {
+ return value - normal_tangent * vertex_normal[index];
+ });
+ const tangent = normalizeVector(orthogonal_tangent,
+ [1.0, 0.0, 0.0]);
+ const handedness = crossVector(vertex_normal, tangent)
+ .reduce(function dotBitangent(total, value, index)
+ {
+ return total + value * raw_bitangent[index];
+ }, 0.0) < 0.0 ? -1.0 : 1.0;
+ normals.push(...vertex_normal);
+ tangents.push(...tangent, handedness);
+ }
+ }
+ return {normals, tangents};
+}
+
+/** Own one drawable geometry and its material. */
+class Model
+{
+ /** Create a tangent-aware textured model from parsed OBJ geometry. */
+ constructor(gl, program, geometry, material)
+ {
+ const data = geometry.data;
+ const frame = calculateTangentFrames(
+ data.position, data.texcoord, data.normal, geometry.object);
+
+ this.vertex_array = new VertexArray(gl);
+ this.vertex_array.addAttribute(
+ program, "a_position", new Float32Array(data.position), {});
+ this.vertex_array.addAttribute(
+ program, "a_texcoord", new Float32Array(data.texcoord),
+ {component_count: 2});
+ this.vertex_array.addAttribute(
+ program, "a_normal", new Float32Array(frame.normals), {});
+ this.vertex_array.addAttribute(
+ program, "a_tangent", new Float32Array(frame.tangents),
+ {component_count: 4});
+ this.vertex_count = data.position.length / 3;
+ this.material = material;
+ this.gl = gl;
+ }
+}
+
+/** Store the drawable models in one scene. */
+class Scene
+{
+ /** Create a scene from an ordered model list. */
+ constructor(models)
+ {
+ this.models = models;
+ }
+}
+
+/** Represent a one-sided disk emitter with D65 spectral radiance. */
+class DiskLight
+{
+ /** Create a validated disk emitter in world space. */
+ constructor(position, normal, radius, radiance)
+ {
+ const normal_length = isFiniteVector3(normal)
+ ? Math.hypot(...normal)
+ : 0.0;
+ if(!isFiniteVector3(position) || normal_length < 1e-8
+ || !Number.isFinite(radius) || !Number.isFinite(radiance)
+ || radius <= 0.0 || radiance < 0.0)
+ {
+ throw(new Error("Disk-light parameters are invalid."));
+ }
+ this.position = position.slice();
+ this.normal = normalizeVector(normal, [0.0, 0.0, -1.0]);
+ const reference = Math.abs(this.normal[2]) < 0.999
+ ? [0.0, 0.0, 1.0]
+ : [0.0, 1.0, 0.0];
+ this.axis_x = normalizeVector(crossVector(reference, this.normal),
+ [1.0, 0.0, 0.0]);
+ this.axis_y = normalizeVector(crossVector(this.normal, this.axis_x),
+ [0.0, 1.0, 0.0]);
+ this.radius = radius;
+ this.radiance = radiance;
+ }
+
+ /** Transform and upload this disk light for the current view. */
+ use(program, view_matrix)
+ {
+ const vec3 = glMatrix.vec3;
+ const view_position = vec3.transformMat4(
+ vec3.create(), this.position, view_matrix);
+ const view_normal = vec3.transformMat3(
+ vec3.create(), this.normal, glMatrix.mat3.fromMat4(
+ glMatrix.mat3.create(), view_matrix));
+ const view_axis_x = vec3.transformMat3(
+ vec3.create(), this.axis_x, glMatrix.mat3.fromMat4(
+ glMatrix.mat3.create(), view_matrix));
+ const view_axis_y = vec3.transformMat3(
+ vec3.create(), this.axis_y, glMatrix.mat3.fromMat4(
+ glMatrix.mat3.create(), view_matrix));
+ const gl = program.gl;
+ gl.uniform3fv(program.uniform("u_light_position"), view_position);
+ gl.uniform3fv(program.uniform("u_light_normal"), view_normal);
+ gl.uniform3fv(program.uniform("u_light_axis_x"), view_axis_x);
+ gl.uniform3fv(program.uniform("u_light_axis_y"), view_axis_y);
+ gl.uniform1f(program.uniform("u_light_radius"), this.radius);
+ gl.uniform1f(program.uniform("u_light_radiance"), this.radiance);
+ }
+}
+
+/** Own frame-level WebGL state and draw scenes declaratively. */
+class Renderer
+{
+ /** Create a renderer for one WebGL context and shader program. */
+ constructor(gl, program)
+ {
+ this.canvas = gl.canvas;
+ this.program = program;
+ this.gl = gl;
+
+ gl.enable(gl.DEPTH_TEST);
+ gl.depthFunc(gl.LEQUAL);
+ gl.enable(gl.CULL_FACE);
+ gl.clearColor(0.0, 0.0, 0.0, 0.0);
+ }
+
+ /** Draw a scene under one disk light from supplied camera matrices. */
+ draw(scene, camera, light)
+ {
+ const mat3 = glMatrix.mat3;
+ const mat4 = glMatrix.mat4;
+ const model_view_matrix = mat4.multiply(
+ mat4.create(), camera.view_matrix, camera.model_matrix);
+ const normal_matrix = mat3.normalFromMat4(
+ mat3.create(), model_view_matrix);
+
+ resizeCanvas(this.canvas, this.gl);
+ this.gl.clear(this.gl.COLOR_BUFFER_BIT | this.gl.DEPTH_BUFFER_BIT);
+ this.program.use();
+ this.gl.uniformMatrix4fv(
+ this.program.uniform("u_projection"), false,
+ camera.projection_matrix);
+ this.gl.uniformMatrix4fv(
+ this.program.uniform("u_model_view"), false,
+ model_view_matrix);
+ this.gl.uniformMatrix3fv(
+ this.program.uniform("u_normal_matrix"), false, normal_matrix);
+ light.use(this.program, camera.view_matrix);
+
+ for(const model of scene.models)
+ {
+ model.vertex_array.use();
+ model.material.use(this.program);
+ this.gl.drawArrays(this.gl.TRIANGLES, 0, model.vertex_count);
+ }
+ }
+}
+
+if(typeof module != "undefined")
+{
+ module.exports = {
+ DiskLight,
+ MAX_CARD_TEXTURE_PIXELS,
+ MATERIAL_KIND,
+ PhysicalFoilMaterial,
+ ShaderProgram,
+ SolidColorMaterial,
+ TEXTURE_ROLE,
+ Texture,
+ bitmapDecodeOptions,
+ calculateTangentFrames,
+ defineShaderConstants,
+ };
+}
diff --git a/static/foil/obj.js b/static/foil/obj.js
new file mode 100644
index 0000000..0497b1d
--- /dev/null
+++ b/static/foil/obj.js
@@ -0,0 +1,386 @@
+// Copied from https://webgl2fundamentals.org/webgl/lessons/webgl-load-obj.html
+/** Parse OBJ text into nonindexed triangle geometry arrays. */
+function parseOBJ(text)
+{
+ // because indices are base 1 let's just fill in the 0th data
+ const objPositions = [[0, 0, 0]];
+ const objTexcoords = [[0, 0]];
+ const objNormals = [[0, 0, 0]];
+ const objColors = [[0, 0, 0]];
+
+ // same order as `f` indices
+ const objVertexData = [
+ objPositions,
+ objTexcoords,
+ objNormals,
+ objColors,
+ ];
+
+ // same order as `f` indices
+ let webglVertexData = [
+ [], // positions
+ [], // texcoords
+ [], // normals
+ [], // colors
+ ];
+
+ const materialLibs = [];
+ const geometries = [];
+ let geometry;
+ let groups = ['default'];
+ let material = 'default';
+ let object = 'default';
+
+ const noop = () => {};
+
+ function newGeometry() {
+ // If there is an existing geometry and it's
+ // not empty then start a new one.
+ if (geometry && geometry.data.position.length) {
+ geometry = undefined;
+ }
+ }
+
+ function setGeometry() {
+ if (!geometry) {
+ const position = [];
+ const texcoord = [];
+ const normal = [];
+ const color = [];
+ webglVertexData = [
+ position,
+ texcoord,
+ normal,
+ color,
+ ];
+ geometry = {
+ object,
+ groups,
+ material,
+ data: {
+ position,
+ texcoord,
+ normal,
+ color,
+ },
+ };
+ geometries.push(geometry);
+ }
+ }
+
+ function addVertex(vert) {
+ const ptn = vert.split('/');
+ ptn.forEach((objIndexStr, i) => {
+ if (!objIndexStr) {
+ return;
+ }
+ const objIndex = parseInt(objIndexStr);
+ const index = objIndex + (objIndex >= 0 ? 0 : objVertexData[i].length);
+ webglVertexData[i].push(...objVertexData[i][index]);
+ // if this is the position index (index 0) and we parsed
+ // vertex colors then copy the vertex colors to the webgl vertex color data
+ if (i === 0 && objColors.length > 1) {
+ geometry.data.color.push(...objColors[index]);
+ }
+ });
+ }
+
+ const keywords = {
+ v(parts) {
+ // if there are more than 3 values here they are vertex colors
+ if (parts.length > 3) {
+ objPositions.push(parts.slice(0, 3).map(parseFloat));
+ objColors.push(parts.slice(3).map(parseFloat));
+ } else {
+ objPositions.push(parts.map(parseFloat));
+ }
+ },
+ vn(parts) {
+ objNormals.push(parts.map(parseFloat));
+ },
+ vt(parts) {
+ // should check for missing v and extra w?
+ objTexcoords.push(parts.map(parseFloat));
+ },
+ f(parts) {
+ setGeometry();
+ const numTriangles = parts.length - 2;
+ for (let tri = 0; tri < numTriangles; ++tri) {
+ addVertex(parts[0]);
+ addVertex(parts[tri + 1]);
+ addVertex(parts[tri + 2]);
+ }
+ },
+ s: noop, // smoothing group
+ mtllib(parts, unparsedArgs) {
+ // the spec says there can be multiple filenames here
+ // but many exist with spaces in a single filename
+ materialLibs.push(unparsedArgs);
+ },
+ usemtl(parts, unparsedArgs) {
+ material = unparsedArgs;
+ newGeometry();
+ },
+ g(parts) {
+ groups = parts;
+ newGeometry();
+ },
+ o(parts, unparsedArgs) {
+ object = unparsedArgs;
+ newGeometry();
+ },
+ };
+
+ const keywordRE = /(\w*)(?: )*(.*)/;
+ const lines = text.split('\n');
+ for (let lineNo = 0; lineNo < lines.length; ++lineNo) {
+ const line = lines[lineNo].trim();
+ if (line === '' || line.startsWith('#')) {
+ continue;
+ }
+ const m = keywordRE.exec(line);
+ if (!m) {
+ continue;
+ }
+ const [, keyword, unparsedArgs] = m;
+ const parts = line.split(/\s+/).slice(1);
+ const handler = keywords[keyword];
+ if (!handler) {
+ console.warn('unhandled keyword:', keyword); // eslint-disable-line no-console
+ continue;
+ }
+ handler(parts, unparsedArgs);
+ }
+
+ // remove any arrays that have no entries.
+ for (const geometry of geometries) {
+ geometry.data = Object.fromEntries(
+ Object.entries(geometry.data).filter(([, array]) => array.length > 0));
+ }
+
+ return {
+ geometries,
+ materialLibs,
+ };
+}
+
+/** Model-space rules for extracting the front from the atlas-mapped OBJ. */
+const CARD_MESH_LAYOUT = Object.freeze({
+ front_normal: Object.freeze([0.0, 1.0, 0.0]),
+ front_normal_threshold: 0.999,
+ front_uv_min: Object.freeze([0.5, 0.3]),
+ front_uv_max: Object.freeze([1.0, 1.0]),
+ uv_tolerance: 0.001,
+});
+
+/** Return a validated normalized three-component vector. */
+function normalizedLayoutVector(value, name)
+{
+ if(!Array.isArray(value) || value.length != 3
+ || !value.every(Number.isFinite))
+ {
+ throw(new Error(`Invalid ${name}.`));
+ }
+ const length = Math.hypot(...value);
+ if(length < 1e-12)
+ {
+ throw(new Error(`Invalid ${name}: vector has zero length.`));
+ }
+ return [value[0] / length, value[1] / length, value[2] / length];
+}
+
+/** Return a geometry with empty arrays matching the source attributes. */
+function emptyPartitionGeometry(geometry, suffix, material)
+{
+ const data = {};
+ for(const name of Object.keys(geometry.data))
+ {
+ data[name] = [];
+ }
+ return {
+ object: `${geometry.object}/${suffix}`,
+ groups: geometry.groups,
+ material,
+ data,
+ };
+}
+
+/** Validate geometry arrays and return their components per vertex. */
+function geometryComponents(geometry)
+{
+ if(geometry == null || geometry.data == null
+ || !Array.isArray(geometry.data.position)
+ || !Array.isArray(geometry.data.texcoord))
+ {
+ throw(new Error("Card geometry requires positions and texcoords."));
+ }
+ const vertex_count = geometry.data.position.length / 3;
+ if(vertex_count == 0 || !Number.isInteger(vertex_count)
+ || vertex_count % 3 != 0)
+ {
+ throw(new Error(
+ `Card geometry ${geometry.object} has incomplete triangles.`));
+ }
+
+ const components = {};
+ for(const [name, data] of Object.entries(geometry.data))
+ {
+ if(!Array.isArray(data) || data.length == 0
+ || !data.every(Number.isFinite)
+ || data.length % vertex_count != 0)
+ {
+ throw(new Error(
+ `Card geometry ${geometry.object} has invalid ${name} data.`));
+ }
+ components[name] = data.length / vertex_count;
+ }
+ if(components.position != 3 || components.texcoord != 2
+ || (components.normal != null && components.normal != 3))
+ {
+ throw(new Error(
+ `Card geometry ${geometry.object} has invalid attribute sizes.`));
+ }
+ return components;
+}
+
+/** Remap one validated card-front coordinate into the unit square. */
+function remapCardFrontUv(u, v, layout, geometry_name, triangle)
+{
+ const values = [u, v];
+ const remapped = [];
+ for(let axis = 0; axis < 2; ++axis)
+ {
+ const minimum = layout.front_uv_min[axis];
+ const maximum = layout.front_uv_max[axis];
+ if(values[axis] < minimum - layout.uv_tolerance
+ || values[axis] > maximum + layout.uv_tolerance)
+ {
+ const coordinate = axis == 0 ? "u" : "v";
+ throw(new Error(
+ `Front UV outside atlas region in ${geometry_name} triangle `
+ + `${triangle}: ${coordinate}=${values[axis]}, expected `
+ + `${minimum}..${maximum} (tolerance `
+ + `${layout.uv_tolerance}).`));
+ }
+ const value = (values[axis] - minimum) / (maximum - minimum);
+ remapped.push(Math.min(Math.max(value, 0.0), 1.0));
+ }
+ return remapped;
+}
+
+/** Append one source triangle, optionally remapping its texture coordinates. */
+function appendPartitionTriangle(source, destination, components,
+ triangle, layout)
+{
+ const first_vertex = triangle * 3;
+ for(const [name, component_count] of Object.entries(components))
+ {
+ const first = first_vertex * component_count;
+ const last = first + 3 * component_count;
+ if(name != "texcoord" || layout == null)
+ {
+ destination.data[name].push(
+ ...source.data[name].slice(first, last));
+ continue;
+ }
+
+ for(let vertex = 0; vertex < 3; ++vertex)
+ {
+ const offset = first + 2 * vertex;
+ destination.data.texcoord.push(...remapCardFrontUv(
+ source.data.texcoord[offset],
+ source.data.texcoord[offset + 1], layout,
+ source.object, triangle));
+ }
+ }
+}
+
+/** Partition and remap an atlas-mapped card into front and shell geometry. */
+function partitionCardGeometry(geometry, layout)
+{
+ const components = geometryComponents(geometry);
+ if(layout == null || !Number.isFinite(layout.front_normal_threshold)
+ || layout.front_normal_threshold < -1.0
+ || layout.front_normal_threshold > 1.0
+ || !Number.isFinite(layout.uv_tolerance)
+ || layout.uv_tolerance < 0.0)
+ {
+ throw(new Error("Invalid card mesh layout."));
+ }
+ const front_normal = normalizedLayoutVector(
+ layout.front_normal, "card front normal");
+ const uv_min = layout.front_uv_min;
+ const uv_max = layout.front_uv_max;
+ if(!Array.isArray(uv_min) || !Array.isArray(uv_max)
+ || uv_min.length != 2 || uv_max.length != 2
+ || !uv_min.every(Number.isFinite) || !uv_max.every(Number.isFinite)
+ || uv_min[0] >= uv_max[0] || uv_min[1] >= uv_max[1])
+ {
+ throw(new Error("Invalid card front UV rectangle."));
+ }
+
+ const front = emptyPartitionGeometry(geometry, "front", "front");
+ const shell = emptyPartitionGeometry(geometry, "shell", "shell");
+ const positions = geometry.data.position;
+ const texcoords = geometry.data.texcoord;
+ const triangle_count = positions.length / 9;
+
+ for(let triangle = 0; triangle < triangle_count; ++triangle)
+ {
+ const offset = triangle * 9;
+ const edge_1 = [
+ positions[offset + 3] - positions[offset],
+ positions[offset + 4] - positions[offset + 1],
+ positions[offset + 5] - positions[offset + 2],
+ ];
+ const edge_2 = [
+ positions[offset + 6] - positions[offset],
+ positions[offset + 7] - positions[offset + 1],
+ positions[offset + 8] - positions[offset + 2],
+ ];
+ const face_normal = [
+ edge_1[1] * edge_2[2] - edge_1[2] * edge_2[1],
+ edge_1[2] * edge_2[0] - edge_1[0] * edge_2[2],
+ edge_1[0] * edge_2[1] - edge_1[1] * edge_2[0],
+ ];
+ const normal_length = Math.hypot(...face_normal);
+ if(normal_length < 1e-12)
+ {
+ throw(new Error(
+ `Degenerate triangle ${triangle} in ${geometry.object}.`));
+ }
+ const facing = (face_normal[0] * front_normal[0]
+ + face_normal[1] * front_normal[1]
+ + face_normal[2] * front_normal[2]) / normal_length;
+ if(facing < layout.front_normal_threshold)
+ {
+ appendPartitionTriangle(
+ geometry, shell, components, triangle, null);
+ continue;
+ }
+
+ appendPartitionTriangle(
+ geometry, front, components, triangle, layout);
+ }
+
+ for(const partition of [front, shell])
+ {
+ for(const [name, data] of Object.entries(partition.data))
+ {
+ if(data.length == 0)
+ {
+ delete partition.data[name];
+ }
+ }
+ }
+ return {front, shell};
+}
+
+if(typeof module != "undefined")
+{
+ module.exports = {
+ CARD_MESH_LAYOUT,
+ parseOBJ,
+ partitionCardGeometry,
+ };
+}
diff --git a/static/foil/spectral_xyz.bin b/static/foil/spectral_xyz.bin
new file mode 100644
index 0000000..18ef0bf
Binary files /dev/null and b/static/foil/spectral_xyz.bin differ
diff --git a/static/foil/vert-shader.glsl b/static/foil/vert-shader.glsl
new file mode 100644
index 0000000..44ac983
--- /dev/null
+++ b/static/foil/vert-shader.glsl
@@ -0,0 +1,57 @@
+#version 300 es
+// -*- mode: c; -*-
+
+in vec4 a_position;
+in vec3 a_normal;
+in vec4 a_tangent;
+in vec2 a_texcoord;
+
+uniform mat4 u_projection;
+uniform mat4 u_model_view;
+uniform mat3 u_normal_matrix;
+
+out vec2 v_texcoord;
+out vec3 v_view_position;
+out vec3 v_view_normal;
+out vec3 v_view_tangent;
+out vec3 v_view_bitangent;
+
+// Normalize interpolant inputs without allowing malformed geometry to inject
+// NaNs into every fragment covered by a primitive.
+vec3 safeNormalize(vec3 value, vec3 fallback)
+{
+ float length_squared = dot(value, value);
+ return length_squared < 0.00001
+ ? fallback
+ : value * inversesqrt(length_squared);
+}
+
+// Construct a fallback tangent perpendicular to the supplied normal.
+vec3 orthogonalVector(vec3 normal)
+{
+ vec3 reference = abs(normal.z) < 0.999
+ ? vec3(0.0, 0.0, 1.0)
+ : vec3(0.0, 1.0, 0.0);
+ return safeNormalize(cross(reference, normal), vec3(1.0, 0.0, 0.0));
+}
+
+void main()
+{
+ vec4 view_position = u_model_view * a_position;
+ vec3 normal = safeNormalize(
+ u_normal_matrix * a_normal, vec3(0.0, 0.0, 1.0));
+ vec3 tangent = mat3(u_model_view) * a_tangent.xyz;
+
+ // Re-orthogonalization suppresses interpolation and transform error before
+ // constructing the UV-aligned bitangent used by the grating orientation.
+ tangent = safeNormalize(
+ tangent - normal * dot(normal, tangent), orthogonalVector(normal));
+
+ gl_Position = u_projection * view_position;
+ v_texcoord = a_texcoord;
+ v_view_position = view_position.xyz;
+ v_view_normal = normal;
+ v_view_tangent = tangent;
+ v_view_bitangent = a_tangent.w * safeNormalize(
+ cross(normal, tangent), orthogonalVector(normal));
+}
diff --git a/styling.md b/styling.md
new file mode 100644
index 0000000..7ac03b8
--- /dev/null
+++ b/styling.md
@@ -0,0 +1,434 @@
+<design-system>
+# High-Fidelity Claymorphism Design System
+
+## Design Philosophy
+
+Note that the design should work for content. This design system here
+should not be applied blindly without assessing whether it works for
+the content or not.
+
+**Spatial Concept: Fullscreen Content with Floating Clay Chrome**
+The application should feel like one continuous, viewport-sized workspace,
+not a document placed inside a centered website container. Primary content
+uses the full available width and, for immersive views, the full viewport.
+Navigation, inspectors, and similar application chrome float above that
+workspace as separate clay objects.
+
+This is especially important to the app-like character of the design:
+
+* The navigation bar remains visible above scrolling content. Page titles,
+ sorting tools, grids, and other content scroll underneath its translucent
+ surface instead of stopping at a permanent header boundary.
+* An initial top inset keeps the first content from being obscured, but the
+ navbar does not occupy normal document flow.
+* Detail-page sidebars are overlays, not layout columns. The preview or
+ primary surface continues behind them and fills the viewport.
+* Floating chrome uses translucency, backdrop blur, a complete clay shadow
+ stack, and generous outer gutters so it remains visually separate from
+ the content below.
+* Fullscreen does not mean cramped or edge-to-edge text. Content grids keep
+ responsive page gutters, and readable prose retains an appropriate line
+ length. It means removing arbitrary centered-page width caps.
+
+**Core Concept: Digital Clay**
+This design system is not merely a "soft UI"—it is a high-fidelity simulation of a tangible, physical world constructed from **premium digital clay**. Every element on the screen should evoke the sensation of holding a high-end, matte-finish vinyl toy or a soft, air-filled silicone object. It rejects the flatness of modern minimalism in favor of volume, weight, and tactility.
+
+**The "High-Fidelity" Difference**:
+Unlike early 2020s "Neumorphism" (which felt like extruded plastic) or basic "Claymorphism" (which often feels like flat vector art), **High-Fidelity Claymorphism** relies on complex, multi-layered lighting simulation using 4-layer shadow stacks. It renders objects that feel dense, substantial, and interactive—not hollow decorations.
+
+* **Materiality**: Think of soft-touch matte silicone, marshmallow-like foam, or high-quality injection-molded plastic with a premium finish. Surfaces absorb light rather than reflecting it sharply, creating a warm, inviting aesthetic.
+* **Lighting**: The "world" is lit by a soft, diffused overhead light source positioned top-left, creating deep ambient occlusion shadows below objects and gentle specular highlights on their upper ridges. This creates the illusion of physical depth.
+* **Shadow Architecture**: Every element uses carefully crafted multi-layer shadows:
+ - **Outer shadows**: Soft, colored drop shadows that define distance from the surface
+ - **Highlight shadows**: Top-left highlights that simulate light reflection
+ - **Inner shadows**: Subtle colored reflections and rim lights that add dimensionality
+ - **Active states**: Pressed elements use inset shadows to simulate physical depression
+
+**The Sensory Vibe**:
+* **Playful & Optimistic**: The interface radiates joy through "candy store" colors (vivid violets, hot pinks, sky blues, emerald greens, amber oranges) and bouncy, organic motion. It feels safe, welcoming, and unpretentious—like a premium toy store display.
+* **Tactile & Responsive**: Elements don't just change color when interacted with—they physically react with exaggerated realism. Buttons actively "squish" (scale-[0.92] + shadow-clayPressed) and compress under the cursor. Cards lift and float towards the user (-translate-y-2 with enhanced shadows). Every interaction provides satisfying visual feedback.
+* **Friendly & Safe**: There are **zero sharp corners** in this universe. Every edge is aggressively rounded (`rounded-[20px]` minimum, up to `rounded-[60px]` for large containers), subconsciously signaling safety and approachability to the user. The design language speaks "friendly" and "accessible" without words.
+* **Premium Craft**: Despite the playfulness, this aesthetic maintains a sense of quality through careful attention to detail: consistent border radii, precise shadow layering, harmonious color relationships, and smooth micro-interactions.
+
+**The "Clay" Physics Engine**:
+1. **Convexity (The Bulge)**: Primary interactive elements (Buttons, Stat orbs, Feature cards) bulge OUT towards the user with `shadow-clayButton` or `shadow-clayCard`. They capture light on their top-left edge and cast soft colored shadows below, creating the illusion of floating above the surface.
+2. **Concavity (The Press)**: Secondary surfaces (Input fields, Active button states, FAQ panels when open) are pressed INTO the clay surface with `shadow-clayPressed`. They cast internal shadows on their top edge and catch light on their bottom lip, making them feel recessed.
+3. **Buoyancy (The Float)**: The interface exists in a zero-gravity environment with high air resistance. Background blobs drift slowly (8-12s animations with translateY and rotate). Cards hover effortlessly with hover states that amplify the float effect. Nothing feels statically "stuck" to the grid—everything breathes and moves subtly.
+4. **Micro-Physics**: Hover states consistently lift elements upward (`hover:-translate-y-1` to `-translate-y-2`) while enhancing their shadows, simulating the element floating closer to the viewer. Active/pressed states do the opposite—compressing downward with reduced shadows.
+
+---
+
+## Design Token System
+
+### Colors (The "Candy Shop" Palette)
+
+**Background**:
+* **Canvas**: `#F4F1FA` (Very pale, cool lavender-white). This provides a cleaner, more modern base than warm beige. Never use pure white—the slight tint creates warmth.
+
+**Foreground**:
+* **Text (Primary)**: `#332F3A` (Soft Charcoal). High contrast (passing WCAG AA) but softer than black for a friendlier feel.
+* **Muted (Secondary)**: `#635F69` (Dark Lavender-Gray). Crucial for readability against light backgrounds. Use for body text, labels, and secondary information. Never go lighter than this value.
+
+**Accents (Vibrant & Saturated)**:
+* **Primary Accent**: `#7C3AED` (Vivid Violet). The hero color used for primary CTAs, links, and brand emphasis.
+* **Secondary Accent**: `#DB2777` (Hot Pink). Used in gradients and for secondary emphasis.
+* **Tertiary**: `#0EA5E9` (Sky Blue). For informational elements and background blobs.
+* **Success/Benefits**: `#10B981` (Emerald Green). For checkmarks and positive indicators.
+* **Warning**: `#F59E0B` (Amber). For alerts and star ratings.
+
+**Gradient Strategy**:
+* **Primary Buttons**: `bg-gradient-to-br from-[#A78BFA] to-[#7C3AED]` (lighter violet to primary violet). This creates depth and prevents overly dark buttons.
+* **Icon Orbs**: `bg-gradient-to-br` from light pastel (400) to saturated hue (600) with varied colors for visual interest (e.g., `from-blue-400 to-blue-600`, `from-purple-400 to-purple-600`, `from-pink-400 to-pink-600`).
+* **Text Highlights**: Use multi-stop gradients for hero text (`clay-text-gradient`): `from-clay-foreground 20%, to-clay-accent 60%, to-clay-accent-alt`. Keep gradient text large (text-5xl+) for readability.
+* **Background Blobs**: Semi-transparent accent colors with 10% opacity and blur-3xl for soft ambient lighting.
+
+### Typography
+
+**Font Selection**:
+* **Headings**: **Nunito** (Google Fonts, Weights: 700/800/900). The rounded terminals perfectly complement the soft clay aesthetic. Apply via inline styles: `style={{ fontFamily: "Nunito, sans-serif" }}` for all headings, stat numbers, and emphasis text.
+* **Body**: **DM Sans** (Google Fonts, Weights: 400/500/700). Geometric, clean, and highly readable. Applied globally via body font-family.
+
+**Hierarchy (Mobile-First with Progressive Enhancement)**:
+* **Hero Headline**: `text-5xl sm:text-6xl md:text-7xl lg:text-8xl`, Black weight (font-black), tight tracking (tracking-tight), line-height 1.1. Always use Nunito.
+* **Section Titles**: `text-3xl sm:text-4xl md:text-5xl`, Extrabold (font-extrabold) or Black. Always use Nunito.
+* **Card Titles**: `text-xl` to `text-2xl` (larger for hero cards: `text-3xl`), Bold (font-bold) to Extrabold. Use Nunito.
+* **Body Text**: `text-base` to `text-lg`, Medium weight (font-medium), relaxed leading (leading-relaxed). Use DM Sans.
+* **Small Text**: `text-sm` to `text-xs`, Medium to Bold weight. Use for labels, metadata, uppercase tracking-wide treatments.
+
+**Typography Best Practices**:
+* Always pair Nunito headings with DM Sans body for optimal hierarchy.
+* Use `font-black` (900 weight) for maximum impact on large headings and numbers.
+* Ensure line-height is generous: `leading-relaxed` (1.625) for body, `leading-[1.1]` for tight display headings.
+* Limit line length to 60-75 characters with max-w-2xl to max-w-3xl containers for optimal readability.
+* Use `tracking-tight` on large headings to maintain visual density, `tracking-wide` or `tracking-widest` on small caps/labels.
+
+### Shapes & Radii
+
+**The "Super-Rounded" Rule** (Absolute Values Only):
+* **Large Containers/Hero Sections**: `rounded-[48px]` to `rounded-[60px]`
+* **Standard Cards**: `rounded-[32px]` (the default for most cards)
+* **Medium Elements** (Benefits pills, Blog cards): `rounded-[24px]`
+* **Buttons & Inputs**: `rounded-[20px]` or `rounded-2xl`
+* **Icon Containers**: `rounded-2xl` (16px) for square icons, `rounded-full` for circular
+* **Small Badges**: `rounded-lg` (8px) minimum, `rounded-full` preferred
+* **Stat Orbs**: `rounded-full` (perfect circles)
+
+**Critical Rules**:
+* Never use `rounded-md` (4px) or `rounded-sm`. They appear too sharp and generic for this aesthetic.
+* Maintain consistency: if a card uses `rounded-[32px]`, its nested image should use `rounded-[24px]` (8px less) to create visual hierarchy.
+* On mobile, you may reduce radii slightly (e.g., `rounded-[32px] sm:rounded-[40px]`) to maximize screen real estate while maintaining the soft aesthetic.
+
+### Shadows (The Engine of Clay)
+
+This system uses a **High-Fidelity Shadow Stack** to simulate complex lighting.
+
+**1. Deep Clay (Surface)**:
+For the main background elements or large containers.
+```css
+box-shadow:
+ 30px 30px 60px #cdc6d9, /* Deep, soft ambient occlusion */
+ -30px -30px 60px #ffffff, /* Top-left ambient light */
+ inset 10px 10px 20px rgba(139, 92, 246, 0.05), /* Subtle color reflection */
+ inset -10px -10px 20px rgba(255, 255, 255, 0.8); /* Surface specularity */
+```
+
+**2. Clay Card (Floating)**:
+For standard content cards.
+```css
+box-shadow:
+ 16px 16px 32px rgba(160, 150, 180, 0.2), /* Soft purple-gray drop shadow */
+ -10px -10px 24px rgba(255, 255, 255, 0.9), /* Strong top-left highlight */
+ inset 6px 6px 12px rgba(139, 92, 246, 0.03), /* Inner colored bounce light */
+ inset -6px -6px 12px rgba(255, 255, 255, 1); /* Inner rim light */
+```
+
+**3. Clay Button (High Convexity)**:
+For clickable elements.
+```css
+box-shadow:
+ 12px 12px 24px rgba(139, 92, 246, 0.3), /* Strong colored drop shadow */
+ -8px -8px 16px rgba(255, 255, 255, 0.4), /* Top-left highlight */
+ inset 4px 4px 8px rgba(255, 255, 255, 0.4), /* Inner rim */
+ inset -4px -4px 8px rgba(0, 0, 0, 0.1); /* Bottom-right shading */
+```
+
+**4. Clay Pressed (Recessed)**:
+For inputs and active states.
+```css
+box-shadow:
+ inset 10px 10px 20px #d9d4e3, /* Deep inner shadow top-left */
+ inset -10px -10px 20px #ffffff; /* Inner highlight bottom-right */
+```
+
+---
+
+## Component Architecture
+
+### 1. The Universal Card (`Card`)
+* **Base Styles**: `relative overflow-hidden rounded-[32px] bg-clay-cardBg p-8 text-clay-foreground shadow-clayCard backdrop-blur-xl`
+* **Interactive States**:
+ * Default: `shadow-clayCard` (4-layer shadow with soft depth)
+ * Hover: `hover:-translate-y-2 hover:shadow-[enhanced]` (lifted with stronger shadow)
+ * Transition: `transition-all duration-500` (smooth, premium feel)
+* **Structure**:
+ * Outer wrapper handles positioning, overflow, shadows
+ * **Inner Content Wrapper**: `<div className="relative z-10 flex h-full flex-col">{children}</div>` to support absolute positioned decorative elements
+* **Decorations**: Use absolute positioned panels with negative margins (`-bottom-8 -left-8 -right-8`) to create "peeking" UI elements that emerge from card bottoms
+* **Variants**:
+ * Glass effect: `bg-white/60` to `bg-white/80`
+ * Solid: `bg-white`
+ * Feature hero card: `md:col-span-2 md:row-span-2` with larger internal padding
+
+### 2. The Clay Button (`Button`)
+* **Base Shape**: `rounded-[20px]` with chunky height (`h-14` default, `h-16` for lg)
+* **Base Styles**: `inline-flex items-center justify-center font-bold tracking-wide transition-all duration-200`
+* **Variants**:
+ * **Primary/Default**: `bg-gradient-to-br from-[#A78BFA] to-[#7C3AED] text-white shadow-clayButton hover:shadow-clayButtonHover`
+ * **Secondary**: `bg-white text-clay-foreground shadow-clayButton`
+ * **Outline**: `border-2 border-clay-accent/20 bg-transparent text-clay-accent hover:border-clay-accent hover:bg-clay-accent/5`
+ * **Ghost**: `text-clay-foreground hover:bg-clay-accent/10 hover:text-clay-accent`
+* **Interactive States**:
+ * Hover: `hover:-translate-y-1` (lift up 4px) + Enhanced shadow
+ * Active: `active:scale-[0.92] active:shadow-clayPressed` (pronounced squish effect)
+ * Focus: `focus-visible:ring-4 focus-visible:ring-clay-accent/30 focus-visible:ring-offset-2`
+* **Sizing**: Use `size` prop: `sm` (h-11), `default` (h-14), `lg` (h-16)
+
+### 3. The Recessed Input (`Input`)
+* **Base Shape**: `rounded-2xl` with generous height `h-16`
+* **Base Styles**: `flex w-full border-0 bg-[#EFEBF5] px-6 py-4 text-clay-foreground text-lg shadow-clayPressed`
+* **States**:
+ * Default: Recessed with `shadow-clayPressed` (inset shadows)
+ * Focus: `focus:bg-white focus:ring-4 focus:ring-clay-accent/20` (transforms to raised white surface)
+ * Placeholder: `placeholder:text-clay-muted`
+* **Accessibility**: `transition-all duration-200` for smooth state changes
+
+### 4. Floating 3D Blobs (Background)
+**Never use a flat background.** Always include 3-4 large, animated blobs.
+* **Container**: `<div className="pointer-events-none fixed inset-0 overflow-hidden -z-10">`
+* **Individual Blobs**:
+ * Classes: `absolute h-[60vh] w-[60vh] rounded-full blur-3xl`
+ * Colors: Accent colors with `/10` opacity (e.g., `bg-[#8B5CF6]/10`, `bg-[#EC4899]/10`, `bg-[#0EA5E9]/10`)
+ * Positioning: Negative margins to bleed off edges (`-top-[10%] -left-[10%]`, `-right-[10%] top-[20%]`)
+ * Animation: `clay-blob` or `clay-blob-alt` with staggered `animation-delay-2000` or `animation-delay-4000`
+* **Purpose**: Creates ambient colored lighting that shows through glass-morphic cards
+
+---
+
+## Animation System
+
+**1. Clay Float (`clay-float`)**:
+Simulates zero-gravity drift for background blobs. 8 second duration.
+```css
+@keyframes clay-float {
+ 0%, 100% { transform: translateY(0) rotate(0deg); }
+ 50% { transform: translateY(-20px) rotate(2deg); }
+}
+```
+
+**2. Clay Float Delayed (`clay-float-delayed`)**:
+Alternative animation with opposite rotation. 10 second duration.
+```css
+@keyframes clay-float-delayed {
+ 0%, 100% { transform: translateY(0) rotate(0deg); }
+ 50% { transform: translateY(-15px) rotate(-2deg); }
+}
+```
+
+**3. Clay Float Slow (`clay-float-slow`)**:
+For hero decorative elements that orbit the headline. 12 second duration with more pronounced movement.
+```css
+@keyframes clay-float-slow {
+ 0%, 100% { transform: translateY(0) rotate(0deg); }
+ 50% { transform: translateY(-30px) rotate(5deg); }
+}
+```
+
+**4. Clay Breathe (`clay-breathe`)**:
+Simulates an object inflating/deflating slightly. 6 second duration. Used on stat orbs.
+```css
+@keyframes clay-breathe {
+ 0%, 100% { transform: scale(1); }
+ 50% { transform: scale(1.02); }
+}
+```
+
+**5. Hover Lift**:
+Standard interactive elements should lift upward on hover:
+* Cards: `hover:-translate-y-2` (8px) with enhanced shadow
+* Benefits pills: `hover:-translate-y-1` (4px)
+* Testimonials: `hover:-translate-y-2` (8px)
+* Blog posts: `hover:-translate-y-3` (12px) for dramatic effect
+* Buttons: `hover:-translate-y-1` (4px) with shadow enhancement
+
+**6. Active Press**:
+Buttons use `active:scale-[0.92]` combined with `active:shadow-clayPressed` to simulate a physical squish when clicked. Duration should be fast (200ms) for immediate feedback.
+
+**7. Scale Transforms**:
+* Stat orbs: `hover:scale-110` (10% growth)
+* How It Works circles: `group-hover:scale-110` with 300ms duration
+* Pricing cards (non-highlighted): `hover:scale-105` (5% subtle growth)
+* Featured card in Bento grid: `hover:scale-[1.02]` (minimal growth due to large size)
+
+**8. Animation Delays**:
+Use staggered animations for visual rhythm:
+* `.animation-delay-2000` (2s delay)
+* `.animation-delay-4000` (4s delay)
+
+**9. Reduced Motion**:
+Always include `@media (prefers-reduced-motion: reduce)` to disable all animations for accessibility.
+
+---
+
+## Layout Patterns
+
+**1. Fullscreen Application Canvas**:
+* The main landmark spans the viewport and has a minimum height of `100svh`.
+* Collection grids, headings, and page-level tools share responsive outer
+ gutters but do not sit inside a fixed-width centered shell.
+* Immersive media and preview surfaces may extend underneath all floating
+ chrome and reach every viewport edge.
+* Use `svh` or `dvh` with a `vh` fallback so browser controls do not make
+ fullscreen layouts jump or crop unexpectedly.
+
+**2. Floating Navigation**:
+* Position the primary navbar above the page with `position: fixed` and a
+ deliberate high layer. Give it outer gutters instead of attaching it to
+ the viewport edges.
+* Use a translucent Glass-Clay surface and `backdrop-filter` so scrolling
+ content remains perceptible beneath it without reducing legibility.
+* Give the beginning of normal pages a top content inset approximately
+ equal to the navbar's occupied area. Once scrolling begins, content is
+ allowed to pass underneath the navbar.
+* Do not add an opaque header strip or reserve a permanent blank row above
+ the main content.
+
+**3. Floating Inspector / Sidebar**:
+* On immersive detail pages, place read-only information or controls in a
+ fixed or absolutely positioned clay panel over the primary surface.
+* The panel must have its own bounded scrolling region when its content is
+ taller than the available viewport. Keep focus indicators and keyboard
+ scrolling functional inside it.
+* Account for the panel when positioning the primary focal object so an
+ important card, image, or model is not needlessly hidden behind it.
+* The overlay should feel detachable from the content: use a translucent
+ surface, blur, deep outer shadow, rounded corners, and a clear gap from
+ both navbar and viewport edges.
+
+**4. Layering Model**:
+* Background lighting and blobs occupy the lowest layer.
+* Fullscreen content and media occupy the normal layer.
+* Page-local hints and statuses sit above the media.
+* Floating sidebars sit above page content.
+* Global navigation occupies the highest routine application layer.
+* Avoid arbitrary `z-index` values on ordinary cards; preserve this small,
+ predictable layer hierarchy.
+
+**5. Masonry / Bento Grid**:
+* Don't use uniform grids. Mix `col-span-1` with `col-span-2` or `row-span-2` cards.
+* Use `hover:scale-[1.02]` on large grid items for a tactile feel.
+
+**6. Split Layouts**:
+* Use 50/50 splits for "Product" or "Benefits" sections.
+* One side text, one side **Abstract 3D Composition** (nested clay shapes, not just an image).
+
+**7. Overlapping Elements**:
+* Allow elements to break their containers (e.g., a "Popular" badge floating *above* a pricing card).
+* Use negative margins to pull decorative elements to the edges.
+
+---
+
+## Responsive Strategy
+
+**Mobile-First Approach with Progressive Enhancement**
+
+The Clay design system maintains its playful, tactile personality across all screen sizes while adapting layouts and sizing for optimal mobile experience.
+
+**Typography Scaling**:
+* Hero headlines: `text-5xl → sm:text-6xl → md:text-7xl → lg:text-8xl`
+* Section titles: `text-3xl → sm:text-4xl → md:text-5xl`
+* Body text: `text-base → sm:text-lg → md:text-xl` where appropriate
+* Always maintain `leading-relaxed` and proper line length constraints
+
+**Layout Transformations**:
+* **Navigation**: Keep the navbar floating on every viewport. Compact it on
+ mobile (`h-16 rounded-[32px] px-4`) and allow wrapping only when necessary.
+ Hide non-essential nav items before allowing the panel to consume most of
+ the screen.
+* **Floating Sidebar**: Convert a desktop side inspector into a bottom-docked
+ floating panel on narrow screens. Limit it to roughly 40-50% of the safe
+ viewport height and make its contents independently scrollable so the
+ primary surface remains visible.
+* **Fullscreen Surfaces**: Preserve the viewport-sized canvas on mobile.
+ Reposition overlays instead of shrinking the primary preview into a small
+ conventional content card.
+* **Hero**: Stack CTAs vertically (`flex-col gap-6`) → Horizontal on desktop (`sm:flex-row`)
+* **Stats**: 2-column grid on mobile (`grid-cols-2 gap-6`) → 4 columns on desktop (`md:grid-cols-4 gap-8`)
+* **Features**: Single column → Bento layout with spans on desktop (`md:grid-cols-2 lg:grid-cols-3` with hero card `md:col-span-2 md:row-span-2`)
+* **Benefits/Product Detail**: Stack vertically on mobile → Side-by-side split on desktop (`lg:grid-cols-2`)
+* **Pricing**: Stack cards on mobile → 3 columns on desktop (`md:grid-cols-3`). Scale effect for highlighted card only applies on desktop (`md:scale-110`)
+
+**Component Adjustments**:
+* **Cards**: Reduce padding on mobile (`p-6 sm:p-8`)
+* **Border Radii**: Maintain generous radii even on mobile (never less than `rounded-[20px]`)
+* **Buttons**: Full width on mobile (`w-full sm:w-auto`) for primary CTAs
+* **Decorative Elements**: Hide some floating shapes on mobile (`hidden lg:block`)
+* **Shadows**: Keep full shadow stacks—they're essential to the aesthetic
+
+**Touch Targets**:
+* All interactive elements meet 44px minimum tap target (buttons are `h-14+`)
+* Increase spacing in mobile navigation for easier tapping
+* Ensure accordion FAQ items have adequate vertical spacing
+* Keep floating panels clear of device safe-area insets and browser chrome
+
+**Performance**:
+* Animations still run on mobile but respect `prefers-reduced-motion`
+* Blur effects (`backdrop-blur-xl`) remain—they're critical to the glass-clay aesthetic
+* Background blobs scale with viewport units (`vh`) so they adapt naturally
+
+**What NOT to Change on Mobile**:
+* Don't flatten the design—keep the shadows and depth
+* Don't reduce border radii to generic values
+* Don't remove the candy-store colors or make them muted
+* Don't disable all animations (only simplify if performance issues arise)
+
+---
+
+## Dos and Don'ts
+
+* **DO** use pronounced "Squish" animations on click (`active:scale-[0.92]` combined with `shadow-clayPressed`).
+* **DO** use varying border radii within components (e.g., `rounded-[48px]` for outer container, `rounded-[32px]` for card, `rounded-[24px]` for inner image).
+* **DO** use "Glass-Clay" hybrid (semi-transparent white `bg-white/60` to `/80` + `backdrop-blur-xl`) for cards to reveal background blobs.
+* **DO** use multi-layer shadow stacks (4 shadows minimum) to achieve high-fidelity depth.
+* **DO** let fullscreen content scroll or render underneath translucent
+ floating application chrome.
+* **DO** give tall overlay panels their own accessible scrolling region.
+* **DO** apply Nunito font family explicitly to all headings, numbers, and labels via inline styles.
+* **DO** use vibrant gradient backgrounds for icon containers with varied colors (blue, purple, pink, green, cyan, amber).
+* **DON'T** use gray text lighter than `#635F69`. This is the minimum for accessibility against light backgrounds.
+* **DON'T** use sharp corners anywhere. Minimum radius is `rounded-[20px]`, never `rounded-md` or `rounded-lg`.
+* **DON'T** use flat colors for backgrounds. Always include animated blobs or subtle gradients.
+* **DON'T** use gradient text for font sizes smaller than `text-5xl` (readability risk).
+* **DON'T** make buttons too small. Minimum height is `h-11` (44px) for accessibility.
+* **DON'T** skip the hover lift effect on interactive elements—it's core to the tactile feel.
+* **DON'T** constrain primary application content to an arbitrary centered
+ maximum width.
+* **DON'T** turn a floating sidebar into a layout column that shrinks the
+ fullscreen preview.
+* **DON'T** let floating chrome permanently obscure headings, focal objects,
+ focus indicators, or essential actions.
+
+---
+
+## Implementation Checklist
+- [ ] **Background**: Canvas `#F4F1FA` + Animated Blobs.
+- [ ] **Shadows**: 4-layer box-shadows defined in CSS.
+- [ ] **Typography**: Nunito Black (Headings) + DM Sans (Body).
+- [ ] **Buttons**: Gradient, rounded-2xl, click-squish.
+- [ ] **Cards**: White/60%, backdrop-blur, rounded-3xl.
+- [ ] **Text**: High contrast charcoal/slate, no light grays.
+- [ ] **Main Canvas**: Full viewport width with responsive content gutters.
+- [ ] **Navigation**: Fixed Glass-Clay overlay; content scrolls beneath it.
+- [ ] **Detail Views**: Fullscreen primary surface with floating inspector.
+- [ ] **Mobile Overlays**: Bottom-docked, height-bounded, independently
+ scrollable, and clear of safe-area insets.
+</design-system>
diff --git a/templates/card_index.html b/templates/card_index.html
index 68c6192..0376090 100644
--- a/templates/card_index.html
+++ b/templates/card_index.html
@@ -7,10 +7,10 @@
<h1>Cards</h1>
</div>
<div class="sort-controls" aria-label="Sort cards">
- <span>Public ID</span>
- <a href="{{ ascending_url }}"
+ <span class="sort-label">Sort by public ID</span>
+ <a class="sort-button" href="{{ ascending_url }}"
{% if not descending %}aria-current="page"{% endif %}>Ascending</a>
- <a href="{{ descending_url }}"
+ <a class="sort-button" href="{{ descending_url }}"
{% if descending %}aria-current="page"{% endif %}>Descending</a>
</div>
</div>
diff --git a/templates/card_view.html b/templates/card_view.html
new file mode 100644
index 0000000..9088fde
--- /dev/null
+++ b/templates/card_view.html
@@ -0,0 +1,100 @@
+{% extends "layout.html" %}
+
+{% block content %}
+<div class="card-view">
+ <section class="card-preview-stage" aria-labelledby="PreviewHeading">
+ <div class="preview-heading">
+ <p id="PreviewHeading" class="eyebrow">Interactive preview</p>
+ <p id="PreviewStatus" class="preview-status" role="status"
+ aria-live="polite">Loading card preview…</p>
+ </div>
+ <canvas id="GLCanvas" aria-label="Interactive preview of {{ name }}">
+ </canvas>
+ <p class="preview-hint">Move the pointer over the card to tilt it.</p>
+ </section>
+
+ <aside class="card-info-panel" aria-labelledby="CardHeading">
+ <a class="back-link" href="{{ back_url }}">← All cards</a>
+ <p class="card-view-id">{{ display_id }}</p>
+ <h1 id="CardHeading">{{ name }}</h1>
+
+ {% if asset_warning %}
+ <div class="asset-warning" role="note">
+ <strong>Some card assets are missing.</strong>
+ <span>
+ Showing a fallback for
+ {% for asset in missing_assets %}
+ {{ asset }}{% if not loop.is_last %}, {% endif %}
+ {% endfor %}.
+ </span>
+ </div>
+ {% endif %}
+
+ <dl class="card-metadata">
+ <div>
+ <dt>Game</dt>
+ <dd>{{ game }}</dd>
+ </div>
+ <div>
+ <dt>Rarity</dt>
+ <dd>{{ rarity }}</dd>
+ </div>
+ <div>
+ <dt>Finish</dt>
+ <dd>{% if has_foil %}Foil{% else %}Standard{% endif %}</dd>
+ </div>
+ </dl>
+
+ <section class="card-info-section" aria-labelledby="SeriesHeading">
+ <h2 id="SeriesHeading">Series</h2>
+ {% if length(series) == 0 %}
+ <p class="muted-copy">Not part of a series.</p>
+ {% else %}
+ <ul class="series-list">
+ {% for series_name in series %}
+ <li>{{ series_name }}</li>
+ {% endfor %}
+ </ul>
+ {% endif %}
+ </section>
+
+ {% if has_short_description %}
+ <section class="card-info-section" aria-labelledby="SummaryHeading">
+ <h2 id="SummaryHeading">Summary</h2>
+ <p class="description-copy">{{ short_description }}</p>
+ </section>
+ {% endif %}
+
+ {% if has_long_description %}
+ <section class="card-info-section"
+ aria-labelledby="DescriptionHeading">
+ <h2 id="DescriptionHeading">Description</h2>
+ <p class="description-copy">{{ long_description }}</p>
+ </section>
+ {% endif %}
+ </aside>
+</div>
+
+<script id="CardPageData" type="application/json">
+{
+ "mode": "view",
+ "front_url": "{{ front_url }}",
+ "foil_url": {% if length(foil_url) > 0 %}
+ "{{ foil_url }}"
+ {% else %}
+ null
+ {% endif %},
+ "model_url": "{{ model_url }}",
+ "vertex_shader_url": "{{ shader_vertex_url }}",
+ "fragment_shader_url": "{{ shader_fragment_url }}",
+ "spectral_lut_url": "{{ spectral_lut_url }}"
+}
+</script>
+{% endblock %}
+
+{% block scripts %}
+<script src="{{ url_for("static", "foil/gl-matrix-min.js") }}"></script>
+<script src="{{ url_for("static", "foil/libwebgl.js") }}"></script>
+<script src="{{ url_for("static", "foil/obj.js") }}"></script>
+<script src="{{ preview_script_url }}"></script>
+{% endblock %}
diff --git a/templates/layout.html b/templates/layout.html
index 84aa7de..c7f824a 100644
--- a/templates/layout.html
+++ b/templates/layout.html
@@ -4,21 +4,38 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ title }}</title>
+ <link rel="preconnect" href="https://fonts.googleapis.com">
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+ <link
+ href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400..700"
+ rel="stylesheet">
+ <link
+ href="https://fonts.googleapis.com/css2?family=Nunito:wght@700..900"
+ rel="stylesheet">
<link rel="stylesheet" href="{{ url_for("static", "css/styles.css") }}">
</head>
<body>
+ <div class="ambient-light" aria-hidden="true">
+ <div class="ambient-blob ambient-blob-violet"></div>
+ <div class="ambient-blob ambient-blob-pink"></div>
+ <div class="ambient-blob ambient-blob-blue"></div>
+ </div>
<header class="site-header">
<a class="site-title" href="{{ url_for("card-index") }}">
Card Collection
</a>
<nav aria-label="Primary navigation">
- <a href="{{ url_for("card-index") }}">Cards</a>
- <a href="{{ url_for("card-new") }}">Create card</a>
- <a href="{{ url_for("series-index") }}">Series</a>
+ <a class="nav-link" href="{{ url_for("card-index") }}">Cards</a>
+ <a class="nav-link" href="{{ url_for("series-index") }}">
+ Series
+ </a>
+ <a class="nav-link nav-link-primary"
+ href="{{ url_for("card-new") }}">Create card</a>
</nav>
</header>
<main>
{% block content %}{% endblock %}
</main>
+ {% block scripts %}{% endblock %}
</body>
</html>
diff --git a/tests/app_test.cpp b/tests/app_test.cpp
index c892ca2..ac17ad7 100644
--- a/tests/app_test.cpp
+++ b/tests/app_test.cpp
@@ -6,12 +6,14 @@
#include <optional>
#include <stdexcept>
#include <string>
+#include <unordered_map>
#include <utility>
#include <vector>
#include <gtest/gtest.h>
#include "data_fake.h"
+#include "public_id.h"
namespace
{
@@ -139,3 +141,74 @@ TEST(AppTest, RendersCardIndex)
ASSERT_NE(tenth_position, std::string::npos);
EXPECT_LT(second_position, tenth_position);
}
+
+/// Verify a card page renders fake metadata and read-only preview data.
+TEST(AppTest, RendersCardView)
+{
+ Card card = makeCard(2, 2, "<script>Moon card</script>");
+ card.short_description = "A quiet <night>.";
+ card.long_description = "First line\nSecond line";
+ card.rarity = 4;
+ auto data_source = std::make_unique<DataSourceFake>(
+ std::vector<Card>{card},
+ std::vector<Series>{
+ {7, "pkm", "Night Signals", "A series description"},
+ },
+ std::unordered_map<std::int64_t, std::vector<std::int64_t>>{
+ {2, {7}},
+ });
+ App app(
+ makeConfig("https://example.test/collection/"),
+ std::move(data_source));
+ App::Request request;
+ request.path_params.emplace("id", "pkm-2");
+ App::Response response;
+
+ app.handleCardView(request, response);
+
+ EXPECT_EQ(response.status, 200);
+ EXPECT_NE(response.body.find("id=\"GLCanvas\""), std::string::npos);
+ EXPECT_NE(response.body.find("PKM-2"), std::string::npos);
+ EXPECT_NE(response.body.find("Night Signals"), std::string::npos);
+ EXPECT_NE(response.body.find("Some card assets are missing"),
+ std::string::npos);
+ EXPECT_NE(response.body.find("\"foil_url\":"), std::string::npos);
+ EXPECT_NE(response.body.find("<script>Moon card</script>"),
+ std::string::npos);
+ EXPECT_EQ(response.body.find("<script>Moon card</script>"),
+ std::string::npos);
+}
+
+/// Verify missing and malformed public IDs return a not-found response.
+TEST(AppTest, RejectsUnknownCardView)
+{
+ App app(
+ makeConfig("https://example.test/"),
+ emptyDataSource());
+ App::Request request;
+ request.path_params.emplace("id", "PKM-2");
+ App::Response response;
+
+ app.handleCardView(request, response);
+
+ EXPECT_EQ(response.status, 404);
+}
+
+/// Verify canonical game and loose public IDs parse without ambiguity.
+TEST(PublicIdTest, ParsesCanonicalIds)
+{
+ const auto game_card = parsePublicId("pkm-42");
+ ASSERT_TRUE(game_card);
+ EXPECT_EQ(game_card->game_short_name, "pkm");
+ EXPECT_EQ(game_card->card_number, 42);
+
+ const auto loose_card = parsePublicId("z1");
+ ASSERT_TRUE(loose_card);
+ EXPECT_EQ(loose_card->game_short_name, std::nullopt);
+ EXPECT_EQ(loose_card->card_number, 1261);
+
+ EXPECT_FALSE(parsePublicId("PKM-42"));
+ EXPECT_FALSE(parsePublicId("pkm-042"));
+ EXPECT_FALSE(parsePublicId("pkm-0"));
+ EXPECT_FALSE(parsePublicId("z-1-extra"));
+}