diff --git a/.gitignore b/.gitignore index 045c317a..cbcc5add 100644 --- a/.gitignore +++ b/.gitignore @@ -114,3 +114,7 @@ studio-shots/ # Campaign V capture/evidence output - session-local, never tracked (423 MB lesson, 2026-07-29) artifacts/ 341-slope-capture.jsonl + +# IconForge DAT extraction scratch (geometry + textures dumped from the +# installed client dats; regenerate with tools/MosswartArt, never commit). +tools/IconForge/work/ diff --git a/AcDream.slnx b/AcDream.slnx index 134a5e72..b1153876 100644 --- a/AcDream.slnx +++ b/AcDream.slnx @@ -18,6 +18,7 @@ + diff --git a/assets/icons/README.md b/assets/icons/README.md new file mode 100644 index 00000000..53622cff --- /dev/null +++ b/assets/icons/README.md @@ -0,0 +1,86 @@ +# acdream application icons + +Two marks, one family. + +| Mark | Files | Used by | +|---|---|---| +| **Client** — the mosswart head | `acdream-client-*.png`, `acdream-client.ico` | `AcDream.App` (PE icon + runtime window icon) | +| **Launcher** — the ring and crescent | `acdream-launcher-*.png`, `acdream-launcher.ico` | `AcDream.Launcher` (PE icon + Avalonia `Window.Icon`) | + +Each ships PNGs at 16/24/32/48/64/128/256/512/1024 plus a multi-size `.ico` +carrying 16 through 256. + +## Where the art comes from + +**The client mark is the retail mosswart**, not a drawing of one. It is the +actual creature head — `Setup 0x02000B4F` part 14, skin atlas `0x05001E11`, +`ClothingBase 0x10000344` — pulled from `client_portal.dat`, smoothed, lit and +graded. Palette values throughout both marks are sampled from that texture: + +| | | +|---|---| +| `#ACB820` | chartreuse upper skin | +| `#A09800` | mustard belly — the "foul yellow" the lore names | +| `#485010` | deep olive shadow | +| `#F2ECD2` | tusk bone | +| `#AC7438` | ear membrane / hide | + +**The launcher mark is inspired by the Asheron's Call sigil** — a forged ring +enclosing a hooked crescent — rebuilt from measurements of the retail wordmark +and the `acclient.exe` icon resource. It is an original construction in the +same visual language, not a copy of the logo. Its warm field matches the retail +client icon's dark-to-gold interior. + +> **Note on rights.** "Asheron's Call" and its logo are trademarks of their +> owners, and the client mark is rendered from copyrighted game art. Unlike DAT +> content — which stays on the user's own disk — these icons are compiled into +> the shipped binaries. If acdream is ever distributed broadly, both marks +> should be reviewed, and the client mark is the one most likely to want an +> original redraw using these renders as reference. + +## Regenerating + +The launcher mark is fully procedural and rebuilds anywhere: + +```bash +py tools/IconForge/forge.py launcher +``` + +That is byte-for-byte deterministic — it reproduces the committed PNGs exactly, +so an accidental edit is visible as a diff. + +The client mark renders real game geometry, so it needs the installed DATs. +One command extracts both halves — the posed geometry and the surfaces it +references — into `tools/IconForge/work/`: + +```bash +dotnet run --project tools/MosswartArt -- 0x02000B4F 0x10000344 tools/IconForge/work/mosswart_mesh.json 0x09000009 +``` + +The trailing MotionTable id is required. Creatures do not define an upright pose +in `Setup.PlacementFrames`; without it every part stacks on the origin. + +Then: + +```bash +py tools/IconForge/forge.py client +``` + +This is deterministic too — given the same DATs it reproduces the committed +PNGs byte-for-byte. + +Requires Python with `numpy`, `pillow` and `scipy`. + +## How they are wired in + +Neither icon is loaded from disk at runtime. + +- **PE icon** — `` in each `.csproj`, pointing at the `.ico` + here. This is what Explorer and the taskbar shortcut show. +- **Client window icon** — `AcDream.App.Rendering.WindowIconLoader` hands GLFW + four sizes at startup. The PNGs are *embedded resources* linked from this + directory, so there is one source of truth for the art and no missing-file + case at runtime. `WindowIconLoaderTests` guards the resource names, which are + otherwise coupled to `LogicalName` in the csproj by string only. +- **Launcher window icon** — `AvaloniaResource` linked from here, referenced as + `avares://acdream-launcher/Assets/acdream-launcher.png`. diff --git a/assets/icons/acdream-client-1024.png b/assets/icons/acdream-client-1024.png new file mode 100644 index 00000000..e0534265 Binary files /dev/null and b/assets/icons/acdream-client-1024.png differ diff --git a/assets/icons/acdream-client-128.png b/assets/icons/acdream-client-128.png new file mode 100644 index 00000000..6cbbbb39 Binary files /dev/null and b/assets/icons/acdream-client-128.png differ diff --git a/assets/icons/acdream-client-16.png b/assets/icons/acdream-client-16.png new file mode 100644 index 00000000..df593d31 Binary files /dev/null and b/assets/icons/acdream-client-16.png differ diff --git a/assets/icons/acdream-client-24.png b/assets/icons/acdream-client-24.png new file mode 100644 index 00000000..f8cf4a02 Binary files /dev/null and b/assets/icons/acdream-client-24.png differ diff --git a/assets/icons/acdream-client-256.png b/assets/icons/acdream-client-256.png new file mode 100644 index 00000000..568ecfa2 Binary files /dev/null and b/assets/icons/acdream-client-256.png differ diff --git a/assets/icons/acdream-client-32.png b/assets/icons/acdream-client-32.png new file mode 100644 index 00000000..4f586be1 Binary files /dev/null and b/assets/icons/acdream-client-32.png differ diff --git a/assets/icons/acdream-client-48.png b/assets/icons/acdream-client-48.png new file mode 100644 index 00000000..01e33ecf Binary files /dev/null and b/assets/icons/acdream-client-48.png differ diff --git a/assets/icons/acdream-client-512.png b/assets/icons/acdream-client-512.png new file mode 100644 index 00000000..bd6f2170 Binary files /dev/null and b/assets/icons/acdream-client-512.png differ diff --git a/assets/icons/acdream-client-64.png b/assets/icons/acdream-client-64.png new file mode 100644 index 00000000..de6062ff Binary files /dev/null and b/assets/icons/acdream-client-64.png differ diff --git a/assets/icons/acdream-client.ico b/assets/icons/acdream-client.ico new file mode 100644 index 00000000..264fb28c Binary files /dev/null and b/assets/icons/acdream-client.ico differ diff --git a/assets/icons/acdream-launcher-1024.png b/assets/icons/acdream-launcher-1024.png new file mode 100644 index 00000000..2f2bc52d Binary files /dev/null and b/assets/icons/acdream-launcher-1024.png differ diff --git a/assets/icons/acdream-launcher-128.png b/assets/icons/acdream-launcher-128.png new file mode 100644 index 00000000..b63a0536 Binary files /dev/null and b/assets/icons/acdream-launcher-128.png differ diff --git a/assets/icons/acdream-launcher-16.png b/assets/icons/acdream-launcher-16.png new file mode 100644 index 00000000..01cf16f0 Binary files /dev/null and b/assets/icons/acdream-launcher-16.png differ diff --git a/assets/icons/acdream-launcher-24.png b/assets/icons/acdream-launcher-24.png new file mode 100644 index 00000000..e1343f7e Binary files /dev/null and b/assets/icons/acdream-launcher-24.png differ diff --git a/assets/icons/acdream-launcher-256.png b/assets/icons/acdream-launcher-256.png new file mode 100644 index 00000000..d6c31628 Binary files /dev/null and b/assets/icons/acdream-launcher-256.png differ diff --git a/assets/icons/acdream-launcher-32.png b/assets/icons/acdream-launcher-32.png new file mode 100644 index 00000000..901d21c5 Binary files /dev/null and b/assets/icons/acdream-launcher-32.png differ diff --git a/assets/icons/acdream-launcher-48.png b/assets/icons/acdream-launcher-48.png new file mode 100644 index 00000000..f48f796a Binary files /dev/null and b/assets/icons/acdream-launcher-48.png differ diff --git a/assets/icons/acdream-launcher-512.png b/assets/icons/acdream-launcher-512.png new file mode 100644 index 00000000..e4dd9595 Binary files /dev/null and b/assets/icons/acdream-launcher-512.png differ diff --git a/assets/icons/acdream-launcher-64.png b/assets/icons/acdream-launcher-64.png new file mode 100644 index 00000000..eb31b602 Binary files /dev/null and b/assets/icons/acdream-launcher-64.png differ diff --git a/assets/icons/acdream-launcher.ico b/assets/icons/acdream-launcher.ico new file mode 100644 index 00000000..3a6822a1 Binary files /dev/null and b/assets/icons/acdream-launcher.ico differ diff --git a/src/AcDream.App/AcDream.App.csproj b/src/AcDream.App/AcDream.App.csproj index 050a4894..77745cf1 100644 --- a/src/AcDream.App/AcDream.App.csproj +++ b/src/AcDream.App/AcDream.App.csproj @@ -8,6 +8,10 @@ true AcDream.App true + + ..\..\assets\icons\acdream-client.ico @@ -81,6 +85,21 @@ PreserveNewest + + + + + + + diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 41c74483..06491c78 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -800,6 +800,10 @@ public sealed class GameWindow : _startupQuality = startup.Quality; _window = Window.Create(options); + // Before any callback binding: the icon is pure window decoration and + // has no ordering relationship with the render loop, so it belongs at + // the earliest point the native window exists. + WindowIconLoader.Apply(_window); IWindow window = _window; _lifetime.PublishNativeWindow( window, diff --git a/src/AcDream.App/Rendering/WindowIconLoader.cs b/src/AcDream.App/Rendering/WindowIconLoader.cs new file mode 100644 index 00000000..b40aa3e8 --- /dev/null +++ b/src/AcDream.App/Rendering/WindowIconLoader.cs @@ -0,0 +1,107 @@ +using Silk.NET.Core; +using Silk.NET.Windowing; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; + +namespace AcDream.App.Rendering; + +/// +/// Applies acdream's window icon to the native window. +/// +/// +/// +/// The icon art is the retail mosswart head (Setup 0x02000B4F part 14, +/// skin 0x05001E11) rendered offline by tools/IconForge; only the +/// baked PNGs ship. See assets/icons/README.md. +/// +/// +/// The PNGs are embedded rather than copied next to the binary, unlike +/// the shader/markup assets in this project. Two reasons: a window icon has no +/// sensible runtime fallback if the file is missing, and embedding keeps it +/// intact under single-file publish. The cost is a few tens of KB in the +/// assembly. +/// +/// +/// Several sizes are handed over at once because the window manager picks the +/// closest match per surface — the title bar wants ~16px while Alt-Tab and the +/// taskbar want 32-256px, and letting the WM choose beats shipping one size and +/// having it resampled badly. +/// +/// +internal static class WindowIconLoader +{ + // Ordered small -> large purely for readability; the window manager selects + // by size, not by position. + private static readonly string[] ResourceNames = + { + "AcDream.App.Rendering.Icons.acdream-client-16.png", + "AcDream.App.Rendering.Icons.acdream-client-32.png", + "AcDream.App.Rendering.Icons.acdream-client-48.png", + "AcDream.App.Rendering.Icons.acdream-client-256.png", + }; + + /// + /// Decode the embedded icon set and hand it to the window. + /// + public static void Apply(IWindow window) + { + ArgumentNullException.ThrowIfNull(window); + + RawImage[] images; + try + { + images = Decode(); + } + catch (Exception failure) + { + // A missing or corrupt embedded resource is a build defect, not a + // runtime condition — say so loudly rather than shipping a silent + // catch, but do not take the client down over cosmetics. + Console.Error.WriteLine($"window icon: could not decode embedded icons — {failure}"); + return; + } + + if (images.Length == 0) + { + Console.Error.WriteLine("window icon: no embedded icon resources found"); + return; + } + + try + { + window.SetWindowIcon(images.AsSpan()); + } + catch (Exception failure) + { + // Wayland has no window-icon protocol and GLFW reports the request + // as unsupported there. That is a platform fact, not a bug, and it + // must not be fatal — but it is still worth printing so an + // unexpectedly icon-less window on a supported platform is + // traceable rather than mysterious. + Console.Error.WriteLine($"window icon: platform rejected the icon — {failure.Message}"); + } + } + + private static RawImage[] Decode() + { + var assembly = typeof(WindowIconLoader).Assembly; + var decoded = new List(ResourceNames.Length); + + foreach (string name in ResourceNames) + { + using Stream? stream = assembly.GetManifestResourceStream(name); + if (stream is null) + { + Console.Error.WriteLine($"window icon: embedded resource missing — {name}"); + continue; + } + + using var image = Image.Load(stream); + var pixels = new byte[image.Width * image.Height * 4]; + image.CopyPixelDataTo(pixels); + decoded.Add(new RawImage(image.Width, image.Height, pixels)); + } + + return decoded.ToArray(); + } +} diff --git a/src/AcDream.Launcher/AcDream.Launcher.csproj b/src/AcDream.Launcher/AcDream.Launcher.csproj index f1e597cf..fd3e1316 100644 --- a/src/AcDream.Launcher/AcDream.Launcher.csproj +++ b/src/AcDream.Launcher/AcDream.Launcher.csproj @@ -8,6 +8,10 @@ enable latest true + + ..\..\assets\icons\acdream-launcher.ico true true true @@ -62,6 +66,14 @@ + + + + + diff --git a/src/AcDream.Launcher/MainWindow.axaml b/src/AcDream.Launcher/MainWindow.axaml index 7cdb3031..28c73b0e 100644 --- a/src/AcDream.Launcher/MainWindow.axaml +++ b/src/AcDream.Launcher/MainWindow.axaml @@ -4,6 +4,7 @@ x:Class="AcDream.Launcher.MainWindow" x:DataType="vm:LauncherWindowViewModel" Title="acdream launcher" + Icon="avares://acdream-launcher/Assets/acdream-launcher.png" Width="1180" Height="760" MinWidth="900" diff --git a/tests/AcDream.App.Tests/Rendering/WindowIconLoaderTests.cs b/tests/AcDream.App.Tests/Rendering/WindowIconLoaderTests.cs new file mode 100644 index 00000000..f8d88a39 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/WindowIconLoaderTests.cs @@ -0,0 +1,79 @@ +using System.Reflection; +using AcDream.App.Rendering; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; +using Xunit; + +namespace AcDream.App.Tests.Rendering; + +/// +/// The icon resource names are coupled to LogicalName in +/// AcDream.App.csproj by string alone, so a rename on either side compiles +/// cleanly and only fails as a silently icon-less window at runtime. These +/// tests are that coupling's only guard. +/// +public class WindowIconLoaderTests +{ + private static IReadOnlyList ExpectedResourceNames() + { + var field = typeof(WindowIconLoader).GetField( + "ResourceNames", BindingFlags.NonPublic | BindingFlags.Static); + Assert.NotNull(field); + var names = (string[]?)field!.GetValue(null); + Assert.NotNull(names); + return names!; + } + + [Fact] + public void EveryDeclaredIconResource_IsActuallyEmbedded() + { + var assembly = typeof(WindowIconLoader).Assembly; + string[] embedded = assembly.GetManifestResourceNames(); + + foreach (string name in ExpectedResourceNames()) + { + Assert.True( + embedded.Contains(name), + $"WindowIconLoader expects embedded resource '{name}', but the " + + "assembly does not contain it. Check the EmbeddedResource " + + "LogicalName entries in AcDream.App.csproj."); + } + } + + [Fact] + public void EmbeddedIcons_DecodeToSquareRgbaImagesOfTheDeclaredSize() + { + var assembly = typeof(WindowIconLoader).Assembly; + + foreach (string name in ExpectedResourceNames()) + { + using Stream? stream = assembly.GetManifestResourceStream(name); + Assert.NotNull(stream); + + using var image = Image.Load(stream!); + Assert.Equal(image.Width, image.Height); + + // The trailing "-.png" must match the actual pixel size, or + // the window manager picks the wrong image for a surface. + string stem = Path.GetFileNameWithoutExtension(name); + string declared = stem[(stem.LastIndexOf('-') + 1)..]; + Assert.Equal(int.Parse(declared), image.Width); + } + } + + [Fact] + public void IconSet_CoversBothSmallAndLargeSurfaces() + { + var sizes = new List(); + foreach (string name in ExpectedResourceNames()) + { + string stem = Path.GetFileNameWithoutExtension(name); + sizes.Add(int.Parse(stem[(stem.LastIndexOf('-') + 1)..])); + } + + // Title bars want ~16px and Alt-Tab/taskbar want a large one; shipping + // only one size leaves the window manager resampling badly. + Assert.Contains(16, sizes); + Assert.True(sizes.Max() >= 128, "icon set has no large size for Alt-Tab/taskbar"); + } +} diff --git a/tools/IconForge/README.md b/tools/IconForge/README.md new file mode 100644 index 00000000..7504c103 --- /dev/null +++ b/tools/IconForge/README.md @@ -0,0 +1,62 @@ +# IconForge + +Generates acdream's application icons into `assets/icons/`. Provenance, +palette, rights notes and the wiring live in +[`assets/icons/README.md`](../../assets/icons/README.md); this file covers how +the pipeline works. + +```bash +py tools/IconForge/forge.py launcher # procedural, no game data needed +py tools/IconForge/forge.py client # needs a DAT export in work/ +py tools/IconForge/forge.py all +``` + +## Modules + +| File | Purpose | +|---|---| +| `render.py` | Software rasterizer: z-buffer, perspective camera, Lambert key/fill/rim, Blinn specular, bilinear texture sampling, matcap materials. | +| `smooth.py` | Crease-aware normal welding + PN-triangle tessellation. | +| `ring.py` | Forged 3-D ring and hook meshes, plus the chrome and verdigris matcaps. | +| `chisel.py` | Distance-transform bevelling: turns any 2-D mask into chiselled metal. | +| `ac_glyph.py` | The ring-and-crescent sigil geometry. | +| `compose.py` | Badging: fields, masks, drop shadows, contact sheets, size strips. | +| `launcher.py` | Portal vortex, placement helpers. | +| `forge.py` | Entry point. | + +## The three techniques worth knowing + +**PN-triangle tessellation** (`smooth.py`). The retail mosswart head is 104 +triangles and renders faceted. Each flat triangle becomes a cubic Bézier patch +built from its own corner positions and normals, with quadratically interpolated +normals — so the silhouette genuinely rounds instead of merely shading smoothly. +It needs no mesh connectivity, which matters because UV seams would otherwise +pull apart. Normals are welded across coincident positions first, but only +within a crease angle, so ear fins and tusk edges stay sharp while the skull +rounds off. + +**Matcaps** (`ring.py`). A Lambert rasterizer cannot produce chrome, because +chrome is almost entirely reflection and there is nothing here to reflect. A +matcap — one image of a lit sphere, sampled by the camera-space normal — is the +standard cheap stand-in for an environment map. The sharp horizon band in +`chrome_matcap` is what makes the eye read "metal" rather than "grey plastic". + +**Distance-transform bevelling** (`chisel.py`). For flat shapes — the crescent, +the ring, letterforms — take the distance transform of the mask, treat +distance-to-edge as height, and derive normals from the height gradient. Shading +those through the *same* matcap the 3-D meshes use keeps everything lit by one +imaginary environment. The height field is blurred before differentiating: +without that, the medial axis of each stroke shows through as a hatched ridge. + +## Gotchas + +- **Creature poses need the MotionTable.** `Setup.PlacementFrames` has no + upright pose for creatures; pass the weenie's MotionTable id to the exporter + or every part stacks on the origin. +- **Build a mask and its decorations in one frame.** The crescent's barb and + tail attach at cusp angles solved from the circle intersection. Rotating the + crescent and the spikes separately mixes sign conventions and leaves the tail + floating clear of the cusp. +- **The launcher path is deterministic** and must stay that way: it reproduces + the committed PNGs byte-for-byte, which is what makes an accidental edit show + up as a diff. No RNG without a fixed seed. diff --git a/tools/IconForge/ac_glyph.py b/tools/IconForge/ac_glyph.py new file mode 100644 index 00000000..aff429d0 --- /dev/null +++ b/tools/IconForge/ac_glyph.py @@ -0,0 +1,141 @@ +"""The Asheron's Call sigil geometry, rebuilt from the retail wordmark. + +Measured off the logo rather than guessed: + +* the ring is a **thin, complete** circle -- roughly 1/16 of its diameter in + thickness -- not a thick band with a gap. It passes *behind* the glyph. +* both ring and glyph are chiselled relief with a raised centre ridge, which is + why every stroke shows a bright spine and two darker chamfers. +* the glyph is a crescent opening to the right. Its upper terminal extends into + a barbed hook that crosses over the ring; its lower terminal tapers to a fine + point. + +Everything here produces masks; chisel.shade() turns them into metal. +""" +import numpy as np +from PIL import Image, ImageDraw, ImageFilter + +SS = 4 # supersample factor for all mask drawing + + +def _canvas(size): + return Image.new("L", (size * SS, size * SS), 0) + + +def _down(img, size): + return np.asarray(img.resize((size, size), Image.LANCZOS), dtype=np.float32) / 255.0 + + +def _px(size, v): + """normalised (-1..1) -> pixel on the supersampled canvas""" + return (v * 0.5 + 0.5) * size * SS + + +def thin_ring(size=1024, R=0.86, thick=0.055, wobble=0.0, seed=3): + """Complete slender circle, the AC ring proportion.""" + W = size * SS + y, x = np.mgrid[0:W, 0:W].astype(np.float32) + x = (x / (W - 1)) * 2 - 1 + y = (y / (W - 1)) * 2 - 1 + r = np.sqrt(x * x + y * y) + if wobble > 0: + th = np.arctan2(y, x) + rng = np.random.default_rng(seed) + r = r - wobble * (np.sin(6 * th + rng.uniform(0, 6)) * 0.6 + + np.sin(11 * th + rng.uniform(0, 6)) * 0.4) + m = (np.abs(r - R) < thick / 2).astype(np.float32) + im = Image.fromarray((m * 255).astype(np.uint8), "L") + return _down(im, size) + + +def crescent(size=1024, R=0.66, inner_r=0.60, offset=0.30, cy=0.0, + rot_deg=-18.0): + """Classic two-circle crescent: outer disc minus an offset inner disc. + + The two intersection points give naturally sharp cusps -- exactly the + terminals the AC glyph has, before the barb is added on top. + """ + W = size * SS + y, x = np.mgrid[0:W, 0:W].astype(np.float32) + x = (x / (W - 1)) * 2 - 1 + y = (y / (W - 1)) * 2 - 1 + a = np.radians(rot_deg) + xr = x * np.cos(a) - y * np.sin(a) + yr = x * np.sin(a) + y * np.cos(a) + outer = (xr * xr + (yr - cy) ** 2) < R * R + inner = ((xr - offset) ** 2 + (yr - cy) ** 2) < inner_r * inner_r + m = (outer & ~inner).astype(np.float32) + return _down(Image.fromarray((m * 255).astype(np.uint8), "L"), size) + + +def _tapered_arc(draw, size, cx, cy, r0, r1, a0_deg, a1_deg, + w0, w1, steps=90, curl=0.0): + """Draw a tapering curved stroke as a polygon strip.""" + t = np.linspace(0, 1, steps) + ang = np.radians(a0_deg + (a1_deg - a0_deg) * t) + rad = r0 + (r1 - r0) * t + curl * np.sin(np.pi * t) + cxs = cx + np.cos(ang) * rad + cys = cy + np.sin(ang) * rad + w = w0 + (w1 - w0) * (t ** 1.35) + dx = np.gradient(cxs); dy = np.gradient(cys) + ln = np.sqrt(dx * dx + dy * dy); ln = np.where(ln < 1e-9, 1, ln) + nx, ny = -dy / ln, dx / ln + left = [( _px(size, cxs[i] + nx[i] * w[i]), _px(size, cys[i] + ny[i] * w[i]) ) + for i in range(steps)] + right = [( _px(size, cxs[i] - nx[i] * w[i]), _px(size, cys[i] - ny[i] * w[i]) ) + for i in range(steps)][::-1] + draw.polygon(left + right, fill=255) + + +def cusp_angles(R, inner_r, offset): + """Where the two circles meet -- the crescent's two sharp terminals. + + Solving the circle intersection gives the exact attachment points, so the + barb and tail grow out of the cusps instead of floating near them. + """ + x = (R * R - inner_r * inner_r + offset * offset) / (2 * offset) + y2 = R * R - x * x + if y2 <= 0: + return None + y = np.sqrt(y2) + return np.degrees(np.arctan2(-y, x)), np.degrees(np.arctan2(y, x)) + + +def ac_glyph(size=1024, R=0.66, inner_r=0.60, offset=0.30, rot_deg=-18.0, + barb=True, tail=True, fork=True): + """Crescent + barbed upper hook + tapering lower tail, as one mask.""" + # Build crescent and spikes in ONE unrotated frame, then rotate the combined + # mask once. Rotating them separately mixed two sign conventions and left the + # tail floating clear of the cusp it is supposed to grow out of. + m = crescent(size, R, inner_r, offset, 0.0, 0.0) + cu = cusp_angles(R, inner_r, offset) + if cu is None: + return m + upper, lower = cu + + img = _canvas(size) + d = ImageDraw.Draw(img) + if barb: + # main spike: leaves the cusp thin, swells, then needles out past the ring + _tapered_arc(d, size, 0.0, 0.0, R, R * 1.34, upper, upper - 62.0, + 0.030, 0.004, curl=0.085) + if fork: + # the second, shorter prong that makes the terminal read as barbed + _tapered_arc(d, size, 0.0, 0.0, R * 1.02, R * 1.16, upper - 26.0, + upper - 54.0, 0.022, 0.003, curl=-0.055) + if tail: + _tapered_arc(d, size, 0.0, 0.0, R, R * 1.14, lower, lower + 34.0, + 0.028, 0.003, curl=0.030) + spikes = _down(img, size) + return np.clip(_rot(np.maximum(m, spikes), rot_deg, size), 0, 1) + + +def _rot(mask, deg, size): + im = Image.fromarray((mask * 255).astype(np.uint8), "L") + im = im.rotate(-deg, resample=Image.BICUBIC, center=(size / 2, size / 2)) + return np.asarray(im, dtype=np.float32) / 255.0 + + +def soften(mask, px=1.0): + im = Image.fromarray((np.clip(mask, 0, 1) * 255).astype(np.uint8), "L") + return np.asarray(im.filter(ImageFilter.GaussianBlur(px)), dtype=np.float32) / 255.0 diff --git a/tools/IconForge/chisel.py b/tools/IconForge/chisel.py new file mode 100644 index 00000000..5a730f41 --- /dev/null +++ b/tools/IconForge/chisel.py @@ -0,0 +1,168 @@ +"""Turn any flat 2D shape into chiselled metal. + +The Asheron's Call wordmark is bevelled chrome letterforms. The cheap, accurate +way to fake that from a silhouette: take the distance transform of the mask, +treat distance-to-edge as height with a bevel profile, derive surface normals +from the height gradient, then shade those normals through the same matcap the +3D ring uses -- so letter and ring are lit by the same imaginary environment. +""" +import numpy as np +from PIL import Image, ImageDraw, ImageFilter +from scipy.ndimage import distance_transform_edt, gaussian_filter + + +def bevel_normals(mask, bevel_px=26, profile="chisel", plateau=0.0): + """mask: HxW float 0..1. Returns (normals HxWx3, height HxW).""" + inside = mask > 0.5 + d = distance_transform_edt(inside).astype(np.float32) + t = np.clip(d / max(bevel_px, 1e-6), 0, 1) + if profile == "round": + h = np.sqrt(np.clip(1 - (1 - t) ** 2, 0, 1)) + elif profile == "flat": + h = t + else: # chisel: linear ramp to a flat top -- sharp facet break + h = np.clip(t / max(1e-6, (1 - plateau)), 0, 1) + + # The distance transform has a crease along the medial axis of every stroke; + # differentiating it raw produces a visible hatched ridge. Smooth the height + # field first -- it costs nothing and removes the artefact entirely. + h = gaussian_filter(h, sigma=max(bevel_px * 0.16, 0.8)) + gy, gx = np.gradient(h * bevel_px) + nx, ny, nz = -gx, -gy, np.ones_like(h) + ln = np.sqrt(nx * nx + ny * ny + nz * nz) + n = np.stack([nx / ln, ny / ln, nz / ln], axis=-1) + return n, h + + +def shade(mask, matcap, bevel_px=26, profile="chisel", plateau=0.35, + ao=0.35, exposure=1.0, tilt=(0.0, 0.0)): + """Shade a mask as metal. Returns an RGBA image.""" + n, h = bevel_normals(mask, bevel_px, profile, plateau) + nx = np.clip(n[..., 0] + tilt[0], -1, 1) + ny = np.clip(n[..., 1] + tilt[1], -1, 1) + mh, mw = matcap.shape[0], matcap.shape[1] + mu = np.clip((nx * 0.5 + 0.5) * (mw - 1), 0, mw - 1).astype(np.int32) + mv = np.clip((1.0 - (ny * 0.5 + 0.5)) * (mh - 1), 0, mh - 1).astype(np.int32) + rgb = matcap[mv, mu][..., :3] * exposure + # darken the bevel skirt so the form reads as raised + rgb = rgb * (1.0 - ao * (1.0 - h))[..., None] + a = np.clip(mask, 0, 1) + out = np.concatenate([np.clip(rgb, 0, 1), a[..., None]], axis=2) + return Image.fromarray((out * 255).astype(np.uint8), "RGBA") + + +# --------------------------------------------------------------- letterforms +def letter_from_font(ch="A", size=1024, font_path=r"C:\Windows\Fonts\palab.ttf", + fill=0.86): + """Render a glyph to a mask. A real serif gives correct counters and serifs; + the chisel pass supplies the forged-metal reading.""" + from PIL import ImageFont + S = 3 + W = size * S + lo, hi = 10, W * 3 + best = None + while lo <= hi: + mid = (lo + hi) // 2 + f = ImageFont.truetype(font_path, mid) + box = f.getbbox(ch) + w, h = box[2] - box[0], box[3] - box[1] + if max(w, h) <= W * fill: + best = (mid, box); lo = mid + 1 + else: + hi = mid - 1 + px, box = best + f = ImageFont.truetype(font_path, px) + img = Image.new("L", (W, W), 0) + d = ImageDraw.Draw(img) + w, h = box[2] - box[0], box[3] - box[1] + d.text(((W - w) / 2 - box[0], (W - h) / 2 - box[1]), ch, font=f, fill=255) + return np.asarray(img.resize((size, size), Image.LANCZOS), dtype=np.float32) / 255.0 + + + +def letter_A(size=1024, weight=1.0, serif=1.0): + """A hand-built chiselled 'A' -- angular, sharp apex, flared serif feet. + + Hand-built rather than set from a system font: full control over the barbed + terminals that make the AC wordmark look forged, and no font licence riding + along in a project logo. + """ + S = 4 # supersample + W = size * S + img = Image.new("L", (W, W), 0) + d = ImageDraw.Draw(img) + + def P(pts): + return [(int(x / 1000 * W), int(y / 1000 * W)) for x, y in pts] + + w = 105 * weight + apex_y, foot_y = 70, 880 + + # two tapered strokes meeting at the apex + left = [(500 - w * 0.28, apex_y), (500 + w * 0.42, apex_y), + (330 + w * 0.55, foot_y), (330 - w * 0.62, foot_y)] + right = [(500 + w * 0.28, apex_y), (500 - w * 0.42, apex_y), + (670 - w * 0.55, foot_y), (670 + w * 0.62, foot_y)] + d.polygon(P(left), fill=255) + d.polygon(P(right), fill=255) + + # crossbar, dipped in the middle like the AC wordmark's angular bar + d.polygon(P([(300, 646), (700, 646), (700, 646 + 74), (300, 646 + 74)]), fill=255) + + # flared serif feet -- barbed, wider on the outside + sf = 96 * serif + d.polygon(P([(330 - w * 0.62, foot_y), (330 + w * 0.55, foot_y), + (330 + w * 0.55 + sf * 0.35, foot_y + 62), + (330 - w * 0.62 - sf, foot_y + 62), + (330 - w * 0.62 - sf * 1.45, foot_y + 20)]), fill=255) + d.polygon(P([(670 + w * 0.62, foot_y), (670 - w * 0.55, foot_y), + (670 - w * 0.55 - sf * 0.35, foot_y + 62), + (670 + w * 0.62 + sf, foot_y + 62), + (670 + w * 0.62 + sf * 1.45, foot_y + 20)]), fill=255) + + # apex spur + d.polygon(P([(500, 34), (500 + w * 0.60, 158), (500 - w * 0.60, 158)]), fill=255) + + img = img.resize((size, size), Image.LANCZOS) + return np.asarray(img, dtype=np.float32) / 255.0 + + +def arch_mask(size=1024, R=0.66, thick=0.155, leg_bottom=0.86, wob_seed=5): + """A standing portal arch: half-ring on two legs that thicken toward the floor.""" + y, x = np.mgrid[0:size, 0:size].astype(np.float32) + x = (x / (size - 1)) * 2 - 1 + y = (y / (size - 1)) * 2 - 1 + cy = -0.10 + r = np.sqrt(x * x + (y - cy) ** 2) + th = np.arctan2(y - cy, x) + + wob = 0.018 * np.sin(7.0 * th + 1.3) + 0.012 * np.sin(13.0 * th + 0.4) + band = np.abs(r - (R + wob)) < thick / 2 + upper = band & (y <= cy) + + # legs flare as they descend, like a forged post set into a plinth + flare = thick / 2 * (1.0 + 0.55 * np.clip((y - cy) / max(leg_bottom - cy, 1e-6), 0, 1) ** 1.6) + legs = (np.abs(np.abs(x) - R) < flare) & (y > cy) & (y < leg_bottom) + plinth = (np.abs(y - (leg_bottom + thick * 0.34)) < thick * 0.42) & (np.abs(x) < R + thick * 1.5) + + m = (upper | legs | plinth).astype(np.float32) + im = Image.fromarray((m * 255).astype(np.uint8), "L").filter(ImageFilter.GaussianBlur(1.2)) + return np.asarray(im, dtype=np.float32) / 255.0 + + +def bone_matcap(size=512): + """Ivory tusk: warm, waxy, low-frequency highlight rather than mirror.""" + yy, xx = np.mgrid[0:size, 0:size].astype(np.float32) + x = (xx / (size - 1)) * 2 - 1 + y = 1 - (yy / (size - 1)) * 2 + r2 = x * x + y * y + inside = (r2 <= 1.0).astype(np.float32) + + lam = np.clip(0.30 + 0.72 * (y * 0.55 + 0.55), 0, 1.4) + warm = np.array([1.00, 0.965, 0.865], dtype=np.float32) + base = lam[..., None] * warm + base = base + 0.42 * np.exp(-(((x + 0.30) ** 2 + (y - 0.42) ** 2)) / 0.075)[..., None] + # subsurface warmth low down + base = base + 0.20 * np.clip(-y, 0, 1)[..., None] * np.array([0.62, 0.46, 0.28], dtype=np.float32) + base = base * (1.0 - 0.30 * np.clip((r2 - 0.60) / 0.40, 0, 1))[..., None] + return np.concatenate([np.clip(base, 0, 1.4), inside[..., None]], axis=2).astype(np.float32) diff --git a/tools/IconForge/compose.py b/tools/IconForge/compose.py new file mode 100644 index 00000000..d5665235 --- /dev/null +++ b/tools/IconForge/compose.py @@ -0,0 +1,112 @@ +"""Compose the rendered mosswart head into an app-icon badge.""" +import math, os +import numpy as np +from PIL import Image, ImageDraw, ImageFilter, ImageChops + +HERE = os.path.dirname(os.path.abspath(__file__)) + + +def rounded_mask(size, radius_frac=0.225, ss=4): + W = size * ss + m = Image.new("L", (W, W), 0) + d = ImageDraw.Draw(m) + d.rounded_rectangle([0, 0, W - 1, W - 1], radius=int(W * radius_frac), fill=255) + return m.resize((size, size), Image.LANCZOS) + + +def circle_mask(size, ss=4): + W = size * ss + m = Image.new("L", (W, W), 0) + ImageDraw.Draw(m).ellipse([0, 0, W - 1, W - 1], fill=255) + return m.resize((size, size), Image.LANCZOS) + + +def field(size, inner="#2C3A1A", outer="#0B1006", cx=0.5, cy=0.36): + """Radial swamp-light gradient behind the head.""" + y, x = np.mgrid[0:size, 0:size].astype(np.float32) / max(size - 1, 1) + r = np.sqrt(((x - cx) * 1.05) ** 2 + ((y - cy) * 1.05) ** 2) / 0.78 + r = np.clip(r, 0, 1) ** 1.15 + ci = np.array([int(inner[i:i + 2], 16) for i in (1, 3, 5)], dtype=np.float32) + co = np.array([int(outer[i:i + 2], 16) for i in (1, 3, 5)], dtype=np.float32) + img = ci[None, None, :] * (1 - r[..., None]) + co[None, None, :] * r[..., None] + return Image.fromarray(img.astype(np.uint8), "RGB").convert("RGBA") + + +def drop_shadow(subject, blur=26, dy=18, opacity=0.55, spread=1.03): + a = subject.split()[3] + w, h = a.size + s = a.resize((int(w * spread), int(h * spread)), Image.LANCZOS) + canvas = Image.new("L", (w, h), 0) + canvas.paste(s, ((w - s.width) // 2, (h - s.height) // 2 + dy)) + canvas = canvas.filter(ImageFilter.GaussianBlur(blur)) + canvas = canvas.point(lambda v: int(v * opacity)) + sh = Image.new("RGBA", (w, h), (0, 0, 0, 0)) + sh.putalpha(canvas) + return sh + + +def fit_subject(sub, size, occupancy=0.86, offset_y=0.0): + """Trim to content, scale so the longest side hits `occupancy`, centre it.""" + bbox = sub.split()[3].getbbox() + sub = sub.crop(bbox) + scale = (size * occupancy) / max(sub.width, sub.height) + sub = sub.resize((max(1, int(sub.width * scale)), max(1, int(sub.height * scale))), + Image.LANCZOS) + out = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + out.paste(sub, ((size - sub.width) // 2, + int((size - sub.height) / 2 + size * offset_y)), sub) + return out + + +def badge(subject, size=1024, shape="rounded", occupancy=0.84, offset_y=0.02, + inner="#2C3A1A", outer="#0B1006", shadow=True, rim=True): + bg = field(size, inner, outer) + if rim: + # faint inner rim so the badge has an edge in dark UI + ring = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + d = ImageDraw.Draw(ring) + d.rounded_rectangle([2, 2, size - 3, size - 3], + radius=int(size * 0.225) if shape == "rounded" else size // 2, + outline=(150, 172, 92, 46), width=max(2, size // 220)) + bg = Image.alpha_composite(bg, ring) + + sub = fit_subject(subject, size, occupancy, offset_y) + if shadow: + bg = Image.alpha_composite(bg, drop_shadow(sub, blur=int(size * 0.028), + dy=int(size * 0.018))) + out = Image.alpha_composite(bg, sub) + + mask = rounded_mask(size) if shape == "rounded" else circle_mask(size) + out.putalpha(ImageChops.multiply(out.split()[3], mask)) + return out + + +def free(subject, size=1024, occupancy=0.94): + """No field -- the head alone on transparency.""" + return fit_subject(subject, size, occupancy) + + +def contact(images, labels, cell=280, pad=14, bg=(22, 24, 20)): + sheet = Image.new("RGB", (len(images) * cell, cell + 24), bg) + d = ImageDraw.Draw(sheet) + for i, (im, lb) in enumerate(zip(images, labels)): + t = im.copy().resize((cell - 2 * pad, cell - 2 * pad), Image.LANCZOS) + base = Image.new("RGBA", t.size, bg + (255,)) + sheet.paste(Image.alpha_composite(base, t).convert("RGB"), (i * cell + pad, pad)) + d.text((i * cell + pad, cell + 4), lb, fill=(200, 210, 170)) + return sheet + + +def size_strip(icon, sizes=(128, 64, 48, 32, 24, 16), bg=(22, 24, 20)): + W = sum(s + 18 for s in sizes) + 20 + H = max(sizes) + 34 + sheet = Image.new("RGB", (W, H), bg) + d = ImageDraw.Draw(sheet) + x = 12 + for s in sizes: + t = icon.resize((s, s), Image.LANCZOS) + base = Image.new("RGBA", t.size, bg + (255,)) + sheet.paste(Image.alpha_composite(base, t).convert("RGB"), (x, 10 + (max(sizes) - s))) + d.text((x, H - 18), "%d" % s, fill=(200, 210, 170)) + x += s + 18 + return sheet diff --git a/tools/IconForge/forge.py b/tools/IconForge/forge.py new file mode 100644 index 00000000..dc2db328 --- /dev/null +++ b/tools/IconForge/forge.py @@ -0,0 +1,144 @@ +"""Regenerate acdream's application icons into assets/icons/. + + py tools/IconForge/forge.py launcher # procedural, no game data needed + py tools/IconForge/forge.py client # needs a mosswart mesh export + py tools/IconForge/forge.py all + +The launcher mark is entirely procedural: a forged ring and the crescent glyph +are generated from geometry in ring.py / ac_glyph.py and shaded through a +matcap, so it rebuilds anywhere. + +The client mark renders the retail mosswart head, so it needs two inputs that +come out of the installed DATs (see README.md): + + mosswart_mesh.json geometry export, Setup 0x02000B4F + textures/0x05001E11... the skin atlas and its siblings + +Both default to --work, which is where those extraction steps write. +""" +import argparse +import os +import sys + +import numpy as np +from PIL import Image, ImageChops, ImageFilter + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, HERE) + +import ac_glyph as ag # noqa: E402 +import chisel # noqa: E402 +import compose # noqa: E402 +import launcher as lz # noqa: E402 +import render # noqa: E402 +import ring # noqa: E402 +import smooth # noqa: E402 + +REPO = os.path.abspath(os.path.join(HERE, "..", "..")) +ASSETS = os.path.join(REPO, "assets", "icons") + +# Sizes written as loose PNGs, and the subset baked into the .ico. +PNG_SIZES = (1024, 512, 256, 128, 64, 48, 32, 24, 16) +ICO_SIZES = [(16, 16), (24, 24), (32, 32), (48, 48), (64, 64), (128, 128), (256, 256)] + +# The head is part 14 of the mosswart Setup; see assets/icons/README.md. +HEAD_PART = 14 + + +def _emit(master, stem): + os.makedirs(ASSETS, exist_ok=True) + master.save(os.path.join(ASSETS, f"{stem}-1024.png")) + for s in PNG_SIZES: + if s == 1024: + continue + master.resize((s, s), Image.LANCZOS).save(os.path.join(ASSETS, f"{stem}-{s}.png")) + master.save(os.path.join(ASSETS, f"{stem}.ico"), sizes=ICO_SIZES) + print(f" wrote {stem}-*.png and {stem}.ico into assets/icons/") + + +def build_launcher(size=1024): + """Forged ring + barbed crescent, on the retail icon's warm field.""" + chrome = ring.chrome_matcap(768) + + def shade(mask, bevel, ao, exposure=1.0): + return chisel.shade(ag.soften(mask, 1.2), chrome, + bevel_px=int(size * bevel), profile="chisel", + plateau=0.0, ao=ao, exposure=exposure) + + ring_img = shade(ag.thin_ring(size, R=0.86, thick=0.058), 0.026, 0.42) + glyph_img = shade(ag.ac_glyph(size, R=0.68, inner_r=0.605, offset=0.275, + rot_deg=-14), 0.034, 0.38, 1.06) + + # Glyph over ring with a soft cast shadow, so the ring reads as passing + # behind the crescent the way it does in the retail wordmark. + def over(base, top): + a = top.split()[3].filter(ImageFilter.GaussianBlur(int(size * 0.012))) + a = ImageChops.offset(a, 0, int(size * 0.006)).point(lambda v: int(v * 0.55)) + sh = Image.new("RGBA", base.size, (0, 0, 0, 0)) + sh.putalpha(a) + return Image.alpha_composite(Image.alpha_composite(base, sh), top) + + sigil = over(Image.new("RGBA", (size, size), (0, 0, 0, 0)), ring_img) + sigil = over(sigil, glyph_img) + + out, _ = lz.place(compose.field(size, "#3A3418", "#070803"), sigil, 0.94) + out.putalpha(ImageChops.multiply(out.split()[3], compose.rounded_mask(size))) + return out + + +def build_client(work, size=1024): + """The retail mosswart head, smoothed, lit, and badged.""" + mesh_path = os.path.join(work, "mosswart_mesh.json") + if not os.path.exists(mesh_path): + raise SystemExit( + f"missing {mesh_path}\n" + "Export it first (see assets/icons/README.md) -- the client mark is\n" + "rendered from the installed DATs, not from committed geometry.") + + textures = render.load_textures(render.all_texture_ids(mesh_path)) + if not textures: + raise SystemExit( + "no textures resolved. Dump the mosswart surfaces first " + "(see assets/icons/README.md).") + + raw = render.load_mesh(mesh_path, parts_filter={HEAD_PART}, ignore_transform=True) + head = smooth.smooth_mesh(raw, crease_deg=52, level=6) + + # az/el chosen because a mosswart's eyes sit on the sides of the skull like + # a frog's: dead-on frontal turns them edge-on and the face stops reading. + hero = render.render( + head, textures, size=size, ss=3, fit=0.90, fov=30, az=266, el=32, + key_col=(0.92, 0.94, 0.76), amb_top=(0.17, 0.22, 0.14), + amb_bot=(0.03, 0.04, 0.02), fill_col=(0.13, 0.19, 0.10), + rim_col=(0.58, 0.80, 0.28), spec_amt=0.46, spec_pow=20.0, + exposure=0.88, gamma=1.28, + key_cam=(-0.45, 0.55, 0.70), fill_cam=(0.80, 0.05, 0.35), + rim_cam=(0.50, 0.35, -0.62)) + + return compose.badge(hero, size, "rounded", occupancy=0.74, offset_y=0.02, + inner="#243014", outer="#070A04") + + +def main(): + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("target", choices=("launcher", "client", "all")) + ap.add_argument("--work", default=os.path.join(HERE, "work"), + help="directory holding the DAT export inputs (client only)") + ap.add_argument("--size", type=int, default=1024) + args = ap.parse_args() + + if args.target in ("launcher", "all"): + print("forging launcher icon...") + _emit(build_launcher(args.size), "acdream-launcher") + + if args.target in ("client", "all"): + print("forging client icon...") + render.TEXDIR = os.path.join(args.work, "textures") + _emit(build_client(args.work, args.size), "acdream-client") + + print("done.") + + +if __name__ == "__main__": + main() diff --git a/tools/IconForge/launcher.py b/tools/IconForge/launcher.py new file mode 100644 index 00000000..ee745854 --- /dev/null +++ b/tools/IconForge/launcher.py @@ -0,0 +1,76 @@ +"""Launcher icon studies: the Asheron's Call ring motif, made acdream's own.""" +import numpy as np +from PIL import Image, ImageFilter, ImageChops + +import render, ring, compose, smooth + + +def portal_swirl(size=1024, arms=2.0, twist=2.9, + core=(0.92, 0.95, 1.00), mid=(0.36, 0.52, 0.95), + outer=(0.30, 0.14, 0.52)): + """Procedural log-spiral vortex -- AC's other signature image.""" + y, x = np.mgrid[0:size, 0:size].astype(np.float32) + x = (x / (size - 1)) * 2 - 1 + y = (y / (size - 1)) * 2 - 1 + r = np.sqrt(x * x + y * y) + th = np.arctan2(y, x) + + safe = np.clip(r, 1e-3, None) + spiral = np.sin(arms * th + twist * np.log(safe) * 3.2) + spiral = (spiral * 0.5 + 0.5) ** 1.15 + + # gentler falloff + a much wider core, so the vortex fills the ring rather + # than sitting in the middle of it as a speck + falloff = np.clip(1.0 - r / 1.05, 0, 1) ** 0.85 + corebloom = np.exp(-(r ** 2) / 0.075) + + t = np.clip(r / 0.9, 0, 1)[..., None] + ramp = (np.array(mid, dtype=np.float32)[None, None, :] * (1 - t) + + np.array(outer, dtype=np.float32)[None, None, :] * t) + rgb = ramp * (0.42 + 1.25 * spiral)[..., None] * falloff[..., None] + rgb = rgb + np.array(core, dtype=np.float32)[None, None, :] * corebloom[..., None] + a = np.clip(falloff * (0.55 + 0.95 * spiral) + corebloom, 0, 1) + + img = np.concatenate([np.clip(rgb, 0, 1), a[..., None]], axis=2) + return Image.fromarray((img * 255).astype(np.uint8), "RGBA") + + +def inner_shadow(subject_alpha, size, blur=22, opacity=0.6, dy=10): + """Soft shadow cast by the ring onto whatever sits inside it.""" + s = subject_alpha.filter(ImageFilter.GaussianBlur(blur)) + s = ImageChops.offset(s, 0, dy) + s = s.point(lambda v: int(v * opacity)) + sh = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + sh.putalpha(s) + return sh + + +def render_ring(size=1024, matcap=None, gap_deg=26.0, el=0.0, exposure=1.0, + gap_center_deg=90.0, R=1.0, r=0.135): + mc = {"RING": matcap if matcap is not None else ring.chrome_matcap(768)} + rp, rn, ruv, rt = ring.forged_ring(R=R, r=r, gap_deg=gap_deg, + gap_center_deg=gap_center_deg) + mesh = (rp, rn, ruv, rt, ["RING"] * len(rt)) + return render.render(mesh, {}, size=size, ss=3, az=270, el=el, fov=30, + fit=1.0, matcaps=mc, exposure=exposure) + + +def render_hook(size=1024, matcap=None, exposure=1.0, scale=0.60): + mc = {"RING": matcap if matcap is not None else ring.chrome_matcap(768)} + gp, gn, guv, gt = ring.hook_glyph(scale=scale) + mesh = (gp, gn, guv, gt, ["RING"] * len(gt)) + return render.render(mesh, {}, size=size, ss=3, az=270, el=0, fov=30, + fit=1.0, matcaps=mc, exposure=exposure) + + +def place(canvas, sub, occupancy, offset=(0.0, 0.0)): + """Scale `sub` to `occupancy` of the canvas and paste it centred + offset.""" + size = canvas.size[0] + bbox = sub.split()[3].getbbox() + s = sub.crop(bbox) + k = (size * occupancy) / max(s.width, s.height) + s = s.resize((max(1, int(s.width * k)), max(1, int(s.height * k))), Image.LANCZOS) + layer = Image.new("RGBA", canvas.size, (0, 0, 0, 0)) + layer.paste(s, (int((size - s.width) / 2 + size * offset[0]), + int((size - s.height) / 2 + size * offset[1])), s) + return Image.alpha_composite(canvas, layer), layer diff --git a/tools/IconForge/render.py b/tools/IconForge/render.py new file mode 100644 index 00000000..6c454b5a --- /dev/null +++ b/tools/IconForge/render.py @@ -0,0 +1,236 @@ +"""Software rasterizer for the exported mosswart mesh. + +Loads the JSON dumped by tools/MosswartArt, textures dumped by tools/IconExtract, +and renders a lit, shaded, supersampled image. Deliberately simple: z-buffer, +perspective camera, Lambert key/fill/rim + Blinn specular for wet skin. +""" +import json, glob, os, math +import numpy as np +from PIL import Image + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.abspath(os.path.join(HERE, "..", "..")) +TEXDIR = os.path.join(ROOT, "tools", "IconExtract", "bin", "Release", "net10.0", "out") + + +# ---------------------------------------------------------------- data loading +def load_textures(ids): + out = {} + for tid in ids: + hits = glob.glob(os.path.join(TEXDIR, tid + "_*.png")) + if not hits: + continue + im = Image.open(hits[0]).convert("RGBA") + out[tid] = np.asarray(im, dtype=np.float32) / 255.0 + return out + + +def load_mesh(path, parts_filter=None, ignore_transform=False): + doc = json.load(open(path)) + P, N, UV, TRI, TEX = [], [], [], [], [] + for part in doc["parts"]: + if parts_filter is not None and part["index"] not in parts_filter: + continue + m = np.array(part["m"], dtype=np.float64).reshape(4, 4) + if ignore_transform: + m = np.eye(4) + # System.Numerics Matrix4x4 is row-vector convention: v' = v * M + rot = m[:3, :3] + trans = m[3, :3] + for sub in part["sub"]: + v = np.array(sub["v"], dtype=np.float64) + if len(v) == 0: + continue + base = len(P) + pos = v[:, 0:3] @ rot + trans + nrm = v[:, 3:6] @ rot + ln = np.linalg.norm(nrm, axis=1, keepdims=True) + nrm = np.divide(nrm, np.where(ln == 0, 1, ln)) + P.append(pos) + N.append(nrm) + UV.append(v[:, 6:8]) + idx = np.array(sub["i"], dtype=np.int64).reshape(-1, 3) + base + TRI.append(idx) + TEX += [sub["tex"]] * len(idx) + return (np.concatenate(P), np.concatenate(N), np.concatenate(UV), + np.concatenate(TRI), TEX) + + +# ---------------------------------------------------------------- math helpers +def look_at(eye, target, up=np.array([0.0, 0.0, 1.0])): + f = target - eye + f = f / np.linalg.norm(f) + s = np.cross(f, up) + if np.linalg.norm(s) < 1e-9: + s = np.cross(f, np.array([0.0, 1.0, 0.0])) + s = s / np.linalg.norm(s) + u = np.cross(s, f) + return np.stack([s, u, -f]) # rows: right, up, back + + +def orbit_eye(target, radius, az_deg, el_deg): + az, el = math.radians(az_deg), math.radians(el_deg) + return target + radius * np.array([ + math.cos(el) * math.cos(az), + math.cos(el) * math.sin(az), + math.sin(el)]) + + +# ---------------------------------------------------------------- rasterizer +def render(mesh, textures, size=512, ss=3, az=90.0, el=8.0, fov=26.0, + target=None, radius=None, fit=1.0, + key=(-0.45, -0.85, 0.45), rim=(0.7, 0.75, -0.15), + bg=(0, 0, 0, 0), key_col=(1.06, 1.04, 0.92), amb_top=(0.30, 0.34, 0.24), + amb_bot=(0.10, 0.10, 0.06), rim_col=(0.55, 0.72, 0.30), spec_pow=26.0, + spec_amt=0.42, key_cam=None, rim_cam=None, fill_cam=None, + fill_col=(0.0, 0.0, 0.0), exposure=1.0, gamma=1.0, matcaps=None): + P, N, UV, TRI, TEX = mesh + W = size * ss + + if target is None: + lo, hi = P.min(axis=0), P.max(axis=0) + target = (lo + hi) / 2.0 + if radius is None: + ext = np.linalg.norm(P.max(axis=0) - P.min(axis=0)) + radius = ext / (2.0 * math.tan(math.radians(fov) / 2.0)) * fit + + eye = orbit_eye(target, radius, az, el) + V = look_at(eye, target) + + cam = (P - eye) @ V.T + ncam = N @ V.T + + f = 1.0 / math.tan(math.radians(fov) / 2.0) + z = -cam[:, 2] + z = np.where(z < 1e-6, 1e-6, z) + sx = (cam[:, 0] * f / z * 0.5 + 0.5) * W + sy = (1.0 - (cam[:, 1] * f / z * 0.5 + 0.5)) * W + + color = np.zeros((W, W, 3), dtype=np.float32) + alpha = np.zeros((W, W), dtype=np.float32) + depth = np.full((W, W), 1e30, dtype=np.float32) + + keyd = np.array(key, dtype=np.float64); keyd /= np.linalg.norm(keyd) + rimd = np.array(rim, dtype=np.float64); rimd /= np.linalg.norm(rimd) + keyc = V @ keyd + rimc = V @ rimd + # Camera-relative light directions make art direction repeatable across + # camera angles -- the key stays on the same side of the face regardless + # of where the model happens to be facing in world space. + if key_cam is not None: + keyc = np.array(key_cam, dtype=np.float64); keyc /= np.linalg.norm(keyc) + if rim_cam is not None: + rimc = np.array(rim_cam, dtype=np.float64); rimc /= np.linalg.norm(rimc) + fillc = None + if fill_cam is not None: + fillc = np.array(fill_cam, dtype=np.float64); fillc /= np.linalg.norm(fillc) + viewd = np.array([0.0, 0.0, 1.0]) + half = keyc + viewd + half = half / np.linalg.norm(half) + + for t in range(len(TRI)): + i0, i1, i2 = TRI[t] + x0, y0 = sx[i0], sy[i0] + x1, y1 = sx[i1], sy[i1] + x2, y2 = sx[i2], sy[i2] + area = (x1 - x0) * (y2 - y0) - (x2 - x0) * (y1 - y0) + if abs(area) < 1e-9: + continue + minx = max(int(math.floor(min(x0, x1, x2))), 0) + maxx = min(int(math.ceil(max(x0, x1, x2))), W - 1) + miny = max(int(math.floor(min(y0, y1, y2))), 0) + maxy = min(int(math.ceil(max(y0, y1, y2))), W - 1) + if minx > maxx or miny > maxy: + continue + + xs = np.arange(minx, maxx + 1) + ys = np.arange(miny, maxy + 1) + gx, gy = np.meshgrid(xs + 0.5, ys + 0.5) + w0 = ((x1 - gx) * (y2 - gy) - (x2 - gx) * (y1 - gy)) / area + w1 = ((x2 - gx) * (y0 - gy) - (x0 - gx) * (y2 - gy)) / area + w2 = 1.0 - w0 - w1 + inside = (w0 >= 0) & (w1 >= 0) & (w2 >= 0) + if not inside.any(): + continue + + zt = w0 * z[i0] + w1 * z[i1] + w2 * z[i2] + sub = depth[miny:maxy + 1, minx:maxx + 1] + closer = inside & (zt < sub) + if not closer.any(): + continue + + u = w0 * UV[i0, 0] + w1 * UV[i1, 0] + w2 * UV[i2, 0] + v = w0 * UV[i0, 1] + w1 * UV[i1, 1] + w2 * UV[i2, 1] + nx = w0 * ncam[i0, 0] + w1 * ncam[i1, 0] + w2 * ncam[i2, 0] + ny = w0 * ncam[i0, 1] + w1 * ncam[i1, 1] + w2 * ncam[i2, 1] + nz = w0 * ncam[i0, 2] + w1 * ncam[i1, 2] + w2 * ncam[i2, 2] + nl = np.sqrt(nx * nx + ny * ny + nz * nz) + nl = np.where(nl == 0, 1, nl) + nx, ny, nz = nx / nl, ny / nl, nz / nl + # two-sided: AC meshes are frequently single-sided but authored either way + flip = nz < 0 + nx = np.where(flip, -nx, nx); ny = np.where(flip, -ny, ny); nz = np.where(flip, -nz, nz) + + tex = textures.get(TEX[t]) + if tex is None: + rgb = np.ones(u.shape + (3,), dtype=np.float32) * 0.6 + ta = np.ones(u.shape, dtype=np.float32) + else: + th, tw = tex.shape[0], tex.shape[1] + fu = (u % 1.0) * (tw - 1) + fv = (v % 1.0) * (th - 1) + u0 = np.clip(np.floor(fu).astype(np.int32), 0, tw - 1) + v0 = np.clip(np.floor(fv).astype(np.int32), 0, th - 1) + u1 = np.clip(u0 + 1, 0, tw - 1) + v1 = np.clip(v0 + 1, 0, th - 1) + du = (fu - u0)[..., None] + dv = (fv - v0)[..., None] + texel = (tex[v0, u0] * (1 - du) * (1 - dv) + tex[v0, u1] * du * (1 - dv) + + tex[v1, u0] * (1 - du) * dv + tex[v1, u1] * du * dv) + rgb = texel[..., :3] + ta = texel[..., 3] + + ndl = np.clip(nx * keyc[0] + ny * keyc[1] + nz * keyc[2], 0, 1) + ndr = np.clip(nx * rimc[0] + ny * rimc[1] + nz * rimc[2], 0, 1) ** 2.4 + up = np.clip(nz * 0.35 + 0.65, 0, 1) + amb = (np.array(amb_bot)[None, None, :] + + (np.array(amb_top) - np.array(amb_bot))[None, None, :] * up[..., None]) + ndh = np.clip(nx * half[0] + ny * half[1] + nz * half[2], 0, 1) + spec = (ndh ** spec_pow) * spec_amt * ndl + + lightsum = amb + np.array(key_col)[None, None, :] * ndl[..., None] + if fillc is not None: + ndf = np.clip(nx * fillc[0] + ny * fillc[1] + nz * fillc[2], 0, 1) + lightsum = lightsum + np.array(fill_col)[None, None, :] * ndf[..., None] + lit = (rgb * lightsum + + np.array(rim_col)[None, None, :] * ndr[..., None] + + spec[..., None]) * exposure + if gamma != 1.0: + lit = np.clip(lit, 0, None) ** gamma + + # Metal: a Lambert term cannot produce chrome, because chrome is almost + # entirely reflection. Sample a matcap by the camera-space normal instead. + mc = matcaps.get(TEX[t]) if matcaps else None + if mc is not None: + mh, mw = mc.shape[0], mc.shape[1] + mu = np.clip((nx * 0.5 + 0.5) * (mw - 1), 0, mw - 1).astype(np.int32) + mv = np.clip((1.0 - (ny * 0.5 + 0.5)) * (mh - 1), 0, mh - 1).astype(np.int32) + lit = mc[mv, mu][..., :3] * exposure + ta = np.ones(u.shape, dtype=np.float32) + + m = closer & (ta > 0.35) + if not m.any(): + continue + ys_i, xs_i = np.nonzero(m) + color[miny + ys_i, minx + xs_i] = lit[ys_i, xs_i] + alpha[miny + ys_i, minx + xs_i] = 1.0 + depth[miny + ys_i, minx + xs_i] = zt[ys_i, xs_i] + + rgba = np.concatenate([np.clip(color, 0, 1), alpha[..., None]], axis=2) + img = Image.fromarray((rgba * 255).astype(np.uint8), "RGBA") + return img.resize((size, size), Image.LANCZOS) + + +def all_texture_ids(path): + doc = json.load(open(path)) + return sorted({s["tex"] for p in doc["parts"] for s in p["sub"]}) diff --git a/tools/IconForge/ring.py b/tools/IconForge/ring.py new file mode 100644 index 00000000..d6573027 --- /dev/null +++ b/tools/IconForge/ring.py @@ -0,0 +1,170 @@ +"""A forged metal ring, plus the matcap needed to shade it as chrome. + +The Asheron's Call mark is a broken, hand-forged silver band enclosing a hooked +glyph. A plain torus reads as a donut, so the tube radius is noise-modulated +along the major angle and tapered to points at the break. + +Chrome needs environment reflection, which a Lambert rasterizer cannot give. +A matcap (material capture) solves it: one image of a lit sphere, sampled by the +camera-space normal. It is the standard cheap stand-in for a full env map. +""" +import numpy as np + + +def _fbm(theta, seed=7, octaves=4): + rng = np.random.default_rng(seed) + out = np.zeros_like(theta) + amp, freq = 1.0, 3.0 + for _ in range(octaves): + phase = rng.uniform(0, 2 * np.pi) + out += amp * np.sin(freq * theta + phase) + amp *= 0.5 + freq *= 2.0 + return out / 1.9 + + +def forged_ring(R=1.0, r=0.135, nu=320, nv=44, gap_deg=26.0, gap_center_deg=90.0, + rough=0.30, taper=2.2, seed=7, end_frac=0.16): + """Broken forged band in the XZ plane (so it faces a -Y camera).""" + span = 360.0 - gap_deg + start = gap_center_deg + gap_deg / 2.0 + u = np.radians(start + np.linspace(0.0, span, nu)) + v = np.linspace(0.0, 2 * np.pi, nv) + + t = np.linspace(0.0, 1.0, nu) + # taper both ends of the band to points, and rough up the middle + # only taper the last end_frac at each end, so the band stays a band + ramp = np.clip(np.minimum(t, 1.0 - t) / max(end_frac, 1e-6), 0.0, 1.0) + ends = ramp ** (1.0 / taper) + tube = r * ends * (1.0 + rough * _fbm(u * 1.7, seed)) + tube = np.maximum(tube, r * 0.05) + + U, V = np.meshgrid(u, v, indexing="ij") + T = np.broadcast_to(tube[:, None], U.shape) + # slight out-of-plane wobble so it reads hand-made, not machined + wob = 0.035 * _fbm(u * 2.3, seed + 3)[:, None] + + cx, cy = np.cos(U), np.sin(U) + px = (R + T * np.cos(V)) * cx + pz = (R + T * np.cos(V)) * cy + py = T * np.sin(V) + wob + + P = np.stack([px, py, pz], axis=-1).reshape(-1, 3) + + # analytic-ish normals: outward from the tube centreline + ccx = R * cx + ccz = R * cy + ccy = np.zeros_like(ccx) + wob + C = np.stack([ccx, ccy, ccz], axis=-1).reshape(-1, 3) + N = P - C + ln = np.linalg.norm(N, axis=1, keepdims=True) + N = N / np.where(ln < 1e-9, 1, ln) + + UV = np.stack([U / (2 * np.pi), V / (2 * np.pi)], axis=-1).reshape(-1, 2) + + tri = [] + for i in range(nu - 1): + for j in range(nv - 1): + a = i * nv + j + b = (i + 1) * nv + j + c = i * nv + (j + 1) + d = (i + 1) * nv + (j + 1) + tri.append((a, b, c)) + tri.append((b, d, c)) + return P, N, UV, np.array(tri, dtype=np.int64) + + +def hook_glyph(scale=0.62, thick=0.115, nu=200, nv=28, seed=11): + """A tapering crescent hook, echoing the glyph inside the AC ring.""" + t = np.linspace(0.0, 1.0, nu) + ang = np.radians(118.0 + t * 250.0) + rad = scale * (1.0 - 0.30 * t) + cx = np.cos(ang) * rad + cz = np.sin(ang) * rad + # taper: fat at the shoulder, needle at the tip + tube = thick * (np.sin(np.pi * (0.18 + 0.82 * t)) ** 0.85) * (1.0 - 0.55 * t) + tube = np.maximum(tube, thick * 0.04) + + v = np.linspace(0.0, 2 * np.pi, nv) + U, V = np.meshgrid(t, v, indexing="ij") + T = np.broadcast_to(tube[:, None], U.shape) + + # local frame along the curve + dx = np.gradient(cx); dz = np.gradient(cz) + tl = np.sqrt(dx * dx + dz * dz); tl = np.where(tl < 1e-9, 1, tl) + tx, tz = dx / tl, dz / tl + nx_, nz_ = -tz, tx # in-plane normal + P = np.stack([ + (cx[:, None] + T * np.cos(V) * nx_[:, None]), + (T * np.sin(V)), + (cz[:, None] + T * np.cos(V) * nz_[:, None]), + ], axis=-1).reshape(-1, 3) + C = np.stack([ + np.broadcast_to(cx[:, None], U.shape), + np.zeros_like(U), + np.broadcast_to(cz[:, None], U.shape), + ], axis=-1).reshape(-1, 3) + N = P - C + ln = np.linalg.norm(N, axis=1, keepdims=True) + N = N / np.where(ln < 1e-9, 1, ln) + UV = np.stack([U, V / (2 * np.pi)], axis=-1).reshape(-1, 2) + + tri = [] + for i in range(nu - 1): + for j in range(nv - 1): + a = i * nv + j; b = (i + 1) * nv + j + c = i * nv + (j + 1); d = (i + 1) * nv + (j + 1) + tri.append((a, b, c)); tri.append((b, d, c)) + return P, N, UV, np.array(tri, dtype=np.int64) + + +def chrome_matcap(size=512, tint=(1.0, 1.0, 1.06), warm=(0.62, 0.55, 0.42)): + """Polished-silver matcap: bright sky above, dark horizon, warm ground.""" + y, x = np.mgrid[0:size, 0:size].astype(np.float32) + x = (x / (size - 1)) * 2 - 1 + y = 1 - (y / (size - 1)) * 2 + r2 = x * x + y * y + inside = r2 <= 1.0 + z = np.sqrt(np.clip(1 - r2, 0, 1)) + + sky = np.clip(y * 0.5 + 0.5, 0, 1) + # sharp horizon band -- what makes metal read as metal + horizon = np.exp(-((y + 0.06) ** 2) / 0.0026) + ground = np.clip(-y * 0.9 + 0.15, 0, 1) + + base = (0.16 + 0.72 * sky ** 1.7)[..., None] * np.array(tint, dtype=np.float32) + base = base + 0.55 * horizon[..., None] * np.array([0.85, 0.90, 1.0], dtype=np.float32) + base = base + 0.42 * (ground ** 1.6)[..., None] * np.array(warm, dtype=np.float32) + + # key specular + a secondary glint + spec = np.exp(-(((x + 0.36) ** 2 + (y - 0.46) ** 2)) / 0.020) + spec2 = np.exp(-(((x - 0.44) ** 2 + (y - 0.16) ** 2)) / 0.055) + base = base + 1.5 * spec[..., None] + 0.40 * spec2[..., None] + # rim brightening at grazing angles + base = base + 0.55 * np.clip((r2 - 0.72) / 0.28, 0, 1)[..., None] + + rgb = np.clip(base, 0, 1.6) + a = inside.astype(np.float32) + return np.concatenate([rgb, a[..., None]], axis=2).astype(np.float32) + + +def verdigris_matcap(size=512): + """Same form, aged bronze-green -- ties the ring to the mosswart palette.""" + m = chrome_matcap(size, tint=(0.72, 0.86, 0.58), warm=(0.50, 0.44, 0.18)) + m[..., 0] *= 0.78 + m[..., 1] *= 0.96 + m[..., 2] *= 0.62 + return m + + +def merge(*meshes): + """Concatenate (P,N,UV,TRI,TEX) tuples into one mesh.""" + P, N, UV, TRI, TEX = [], [], [], [], [] + base = 0 + for p, n, uv, tri, tex in meshes: + P.append(p); N.append(n); UV.append(uv) + TRI.append(tri + base) + TEX += tex + base += len(p) + return (np.concatenate(P), np.concatenate(N), np.concatenate(UV), + np.concatenate(TRI), TEX) diff --git a/tools/IconForge/smooth.py b/tools/IconForge/smooth.py new file mode 100644 index 00000000..97a0be0a --- /dev/null +++ b/tools/IconForge/smooth.py @@ -0,0 +1,124 @@ +"""Turn the retail low-poly head into something that can carry a 3D-realistic icon. + +Two steps, both standard: + +1. Crease-aware normal welding. The dat mesh stores one normal per (position,uv) + pair, so shared corners come back faceted. We average normals across + coincident positions, but only between faces whose normals are within a + crease angle -- so the ear fins and tusk edges stay sharp while the skull + rounds off. + +2. PN-triangle tessellation (Vlachos et al. 2001). Each flat triangle becomes a + cubic Bezier patch built from its own corner positions and normals, with + quadratically-interpolated normals. It rounds the silhouette without needing + mesh connectivity, so UV seams cannot pull apart. +""" +import numpy as np + + +def weld_normals(P, N, crease_deg=52.0, decimals=5): + """Average normals across coincident positions within a crease angle.""" + key = np.round(P, decimals) + _, inv = np.unique(key, axis=0, return_inverse=True) + inv = inv.ravel() + out = N.copy() + cos_t = np.cos(np.radians(crease_deg)) + order = np.argsort(inv, kind="stable") + inv_sorted = inv[order] + bounds = np.flatnonzero(np.diff(inv_sorted)) + 1 + for grp in np.split(order, bounds): + if len(grp) < 2: + continue + n = N[grp] + for a in range(len(grp)): + sel = n @ n[a] >= cos_t + acc = n[sel].sum(axis=0) + ln = np.linalg.norm(acc) + if ln > 1e-9: + out[grp[a]] = acc / ln + return out + + +def _pn_patch(p1, p2, p3, n1, n2, n3): + def edge(pa, pb, na): + w = np.einsum("ij,ij->i", pb - pa, na) + return (2 * pa + pb - w[:, None] * na) / 3.0 + + b210 = edge(p1, p2, n1); b120 = edge(p2, p1, n2) + b021 = edge(p2, p3, n2); b012 = edge(p3, p2, n3) + b102 = edge(p3, p1, n3); b201 = edge(p1, p3, n1) + E = (b210 + b120 + b021 + b012 + b102 + b201) / 6.0 + V = (p1 + p2 + p3) / 3.0 + b111 = E + (E - V) / 2.0 + + def nedge(pa, pb, na, nb): + d = pb - pa + denom = np.einsum("ij,ij->i", d, d) + denom = np.where(denom < 1e-12, 1e-12, denom) + v = 2.0 * np.einsum("ij,ij->i", d, na + nb) / denom + r = na + nb - v[:, None] * d + ln = np.linalg.norm(r, axis=1, keepdims=True) + return r / np.where(ln < 1e-9, 1, ln) + + n110 = nedge(p1, p2, n1, n2) + n011 = nedge(p2, p3, n2, n3) + n101 = nedge(p3, p1, n3, n1) + return (b210, b120, b021, b012, b102, b201, b111, n110, n011, n101) + + +def pn_tessellate(P, N, UV, TRI, level=4): + """Subdivide every triangle into level^2 sub-triangles on its PN patch.""" + i1, i2, i3 = TRI[:, 0], TRI[:, 1], TRI[:, 2] + p1, p2, p3 = P[i1], P[i2], P[i3] + n1, n2, n3 = N[i1], N[i2], N[i3] + t1, t2, t3 = UV[i1], UV[i2], UV[i3] + (b210, b120, b021, b012, b102, b201, b111, + n110, n011, n101) = _pn_patch(p1, p2, p3, n1, n2, n3) + + # barycentric lattice + lat, lat_index = [], {} + for i in range(level + 1): + for j in range(level + 1 - i): + lat_index[(i, j)] = len(lat) + lat.append((i / level, j / level)) + lat = np.array(lat) + + T = len(TRI) + L = len(lat) + newP = np.empty((T, L, 3)); newN = np.empty((T, L, 3)); newUV = np.empty((T, L, 2)) + for k, (u, v) in enumerate(lat): + w = 1.0 - u - v + pos = (p1 * (w ** 3) + p2 * (u ** 3) + p3 * (v ** 3) + + b210 * (3 * w * w * u) + b120 * (3 * w * u * u) + + b021 * (3 * u * u * v) + b012 * (3 * u * v * v) + + b102 * (3 * w * v * v) + b201 * (3 * w * w * v) + + b111 * (6 * w * u * v)) + nrm = (n1 * (w * w) + n2 * (u * u) + n3 * (v * v) + + n110 * (2 * w * u) + n011 * (2 * u * v) + n101 * (2 * w * v)) + ln = np.linalg.norm(nrm, axis=1, keepdims=True) + newP[:, k] = pos + newN[:, k] = nrm / np.where(ln < 1e-9, 1, ln) + newUV[:, k] = t1 * w + t2 * u + t3 * v + + tris = [] + for i in range(level): + for j in range(level - i): + a = lat_index[(i, j)]; b = lat_index[(i + 1, j)]; c = lat_index[(i, j + 1)] + tris.append((a, b, c)) + if i + j < level - 1: + dd = lat_index[(i + 1, j + 1)] + tris.append((b, dd, c)) + tris = np.array(tris, dtype=np.int64) + + offs = (np.arange(T) * L)[:, None, None] + TRI2 = (tris[None, :, :] + offs).reshape(-1, 3) + return (newP.reshape(-1, 3), newN.reshape(-1, 3), newUV.reshape(-1, 2), TRI2) + + +def smooth_mesh(mesh, crease_deg=52.0, level=4): + P, N, UV, TRI, TEX = mesh + N2 = weld_normals(P, N, crease_deg) + P3, N3, UV3, TRI3 = pn_tessellate(P, N2, UV, TRI, level) + per = len(TRI3) // len(TRI) + TEX3 = [t for t in TEX for _ in range(per)] + return (P3, N3, UV3, TRI3, TEX3) diff --git a/tools/MosswartArt/Export.cs b/tools/MosswartArt/Export.cs new file mode 100644 index 00000000..abe158bc --- /dev/null +++ b/tools/MosswartArt/Export.cs @@ -0,0 +1,143 @@ +// Export a creature Setup's geometry (per part, with UVs + normals + the +// ClothingBase texture substitutions applied) to JSON so an offline rasterizer +// can render it. Uses acdream's own tested GfxObjMesh/SetupMesh port rather than +// re-deriving the dat mesh format. +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Text; +using AcDream.Core.Content; +using AcDream.Core.Meshing; +using DatReaderWriter; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Lib.IO; + +namespace MosswartArt; + +internal sealed class DatSource(DatCollection dats) : IDatObjectSource +{ + [return: MaybeNull] + public T Get(uint fileId) where T : IDBObj + => dats.TryGet(fileId, out var v) ? v : default; + + public bool TryGet(uint fileId, [MaybeNullWhen(false)] out T value) where T : IDBObj + => dats.TryGet(fileId, out value!); +} + +internal sealed class AnimLoader(DatCollection dats) : AcDream.Core.Physics.IAnimationLoader +{ + public Animation? LoadAnimation(uint id) + => dats.TryGet(id, out var a) ? a : null; +} + +internal static class Export +{ + private static string F(float v) => v.ToString("R", CultureInfo.InvariantCulture); + + public static int Run(DatCollection dats, uint setupId, uint clothingId, string outPath, uint motionTableId = 0) + { + if (!dats.TryGet(setupId, out var setup) || setup is null) + { + Console.Error.WriteLine($"Setup 0x{setupId:X8} not found"); + return 1; + } + + // ClothingBase substitution map, at SurfaceTexture (0x05) level. + var texSwap = new Dictionary(); + if (clothingId != 0 && dats.TryGet(clothingId, out var ct) && ct is not null) + { + foreach (var baseEffect in ct.ClothingBaseEffects.Values) + foreach (var objEffect in baseEffect.CloObjectEffects) + foreach (var te in objEffect.CloTextureEffects) + texSwap[(uint)te.OldTexture] = (uint)te.NewTexture; + } + + var referencedTextures = new HashSet(); + var src = new DatSource(dats); + + // Creatures do not define an upright pose in Setup.PlacementFrames -- + // the idle frame has to come from the MotionTable, or every part lands + // stacked on the origin. + var idle = MotionResolver.GetIdleFrame(setup, src, new AnimLoader(dats), + motionTableId == 0 ? null : motionTableId); + Console.WriteLine(idle is null ? "idle frame: NONE (falling back to placement frame)" + : $"idle frame: {idle.Frames.Count} part frames"); + var refs = SetupMesh.Flatten(setup, idle); + + var sb = new StringBuilder(); + sb.Append("{\"setup\":\"0x").Append(setupId.ToString("X8")).Append("\",\"parts\":["); + + for (int i = 0; i < refs.Count; i++) + { + var mr = refs[i]; + if (!dats.TryGet(mr.GfxObjId, out var gfx) || gfx is null) continue; + var subs = GfxObjMesh.Build(gfx, src); + + var m = mr.PartTransform; + if (i > 0) sb.Append(','); + sb.Append("{\"index\":").Append(i) + .Append(",\"gfx\":\"0x").Append(mr.GfxObjId.ToString("X8")).Append('"') + .Append(",\"m\":[") + .Append(F(m.M11)).Append(',').Append(F(m.M12)).Append(',').Append(F(m.M13)).Append(',').Append(F(m.M14)).Append(',') + .Append(F(m.M21)).Append(',').Append(F(m.M22)).Append(',').Append(F(m.M23)).Append(',').Append(F(m.M24)).Append(',') + .Append(F(m.M31)).Append(',').Append(F(m.M32)).Append(',').Append(F(m.M33)).Append(',').Append(F(m.M34)).Append(',') + .Append(F(m.M41)).Append(',').Append(F(m.M42)).Append(',').Append(F(m.M43)).Append(',').Append(F(m.M44)) + .Append("],\"sub\":["); + + for (int s = 0; s < subs.Count; s++) + { + var sm = subs[s]; + + // Surface (0x08) -> OrigTextureId (0x05) -> ClothingBase swap. + uint texId = sm.SurfaceId; + if ((texId >> 24) == 0x08 && dats.TryGet(texId, out var surf) && surf is not null) + texId = (uint)surf.OrigTextureId; + if (texSwap.TryGetValue(texId, out var swapped)) texId = swapped; + referencedTextures.Add(texId); + + if (s > 0) sb.Append(','); + sb.Append("{\"surface\":\"0x").Append(sm.SurfaceId.ToString("X8")).Append('"') + .Append(",\"tex\":\"0x").Append(texId.ToString("X8")).Append('"') + .Append(",\"v\":["); + for (int v = 0; v < sm.Vertices.Length; v++) + { + var vert = sm.Vertices[v]; + if (v > 0) sb.Append(','); + sb.Append('[').Append(F(vert.Position.X)).Append(',').Append(F(vert.Position.Y)).Append(',').Append(F(vert.Position.Z)) + .Append(',').Append(F(vert.Normal.X)).Append(',').Append(F(vert.Normal.Y)).Append(',').Append(F(vert.Normal.Z)) + .Append(',').Append(F(vert.TexCoord.X)).Append(',').Append(F(vert.TexCoord.Y)).Append(']'); + } + sb.Append("],\"i\":["); + for (int k = 0; k < sm.Indices.Length; k++) + { + if (k > 0) sb.Append(','); + sb.Append(sm.Indices[k]); + } + sb.Append("]}"); + } + sb.Append("]}"); + } + sb.Append("]}"); + + // The documented destination (tools/IconForge/work/) does not exist in a + // fresh checkout, so create it rather than making the caller mkdir first. + string outFull = Path.GetFullPath(outPath); + Directory.CreateDirectory(Path.GetDirectoryName(outFull) ?? "."); + + File.WriteAllText(outFull, sb.ToString()); + Console.WriteLine($"wrote {outPath} ({new FileInfo(outFull).Length / 1024} KB), {refs.Count} parts"); + + // Dump the surfaces alongside the geometry. Keeping both halves in one + // command means the render pipeline has a single documented input step + // and cannot be handed geometry whose textures were never extracted. + string textureDir = Path.Combine(Path.GetDirectoryName(outFull) ?? ".", "textures"); + Console.WriteLine($"dumping {referencedTextures.Count} referenced surfaces -> {textureDir}"); + int written = Textures.Dump(dats, referencedTextures, textureDir); + if (written != referencedTextures.Count) + { + Console.Error.WriteLine( + $"WARNING: {referencedTextures.Count - written} surface(s) did not resolve; " + + "the render will fall back to flat grey for those."); + } + return 0; + } +} diff --git a/tools/MosswartArt/MosswartArt.csproj b/tools/MosswartArt/MosswartArt.csproj new file mode 100644 index 00000000..51325d5c --- /dev/null +++ b/tools/MosswartArt/MosswartArt.csproj @@ -0,0 +1,13 @@ + + + Exe + net10.0 + enable + enable + MosswartArt + + + + + + diff --git a/tools/MosswartArt/Program.cs b/tools/MosswartArt/Program.cs new file mode 100644 index 00000000..70147106 --- /dev/null +++ b/tools/MosswartArt/Program.cs @@ -0,0 +1,59 @@ +// One-off: walk a creature Setup -> parts -> GfxObj -> Surface ids, and walk its +// ClothingBase texture/palette overrides, so we can dump the real skin textures. +using System.Globalization; +using DatReaderWriter; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Options; +using SysEnv = System.Environment; + +static uint Hex(string s) => uint.Parse(s.StartsWith("0x", StringComparison.OrdinalIgnoreCase) ? s[2..] : s, + NumberStyles.HexNumber, CultureInfo.InvariantCulture); + +uint setupId = Hex(args[0]); +string? exportPath = args.Length > 2 ? args[2] : null; +uint motionTableId = args.Length > 3 ? Hex(args[3]) : 0; +uint clothingId = args.Length > 1 ? Hex(args[1]) : 0; + +string datDir = SysEnv.GetEnvironmentVariable("ACDREAM_DAT_DIR") + ?? Path.Combine(SysEnv.GetFolderPath(SysEnv.SpecialFolder.UserProfile), "Documents", "Asheron's Call"); +using var dats = new DatCollection(datDir, DatAccessType.Read); + +if (exportPath is not null) + return MosswartArt.Export.Run(dats, setupId, clothingId, exportPath, motionTableId); + +if (!dats.TryGet(setupId, out var setup) || setup is null) { Console.Error.WriteLine("no setup"); return 1; } +Console.WriteLine($"Setup 0x{setupId:X8}: {setup.Parts.Count} parts, radius {setup.Radius:F2} height {setup.Height:F2}"); + +var allSurfaces = new SortedSet(); +for (int i = 0; i < setup.Parts.Count; i++) +{ + uint gid = setup.Parts[i]; + if (!dats.TryGet(gid, out var g) || g is null) { Console.WriteLine($" part[{i}] gfx 0x{gid:X8} MISSING"); continue; } + var surfs = string.Join(", ", g.Surfaces.Select(s => $"0x{(uint)s:X8}")); + Console.WriteLine($" part[{i}] gfx 0x{gid:X8} verts={g.VertexArray?.Vertices?.Count ?? 0} surfaces=[{surfs}]"); + foreach (var s in g.Surfaces) allSurfaces.Add((uint)s); +} + +if (clothingId != 0 && dats.TryGet(clothingId, out var ct) && ct is not null) +{ + Console.WriteLine($"\nClothingBase 0x{clothingId:X8}: {ct.ClothingBaseEffects.Count} base effects, {ct.ClothingSubPalEffects.Count} subpal effects"); + foreach (var kv in ct.ClothingBaseEffects) + { + Console.WriteLine($" setup 0x{kv.Key:X8}:"); + foreach (var ce in kv.Value.CloObjectEffects) + { + Console.WriteLine($" part {ce.Index} gfx 0x{ce.ModelId:X8}"); + foreach (var te in ce.CloTextureEffects) + { + Console.WriteLine($" tex 0x{te.OldTexture:X8} -> 0x{te.NewTexture:X8}"); + allSurfaces.Add((uint)te.NewTexture); + } + } + } + foreach (var kv in ct.ClothingSubPalEffects) + Console.WriteLine($" subpal key {kv.Key}: icon 0x{kv.Value.Icon:X8} ranges={kv.Value.CloSubPalettes.Count}"); +} + +Console.WriteLine("\nALL SURFACE IDS:"); +Console.WriteLine(string.Join(" ", allSurfaces.Select(s => $"0x{s:X8}"))); +return 0; diff --git a/tools/MosswartArt/Textures.cs b/tools/MosswartArt/Textures.cs new file mode 100644 index 00000000..c5422c4c --- /dev/null +++ b/tools/MosswartArt/Textures.cs @@ -0,0 +1,164 @@ +// Dump the surfaces a geometry export references, as PNGs named the way +// tools/IconForge's loader globs for them: <0xID>_x.png. +// +// PNG is hand-rolled on ZLibStream so this tool needs no image package; the +// same approach is used by tools/IconExtract. +using System.IO.Compression; +using System.Text; +using AcDream.Core.Textures; +using DatReaderWriter; +using DatReaderWriter.DBObjs; + +namespace MosswartArt; + +internal static class Textures +{ + /// + /// Resolve each id to pixels and write it out. Ids may be Surface (0x08), + /// SurfaceTexture (0x05) or RenderSurface (0x06/0x07); the chain is walked + /// down to pixels either way. + /// + public static int Dump(DatCollection dats, IEnumerable ids, string outDir) + { + Directory.CreateDirectory(outDir); + int written = 0; + + foreach (uint id in ids.Distinct().OrderBy(v => v)) + { + RenderSurface? rs = Resolve(dats, id); + if (rs is null || rs.Width <= 0 || rs.Height <= 0) + { + Console.Error.WriteLine($" 0x{id:X8} UNRESOLVED"); + continue; + } + + Palette? palette = null; + if (rs.DefaultPaletteId != 0 + && dats.TryGet(rs.DefaultPaletteId, out var pal) && pal is not null) + { + palette = pal; + } + + var decoded = SurfaceDecoder.DecodeRenderSurface(rs, palette); + if (decoded.Rgba8 is null || decoded.Rgba8.Length < decoded.Width * decoded.Height * 4) + { + Console.Error.WriteLine($" 0x{id:X8} DECODE FAILED"); + continue; + } + + string path = Path.Combine( + outDir, $"0x{id:X8}_{decoded.Width}x{decoded.Height}.png"); + WritePng(path, decoded.Rgba8, decoded.Width, decoded.Height); + Console.WriteLine($" 0x{id:X8} {decoded.Width}x{decoded.Height} -> {Path.GetFileName(path)}"); + written++; + } + + return written; + } + + // DatReaderWriter's TryGet does not validate the file type -- it just + // deserializes the bytes as T -- so dispatch on the id range rather than + // trying each type in turn. + private static RenderSurface? Resolve(DatCollection dats, uint id) + { + uint lookupId = id; + uint type = id >> 24; + + if (type == 0x08) + { + if (dats.TryGet(id, out var surface) && surface is not null) + lookupId = (uint)surface.OrigTextureId; + type = lookupId >> 24; + } + + if (type == 0x05) + { + if (dats.TryGet(lookupId, out var st) && st is not null + && st.Textures.Count > 0 + && dats.TryGet((uint)st.Textures[0], out var inner)) + { + return inner; + } + return null; + } + + if (type is 0x06 or 0x07) + return dats.TryGet(lookupId, out var direct) ? direct : null; + + return null; + } + + private static void WritePng(string path, byte[] rgba, int w, int h) + { + using var fs = File.Create(path); + fs.Write(new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A }); + + var ihdr = new byte[13]; + WriteBE(ihdr, 0, (uint)w); + WriteBE(ihdr, 4, (uint)h); + ihdr[8] = 8; // bit depth + ihdr[9] = 6; // colour type: RGBA + WriteChunk(fs, "IHDR", ihdr); + + using var ms = new MemoryStream(); + using (var z = new ZLibStream(ms, CompressionLevel.Optimal, leaveOpen: true)) + { + var row = new byte[1 + w * 4]; + for (int y = 0; y < h; y++) + { + row[0] = 0; // filter: none + Array.Copy(rgba, y * w * 4, row, 1, w * 4); + z.Write(row, 0, row.Length); + } + } + WriteChunk(fs, "IDAT", ms.ToArray()); + WriteChunk(fs, "IEND", Array.Empty()); + } + + private static void WriteBE(byte[] b, int o, uint v) + { + b[o] = (byte)(v >> 24); + b[o + 1] = (byte)(v >> 16); + b[o + 2] = (byte)(v >> 8); + b[o + 3] = (byte)v; + } + + private static void WriteChunk(Stream s, string type, byte[] data) + { + Span len = stackalloc byte[4]; + WriteBE(len, (uint)data.Length); + s.Write(len); + + byte[] t = Encoding.ASCII.GetBytes(type); + s.Write(t); + s.Write(data); + + uint crc = Crc32(t, data); + Span c = stackalloc byte[4]; + WriteBE(c, crc); + s.Write(c); + } + + private static void WriteBE(Span b, uint v) + { + b[0] = (byte)(v >> 24); + b[1] = (byte)(v >> 16); + b[2] = (byte)(v >> 8); + b[3] = (byte)v; + } + + private static uint Crc32(byte[] a, byte[] b) + { + uint crc = 0xFFFFFFFFu; + foreach (byte[] arr in new[] { a, b }) + { + foreach (byte by in arr) + { + crc ^= by; + for (int k = 0; k < 8; k++) + crc = (crc & 1) != 0 ? (crc >> 1) ^ 0xEDB88320u : crc >> 1; + } + } + return crc ^ 0xFFFFFFFFu; + } +} diff --git a/tools/MosswartArt/packages.neutral.lock.json b/tools/MosswartArt/packages.neutral.lock.json new file mode 100644 index 00000000..3df2e7d1 --- /dev/null +++ b/tools/MosswartArt/packages.neutral.lock.json @@ -0,0 +1,285 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "Chorizite.DatReaderWriter": { + "type": "Direct", + "requested": "[2.1.7, )", + "resolved": "2.1.7", + "contentHash": "6CpUhfHDV/O+lNx0xUZUcXK7wGkEkHYCnGHFtD8BBeAv4i1BuaNfTID+VQoSzx2EtmOVM0A2O/9wRFibApz6rQ==", + "dependencies": { + "DotNet.Standard.Common": "2.0.1", + "ZLibDotNet": "0.1.1" + } + }, + "Autofac": { + "type": "Transitive", + "resolved": "8.4.0", + "contentHash": "XMWHyO6fXTv8rwCfhm6+64mQS6CyL0rve/hWSODsUrVuEGtq1fjxSOlVTBqCRsW6L8K3OQDskJaPB1boVMI2eQ==" + }, + "Chorizite.ACProtocol": { + "type": "Transitive", + "resolved": "1.0.1", + "contentHash": "PVDw/KRu4WPxT+2MzHwOQ9UFqYlOgpIRswSnco/EgHSHnbQyxOqxwiOwSK0Il+cI6dTSXTW34QNmL7iH0lXLKw==", + "dependencies": { + "Chorizite.Common": "1.0.0", + "Medo.PcapRW": "1.2.0", + "Microsoft.Extensions.Logging.Abstractions": "9.0.0", + "System.CodeDom": "9.0.0" + } + }, + "Chorizite.Common": { + "type": "Transitive", + "resolved": "1.0.3", + "contentHash": "KqI0su7UY2diiSQuq11gF/NztqR6orZLr/e5UKTQ91XM8OsZGgBGHmhf2/jopX9VhwpXOTTLIeFBYTBc30cK8w==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "9.0.0" + } + }, + "CommunityToolkit.HighPerformance": { + "type": "Transitive", + "resolved": "8.4.0", + "contentHash": "flxspiBs0G/0GMp7IK2J2ijV9bTG6hEwFc/z6ekHqB6nwRJ4Ry2yLdx+TkbCUYFCl4XhABkAwomeKbT6zM2Zlg==" + }, + "Cyotek.Drawing.BitmapFont": { + "type": "Transitive", + "resolved": "2.0.4", + "contentHash": "iA6WehGVdMUuNbfsQQDq/Bt+mMd/OqHjiMUtKFLIQd/0pyYh4ehT7FEjTxN9/4OXNKQZsp9bAJgltP2nnswUJg==" + }, + "DotNet.Standard.Common": { + "type": "Transitive", + "resolved": "2.0.1", + "contentHash": "zW0m0ytHi43ccbEOTNDa10cDDnT7BAzY1R1Rb1dlhbdkiyglsALjrsyTPSEjbdnTmCOAvAvl4kkbvBLoYhC6dQ==" + }, + "FontStashSharp": { + "type": "Transitive", + "resolved": "1.3.10", + "contentHash": "7JTrihTt3DR8LYbb4L1eZcnbwOUOu/mvY+PJoZ3WVWiKjA6xNUk93GSW3OC/kZBD3iYzrXK8QlmKyYY5Lef/Rg==", + "dependencies": { + "Cyotek.Drawing.BitmapFont": "2.0.4", + "FontStashSharp.Base": "1.1.9", + "FontStashSharp.Rasterizers.StbTrueTypeSharp": "1.1.9", + "StbImageSharp": "2.30.15" + } + }, + "FontStashSharp.Base": { + "type": "Transitive", + "resolved": "1.1.9", + "contentHash": "/AjkOcPNijs8vyNgcCj3FfBJbVWmsSH744hqkhLfBt8qspDz/tEoD+U09my5u9eRBX6zX+RLQ/gdCvwy+ZBOtg==" + }, + "FontStashSharp.Rasterizers.StbTrueTypeSharp": { + "type": "Transitive", + "resolved": "1.1.9", + "contentHash": "yi5iuTERem46uyHC5p+jRi3Jh8dKWzgNWLqcvHciGlyVHD1cWFdERgnxshZU4xWB2hRGnogxaCudCirbMpg4eQ==", + "dependencies": { + "FontStashSharp.Base": "1.1.9", + "StbTrueTypeSharp": "1.26.12" + } + }, + "Medo.PcapRW": { + "type": "Transitive", + "resolved": "1.2.0", + "contentHash": "vgwcHDg60Q9LJfry7twA78pUFio1P4EypI2IIlk+7mEuySsIRInm+Gx2OINDICyocbuyQZdS/zABjbmejAObeg==" + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "1.1.0", + "contentHash": "1Am6l4Vpn3/K32daEqZI+FFr96OlZkgwK2LcT3pZ2zWubR5zTPW3/FkO1Rat9kb7oQOa4rxgl9LJHc5tspCWfg==" + }, + "Microsoft.Diagnostics.NETCore.Client": { + "type": "Transitive", + "resolved": "0.2.410101", + "contentHash": "I4hMjlbPcM5R+M4ThD2Zt1z58M8uZnWkDbFLXHntOOAajajEucrw4XYNSaoi5rgoqksgxQ3g388Vof4QzUNwdQ==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "1.1.0", + "Microsoft.Extensions.Logging": "2.1.1" + } + }, + "Microsoft.Diagnostics.Runtime": { + "type": "Transitive", + "resolved": "3.1.512801", + "contentHash": "0lMUDr2oxNZa28D6NH5BuSQEe5T9tZziIkvkD44YkkCGQXPJqvFjLq5ZQq1hYLl3RjQJrY+hR0jFgap+EWPDTw==", + "dependencies": { + "Microsoft.Diagnostics.NETCore.Client": "0.2.410101" + } + }, + "Microsoft.Extensions.Configuration": { + "type": "Transitive", + "resolved": "2.1.1", + "contentHash": "LjVKO6P2y52c5ZhTLX/w8zc5H4Y3J/LJsgqTBj49TtFq/hAtVNue/WA0F6/7GMY90xhD7K0MDZ4qpOeWXbLvzg==", + "dependencies": { + "Microsoft.Extensions.Configuration.Abstractions": "2.1.1" + } + }, + "Microsoft.Extensions.Configuration.Abstractions": { + "type": "Transitive", + "resolved": "2.1.1", + "contentHash": "VfuZJNa0WUshZ/+8BFZAhwFKiKuu/qOUCFntfdLpHj7vcRnsGHqd3G2Hse78DM+pgozczGM63lGPRLmy+uhUOA==", + "dependencies": { + "Microsoft.Extensions.Primitives": "2.1.1" + } + }, + "Microsoft.Extensions.Configuration.Binder": { + "type": "Transitive", + "resolved": "2.1.1", + "contentHash": "fcLCTS03poWE4v9tSNBr3pWn0QwGgAn1vzqHXlXgvqZeOc7LvQNzaWcKRQZTdEc3+YhQKwMsOtm3VKSA2aWQ8w==", + "dependencies": { + "Microsoft.Extensions.Configuration": "2.1.1" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "9.0.9", + "contentHash": "/hymojfWbE9AlDOa0mczR44m00Jj+T3+HZO0ZnVTI032fVycI0ZbNOVFP6kqZMcXiLSYXzR2ilcwaRi6dzeGyA==" + }, + "Microsoft.Extensions.Logging": { + "type": "Transitive", + "resolved": "2.1.1", + "contentHash": "hh+mkOAQDTp6XH80xJt3+wwYVzkbwYQl9XZRCz4Um0JjP/o7N9vHM3rZ6wwwtr+BBe/L6iBO2sz0px6OWBzqZQ==", + "dependencies": { + "Microsoft.Extensions.Configuration.Binder": "2.1.1", + "Microsoft.Extensions.DependencyInjection.Abstractions": "2.1.1", + "Microsoft.Extensions.Logging.Abstractions": "2.1.1", + "Microsoft.Extensions.Options": "2.1.1" + } + }, + "Microsoft.Extensions.Options": { + "type": "Transitive", + "resolved": "2.1.1", + "contentHash": "V7lXCU78lAbzaulCGFKojcCyG8RTJicEbiBkPJjFqiqXwndEBBIehdXRMWEVU3UtzQ1yDvphiWUL9th6/4gJ7w==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "2.1.1", + "Microsoft.Extensions.Primitives": "2.1.1" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Transitive", + "resolved": "2.1.1", + "contentHash": "scJ1GZNIxMmjpENh0UZ8XCQ6vzr/LzeF9WvEA51Ix2OQGAs9WPgPu8ABVUdvpKPLuor/t05gm6menJK3PwqOXg==" + }, + "Namotion.Reflection": { + "type": "Transitive", + "resolved": "3.4.3", + "contentHash": "KLk2gLR9f8scM82EiL+p9TONXXPy9+IAZVMzJOA/Wsa7soZD7UJGG6j0fq0D9ZoVnBRRnSeEC7kShhRo3Olgaw==" + }, + "Newtonsoft.Json": { + "type": "Transitive", + "resolved": "13.0.3", + "contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ==" + }, + "NJsonSchema": { + "type": "Transitive", + "resolved": "11.5.1", + "contentHash": "3a7ntoBncSKkLgpIhT3uQ8BiyDzYKOHIzpzNF4o1vtKc+Re4vWxBcDXFDarOWcr/UkxZ8nxRXbbWk05j6bXFzQ==", + "dependencies": { + "NJsonSchema.Annotations": "11.5.1", + "Namotion.Reflection": "3.4.3", + "Newtonsoft.Json": "13.0.3" + } + }, + "NJsonSchema.Annotations": { + "type": "Transitive", + "resolved": "11.5.1", + "contentHash": "xiqZ2DBJM1HuV+EhXgueb5ZUBlWFN3kVfLTKdtpTSxvtyQCO/vit8lqZiUiejnReUMRMIUhtS9m0GbieHZlSow==" + }, + "SixLabors.Fonts": { + "type": "Transitive", + "resolved": "2.1.3", + "contentHash": "ORWbZ5BHrC/LZvo+Y09MnoJq5VUKD85LsYALk+YI7CHFra+m5arCkz00IntDM6SrAiB22bvSdKtKmuCyHOKlqg==" + }, + "SixLabors.ImageSharp.Drawing": { + "type": "Transitive", + "resolved": "2.1.7", + "contentHash": "9KwCo9Fa350cx6ckpsy8NqXQZKwir4RQ8Kj0sdCmJA7wsK9FMyfgC527Sn4l/D6bj2ditSHlhS7dGzcgGszvSQ==", + "dependencies": { + "SixLabors.Fonts": "2.1.3", + "SixLabors.ImageSharp": "3.1.11" + } + }, + "System.CodeDom": { + "type": "Transitive", + "resolved": "9.0.0", + "contentHash": "oTE5IfuMoET8yaZP/vdvy9xO47guAv/rOhe4DODuFBN3ySprcQOlXqO3j+e/H/YpKKR5sglrxRaZ2HYOhNJrqA==" + }, + "ZLibDotNet": { + "type": "Transitive", + "resolved": "0.1.1", + "contentHash": "QEti4O7dwRcOb9zbnLuudSrt2IT61OYjq0R7lcJb+EzUm5N6djOVGU/cp+FZIZzJUnaFIntwrpwiuRvhpS7ZHg==" + }, + "acdream.core": { + "type": "Project", + "dependencies": { + "AcDream.Plugin.Abstractions": "[1.0.0, )", + "BCnEncoder.Net": "[2.2.1, )", + "Chorizite.Core": "[0.0.18, )", + "Chorizite.DatReaderWriter": "[2.1.7, )", + "Serilog": "[4.0.2, )", + "StbImageSharp": "[2.30.16, )" + } + }, + "acdream.plugin.abstractions": { + "type": "Project" + }, + "BCnEncoder.Net": { + "type": "CentralTransitive", + "requested": "[2.2.1, )", + "resolved": "2.2.1", + "contentHash": "tI5+/OQo0kciLqWrViRjpOH+IL3FjexYnoWZajiGV41g/EM9CGbWsxsPzBDmpoxNkrV9uox/EtIhCIi9chBSFw==", + "dependencies": { + "CommunityToolkit.HighPerformance": "8.4.0" + } + }, + "Chorizite.Core": { + "type": "CentralTransitive", + "requested": "[0.0.18, )", + "resolved": "0.0.18", + "contentHash": "Pvf5idSsN0NfhZspJhrpo7QiNTmHx3mnKFkmAvHFpEFx+RnqfXh96Q+Pxam+hrqXbimmKGfk1ooIl5kpQcMo6w==", + "dependencies": { + "Autofac": "8.4.0", + "Chorizite.ACProtocol": "1.0.1", + "Chorizite.Common": "1.0.3", + "Chorizite.DatReaderWriter": "1.0.0", + "FontStashSharp": "1.3.10", + "Microsoft.Diagnostics.Runtime": "3.1.512801", + "Microsoft.Extensions.Logging.Abstractions": "9.0.9", + "NJsonSchema": "11.5.1", + "SixLabors.ImageSharp": "3.1.11", + "SixLabors.ImageSharp.Drawing": "2.1.7" + } + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "CentralTransitive", + "requested": "[9.0.9, )", + "resolved": "9.0.9", + "contentHash": "FEgpSF+Z9StMvrsSViaybOBwR0f0ZZxDm8xV5cSOFiXN/t+ys+rwAlTd/6yG7Ld1gfppgvLcMasZry3GsI9lGA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.9" + } + }, + "Serilog": { + "type": "CentralTransitive", + "requested": "[4.0.2, )", + "resolved": "4.0.2", + "contentHash": "Vehq4uNYtURe/OnHEpWGvMgrvr5Vou7oZLdn3BuEH5FSCeHXDpNJtpzWoqywXsSvCTuiv0I65mZDRnJSeUvisA==" + }, + "SixLabors.ImageSharp": { + "type": "CentralTransitive", + "requested": "[3.1.12, )", + "resolved": "3.1.11", + "contentHash": "JfPLyigLthuE50yi6tMt7Amrenr/fA31t2CvJyhy/kQmfulIBAqo5T/YFUSRHtuYPXRSaUHygFeh6Qd933EoSw==" + }, + "StbImageSharp": { + "type": "CentralTransitive", + "requested": "[2.30.16, )", + "resolved": "2.30.16", + "contentHash": "qg1i+NHihXVKLKYacGKrauhSQIGL31eBWcTC4Vc7jnGmBFj87LkRuCdXB5aDZisWMRnB6x2mcfwYXQxMWEG/lw==" + }, + "StbTrueTypeSharp": { + "type": "CentralTransitive", + "requested": "[1.26.12, )", + "resolved": "1.26.12", + "contentHash": "hCc6/OsfcPa5VsLECcEU2m78WOshBrKwK42nAodSm9Z5wH68f7n66SoiRLCdGCkDaqbWz2TlX4zYHIjogj1HJA==" + } + } + } +} \ No newline at end of file