feat: mosswart client icon and Asheron's Call-inspired launcher icon
acdream had no application icon on either executable. Two marks now ship, built from the game's own material rather than drawn freehand: * Client - the retail mosswart head. Not an illustration of one: the actual creature mesh (Setup 0x02000B4F part 14, skin atlas 0x05001E11, ClothingBase 0x10000344) read out of client_portal.dat through acdream's own GfxObjMesh/SetupMesh port, then smoothed, lit and graded. Palette values are sampled from that texture, including the mustard belly the Mosswart lore calls a "foul yellow". * Launcher - a forged ring enclosing a barbed crescent, rebuilt from measurements of the retail wordmark and the acclient.exe icon resource. An original construction in the same visual language, not a copy of the trademarked logo. Its warm field matches the retail client icon. Three techniques carry the render quality, all in tools/IconForge: * PN-triangle tessellation (smooth.py). The retail head is 104 triangles and renders faceted. Each triangle becomes a cubic Bezier patch built from its own corner positions and normals, so the silhouette genuinely rounds rather than merely shading smoothly - and 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. * Matcaps (ring.py). A Lambert rasterizer cannot produce chrome, because chrome is almost entirely reflection and there is nothing here to reflect. Sampling a lit-sphere image by the camera-space normal is the standard stand-in for an environment map. * Distance-transform bevelling (chisel.py). Flat shapes become chiselled metal by treating distance-to-edge as height. The height field is blurred before differentiating; without that the medial axis of each stroke shows through as a hatched ridge. Two facts worth recording, both discovered the hard way. Creature Setups define no upright pose in PlacementFrames, so the exporter must be handed the weenie's MotionTable id or all 17 parts stack on the origin. And a mosswart's eyes sit on the sides of the skull like a frog's, so a dead-on frontal turns them edge-on and the face stops reading as a mosswart at all; the hero angle is az 266 / el 32. Wiring: <ApplicationIcon> gives each executable its PE icon. The client's runtime window icon is embedded rather than copied beside the binary - a window icon has no sensible fallback if the file goes missing, and embedding survives single-file publish. WindowIconLoaderTests guards the resource names, which are coupled to LogicalName in the csproj by string alone and would otherwise fail only as a silently icon-less window. Both halves of the pipeline are deterministic and reproduce the committed PNGs byte-for-byte, so an accidental edit shows up as a diff. Solution builds clean; 14,378 tests pass on the standard hermetic lane filter, 0 failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
4d84456c21
commit
a1ffe77af4
43 changed files with 2210 additions and 0 deletions
62
tools/IconForge/README.md
Normal file
62
tools/IconForge/README.md
Normal file
|
|
@ -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.
|
||||
141
tools/IconForge/ac_glyph.py
Normal file
141
tools/IconForge/ac_glyph.py
Normal file
|
|
@ -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
|
||||
168
tools/IconForge/chisel.py
Normal file
168
tools/IconForge/chisel.py
Normal file
|
|
@ -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)
|
||||
112
tools/IconForge/compose.py
Normal file
112
tools/IconForge/compose.py
Normal file
|
|
@ -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
|
||||
144
tools/IconForge/forge.py
Normal file
144
tools/IconForge/forge.py
Normal file
|
|
@ -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()
|
||||
76
tools/IconForge/launcher.py
Normal file
76
tools/IconForge/launcher.py
Normal file
|
|
@ -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
|
||||
236
tools/IconForge/render.py
Normal file
236
tools/IconForge/render.py
Normal file
|
|
@ -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"]})
|
||||
170
tools/IconForge/ring.py
Normal file
170
tools/IconForge/ring.py
Normal file
|
|
@ -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)
|
||||
124
tools/IconForge/smooth.py
Normal file
124
tools/IconForge/smooth.py
Normal file
|
|
@ -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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue