e4dcfc49aa
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled
437 lines
12 KiB
Python
437 lines
12 KiB
Python
"""Coordinate transformation utilities.
|
|
|
|
Converts between BBox pixel coordinates and GeoGebra math coordinates.
|
|
|
|
BBox coordinate system:
|
|
- Origin at top-left
|
|
- X-axis: right is positive
|
|
- Y-axis: down is positive
|
|
|
|
GeoGebra coordinate system:
|
|
- Origin at center (or user-specified)
|
|
- X-axis: right is positive
|
|
- Y-axis: up is positive
|
|
"""
|
|
|
|
from dataclasses import dataclass
|
|
import math
|
|
|
|
|
|
@dataclass
|
|
class ImageDimensions:
|
|
"""Image dimensions."""
|
|
|
|
width: int
|
|
height: int
|
|
|
|
|
|
@dataclass
|
|
class GGBCoordSystem:
|
|
"""GeoGebra coordinate system range."""
|
|
|
|
x_min: float
|
|
x_max: float
|
|
y_min: float
|
|
y_max: float
|
|
|
|
@property
|
|
def width(self) -> float:
|
|
"""Coordinate system width."""
|
|
return self.x_max - self.x_min
|
|
|
|
@property
|
|
def height(self) -> float:
|
|
"""Coordinate system height."""
|
|
return self.y_max - self.y_min
|
|
|
|
@property
|
|
def center(self) -> tuple[float, float]:
|
|
"""Coordinate system center."""
|
|
return ((self.x_min + self.x_max) / 2, (self.y_min + self.y_max) / 2)
|
|
|
|
|
|
@dataclass
|
|
class Point:
|
|
"""2D point."""
|
|
|
|
x: float
|
|
y: float
|
|
|
|
def __repr__(self) -> str:
|
|
return f"({self.x:.2f}, {self.y:.2f})"
|
|
|
|
|
|
# Default configuration
|
|
DEFAULT_GGB_COORD = GGBCoordSystem(x_min=-10, x_max=10, y_min=-8, y_max=8)
|
|
|
|
|
|
def bbox_to_ggb(
|
|
bbox_x: float,
|
|
bbox_y: float,
|
|
img_dimensions: ImageDimensions,
|
|
ggb_coord: GGBCoordSystem | None = None,
|
|
) -> Point:
|
|
"""Convert BBox pixel coordinates to GeoGebra math coordinates.
|
|
|
|
Args:
|
|
bbox_x: BBox X coordinate (pixels)
|
|
bbox_y: BBox Y coordinate (pixels)
|
|
img_dimensions: Image dimensions
|
|
ggb_coord: GeoGebra coordinate range, default [-10, 10] x [-8, 8]
|
|
|
|
Returns:
|
|
Point in GeoGebra coordinate system
|
|
"""
|
|
if ggb_coord is None:
|
|
ggb_coord = DEFAULT_GGB_COORD
|
|
|
|
# Normalize to [0, 1]
|
|
norm_x = bbox_x / img_dimensions.width
|
|
norm_y = bbox_y / img_dimensions.height
|
|
|
|
# Map to GeoGebra coordinates
|
|
# X: direct linear mapping
|
|
ggb_x = ggb_coord.x_min + norm_x * ggb_coord.width
|
|
|
|
# Y: need to flip (BBox Y down, GeoGebra Y up)
|
|
ggb_y = ggb_coord.y_max - norm_y * ggb_coord.height
|
|
|
|
return Point(x=ggb_x, y=ggb_y)
|
|
|
|
|
|
def ggb_to_bbox(
|
|
ggb_x: float,
|
|
ggb_y: float,
|
|
img_dimensions: ImageDimensions,
|
|
ggb_coord: GGBCoordSystem | None = None,
|
|
) -> Point:
|
|
"""Convert GeoGebra math coordinates to BBox pixel coordinates.
|
|
|
|
Args:
|
|
ggb_x: GeoGebra X coordinate
|
|
ggb_y: GeoGebra Y coordinate
|
|
img_dimensions: Image dimensions
|
|
ggb_coord: GeoGebra coordinate range, default [-10, 10] x [-8, 8]
|
|
|
|
Returns:
|
|
Point in BBox pixel coordinate system
|
|
"""
|
|
if ggb_coord is None:
|
|
ggb_coord = DEFAULT_GGB_COORD
|
|
|
|
# Normalize to [0, 1]
|
|
norm_x = (ggb_x - ggb_coord.x_min) / ggb_coord.width
|
|
norm_y = (ggb_coord.y_max - ggb_y) / ggb_coord.height # Y-axis flip
|
|
|
|
# Map to pixel coordinates
|
|
bbox_x = norm_x * img_dimensions.width
|
|
bbox_y = norm_y * img_dimensions.height
|
|
|
|
return Point(x=bbox_x, y=bbox_y)
|
|
|
|
|
|
def convert_bbox_elements_to_ggb(
|
|
bbox_output: dict,
|
|
ggb_coord: GGBCoordSystem | None = None,
|
|
) -> dict:
|
|
"""Batch convert all element coordinates in BBox output.
|
|
|
|
Args:
|
|
bbox_output: BBox node output
|
|
ggb_coord: GeoGebra coordinate range
|
|
|
|
Returns:
|
|
Converted BBox output (with ggb_position fields)
|
|
"""
|
|
if ggb_coord is None:
|
|
ggb_coord = DEFAULT_GGB_COORD
|
|
|
|
# Get image dimensions
|
|
img_dims_data = bbox_output.get("image_dimensions", {})
|
|
img_dimensions = ImageDimensions(
|
|
width=img_dims_data.get("width", 800),
|
|
height=img_dims_data.get("height", 600),
|
|
)
|
|
|
|
# Convert each element
|
|
result = bbox_output.copy()
|
|
converted_elements = []
|
|
|
|
for element in bbox_output.get("elements", []):
|
|
converted = element.copy()
|
|
|
|
# Convert point coordinates
|
|
if "position" in element and element["position"]:
|
|
pos = element["position"]
|
|
ggb_point = bbox_to_ggb(
|
|
pos.get("x", 0),
|
|
pos.get("y", 0),
|
|
img_dimensions,
|
|
ggb_coord,
|
|
)
|
|
converted["ggb_position"] = {"x": ggb_point.x, "y": ggb_point.y}
|
|
|
|
# Convert segment start and end
|
|
if "start" in element and element["start"]:
|
|
start = element["start"]
|
|
ggb_start = bbox_to_ggb(
|
|
start.get("x", 0),
|
|
start.get("y", 0),
|
|
img_dimensions,
|
|
ggb_coord,
|
|
)
|
|
converted["ggb_start"] = {"x": ggb_start.x, "y": ggb_start.y}
|
|
|
|
if "end" in element and element["end"]:
|
|
end = element["end"]
|
|
ggb_end = bbox_to_ggb(
|
|
end.get("x", 0),
|
|
end.get("y", 0),
|
|
img_dimensions,
|
|
ggb_coord,
|
|
)
|
|
converted["ggb_end"] = {"x": ggb_end.x, "y": ggb_end.y}
|
|
|
|
# Convert polygon vertices
|
|
if "vertices" in element and element["vertices"]:
|
|
ggb_vertices = []
|
|
for vertex in element["vertices"]:
|
|
ggb_v = bbox_to_ggb(
|
|
vertex.get("x", 0),
|
|
vertex.get("y", 0),
|
|
img_dimensions,
|
|
ggb_coord,
|
|
)
|
|
ggb_vertices.append({"label": vertex.get("label", ""), "x": ggb_v.x, "y": ggb_v.y})
|
|
converted["ggb_vertices"] = ggb_vertices
|
|
|
|
# Convert circle center
|
|
if "center" in element and element["center"]:
|
|
center = element["center"]
|
|
ggb_center = bbox_to_ggb(
|
|
center.get("x", 0),
|
|
center.get("y", 0),
|
|
img_dimensions,
|
|
ggb_coord,
|
|
)
|
|
converted["ggb_center"] = {"x": ggb_center.x, "y": ggb_center.y}
|
|
|
|
# Convert radius (scale proportionally)
|
|
if "radius" in element:
|
|
pixel_radius = element["radius"]
|
|
scale_x = ggb_coord.width / img_dimensions.width
|
|
converted["ggb_radius"] = pixel_radius * scale_x
|
|
|
|
converted_elements.append(converted)
|
|
|
|
result["elements"] = converted_elements
|
|
return result
|
|
|
|
|
|
def validate_point_in_bounds(
|
|
point: Point,
|
|
ggb_coord: GGBCoordSystem | None = None,
|
|
tolerance: float = 0.1,
|
|
) -> tuple[bool, str]:
|
|
"""Validate if point is within GeoGebra coordinate bounds.
|
|
|
|
Args:
|
|
point: Point to validate
|
|
ggb_coord: Coordinate range
|
|
tolerance: Boundary tolerance
|
|
|
|
Returns:
|
|
(is_valid, error_message)
|
|
"""
|
|
if ggb_coord is None:
|
|
ggb_coord = DEFAULT_GGB_COORD
|
|
|
|
x_valid = ggb_coord.x_min - tolerance <= point.x <= ggb_coord.x_max + tolerance
|
|
y_valid = ggb_coord.y_min - tolerance <= point.y <= ggb_coord.y_max + tolerance
|
|
|
|
if not x_valid:
|
|
return (
|
|
False,
|
|
f"X coordinate {point.x:.2f} out of range [{ggb_coord.x_min}, {ggb_coord.x_max}]",
|
|
)
|
|
if not y_valid:
|
|
return (
|
|
False,
|
|
f"Y coordinate {point.y:.2f} out of range [{ggb_coord.y_min}, {ggb_coord.y_max}]",
|
|
)
|
|
|
|
return True, ""
|
|
|
|
|
|
def calculate_distance(p1: Point, p2: Point) -> float:
|
|
"""Calculate distance between two points."""
|
|
return math.sqrt((p2.x - p1.x) ** 2 + (p2.y - p1.y) ** 2)
|
|
|
|
|
|
def calculate_midpoint(p1: Point, p2: Point) -> Point:
|
|
"""Calculate midpoint of two points."""
|
|
return Point(x=(p1.x + p2.x) / 2, y=(p1.y + p2.y) / 2)
|
|
|
|
|
|
def is_perpendicular(
|
|
p1: Point,
|
|
p2: Point,
|
|
p3: Point,
|
|
p4: Point,
|
|
tolerance: float = 0.01,
|
|
) -> bool:
|
|
"""Check if two segments are perpendicular.
|
|
|
|
Segment 1: p1 -> p2
|
|
Segment 2: p3 -> p4
|
|
"""
|
|
# Direction vectors
|
|
v1 = (p2.x - p1.x, p2.y - p1.y)
|
|
v2 = (p4.x - p3.x, p4.y - p3.y)
|
|
|
|
# Dot product
|
|
dot_product = v1[0] * v2[0] + v1[1] * v2[1]
|
|
|
|
return abs(dot_product) < tolerance
|
|
|
|
|
|
def is_parallel(
|
|
p1: Point,
|
|
p2: Point,
|
|
p3: Point,
|
|
p4: Point,
|
|
tolerance: float = 0.01,
|
|
) -> bool:
|
|
"""Check if two segments are parallel.
|
|
|
|
Segment 1: p1 -> p2
|
|
Segment 2: p3 -> p4
|
|
"""
|
|
# Direction vectors
|
|
v1 = (p2.x - p1.x, p2.y - p1.y)
|
|
v2 = (p4.x - p3.x, p4.y - p3.y)
|
|
|
|
# Cross product (parallel when 0)
|
|
cross_product = v1[0] * v2[1] - v1[1] * v2[0]
|
|
|
|
# Normalize
|
|
len1 = math.sqrt(v1[0] ** 2 + v1[1] ** 2)
|
|
len2 = math.sqrt(v2[0] ** 2 + v2[1] ** 2)
|
|
|
|
if len1 < 1e-10 or len2 < 1e-10:
|
|
return False # Degenerate case
|
|
|
|
normalized_cross = abs(cross_product) / (len1 * len2)
|
|
|
|
return normalized_cross < tolerance
|
|
|
|
|
|
def suggest_coord_system(
|
|
bbox_output: dict,
|
|
padding_ratio: float = 0.2,
|
|
) -> GGBCoordSystem:
|
|
"""Suggest appropriate GeoGebra coordinate range based on BBox output.
|
|
|
|
Args:
|
|
bbox_output: BBox node output
|
|
padding_ratio: Boundary padding ratio
|
|
|
|
Returns:
|
|
Suggested coordinate range
|
|
"""
|
|
# Collect all coordinate points
|
|
all_x: list[float] = []
|
|
all_y: list[float] = []
|
|
|
|
img_dims_data = bbox_output.get("image_dimensions", {})
|
|
img_dimensions = ImageDimensions(
|
|
width=img_dims_data.get("width", 800),
|
|
height=img_dims_data.get("height", 600),
|
|
)
|
|
|
|
for element in bbox_output.get("elements", []):
|
|
if "position" in element and element["position"]:
|
|
all_x.append(element["position"].get("x", 0))
|
|
all_y.append(element["position"].get("y", 0))
|
|
|
|
if "start" in element and element["start"]:
|
|
all_x.append(element["start"].get("x", 0))
|
|
all_y.append(element["start"].get("y", 0))
|
|
|
|
if "end" in element and element["end"]:
|
|
all_x.append(element["end"].get("x", 0))
|
|
all_y.append(element["end"].get("y", 0))
|
|
|
|
if "vertices" in element:
|
|
for v in element["vertices"]:
|
|
all_x.append(v.get("x", 0))
|
|
all_y.append(v.get("y", 0))
|
|
|
|
if "center" in element and element["center"]:
|
|
all_x.append(element["center"].get("x", 0))
|
|
all_y.append(element["center"].get("y", 0))
|
|
|
|
if not all_x or not all_y:
|
|
return DEFAULT_GGB_COORD
|
|
|
|
# Calculate bounds
|
|
min_x, max_x = min(all_x), max(all_x)
|
|
min_y, max_y = min(all_y), max(all_y)
|
|
|
|
# Calculate range
|
|
range_x = max_x - min_x if max_x > min_x else img_dimensions.width
|
|
range_y = max_y - min_y if max_y > min_y else img_dimensions.height
|
|
|
|
# Maintain aspect ratio
|
|
aspect_ratio = img_dimensions.width / img_dimensions.height
|
|
|
|
# Estimate appropriate coordinate range
|
|
ggb_range_x = range_x / img_dimensions.width * 20
|
|
ggb_range_y = range_y / img_dimensions.height * 16
|
|
|
|
# Add padding
|
|
ggb_range_x *= 1 + padding_ratio
|
|
ggb_range_y *= 1 + padding_ratio
|
|
|
|
# Use larger range to ensure complete display
|
|
max_range = max(ggb_range_x, ggb_range_y / aspect_ratio * aspect_ratio)
|
|
|
|
# Ensure minimum range
|
|
max_range = max(max_range, 10)
|
|
|
|
# Center
|
|
half_x = max_range / 2
|
|
half_y = half_x / aspect_ratio
|
|
|
|
return GGBCoordSystem(x_min=-half_x, x_max=half_x, y_min=-half_y, y_max=half_y)
|
|
|
|
|
|
def format_ggb_point(point: Point, name: str = "", decimals: int = 2) -> str:
|
|
"""Format as GeoGebra point definition command.
|
|
|
|
Args:
|
|
point: Point coordinates
|
|
name: Point name (optional)
|
|
decimals: Decimal places
|
|
|
|
Returns:
|
|
GeoGebra command string
|
|
"""
|
|
x_str = f"{point.x:.{decimals}f}"
|
|
y_str = f"{point.y:.{decimals}f}"
|
|
|
|
if name:
|
|
return f"{name} = ({x_str}, {y_str})"
|
|
else:
|
|
return f"({x_str}, {y_str})"
|
|
|
|
|
|
def format_set_coord_system(ggb_coord: GGBCoordSystem, decimals: int = 0) -> str:
|
|
"""Format as SetCoordSystem command."""
|
|
return (
|
|
f"SetCoordSystem[{ggb_coord.x_min:.{decimals}f}, "
|
|
f"{ggb_coord.x_max:.{decimals}f}, "
|
|
f"{ggb_coord.y_min:.{decimals}f}, "
|
|
f"{ggb_coord.y_max:.{decimals}f}]"
|
|
)
|