diff --git a/catser/assets_manager.py b/catser/assets_manager.py index 9501a2c..94e08a9 100644 --- a/catser/assets_manager.py +++ b/catser/assets_manager.py @@ -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 ~4–145 in X and ~8–112 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: