diff --git a/documentation/Example Prompts.txt b/documentation/Example Prompts.txt index 92a1026c..12f13331 100644 --- a/documentation/Example Prompts.txt +++ b/documentation/Example Prompts.txt @@ -42,6 +42,18 @@ Plot the parametric curve x(t) = cos(t), y(t) = sin(t), then draw the tangent at Create an ellipse e1 with radii 4 and 2, draw the normal line at angle pi/2. Draw a tangent line to the function f1 at x = 1 with length 6 in blue. +# Geometric Constructions +Create points A(0,0) and B(6,0), draw segment AB, then find the midpoint of AB. +Find the midpoint between points P and Q. +Draw the perpendicular bisector of segment AB. +Drop a perpendicular from point C to segment AB. +Create points A(0,0), B(4,0), C(0,3) and bisect the angle at A defined by B and C. +Draw a line through point P parallel to segment AB. +Construct the perpendicular bisector of segment s1 with length 10 in blue. +Create a triangle with vertices A(0,0), B(4,0), C(0,3) and construct its circumcircle. +Construct the incircle of triangle ABC. +Create points P(0,0), Q(6,0), R(3,5) and draw the circle passing through all three. + # 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 3c29187e..60a81196 100644 --- a/documentation/Reference Manual.txt +++ b/documentation/Reference Manual.txt @@ -306,6 +306,13 @@ Attributes: - `update_parametric_function(name, new_color=None, new_t_min=None, new_t_max=None)`: Update editable properties of an existing parametric function - `draw_tangent_line(curve_name, parameter, name=None, length=4.0, color=None)`: Draw a tangent line segment to a curve at a specified point. For functions y=f(x), parameter is the x-coordinate. For parametric curves, it's the t value. For circles/ellipses, it's the angle in radians from the positive x-axis. - `draw_normal_line(curve_name, parameter, name=None, length=4.0, color=None)`: Draw a normal line segment (perpendicular to tangent) to a curve at a specified point. Same parameter conventions as draw_tangent_line. +- `construct_midpoint(p1_name=None, p2_name=None, segment_name=None, name=None, color=None)`: Construct a point at the midpoint of a segment or between two named points. Provide either `segment_name` or both `p1_name` and `p2_name`. +- `construct_perpendicular_bisector(segment_name, length=6.0, name=None, color=None)`: Construct the perpendicular bisector of a segment. Creates a new segment passing through the midpoint, perpendicular to the original. +- `construct_perpendicular_from_point(point_name, segment_name, name=None, color=None)`: Drop a perpendicular from a point to a segment. Creates the foot point on the line and a segment from the original point to the foot (single undo step). +- `construct_angle_bisector(vertex_name=None, p1_name=None, p2_name=None, angle_name=None, length=6.0, name=None, color=None)`: Construct a segment along the angle bisector. Provide either `angle_name` or all three point names (`vertex_name`, `p1_name`, `p2_name`). +- `construct_parallel_line(segment_name, point_name, length=6.0, name=None, color=None)`: Construct a segment through a point, parallel to a given segment. The new segment is centered on the specified point. +- `construct_circumcircle(triangle_name=None, p1_name=None, p2_name=None, p3_name=None, name=None, color=None)`: Construct the circumscribed circle (circumcircle) of a triangle or three points. The circumcircle passes through all three vertices. Provide either `triangle_name` or all three point names. +- `construct_incircle(triangle_name, name=None, color=None)`: Construct the inscribed circle (incircle) of a triangle. The incircle is tangent to all three sides. - `plot_distribution(name=None, representation="continuous", distribution_type="normal", distribution_params=None, plot_bounds=None, shade_bounds=None, curve_color=None, fill_color=None, fill_opacity=None, bar_count=None)`: Plot a probability distribution. For representation="continuous", `plot_bounds` controls the curve domain, while `shade_bounds` controls the shaded interval under the curve (clamped into `plot_bounds`). For representation="discrete", `plot_bounds` controls the bar span and `shade_bounds` is ignored. Discrete distribution plots create a `DiscretePlot` composite plus derived `Bar` drawables for rendering; derived bars are regenerated on workspace load and may be omitted from serialized canvas state to keep prompts compact. - `plot_bars(name=None, values=None, labels_below=None, labels_above=None, bar_spacing=None, bar_width=None, stroke_color=None, fill_color=None, fill_opacity=None, x_start=None, y_base=None)`: Plot a bar chart (`BarsPlot` composite plus derived `Bar` drawables). Derived bars are regenerated on workspace load and may be omitted from serialized canvas state to keep prompts compact. - `fit_regression(name=None, x_data=None, y_data=None, model_type="linear", degree=None, plot_bounds=None, curve_color=None, show_points=True, point_color=None)`: Fit a regression model to data and plot the resulting curve. Supported model types: "linear" (y=mx+b), "polynomial" (y=a0+a1*x+...+an*x^n, requires `degree`), "exponential" (y=a*e^(bx), requires positive y), "logarithmic" (y=a+b*ln(x), requires positive x), "power" (y=a*x^b, requires positive x and y), "logistic" (y=L/(1+e^(-k(x-x0)))), and "sinusoidal" (y=a*sin(bx+c)+d, requires at least 4 points). Returns function_name, expression, coefficients, r_squared, model_type, bounds, and optionally point_names. Use `delete_function` to remove the curve; delete points individually. @@ -4496,6 +4503,17 @@ Key Features: - `logistic`: Logistic growth (y = L/(1+e^(-k(x-x0)))) - `sinusoidal`: Sinusoidal regression (y = a*sin(bx+c)+d) +#### Construction Manager (`managers/construction_manager.py`) + +Manages geometric constructions (midpoints, perpendicular bisectors, angle bisectors, perpendicular/parallel lines) by computing coordinates and creating standard Point and Segment drawables via existing managers. Constructions produce static snapshots — no reactive re-computation when source objects move. + +**Key Methods:** +- `create_midpoint(p1_name, p2_name, segment_name, name, color)`: Create a point at the midpoint of two points or a segment +- `create_perpendicular_bisector(segment_name, length, name, color)`: Create the perpendicular bisector of a segment +- `create_perpendicular_from_point(point_name, segment_name, name, color)`: Drop a perpendicular from a point to a segment (creates foot point + segment as single undo step) +- `create_angle_bisector(vertex_name, p1_name, p2_name, angle_name, length, name, color)`: Create a segment along the angle bisector +- `create_parallel_line(segment_name, point_name, length, name, color)`: Create a segment through a point parallel to a given segment + #### Polygon Type (`managers/polygon_type.py`) ``` diff --git a/documentation/todo.txt b/documentation/todo.txt index 63cf4cc7..6b7485a4 100644 --- a/documentation/todo.txt +++ b/documentation/todo.txt @@ -21,7 +21,7 @@ - tabular workflow: create/query/update data tables from chat with HUD-linked previews - CSV workflow: import/export datasets through chat commands with validation and schema feedback - plotting suite: scatter/histogram/box plots, polar plots, implicit plots from natural language intents -- geometric construction toolkit: midpoint/perpendicular/parallel/bisectors/circle-through-3-points through declarative commands +- [done] geometric construction toolkit: midpoint/perpendicular/parallel/bisectors/circumcircle/incircle through declarative commands (PR #41 + circumcircle/incircle follow-up) - relation inspector: explain and verify geometric relations (parallel/perpendicular/collinear/concyclic/equal-length) on demand - transform workflows: reflection/dilation/shear/rotation/translation using object references and constraint-aware execution - interaction tools: tracing, root/extrema/intersection discovery, parameter sweeps, and dynamic slider orchestration from chat diff --git a/server_tests/data/tool_discovery_cases.yaml b/server_tests/data/tool_discovery_cases.yaml index d6d8ffe2..891b06a3 100644 --- a/server_tests/data/tool_discovery_cases.yaml +++ b/server_tests/data/tool_discovery_cases.yaml @@ -2,8 +2,8 @@ "metadata": { "schema_version": 1, "description": "Tool discovery benchmark cases for search_tools semantic routing.", - "expected_tool_count": 79, - "expected_tool_hash": "ae51bab482526d31320b100c85238a6db4ab9aa5c6c6a9037494d4c0686a6675", + "expected_tool_count": 87, + "expected_tool_hash": "932b5456edb8acedd97213b4f7fc186b483f1439c437f04fe63f7e3ad0395462", "confusion_clusters": { "convert_cluster": [ "convert", diff --git a/static/client/canvas.py b/static/client/canvas.py index a29cc853..ba8c29de 100644 --- a/static/client/canvas.py +++ b/static/client/canvas.py @@ -1436,6 +1436,108 @@ def create_normal_line( curve_name, parameter, name=name, length=length, color=color ) + # ------------------- Construction Methods ------------------- + + def create_midpoint( + self, + p1_name: Optional[str] = None, + p2_name: Optional[str] = None, + *, + segment_name: Optional[str] = None, + name: Optional[str] = None, + color: Optional[str] = None, + ) -> "Drawable": + """Create a point at the midpoint of two points or a segment.""" + return self.drawable_manager.create_midpoint( + p1_name, p2_name, segment_name=segment_name, name=name, color=color + ) + + def create_perpendicular_bisector( + self, + segment_name: str, + *, + length: Optional[float] = None, + name: Optional[str] = None, + color: Optional[str] = None, + ) -> "Drawable": + """Create the perpendicular bisector of a segment.""" + return self.drawable_manager.create_perpendicular_bisector( + segment_name, length=length, name=name, color=color + ) + + def create_perpendicular_from_point( + self, + point_name: str, + segment_name: str, + *, + name: Optional[str] = None, + color: Optional[str] = None, + ) -> Dict[str, Any]: + """Drop a perpendicular from a point to a segment.""" + return self.drawable_manager.create_perpendicular_from_point( + point_name, segment_name, name=name, color=color + ) + + def create_angle_bisector( + self, + vertex_name: Optional[str] = None, + p1_name: Optional[str] = None, + p2_name: Optional[str] = None, + *, + angle_name: Optional[str] = None, + length: Optional[float] = None, + name: Optional[str] = None, + color: Optional[str] = None, + ) -> "Drawable": + """Create a segment along the bisector of an angle.""" + return self.drawable_manager.create_angle_bisector( + vertex_name, p1_name, p2_name, + angle_name=angle_name, length=length, name=name, color=color + ) + + def create_parallel_line( + self, + segment_name: str, + point_name: str, + *, + length: Optional[float] = None, + name: Optional[str] = None, + color: Optional[str] = None, + ) -> "Drawable": + """Create a segment through a point, parallel to a given segment.""" + return self.drawable_manager.create_parallel_line( + segment_name, point_name, length=length, name=name, color=color + ) + + def create_circumcircle( + self, + *, + triangle_name: Optional[str] = None, + p1_name: Optional[str] = None, + p2_name: Optional[str] = None, + p3_name: Optional[str] = None, + name: Optional[str] = None, + color: Optional[str] = None, + ) -> "Drawable": + """Create the circumscribed circle of a triangle or three points.""" + return self.drawable_manager.create_circumcircle( + triangle_name=triangle_name, + p1_name=p1_name, p2_name=p2_name, p3_name=p3_name, + name=name, color=color, + ) + + def create_incircle( + self, + triangle_name: str, + *, + name: Optional[str] = None, + color: Optional[str] = None, + ) -> "Drawable": + """Create the inscribed circle of a triangle.""" + return self.drawable_manager.create_incircle( + triangle_name, name=name, color=color, + ) + 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)) diff --git a/static/client/client_tests/test_construction_manager.py b/static/client/client_tests/test_construction_manager.py new file mode 100644 index 00000000..a4a904f7 --- /dev/null +++ b/static/client/client_tests/test_construction_manager.py @@ -0,0 +1,595 @@ +"""Tests for ConstructionManager - geometric constructions.""" + +from __future__ import annotations + +import math +import unittest +from typing import List, Optional + +from canvas import Canvas +from drawables.point import Point +from drawables.segment import Segment +from utils.math_utils import MathUtils + + +class TestConstructionManager(unittest.TestCase): + """Base test class for ConstructionManager tests.""" + + def setUp(self) -> None: + """Set up test canvas with draw disabled.""" + self.canvas = Canvas(500, 500, draw_enabled=False) + + def _get_points(self) -> List[Point]: + return [d for d in self.canvas.get_drawables_by_class_name("Point")] + + def _get_segments(self) -> List[Segment]: + return [d for d in self.canvas.get_drawables_by_class_name("Segment")] + + def _point_count(self) -> int: + return len(self._get_points()) + + def _segment_count(self) -> int: + return len(self._get_segments()) + + +class TestConstructMidpoint(TestConstructionManager): + """Tests for midpoint construction.""" + + def test_midpoint_of_horizontal_segment(self) -> None: + """Midpoint of (0,0)-(4,0) should be (2,0).""" + self.canvas.create_point(0, 0, name="A") + self.canvas.create_point(4, 0, name="B") + self.canvas.create_segment(0, 0, 4, 0, name="AB") + pt = self.canvas.create_midpoint(segment_name="AB", name="M") + self.assertAlmostEqual(pt.x, 2.0, places=5) + self.assertAlmostEqual(pt.y, 0.0, places=5) + + def test_midpoint_of_vertical_segment(self) -> None: + """Midpoint of (0,0)-(0,6) should be (0,3).""" + self.canvas.create_point(0, 0, name="A") + self.canvas.create_point(0, 6, name="B") + self.canvas.create_segment(0, 0, 0, 6, name="AB") + pt = self.canvas.create_midpoint(segment_name="AB", name="M") + self.assertAlmostEqual(pt.x, 0.0, places=5) + self.assertAlmostEqual(pt.y, 3.0, places=5) + + def test_midpoint_of_diagonal_segment(self) -> None: + """Midpoint of (1,1)-(5,7) should be (3,4).""" + self.canvas.create_point(1, 1, name="A") + self.canvas.create_point(5, 7, name="B") + self.canvas.create_segment(1, 1, 5, 7, name="AB") + pt = self.canvas.create_midpoint(segment_name="AB", name="M") + self.assertAlmostEqual(pt.x, 3.0, places=5) + self.assertAlmostEqual(pt.y, 4.0, places=5) + + def test_midpoint_by_point_names(self) -> None: + """Midpoint using p1_name/p2_name instead of segment_name.""" + self.canvas.create_point(0, 0, name="A") + self.canvas.create_point(10, 0, name="B") + pt = self.canvas.create_midpoint(p1_name="A", p2_name="B", name="M") + self.assertAlmostEqual(pt.x, 5.0, places=5) + self.assertAlmostEqual(pt.y, 0.0, places=5) + + def test_midpoint_with_negative_coords(self) -> None: + """Midpoint of (-4,-2)-(6,8) should be (1,3).""" + self.canvas.create_point(-4, -2, name="A") + self.canvas.create_point(6, 8, name="B") + pt = self.canvas.create_midpoint(p1_name="A", p2_name="B", name="M") + self.assertAlmostEqual(pt.x, 1.0, places=5) + self.assertAlmostEqual(pt.y, 3.0, places=5) + + def test_midpoint_nonexistent_segment_raises(self) -> None: + """Midpoint of nonexistent segment raises ValueError.""" + with self.assertRaises(ValueError): + self.canvas.create_midpoint(segment_name="nonexistent") + + def test_midpoint_nonexistent_point_raises(self) -> None: + """Midpoint with nonexistent point names raises ValueError.""" + self.canvas.create_point(0, 0, name="A") + with self.assertRaises(ValueError): + self.canvas.create_midpoint(p1_name="A", p2_name="Z") + + def test_midpoint_no_args_raises(self) -> None: + """Midpoint with no arguments raises ValueError.""" + with self.assertRaises(ValueError): + self.canvas.create_midpoint() + + def test_midpoint_undo(self) -> None: + """Undo should remove the midpoint.""" + self.canvas.create_point(0, 0, name="A") + self.canvas.create_point(4, 0, name="B") + self.canvas.create_segment(0, 0, 4, 0, name="AB") + count_before = self._point_count() + self.canvas.create_midpoint(segment_name="AB", name="M") + self.assertEqual(self._point_count(), count_before + 1) + self.canvas.undo() + self.assertEqual(self._point_count(), count_before) + + +class TestConstructPerpendicularBisector(TestConstructionManager): + """Tests for perpendicular bisector construction.""" + + def test_bisector_passes_through_midpoint(self) -> None: + """Perpendicular bisector should pass through the midpoint.""" + self.canvas.create_segment(0, 0, 4, 0, name="AB") + bisector = self.canvas.create_perpendicular_bisector("AB") + # Midpoint of (0,0)-(4,0) is (2,0) + # Bisector is vertical (perp to horizontal), so both endpoints have x=2 + mid_x = (bisector.point1.x + bisector.point2.x) / 2 + mid_y = (bisector.point1.y + bisector.point2.y) / 2 + self.assertAlmostEqual(mid_x, 2.0, places=5) + self.assertAlmostEqual(mid_y, 0.0, places=5) + + def test_bisector_is_perpendicular(self) -> None: + """Perpendicular bisector should be perpendicular to the original (dot product ~ 0).""" + self.canvas.create_segment(0, 0, 4, 2, name="AB") + bisector = self.canvas.create_perpendicular_bisector("AB") + # Original direction vector + orig_dx = 4.0 - 0.0 + orig_dy = 2.0 - 0.0 + # Bisector direction vector + bis_dx = bisector.point2.x - bisector.point1.x + bis_dy = bisector.point2.y - bisector.point1.y + dot = orig_dx * bis_dx + orig_dy * bis_dy + self.assertAlmostEqual(dot, 0.0, places=3) + + def test_bisector_of_vertical_segment(self) -> None: + """Perpendicular bisector of vertical segment should be horizontal.""" + self.canvas.create_segment(0, 0, 0, 6, name="AB") + bisector = self.canvas.create_perpendicular_bisector("AB") + # Bisector should be horizontal (same y for both endpoints) + self.assertAlmostEqual(bisector.point1.y, bisector.point2.y, places=5) + # And pass through midpoint (0, 3) + self.assertAlmostEqual(bisector.point1.y, 3.0, places=5) + + def test_bisector_custom_length(self) -> None: + """Perpendicular bisector should have the specified length.""" + self.canvas.create_segment(0, 0, 4, 0, name="AB") + bisector = self.canvas.create_perpendicular_bisector("AB", length=10.0) + dx = bisector.point2.x - bisector.point1.x + dy = bisector.point2.y - bisector.point1.y + actual_length = math.sqrt(dx**2 + dy**2) + self.assertAlmostEqual(actual_length, 10.0, places=3) + + def test_bisector_nonexistent_segment_raises(self) -> None: + """Perpendicular bisector of nonexistent segment raises ValueError.""" + with self.assertRaises(ValueError): + self.canvas.create_perpendicular_bisector("nonexistent") + + def test_bisector_undo(self) -> None: + """Undo should remove the bisector segment.""" + self.canvas.create_segment(0, 0, 4, 0, name="AB") + count_before = self._segment_count() + self.canvas.create_perpendicular_bisector("AB") + self.assertEqual(self._segment_count(), count_before + 1) + self.canvas.undo() + self.assertEqual(self._segment_count(), count_before) + + +class TestConstructPerpendicularFromPoint(TestConstructionManager): + """Tests for perpendicular from point to line construction.""" + + def test_perpendicular_foot_on_horizontal_segment(self) -> None: + """Foot of perpendicular from (3,5) to x-axis segment should be (3,0).""" + self.canvas.create_point(3, 5, name="P") + self.canvas.create_segment(0, 0, 6, 0, name="AB") + result = self.canvas.create_perpendicular_from_point("P", "AB") + foot = result["foot"] + self.assertAlmostEqual(foot.x, 3.0, places=5) + self.assertAlmostEqual(foot.y, 0.0, places=5) + + def test_perpendicular_foot_on_vertical_segment(self) -> None: + """Foot of perpendicular from (5,3) to y-axis segment should be (0,3).""" + self.canvas.create_point(5, 3, name="P") + self.canvas.create_segment(0, 0, 0, 6, name="AB") + result = self.canvas.create_perpendicular_from_point("P", "AB") + foot = result["foot"] + self.assertAlmostEqual(foot.x, 0.0, places=5) + self.assertAlmostEqual(foot.y, 3.0, places=5) + + def test_perpendicular_foot_on_diagonal(self) -> None: + """Foot of perpendicular from (0,4) to y=x should be (2,2).""" + self.canvas.create_point(0, 4, name="P") + self.canvas.create_segment(0, 0, 4, 4, name="AB") + result = self.canvas.create_perpendicular_from_point("P", "AB") + foot = result["foot"] + self.assertAlmostEqual(foot.x, 2.0, places=5) + self.assertAlmostEqual(foot.y, 2.0, places=5) + + def test_perpendicular_creates_segment(self) -> None: + """Construction should create a segment from point to foot.""" + self.canvas.create_point(3, 5, name="P") + self.canvas.create_segment(0, 0, 6, 0, name="AB") + result = self.canvas.create_perpendicular_from_point("P", "AB") + seg = result["segment"] + # Segment endpoints should be at (3,5) and (3,0) + xs = sorted([seg.point1.x, seg.point2.x]) + ys = sorted([seg.point1.y, seg.point2.y]) + self.assertAlmostEqual(xs[0], 3.0, places=5) + self.assertAlmostEqual(xs[1], 3.0, places=5) + self.assertAlmostEqual(ys[0], 0.0, places=5) + self.assertAlmostEqual(ys[1], 5.0, places=5) + + def test_perpendicular_single_undo(self) -> None: + """Composite construction (point + segment) should undo in one step.""" + self.canvas.create_point(3, 5, name="P") + self.canvas.create_segment(0, 0, 6, 0, name="AB") + pts_before = self._point_count() + segs_before = self._segment_count() + self.canvas.create_perpendicular_from_point("P", "AB") + # Should have added 1 point (foot) and 1 segment + self.assertEqual(self._point_count(), pts_before + 1) + self.assertEqual(self._segment_count(), segs_before + 1) + # Single undo should remove both + self.canvas.undo() + self.assertEqual(self._point_count(), pts_before) + self.assertEqual(self._segment_count(), segs_before) + + def test_perpendicular_nonexistent_point_raises(self) -> None: + """Nonexistent point name raises ValueError.""" + self.canvas.create_segment(0, 0, 6, 0, name="AB") + with self.assertRaises(ValueError): + self.canvas.create_perpendicular_from_point("Z", "AB") + + def test_perpendicular_nonexistent_segment_raises(self) -> None: + """Nonexistent segment name raises ValueError.""" + self.canvas.create_point(3, 5, name="P") + with self.assertRaises(ValueError): + self.canvas.create_perpendicular_from_point("P", "nonexistent") + + +class TestConstructAngleBisector(TestConstructionManager): + """Tests for angle bisector construction.""" + + def test_bisector_of_right_angle(self) -> None: + """Bisector of 90-degree angle at origin should point at 45 degrees.""" + self.canvas.create_point(0, 0, name="V") + self.canvas.create_point(4, 0, name="A") + self.canvas.create_point(0, 4, name="B") + bisector = self.canvas.create_angle_bisector("V", "A", "B") + # Direction from vertex should be (1/sqrt2, 1/sqrt2) + dx = bisector.point2.x - bisector.point1.x + dy = bisector.point2.y - bisector.point1.y + length = math.sqrt(dx**2 + dy**2) + if length > 1e-10: + angle = math.atan2(dy, dx) + self.assertAlmostEqual(angle, math.pi / 4, places=3) + + def test_bisector_of_60_degree_angle(self) -> None: + """Bisector of 60-degree angle should point at 30 degrees.""" + self.canvas.create_point(0, 0, name="V") + self.canvas.create_point(4, 0, name="A") + self.canvas.create_point(2, 2 * math.sqrt(3), name="B") # 60 degrees + bisector = self.canvas.create_angle_bisector("V", "A", "B") + dx = bisector.point2.x - bisector.point1.x + dy = bisector.point2.y - bisector.point1.y + angle = math.atan2(dy, dx) + self.assertAlmostEqual(angle, math.pi / 6, places=2) + + def test_bisector_starts_at_vertex(self) -> None: + """Bisector segment should start at the vertex.""" + self.canvas.create_point(1, 1, name="V") + self.canvas.create_point(5, 1, name="A") + self.canvas.create_point(1, 5, name="B") + bisector = self.canvas.create_angle_bisector("V", "A", "B") + self.assertAlmostEqual(bisector.point1.x, 1.0, places=5) + self.assertAlmostEqual(bisector.point1.y, 1.0, places=5) + + def test_bisector_custom_length(self) -> None: + """Bisector should have the specified length.""" + self.canvas.create_point(0, 0, name="V") + self.canvas.create_point(4, 0, name="A") + self.canvas.create_point(0, 4, name="B") + bisector = self.canvas.create_angle_bisector("V", "A", "B", length=8.0) + dx = bisector.point2.x - bisector.point1.x + dy = bisector.point2.y - bisector.point1.y + actual_length = math.sqrt(dx**2 + dy**2) + self.assertAlmostEqual(actual_length, 8.0, places=3) + + def test_bisector_collinear_raises(self) -> None: + """Bisector of 180-degree angle (collinear arms) should raise ValueError.""" + self.canvas.create_point(0, 0, name="V") + self.canvas.create_point(4, 0, name="A") + self.canvas.create_point(-4, 0, name="B") + with self.assertRaises(ValueError): + self.canvas.create_angle_bisector("V", "A", "B") + + def test_bisector_zero_length_arm_raises(self) -> None: + """Bisector with coincident vertex and arm point should raise.""" + self.canvas.create_point(0, 0, name="V") + self.canvas.create_point(0, 0, name="A") + self.canvas.create_point(0, 4, name="B") + with self.assertRaises(ValueError): + self.canvas.create_angle_bisector("V", "A", "B") + + def test_bisector_nonexistent_point_raises(self) -> None: + """Bisector with nonexistent point raises ValueError.""" + self.canvas.create_point(0, 0, name="V") + self.canvas.create_point(4, 0, name="A") + with self.assertRaises(ValueError): + self.canvas.create_angle_bisector("V", "A", "Z") + + def test_bisector_no_args_raises(self) -> None: + """Bisector with neither points nor angle raises ValueError.""" + with self.assertRaises(ValueError): + self.canvas.create_angle_bisector() + + def test_bisector_undo(self) -> None: + """Undo should remove the bisector segment.""" + self.canvas.create_point(0, 0, name="V") + self.canvas.create_point(4, 0, name="A") + self.canvas.create_point(0, 4, name="B") + count_before = self._segment_count() + self.canvas.create_angle_bisector("V", "A", "B") + self.assertEqual(self._segment_count(), count_before + 1) + self.canvas.undo() + self.assertEqual(self._segment_count(), count_before) + + def test_bisector_reflex_angle_by_name(self) -> None: + """Bisector of a reflex angle should point into the reflex arc.""" + # Create a 90-degree angle at origin: arms along +x and +y + self.canvas.create_point(0, 0, name="V") + self.canvas.create_point(4, 0, name="A") + self.canvas.create_point(0, 4, name="B") + self.canvas.create_segment(0, 0, 4, 0) + self.canvas.create_segment(0, 0, 0, 4) + # Create the reflex (270-degree) angle + reflex = self.canvas.create_angle(0, 0, 4, 0, 0, 4, is_reflex=True) + bisector = self.canvas.create_angle_bisector(angle_name=reflex.name) + # The reflex bisector should point into the third quadrant (negative x, negative y) + dx = bisector.point2.x - bisector.point1.x + dy = bisector.point2.y - bisector.point1.y + # For a 90-degree angle along +x and +y, the internal bisector is at +45 degrees. + # The reflex bisector should be at +45+180 = 225 degrees (third quadrant). + self.assertLess(dx, 0, "Reflex bisector dx should be negative") + self.assertLess(dy, 0, "Reflex bisector dy should be negative") + + +class TestConstructParallelLine(TestConstructionManager): + """Tests for parallel line construction.""" + + def test_parallel_to_horizontal(self) -> None: + """Parallel to horizontal segment through (0,5) should be horizontal at y=5.""" + self.canvas.create_segment(0, 0, 4, 0, name="AB") + self.canvas.create_point(0, 5, name="P") + parallel = self.canvas.create_parallel_line("AB", "P") + # Both endpoints should have y=5 + self.assertAlmostEqual(parallel.point1.y, 5.0, places=5) + self.assertAlmostEqual(parallel.point2.y, 5.0, places=5) + + def test_parallel_to_vertical(self) -> None: + """Parallel to vertical segment through (5,0) should be vertical at x=5.""" + self.canvas.create_segment(0, 0, 0, 4, name="AB") + self.canvas.create_point(5, 0, name="P") + parallel = self.canvas.create_parallel_line("AB", "P") + # Both endpoints should have x=5 + self.assertAlmostEqual(parallel.point1.x, 5.0, places=5) + self.assertAlmostEqual(parallel.point2.x, 5.0, places=5) + + def test_parallel_same_slope(self) -> None: + """Parallel line should have the same slope as the original.""" + self.canvas.create_segment(0, 0, 4, 2, name="AB") + self.canvas.create_point(0, 5, name="P") + parallel = self.canvas.create_parallel_line("AB", "P") + # Original slope = 2/4 = 0.5 + orig_slope = 2.0 / 4.0 + par_dx = parallel.point2.x - parallel.point1.x + par_dy = parallel.point2.y - parallel.point1.y + if abs(par_dx) > 1e-10: + par_slope = par_dy / par_dx + self.assertAlmostEqual(par_slope, orig_slope, places=3) + + def test_parallel_centered_on_point(self) -> None: + """Parallel line should be centered on the specified point.""" + self.canvas.create_segment(0, 0, 4, 0, name="AB") + self.canvas.create_point(2, 3, name="P") + parallel = self.canvas.create_parallel_line("AB", "P") + mid_x = (parallel.point1.x + parallel.point2.x) / 2 + mid_y = (parallel.point1.y + parallel.point2.y) / 2 + self.assertAlmostEqual(mid_x, 2.0, places=5) + self.assertAlmostEqual(mid_y, 3.0, places=5) + + def test_parallel_custom_length(self) -> None: + """Parallel line should have the specified length.""" + self.canvas.create_segment(0, 0, 4, 0, name="AB") + self.canvas.create_point(0, 5, name="P") + parallel = self.canvas.create_parallel_line("AB", "P", length=12.0) + dx = parallel.point2.x - parallel.point1.x + dy = parallel.point2.y - parallel.point1.y + actual_length = math.sqrt(dx**2 + dy**2) + self.assertAlmostEqual(actual_length, 12.0, places=3) + + def test_parallel_nonexistent_segment_raises(self) -> None: + """Parallel to nonexistent segment raises ValueError.""" + self.canvas.create_point(0, 5, name="P") + with self.assertRaises(ValueError): + self.canvas.create_parallel_line("nonexistent", "P") + + def test_parallel_nonexistent_point_raises(self) -> None: + """Parallel through nonexistent point raises ValueError.""" + self.canvas.create_segment(0, 0, 4, 0, name="AB") + with self.assertRaises(ValueError): + self.canvas.create_parallel_line("AB", "Z") + + def test_parallel_undo(self) -> None: + """Undo should remove the parallel segment.""" + self.canvas.create_segment(0, 0, 4, 0, name="AB") + self.canvas.create_point(0, 5, name="P") + count_before = self._segment_count() + self.canvas.create_parallel_line("AB", "P") + self.assertEqual(self._segment_count(), count_before + 1) + self.canvas.undo() + self.assertEqual(self._segment_count(), count_before) + + +class TestMathUtilsConstructionFunctions(TestConstructionManager): + """Tests for the low-level math utility functions used by constructions.""" + + def test_perpendicular_foot_point_on_line(self) -> None: + """When point is already on the line, foot should be the point itself.""" + fx, fy = MathUtils.perpendicular_foot(2, 0, 0, 0, 4, 0) + self.assertAlmostEqual(fx, 2.0, places=5) + self.assertAlmostEqual(fy, 0.0, places=5) + + def test_perpendicular_foot_degenerate_raises(self) -> None: + """Degenerate segment (same endpoints) should raise ValueError.""" + with self.assertRaises(ValueError): + MathUtils.perpendicular_foot(1, 1, 3, 3, 3, 3) + + def test_angle_bisector_direction_right_angle(self) -> None: + """Bisector of right angle should point at 45 degrees.""" + dx, dy = MathUtils.angle_bisector_direction(0, 0, 1, 0, 0, 1) + angle = math.atan2(dy, dx) + self.assertAlmostEqual(angle, math.pi / 4, places=5) + # Should be unit vector + length = math.sqrt(dx**2 + dy**2) + self.assertAlmostEqual(length, 1.0, places=5) + + def test_angle_bisector_direction_collinear_raises(self) -> None: + """Collinear arms (180 degrees) should raise ValueError.""" + with self.assertRaises(ValueError): + MathUtils.angle_bisector_direction(0, 0, 1, 0, -1, 0) + + def test_angle_bisector_direction_zero_arm_raises(self) -> None: + """Zero-length arm should raise ValueError.""" + with self.assertRaises(ValueError): + MathUtils.angle_bisector_direction(0, 0, 0, 0, 1, 0) + + def test_circumcenter_right_triangle(self) -> None: + """Circumcenter of right triangle at origin should be midpoint of hypotenuse.""" + cx, cy, r = MathUtils.circumcenter(0, 0, 4, 0, 0, 3) + self.assertAlmostEqual(cx, 2.0, places=5) + self.assertAlmostEqual(cy, 1.5, places=5) + self.assertAlmostEqual(r, 2.5, places=5) + + def test_circumcenter_equilateral(self) -> None: + """Circumcenter of equilateral triangle should be at centroid.""" + s = 2.0 + x1, y1 = 0.0, 0.0 + x2, y2 = s, 0.0 + x3, y3 = s / 2, s * math.sqrt(3) / 2 + cx, cy, r = MathUtils.circumcenter(x1, y1, x2, y2, x3, y3) + # Centroid + self.assertAlmostEqual(cx, s / 2, places=5) + self.assertAlmostEqual(cy, s * math.sqrt(3) / 6, places=4) + # Circumradius = s / sqrt(3) + self.assertAlmostEqual(r, s / math.sqrt(3), places=4) + + def test_circumcenter_collinear_raises(self) -> None: + """Collinear points should raise ValueError.""" + with self.assertRaises(ValueError): + MathUtils.circumcenter(0, 0, 1, 0, 2, 0) + + def test_incenter_equilateral(self) -> None: + """Incircle of equilateral triangle: inradius = s*sqrt(3)/6.""" + s = 6.0 + x1, y1 = 0.0, 0.0 + x2, y2 = s, 0.0 + x3, y3 = s / 2, s * math.sqrt(3) / 2 + cx, cy, r = MathUtils.incenter_and_inradius(x1, y1, x2, y2, x3, y3) + expected_r = s * math.sqrt(3) / 6 + self.assertAlmostEqual(r, expected_r, places=4) + # Incenter at centroid for equilateral + self.assertAlmostEqual(cx, s / 2, places=4) + self.assertAlmostEqual(cy, s * math.sqrt(3) / 6, places=4) + + def test_incenter_degenerate_raises(self) -> None: + """Degenerate triangle (collinear) should raise ValueError.""" + with self.assertRaises(ValueError): + MathUtils.incenter_and_inradius(0, 0, 1, 0, 2, 0) + + +class TestConstructCircumcircle(TestConstructionManager): + """Tests for circumcircle construction.""" + + def _create_triangle(self, name_suffix: str = "") -> None: + """Create a right triangle at origin: (0,0), (4,0), (0,3).""" + self.canvas.create_point(0, 0, name=f"A{name_suffix}") + self.canvas.create_point(4, 0, name=f"B{name_suffix}") + self.canvas.create_point(0, 3, name=f"C{name_suffix}") + self.canvas.create_segment(0, 0, 4, 0) + self.canvas.create_segment(4, 0, 0, 3) + self.canvas.create_segment(0, 3, 0, 0) + + def _circle_count(self) -> int: + return len(self.canvas.get_drawables_by_class_name("Circle")) + + def test_circumcircle_by_triangle_name(self) -> None: + """Circumcircle of right triangle has center at midpoint of hypotenuse.""" + self._create_triangle() + triangles = self.canvas.get_drawables_by_class_name("Triangle") + self.assertTrue(len(triangles) > 0) + tri_name = triangles[0].name + circle = self.canvas.create_circumcircle(triangle_name=tri_name) + self.assertAlmostEqual(circle.radius, 2.5, places=3) + + def test_circumcircle_by_three_points(self) -> None: + """Circumcircle by 3 point names should produce correct radius.""" + self.canvas.create_point(0, 0, name="P") + self.canvas.create_point(4, 0, name="Q") + self.canvas.create_point(0, 3, name="R") + circle = self.canvas.create_circumcircle(p1_name="P", p2_name="Q", p3_name="R") + self.assertAlmostEqual(circle.radius, 2.5, places=3) + + def test_circumcircle_collinear_raises(self) -> None: + """Collinear points should raise ValueError.""" + self.canvas.create_point(0, 0, name="P") + self.canvas.create_point(1, 0, name="Q") + self.canvas.create_point(2, 0, name="R") + with self.assertRaises(ValueError): + self.canvas.create_circumcircle(p1_name="P", p2_name="Q", p3_name="R") + + def test_circumcircle_no_args_raises(self) -> None: + """No arguments should raise ValueError.""" + with self.assertRaises(ValueError): + self.canvas.create_circumcircle() + + def test_circumcircle_undo(self) -> None: + """Undo should remove the circumcircle.""" + self.canvas.create_point(0, 0, name="P") + self.canvas.create_point(4, 0, name="Q") + self.canvas.create_point(0, 3, name="R") + count_before = self._circle_count() + self.canvas.create_circumcircle(p1_name="P", p2_name="Q", p3_name="R") + self.assertEqual(self._circle_count(), count_before + 1) + self.canvas.undo() + self.assertEqual(self._circle_count(), count_before) + + +class TestConstructIncircle(TestConstructionManager): + """Tests for incircle construction.""" + + def _create_triangle(self) -> str: + """Create a right triangle and return its name.""" + self.canvas.create_point(0, 0, name="A") + self.canvas.create_point(4, 0, name="B") + self.canvas.create_point(0, 3, name="C") + self.canvas.create_segment(0, 0, 4, 0) + self.canvas.create_segment(4, 0, 0, 3) + self.canvas.create_segment(0, 3, 0, 0) + triangles = self.canvas.get_drawables_by_class_name("Triangle") + self.assertTrue(len(triangles) > 0) + return triangles[0].name + + def _circle_count(self) -> int: + return len(self.canvas.get_drawables_by_class_name("Circle")) + + def test_incircle_right_triangle(self) -> None: + """Incircle of 3-4-5 right triangle: inradius = (3+4-5)/2 = 1.""" + tri_name = self._create_triangle() + circle = self.canvas.create_incircle(tri_name) + self.assertAlmostEqual(circle.radius, 1.0, places=3) + + def test_incircle_nonexistent_raises(self) -> None: + """Nonexistent triangle name should raise ValueError.""" + with self.assertRaises(ValueError): + self.canvas.create_incircle("nonexistent") + + def test_incircle_undo(self) -> None: + """Undo should remove the incircle.""" + tri_name = self._create_triangle() + count_before = self._circle_count() + self.canvas.create_incircle(tri_name) + self.assertEqual(self._circle_count(), count_before + 1) + self.canvas.undo() + self.assertEqual(self._circle_count(), count_before) diff --git a/static/client/client_tests/tests.py b/static/client/client_tests/tests.py index 262fb051..65b67f67 100644 --- a/static/client/client_tests/tests.py +++ b/static/client/client_tests/tests.py @@ -89,6 +89,16 @@ TestMathUtilsTangentFunctions, TestUndoRedo as TestTangentUndoRedo, ) +from .test_construction_manager import ( + TestConstructMidpoint, + TestConstructPerpendicularBisector, + TestConstructPerpendicularFromPoint, + TestConstructAngleBisector, + TestConstructParallelLine, + TestConstructCircumcircle, + TestConstructIncircle, + TestMathUtilsConstructionFunctions, +) from .test_polygon_canonicalizer import TestPolygonCanonicalizer from .test_triangle import TestTriangle from .test_quadrilateral import TestQuadrilateral @@ -408,6 +418,14 @@ def _get_test_cases(self) -> List[Type[unittest.TestCase]]: TestTangentToParametricFunction, TestMathUtilsTangentFunctions, TestTangentUndoRedo, + TestConstructMidpoint, + TestConstructPerpendicularBisector, + TestConstructPerpendicularFromPoint, + TestConstructAngleBisector, + TestConstructParallelLine, + TestConstructCircumcircle, + TestConstructIncircle, + TestMathUtilsConstructionFunctions, TestVector, TestTriangle, TestQuadrilateral, diff --git a/static/client/function_registry.py b/static/client/function_registry.py index de4175a1..db2ca503 100644 --- a/static/client/function_registry.py +++ b/static/client/function_registry.py @@ -156,6 +156,15 @@ def get_available_functions(canvas: "Canvas", workspace_manager: "WorkspaceManag "draw_tangent_line": canvas.create_tangent_line, "draw_normal_line": canvas.create_normal_line, + # ===== GEOMETRIC CONSTRUCTIONS ===== + "construct_midpoint": canvas.create_midpoint, + "construct_perpendicular_bisector": canvas.create_perpendicular_bisector, + "construct_perpendicular_from_point": canvas.create_perpendicular_from_point, + "construct_angle_bisector": canvas.create_angle_bisector, + "construct_parallel_line": canvas.create_parallel_line, + "construct_circumcircle": canvas.create_circumcircle, + "construct_incircle": canvas.create_incircle, + # ===== OBJECT TRANSFORMATIONS ===== "translate_object": canvas.translate_object, "rotate_object": canvas.rotate_object, @@ -372,6 +381,15 @@ def get_undoable_functions() -> Tuple[str, ...]: "draw_tangent_line", "draw_normal_line", + # Geometric construction operations + "construct_midpoint", + "construct_perpendicular_bisector", + "construct_perpendicular_from_point", + "construct_angle_bisector", + "construct_parallel_line", + "construct_circumcircle", + "construct_incircle", + # Object transformations "translate_object", "rotate_object", diff --git a/static/client/managers/construction_manager.py b/static/client/managers/construction_manager.py new file mode 100644 index 00000000..d384a444 --- /dev/null +++ b/static/client/managers/construction_manager.py @@ -0,0 +1,575 @@ +""" +Geometric Construction Manager for MatHud + +Manages geometric constructions (midpoints, perpendicular bisectors, angle +bisectors, perpendicular/parallel lines) by computing coordinates and creating +standard Point and Segment drawables via existing managers. + +Constructions produce static snapshots — they create ordinary drawables at +computed positions, with no reactive re-computation if source objects move. +""" + +from __future__ import annotations + +import math +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Union + +from constants import default_color +from utils.math_utils import MathUtils + +if TYPE_CHECKING: + from canvas import Canvas + from managers.drawables_container import DrawablesContainer + from managers.point_manager import PointManager + from managers.segment_manager import SegmentManager + from managers.angle_manager import AngleManager + from managers.drawable_dependency_manager import DrawableDependencyManager + from managers.drawable_manager_proxy import DrawableManagerProxy + from name_generator.drawable import DrawableNameGenerator + from drawables.point import Point + from drawables.segment import Segment + from drawables.angle import Angle + from drawables.circle import Circle + from drawables.triangle import Triangle + +# Default construction line length in math units +DEFAULT_CONSTRUCTION_LENGTH = 6.0 + + +class ConstructionManager: + """Manages geometric constructions that produce standard Point/Segment drawables. + + Follows the TangentManager pattern: a dedicated manager that computes + geometry, then delegates to PointManager/SegmentManager for creation. + + Composite constructions (creating multiple primitives) use the + ``suspend_archiving`` pattern from AngleManager so the entire + construction collapses into a single undo step. + + Attributes: + canvas: Reference to the parent Canvas instance + drawables: Container for all drawable objects + point_manager: Manager for creating point drawables + segment_manager: Manager for creating segment drawables + angle_manager: Manager for looking up angle drawables + name_generator: Generates unique names for drawables + dependency_manager: Tracks object dependencies + proxy: Manager proxy for inter-manager communication + """ + + def __init__( + self, + canvas: "Canvas", + drawables: "DrawablesContainer", + point_manager: "PointManager", + segment_manager: "SegmentManager", + angle_manager: "AngleManager", + name_generator: "DrawableNameGenerator", + dependency_manager: "DrawableDependencyManager", + proxy: "DrawableManagerProxy", + ) -> None: + self.canvas: "Canvas" = canvas + self.drawables: "DrawablesContainer" = drawables + self.point_manager: "PointManager" = point_manager + self.segment_manager: "SegmentManager" = segment_manager + self.angle_manager: "AngleManager" = angle_manager + self.name_generator: "DrawableNameGenerator" = name_generator + self.dependency_manager: "DrawableDependencyManager" = dependency_manager + self.proxy: "DrawableManagerProxy" = proxy + + # ------------------- Helpers ------------------- + + def _archive_for_undo(self) -> None: + """Archive current state before making changes for undo support.""" + undo_redo = getattr(self.canvas, "undo_redo_manager", None) + if undo_redo: + undo_redo.archive() + + def _get_point(self, name: str) -> "Point": + """Retrieve a point by name, raising ValueError if not found.""" + for pt in self.drawables.Points: + if getattr(pt, "name", None) == name: + return pt # type: ignore[return-value] + raise ValueError(f"Point '{name}' not found") + + def _get_segment(self, name: str) -> "Segment": + """Retrieve a segment by name, raising ValueError if not found.""" + for seg in self.drawables.Segments: + if getattr(seg, "name", None) == name: + return seg # type: ignore[return-value] + raise ValueError(f"Segment '{name}' not found") + + def _get_angle(self, name: str) -> "Angle": + """Retrieve an angle by name, raising ValueError if not found.""" + angle = self.angle_manager.get_angle_by_name(name) + if angle is None: + raise ValueError(f"Angle '{name}' not found") + return angle + + def _get_triangle(self, name: str) -> "Triangle": + """Retrieve a triangle by name, raising ValueError if not found.""" + tri = self.drawables.get_triangle_by_name(name) + if tri is None: + raise ValueError(f"Triangle '{name}' not found") + return tri # type: ignore[return-value] + + def _segment_slope(self, seg: "Segment") -> Optional[float]: + """Return the slope of a segment, or None for vertical. + + Raises: + ValueError: If the segment has zero length (coincident endpoints) + """ + dx = seg.point2.x - seg.point1.x + dy = seg.point2.y - seg.point1.y + if abs(dx) < MathUtils.EPSILON and abs(dy) < MathUtils.EPSILON: + raise ValueError( + f"Degenerate segment '{getattr(seg, 'name', '')}': endpoints coincide" + ) + if abs(dx) < MathUtils.EPSILON: + return None + return dy / dx + + # ------------------- Public Construction Methods ------------------- + + def create_midpoint( + self, + p1_name: Optional[str] = None, + p2_name: Optional[str] = None, + *, + segment_name: Optional[str] = None, + name: Optional[str] = None, + color: Optional[str] = None, + ) -> "Point": + """Create a point at the midpoint of two points or a segment. + + Either provide ``p1_name`` and ``p2_name``, or ``segment_name``. + + Args: + p1_name: Name of the first point + p2_name: Name of the second point + segment_name: Name of the segment (alternative to point names) + name: Optional name for the created midpoint + color: Optional color for the midpoint + + Returns: + The created Point drawable + + Raises: + ValueError: If neither points nor segment specified, or not found + """ + if segment_name: + seg = self._get_segment(segment_name) + p1, p2 = seg.point1, seg.point2 + elif p1_name and p2_name: + p1 = self._get_point(p1_name) + p2 = self._get_point(p2_name) + else: + raise ValueError( + "Provide either 'segment_name' or both 'p1_name' and 'p2_name'" + ) + + mx, my = MathUtils.get_2D_midpoint(p1, p2) + + point = self.point_manager.create_point( + mx, my, + name=name or "", + color=color or default_color, + extra_graphics=False, + ) + return point + + def create_perpendicular_bisector( + self, + segment_name: str, + *, + length: Optional[float] = None, + name: Optional[str] = None, + color: Optional[str] = None, + ) -> "Segment": + """Create a segment that is the perpendicular bisector of a given segment. + + The resulting segment passes through the midpoint and is perpendicular + to the original segment. + + Args: + segment_name: Name of the segment to bisect + length: Total length of the bisector segment (default: 6.0) + name: Optional name for the created segment + color: Optional color for the segment + + Returns: + The created Segment drawable + + Raises: + ValueError: If segment not found + """ + seg = self._get_segment(segment_name) + if length is None: + length = DEFAULT_CONSTRUCTION_LENGTH + if color is None: + color = getattr(seg, "color", default_color) + + midpoint = MathUtils.get_2D_midpoint(seg.point1, seg.point2) + tangent_slope = self._segment_slope(seg) + perp_slope = MathUtils.normal_slope(tangent_slope) + endpoints = MathUtils.tangent_line_endpoints(perp_slope, midpoint, length) + + (x1, y1), (x2, y2) = endpoints + + self._archive_for_undo() + segment = self.segment_manager.create_segment( + x1, y1, x2, y2, + name=name or "", + color=color, + extra_graphics=True, + ) + return segment + + def create_perpendicular_from_point( + self, + point_name: str, + segment_name: str, + *, + name: Optional[str] = None, + color: Optional[str] = None, + ) -> Dict[str, Union["Point", "Segment"]]: + """Drop a perpendicular from a point to a segment. + + Creates the foot point on the segment and a segment from the given + point to the foot. Both are created as a single undo step. + + Args: + point_name: Name of the point to project + segment_name: Name of the target segment + name: Optional name for the perpendicular segment + color: Optional color for created drawables + + Returns: + Dict with keys 'foot' (Point) and 'segment' (Segment) + + Raises: + ValueError: If point or segment not found + """ + pt = self._get_point(point_name) + seg = self._get_segment(segment_name) + if color is None: + color = default_color + + foot_x, foot_y = MathUtils.perpendicular_foot( + pt.x, pt.y, + seg.point1.x, seg.point1.y, + seg.point2.x, seg.point2.y, + ) + + # Use suspend_archiving pattern for composite construction + undo_manager = self.canvas.undo_redo_manager + baseline_state = undo_manager.capture_state() + undo_manager.suspend_archiving() + + try: + foot_point = self.point_manager.create_point( + foot_x, foot_y, + name="", + color=color, + extra_graphics=False, + ) + + perp_segment = self.segment_manager.create_segment( + pt.x, pt.y, foot_x, foot_y, + name=name or "", + color=color, + extra_graphics=True, + ) + + undo_manager.push_undo_state(baseline_state) + + if self.canvas.draw_enabled: + self.canvas.draw() + + return {"foot": foot_point, "segment": perp_segment} + except Exception: + undo_manager.restore_state(baseline_state, redraw=self.canvas.draw_enabled) + raise + finally: + undo_manager.resume_archiving() + + def create_angle_bisector( + self, + vertex_name: Optional[str] = None, + p1_name: Optional[str] = None, + p2_name: Optional[str] = None, + *, + angle_name: Optional[str] = None, + length: Optional[float] = None, + name: Optional[str] = None, + color: Optional[str] = None, + ) -> "Segment": + """Create a segment along the bisector of an angle. + + Either provide ``vertex_name``, ``p1_name``, ``p2_name`` directly, + or ``angle_name`` to look up an existing Angle object. + + Args: + vertex_name: Name of the vertex point + p1_name: Name of the first arm endpoint + p2_name: Name of the second arm endpoint + angle_name: Name of an existing Angle (alternative to point names) + length: Total length of the bisector segment (default: 6.0) + name: Optional name for the created segment + color: Optional color for the segment + + Returns: + The created Segment drawable + + Raises: + ValueError: If inputs not found or angle is degenerate + """ + if angle_name: + angle = self._get_angle(angle_name) + # Extract vertex and arm endpoints from the angle's segments + seg1 = angle.segment1 + seg2 = angle.segment2 + vertex = angle.vertex_point + vx, vy = vertex.x, vertex.y + + # Determine which endpoint of each segment is NOT the vertex + if abs(seg1.point1.x - vx) < MathUtils.EPSILON and abs(seg1.point1.y - vy) < MathUtils.EPSILON: + p1x, p1y = seg1.point2.x, seg1.point2.y + else: + p1x, p1y = seg1.point1.x, seg1.point1.y + + if abs(seg2.point1.x - vx) < MathUtils.EPSILON and abs(seg2.point1.y - vy) < MathUtils.EPSILON: + p2x, p2y = seg2.point2.x, seg2.point2.y + else: + p2x, p2y = seg2.point1.x, seg2.point1.y + elif vertex_name and p1_name and p2_name: + v = self._get_point(vertex_name) + p1 = self._get_point(p1_name) + p2 = self._get_point(p2_name) + vx, vy = v.x, v.y + p1x, p1y = p1.x, p1.y + p2x, p2y = p2.x, p2.y + else: + raise ValueError( + "Provide either 'angle_name' or all of 'vertex_name', 'p1_name', 'p2_name'" + ) + + if length is None: + length = DEFAULT_CONSTRUCTION_LENGTH + if color is None: + color = default_color + + dx, dy = MathUtils.angle_bisector_direction(vx, vy, p1x, p1y, p2x, p2y) + + # For reflex angles, negate the direction so the bisector points + # into the reflex arc instead of the minor arc. + if angle_name and getattr(angle, "is_reflex", False): + dx, dy = -dx, -dy + + # Create segment from vertex along bisector direction + half = length / 2 + x1 = vx + y1 = vy + x2 = vx + dx * length + y2 = vy + dy * length + + self._archive_for_undo() + segment = self.segment_manager.create_segment( + x1, y1, x2, y2, + name=name or "", + color=color, + extra_graphics=True, + ) + return segment + + def create_parallel_line( + self, + segment_name: str, + point_name: str, + *, + length: Optional[float] = None, + name: Optional[str] = None, + color: Optional[str] = None, + ) -> "Segment": + """Create a segment through a point, parallel to a given segment. + + The resulting segment is centered on the given point and has the + same slope as the reference segment. + + Args: + segment_name: Name of the reference segment + point_name: Name of the point the parallel line passes through + length: Total length of the parallel segment (default: 6.0) + name: Optional name for the created segment + color: Optional color for the segment + + Returns: + The created Segment drawable + + Raises: + ValueError: If segment or point not found + """ + seg = self._get_segment(segment_name) + pt = self._get_point(point_name) + + if length is None: + length = DEFAULT_CONSTRUCTION_LENGTH + if color is None: + color = getattr(seg, "color", default_color) + + slope = self._segment_slope(seg) + point = (pt.x, pt.y) + endpoints = MathUtils.tangent_line_endpoints(slope, point, length) + + (x1, y1), (x2, y2) = endpoints + + self._archive_for_undo() + segment = self.segment_manager.create_segment( + x1, y1, x2, y2, + name=name or "", + color=color, + extra_graphics=True, + ) + return segment + + # ------------------- Circle Construction Methods ------------------- + + def _triangle_vertices( + self, + triangle_name: Optional[str], + p1_name: Optional[str], + p2_name: Optional[str], + p3_name: Optional[str], + ) -> Tuple[float, float, float, float, float, float]: + """Resolve three vertex coordinates from a triangle name or three point names.""" + if triangle_name: + tri = self._get_triangle(triangle_name) + verts = list(tri.get_vertices()) + if len(verts) != 3: + raise ValueError(f"Triangle '{triangle_name}' does not have exactly 3 vertices") + return (verts[0].x, verts[0].y, verts[1].x, verts[1].y, verts[2].x, verts[2].y) + elif p1_name and p2_name and p3_name: + p1 = self._get_point(p1_name) + p2 = self._get_point(p2_name) + p3 = self._get_point(p3_name) + return (p1.x, p1.y, p2.x, p2.y, p3.x, p3.y) + else: + raise ValueError( + "Provide either 'triangle_name' or all of 'p1_name', 'p2_name', 'p3_name'" + ) + + def create_circumcircle( + self, + *, + triangle_name: Optional[str] = None, + p1_name: Optional[str] = None, + p2_name: Optional[str] = None, + p3_name: Optional[str] = None, + name: Optional[str] = None, + color: Optional[str] = None, + ) -> "Circle": + """Create the circumscribed circle of a triangle or three points. + + The circumcircle passes through all three vertices. + + Args: + triangle_name: Name of an existing triangle + p1_name: Name of the first point (alternative to triangle_name) + p2_name: Name of the second point + p3_name: Name of the third point + name: Optional name for the created circle + color: Optional color for the circle + + Returns: + The created Circle drawable + + Raises: + ValueError: If inputs not found or points are collinear + """ + x1, y1, x2, y2, x3, y3 = self._triangle_vertices( + triangle_name, p1_name, p2_name, p3_name + ) + if color is None: + color = default_color + + cx, cy, radius = MathUtils.circumcenter(x1, y1, x2, y2, x3, y3) + + # Use suspend_archiving since create_circle internally archives + undo_manager = self.canvas.undo_redo_manager + baseline_state = undo_manager.capture_state() + undo_manager.suspend_archiving() + + try: + circle = self.proxy.create_circle( + cx, cy, radius, + name=name or "", + color=color, + extra_graphics=True, + ) + undo_manager.push_undo_state(baseline_state) + if self.canvas.draw_enabled: + self.canvas.draw() + return circle # type: ignore[return-value] + except Exception: + undo_manager.restore_state(baseline_state, redraw=self.canvas.draw_enabled) + raise + finally: + undo_manager.resume_archiving() + + def create_incircle( + self, + triangle_name: str, + *, + name: Optional[str] = None, + color: Optional[str] = None, + ) -> "Circle": + """Create the inscribed circle of a triangle. + + The incircle is tangent to all three sides. + + Args: + triangle_name: Name of an existing triangle + name: Optional name for the created circle + color: Optional color for the circle + + Returns: + The created Circle drawable + + Raises: + ValueError: If triangle not found or is degenerate + """ + tri = self._get_triangle(triangle_name) + verts = list(tri.get_vertices()) + if len(verts) != 3: + raise ValueError(f"Triangle '{triangle_name}' does not have exactly 3 vertices") + + if color is None: + color = default_color + + cx, cy, radius = MathUtils.incenter_and_inradius( + verts[0].x, verts[0].y, + verts[1].x, verts[1].y, + verts[2].x, verts[2].y, + ) + + # Use suspend_archiving since create_circle internally archives + undo_manager = self.canvas.undo_redo_manager + baseline_state = undo_manager.capture_state() + undo_manager.suspend_archiving() + + try: + circle = self.proxy.create_circle( + cx, cy, radius, + name=name or "", + color=color, + extra_graphics=True, + ) + undo_manager.push_undo_state(baseline_state) + if self.canvas.draw_enabled: + self.canvas.draw() + return circle # type: ignore[return-value] + except Exception: + undo_manager.restore_state(baseline_state, redraw=self.canvas.draw_enabled) + raise + finally: + undo_manager.resume_archiving() diff --git a/static/client/managers/drawable_manager.py b/static/client/managers/drawable_manager.py index 57938dfe..fb53ac74 100644 --- a/static/client/managers/drawable_manager.py +++ b/static/client/managers/drawable_manager.py @@ -70,6 +70,7 @@ from managers.statistics_manager import StatisticsManager from managers.bar_manager import BarManager from managers.tangent_manager import TangentManager +from managers.construction_manager import ConstructionManager from drawables.closed_shape_colored_area import ClosedShapeColoredArea if TYPE_CHECKING: @@ -221,6 +222,17 @@ def __init__(self, canvas: "Canvas") -> None: self.proxy, ) + self.construction_manager: ConstructionManager = ConstructionManager( + canvas, + self.drawables, + self.point_manager, + self.segment_manager, + self.angle_manager, + self.name_generator, + self.dependency_manager, + self.proxy, + ) + # No need for the loop that sets drawable_manager anymore # The proxy handles forwarding calls to the appropriate managers @@ -1159,3 +1171,105 @@ def create_normal_line( return self.tangent_manager.create_normal_line( curve_name, parameter, name=name, length=length, color=color ) + + # ------------------- Construction Methods ------------------- + + def create_midpoint( + self, + p1_name: Optional[str] = None, + p2_name: Optional[str] = None, + *, + segment_name: Optional[str] = None, + name: Optional[str] = None, + color: Optional[str] = None, + ) -> "Point": + """Create a point at the midpoint of two points or a segment.""" + return self.construction_manager.create_midpoint( + p1_name, p2_name, segment_name=segment_name, name=name, color=color + ) + + def create_perpendicular_bisector( + self, + segment_name: str, + *, + length: Optional[float] = None, + name: Optional[str] = None, + color: Optional[str] = None, + ) -> "Segment": + """Create the perpendicular bisector of a segment.""" + return self.construction_manager.create_perpendicular_bisector( + segment_name, length=length, name=name, color=color + ) + + def create_perpendicular_from_point( + self, + point_name: str, + segment_name: str, + *, + name: Optional[str] = None, + color: Optional[str] = None, + ) -> Dict[str, Any]: + """Drop a perpendicular from a point to a segment.""" + return self.construction_manager.create_perpendicular_from_point( + point_name, segment_name, name=name, color=color + ) + + def create_angle_bisector( + self, + vertex_name: Optional[str] = None, + p1_name: Optional[str] = None, + p2_name: Optional[str] = None, + *, + angle_name: Optional[str] = None, + length: Optional[float] = None, + name: Optional[str] = None, + color: Optional[str] = None, + ) -> "Segment": + """Create a segment along the bisector of an angle.""" + return self.construction_manager.create_angle_bisector( + vertex_name, p1_name, p2_name, + angle_name=angle_name, length=length, name=name, color=color + ) + + def create_circumcircle( + self, + *, + triangle_name: Optional[str] = None, + p1_name: Optional[str] = None, + p2_name: Optional[str] = None, + p3_name: Optional[str] = None, + name: Optional[str] = None, + color: Optional[str] = None, + ) -> "Circle": + """Create the circumscribed circle of a triangle or three points.""" + return self.construction_manager.create_circumcircle( + triangle_name=triangle_name, + p1_name=p1_name, p2_name=p2_name, p3_name=p3_name, + name=name, color=color, + ) + + def create_incircle( + self, + triangle_name: str, + *, + name: Optional[str] = None, + color: Optional[str] = None, + ) -> "Circle": + """Create the inscribed circle of a triangle.""" + return self.construction_manager.create_incircle( + triangle_name, name=name, color=color, + ) + + def create_parallel_line( + self, + segment_name: str, + point_name: str, + *, + length: Optional[float] = None, + name: Optional[str] = None, + color: Optional[str] = None, + ) -> "Segment": + """Create a segment through a point, parallel to a given segment.""" + return self.construction_manager.create_parallel_line( + segment_name, point_name, length=length, name=name, color=color + ) diff --git a/static/client/utils/math_utils.py b/static/client/utils/math_utils.py index 7b4e982e..6c3eaa95 100644 --- a/static/client/utils/math_utils.py +++ b/static/client/utils/math_utils.py @@ -2831,3 +2831,196 @@ def ellipse_tangent_slope_at_angle( slope = world_dy / world_dx return (world_x, world_y), slope + + # ------------------- Construction Geometry Utilities ------------------- + + @staticmethod + def perpendicular_foot( + px: float, py: float, + x1: float, y1: float, + x2: float, y2: float, + ) -> Tuple[float, float]: + """Project a point onto a line defined by two points. + + Uses the vector dot-product projection formula to find the closest + point on the line through (x1, y1)-(x2, y2) to the point (px, py). + + Args: + px: X-coordinate of the point to project + py: Y-coordinate of the point to project + x1: X-coordinate of the first line point + y1: Y-coordinate of the first line point + x2: X-coordinate of the second line point + y2: Y-coordinate of the second line point + + Returns: + (foot_x, foot_y) coordinates of the perpendicular foot + + Raises: + ValueError: If the two line points coincide (degenerate segment) + """ + dx = x2 - x1 + dy = y2 - y1 + len_sq = dx * dx + dy * dy + if len_sq < MathUtils.EPSILON * MathUtils.EPSILON: + raise ValueError("Degenerate segment: endpoints coincide") + + t = ((px - x1) * dx + (py - y1) * dy) / len_sq + foot_x = x1 + t * dx + foot_y = y1 + t * dy + + if not (math.isfinite(foot_x) and math.isfinite(foot_y)): + raise ValueError("Perpendicular foot computation produced non-finite result") + + return foot_x, foot_y + + @staticmethod + def angle_bisector_direction( + vx: float, vy: float, + p1x: float, p1y: float, + p2x: float, p2y: float, + ) -> Tuple[float, float]: + """Compute the unit vector along the angle bisector. + + Given a vertex (vx, vy) and two arm endpoints (p1x, p1y) and + (p2x, p2y), returns the unit direction vector from the vertex + along the bisector of the angle formed by the two arms. + + The bisector direction is found by normalizing each arm vector + and summing them. + + Args: + vx: X-coordinate of the angle vertex + vy: Y-coordinate of the angle vertex + p1x: X-coordinate of the first arm endpoint + p1y: Y-coordinate of the first arm endpoint + p2x: X-coordinate of the second arm endpoint + p2y: Y-coordinate of the second arm endpoint + + Returns: + (dx, dy) unit vector along the bisector + + Raises: + ValueError: If an arm has zero length or the arms are collinear + (180-degree angle, bisector undefined) + """ + # Arm vectors from vertex + a1x = p1x - vx + a1y = p1y - vy + a2x = p2x - vx + a2y = p2y - vy + + len1 = math.sqrt(a1x * a1x + a1y * a1y) + len2 = math.sqrt(a2x * a2x + a2y * a2y) + + if len1 < MathUtils.EPSILON: + raise ValueError("First arm has zero length") + if len2 < MathUtils.EPSILON: + raise ValueError("Second arm has zero length") + + # Normalize + u1x, u1y = a1x / len1, a1y / len1 + u2x, u2y = a2x / len2, a2y / len2 + + # Sum of unit vectors gives bisector direction + bx = u1x + u2x + by = u1y + u2y + + blen = math.sqrt(bx * bx + by * by) + if blen < MathUtils.EPSILON: + raise ValueError("Arms are collinear (180° angle): bisector is undefined") + + if not (math.isfinite(bx / blen) and math.isfinite(by / blen)): + raise ValueError("Bisector computation produced non-finite result") + + return bx / blen, by / blen + + @staticmethod + def circumcenter( + x1: float, y1: float, + x2: float, y2: float, + x3: float, y3: float, + ) -> Tuple[float, float, float]: + """Compute the circumcenter and circumradius of a triangle. + + The circumcircle passes through all three vertices. The center is + found using the determinant-based formula (intersection of + perpendicular bisectors). + + Args: + x1, y1: First vertex + x2, y2: Second vertex + x3, y3: Third vertex + + Returns: + (cx, cy, radius) of the circumscribed circle + + Raises: + ValueError: If the three points are collinear or coincident + """ + d = 2.0 * (x1 * (y2 - y3) + x2 * (y3 - y1) + x3 * (y1 - y2)) + if abs(d) < MathUtils.EPSILON: + raise ValueError("Points are collinear: circumcircle is undefined") + + sq1 = x1 * x1 + y1 * y1 + sq2 = x2 * x2 + y2 * y2 + sq3 = x3 * x3 + y3 * y3 + + cx = (sq1 * (y2 - y3) + sq2 * (y3 - y1) + sq3 * (y1 - y2)) / d + cy = (sq1 * (x3 - x2) + sq2 * (x1 - x3) + sq3 * (x2 - x1)) / d + + radius = math.sqrt((x1 - cx) ** 2 + (y1 - cy) ** 2) + + if not (math.isfinite(cx) and math.isfinite(cy) and math.isfinite(radius)): + raise ValueError("Circumcenter computation produced non-finite result") + + return cx, cy, radius + + @staticmethod + def incenter_and_inradius( + x1: float, y1: float, + x2: float, y2: float, + x3: float, y3: float, + ) -> Tuple[float, float, float]: + """Compute the incenter and inradius of a triangle. + + The incircle is tangent to all three sides. The incenter is the + weighted average of the vertices, weighted by the length of the + opposite side. The inradius is ``2 * area / perimeter``. + + Args: + x1, y1: First vertex + x2, y2: Second vertex + x3, y3: Third vertex + + Returns: + (cx, cy, radius) of the inscribed circle + + Raises: + ValueError: If the triangle is degenerate (zero area) + """ + # Side lengths (opposite to each vertex) + a = math.sqrt((x2 - x3) ** 2 + (y2 - y3) ** 2) # opposite vertex 1 + b = math.sqrt((x1 - x3) ** 2 + (y1 - y3) ** 2) # opposite vertex 2 + c = math.sqrt((x1 - x2) ** 2 + (y1 - y2) ** 2) # opposite vertex 3 + + perimeter = a + b + c + if perimeter < MathUtils.EPSILON: + raise ValueError("Degenerate triangle: zero perimeter") + + # Area via cross product: 2A = |(x2-x1)(y3-y1) - (x3-x1)(y2-y1)| + area = abs((x2 - x1) * (y3 - y1) - (x3 - x1) * (y2 - y1)) / 2.0 + if area < MathUtils.EPSILON: + raise ValueError("Degenerate triangle: zero area") + + # Incenter: weighted average by opposite side lengths + cx = (a * x1 + b * x2 + c * x3) / perimeter + cy = (a * y1 + b * y2 + c * y3) / perimeter + + # Inradius = 2 * area / perimeter + radius = 2.0 * area / perimeter + + if not (math.isfinite(cx) and math.isfinite(cy) and math.isfinite(radius)): + raise ValueError("Incenter computation produced non-finite result") + + return cx, cy, radius diff --git a/static/functions_definitions.py b/static/functions_definitions.py index be04c1f8..40ad2331 100644 --- a/static/functions_definitions.py +++ b/static/functions_definitions.py @@ -1362,6 +1362,247 @@ } } }, + { + "type": "function", + "function": { + "name": "construct_midpoint", + "description": "Constructs a point at the midpoint of a segment or between two named points. Provide either 'segment_name' or both 'p1_name' and 'p2_name'.", + "strict": True, + "parameters": { + "type": "object", + "properties": { + "p1_name": { + "type": ["string", "null"], + "description": "Name of the first point (use with p2_name)" + }, + "p2_name": { + "type": ["string", "null"], + "description": "Name of the second point (use with p1_name)" + }, + "segment_name": { + "type": ["string", "null"], + "description": "Name of the segment whose midpoint to find (alternative to p1_name/p2_name)" + }, + "name": { + "type": ["string", "null"], + "description": "Optional name for the created midpoint" + }, + "color": { + "type": ["string", "null"], + "description": "Optional color for the midpoint" + } + }, + "required": ["p1_name", "p2_name", "segment_name", "name", "color"], + "additionalProperties": False + } + } + }, + { + "type": "function", + "function": { + "name": "construct_perpendicular_bisector", + "description": "Constructs the perpendicular bisector of a segment. Creates a new segment that passes through the midpoint and is perpendicular to the original.", + "strict": True, + "parameters": { + "type": "object", + "properties": { + "segment_name": { + "type": "string", + "description": "Name of the segment to bisect perpendicularly" + }, + "length": { + "type": ["number", "null"], + "description": "Total length of the bisector segment in math units (default: 6.0)" + }, + "name": { + "type": ["string", "null"], + "description": "Optional name for the created bisector segment" + }, + "color": { + "type": ["string", "null"], + "description": "Optional color for the bisector segment" + } + }, + "required": ["segment_name", "length", "name", "color"], + "additionalProperties": False + } + } + }, + { + "type": "function", + "function": { + "name": "construct_perpendicular_from_point", + "description": "Drops a perpendicular from a point to a segment. Creates the foot point on the line and a segment from the original point to the foot.", + "strict": True, + "parameters": { + "type": "object", + "properties": { + "point_name": { + "type": "string", + "description": "Name of the point to project onto the segment" + }, + "segment_name": { + "type": "string", + "description": "Name of the target segment" + }, + "name": { + "type": ["string", "null"], + "description": "Optional name for the perpendicular segment" + }, + "color": { + "type": ["string", "null"], + "description": "Optional color for created drawables" + } + }, + "required": ["point_name", "segment_name", "name", "color"], + "additionalProperties": False + } + } + }, + { + "type": "function", + "function": { + "name": "construct_angle_bisector", + "description": "Constructs a segment along the bisector of an angle. Provide either 'angle_name' for an existing angle, or 'vertex_name', 'p1_name', 'p2_name' to define the angle by three points.", + "strict": True, + "parameters": { + "type": "object", + "properties": { + "vertex_name": { + "type": ["string", "null"], + "description": "Name of the angle vertex point (use with p1_name and p2_name)" + }, + "p1_name": { + "type": ["string", "null"], + "description": "Name of the first arm endpoint (use with vertex_name and p2_name)" + }, + "p2_name": { + "type": ["string", "null"], + "description": "Name of the second arm endpoint (use with vertex_name and p1_name)" + }, + "angle_name": { + "type": ["string", "null"], + "description": "Name of an existing angle to bisect (alternative to vertex/p1/p2)" + }, + "length": { + "type": ["number", "null"], + "description": "Length of the bisector segment in math units (default: 6.0)" + }, + "name": { + "type": ["string", "null"], + "description": "Optional name for the created bisector segment" + }, + "color": { + "type": ["string", "null"], + "description": "Optional color for the bisector segment" + } + }, + "required": ["vertex_name", "p1_name", "p2_name", "angle_name", "length", "name", "color"], + "additionalProperties": False + } + } + }, + { + "type": "function", + "function": { + "name": "construct_parallel_line", + "description": "Constructs a segment through a point that is parallel to a given segment. The new segment is centered on the specified point.", + "strict": True, + "parameters": { + "type": "object", + "properties": { + "segment_name": { + "type": "string", + "description": "Name of the reference segment to be parallel to" + }, + "point_name": { + "type": "string", + "description": "Name of the point the parallel line passes through" + }, + "length": { + "type": ["number", "null"], + "description": "Total length of the parallel segment in math units (default: 6.0)" + }, + "name": { + "type": ["string", "null"], + "description": "Optional name for the created parallel segment" + }, + "color": { + "type": ["string", "null"], + "description": "Optional color for the parallel segment" + } + }, + "required": ["segment_name", "point_name", "length", "name", "color"], + "additionalProperties": False + } + } + }, + { + "type": "function", + "function": { + "name": "construct_circumcircle", + "description": "Constructs the circumscribed circle (circumcircle) of a triangle or three points. The circumcircle passes through all three vertices. Provide either triangle_name or all three point names.", + "strict": True, + "parameters": { + "type": "object", + "properties": { + "triangle_name": { + "type": ["string", "null"], + "description": "Name of an existing triangle (alternative to specifying three points)" + }, + "p1_name": { + "type": ["string", "null"], + "description": "Name of the first point (used with p2_name and p3_name instead of triangle_name)" + }, + "p2_name": { + "type": ["string", "null"], + "description": "Name of the second point" + }, + "p3_name": { + "type": ["string", "null"], + "description": "Name of the third point" + }, + "name": { + "type": ["string", "null"], + "description": "Optional name for the created circumcircle" + }, + "color": { + "type": ["string", "null"], + "description": "Optional color for the circumcircle" + } + }, + "required": ["triangle_name", "p1_name", "p2_name", "p3_name", "name", "color"], + "additionalProperties": False + } + } + }, + { + "type": "function", + "function": { + "name": "construct_incircle", + "description": "Constructs the inscribed circle (incircle) of a triangle. The incircle is tangent to all three sides of the triangle.", + "strict": True, + "parameters": { + "type": "object", + "properties": { + "triangle_name": { + "type": "string", + "description": "Name of an existing triangle" + }, + "name": { + "type": ["string", "null"], + "description": "Optional name for the created incircle" + }, + "color": { + "type": ["string", "null"], + "description": "Optional color for the incircle" + } + }, + "required": ["triangle_name", "name", "color"], + "additionalProperties": False + } + } + }, { "type": "function", "function": {