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:
+51
-40
@@ -30,8 +30,11 @@ from .config import (
|
|||||||
LIFE_MOTION_FRAME_COUNT,
|
LIFE_MOTION_FRAME_COUNT,
|
||||||
CAT_BOX_WIDTH,
|
CAT_BOX_WIDTH,
|
||||||
CAT_BOX_HEIGHT,
|
CAT_BOX_HEIGHT,
|
||||||
|
GAIT_SHIFT_X,
|
||||||
|
GAIT_SHIFT_Y,
|
||||||
Coat,
|
Coat,
|
||||||
COATS,
|
COATS,
|
||||||
|
Config,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger("catser.assets")
|
logger = logging.getLogger("catser.assets")
|
||||||
@@ -80,10 +83,15 @@ class AssetsManager:
|
|||||||
Manages downloading, caching, rasterizing, and scaling of all cat sprites.
|
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.assets_dir = assets_dir or (Path(__file__).parent / "assets")
|
||||||
self.cat_width = cat_width
|
self.config = config or Config(cat_width=cat_width)
|
||||||
self.cat_height = int(round(cat_width * (CAT_BOX_HEIGHT / CAT_BOX_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
|
self.scale = self.cat_width / CAT_BOX_WIDTH
|
||||||
|
|
||||||
# Memory cache for rendered frames keyed by (coat_name, frame_id, facing_left)
|
# 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_w = self.cat_width
|
||||||
target_h = self.cat_height
|
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
|
# 4× supersampling for clean anti-aliased polygon edges
|
||||||
SS = 4
|
SS = 4
|
||||||
canvas_w = target_w * SS
|
ss_w = canvas_w * SS
|
||||||
canvas_h = target_h * SS
|
ss_h = canvas_h * SS
|
||||||
img = Image.new("RGBA", (canvas_w, canvas_h), (0, 0, 0, 0))
|
img = Image.new("RGBA", (ss_w, ss_h), (0, 0, 0, 0))
|
||||||
draw = ImageDraw.Draw(img)
|
draw = ImageDraw.Draw(img)
|
||||||
|
|
||||||
# The Workcat SVG walk frames use viewBox="0 0 150 120".
|
# In Workcat:
|
||||||
# Polygon coordinates span ~4–145 in X and ~8–112 in Y, fitting the viewBox.
|
# The SVG viewBox is 0 0 150 120, scaled to target_w x target_h,
|
||||||
# We simply map viewBox coords → supersampled canvas pixels.
|
# shifted by GAIT_SHIFT_X/Y relative to the cat unit (target_w / CAT_BOX_WIDTH),
|
||||||
# No GAIT_SHIFT or overcorrection needed: those are internal cat.js transforms
|
# and placed with pad_x / pad_y inside the overlay canvas.
|
||||||
# that cancel out at the viewBox level.
|
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_x = (target_w / 150.0) * SS
|
||||||
scale_y = (target_h / 120.0) * SS
|
scale_y = (target_h / 120.0) * SS
|
||||||
|
|
||||||
if self.walk_path_strings:
|
if self.walk_path_strings:
|
||||||
pts = _parse_svg_polygon_points(self.walk_path_strings[frame_index])
|
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)
|
fur_rgba = (*coat.fur_color, 255)
|
||||||
draw.polygon(ss_points, fill=fur_rgba)
|
draw.polygon(ss_points, fill=fur_rgba)
|
||||||
|
|
||||||
# Face features — all coordinates in the same 150×120 viewBox space.
|
# Face features — coordinates in 150x120 viewBox shifted into position
|
||||||
# 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)
|
|
||||||
ink_rgba = (*coat.ink_color, 255)
|
ink_rgba = (*coat.ink_color, 255)
|
||||||
|
|
||||||
eye_l_cx = 120.4 * scale_x
|
eye_l_cx = 120.4 * scale_x + shift_x
|
||||||
eye_l_cy = 46.2 * scale_y
|
eye_l_cy = 46.2 * scale_y + shift_y
|
||||||
eye_r_cx = 140.3 * scale_x
|
eye_r_cx = 140.3 * scale_x + shift_x
|
||||||
eye_r_cy = 46.2 * scale_y
|
eye_r_cy = 46.2 * scale_y + shift_y
|
||||||
eye_r = 2.2 * scale_x
|
eye_r = 2.2 * scale_x
|
||||||
|
|
||||||
if face_type == "open":
|
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_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))
|
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_cx = 131.6 * scale_x + shift_x
|
||||||
mouth_cy = 51.0 * scale_y
|
mouth_cy = 51.0 * scale_y + shift_y
|
||||||
mw = 4.0 * scale_x
|
mw = 4.0 * scale_x
|
||||||
mh = 2.5 * scale_y
|
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 - 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))
|
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
|
# Downsample to canvas resolution with high-quality Lanczos resampling
|
||||||
final_img = img.resize((target_w, target_h), Image.Resampling.LANCZOS)
|
final_img = img.resize((canvas_w, canvas_h), Image.Resampling.LANCZOS)
|
||||||
|
|
||||||
if facing_left:
|
if facing_left:
|
||||||
final_img = final_img.transpose(Image.Transpose.FLIP_LEFT_RIGHT)
|
final_img = final_img.transpose(Image.Transpose.FLIP_LEFT_RIGHT)
|
||||||
@@ -271,7 +283,6 @@ class AssetsManager:
|
|||||||
self._cache[cache_key] = final_img
|
self._cache[cache_key] = final_img
|
||||||
return final_img
|
return final_img
|
||||||
|
|
||||||
|
|
||||||
def get_paw_frame(self, frame_index: int, coat: Coat, facing_left: bool = False) -> Image.Image:
|
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).
|
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))
|
ph = int(round(self.cat_height * 0.96833))
|
||||||
resized = tinted.resize((pw, ph), Image.Resampling.LANCZOS)
|
resized = tinted.resize((pw, ph), Image.Resampling.LANCZOS)
|
||||||
|
|
||||||
# Composite onto standard cat canvas with offsets
|
# Composite onto canvas with pad offsets
|
||||||
canvas = Image.new("RGBA", (self.cat_width, self.cat_height), (0, 0, 0, 0))
|
canvas = Image.new("RGBA", (self.canvas_width, self.canvas_height), (0, 0, 0, 0))
|
||||||
offset_x = int(round(self.cat_width * 0.02265))
|
offset_x = self.pad_x + int(round(self.cat_width * 0.02265))
|
||||||
offset_y = int(round(self.cat_height * -0.03083))
|
offset_y = self.pad_y + int(round(self.cat_height * -0.03083))
|
||||||
canvas.paste(resized, (offset_x, offset_y), resized)
|
canvas.paste(resized, (offset_x, offset_y), resized)
|
||||||
|
|
||||||
if facing_left:
|
if facing_left:
|
||||||
@@ -319,10 +330,9 @@ class AssetsManager:
|
|||||||
sh = int(round(self.cat_height * 1.29))
|
sh = int(round(self.cat_height * 1.29))
|
||||||
resized = tinted.resize((sw, sh), Image.Resampling.LANCZOS)
|
resized = tinted.resize((sw, sh), Image.Resampling.LANCZOS)
|
||||||
|
|
||||||
# Center in canvas
|
canvas = Image.new("RGBA", (self.canvas_width, self.canvas_height), (0, 0, 0, 0))
|
||||||
canvas = Image.new("RGBA", (self.cat_width, self.cat_height), (0, 0, 0, 0))
|
ox = self.pad_x + (self.cat_width - sw) // 2
|
||||||
ox = (self.cat_width - sw) // 2
|
oy = self.pad_y + (self.cat_height - sh) // 2
|
||||||
oy = (self.cat_height - sh) // 2
|
|
||||||
canvas.paste(resized, (ox, oy), resized)
|
canvas.paste(resized, (ox, oy), resized)
|
||||||
|
|
||||||
self._cache[cache_key] = canvas
|
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:
|
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.
|
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
|
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}"
|
cache_key = f"tail_{frame_index}_{coat.name}_{facing_left}"
|
||||||
@@ -345,9 +355,10 @@ class AssetsManager:
|
|||||||
th = int(round(self.cat_height * 1.153692))
|
th = int(round(self.cat_height * 1.153692))
|
||||||
resized = tinted.resize((tw, th), Image.Resampling.LANCZOS)
|
resized = tinted.resize((tw, th), Image.Resampling.LANCZOS)
|
||||||
|
|
||||||
canvas = Image.new("RGBA", (self.cat_width, self.cat_height), (0, 0, 0, 0))
|
# Composite onto padded canvas — tail extension is fully preserved
|
||||||
ox = int(round(self.cat_width * -0.150564))
|
canvas = Image.new("RGBA", (self.canvas_width, self.canvas_height), (0, 0, 0, 0))
|
||||||
oy = int(round(self.cat_height * -0.142679))
|
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)
|
canvas.paste(resized, (ox, oy), resized)
|
||||||
|
|
||||||
if facing_left:
|
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:
|
def get_stretch_frame(self, frame_index: int, coat: Coat, facing_left: bool = False) -> Image.Image:
|
||||||
"""
|
"""
|
||||||
Frame from the 37-frame stretch life motion.
|
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
|
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}"
|
cache_key = f"stretch_{frame_index}_{coat.name}_{facing_left}"
|
||||||
@@ -373,9 +384,9 @@ class AssetsManager:
|
|||||||
sh = int(round(self.cat_height * 1.008865))
|
sh = int(round(self.cat_height * 1.008865))
|
||||||
resized = tinted.resize((sw, sh), Image.Resampling.LANCZOS)
|
resized = tinted.resize((sw, sh), Image.Resampling.LANCZOS)
|
||||||
|
|
||||||
canvas = Image.new("RGBA", (self.cat_width, self.cat_height), (0, 0, 0, 0))
|
canvas = Image.new("RGBA", (self.canvas_width, self.canvas_height), (0, 0, 0, 0))
|
||||||
ox = int(round(self.cat_width * -0.045169))
|
ox = self.pad_x + int(round(self.cat_width * -0.045169))
|
||||||
oy = int(round(self.cat_height * 0.00215))
|
oy = self.pad_y + int(round(self.cat_height * 0.00215))
|
||||||
canvas.paste(resized, (ox, oy), resized)
|
canvas.paste(resized, (ox, oy), resized)
|
||||||
|
|
||||||
if facing_left:
|
if facing_left:
|
||||||
|
|||||||
Reference in New Issue
Block a user