From 71a9533c50c16e97b91983e76a57c854dcc9e639 Mon Sep 17 00:00:00 2001 From: vl3c <95963142+vl3c@users.noreply.github.com> Date: Sun, 15 Feb 2026 21:58:31 +0200 Subject: [PATCH 1/8] Add reflect, scale, shear, and rotate_around to drawable classes Add four new transform methods to each geometry drawable: - Point: direct coordinate transforms with label sync - Segment: delegates to point methods, recalculates line formula - Vector: delegates to underlying segment - Polygon: iterates vertices for all transforms - Circle: reflect/scale/rotate center; shear raises ValueError - Ellipse: reflect adjusts rotation_angle; scale supports uniform and axis-aligned non-uniform; shear raises ValueError --- static/client/drawables/circle.py | 39 +++++++++++++++++++ static/client/drawables/ellipse.py | 60 +++++++++++++++++++++++++++++ static/client/drawables/point.py | 61 ++++++++++++++++++++++++++++++ static/client/drawables/polygon.py | 20 ++++++++++ static/client/drawables/segment.py | 28 ++++++++++++++ static/client/drawables/vector.py | 16 ++++++++ 6 files changed, 224 insertions(+) diff --git a/static/client/drawables/circle.py b/static/client/drawables/circle.py index a8b46be7..e1960ac3 100644 --- a/static/client/drawables/circle.py +++ b/static/client/drawables/circle.py @@ -23,6 +23,7 @@ from __future__ import annotations +import math from copy import deepcopy from typing import Any, Dict, cast @@ -89,6 +90,44 @@ def translate(self, x_offset: float, y_offset: float) -> None: self.circle_formula = self._calculate_circle_algebraic_formula() self.regenerate_name() + def reflect(self, axis: str, a: float = 0, b: float = 0, c: float = 0) -> None: + """Reflect the circle across the specified axis (center moves, radius unchanged).""" + self.center.reflect(axis, a, b, c) + self.circle_formula = self._calculate_circle_algebraic_formula() + self.regenerate_name() + + def scale(self, sx: float, sy: float, cx: float, cy: float) -> None: + """Scale the circle uniformly from center (cx, cy). + + Raises: + ValueError: If scaling is non-uniform or zero. + """ + if abs(sx) < 1e-18 or abs(sy) < 1e-18: + raise ValueError("Scale factor must not be zero") + if abs(sx - sy) > 1e-9: + raise ValueError( + "Non-uniform scaling of a circle is not supported; " + "convert to an ellipse first or use equal sx and sy" + ) + self.center.scale(sx, sy, cx, cy) + self.radius = abs(self.radius * sx) + self.circle_formula = self._calculate_circle_algebraic_formula() + self.regenerate_name() + + def shear(self, axis: str, factor: float, cx: float, cy: float) -> None: + """Shearing a circle is not supported. + + Raises: + ValueError: Always raised. + """ + raise ValueError("Shearing a circle is not supported; convert to an ellipse first") + + def rotate_around(self, angle_deg: float, cx: float, cy: float) -> None: + """Rotate the circle center around an arbitrary point (cx, cy).""" + self.center.rotate_around(angle_deg, cx, cy) + self.circle_formula = self._calculate_circle_algebraic_formula() + self.regenerate_name() + def rotate(self, angle: float) -> None: pass diff --git a/static/client/drawables/ellipse.py b/static/client/drawables/ellipse.py index 2377155a..a792f64c 100644 --- a/static/client/drawables/ellipse.py +++ b/static/client/drawables/ellipse.py @@ -24,6 +24,7 @@ from __future__ import annotations +import math from copy import deepcopy from typing import Any, Dict, Optional, Tuple, cast @@ -104,6 +105,65 @@ def translate(self, x_offset: float, y_offset: float) -> None: self.ellipse_formula = self._calculate_ellipse_algebraic_formula() self.regenerate_name() + def reflect(self, axis: str, a: float = 0, b: float = 0, c: float = 0) -> None: + """Reflect the ellipse across the specified axis. + + Center is reflected; rotation_angle is adjusted to preserve shape orientation. + """ + self.center.reflect(axis, a, b, c) + if axis == "x_axis": + self.rotation_angle = (-self.rotation_angle) % 360 + elif axis == "y_axis": + self.rotation_angle = (180 - self.rotation_angle) % 360 + elif axis == "line": + denom = a * a + b * b + if denom >= 1e-18: + line_angle_deg = math.degrees(math.atan2(-a, b)) + self.rotation_angle = (2 * line_angle_deg - self.rotation_angle) % 360 + self.ellipse_formula = self._calculate_ellipse_algebraic_formula() + self.regenerate_name() + + def scale(self, sx: float, sy: float, cx: float, cy: float) -> None: + """Scale the ellipse from center (cx, cy). + + Supports uniform scaling and axis-aligned non-uniform scaling. + + Raises: + ValueError: If non-uniform scaling on a rotated ellipse, or zero factor. + """ + if abs(sx) < 1e-18 or abs(sy) < 1e-18: + raise ValueError("Scale factor must not be zero") + uniform = abs(sx - sy) < 1e-9 + rotated = (self.rotation_angle % 180) > 1e-9 + if not uniform and rotated: + raise ValueError( + "Non-uniform scaling of a rotated ellipse is not supported" + ) + self.center.scale(sx, sy, cx, cy) + if uniform: + self.radius_x = abs(self.radius_x * sx) + self.radius_y = abs(self.radius_y * sx) + else: + self.radius_x = abs(self.radius_x * sx) + self.radius_y = abs(self.radius_y * sy) + self.ellipse_formula = self._calculate_ellipse_algebraic_formula() + self.regenerate_name() + + def shear(self, axis: str, factor: float, cx: float, cy: float) -> None: + """Shearing an ellipse is not supported. + + Raises: + ValueError: Always raised. + """ + raise ValueError("Shearing an ellipse is not supported") + + def rotate_around(self, angle_deg: float, cx: float, cy: float) -> None: + """Rotate the ellipse around an arbitrary point (cx, cy).""" + self.center.rotate_around(angle_deg, cx, cy) + self.rotation_angle = (self.rotation_angle + angle_deg) % 360 + self.ellipse_formula = self._calculate_ellipse_algebraic_formula() + self.regenerate_name() + def rotate(self, angle: float) -> Tuple[bool, Optional[str]]: """Rotate the ellipse around its center by the given angle in degrees""" # Update rotation angle (keep it between 0 and 360 degrees) diff --git a/static/client/drawables/point.py b/static/client/drawables/point.py index d300196f..6e690163 100644 --- a/static/client/drawables/point.py +++ b/static/client/drawables/point.py @@ -22,6 +22,7 @@ from __future__ import annotations +import math from typing import Any, Dict, cast from constants import default_color @@ -108,6 +109,66 @@ def update_name(self, name: str) -> None: self.name = name self._sync_label_text() + def reflect(self, axis: str, a: float = 0, b: float = 0, c: float = 0) -> None: + """Reflect the point across the specified axis. + + Args: + axis: 'x_axis', 'y_axis', or 'line' (ax + by + c = 0) + a, b, c: Coefficients for line reflection + """ + if axis == "x_axis": + self._y = -self._y + elif axis == "y_axis": + self._x = -self._x + elif axis == "line": + denom = a * a + b * b + if denom < 1e-18: + return + dot = a * self._x + b * self._y + c + self._x = self._x - 2 * a * dot / denom + self._y = self._y - 2 * b * dot / denom + self._sync_label_position() + + def scale(self, sx: float, sy: float, cx: float, cy: float) -> None: + """Scale the point relative to center (cx, cy).""" + self._x = cx + sx * (self._x - cx) + self._y = cy + sy * (self._y - cy) + self._sync_label_position() + + def shear(self, axis: str, factor: float, cx: float, cy: float) -> None: + """Shear the point relative to center (cx, cy). + + Args: + axis: 'horizontal' or 'vertical' + factor: Shear factor + cx, cy: Center of shear + """ + dx = self._x - cx + dy = self._y - cy + if axis == "horizontal": + self._x = cx + dx + factor * dy + self._y = cy + dy + elif axis == "vertical": + self._x = cx + dx + self._y = cy + dy + factor * dx + self._sync_label_position() + + def rotate_around(self, angle_deg: float, cx: float, cy: float) -> None: + """Rotate the point around an arbitrary center (cx, cy). + + Args: + angle_deg: Rotation angle in degrees (positive = counterclockwise) + cx, cy: Center of rotation + """ + angle_rad = math.radians(angle_deg) + dx = self._x - cx + dy = self._y - cy + cos_a = math.cos(angle_rad) + sin_a = math.sin(angle_rad) + self._x = cx + dx * cos_a - dy * sin_a + self._y = cy + dx * sin_a + dy * cos_a + self._sync_label_position() + def rotate(self, angle: float) -> None: pass diff --git a/static/client/drawables/polygon.py b/static/client/drawables/polygon.py index e5931779..6bce6bb5 100644 --- a/static/client/drawables/polygon.py +++ b/static/client/drawables/polygon.py @@ -63,6 +63,26 @@ def translate(self, x_offset: float, y_offset: float) -> None: for point in points: point.translate(x_offset, y_offset) + def reflect(self, axis: str, a: float = 0, b: float = 0, c: float = 0) -> None: + """Reflect all polygon vertices across the specified axis.""" + for point in self.get_vertices(): + point.reflect(axis, a, b, c) + + def scale(self, sx: float, sy: float, cx: float, cy: float) -> None: + """Scale all polygon vertices relative to center (cx, cy).""" + for point in self.get_vertices(): + point.scale(sx, sy, cx, cy) + + def shear(self, axis: str, factor: float, cx: float, cy: float) -> None: + """Shear all polygon vertices relative to center (cx, cy).""" + for point in self.get_vertices(): + point.shear(axis, factor, cx, cy) + + def rotate_around(self, angle_deg: float, cx: float, cy: float) -> None: + """Rotate all polygon vertices around an arbitrary center (cx, cy).""" + for point in self.get_vertices(): + point.rotate_around(angle_deg, cx, cy) + # ------------------------------------------------------------------ # Type metadata caching # ------------------------------------------------------------------ diff --git a/static/client/drawables/segment.py b/static/client/drawables/segment.py index bf301876..7ac9fcb2 100644 --- a/static/client/drawables/segment.py +++ b/static/client/drawables/segment.py @@ -200,6 +200,34 @@ def _rotate_point_around_center(self, point: Point, center_x: float, center_y: f point.x = center_x + (dx * math.cos(angle_rad) - dy * math.sin(angle_rad)) point.y = center_y + (dx * math.sin(angle_rad) + dy * math.cos(angle_rad)) + def reflect(self, axis: str, a: float = 0, b: float = 0, c: float = 0) -> None: + """Reflect the segment across the specified axis.""" + self.point1.reflect(axis, a, b, c) + self.point2.reflect(axis, a, b, c) + self.line_formula = self._calculate_line_algebraic_formula() + self._sync_label_position() + + def scale(self, sx: float, sy: float, cx: float, cy: float) -> None: + """Scale the segment relative to center (cx, cy).""" + self.point1.scale(sx, sy, cx, cy) + self.point2.scale(sx, sy, cx, cy) + self.line_formula = self._calculate_line_algebraic_formula() + self._sync_label_position() + + def shear(self, axis: str, factor: float, cx: float, cy: float) -> None: + """Shear the segment relative to center (cx, cy).""" + self.point1.shear(axis, factor, cx, cy) + self.point2.shear(axis, factor, cx, cy) + self.line_formula = self._calculate_line_algebraic_formula() + self._sync_label_position() + + def rotate_around(self, angle_deg: float, cx: float, cy: float) -> None: + """Rotate the segment around an arbitrary center (cx, cy).""" + self.point1.rotate_around(angle_deg, cx, cy) + self.point2.rotate_around(angle_deg, cx, cy) + self.line_formula = self._calculate_line_algebraic_formula() + self._sync_label_position() + def rotate(self, angle: float) -> Tuple[bool, Optional[str]]: """Rotate the segment around its midpoint by the given angle in degrees""" # Get midpoint diff --git a/static/client/drawables/vector.py b/static/client/drawables/vector.py index f2d7a337..bfa4d206 100644 --- a/static/client/drawables/vector.py +++ b/static/client/drawables/vector.py @@ -97,6 +97,22 @@ def __deepcopy__(self, memo: Dict[int, Any]) -> Any: def translate(self, x_offset: float, y_offset: float) -> None: self.segment.translate(x_offset, y_offset) + def reflect(self, axis: str, a: float = 0, b: float = 0, c: float = 0) -> None: + """Reflect the vector across the specified axis.""" + self.segment.reflect(axis, a, b, c) + + def scale(self, sx: float, sy: float, cx: float, cy: float) -> None: + """Scale the vector relative to center (cx, cy).""" + self.segment.scale(sx, sy, cx, cy) + + def shear(self, axis: str, factor: float, cx: float, cy: float) -> None: + """Shear the vector relative to center (cx, cy).""" + self.segment.shear(axis, factor, cx, cy) + + def rotate_around(self, angle_deg: float, cx: float, cy: float) -> None: + """Rotate the vector around an arbitrary center (cx, cy).""" + self.segment.rotate_around(angle_deg, cx, cy) + def rotate(self, angle: float) -> Tuple[bool, Optional[str]]: """Rotate the vector around its origin by the given angle in degrees""" # Use segment's rotation method to rotate the line portion From 2f71eb8030e640db5bbfcf1d2e58e4a41c2d47b3 Mon Sep 17 00:00:00 2001 From: vl3c <95963142+vl3c@users.noreply.github.com> Date: Sun, 15 Feb 2026 21:59:57 +0200 Subject: [PATCH 2/8] Add reflect, scale, shear to TransformationsManager; extend rotate with arbitrary center MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New methods: reflect_object, scale_object, shear_object follow the same orchestration pattern as translate_object (find → archive → transform → refresh dependencies → redraw). Extend rotate_object with optional center_x/center_y parameters for rotation around an arbitrary point. When provided, Points and Circles become eligible targets (they have rotate_around methods). Extract shared helpers: _find_drawable_by_name, _gather_moved_points, _refresh_dependencies_after_transform, _get_class_name, _redraw. Add _resolve_segment_to_line to convert a named segment into line coefficients for segment-axis reflections. --- .../managers/transformations_manager.py | 297 ++++++++++++++++-- 1 file changed, 271 insertions(+), 26 deletions(-) diff --git a/static/client/managers/transformations_manager.py b/static/client/managers/transformations_manager.py index b8a09a3e..7d8480fd 100644 --- a/static/client/managers/transformations_manager.py +++ b/static/client/managers/transformations_manager.py @@ -1,12 +1,16 @@ """ MatHud Geometric Transformations Management System -Handles geometric transformations of drawable objects including translation and rotation. +Handles geometric transformations of drawable objects including translation, +rotation, reflection, scaling, and shearing. Provides coordinated transformation operations with proper state management and canvas integration. Transformation Types: - Translation: Moving objects by specified x and y offsets - - Rotation: Rotating objects around specified points or their centers + - Rotation: Rotating objects around their centers or an arbitrary point + - Reflection: Mirroring objects across x-axis, y-axis, or an arbitrary line + - Scaling (dilation): Uniform or non-uniform scaling from a center point + - Shearing: Horizontal or vertical shear from a center point Operation Coordination: - State Archiving: Automatic undo/redo state capture before transformations @@ -29,18 +33,26 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, Iterable, List, Set +from typing import TYPE_CHECKING, Any, Iterable, List, Optional, Set, Tuple from drawables.segment import Segment if TYPE_CHECKING: from canvas import Canvas +# Types that do not support geometric transforms via AI tools. +_EXCLUDE_TRANSFORM: Tuple[str, ...] = ( + "Function", "ParametricFunction", "PiecewiseFunction", + "Graph", "Angle", "CircleArc", "ColoredArea", "Label", "Bar", "Plot", +) + + class TransformationsManager: """Manages geometric transformations of drawable objects on a Canvas. - Coordinates translation and rotation operations with proper state management, - object validation, and canvas integration. + Coordinates translation, rotation, reflection, scaling, and shearing + operations with proper state management, object validation, + and canvas integration. """ def __init__(self, canvas: "Canvas") -> None: @@ -52,6 +64,70 @@ def __init__(self, canvas: "Canvas") -> None: """ self.canvas: "Canvas" = canvas + # ------------------------------------------------------------------ + # Shared helpers + # ------------------------------------------------------------------ + + def _find_drawable_by_name( + self, + name: str, + exclude_types: Tuple[str, ...] = (), + ) -> Any: + """Look up a drawable by name, optionally skipping certain class names. + + Returns: + The matching drawable. + + Raises: + ValueError: If no drawable with the given name is found. + """ + for d in self.canvas.drawable_manager.get_drawables(): + if exclude_types: + cn = d.get_class_name() if hasattr(d, "get_class_name") else d.__class__.__name__ + if cn in exclude_types: + continue + if d.name == name: + return d + raise ValueError(f"No drawable found with name '{name}'") + + def _get_class_name(self, drawable: Any) -> str: + getter = getattr(drawable, "get_class_name", None) + return getter() if callable(getter) else drawable.__class__.__name__ + + def _gather_moved_points(self, drawable: Any) -> List[Any]: + get_vertices = getattr(drawable, "get_vertices", None) + if callable(get_vertices): + try: + return list(get_vertices()) + except Exception: + pass + return [] + + def _refresh_dependencies_after_transform( + self, + drawable: Any, + moved_points: List[Any], + ) -> None: + """Refresh formulas, names, and caches after a transform.""" + class_name = self._get_class_name(drawable) + + if moved_points and class_name in {"Triangle", "Rectangle", "Polygon"}: + self._refresh_polygon_dependencies(drawable, moved_points) + elif class_name == "Circle": + self._refresh_circle_dependencies(drawable) + elif class_name == "Ellipse": + self._refresh_ellipse_dependencies(drawable) + else: + self._invalidate_drawables([drawable]) + + def _redraw(self) -> None: + if self.canvas.draw_enabled: + self.canvas.draw() + + # ------------------------------------------------------------------ + # Public transform operations + # ------------------------------------------------------------------ + def translate_object(self, name: str, x_offset: float, y_offset: float) -> bool: """ Translates a drawable object by the specified offset. @@ -112,13 +188,29 @@ def translate_object(self, name: str, x_offset: float, y_offset: float) -> bool: return True - def rotate_object(self, name: str, angle: float) -> bool: + def rotate_object( + self, + name: str, + angle: float, + center_x: Optional[float] = None, + center_y: Optional[float] = None, + ) -> bool: """ Rotates a drawable object by the specified angle. + When *center_x* and *center_y* are both provided the rotation is + performed around that arbitrary point (all drawable types with a + ``rotate_around`` method are eligible, including Point and Circle). + + When both are ``None`` the existing center-of-object rotation is used + (Points and Circles are excluded since they are rotationally invariant + around their own center). + Args: name: Name of the drawable to rotate angle: Angle in degrees to rotate the object + center_x: Optional x-coordinate of the rotation center + center_y: Optional y-coordinate of the rotation center Returns: bool: True if the rotation was successful @@ -126,36 +218,189 @@ def rotate_object(self, name: str, angle: float) -> bool: Raises: ValueError: If no drawable with the given name is found or if rotation fails """ - # Find the drawable first to validate it exists - drawable = None - # Get all drawables except Points, Functions, and Circles which don't support rotation - for d in self.canvas.drawable_manager.get_drawables(): - if d.get_class_name() in ['Function', 'Point', 'Circle']: - continue - if d.name == name: - drawable = d - break - - if not drawable: - raise ValueError(f"No drawable found with name '{name}'") + arbitrary_center = center_x is not None and center_y is not None + if (center_x is None) != (center_y is None): + raise ValueError("Both center_x and center_y must be provided for rotation around an arbitrary center") + + if arbitrary_center: + drawable = self._find_drawable_by_name(name, exclude_types=_EXCLUDE_TRANSFORM) + else: + # Original behaviour: skip Point/Circle which are no-ops + drawable = self._find_drawable_by_name( + name, + exclude_types=_EXCLUDE_TRANSFORM + ("Point", "Circle"), + ) - # Archive current state for undo/redo AFTER finding the object but BEFORE modifying it self.canvas.undo_redo_manager.archive() - # Apply rotation using the drawable's rotate method + moved_points = self._gather_moved_points(drawable) + try: - drawable.rotate(angle) + if arbitrary_center: + drawable.rotate_around(angle, center_x, center_y) + else: + drawable.rotate(angle) except Exception as e: - # Raise an error to be handled by the AI interface raise ValueError(f"Error rotating drawable: {str(e)}") - # If we got here, the rotation was successful - # Redraw the canvas - if self.canvas.draw_enabled: - self.canvas.draw() + if arbitrary_center: + self._refresh_dependencies_after_transform(drawable, moved_points) + self._redraw() return True + def reflect_object( + self, + name: str, + axis: str, + line_a: float = 0, + line_b: float = 0, + line_c: float = 0, + segment_name: str = "", + ) -> bool: + """Reflect a drawable across an axis or line. + + Args: + name: Name of the drawable to reflect + axis: One of 'x_axis', 'y_axis', 'line', or 'segment' + line_a, line_b, line_c: Coefficients for ``ax + by + c = 0`` (axis='line') + segment_name: Named segment to use as reflection axis (axis='segment') + + Raises: + ValueError: On invalid axis, degenerate line, or missing segment. + """ + if axis not in ("x_axis", "y_axis", "line", "segment"): + raise ValueError(f"Invalid reflection axis '{axis}'; use x_axis, y_axis, line, or segment") + + a, b, c = float(line_a), float(line_b), float(line_c) + + if axis == "segment": + a, b, c = self._resolve_segment_to_line(segment_name) + axis = "line" + elif axis == "line": + if a * a + b * b < 1e-18: + raise ValueError("Line coefficients a and b must not both be zero") + + drawable = self._find_drawable_by_name(name, exclude_types=_EXCLUDE_TRANSFORM) + self.canvas.undo_redo_manager.archive() + moved_points = self._gather_moved_points(drawable) + + try: + drawable.reflect(axis, a, b, c) + except Exception as e: + raise ValueError(f"Error reflecting drawable: {str(e)}") + + self._refresh_dependencies_after_transform(drawable, moved_points) + self._redraw() + return True + + def scale_object( + self, + name: str, + sx: float, + sy: float, + cx: float, + cy: float, + ) -> bool: + """Scale (dilate) a drawable from center (cx, cy). + + Args: + name: Name of the drawable to scale + sx: Horizontal scale factor + sy: Vertical scale factor + cx, cy: Center of scaling + + Raises: + ValueError: On zero scale factor or unsupported type. + """ + if abs(sx) < 1e-18 or abs(sy) < 1e-18: + raise ValueError("Scale factors must not be zero") + + drawable = self._find_drawable_by_name(name, exclude_types=_EXCLUDE_TRANSFORM) + self.canvas.undo_redo_manager.archive() + moved_points = self._gather_moved_points(drawable) + + try: + drawable.scale(sx, sy, cx, cy) + except Exception as e: + raise ValueError(f"Error scaling drawable: {str(e)}") + + self._refresh_dependencies_after_transform(drawable, moved_points) + self._redraw() + return True + + def shear_object( + self, + name: str, + axis: str, + factor: float, + cx: float, + cy: float, + ) -> bool: + """Shear a drawable along an axis from center (cx, cy). + + Args: + name: Name of the drawable to shear + axis: 'horizontal' or 'vertical' + factor: Shear factor + cx, cy: Center of shear + + Raises: + ValueError: On invalid axis or unsupported type. + """ + if axis not in ("horizontal", "vertical"): + raise ValueError(f"Invalid shear axis '{axis}'; use 'horizontal' or 'vertical'") + + drawable = self._find_drawable_by_name(name, exclude_types=_EXCLUDE_TRANSFORM) + self.canvas.undo_redo_manager.archive() + moved_points = self._gather_moved_points(drawable) + + try: + drawable.shear(axis, factor, cx, cy) + except Exception as e: + raise ValueError(f"Error shearing drawable: {str(e)}") + + self._refresh_dependencies_after_transform(drawable, moved_points) + self._redraw() + return True + + # ------------------------------------------------------------------ + # Segment resolution + # ------------------------------------------------------------------ + + def _resolve_segment_to_line(self, segment_name: str) -> Tuple[float, float, float]: + """Convert a named segment to line coefficients (a, b, c). + + Raises: + ValueError: If the segment is not found or is degenerate (zero-length). + """ + if not segment_name: + raise ValueError("segment_name is required when axis is 'segment'") + + segment: Optional[Segment] = None + for d in self.canvas.drawable_manager.get_drawables(): + if d.name == segment_name and isinstance(d, Segment): + segment = d + break + + if segment is None: + raise ValueError(f"No segment found with name '{segment_name}'") + + dx = segment.point2.x - segment.point1.x + dy = segment.point2.y - segment.point1.y + if dx * dx + dy * dy < 1e-18: + raise ValueError(f"Segment '{segment_name}' has zero length and cannot define a reflection axis") + + # Line through two points: a = dy, b = -dx, c = -(dy*x1 - dx*y1) + a = dy + b = -dx + c_val = -(a * segment.point1.x + b * segment.point1.y) + return a, b, c_val + + # ------------------------------------------------------------------ + # Dependency refresh helpers + # ------------------------------------------------------------------ + def _refresh_polygon_dependencies(self, polygon: Any, points: Iterable[Any]) -> None: dependency_manager = getattr(self.canvas, "dependency_manager", None) From eb62d29b496f1aba243e677a19be887acaaff645 Mon Sep 17 00:00:00 2001 From: vl3c <95963142+vl3c@users.noreply.github.com> Date: Sun, 15 Feb 2026 22:00:56 +0200 Subject: [PATCH 3/8] Wire reflect_object, scale_object, shear_object through Canvas and FunctionRegistry Add Canvas delegation methods for the three new transforms and update rotate_object signature with optional center_x/center_y. Register all three in get_available_functions() and get_undoable_functions() alongside the existing transform entries. --- static/client/canvas.py | 56 ++++++++++++++++++++++++++++-- static/client/function_registry.py | 6 ++++ 2 files changed, 59 insertions(+), 3 deletions(-) diff --git a/static/client/canvas.py b/static/client/canvas.py index 6a869115..2b69648c 100644 --- a/static/client/canvas.py +++ b/static/client/canvas.py @@ -1595,9 +1595,59 @@ def translate_object(self, name: str, x_offset: float, y_offset: float) -> bool: """Translates a drawable object by the specified offset""" return bool(self.transformations_manager.translate_object(name, x_offset, y_offset)) - def rotate_object(self, name: str, angle: float) -> bool: - """Rotates a drawable object by the specified angle""" - return bool(self.transformations_manager.rotate_object(name, angle)) + def rotate_object( + self, + name: str, + angle: float, + center_x: Optional[float] = None, + center_y: Optional[float] = None, + ) -> bool: + """Rotates a drawable object by the specified angle. + + When *center_x* and *center_y* are both provided the object is rotated + around that arbitrary point instead of its own center. + """ + return bool(self.transformations_manager.rotate_object(name, angle, center_x, center_y)) + + def reflect_object( + self, + name: str, + axis: str, + line_a: Optional[float] = None, + line_b: Optional[float] = None, + line_c: Optional[float] = None, + segment_name: Optional[str] = None, + ) -> bool: + """Reflect a drawable across an axis, line, or segment.""" + return bool(self.transformations_manager.reflect_object( + name, axis, + line_a=float(line_a) if line_a is not None else 0, + line_b=float(line_b) if line_b is not None else 0, + line_c=float(line_c) if line_c is not None else 0, + segment_name=str(segment_name) if segment_name else "", + )) + + def scale_object( + self, + name: str, + sx: float, + sy: float, + cx: float, + cy: float, + ) -> bool: + """Scale (dilate) a drawable from center (cx, cy).""" + return bool(self.transformations_manager.scale_object(name, sx, sy, cx, cy)) + + def shear_object( + self, + name: str, + axis: str, + factor: float, + cx: float, + cy: float, + ) -> bool: + """Shear a drawable along an axis from center (cx, cy).""" + return bool(self.transformations_manager.shear_object(name, axis, factor, cx, cy)) def has_computation(self, expression: str) -> bool: """Check if a computation with the given expression already exists.""" diff --git a/static/client/function_registry.py b/static/client/function_registry.py index e9130ff5..19b13891 100644 --- a/static/client/function_registry.py +++ b/static/client/function_registry.py @@ -168,6 +168,9 @@ def get_available_functions(canvas: "Canvas", workspace_manager: "WorkspaceManag # ===== OBJECT TRANSFORMATIONS ===== "translate_object": canvas.translate_object, "rotate_object": canvas.rotate_object, + "reflect_object": canvas.reflect_object, + "scale_object": canvas.scale_object, + "shear_object": canvas.shear_object, # ===== MATHEMATICAL OPERATIONS ===== "evaluate_expression": ProcessFunctionCalls.evaluate_expression, @@ -396,6 +399,9 @@ def get_undoable_functions() -> Tuple[str, ...]: # Object transformations "translate_object", "rotate_object", + "reflect_object", + "scale_object", + "shear_object", # Colored area operations "create_colored_area", From 76c53bc33a69aa702bfbd28b0e48a8130acf101d Mon Sep 17 00:00:00 2001 From: vl3c <95963142+vl3c@users.noreply.github.com> Date: Sun, 15 Feb 2026 22:01:30 +0200 Subject: [PATCH 4/8] Add reflect_object, scale_object, shear_object AI tool definitions and update rotate_object Add JSON schemas for three new transform tools with strict parameter validation. Update rotate_object with optional center_x/center_y parameters for arbitrary-center rotation. --- static/functions_definitions.py | 123 +++++++++++++++++++++++++++++++- 1 file changed, 121 insertions(+), 2 deletions(-) diff --git a/static/functions_definitions.py b/static/functions_definitions.py index 13147e74..449b9dda 100644 --- a/static/functions_definitions.py +++ b/static/functions_definitions.py @@ -1992,7 +1992,7 @@ "type": "function", "function": { "name": "rotate_object", - "description": "Rotates a drawable object around its center by the specified angle", + "description": "Rotates a drawable object by the specified angle. By default rotates around the object's own center. When center_x and center_y are provided, rotates around that arbitrary point (works for all types including points and circles).", "strict": True, "parameters": { "type": "object", @@ -2004,9 +2004,128 @@ "angle": { "type": "number", "description": "The angle in degrees to rotate the object (positive for counterclockwise)" + }, + "center_x": { + "type": ["number", "null"], + "description": "X-coordinate of the rotation center. Must be provided together with center_y for rotation around an arbitrary point. Omit (null) to rotate around the object's own center." + }, + "center_y": { + "type": ["number", "null"], + "description": "Y-coordinate of the rotation center. Must be provided together with center_x for rotation around an arbitrary point. Omit (null) to rotate around the object's own center." + } + }, + "required": ["name", "angle", "center_x", "center_y"], + "additionalProperties": False + } + } + }, + { + "type": "function", + "function": { + "name": "reflect_object", + "description": "Reflects (mirrors) a drawable object across an axis or line. Supports x-axis, y-axis, an arbitrary line (ax + by + c = 0), or a named segment as the reflection axis.", + "strict": True, + "parameters": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The name of the object to reflect" + }, + "axis": { + "type": "string", + "enum": ["x_axis", "y_axis", "line", "segment"], + "description": "The reflection axis type" + }, + "line_a": { + "type": ["number", "null"], + "description": "Coefficient a in ax + by + c = 0 (required when axis is 'line')" + }, + "line_b": { + "type": ["number", "null"], + "description": "Coefficient b in ax + by + c = 0 (required when axis is 'line')" + }, + "line_c": { + "type": ["number", "null"], + "description": "Coefficient c in ax + by + c = 0 (required when axis is 'line')" + }, + "segment_name": { + "type": ["string", "null"], + "description": "Name of a segment to use as the reflection axis (required when axis is 'segment')" + } + }, + "required": ["name", "axis", "line_a", "line_b", "line_c", "segment_name"], + "additionalProperties": False + } + } + }, + { + "type": "function", + "function": { + "name": "scale_object", + "description": "Scales (dilates) a drawable object by the specified factors from a center point. Use equal sx and sy for uniform scaling. Circles require uniform scaling (equal sx and sy); for non-uniform scaling, convert to an ellipse first.", + "strict": True, + "parameters": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The name of the object to scale" + }, + "sx": { + "type": "number", + "description": "Horizontal scale factor (e.g. 2 to double width, 0.5 to halve)" + }, + "sy": { + "type": "number", + "description": "Vertical scale factor (e.g. 2 to double height, 0.5 to halve)" + }, + "cx": { + "type": "number", + "description": "X-coordinate of the scaling center" + }, + "cy": { + "type": "number", + "description": "Y-coordinate of the scaling center" + } + }, + "required": ["name", "sx", "sy", "cx", "cy"], + "additionalProperties": False + } + } + }, + { + "type": "function", + "function": { + "name": "shear_object", + "description": "Shears a drawable object along the specified axis from a center point. Not supported for circles and ellipses.", + "strict": True, + "parameters": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The name of the object to shear" + }, + "axis": { + "type": "string", + "enum": ["horizontal", "vertical"], + "description": "The shear direction" + }, + "factor": { + "type": "number", + "description": "The shear factor (e.g. 0.5 shifts x by 0.5*dy for horizontal shear)" + }, + "cx": { + "type": "number", + "description": "X-coordinate of the shear center" + }, + "cy": { + "type": "number", + "description": "Y-coordinate of the shear center" } }, - "required": ["name", "angle"], + "required": ["name", "axis", "factor", "cx", "cy"], "additionalProperties": False } } From d3ced9c60fda66ccc3fd803169c87365f8098136 Mon Sep 17 00:00:00 2001 From: vl3c <95963142+vl3c@users.noreply.github.com> Date: Sun, 15 Feb 2026 22:03:21 +0200 Subject: [PATCH 5/8] Add Brython tests for geometric transform methods Test drawable-level transforms (reflect, scale, shear, rotate_around) on Point, Segment, Vector, Triangle, Circle, and Ellipse. Test manager orchestration for reflect_object, scale_object, shear_object, and rotate_object with arbitrary center. Verify undo archiving, error cases (non-uniform circle scale, shear on circle/ellipse, missing drawable, zero-length segment axis, single center coord). --- static/client/client_tests/test_transforms.py | 490 ++++++++++++++++++ static/client/client_tests/tests.py | 2 + 2 files changed, 492 insertions(+) create mode 100644 static/client/client_tests/test_transforms.py diff --git a/static/client/client_tests/test_transforms.py b/static/client/client_tests/test_transforms.py new file mode 100644 index 00000000..b15a4dd4 --- /dev/null +++ b/static/client/client_tests/test_transforms.py @@ -0,0 +1,490 @@ +"""Tests for geometric transform methods on drawables and TransformationsManager.""" + +from __future__ import annotations + +import math +import unittest +from typing import Dict, Iterable, List, Set, Tuple + +from drawables_aggregator import Point, Segment, Triangle, Rectangle, Circle, Ellipse +from drawables.vector import Vector +from managers.transformations_manager import TransformationsManager +from client_tests.simple_mock import SimpleMock + + +# --------------------------------------------------------------------------- +# Shared helpers +# --------------------------------------------------------------------------- + +class _IdentityDependencyManager: + """Minimal dependency manager for manager-level tests.""" + + def __init__(self) -> None: + self._edges: Dict[int, Set[int]] = {} + self._lookup: Dict[int, object] = {} + + def register(self, parent: object, child: object) -> None: + self._edges.setdefault(id(parent), set()).add(id(child)) + self._lookup[id(parent)] = parent + self._lookup[id(child)] = child + + def get_children(self, drawable: object) -> set: + child_ids = self._edges.get(id(drawable), set()) + return {self._lookup[cid] for cid in child_ids if cid in self._lookup} + + +def _build_canvas( + primary_drawable: object, + segments: List[Segment], + extra_drawables: List[object] | None = None, +) -> Tuple[SimpleMock, _IdentityDependencyManager, SimpleMock]: + renderer = SimpleMock() + renderer.invalidate_drawable_cache = SimpleMock() + + dependency_manager = _IdentityDependencyManager() + for seg in segments: + dependency_manager.register(seg, primary_drawable) + + all_drawables: list = [primary_drawable] + if extra_drawables: + all_drawables.extend(extra_drawables) + + drawables_container = SimpleMock(Segments=list(segments)) + drawable_manager = SimpleMock( + get_drawables=SimpleMock(return_value=all_drawables), + drawables=drawables_container, + ) + canvas = SimpleMock( + renderer=renderer, + dependency_manager=dependency_manager, + drawable_manager=drawable_manager, + draw_enabled=False, + draw=SimpleMock(), + undo_redo_manager=SimpleMock(archive=SimpleMock()), + ) + return canvas, dependency_manager, renderer + + +def _approx(a: float, b: float, tol: float = 1e-9) -> bool: + return abs(a - b) < tol + + +# =================================================================== +# Drawable-level tests +# =================================================================== + + +class TestTransforms(unittest.TestCase): + """Comprehensive tests for geometric transform methods.""" + + # --------------------------------------------------------------- + # Point: reflect + # --------------------------------------------------------------- + def test_point_reflect_x_axis(self) -> None: + p = Point(3, 4, name="P") + p.reflect("x_axis") + self.assertTrue(_approx(p.x, 3)) + self.assertTrue(_approx(p.y, -4)) + + def test_point_reflect_y_axis(self) -> None: + p = Point(3, 4, name="P") + p.reflect("y_axis") + self.assertTrue(_approx(p.x, -3)) + self.assertTrue(_approx(p.y, 4)) + + def test_point_reflect_line_y_equals_x(self) -> None: + """Reflect across y = x (a=1, b=-1, c=0).""" + p = Point(3, 4, name="P") + p.reflect("line", a=1, b=-1, c=0) + self.assertTrue(_approx(p.x, 4)) + self.assertTrue(_approx(p.y, 3)) + + def test_point_reflect_degenerate_line(self) -> None: + """Degenerate line (a=b=c=0) should leave point unchanged.""" + p = Point(3, 4, name="P") + p.reflect("line", a=0, b=0, c=0) + self.assertTrue(_approx(p.x, 3)) + self.assertTrue(_approx(p.y, 4)) + + # --------------------------------------------------------------- + # Point: scale + # --------------------------------------------------------------- + def test_point_scale_uniform_from_origin(self) -> None: + p = Point(2, 3, name="P") + p.scale(2, 2, 0, 0) + self.assertTrue(_approx(p.x, 4)) + self.assertTrue(_approx(p.y, 6)) + + def test_point_scale_nonuniform_from_center(self) -> None: + p = Point(4, 6, name="P") + p.scale(0.5, 2, 2, 2) + # x: 2 + 0.5*(4-2) = 3, y: 2 + 2*(6-2) = 10 + self.assertTrue(_approx(p.x, 3)) + self.assertTrue(_approx(p.y, 10)) + + # --------------------------------------------------------------- + # Point: shear + # --------------------------------------------------------------- + def test_point_shear_horizontal(self) -> None: + p = Point(1, 2, name="P") + p.shear("horizontal", 0.5, 0, 0) + # x = 0 + (1 - 0) + 0.5 * (2 - 0) = 2, y = 2 + self.assertTrue(_approx(p.x, 2)) + self.assertTrue(_approx(p.y, 2)) + + def test_point_shear_vertical(self) -> None: + p = Point(3, 1, name="P") + p.shear("vertical", 2, 0, 0) + # x = 3, y = 0 + (1 - 0) + 2 * (3 - 0) = 7 + self.assertTrue(_approx(p.x, 3)) + self.assertTrue(_approx(p.y, 7)) + + # --------------------------------------------------------------- + # Point: rotate_around + # --------------------------------------------------------------- + def test_point_rotate_around_origin_90(self) -> None: + p = Point(1, 0, name="P") + p.rotate_around(90, 0, 0) + self.assertTrue(_approx(p.x, 0)) + self.assertTrue(_approx(p.y, 1)) + + def test_point_rotate_around_arbitrary_center(self) -> None: + p = Point(3, 0, name="P") + p.rotate_around(180, 2, 0) + self.assertTrue(_approx(p.x, 1)) + self.assertTrue(_approx(p.y, 0, tol=1e-9)) + + # --------------------------------------------------------------- + # Segment transforms + # --------------------------------------------------------------- + def test_segment_reflect_x_axis(self) -> None: + p1 = Point(0, 1, name="A") + p2 = Point(4, 3, name="B") + s = Segment(p1, p2) + s.reflect("x_axis") + self.assertTrue(_approx(p1.y, -1)) + self.assertTrue(_approx(p2.y, -3)) + # Line formula should be recalculated + self.assertIsNotNone(s.line_formula) + + def test_segment_scale(self) -> None: + p1 = Point(1, 1, name="A") + p2 = Point(3, 1, name="B") + s = Segment(p1, p2) + s.scale(2, 2, 0, 0) + self.assertTrue(_approx(p1.x, 2)) + self.assertTrue(_approx(p2.x, 6)) + + def test_segment_shear(self) -> None: + p1 = Point(0, 0, name="A") + p2 = Point(2, 0, name="B") + s = Segment(p1, p2) + s.shear("horizontal", 1, 0, 0) + # p1 unchanged (dy=0), p2 unchanged (dy=0) + self.assertTrue(_approx(p1.x, 0)) + self.assertTrue(_approx(p2.x, 2)) + + def test_segment_rotate_around(self) -> None: + p1 = Point(0, 0, name="A") + p2 = Point(2, 0, name="B") + s = Segment(p1, p2) + s.rotate_around(90, 0, 0) + self.assertTrue(_approx(p1.x, 0)) + self.assertTrue(_approx(p1.y, 0)) + self.assertTrue(_approx(p2.x, 0)) + self.assertTrue(_approx(p2.y, 2)) + + # --------------------------------------------------------------- + # Vector transforms + # --------------------------------------------------------------- + def test_vector_reflect(self) -> None: + v = Vector(Point(0, 0, name="O"), Point(1, 1, name="T")) + v.reflect("y_axis") + self.assertTrue(_approx(v.origin.x, 0)) + self.assertTrue(_approx(v.tip.x, -1)) + + def test_vector_scale(self) -> None: + v = Vector(Point(1, 0, name="O"), Point(3, 0, name="T")) + v.scale(2, 2, 0, 0) + self.assertTrue(_approx(v.origin.x, 2)) + self.assertTrue(_approx(v.tip.x, 6)) + + # --------------------------------------------------------------- + # Polygon (Triangle) transforms + # --------------------------------------------------------------- + def test_triangle_reflect_x_axis(self) -> None: + p1 = Point(0, 0, name="A") + p2 = Point(4, 0, name="B") + p3 = Point(2, 3, name="C") + s1, s2, s3 = Segment(p1, p2), Segment(p2, p3), Segment(p3, p1) + tri = Triangle(s1, s2, s3) + tri.reflect("x_axis") + self.assertTrue(_approx(p3.y, -3)) + + def test_triangle_scale_from_external_center(self) -> None: + p1 = Point(0, 0, name="A") + p2 = Point(4, 0, name="B") + p3 = Point(2, 3, name="C") + s1, s2, s3 = Segment(p1, p2), Segment(p2, p3), Segment(p3, p1) + tri = Triangle(s1, s2, s3) + tri.scale(2, 2, 0, 0) + self.assertTrue(_approx(p2.x, 8)) + self.assertTrue(_approx(p3.y, 6)) + + def test_triangle_shear(self) -> None: + p1 = Point(0, 0, name="A") + p2 = Point(4, 0, name="B") + p3 = Point(2, 3, name="C") + s1, s2, s3 = Segment(p1, p2), Segment(p2, p3), Segment(p3, p1) + tri = Triangle(s1, s2, s3) + tri.shear("horizontal", 1, 0, 0) + # p3: x = 2 + 1*3 = 5 + self.assertTrue(_approx(p3.x, 5)) + + def test_triangle_rotate_around(self) -> None: + p1 = Point(0, 0, name="A") + p2 = Point(4, 0, name="B") + p3 = Point(2, 3, name="C") + s1, s2, s3 = Segment(p1, p2), Segment(p2, p3), Segment(p3, p1) + tri = Triangle(s1, s2, s3) + tri.rotate_around(180, 2, 0) + # p1: (0,0) → rotate 180 around (2,0): (4, 0) + self.assertTrue(_approx(p1.x, 4)) + self.assertTrue(_approx(p1.y, 0, tol=1e-9)) + # p2: (4,0) → (0, 0) + self.assertTrue(_approx(p2.x, 0, tol=1e-9)) + + # --------------------------------------------------------------- + # Circle transforms + # --------------------------------------------------------------- + def test_circle_reflect_center_moves(self) -> None: + c = Circle(Point(3, 4, name="C"), 5) + c.reflect("x_axis") + self.assertTrue(_approx(c.center.y, -4)) + self.assertTrue(_approx(c.radius, 5)) + + def test_circle_scale_uniform(self) -> None: + c = Circle(Point(1, 1, name="C"), 3) + c.scale(2, 2, 0, 0) + self.assertTrue(_approx(c.center.x, 2)) + self.assertTrue(_approx(c.center.y, 2)) + self.assertTrue(_approx(c.radius, 6)) + + def test_circle_scale_negative_uniform(self) -> None: + """Negative uniform factor should use abs for radius.""" + c = Circle(Point(1, 1, name="C"), 3) + c.scale(-2, -2, 0, 0) + self.assertTrue(_approx(c.radius, 6)) + + def test_circle_scale_nonuniform_raises(self) -> None: + c = Circle(Point(0, 0, name="C"), 5) + with self.assertRaises(ValueError): + c.scale(2, 3, 0, 0) + + def test_circle_shear_raises(self) -> None: + c = Circle(Point(0, 0, name="C"), 5) + with self.assertRaises(ValueError): + c.shear("horizontal", 1, 0, 0) + + def test_circle_scale_zero_raises(self) -> None: + c = Circle(Point(0, 0, name="C"), 5) + with self.assertRaises(ValueError): + c.scale(0, 0, 0, 0) + + def test_circle_rotate_around(self) -> None: + c = Circle(Point(2, 0, name="C"), 3) + c.rotate_around(90, 0, 0) + self.assertTrue(_approx(c.center.x, 0)) + self.assertTrue(_approx(c.center.y, 2)) + self.assertTrue(_approx(c.radius, 3)) + + # --------------------------------------------------------------- + # Ellipse transforms + # --------------------------------------------------------------- + def test_ellipse_reflect_x_axis(self) -> None: + e = Ellipse(Point(0, 0, name="E"), 5, 3, rotation_angle=30) + e.reflect("x_axis") + self.assertTrue(_approx(e.rotation_angle, 330)) + + def test_ellipse_reflect_y_axis(self) -> None: + e = Ellipse(Point(0, 0, name="E"), 5, 3, rotation_angle=30) + e.reflect("y_axis") + self.assertTrue(_approx(e.rotation_angle, 150)) + + def test_ellipse_scale_uniform(self) -> None: + e = Ellipse(Point(1, 1, name="E"), 4, 2) + e.scale(3, 3, 0, 0) + self.assertTrue(_approx(e.radius_x, 12)) + self.assertTrue(_approx(e.radius_y, 6)) + + def test_ellipse_scale_nonuniform_axis_aligned(self) -> None: + e = Ellipse(Point(0, 0, name="E"), 4, 2, rotation_angle=0) + e.scale(2, 3, 0, 0) + self.assertTrue(_approx(e.radius_x, 8)) + self.assertTrue(_approx(e.radius_y, 6)) + + def test_ellipse_scale_nonuniform_rotated_raises(self) -> None: + e = Ellipse(Point(0, 0, name="E"), 4, 2, rotation_angle=45) + with self.assertRaises(ValueError): + e.scale(2, 3, 0, 0) + + def test_ellipse_shear_raises(self) -> None: + e = Ellipse(Point(0, 0, name="E"), 4, 2) + with self.assertRaises(ValueError): + e.shear("horizontal", 1, 0, 0) + + def test_ellipse_rotate_around(self) -> None: + e = Ellipse(Point(2, 0, name="E"), 4, 2, rotation_angle=0) + e.rotate_around(90, 0, 0) + self.assertTrue(_approx(e.center.x, 0)) + self.assertTrue(_approx(e.center.y, 2)) + self.assertTrue(_approx(e.rotation_angle, 90)) + + # --------------------------------------------------------------- + # Manager-level: reflect_object + # --------------------------------------------------------------- + def test_manager_reflect_triangle(self) -> None: + p1 = Point(0, 0, name="A") + p2 = Point(4, 0, name="B") + p3 = Point(2, 3, name="C") + s1, s2, s3 = Segment(p1, p2), Segment(p2, p3), Segment(p3, p1) + tri = Triangle(s1, s2, s3) + + canvas, _, renderer = _build_canvas(tri, [s1, s2, s3]) + mgr = TransformationsManager(canvas) + mgr.reflect_object(tri.name, "x_axis") + + self.assertTrue(_approx(p3.y, -3)) + self.assertTrue(renderer.invalidate_drawable_cache.calls) + + def test_manager_reflect_via_segment(self) -> None: + """Reflect a point across a segment acting as the axis.""" + p = Point(3, 4, name="P") + # Segment along x-axis + sp1 = Point(0, 0, name="S1") + sp2 = Point(1, 0, name="S2") + seg = Segment(sp1, sp2) + + canvas, _, _ = _build_canvas(p, [], extra_drawables=[seg]) + mgr = TransformationsManager(canvas) + mgr.reflect_object("P", "segment", segment_name=seg.name) + + # Reflection across y=0 line: y negated + self.assertTrue(_approx(p.x, 3)) + self.assertTrue(_approx(p.y, -4)) + + def test_manager_reflect_zero_length_segment_raises(self) -> None: + p = Point(1, 1, name="P") + sp = Point(0, 0, name="S") + seg = Segment(sp, Point(0, 0, name="S2")) + + canvas, _, _ = _build_canvas(p, [], extra_drawables=[seg]) + mgr = TransformationsManager(canvas) + + with self.assertRaises(ValueError): + mgr.reflect_object("P", "segment", segment_name=seg.name) + + # --------------------------------------------------------------- + # Manager-level: scale_object + # --------------------------------------------------------------- + def test_manager_scale_circle_uniform(self) -> None: + center = Point(0, 0, name="C") + circle = Circle(center, 5) + + canvas, _, renderer = _build_canvas(circle, []) + mgr = TransformationsManager(canvas) + mgr.scale_object(circle.name, 2, 2, 0, 0) + + self.assertTrue(_approx(circle.radius, 10)) + self.assertTrue(renderer.invalidate_drawable_cache.calls) + + def test_manager_scale_circle_nonuniform_raises(self) -> None: + center = Point(0, 0, name="C") + circle = Circle(center, 5) + + canvas, _, _ = _build_canvas(circle, []) + mgr = TransformationsManager(canvas) + + with self.assertRaises(ValueError): + mgr.scale_object(circle.name, 2, 3, 0, 0) + + # --------------------------------------------------------------- + # Manager-level: shear_object + # --------------------------------------------------------------- + def test_manager_shear_rectangle(self) -> None: + p1 = Point(0, 0, name="A") + p2 = Point(4, 0, name="B") + p3 = Point(4, 3, name="C") + p4 = Point(0, 3, name="D") + s1 = Segment(p1, p2) + s2 = Segment(p2, p3) + s3 = Segment(p3, p4) + s4 = Segment(p4, p1) + rect = Rectangle(s1, s2, s3, s4) + + canvas, _, renderer = _build_canvas(rect, [s1, s2, s3, s4]) + mgr = TransformationsManager(canvas) + mgr.shear_object(rect.name, "horizontal", 0.5, 0, 0) + + # p3: x = 4 + 0.5 * 3 = 5.5 + self.assertTrue(_approx(p3.x, 5.5)) + self.assertTrue(renderer.invalidate_drawable_cache.calls) + + # --------------------------------------------------------------- + # Manager-level: rotate_object with arbitrary center + # --------------------------------------------------------------- + def test_manager_rotate_point_around_center(self) -> None: + p = Point(1, 0, name="P") + + canvas, _, _ = _build_canvas(p, []) + mgr = TransformationsManager(canvas) + mgr.rotate_object("P", 90, center_x=0, center_y=0) + + self.assertTrue(_approx(p.x, 0)) + self.assertTrue(_approx(p.y, 1)) + + def test_manager_rotate_one_center_coord_raises(self) -> None: + p = Point(1, 0, name="P") + canvas, _, _ = _build_canvas(p, []) + mgr = TransformationsManager(canvas) + + with self.assertRaises(ValueError): + mgr.rotate_object("P", 90, center_x=0, center_y=None) + + # --------------------------------------------------------------- + # Manager-level: missing drawable raises + # --------------------------------------------------------------- + def test_manager_missing_drawable_raises(self) -> None: + p = Point(0, 0, name="P") + canvas, _, _ = _build_canvas(p, []) + mgr = TransformationsManager(canvas) + + with self.assertRaises(ValueError): + mgr.reflect_object("NONEXISTENT", "x_axis") + + # --------------------------------------------------------------- + # Manager-level: undo archiving called + # --------------------------------------------------------------- + def test_manager_archive_called_before_reflect(self) -> None: + p = Point(0, 0, name="P") + canvas, _, _ = _build_canvas(p, []) + mgr = TransformationsManager(canvas) + mgr.reflect_object("P", "x_axis") + + self.assertTrue(canvas.undo_redo_manager.archive.calls) + + def test_manager_archive_called_before_scale(self) -> None: + p = Point(0, 0, name="P") + canvas, _, _ = _build_canvas(p, []) + mgr = TransformationsManager(canvas) + mgr.scale_object("P", 2, 2, 0, 0) + + self.assertTrue(canvas.undo_redo_manager.archive.calls) + + def test_manager_archive_called_before_shear(self) -> None: + p = Point(0, 0, name="P") + canvas, _, _ = _build_canvas(p, []) + mgr = TransformationsManager(canvas) + mgr.shear_object("P", "horizontal", 1, 0, 0) + + self.assertTrue(canvas.undo_redo_manager.archive.calls) diff --git a/static/client/client_tests/tests.py b/static/client/client_tests/tests.py index e147f904..04df9be8 100644 --- a/static/client/client_tests/tests.py +++ b/static/client/client_tests/tests.py @@ -190,6 +190,7 @@ TestRenderColoredAreaHelper, ) from .test_transformations_manager import TestTransformationsManager +from .test_transforms import TestTransforms from .test_area_expression_evaluator import ( TestAreaCalculation, TestRegionGeneration, @@ -421,6 +422,7 @@ def _get_test_cases(self) -> List[Type[unittest.TestCase]]: TestEllipseManager, TestColoredAreaManager, TestTransformationsManager, + TestTransforms, TestFunctionManager, TestParametricFunction, TestParametricFunctionRenderable, From 9057dc3265fa1616847afcc1c4c1a7b4178ac2c8 Mon Sep 17 00:00:00 2001 From: vl3c <95963142+vl3c@users.noreply.github.com> Date: Sun, 15 Feb 2026 22:05:54 +0200 Subject: [PATCH 6/8] Update docs for geometric transform workflows Add Geometric Transformations section to Example Prompts with reflect, scale, shear, and arbitrary-center rotation examples. Update Reference Manual: TransformationsManager description and methods, Canvas methods, drawable method lists for Point, Segment, Vector, Polygon, Circle, and Ellipse, and function category summaries. --- documentation/Example Prompts.txt | 10 ++++++ documentation/Reference Manual.txt | 50 +++++++++++++++++++++++++----- 2 files changed, 52 insertions(+), 8 deletions(-) diff --git a/documentation/Example Prompts.txt b/documentation/Example Prompts.txt index 5666cdef..a4819f1f 100644 --- a/documentation/Example Prompts.txt +++ b/documentation/Example Prompts.txt @@ -62,6 +62,16 @@ Create four points on a circle and verify they are concyclic. Check if triangles T1 and T2 are similar. Draw a tangent line to circle c1 and verify the tangency with inspect_relation. +# Geometric Transformations +Reflect triangle ABC across the x-axis. +Mirror segment AB across the line y = x (line coefficients a=1, b=-1, c=0). +Reflect the rectangle across segment CD. +Scale triangle ABC by factor 2 from the origin (sx=2, sy=2, cx=0, cy=0). +Scale the circle uniformly by 0.5 from its own center. +Shear the rectangle horizontally by factor 0.5 from the origin. +Rotate triangle ABC by 45 degrees around point (2, 3). +Rotate point A by 90 degrees around the origin. + # Regression Analysis Fit a linear regression to x_data=[1,2,3,4,5] and y_data=[2.1,3.9,6.2,7.8,10.1]. Show the data points and fitted curve. Fit a quadratic polynomial (degree 2) to x_data=[0,1,2,3,4] and y_data=[0,1,4,9,16]. Report the R-squared value. diff --git a/documentation/Reference Manual.txt b/documentation/Reference Manual.txt index ab3a51b0..3c4a31cb 100644 --- a/documentation/Reference Manual.txt +++ b/documentation/Reference Manual.txt @@ -320,7 +320,10 @@ Attributes: - `zoom_to_bounds(left_bound, right_bound, top_bound, bottom_bound)`: Fit the viewport so the specified math-space rectangle is entirely visible while preserving aspect ratio - `create_colored_area(drawable1_name, drawable2_name=None, left_bound=None, right_bound=None, color="lightblue", opacity=0.3)`: Create a colored area between two objects - `translate_object(name, x_offset, y_offset)`: Translates a drawable object by the specified offset -- `rotate_object(name, angle)`: Rotates a drawable object by the specified angle +- `rotate_object(name, angle, center_x=None, center_y=None)`: Rotates a drawable object by the specified angle, optionally around an arbitrary center +- `reflect_object(name, axis, line_a=None, line_b=None, line_c=None, segment_name=None)`: Reflects a drawable across x_axis, y_axis, a line (ax+by+c=0), or a named segment +- `scale_object(name, sx, sy, cx, cy)`: Scales a drawable from center (cx, cy) +- `shear_object(name, axis, factor, cx, cy)`: Shears a drawable along horizontal or vertical axis from center (cx, cy) - `create_angle(vx, vy, p1x, p1y, p2x, p2y, color=None, angle_name=None, is_reflex=False)`: Create an angle from three points Example prompt for partial shading: @@ -1077,6 +1080,10 @@ Attributes: - `pan()`: Update point screen coordinates for pan operations - `translate(x_offset, y_offset)`: Move point by translating original position - `rotate(angle)`: Rotate point (placeholder implementation) +- `reflect(axis, a=0, b=0, c=0)`: Reflect point across x_axis, y_axis, or line ax+by+c=0 +- `scale(sx, sy, cx, cy)`: Scale point relative to center (cx, cy) +- `shear(axis, factor, cx, cy)`: Shear point relative to center (cx, cy) +- `rotate_around(angle_deg, cx, cy)`: Rotate point around arbitrary center (cx, cy) - `get_state()`: Serialize point state for persistence - `is_visible()`: Check if the point is within the canvas visible area - `__deepcopy__(memo)`: Create deep copy for undo/redo functionality @@ -1132,6 +1139,10 @@ Attributes: - `pan()`: Update segment for pan operations (handled by endpoints) - `translate(x_offset, y_offset)`: Move segment by translating both endpoints - `rotate(angle)`: Rotate the segment around its midpoint by the given angle in degrees +- `reflect(axis, a=0, b=0, c=0)`: Reflect both endpoints and recalculate line formula +- `scale(sx, sy, cx, cy)`: Scale both endpoints and recalculate line formula +- `shear(axis, factor, cx, cy)`: Shear both endpoints and recalculate line formula +- `rotate_around(angle_deg, cx, cy)`: Rotate both endpoints around arbitrary center - `get_state()`: Serialize segment state for persistence - `is_visible()`: Check if any part of the segment is visible in the canvas area - `__deepcopy__(memo)`: Create deep copy for undo/redo functionality @@ -1185,6 +1196,10 @@ Attributes: - `draw()`: Render the vector (line + arrow tip) - `translate(x_offset, y_offset)`: Translate origin and tip - `rotate(angle)`: Rotate vector around origin +- `reflect(axis, a=0, b=0, c=0)`: Reflect vector (delegates to segment) +- `scale(sx, sy, cx, cy)`: Scale vector (delegates to segment) +- `shear(axis, factor, cx, cy)`: Shear vector (delegates to segment) +- `rotate_around(angle_deg, cx, cy)`: Rotate vector around arbitrary center - `get_state()`: Serialize vector state - `__deepcopy__(memo)`: Deep copy @@ -1343,6 +1358,10 @@ Attributes: - `pan()`: Update circle for pan operations (handled by center point) - `translate(x_offset, y_offset)`: Move circle by translating center point - `rotate(angle)`: Rotate circle (placeholder implementation) +- `reflect(axis, a=0, b=0, c=0)`: Reflect center, radius unchanged +- `scale(sx, sy, cx, cy)`: Uniform scale only (raises ValueError for non-uniform) +- `shear(axis, factor, cx, cy)`: Not supported (raises ValueError) +- `rotate_around(angle_deg, cx, cy)`: Rotate center around arbitrary point - `get_state()`: Serialize circle state for persistence - `__deepcopy__(memo)`: Create deep copy for undo/redo functionality @@ -1399,6 +1418,10 @@ Attributes: - `pan()`: Update ellipse for pan operations (handled by center point) - `translate(x_offset, y_offset)`: Move ellipse by translating center point - `rotate(angle)`: Rotate ellipse around its center by the given angle in degrees +- `reflect(axis, a=0, b=0, c=0)`: Reflect center and adjust rotation_angle +- `scale(sx, sy, cx, cy)`: Uniform or axis-aligned non-uniform (rotated + non-uniform raises ValueError) +- `shear(axis, factor, cx, cy)`: Not supported (raises ValueError) +- `rotate_around(angle_deg, cx, cy)`: Rotate center around arbitrary point and add to rotation_angle - `get_state()`: Serialize ellipse state for persistence including rotation - `__deepcopy__(memo)`: Create deep copy for undo/redo functionality @@ -1782,6 +1805,10 @@ Subclasses must implement: - `_rotate_point_around_center(point, center_x, center_y, angle_rad)`: Rotate a single point around a center by given angle in radians - `get_vertices()`: Abstract method to be implemented by subclasses to return their vertices - `rotate(angle)`: Rotate the polygon around its center by the given angle in degrees +- `reflect(axis, a=0, b=0, c=0)`: Reflect all vertices across the specified axis +- `scale(sx, sy, cx, cy)`: Scale all vertices relative to center (cx, cy) +- `shear(axis, factor, cx, cy)`: Shear all vertices relative to center (cx, cy) +- `rotate_around(angle_deg, cx, cy)`: Rotate all vertices around an arbitrary center ### Management Classes @@ -2163,12 +2190,16 @@ This class is responsible for: ``` MatHud Geometric Transformations Management System -Handles geometric transformations of drawable objects including translation and rotation. +Handles geometric transformations of drawable objects including translation, +rotation, reflection, scaling, and shearing. Provides coordinated transformation operations with proper state management and canvas integration. Transformation Types: - Translation: Moving objects by specified x and y offsets - - Rotation: Rotating objects around specified points or their centers + - Rotation: Rotating objects around their centers or an arbitrary point + - Reflection: Mirroring objects across x-axis, y-axis, or an arbitrary line + - Scaling (dilation): Uniform or non-uniform scaling from a center point + - Shearing: Horizontal or vertical shear from a center point Operation Coordination: - State Archiving: Automatic undo/redo state capture before transformations @@ -2193,14 +2224,17 @@ Integration Points: ``` Manages geometric transformations of drawable objects on a Canvas. -Coordinates translation and rotation operations with proper state management, -object validation, and canvas integration. +Coordinates translation, rotation, reflection, scaling, and shearing +operations with proper state management, object validation, and canvas integration. ``` **Key Methods:** - `__init__(canvas)`: Initialize the TransformationsManager - `translate_object(name, x_offset, y_offset)`: Translates a drawable object by the specified offset -- `rotate_object(name, angle)`: Rotates a drawable object by the specified angle +- `rotate_object(name, angle, center_x=None, center_y=None)`: Rotates a drawable object by the specified angle. When center_x and center_y are provided, rotates around that arbitrary point (all types including Point and Circle are eligible). +- `reflect_object(name, axis, line_a=0, line_b=0, line_c=0, segment_name="")`: Reflects a drawable across an axis. axis is one of 'x_axis', 'y_axis', 'line' (ax+by+c=0), or 'segment' (resolve named segment). +- `scale_object(name, sx, sy, cx, cy)`: Scales a drawable from center (cx, cy). Circles require uniform scaling (sx == sy). Rotated ellipses require uniform scaling. +- `shear_object(name, axis, factor, cx, cy)`: Shears a drawable along 'horizontal' or 'vertical' axis from center (cx, cy). Not supported for circles or ellipses (raises ValueError). #### Coordinate System Manager (`managers/coordinate_system_manager.py`) @@ -3340,7 +3374,7 @@ Function Categories: - Canvas operations: reset, clear, undo, redo - Geometric shapes: points, segments, vectors, triangles, rectangles, circles, ellipses - Mathematical functions: plotting, evaluation, symbolic computation - - Object transformations: translate, rotate + - Object transformations: translate, rotate, reflect, scale, shear - Workspace management: save, load, list, delete - Special features: colored areas, angle measurement, testing @@ -3711,7 +3745,7 @@ Categories: - Geometric Shapes: points, segments, vectors, triangles, rectangles, circles, ellipses, angles - Mathematical Functions: plotting, colored areas, bounded regions - Calculations: expressions, trigonometry, algebra, calculus - - Transformations: translate, rotate, scale geometric objects + - Transformations: translate, rotate, reflect, scale, shear geometric objects - Workspace Management: save, load, list, delete workspaces Dependencies: From a1b2f408c5f61e2b2726b21439213dfb92032ad6 Mon Sep 17 00:00:00 2001 From: vl3c <95963142+vl3c@users.noreply.github.com> Date: Sun, 15 Feb 2026 23:21:45 +0200 Subject: [PATCH 7/8] Fix polygon subclass refresh and prevent undo pollution on unsupported transforms Fix dependency refresh to use moved_points presence instead of a hard-coded class name set, so all Polygon subclasses (Quadrilateral, Pentagon, Hexagon, etc.) get proper segment formula and cache refresh after transforms. Also refactor translate_object to use the shared _refresh_dependencies_after_transform helper. Add _validate_shear_support and _validate_scale_support checks that run before undo archiving, so unsupported operations (shear on circle/ellipse, non-uniform scale on circle/rotated ellipse) raise ValueError without polluting the undo history. Add tests verifying archive is not called on rejected transforms. --- static/client/client_tests/test_transforms.py | 43 +++++++++++++++++++ .../managers/transformations_manager.py | 41 +++++++++++++----- 2 files changed, 74 insertions(+), 10 deletions(-) diff --git a/static/client/client_tests/test_transforms.py b/static/client/client_tests/test_transforms.py index b15a4dd4..5dc23c00 100644 --- a/static/client/client_tests/test_transforms.py +++ b/static/client/client_tests/test_transforms.py @@ -488,3 +488,46 @@ def test_manager_archive_called_before_shear(self) -> None: mgr.shear_object("P", "horizontal", 1, 0, 0) self.assertTrue(canvas.undo_redo_manager.archive.calls) + + # --------------------------------------------------------------- + # Manager-level: no archive on unsupported transforms + # --------------------------------------------------------------- + def test_manager_no_archive_on_circle_shear(self) -> None: + """Shearing a circle should fail before archiving undo state.""" + c = Circle(Point(0, 0, name="C"), 5) + canvas, _, _ = _build_canvas(c, []) + mgr = TransformationsManager(canvas) + + with self.assertRaises(ValueError): + mgr.shear_object(c.name, "horizontal", 1, 0, 0) + self.assertFalse(canvas.undo_redo_manager.archive.calls) + + def test_manager_no_archive_on_ellipse_shear(self) -> None: + """Shearing an ellipse should fail before archiving undo state.""" + e = Ellipse(Point(0, 0, name="E"), 4, 2) + canvas, _, _ = _build_canvas(e, []) + mgr = TransformationsManager(canvas) + + with self.assertRaises(ValueError): + mgr.shear_object(e.name, "horizontal", 1, 0, 0) + self.assertFalse(canvas.undo_redo_manager.archive.calls) + + def test_manager_no_archive_on_circle_nonuniform_scale(self) -> None: + """Non-uniform scaling a circle should fail before archiving undo state.""" + c = Circle(Point(0, 0, name="C"), 5) + canvas, _, _ = _build_canvas(c, []) + mgr = TransformationsManager(canvas) + + with self.assertRaises(ValueError): + mgr.scale_object(c.name, 2, 3, 0, 0) + self.assertFalse(canvas.undo_redo_manager.archive.calls) + + def test_manager_no_archive_on_rotated_ellipse_nonuniform_scale(self) -> None: + """Non-uniform scaling a rotated ellipse should fail before archiving.""" + e = Ellipse(Point(0, 0, name="E"), 4, 2, rotation_angle=45) + canvas, _, _ = _build_canvas(e, []) + mgr = TransformationsManager(canvas) + + with self.assertRaises(ValueError): + mgr.scale_object(e.name, 2, 3, 0, 0) + self.assertFalse(canvas.undo_redo_manager.archive.calls) diff --git a/static/client/managers/transformations_manager.py b/static/client/managers/transformations_manager.py index 7d8480fd..3b67d18f 100644 --- a/static/client/managers/transformations_manager.py +++ b/static/client/managers/transformations_manager.py @@ -103,6 +103,30 @@ def _gather_moved_points(self, drawable: Any) -> List[Any]: pass return [] + def _validate_shear_support(self, drawable: Any) -> None: + """Raise before archiving if the drawable cannot be sheared.""" + cn = self._get_class_name(drawable) + if cn == "Circle": + raise ValueError("Shearing a circle is not supported; convert to an ellipse first") + if cn == "Ellipse": + raise ValueError("Shearing an ellipse is not supported") + + def _validate_scale_support(self, drawable: Any, sx: float, sy: float) -> None: + """Raise before archiving if the drawable cannot be scaled with these factors.""" + cn = self._get_class_name(drawable) + uniform = abs(sx - sy) < 1e-9 + if cn == "Circle" and not uniform: + raise ValueError( + "Non-uniform scaling of a circle is not supported; " + "convert to an ellipse first or use equal sx and sy" + ) + if cn == "Ellipse" and not uniform: + rot = getattr(drawable, "rotation_angle", 0) + if (rot % 180) > 1e-9: + raise ValueError( + "Non-uniform scaling of a rotated ellipse is not supported" + ) + def _refresh_dependencies_after_transform( self, drawable: Any, @@ -111,7 +135,10 @@ def _refresh_dependencies_after_transform( """Refresh formulas, names, and caches after a transform.""" class_name = self._get_class_name(drawable) - if moved_points and class_name in {"Triangle", "Rectangle", "Polygon"}: + # moved_points is non-empty only for drawables with get_vertices() + # (all Polygon subclasses: Triangle, Rectangle, Quadrilateral, + # Pentagon, Hexagon, etc.) — no hard-coded class name set needed. + if moved_points: self._refresh_polygon_dependencies(drawable, moved_points) elif class_name == "Circle": self._refresh_circle_dependencies(drawable) @@ -171,15 +198,7 @@ def translate_object(self, name: str, x_offset: float, y_offset: float) -> bool: # Raise an error to be handled by the AI interface raise ValueError(f"Error translating drawable: {str(e)}") - class_name_getter = getattr(drawable, "get_class_name", None) - class_name = class_name_getter() if callable(class_name_getter) else drawable.__class__.__name__ - - if moved_points and class_name in {"Triangle", "Rectangle", "Polygon"}: - self._refresh_polygon_dependencies(drawable, moved_points) - elif class_name == "Circle": - self._refresh_circle_dependencies(drawable) - elif class_name == "Ellipse": - self._refresh_ellipse_dependencies(drawable) + self._refresh_dependencies_after_transform(drawable, moved_points) # If we got here, the translation was successful # Redraw the canvas @@ -317,6 +336,7 @@ def scale_object( raise ValueError("Scale factors must not be zero") drawable = self._find_drawable_by_name(name, exclude_types=_EXCLUDE_TRANSFORM) + self._validate_scale_support(drawable, sx, sy) self.canvas.undo_redo_manager.archive() moved_points = self._gather_moved_points(drawable) @@ -352,6 +372,7 @@ def shear_object( raise ValueError(f"Invalid shear axis '{axis}'; use 'horizontal' or 'vertical'") drawable = self._find_drawable_by_name(name, exclude_types=_EXCLUDE_TRANSFORM) + self._validate_shear_support(drawable) self.canvas.undo_redo_manager.archive() moved_points = self._gather_moved_points(drawable) From 6329d4aa52d0fb1ff83c416c28b7740db31c9850 Mon Sep 17 00:00:00 2001 From: vl3c <95963142+vl3c@users.noreply.github.com> Date: Mon, 16 Feb 2026 20:54:04 +0200 Subject: [PATCH 8/8] Add comprehensive edge-case tests for geometric transforms Expand test_transforms.py from 42 to 126 test methods covering: - Point: general line reflection, identity transforms, negative scale, non-origin shear centers, unknown axis no-op behavior - Segment: y-axis/arbitrary line reflection, shear with visible effect, formula recalculation after scale (fix weak assertion) - Vector: shear and rotate_around - Rectangle: drawable-level reflect, scale, rotate_around - Circle: y-axis/line reflection, scale from non-origin, negative scale center movement - Ellipse: line reflection with offset, zero scale, negative scale, 180-aligned non-uniform scale, modulo wrap, degenerate line no-op - Manager: line axis, invalid axis, zero coefficients, segment not found, empty segment name, zero/threshold scale factors, invalid shear axis, excluded types (Function/Graph/Angle), no-archive on missing drawable, rotate point/circle exclusion without center, return values, redraw control, segment formula refresh - Invariants: double reflect identity, scale inverse identity, 360 rotation, four 90s rotation, shear inverse identity - Composition: reflect+scale, shear+rotate, scale+reflect circle, rotate+reflect ellipse Fix two weak existing tests found by Codex review: - test_segment_scale_verifies_line_formula: was a no-op assertion - test_composition_shear_then_rotate: shear had no visible effect (dy=0) --- static/client/client_tests/test_transforms.py | 757 ++++++++++++++++++ 1 file changed, 757 insertions(+) diff --git a/static/client/client_tests/test_transforms.py b/static/client/client_tests/test_transforms.py index 5dc23c00..0a2d1f07 100644 --- a/static/client/client_tests/test_transforms.py +++ b/static/client/client_tests/test_transforms.py @@ -531,3 +531,760 @@ def test_manager_no_archive_on_rotated_ellipse_nonuniform_scale(self) -> None: with self.assertRaises(ValueError): mgr.scale_object(e.name, 2, 3, 0, 0) self.assertFalse(canvas.undo_redo_manager.archive.calls) + + # =================================================================== + # Additional edge-case tests + # =================================================================== + + # --------------------------------------------------------------- + # Point: reflect across general line with non-zero c + # --------------------------------------------------------------- + def test_point_reflect_line_with_offset(self) -> None: + """Reflect across 0x + 1y - 2 = 0 (the line y = 2).""" + p = Point(5, 0, name="P") + p.reflect("line", a=0, b=1, c=-2) + self.assertTrue(_approx(p.x, 5)) + self.assertTrue(_approx(p.y, 4)) + + def test_point_reflect_general_line(self) -> None: + """Reflect (1, 1) across 1x + 1y + 0 = 0 (the line x + y = 0).""" + p = Point(1, 1, name="P") + p.reflect("line", a=1, b=1, c=0) + self.assertTrue(_approx(p.x, -1)) + self.assertTrue(_approx(p.y, -1)) + + # --------------------------------------------------------------- + # Point: identity transforms + # --------------------------------------------------------------- + def test_point_scale_identity(self) -> None: + """Scale by 1 from any center is a no-op.""" + p = Point(7, -3, name="P") + p.scale(1, 1, 99, 99) + self.assertTrue(_approx(p.x, 7)) + self.assertTrue(_approx(p.y, -3)) + + def test_point_shear_zero_factor(self) -> None: + """Shear with factor 0 is a no-op.""" + p = Point(4, 5, name="P") + p.shear("horizontal", 0, 0, 0) + self.assertTrue(_approx(p.x, 4)) + self.assertTrue(_approx(p.y, 5)) + + def test_point_rotate_360_identity(self) -> None: + """Full rotation returns to the original position.""" + p = Point(3, 7, name="P") + p.rotate_around(360, 1, 1) + self.assertTrue(_approx(p.x, 3)) + self.assertTrue(_approx(p.y, 7)) + + def test_point_rotate_negative_angle(self) -> None: + """Negative angle rotates clockwise.""" + p = Point(0, 1, name="P") + p.rotate_around(-90, 0, 0) + self.assertTrue(_approx(p.x, 1)) + self.assertTrue(_approx(p.y, 0)) + + def test_point_scale_negative_mirror(self) -> None: + """Negative scale factor mirrors through center.""" + p = Point(3, 4, name="P") + p.scale(-1, -1, 0, 0) + self.assertTrue(_approx(p.x, -3)) + self.assertTrue(_approx(p.y, -4)) + + # --------------------------------------------------------------- + # Point: shear from non-origin center + # --------------------------------------------------------------- + def test_point_shear_horizontal_nonorigin_center(self) -> None: + """Shear horizontally from a center that is not the origin.""" + p = Point(3, 5, name="P") + p.shear("horizontal", 1.0, 1, 2) + # dx = 3-1 = 2, dy = 5-2 = 3 => new x = 1 + 2 + 1*3 = 6, y = 2 + 3 = 5 + self.assertTrue(_approx(p.x, 6)) + self.assertTrue(_approx(p.y, 5)) + + def test_point_shear_vertical_nonorigin_center(self) -> None: + """Shear vertically from a center that is not the origin.""" + p = Point(5, 1, name="P") + p.shear("vertical", 2.0, 2, 0) + # dx = 5-2 = 3, dy = 1-0 = 1 => x = 2+3 = 5, y = 0 + 1 + 2*3 = 7 + self.assertTrue(_approx(p.x, 5)) + self.assertTrue(_approx(p.y, 7)) + + # --------------------------------------------------------------- + # Segment: reflect across y_axis and arbitrary line + # --------------------------------------------------------------- + def test_segment_reflect_y_axis(self) -> None: + p1 = Point(1, 2, name="A") + p2 = Point(3, 4, name="B") + s = Segment(p1, p2) + s.reflect("y_axis") + self.assertTrue(_approx(p1.x, -1)) + self.assertTrue(_approx(p2.x, -3)) + self.assertTrue(_approx(p1.y, 2)) + self.assertTrue(_approx(p2.y, 4)) + + def test_segment_reflect_arbitrary_line(self) -> None: + """Reflect a segment across y = x.""" + p1 = Point(0, 2, name="A") + p2 = Point(4, 0, name="B") + s = Segment(p1, p2) + s.reflect("line", a=1, b=-1, c=0) + self.assertTrue(_approx(p1.x, 2)) + self.assertTrue(_approx(p1.y, 0)) + self.assertTrue(_approx(p2.x, 0)) + self.assertTrue(_approx(p2.y, 4)) + + def test_segment_shear_with_nonzero_dy(self) -> None: + """Shear where dy != 0 so shear has a visible effect.""" + p1 = Point(0, 0, name="A") + p2 = Point(2, 3, name="B") + s = Segment(p1, p2) + s.shear("horizontal", 1, 0, 0) + # p1 unchanged (dy=0), p2: x = 2 + 1*3 = 5, y = 3 + self.assertTrue(_approx(p1.x, 0)) + self.assertTrue(_approx(p2.x, 5)) + self.assertTrue(_approx(p2.y, 3)) + + def test_segment_scale_changes_line_formula(self) -> None: + """Scaling a non-origin segment should change its line_formula.""" + p1 = Point(1, 0, name="A") + p2 = Point(1, 2, name="B") + s = Segment(p1, p2) + formula_before = s.line_formula + s.scale(2, 1, 0, 0) + # After scale: A=(2,0), B=(2,2) — still vertical x=2 but different intercept + self.assertIsNotNone(s.line_formula) + self.assertNotEqual(s.line_formula, formula_before) + + # --------------------------------------------------------------- + # Vector: shear and rotate_around + # --------------------------------------------------------------- + def test_vector_shear(self) -> None: + v = Vector(Point(0, 0, name="O"), Point(2, 3, name="T")) + v.shear("horizontal", 1, 0, 0) + self.assertTrue(_approx(v.tip.x, 5)) # 2 + 1*3 + self.assertTrue(_approx(v.tip.y, 3)) + + def test_vector_rotate_around(self) -> None: + v = Vector(Point(1, 0, name="O"), Point(3, 0, name="T")) + v.rotate_around(90, 0, 0) + self.assertTrue(_approx(v.origin.x, 0)) + self.assertTrue(_approx(v.origin.y, 1)) + self.assertTrue(_approx(v.tip.x, 0)) + self.assertTrue(_approx(v.tip.y, 3)) + + # --------------------------------------------------------------- + # Rectangle: drawable-level transforms + # --------------------------------------------------------------- + def test_rectangle_reflect_y_axis(self) -> None: + p1 = Point(1, 0, name="A") + p2 = Point(5, 0, name="B") + p3 = Point(5, 3, name="C") + p4 = Point(1, 3, name="D") + s1, s2, s3, s4 = Segment(p1, p2), Segment(p2, p3), Segment(p3, p4), Segment(p4, p1) + rect = Rectangle(s1, s2, s3, s4) + rect.reflect("y_axis") + self.assertTrue(_approx(p1.x, -1)) + self.assertTrue(_approx(p2.x, -5)) + self.assertTrue(_approx(p3.x, -5)) + self.assertTrue(_approx(p4.x, -1)) + + def test_rectangle_scale_nonuniform(self) -> None: + p1 = Point(0, 0, name="A") + p2 = Point(4, 0, name="B") + p3 = Point(4, 2, name="C") + p4 = Point(0, 2, name="D") + s1, s2, s3, s4 = Segment(p1, p2), Segment(p2, p3), Segment(p3, p4), Segment(p4, p1) + rect = Rectangle(s1, s2, s3, s4) + rect.scale(2, 3, 0, 0) + self.assertTrue(_approx(p2.x, 8)) + self.assertTrue(_approx(p3.y, 6)) + + def test_rectangle_rotate_around(self) -> None: + p1 = Point(0, 0, name="A") + p2 = Point(2, 0, name="B") + p3 = Point(2, 1, name="C") + p4 = Point(0, 1, name="D") + s1, s2, s3, s4 = Segment(p1, p2), Segment(p2, p3), Segment(p3, p4), Segment(p4, p1) + rect = Rectangle(s1, s2, s3, s4) + rect.rotate_around(180, 1, 0.5) + # Each vertex should be mirrored through center (1, 0.5) + self.assertTrue(_approx(p1.x, 2, tol=1e-9)) + self.assertTrue(_approx(p1.y, 1, tol=1e-9)) + self.assertTrue(_approx(p2.x, 0, tol=1e-9)) + self.assertTrue(_approx(p2.y, 1, tol=1e-9)) + + # --------------------------------------------------------------- + # Circle: additional reflect axes + # --------------------------------------------------------------- + def test_circle_reflect_y_axis(self) -> None: + c = Circle(Point(3, 4, name="C"), 5) + c.reflect("y_axis") + self.assertTrue(_approx(c.center.x, -3)) + self.assertTrue(_approx(c.center.y, 4)) + self.assertTrue(_approx(c.radius, 5)) + + def test_circle_reflect_line(self) -> None: + """Reflect circle across the line y = x.""" + c = Circle(Point(3, 1, name="C"), 2) + c.reflect("line", a=1, b=-1, c=0) + self.assertTrue(_approx(c.center.x, 1)) + self.assertTrue(_approx(c.center.y, 3)) + self.assertTrue(_approx(c.radius, 2)) + + def test_circle_scale_from_nonorigin(self) -> None: + """Scale from a center that is not the origin.""" + c = Circle(Point(4, 0, name="C"), 3) + c.scale(2, 2, 2, 0) + # center: 2 + 2*(4-2) = 6; radius: 3*2 = 6 + self.assertTrue(_approx(c.center.x, 6)) + self.assertTrue(_approx(c.radius, 6)) + + # --------------------------------------------------------------- + # Ellipse: additional edges + # --------------------------------------------------------------- + def test_ellipse_reflect_line(self) -> None: + """Reflect across y = x (a=1, b=-1, c=0).""" + e = Ellipse(Point(2, 0, name="E"), 5, 3, rotation_angle=0) + e.reflect("line", a=1, b=-1, c=0) + # Center: (2,0) -> (0,2) + self.assertTrue(_approx(e.center.x, 0)) + self.assertTrue(_approx(e.center.y, 2)) + # line angle = atan2(-1, -1) = -135 deg -> mapped to 225 or we just verify it changed + # For rotation_angle=0, new = (2*line_angle - 0) % 360 + line_angle_deg = math.degrees(math.atan2(-1, -1)) + expected_rot = (2 * line_angle_deg - 0) % 360 + self.assertTrue(_approx(e.rotation_angle, expected_rot)) + + def test_ellipse_scale_zero_raises(self) -> None: + e = Ellipse(Point(0, 0, name="E"), 4, 2) + with self.assertRaises(ValueError): + e.scale(0, 0, 0, 0) + + def test_ellipse_scale_negative_uniform(self) -> None: + """Negative uniform factor should use abs for radii.""" + e = Ellipse(Point(1, 1, name="E"), 4, 2) + e.scale(-2, -2, 0, 0) + self.assertTrue(_approx(e.radius_x, 8)) + self.assertTrue(_approx(e.radius_y, 4)) + + def test_ellipse_scale_nonuniform_on_180_aligned(self) -> None: + """rotation_angle=180 is axis-aligned (180 % 180 == 0), should succeed.""" + e = Ellipse(Point(0, 0, name="E"), 4, 2, rotation_angle=180) + e.scale(2, 3, 0, 0) + self.assertTrue(_approx(e.radius_x, 8)) + self.assertTrue(_approx(e.radius_y, 6)) + + def test_ellipse_rotate_around_accumulates(self) -> None: + """Two successive rotations should accumulate correctly.""" + e = Ellipse(Point(2, 0, name="E"), 4, 2, rotation_angle=10) + e.rotate_around(45, 0, 0) + e.rotate_around(45, 0, 0) + self.assertTrue(_approx(e.rotation_angle, 100)) + + def test_ellipse_reflect_degenerate_line(self) -> None: + """Degenerate line coefficients should leave rotation_angle unchanged.""" + e = Ellipse(Point(0, 0, name="E"), 4, 2, rotation_angle=30) + e.reflect("line", a=0, b=0, c=0) + # center unchanged (degenerate), rotation_angle unchanged + self.assertTrue(_approx(e.rotation_angle, 30)) + + # --------------------------------------------------------------- + # Manager: reflect_object with axis='line' + # --------------------------------------------------------------- + def test_manager_reflect_line_axis(self) -> None: + """Reflect a point across y = 0 via line coefficients (0x + 1y + 0 = 0).""" + p = Point(3, 4, name="P") + canvas, _, _ = _build_canvas(p, []) + mgr = TransformationsManager(canvas) + mgr.reflect_object("P", "line", line_a=0, line_b=1, line_c=0) + self.assertTrue(_approx(p.x, 3)) + self.assertTrue(_approx(p.y, -4)) + + def test_manager_reflect_invalid_axis_raises(self) -> None: + p = Point(0, 0, name="P") + canvas, _, _ = _build_canvas(p, []) + mgr = TransformationsManager(canvas) + with self.assertRaises(ValueError): + mgr.reflect_object("P", "diagonal") + + def test_manager_reflect_line_zero_coefficients_raises(self) -> None: + """axis='line' with a=b=0 is degenerate and should raise.""" + p = Point(0, 0, name="P") + canvas, _, _ = _build_canvas(p, []) + mgr = TransformationsManager(canvas) + with self.assertRaises(ValueError): + mgr.reflect_object("P", "line", line_a=0, line_b=0, line_c=0) + + def test_manager_reflect_segment_not_found_raises(self) -> None: + p = Point(0, 0, name="P") + canvas, _, _ = _build_canvas(p, []) + mgr = TransformationsManager(canvas) + with self.assertRaises(ValueError): + mgr.reflect_object("P", "segment", segment_name="NONEXISTENT") + + def test_manager_reflect_empty_segment_name_raises(self) -> None: + p = Point(0, 0, name="P") + canvas, _, _ = _build_canvas(p, []) + mgr = TransformationsManager(canvas) + with self.assertRaises(ValueError): + mgr.reflect_object("P", "segment", segment_name="") + + # --------------------------------------------------------------- + # Manager: scale_object edge cases + # --------------------------------------------------------------- + def test_manager_scale_zero_factor_raises(self) -> None: + p = Point(1, 1, name="P") + canvas, _, _ = _build_canvas(p, []) + mgr = TransformationsManager(canvas) + with self.assertRaises(ValueError): + mgr.scale_object("P", 0, 0, 0, 0) + + def test_manager_scale_one_zero_factor_raises(self) -> None: + """Even if only one factor is zero, it should raise.""" + p = Point(1, 1, name="P") + canvas, _, _ = _build_canvas(p, []) + mgr = TransformationsManager(canvas) + with self.assertRaises(ValueError): + mgr.scale_object("P", 2, 0, 0, 0) + + # --------------------------------------------------------------- + # Manager: shear_object edge cases + # --------------------------------------------------------------- + def test_manager_shear_invalid_axis_raises(self) -> None: + p = Point(0, 0, name="P") + canvas, _, _ = _build_canvas(p, []) + mgr = TransformationsManager(canvas) + with self.assertRaises(ValueError): + mgr.shear_object("P", "diagonal", 1, 0, 0) + + def test_manager_shear_vertical(self) -> None: + """Shear a point vertically via manager.""" + p = Point(3, 1, name="P") + canvas, _, _ = _build_canvas(p, []) + mgr = TransformationsManager(canvas) + mgr.shear_object("P", "vertical", 2, 0, 0) + # y = 0 + (1-0) + 2*(3-0) = 7 + self.assertTrue(_approx(p.y, 7)) + + # --------------------------------------------------------------- + # Manager: rotate_object default (no center) + # --------------------------------------------------------------- + def test_manager_rotate_segment_no_center(self) -> None: + """Rotate a segment around its own center (original behaviour).""" + p1 = Point(0, 0, name="A") + p2 = Point(4, 0, name="B") + s = Segment(p1, p2) + canvas, _, _ = _build_canvas(s, [s]) + mgr = TransformationsManager(canvas) + mgr.rotate_object(s.name, 90) + # Segment rotates around midpoint (2, 0); endpoints move + self.assertTrue(_approx(p1.x, 2, tol=1e-6)) + self.assertTrue(_approx(p1.y, -2, tol=1e-6)) + + def test_manager_rotate_circle_around_center(self) -> None: + """Circle can rotate around arbitrary center at manager level.""" + c = Circle(Point(3, 0, name="C"), 2) + canvas, _, renderer = _build_canvas(c, []) + mgr = TransformationsManager(canvas) + mgr.rotate_object(c.name, 90, center_x=0, center_y=0) + self.assertTrue(_approx(c.center.x, 0)) + self.assertTrue(_approx(c.center.y, 3)) + self.assertTrue(_approx(c.radius, 2)) + + # --------------------------------------------------------------- + # Manager: reflect on circle and ellipse + # --------------------------------------------------------------- + def test_manager_reflect_circle(self) -> None: + c = Circle(Point(3, 4, name="C"), 5) + canvas, _, renderer = _build_canvas(c, []) + mgr = TransformationsManager(canvas) + mgr.reflect_object(c.name, "y_axis") + self.assertTrue(_approx(c.center.x, -3)) + self.assertTrue(_approx(c.radius, 5)) + self.assertTrue(renderer.invalidate_drawable_cache.calls) + + def test_manager_reflect_ellipse(self) -> None: + e = Ellipse(Point(2, 0, name="E"), 5, 3, rotation_angle=30) + canvas, _, renderer = _build_canvas(e, []) + mgr = TransformationsManager(canvas) + mgr.reflect_object(e.name, "x_axis") + self.assertTrue(_approx(e.rotation_angle, 330)) + self.assertTrue(renderer.invalidate_drawable_cache.calls) + + def test_manager_scale_ellipse_uniform(self) -> None: + e = Ellipse(Point(1, 1, name="E"), 4, 2) + canvas, _, renderer = _build_canvas(e, []) + mgr = TransformationsManager(canvas) + mgr.scale_object(e.name, 3, 3, 0, 0) + self.assertTrue(_approx(e.radius_x, 12)) + self.assertTrue(_approx(e.radius_y, 6)) + + # --------------------------------------------------------------- + # Manager: excluded types properly rejected + # --------------------------------------------------------------- + def test_manager_excluded_type_raises(self) -> None: + """Transform on an excluded type (e.g. Function) should raise ValueError.""" + func_mock = SimpleMock() + func_mock.name = "f1" + func_mock.get_class_name = SimpleMock(return_value="Function") + canvas, _, _ = _build_canvas(func_mock, []) + mgr = TransformationsManager(canvas) + with self.assertRaises(ValueError): + mgr.reflect_object("f1", "x_axis") + + def test_manager_excluded_type_scale_raises(self) -> None: + graph_mock = SimpleMock() + graph_mock.name = "G1" + graph_mock.get_class_name = SimpleMock(return_value="Graph") + canvas, _, _ = _build_canvas(graph_mock, []) + mgr = TransformationsManager(canvas) + with self.assertRaises(ValueError): + mgr.scale_object("G1", 2, 2, 0, 0) + + def test_manager_excluded_type_shear_raises(self) -> None: + angle_mock = SimpleMock() + angle_mock.name = "ang1" + angle_mock.get_class_name = SimpleMock(return_value="Angle") + canvas, _, _ = _build_canvas(angle_mock, []) + mgr = TransformationsManager(canvas) + with self.assertRaises(ValueError): + mgr.shear_object("ang1", "horizontal", 1, 0, 0) + + # --------------------------------------------------------------- + # Manager: no archive on missing drawable + # --------------------------------------------------------------- + def test_manager_no_archive_on_missing_reflect(self) -> None: + p = Point(0, 0, name="P") + canvas, _, _ = _build_canvas(p, []) + mgr = TransformationsManager(canvas) + with self.assertRaises(ValueError): + mgr.reflect_object("GONE", "x_axis") + self.assertFalse(canvas.undo_redo_manager.archive.calls) + + def test_manager_no_archive_on_missing_scale(self) -> None: + p = Point(0, 0, name="P") + canvas, _, _ = _build_canvas(p, []) + mgr = TransformationsManager(canvas) + with self.assertRaises(ValueError): + mgr.scale_object("GONE", 2, 2, 0, 0) + self.assertFalse(canvas.undo_redo_manager.archive.calls) + + def test_manager_no_archive_on_missing_shear(self) -> None: + p = Point(0, 0, name="P") + canvas, _, _ = _build_canvas(p, []) + mgr = TransformationsManager(canvas) + with self.assertRaises(ValueError): + mgr.shear_object("GONE", "horizontal", 1, 0, 0) + self.assertFalse(canvas.undo_redo_manager.archive.calls) + + # --------------------------------------------------------------- + # Mathematical invariants: double-reflect, scale+inverse, 360 + # --------------------------------------------------------------- + def test_invariant_double_reflect_x_identity(self) -> None: + """Reflecting across x-axis twice returns to original.""" + p = Point(3, 7, name="P") + p.reflect("x_axis") + p.reflect("x_axis") + self.assertTrue(_approx(p.x, 3)) + self.assertTrue(_approx(p.y, 7)) + + def test_invariant_double_reflect_y_identity(self) -> None: + p = Point(-2, 5, name="P") + p.reflect("y_axis") + p.reflect("y_axis") + self.assertTrue(_approx(p.x, -2)) + self.assertTrue(_approx(p.y, 5)) + + def test_invariant_double_reflect_line_identity(self) -> None: + """Reflecting across an arbitrary line twice returns to original.""" + p = Point(3, 7, name="P") + p.reflect("line", a=2, b=-3, c=1) + p.reflect("line", a=2, b=-3, c=1) + self.assertTrue(_approx(p.x, 3)) + self.assertTrue(_approx(p.y, 7)) + + def test_invariant_scale_inverse_identity(self) -> None: + """Scale by k then by 1/k returns to original.""" + p = Point(5, -3, name="P") + p.scale(3, 2, 1, 1) + p.scale(1 / 3, 1 / 2, 1, 1) + self.assertTrue(_approx(p.x, 5)) + self.assertTrue(_approx(p.y, -3)) + + def test_invariant_rotate_360_identity(self) -> None: + """Rotating 360 degrees returns to original.""" + p = Point(4, 5, name="P") + p.rotate_around(360, 2, 3) + self.assertTrue(_approx(p.x, 4)) + self.assertTrue(_approx(p.y, 5)) + + def test_invariant_rotate_four_90s_identity(self) -> None: + """Four 90-degree rotations return to original.""" + p = Point(4, 5, name="P") + for _ in range(4): + p.rotate_around(90, 2, 3) + self.assertTrue(_approx(p.x, 4)) + self.assertTrue(_approx(p.y, 5)) + + def test_invariant_shear_inverse_identity(self) -> None: + """Shear by k then by -k returns to original.""" + p = Point(3, 7, name="P") + p.shear("horizontal", 2.5, 1, 1) + p.shear("horizontal", -2.5, 1, 1) + self.assertTrue(_approx(p.x, 3)) + self.assertTrue(_approx(p.y, 7)) + + # --------------------------------------------------------------- + # Composition: sequential transforms on a triangle + # --------------------------------------------------------------- + def test_composition_reflect_then_scale(self) -> None: + """Reflect across x-axis then scale by 2 from origin.""" + p1 = Point(0, 0, name="A") + p2 = Point(3, 0, name="B") + p3 = Point(0, 4, name="C") + s1, s2, s3 = Segment(p1, p2), Segment(p2, p3), Segment(p3, p1) + tri = Triangle(s1, s2, s3) + tri.reflect("x_axis") + tri.scale(2, 2, 0, 0) + # C: (0,4) -> (0,-4) -> (0,-8) + self.assertTrue(_approx(p3.x, 0)) + self.assertTrue(_approx(p3.y, -8)) + # B: (3,0) -> (3,0) -> (6,0) + self.assertTrue(_approx(p2.x, 6)) + + def test_composition_shear_then_rotate(self) -> None: + """Shear a point (with non-zero dy) then rotate it.""" + p = Point(1, 2, name="P") + p.shear("horizontal", 1, 0, 0) # x = 1 + 1*2 = 3, y = 2 + self.assertTrue(_approx(p.x, 3)) + self.assertTrue(_approx(p.y, 2)) + p.rotate_around(90, 0, 0) # (3, 2) -> (-2, 3) + self.assertTrue(_approx(p.x, -2)) + self.assertTrue(_approx(p.y, 3)) + + def test_composition_scale_circle_then_reflect(self) -> None: + """Scale a circle then reflect it.""" + c = Circle(Point(2, 3, name="C"), 5) + c.scale(2, 2, 0, 0) + c.reflect("x_axis") + self.assertTrue(_approx(c.center.x, 4)) + self.assertTrue(_approx(c.center.y, -6)) + self.assertTrue(_approx(c.radius, 10)) + + def test_composition_ellipse_rotate_then_reflect(self) -> None: + """Rotate an ellipse around external center, then reflect across y-axis.""" + e = Ellipse(Point(3, 0, name="E"), 5, 2, rotation_angle=0) + e.rotate_around(90, 0, 0) + # center: (0, 3), rotation_angle: 90 + self.assertTrue(_approx(e.center.x, 0)) + self.assertTrue(_approx(e.center.y, 3)) + self.assertTrue(_approx(e.rotation_angle, 90)) + e.reflect("y_axis") + # center: (0, 3), rotation_angle: (180 - 90) % 360 = 90 + self.assertTrue(_approx(e.center.x, 0)) + self.assertTrue(_approx(e.rotation_angle, 90)) + + # --------------------------------------------------------------- + # Manager: redraw called on success + # --------------------------------------------------------------- + def test_manager_redraw_called_when_enabled(self) -> None: + """When draw_enabled is True, draw() should be called after transform.""" + p = Point(1, 1, name="P") + canvas, _, _ = _build_canvas(p, []) + canvas.draw_enabled = True + mgr = TransformationsManager(canvas) + mgr.reflect_object("P", "x_axis") + self.assertTrue(canvas.draw.calls) + + def test_manager_no_redraw_when_disabled(self) -> None: + """When draw_enabled is False, draw() should not be called.""" + p = Point(1, 1, name="P") + canvas, _, _ = _build_canvas(p, []) + canvas.draw_enabled = False + mgr = TransformationsManager(canvas) + mgr.reflect_object("P", "x_axis") + self.assertFalse(canvas.draw.calls) + + # --------------------------------------------------------------- + # Manager: segment formula refresh after polygon transform + # --------------------------------------------------------------- + def test_manager_segment_formulas_refreshed_after_reflect(self) -> None: + """Reflecting a triangle should recalculate its segment line formulas.""" + p1 = Point(0, 0, name="A") + p2 = Point(4, 0, name="B") + p3 = Point(2, 3, name="C") + s1, s2, s3 = Segment(p1, p2), Segment(p2, p3), Segment(p3, p1) + tri = Triangle(s1, s2, s3) + formula_before = s2.line_formula + + canvas, dep_mgr, _ = _build_canvas(tri, [s1, s2, s3]) + # Register segments as children of triangle for dependency refresh + for seg in [s1, s2, s3]: + dep_mgr.register(tri, seg) + + mgr = TransformationsManager(canvas) + mgr.reflect_object(tri.name, "x_axis") + + # p3 moved to (2, -3), so s2 (B-C) formula must have changed + self.assertNotEqual(s2.line_formula, formula_before) + + # =================================================================== + # Codex-review edge cases + # =================================================================== + + # --------------------------------------------------------------- + # Circle: negative scale also moves center + # --------------------------------------------------------------- + def test_circle_scale_negative_moves_center(self) -> None: + """Negative uniform scale mirrors center through scaling center.""" + c = Circle(Point(4, 1, name="C"), 3) + c.scale(-2, -2, 1, 1) + # center: 1 + (-2)*(4-1) = -5, 1 + (-2)*(1-1) = 1 + self.assertTrue(_approx(c.center.x, -5)) + self.assertTrue(_approx(c.center.y, 1)) + self.assertTrue(_approx(c.radius, 6)) + + # --------------------------------------------------------------- + # Ellipse: rotation_angle modulo wrap at 360 boundary + # --------------------------------------------------------------- + def test_ellipse_rotate_around_wraps_modulo(self) -> None: + """350 + 20 = 370 should wrap to 10.""" + e = Ellipse(Point(2, 3, name="E"), 4, 2, rotation_angle=350) + e.rotate_around(20, 2, 3) + # Center unchanged (rotating around self), angle wraps + self.assertTrue(_approx(e.center.x, 2)) + self.assertTrue(_approx(e.center.y, 3)) + self.assertTrue(_approx(e.rotation_angle, 10)) + + # --------------------------------------------------------------- + # Ellipse: reflection across offset line (non-zero c) + # --------------------------------------------------------------- + def test_ellipse_reflect_offset_line(self) -> None: + """Reflect ellipse across x - y + 1 = 0 (a=1, b=-1, c=1).""" + e = Ellipse(Point(2, 0, name="E"), 5, 3, rotation_angle=30) + e.reflect("line", a=1, b=-1, c=1) + # Center reflection: dot = 1*2 + (-1)*0 + 1 = 3; denom = 2 + # new_x = 2 - 2*1*3/2 = -1, new_y = 0 - 2*(-1)*3/2 = 3 + self.assertTrue(_approx(e.center.x, -1)) + self.assertTrue(_approx(e.center.y, 3)) + # line_angle_deg = atan2(-1, -1) = -135 degrees + # new rotation = (2*(-135) - 30) % 360 = (-300) % 360 = 60 + self.assertTrue(_approx(e.rotation_angle, 60)) + + # --------------------------------------------------------------- + # Point on reflection axis is a fixed point + # --------------------------------------------------------------- + def test_point_on_segment_axis_is_fixed(self) -> None: + """A point lying on the reflection segment should not move.""" + # Segment from (0,0) to (2,1) defines line y = x/2 => 1x - 2y + 0 = 0 + sp1 = Point(0, 0, name="S1") + sp2 = Point(2, 1, name="S2") + seg = Segment(sp1, sp2) + # Point on the same line + p = Point(4, 2, name="P") + + canvas, _, _ = _build_canvas(p, [], extra_drawables=[seg]) + mgr = TransformationsManager(canvas) + mgr.reflect_object("P", "segment", segment_name=seg.name) + + self.assertTrue(_approx(p.x, 4)) + self.assertTrue(_approx(p.y, 2)) + + # --------------------------------------------------------------- + # Scale factor threshold boundary (1e-18) + # --------------------------------------------------------------- + def test_manager_scale_just_below_threshold_raises(self) -> None: + """Scale factor abs < 1e-18 should raise.""" + p = Point(2, 3, name="P") + canvas, _, _ = _build_canvas(p, []) + mgr = TransformationsManager(canvas) + with self.assertRaises(ValueError): + mgr.scale_object("P", 1e-19, 1, 0, 0) + self.assertFalse(canvas.undo_redo_manager.archive.calls) + + def test_manager_scale_at_threshold_succeeds(self) -> None: + """Scale factor abs == 1e-18 should not raise (boundary is strict <).""" + p = Point(2, 3, name="P") + canvas, _, _ = _build_canvas(p, []) + mgr = TransformationsManager(canvas) + # 1e-18 is exactly at the boundary — abs(1e-18) < 1e-18 is False + mgr.scale_object("P", 1e-18, 1e-18, 0, 0) + # Should succeed (extreme but valid) + self.assertTrue(canvas.undo_redo_manager.archive.calls) + + # --------------------------------------------------------------- + # Point: unknown axis silently no-ops + # --------------------------------------------------------------- + def test_point_reflect_unknown_axis_no_op(self) -> None: + """An unrecognised axis string should not move the point.""" + p = Point(3, 4, name="P") + p.reflect("unknown_axis") + self.assertTrue(_approx(p.x, 3)) + self.assertTrue(_approx(p.y, 4)) + + def test_point_shear_unknown_axis_no_op(self) -> None: + """An unrecognised shear axis should not move the point.""" + p = Point(3, 4, name="P") + p.shear("diagonal", 1, 0, 0) + self.assertTrue(_approx(p.x, 3)) + self.assertTrue(_approx(p.y, 4)) + + # --------------------------------------------------------------- + # Manager: rotate_object excludes Point/Circle with no center + # --------------------------------------------------------------- + def test_manager_rotate_point_no_center_raises(self) -> None: + """Rotating a point without center is a no-op exclusion.""" + p = Point(1, 0, name="P") + canvas, _, _ = _build_canvas(p, []) + mgr = TransformationsManager(canvas) + with self.assertRaises(ValueError): + mgr.rotate_object("P", 90) + + def test_manager_rotate_circle_no_center_raises(self) -> None: + """Rotating a circle without center is a no-op exclusion.""" + c = Circle(Point(3, 0, name="C"), 2) + canvas, _, _ = _build_canvas(c, []) + mgr = TransformationsManager(canvas) + with self.assertRaises(ValueError): + mgr.rotate_object(c.name, 45) + + def test_manager_rotate_no_center_no_archive(self) -> None: + """Excluded rotate should not archive (ValueError before archive).""" + p = Point(1, 0, name="P") + canvas, _, _ = _build_canvas(p, []) + mgr = TransformationsManager(canvas) + with self.assertRaises(ValueError): + mgr.rotate_object("P", 90) + self.assertFalse(canvas.undo_redo_manager.archive.calls) + + # --------------------------------------------------------------- + # Manager: return value is True on success + # --------------------------------------------------------------- + def test_manager_reflect_returns_true(self) -> None: + p = Point(0, 0, name="P") + canvas, _, _ = _build_canvas(p, []) + mgr = TransformationsManager(canvas) + result = mgr.reflect_object("P", "x_axis") + self.assertTrue(result) + + def test_manager_scale_returns_true(self) -> None: + p = Point(1, 1, name="P") + canvas, _, _ = _build_canvas(p, []) + mgr = TransformationsManager(canvas) + result = mgr.scale_object("P", 2, 2, 0, 0) + self.assertTrue(result) + + def test_manager_shear_returns_true(self) -> None: + p = Point(1, 1, name="P") + canvas, _, _ = _build_canvas(p, []) + mgr = TransformationsManager(canvas) + result = mgr.shear_object("P", "horizontal", 1, 0, 0) + self.assertTrue(result) + + def test_manager_rotate_returns_true(self) -> None: + p = Point(1, 0, name="P") + canvas, _, _ = _build_canvas(p, []) + mgr = TransformationsManager(canvas) + result = mgr.rotate_object("P", 90, center_x=0, center_y=0) + self.assertTrue(result)