diff --git a/backend/data_types.py b/backend/data_types.py index 9a94e74..99e2385 100644 --- a/backend/data_types.py +++ b/backend/data_types.py @@ -860,6 +860,216 @@ def _apply_markup_overlay(image: np.ndarray, field: DataField | None, spec: dict return np.asarray(pil_image, dtype=np.uint8) +def _angle_label_base_position(spec: dict[str, Any]) -> tuple[float, float]: + x1 = float(np.clip(spec.get("x1", 0.0), 0.0, 1.0)) + y1 = float(np.clip(spec.get("y1", 0.0), 0.0, 1.0)) + xm = float(np.clip(spec.get("xm", 0.5), 0.0, 1.0)) + ym = float(np.clip(spec.get("ym", 0.5), 0.0, 1.0)) + x2 = float(np.clip(spec.get("x2", 1.0), 0.0, 1.0)) + y2 = float(np.clip(spec.get("y2", 0.0), 0.0, 1.0)) + + va = np.array([x1 - xm, y1 - ym], dtype=np.float64) + vb = np.array([x2 - xm, y2 - ym], dtype=np.float64) + len_a = float(np.hypot(va[0], va[1])) + len_b = float(np.hypot(vb[0], vb[1])) + if len_a <= 1e-6 or len_b <= 1e-6: + return (float(np.clip(xm, 0.0, 1.0)), float(np.clip(ym - 0.14, 0.0, 1.0))) + + unit = np.array([ + va[0] / len_a + vb[0] / len_b, + va[1] / len_a + vb[1] / len_b, + ], dtype=np.float64) + unit_len = float(np.hypot(unit[0], unit[1])) + if unit_len <= 1e-6: + bisector = np.array([0.0, -1.0], dtype=np.float64) + else: + bisector = unit / unit_len + + return ( + float(np.clip(xm + bisector[0] * 0.14, 0.0, 1.0)), + float(np.clip(ym + bisector[1] * 0.14, 0.0, 1.0)), + ) + + +def _sanitize_angle_overlay_thickness(value: Any, default: float = 1.35) -> float: + try: + numeric = float(value) + except (TypeError, ValueError): + numeric = default + if not np.isfinite(numeric): + numeric = default + return float(np.clip(numeric, 0.35, 6.0)) + + +def _sanitize_angle_overlay_color(value: Any, default: str = "#ff0000") -> str: + if isinstance(value, str): + text = value.strip() + if len(text) == 4 and text.startswith("#"): + text = "#" + "".join(ch * 2 for ch in text[1:]) + if len(text) == 7 and text.startswith("#"): + try: + int(text[1:], 16) + return text.lower() + except ValueError: + pass + return default + + +def _hex_to_rgb(color: str) -> tuple[int, int, int]: + return tuple(int(color[index:index + 2], 16) for index in (1, 3, 5)) + + +def _mix_rgb(color_a: tuple[int, int, int], color_b: tuple[int, int, int], weight: float) -> tuple[int, int, int]: + alpha = float(np.clip(weight, 0.0, 1.0)) + return tuple( + int(round(a * (1.0 - alpha) + b * alpha)) + for a, b in zip(color_a, color_b) + ) + + +def _draw_round_line(draw, start: tuple[float, float], end: tuple[float, float], fill, width: int) -> None: + width = max(1, int(round(width))) + draw.line((start, end), fill=fill, width=width) + radius = max(1, int(np.ceil(width / 2.0))) + for px, py in (start, end): + draw.ellipse((px - radius, py - radius, px + radius, py + radius), fill=fill) + + +def _draw_dashed_polyline( + draw, + points: list[tuple[float, float]], + fill, + width: int, + dash_length: float, + gap_length: float, +) -> None: + if len(points) < 2: + return + + dash_length = max(1.0, float(dash_length)) + gap_length = max(1.0, float(gap_length)) + cycle_length = dash_length + gap_length + traversed = 0.0 + + for start, end in zip(points[:-1], points[1:]): + seg_len = float(np.hypot(end[0] - start[0], end[1] - start[1])) + if seg_len <= 1e-6: + continue + if ((traversed + 0.5 * seg_len) % cycle_length) < dash_length: + _draw_round_line(draw, start, end, fill=fill, width=width) + traversed += seg_len + + +def _apply_angle_measure_overlay(image: np.ndarray, field: DataField | None, spec: dict[str, Any]) -> np.ndarray: + from PIL import Image, ImageDraw + + current = np.asarray(image, dtype=np.uint8) + if current.ndim == 2: + current = np.repeat(current[:, :, np.newaxis], 3, axis=2) + + pil_image = Image.fromarray(current.copy()).convert("RGBA") + draw = ImageDraw.Draw(pil_image, "RGBA") + field_width = max(1, int(field.xres)) if isinstance(field, DataField) else max(1, current.shape[1]) + field_height = max(1, int(field.yres)) if isinstance(field, DataField) else max(1, current.shape[0]) + longest_dim = max(field_width, field_height) + + x1 = float(np.clip(spec.get("x1", 0.0), 0.0, 1.0)) + y1 = float(np.clip(spec.get("y1", 0.0), 0.0, 1.0)) + xm = float(np.clip(spec.get("xm", 0.5), 0.0, 1.0)) + ym = float(np.clip(spec.get("ym", 0.5), 0.0, 1.0)) + x2 = float(np.clip(spec.get("x2", 1.0), 0.0, 1.0)) + y2 = float(np.clip(spec.get("y2", 0.0), 0.0, 1.0)) + label_dx = float(spec.get("label_dx", 0.0) or 0.0) + label_dy = float(spec.get("label_dy", 0.0) or 0.0) + angle_deg = float(spec.get("angle_deg", 0.0) or 0.0) + color_hex = _sanitize_angle_overlay_color(spec.get("color", "#ff0000")) + line_thickness = _sanitize_angle_overlay_thickness(spec.get("line_thickness", 1.35)) + base_rgb = _hex_to_rgb(color_hex) + arc_rgb = _mix_rgb(base_rgb, (255, 255, 255), 0.42) + badge_text_rgb = _mix_rgb(base_rgb, (255, 255, 255), 0.72) + badge_border_rgb = _mix_rgb(base_rgb, (255, 255, 255), 0.32) + + line_width = max(1, int(round(longest_dim * line_thickness / 100.0))) + arc_width = max(1, int(round(longest_dim * max(0.85, line_thickness * 0.78) / 100.0))) + line_color = (*base_rgb, 255) + arc_color = (*arc_rgb, 242) + + points = [ + (x1 * field_width, y1 * field_height), + (xm * field_width, ym * field_height), + (x2 * field_width, y2 * field_height), + ] + _draw_round_line(draw, points[0], points[1], fill=line_color, width=line_width) + _draw_round_line(draw, points[1], points[2], fill=line_color, width=line_width) + + va = np.array([x1 - xm, y1 - ym], dtype=np.float64) + vb = np.array([x2 - xm, y2 - ym], dtype=np.float64) + len_a = float(np.hypot(va[0], va[1])) + len_b = float(np.hypot(vb[0], vb[1])) + if len_a > 1e-6 and len_b > 1e-6: + radius = min(0.12, 0.38 * min(len_a, len_b)) + start_angle = float(np.arctan2(va[1], va[0])) + delta = float(np.arctan2(va[0] * vb[1] - va[1] * vb[0], np.dot(va, vb))) + arc_points = [] + for theta in np.linspace(start_angle, start_angle + delta, 48): + arc_points.append(( + (xm + radius * np.cos(theta)) * field_width, + (ym + radius * np.sin(theta)) * field_height, + )) + if len(arc_points) >= 2: + _draw_dashed_polyline( + draw, + arc_points, + fill=arc_color, + width=arc_width, + dash_length=max(4.0, longest_dim * 0.05), + gap_length=max(3.0, longest_dim * 0.03), + ) + + base_label_x, base_label_y = _angle_label_base_position(spec) + label_x = float(np.clip(base_label_x + label_dx, 0.0, 1.0)) + label_y = float(np.clip(base_label_y + label_dy, 0.0, 1.0)) + label_text = f"{angle_deg:.1f} deg" + text_image = _render_overlay_text( + label_text, + max(10, int(round(longest_dim / 26.0))), + badge_text_rgb, + ) + + bg_pad_x = max(5, int(round(text_image.size[0] * 0.16))) + bg_pad_y = max(3, int(round(text_image.size[1] * 0.18))) + bg_width = text_image.size[0] + 2 * bg_pad_x + bg_height = text_image.size[1] + 2 * bg_pad_y + center_x = int(round(label_x * field_width)) + center_y = int(round(label_y * field_height)) + bg_left = max(0, min(field_width - bg_width, center_x - bg_width // 2)) + bg_top = max(0, min(field_height - bg_height, center_y - bg_height // 2)) + bg_right = bg_left + bg_width + bg_bottom = bg_top + bg_height + + shadow_offset = max(1, int(round(bg_height * 0.08))) + draw.rounded_rectangle( + ( + bg_left, + min(field_height, bg_top + shadow_offset), + bg_right, + min(field_height, bg_bottom + shadow_offset), + ), + radius=max(6, bg_height // 2), + fill=(15, 23, 42, 86), + ) + draw.rounded_rectangle( + (bg_left, bg_top, bg_right, bg_bottom), + radius=max(6, bg_height // 2), + fill=(15, 23, 42, 230), + outline=(*badge_border_rgb, 115), + width=1, + ) + pil_image.paste(text_image, (bg_left + bg_pad_x, bg_top + bg_pad_y), text_image) + + return np.asarray(pil_image.convert("RGB"), dtype=np.uint8) + + def render_datafield_preview(df: DataField, colormap: Any = "gray") -> np.ndarray: current = datafield_to_uint8(df, colormap) for overlay in df.overlays: @@ -870,6 +1080,8 @@ def render_datafield_preview(df: DataField, colormap: Any = "gray") -> np.ndarra current = _apply_annotation_overlay(current, df, colormap, overlay) elif kind == "markup": current = _apply_markup_overlay(current, df, overlay) + elif kind == "angle_measure": + current = _apply_angle_measure_overlay(current, df, overlay) return current diff --git a/backend/node_menu.py b/backend/node_menu.py index 8a1cd46..85410a0 100644 --- a/backend/node_menu.py +++ b/backend/node_menu.py @@ -34,6 +34,7 @@ MENU_LAYOUT: dict[str, list[str]] = { "Overlay": [ "Markup", "Annotations", + "AngleMeasure", ], "Modify": [ "ColormapAdjust", diff --git a/backend/nodes/__init__.py b/backend/nodes/__init__.py index 4fae0ad..1e91d83 100644 --- a/backend/nodes/__init__.py +++ b/backend/nodes/__init__.py @@ -38,6 +38,7 @@ from backend.nodes import ( color_map, font_node, annotations, + angle_measure, markup, preview_image, view_3d, diff --git a/backend/nodes/angle_measure.py b/backend/nodes/angle_measure.py new file mode 100644 index 0000000..83e8e52 --- /dev/null +++ b/backend/nodes/angle_measure.py @@ -0,0 +1,215 @@ +from __future__ import annotations + +import numpy as np + +from backend.data_types import ( + DataField, + ImageData, + MeasureTable, + _apply_angle_measure_overlay, + encode_preview, + image_metadata, + image_to_uint8, + render_datafield_preview, +) +from backend.execution_context import emit_overlay, emit_table +from backend.node_registry import register_node +from backend.nodes.helpers import _normalize_markup_color + + +def _clamp01(value: float) -> float: + return float(np.clip(value, 0.0, 1.0)) + + +def _sanitize_line_thickness(value: float | None, default: float = 1.35) -> float: + try: + numeric = float(value) + except (TypeError, ValueError): + numeric = default + if not np.isfinite(numeric): + numeric = default + return float(np.clip(numeric, 0.35, 6.0)) + + +def _angle_metrics(ax: float, ay: float, vx: float, vy: float, bx: float, by: float) -> tuple[float, float, float]: + va = np.array([ax - vx, ay - vy], dtype=np.float64) + vb = np.array([bx - vx, by - vy], dtype=np.float64) + len_a = float(np.hypot(va[0], va[1])) + len_b = float(np.hypot(vb[0], vb[1])) + + if len_a <= 1e-12 or len_b <= 1e-12: + return (0.0, len_a, len_b) + + cos_theta = float(np.dot(va, vb) / (len_a * len_b)) + cos_theta = float(np.clip(cos_theta, -1.0, 1.0)) + angle_deg = float(np.degrees(np.arccos(cos_theta))) + return (angle_deg, len_a, len_b) + + +def _angle_overlay_spec( + x1: float, + y1: float, + xm: float, + ym: float, + x2: float, + y2: float, + angle_deg: float, + label_dx: float, + label_dy: float, + line_thickness: float, + color: str, +) -> dict[str, float | str]: + return { + "kind": "angle_measure", + "x1": x1, + "y1": y1, + "xm": xm, + "ym": ym, + "x2": x2, + "y2": y2, + "angle_deg": float(angle_deg), + "label_dx": float(label_dx), + "label_dy": float(label_dy), + "line_thickness": float(line_thickness), + "color": str(color), + } + + +@register_node(display_name="Angle Measure") +class AngleMeasure: + _CUSTOM_PREVIEW = True + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "input": ("ANNOTATION_SOURCE", {"label": "Input"}), + "color": ("STRING", {"default": "#ff0000", "color_picker": True}), + "line_thickness": ("FLOAT", { + "default": 1.35, + "min": 0.35, + "max": 6.0, + "step": 0.05, + "label": "line thickness", + "hide_when_input_connected": "line_thickness_input", + }), + "x1": ("FLOAT", {"default": 0.22, "min": 0.0, "max": 1.0, "step": 0.01, "hidden": True}), + "y1": ("FLOAT", {"default": 0.72, "min": 0.0, "max": 1.0, "step": 0.01, "hidden": True}), + "xm": ("FLOAT", {"default": 0.50, "min": 0.0, "max": 1.0, "step": 0.01, "hidden": True}), + "ym": ("FLOAT", {"default": 0.50, "min": 0.0, "max": 1.0, "step": 0.01, "hidden": True}), + "x2": ("FLOAT", {"default": 0.78, "min": 0.0, "max": 1.0, "step": 0.01, "hidden": True}), + "y2": ("FLOAT", {"default": 0.28, "min": 0.0, "max": 1.0, "step": 0.01, "hidden": True}), + "label_dx": ("FLOAT", {"default": 0.0, "min": -1.0, "max": 1.0, "step": 0.01, "hidden": True}), + "label_dy": ("FLOAT", {"default": 0.0, "min": -1.0, "max": 1.0, "step": 0.01, "hidden": True}), + }, + "optional": { + "line_thickness_input": ("FLOAT", {"label": "line thickness"}), + }, + } + + RETURN_TYPES = ("ANNOTATION_SOURCE", "MEASURE_TABLE") + RETURN_NAMES = ("output", "measurements") + FUNCTION = "process" + + DESCRIPTION = ( + "Measure the included angle between two draggable line segments over a DATA_FIELD or IMAGE. " + "Drag either endpoint to change that arm, drag the middle joint to move the whole widget, " + "drag the angle label to reposition it, choose the overlay color, and adjust line thickness " + "with the widget or a FLOAT input." + ) + + def process( + self, + input, + color: str, + line_thickness: float, + x1: float, + y1: float, + xm: float, + ym: float, + x2: float, + y2: float, + label_dx: float, + label_dy: float, + line_thickness_input: float | None = None, + ) -> tuple: + x1 = _clamp01(x1) + y1 = _clamp01(y1) + xm = _clamp01(xm) + ym = _clamp01(ym) + x2 = _clamp01(x2) + y2 = _clamp01(y2) + label_dx = float(np.clip(label_dx, -1.0, 1.0)) + label_dy = float(np.clip(label_dy, -1.0, 1.0)) + resolved_color = _normalize_markup_color(color, default="#ff0000") + resolved_line_thickness = _sanitize_line_thickness( + line_thickness_input if line_thickness_input is not None else line_thickness, + ) + + if isinstance(input, DataField): + preview_base = render_datafield_preview(input, input.colormap) + ax = float(input.xoff + x1 * input.xreal) + ay = float(input.yoff + y1 * input.yreal) + vx = float(input.xoff + xm * input.xreal) + vy = float(input.yoff + ym * input.yreal) + bx = float(input.xoff + x2 * input.xreal) + by = float(input.yoff + y2 * input.yreal) + length_unit = input.si_unit_xy + else: + preview_base = image_to_uint8(input) + height, width = preview_base.shape[:2] + ax = float(x1 * max(width - 1, 0)) + ay = float(y1 * max(height - 1, 0)) + vx = float(xm * max(width - 1, 0)) + vy = float(ym * max(height - 1, 0)) + bx = float(x2 * max(width - 1, 0)) + by = float(y2 * max(height - 1, 0)) + length_unit = "px" + + angle_deg, length_a, length_b = _angle_metrics(ax, ay, vx, vy, bx, by) + angle_overlay = _angle_overlay_spec( + x1=x1, + y1=y1, + xm=xm, + ym=ym, + x2=x2, + y2=y2, + angle_deg=angle_deg, + label_dx=label_dx, + label_dy=label_dy, + line_thickness=resolved_line_thickness, + color=resolved_color, + ) + table = MeasureTable([ + {"quantity": "Angle", "value": angle_deg, "unit": "deg"}, + {"quantity": "Arm A length", "value": length_a, "unit": length_unit}, + {"quantity": "Arm B length", "value": length_b, "unit": length_unit}, + {"quantity": "Arm A x", "value": ax, "unit": length_unit}, + {"quantity": "Arm A y", "value": ay, "unit": length_unit}, + {"quantity": "Vertex x", "value": vx, "unit": length_unit}, + {"quantity": "Vertex y", "value": vy, "unit": length_unit}, + {"quantity": "Arm B x", "value": bx, "unit": length_unit}, + {"quantity": "Arm B y", "value": by, "unit": length_unit}, + ]) + + if isinstance(input, DataField): + output = input.replace( + overlays=[ + *input.overlays, + angle_overlay, + ], + ) + else: + output = ImageData( + _apply_angle_measure_overlay(preview_base, None, angle_overlay), + metadata=image_metadata(input), + ) + + emit_overlay({ + "section_title": "Angle", + "image": encode_preview(preview_base), + **angle_overlay, + }) + emit_table(table) + + return (output, table) diff --git a/backend/nodes/cursors.py b/backend/nodes/cursors.py index 5488d70..faa15b7 100644 --- a/backend/nodes/cursors.py +++ b/backend/nodes/cursors.py @@ -102,12 +102,12 @@ class Cursors: }) table = MeasureTable([ + {"quantity": "dx", "value": xb - xa, "unit": x_unit}, + {"quantity": "dy", "value": yb - ya, "unit": y_unit}, {"quantity": "A x", "value": xa, "unit": x_unit}, {"quantity": "A y", "value": ya, "unit": y_unit}, {"quantity": "B x", "value": xb, "unit": x_unit}, {"quantity": "B y", "value": yb, "unit": y_unit}, - {"quantity": "dx", "value": xb - xa, "unit": x_unit}, - {"quantity": "dy", "value": yb - ya, "unit": y_unit}, ]) return (table, ((x1, y1), (x2, y2))) diff --git a/backend/nodes/histogram.py b/backend/nodes/histogram.py index 9199f33..dba4ca5 100644 --- a/backend/nodes/histogram.py +++ b/backend/nodes/histogram.py @@ -87,11 +87,11 @@ class Histogram: }) table = MeasureTable([ + {"quantity": "delta X", "value": xb - xa, "unit": field.si_unit_z}, + {"quantity": "delta Y", "value": yb - ya, "unit": count_unit}, {"quantity": "A position", "value": xa, "unit": field.si_unit_z}, {"quantity": "A count", "value": ya, "unit": count_unit}, {"quantity": "B position", "value": xb, "unit": field.si_unit_z}, {"quantity": "B count", "value": yb, "unit": count_unit}, - {"quantity": "delta X", "value": xb - xa, "unit": field.si_unit_z}, - {"quantity": "delta Y", "value": yb - ya, "unit": count_unit}, ]) return (table, ((x1, y1), (x2, y2))) diff --git a/frontend/src/AngleMeasureOverlay.jsx b/frontend/src/AngleMeasureOverlay.jsx new file mode 100644 index 0000000..342e149 --- /dev/null +++ b/frontend/src/AngleMeasureOverlay.jsx @@ -0,0 +1,214 @@ +import React, { useRef, useState, useCallback } from 'react'; + +import { + getAngleLabelBasePosition, + getAngleLabelPosition, + measureAngleDegrees, + moveAngleWidget, + round3, +} from './angleMeasureGeometry.js'; + +function clamp01(value) { + return Math.max(0, Math.min(1, Number(value) || 0)); +} + +function sanitizeHexColor(value, fallback = '#ff0000') { + if (typeof value !== 'string') return fallback; + const text = value.trim(); + return /^#[0-9a-fA-F]{6}$/.test(text) ? text.toLowerCase() : fallback; +} + +function hexToRgb(value) { + const color = sanitizeHexColor(value); + return { + r: parseInt(color.slice(1, 3), 16), + g: parseInt(color.slice(3, 5), 16), + b: parseInt(color.slice(5, 7), 16), + }; +} + +function mixColor(baseColor, mixWith, weight) { + const alpha = Math.max(0, Math.min(1, Number(weight) || 0)); + const base = hexToRgb(baseColor); + const target = hexToRgb(mixWith); + const r = Math.round((base.r * (1 - alpha)) + (target.r * alpha)); + const g = Math.round((base.g * (1 - alpha)) + (target.g * alpha)); + const b = Math.round((base.b * (1 - alpha)) + (target.b * alpha)); + return `rgb(${r}, ${g}, ${b})`; +} + +function formatAngle(value) { + const numeric = Number(value); + if (!Number.isFinite(numeric)) return '0.0 deg'; + return `${numeric.toFixed(1)} deg`; +} + +function buildAngleArcPath(x1, y1, xm, ym, x2, y2) { + const va = { x: x1 - xm, y: y1 - ym }; + const vb = { x: x2 - xm, y: y2 - ym }; + const lenA = Math.hypot(va.x, va.y); + const lenB = Math.hypot(vb.x, vb.y); + if (lenA <= 1e-6 || lenB <= 1e-6) return ''; + + const radius = Math.min(0.12, 0.38 * Math.min(lenA, lenB)); + const start = { x: xm + (va.x / lenA) * radius, y: ym + (va.y / lenA) * radius }; + const end = { x: xm + (vb.x / lenB) * radius, y: ym + (vb.y / lenB) * radius }; + const cross = (va.x * vb.y) - (va.y * vb.x); + + return [ + `M ${start.x * 100} ${start.y * 100}`, + `A ${radius * 100} ${radius * 100} 0 0 ${cross >= 0 ? 1 : 0} ${end.x * 100} ${end.y * 100}`, + ].join(' '); +} + +export default function AngleMeasureOverlay({ + image, + x1, + y1, + xm, + ym, + x2, + y2, + labelDx, + labelDy, + angleDeg, + color, + lineThickness, + nodeId, + onWidgetChange, +}) { + const containerRef = useRef(null); + const [dragging, setDragging] = useState(null); + const resolvedColor = sanitizeHexColor(color, '#ff0000'); + const resolvedLineThickness = Math.max(0.35, Math.min(6, Number(lineThickness) || 1.35)); + const resolvedArcThickness = Math.max(0.85, resolvedLineThickness * 0.78); + const resolvedArcColor = mixColor(resolvedColor, '#ffffff', 0.42); + const resolvedMidColor = mixColor(resolvedColor, '#ffffff', 0.72); + const resolvedBadgeTextColor = mixColor(resolvedColor, '#ffffff', 0.72); + const resolvedBadgeBorderColor = mixColor(resolvedColor, '#ffffff', 0.32); + + const getCoords = useCallback((event) => { + const rect = containerRef.current.getBoundingClientRect(); + return { + fx: clamp01((event.clientX - rect.left) / rect.width), + fy: clamp01((event.clientY - rect.top) / rect.height), + }; + }, []); + + const updateWidgets = useCallback((updates) => { + Object.entries(updates).forEach(([name, value]) => { + onWidgetChange(nodeId, name, value); + }); + }, [nodeId, onWidgetChange]); + + const onPointerDown = useCallback((handle) => (event) => { + event.stopPropagation(); + event.preventDefault(); + event.currentTarget.setPointerCapture(event.pointerId); + + if (handle === 'mid') { + const start = getCoords(event); + setDragging({ + handle, + start, + points: { x1, y1, xm, ym, x2, y2 }, + }); + return; + } + + setDragging({ handle }); + }, [getCoords, x1, y1, xm, ym, x2, y2]); + + const onPointerMove = useCallback((event) => { + if (!dragging || !containerRef.current) return; + const { fx, fy } = getCoords(event); + + if (dragging.handle === 'mid') { + updateWidgets(moveAngleWidget( + dragging.points, + fx - dragging.start.fx, + fy - dragging.start.fy, + )); + return; + } + + if (dragging.handle === 'p1') { + updateWidgets({ x1: round3(fx), y1: round3(fy) }); + } else if (dragging.handle === 'p2') { + updateWidgets({ x2: round3(fx), y2: round3(fy) }); + } else if (dragging.handle === 'label') { + const base = getAngleLabelBasePosition(x1, y1, xm, ym, x2, y2); + updateWidgets({ + label_dx: round3(fx - base.x), + label_dy: round3(fy - base.y), + }); + } + }, [dragging, getCoords, updateWidgets, x1, y1, xm, ym, x2, y2]); + + const onPointerUp = useCallback(() => { + setDragging(null); + }, []); + + const displayedAngle = Number.isFinite(Number(angleDeg)) + ? Number(angleDeg) + : measureAngleDegrees(x1, y1, xm, ym, x2, y2); + const arcPath = buildAngleArcPath(x1, y1, xm, ym, x2, y2); + const labelPosition = getAngleLabelPosition({ x1, y1, xm, ym, x2, y2 }, labelDx, labelDy); + + return ( +