fix(assets): correct walk frame scaling -- remove 1.25 overcorrection and wrong GAIT_SHIFT

The walk polygon was rendered 25% too large due to an erroneous * 1.25
factor on scale_x / scale_y. On top of this, GAIT_SHIFT_X=-6.383 and
GAIT_SHIFT_Y=3.76 (internal cat.js group transforms) were incorrectly
ported as canvas offsets, pushing the polygon partially above the top
edge of the overlay window (visible clipping).

Root cause: the SVG viewBox is 0 0 150 120. Walk polygon coordinates
span 4-145 in X and 8-112 in Y -- already fitting the viewBox with no
additional scaling or translation needed.

Fix: use scale_x = (cat_width / 150) * SS and scale_y = (cat_height / 120) * SS
with zero offset. Walk frame now renders at the same visual size as paw/
scruff/tail/stretch frames (all on a cat_width x cat_height canvas).

Result: cat body is no longer clipped at the top and matches size of all
other animation states.
This commit is contained in:
2026-09-08 23:20:57 +02:00
parent 4e57a9c596
commit 145971f6ab
+23 -28
View File
@@ -204,40 +204,37 @@ class AssetsManager:
target_w = self.cat_width target_w = self.cat_width
target_h = self.cat_height target_h = self.cat_height
# 4x Supersampling for clean anti-aliased polygon rasterization # 4× supersampling for clean anti-aliased polygon edges
SS = 4 SS = 4
canvas_w = target_w * SS canvas_w = target_w * SS
canvas_h = target_h * SS canvas_h = target_h * SS
img = Image.new("RGBA", (canvas_w, canvas_h), (0, 0, 0, 0)) img = Image.new("RGBA", (canvas_w, canvas_h), (0, 0, 0, 0))
draw = ImageDraw.Draw(img) draw = ImageDraw.Draw(img)
# Scale from SVG viewBox (150x120) to supersampled canvas # The Workcat SVG walk frames use viewBox="0 0 150 120".
# Note: In cat.js viewBox is 0 0 150 120 and CAT_BOX is 121.2x92.4 with shift # Polygon coordinates span ~4145 in X and ~8112 in Y, fitting the viewBox.
# Shift in cat.js: translate(GAIT_SHIFT_X * unit, GAIT_SHIFT_Y * unit) # We simply map viewBox coords → supersampled canvas pixels.
unit = (target_w / CAT_BOX_WIDTH) * SS # No GAIT_SHIFT or overcorrection needed: those are internal cat.js transforms
shift_x = -6.383 * unit # that cancel out at the viewBox level.
shift_y = 3.76 * unit scale_x = (target_w / 150.0) * SS
scale_x = (target_w / 150.0) * SS * 1.25 # Aspect ratio fitting scale_y = (target_h / 120.0) * SS
scale_y = (target_h / 120.0) * SS * 1.25
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 + shift_x, p[1] * scale_y + shift_y) for p in pts] ss_points = [(p[0] * scale_x, p[1] * scale_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)
# Draw Face Features # Face features — all coordinates in the same 150×120 viewBox space.
# Face coordinates in 150x120 viewBox: # Eyes: left at (120.4, 46.2), right at (140.3, 46.2), radius 2.2
# Eye L: cx=120.4, cy=46.2, r=2.2 # Mouth: W-shape centred around (131.6, 51.0)
# Eye R: cx=140.3, cy=46.2, r=2.2
# Mouth: Q curves around x=127-135, y=50-52
ink_rgba = (*coat.ink_color, 255) ink_rgba = (*coat.ink_color, 255)
eye_l_cx = 120.4 * scale_x + shift_x eye_l_cx = 120.4 * scale_x
eye_l_cy = 46.2 * scale_y + shift_y eye_l_cy = 46.2 * scale_y
eye_r_cx = 140.3 * scale_x + shift_x eye_r_cx = 140.3 * scale_x
eye_r_cy = 46.2 * scale_y + shift_y eye_r_cy = 46.2 * scale_y
eye_r = 2.2 * scale_x eye_r = 2.2 * scale_x
if face_type == "open": if face_type == "open":
draw.ellipse( draw.ellipse(
@@ -249,26 +246,23 @@ class AssetsManager:
fill=ink_rgba, fill=ink_rgba,
) )
elif face_type == "blink": elif face_type == "blink":
# Downward blink lines
w_eye = eye_r * 1.5 w_eye = eye_r * 1.5
draw.line([(eye_l_cx - w_eye, eye_l_cy), (eye_l_cx + w_eye, eye_l_cy)], fill=ink_rgba, width=int(2 * SS)) draw.line([(eye_l_cx - w_eye, eye_l_cy), (eye_l_cx + w_eye, eye_l_cy)], fill=ink_rgba, width=int(2 * SS))
draw.line([(eye_r_cx - w_eye, eye_r_cy), (eye_r_cx + w_eye, eye_r_cy)], fill=ink_rgba, width=int(2 * SS)) draw.line([(eye_r_cx - w_eye, eye_r_cy), (eye_r_cx + w_eye, eye_r_cy)], fill=ink_rgba, width=int(2 * SS))
elif face_type == "happy": elif face_type == "happy":
# Upward arc happy eyes (^^)
w_eye = eye_r * 1.6 w_eye = eye_r * 1.6
h_eye = eye_r * 1.3 h_eye = eye_r * 1.3
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 (w shape) 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 back to target dimensions with high-quality Lanczos resampling # Downsample to target resolution with high-quality Lanczos resampling
final_img = img.resize((target_w, target_h), Image.Resampling.LANCZOS) final_img = img.resize((target_w, target_h), Image.Resampling.LANCZOS)
if facing_left: if facing_left:
@@ -277,6 +271,7 @@ 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).