Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:00:43 +08:00

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}]"
)