fix(assets): unified padded canvas eliminates tail clipping and fixes animation scaling

Workcat's tail flick (-15% left), paw reach (+9% right), and stretch motions
extend beyond the nominal cat bounding box. Previously, frames were drawn
onto a compact 144x110 canvas without padding, causing negative offsets
(e.g. ox=-22px on tail) to truncate the tail tip and create mismatched scales.

Fixes:
- Added padding (pad_x=20%, pad_y=20%) to the render canvas so all extended
  poses (tail swings to x=16, paw strikes to x=185) fit completely without clipping.
- Unified the coordinate system: walk, tail, stretch, paw, and scruff now share
  the exact same body scale (height ~93-94px) and feet baseline (y=128).
This commit is contained in:
2026-09-08 23:33:12 +02:00
parent 0d52a5451b
commit 78b3f949c3
+51 -40
View File
@@ -30,8 +30,11 @@ from .config import (
LIFE_MOTION_FRAME_COUNT,
CAT_BOX_WIDTH,
CAT_BOX_HEIGHT,
GAIT_SHIFT_X,
GAIT_SHIFT_Y,
Coat,
COATS,
Config,
)
logger = logging.getLogger("catser.assets")
@@ -80,10 +83,15 @@ class AssetsManager:
Manages downloading, caching, rasterizing, and scaling of all cat sprites.
"""
def __init__(self, assets_dir: Optional[Path] = None, cat_width: int = 144):
def __init__(self, assets_dir: Optional[Path] = None, cat_width: int = 144, config: Optional[Config] = None):
self.assets_dir = assets_dir or (Path(__file__).parent / "assets")
self.cat_width = cat_width
self.cat_height = int(round(cat_width * (CAT_BOX_HEIGHT / CAT_BOX_WIDTH)))
self.config = config or Config(cat_width=cat_width)
self.cat_width = self.config.cat_width
self.cat_height = self.config.cat_height
self.canvas_width = self.config.canvas_width
self.canvas_height = self.config.canvas_height
self.pad_x = self.config.pad_x
self.pad_y = self.config.pad_y
self.scale = self.cat_width / CAT_BOX_WIDTH
# Memory cache for rendered frames keyed by (coat_name, frame_id, facing_left)
@@ -203,37 +211,41 @@ class AssetsManager:
target_w = self.cat_width
target_h = self.cat_height
canvas_w = self.canvas_width
canvas_h = self.canvas_height
pad_x = self.pad_x
pad_y = self.pad_y
# 4× supersampling for clean anti-aliased polygon edges
SS = 4
canvas_w = target_w * SS
canvas_h = target_h * SS
img = Image.new("RGBA", (canvas_w, canvas_h), (0, 0, 0, 0))
ss_w = canvas_w * SS
ss_h = canvas_h * SS
img = Image.new("RGBA", (ss_w, ss_h), (0, 0, 0, 0))
draw = ImageDraw.Draw(img)
# The Workcat SVG walk frames use viewBox="0 0 150 120".
# Polygon coordinates span ~4145 in X and ~8112 in Y, fitting the viewBox.
# We simply map viewBox coords → supersampled canvas pixels.
# No GAIT_SHIFT or overcorrection needed: those are internal cat.js transforms
# that cancel out at the viewBox level.
# In Workcat:
# The SVG viewBox is 0 0 150 120, scaled to target_w x target_h,
# shifted by GAIT_SHIFT_X/Y relative to the cat unit (target_w / CAT_BOX_WIDTH),
# and placed with pad_x / pad_y inside the overlay canvas.
unit = (target_w / CAT_BOX_WIDTH) * SS
shift_x = pad_x * SS + GAIT_SHIFT_X * unit
shift_y = pad_y * SS + GAIT_SHIFT_Y * unit
scale_x = (target_w / 150.0) * SS
scale_y = (target_h / 120.0) * SS
if self.walk_path_strings:
pts = _parse_svg_polygon_points(self.walk_path_strings[frame_index])
ss_points = [(p[0] * scale_x, p[1] * scale_y) for p in pts]
ss_points = [(p[0] * scale_x + shift_x, p[1] * scale_y + shift_y) for p in pts]
fur_rgba = (*coat.fur_color, 255)
draw.polygon(ss_points, fill=fur_rgba)
# Face features — all coordinates in the same 150×120 viewBox space.
# Eyes: left at (120.4, 46.2), right at (140.3, 46.2), radius 2.2
# Mouth: W-shape centred around (131.6, 51.0)
# Face features — coordinates in 150x120 viewBox shifted into position
ink_rgba = (*coat.ink_color, 255)
eye_l_cx = 120.4 * scale_x
eye_l_cy = 46.2 * scale_y
eye_r_cx = 140.3 * scale_x
eye_r_cy = 46.2 * scale_y
eye_l_cx = 120.4 * scale_x + shift_x
eye_l_cy = 46.2 * scale_y + shift_y
eye_r_cx = 140.3 * scale_x + shift_x
eye_r_cy = 46.2 * scale_y + shift_y
eye_r = 2.2 * scale_x
if face_type == "open":
@@ -255,15 +267,15 @@ class AssetsManager:
draw.arc([eye_l_cx - w_eye, eye_l_cy - h_eye, eye_l_cx + w_eye, eye_l_cy + h_eye], start=180, end=360, fill=ink_rgba, width=int(2 * SS))
draw.arc([eye_r_cx - w_eye, eye_r_cy - h_eye, eye_r_cx + w_eye, eye_r_cy + h_eye], start=180, end=360, fill=ink_rgba, width=int(2 * SS))
mouth_cx = 131.6 * scale_x
mouth_cy = 51.0 * scale_y
mouth_cx = 131.6 * scale_x + shift_x
mouth_cy = 51.0 * scale_y + shift_y
mw = 4.0 * scale_x
mh = 2.5 * scale_y
draw.arc([mouth_cx - mw, mouth_cy - mh, mouth_cx, mouth_cy + mh], start=0, end=180, fill=ink_rgba, width=int(1.8 * SS))
draw.arc([mouth_cx, mouth_cy - mh, mouth_cx + mw, mouth_cy + mh], start=0, end=180, fill=ink_rgba, width=int(1.8 * SS))
# Downsample to target resolution with high-quality Lanczos resampling
final_img = img.resize((target_w, target_h), Image.Resampling.LANCZOS)
# Downsample to canvas resolution with high-quality Lanczos resampling
final_img = img.resize((canvas_w, canvas_h), Image.Resampling.LANCZOS)
if facing_left:
final_img = final_img.transpose(Image.Transpose.FLIP_LEFT_RIGHT)
@@ -271,7 +283,6 @@ class AssetsManager:
self._cache[cache_key] = final_img
return final_img
def get_paw_frame(self, frame_index: int, coat: Coat, facing_left: bool = False) -> Image.Image:
"""
Retrieves and tints a frame from the paw strike animation (f001-f017).
@@ -291,10 +302,10 @@ class AssetsManager:
ph = int(round(self.cat_height * 0.96833))
resized = tinted.resize((pw, ph), Image.Resampling.LANCZOS)
# Composite onto standard cat canvas with offsets
canvas = Image.new("RGBA", (self.cat_width, self.cat_height), (0, 0, 0, 0))
offset_x = int(round(self.cat_width * 0.02265))
offset_y = int(round(self.cat_height * -0.03083))
# Composite onto canvas with pad offsets
canvas = Image.new("RGBA", (self.canvas_width, self.canvas_height), (0, 0, 0, 0))
offset_x = self.pad_x + int(round(self.cat_width * 0.02265))
offset_y = self.pad_y + int(round(self.cat_height * -0.03083))
canvas.paste(resized, (offset_x, offset_y), resized)
if facing_left:
@@ -319,10 +330,9 @@ class AssetsManager:
sh = int(round(self.cat_height * 1.29))
resized = tinted.resize((sw, sh), Image.Resampling.LANCZOS)
# Center in canvas
canvas = Image.new("RGBA", (self.cat_width, self.cat_height), (0, 0, 0, 0))
ox = (self.cat_width - sw) // 2
oy = (self.cat_height - sh) // 2
canvas = Image.new("RGBA", (self.canvas_width, self.canvas_height), (0, 0, 0, 0))
ox = self.pad_x + (self.cat_width - sw) // 2
oy = self.pad_y + (self.cat_height - sh) // 2
canvas.paste(resized, (ox, oy), resized)
self._cache[cache_key] = canvas
@@ -331,7 +341,7 @@ class AssetsManager:
def get_tail_frame(self, frame_index: int, coat: Coat, facing_left: bool = False) -> Image.Image:
"""
Frame from the 37-frame tail flick life motion.
Geometry: left: -15.0564%, top: -14.2679%, width: 117.5657%, height: 115.3692%
Geometry from Workcat: left: -15.0564%, top: -14.2679%, width: 117.5657%, height: 115.3692%
"""
frame_index = max(0, min(frame_index, len(self.tail_images) - 1)) if self.tail_images else 0
cache_key = f"tail_{frame_index}_{coat.name}_{facing_left}"
@@ -345,9 +355,10 @@ class AssetsManager:
th = int(round(self.cat_height * 1.153692))
resized = tinted.resize((tw, th), Image.Resampling.LANCZOS)
canvas = Image.new("RGBA", (self.cat_width, self.cat_height), (0, 0, 0, 0))
ox = int(round(self.cat_width * -0.150564))
oy = int(round(self.cat_height * -0.142679))
# Composite onto padded canvas — tail extension is fully preserved
canvas = Image.new("RGBA", (self.canvas_width, self.canvas_height), (0, 0, 0, 0))
ox = self.pad_x + int(round(self.cat_width * -0.150564))
oy = self.pad_y + int(round(self.cat_height * -0.142679))
canvas.paste(resized, (ox, oy), resized)
if facing_left:
@@ -359,7 +370,7 @@ class AssetsManager:
def get_stretch_frame(self, frame_index: int, coat: Coat, facing_left: bool = False) -> Image.Image:
"""
Frame from the 37-frame stretch life motion.
Geometry: left: -4.5169%, top: 0.215%, width: 107.0263%, height: 100.8865%
Geometry from Workcat: left: -4.5169%, top: 0.215%, width: 107.0263%, height: 100.8865%
"""
frame_index = max(0, min(frame_index, len(self.stretch_images) - 1)) if self.stretch_images else 0
cache_key = f"stretch_{frame_index}_{coat.name}_{facing_left}"
@@ -373,9 +384,9 @@ class AssetsManager:
sh = int(round(self.cat_height * 1.008865))
resized = tinted.resize((sw, sh), Image.Resampling.LANCZOS)
canvas = Image.new("RGBA", (self.cat_width, self.cat_height), (0, 0, 0, 0))
ox = int(round(self.cat_width * -0.045169))
oy = int(round(self.cat_height * 0.00215))
canvas = Image.new("RGBA", (self.canvas_width, self.canvas_height), (0, 0, 0, 0))
ox = self.pad_x + int(round(self.cat_width * -0.045169))
oy = self.pad_y + int(round(self.cat_height * 0.00215))
canvas.paste(resized, (ox, oy), resized)
if facing_left: