angle node kind of working

This commit is contained in:
2026-03-27 22:37:09 -07:00
parent e10a30c08f
commit 3752e1c733
12 changed files with 993 additions and 4 deletions

View File

@@ -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

View File

@@ -34,6 +34,7 @@ MENU_LAYOUT: dict[str, list[str]] = {
"Overlay": [
"Markup",
"Annotations",
"AngleMeasure",
],
"Modify": [
"ColormapAdjust",

View File

@@ -38,6 +38,7 @@ from backend.nodes import (
color_map,
font_node,
annotations,
angle_measure,
markup,
preview_image,
view_3d,

View File

@@ -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)

View File

@@ -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)))

View File

@@ -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)))

View File

@@ -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 (
<div
ref={containerRef}
className="nodrag nowheel angle-overlay"
style={{
'--angle-line-color': resolvedColor,
'--angle-arc-color': resolvedArcColor,
'--angle-end-handle-color': resolvedColor,
'--angle-mid-handle-color': resolvedMidColor,
'--angle-badge-text-color': resolvedBadgeTextColor,
'--angle-badge-border-color': resolvedBadgeBorderColor,
'--angle-line-thickness': `${resolvedLineThickness}`,
'--angle-arc-thickness': `${resolvedArcThickness}`,
}}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onLostPointerCapture={onPointerUp}
>
<img src={image} alt="angle source" draggable={false} className="angle-image" />
<svg className="angle-svg" viewBox="0 0 100 100" preserveAspectRatio="none">
<line className="angle-line" x1={x1 * 100} y1={y1 * 100} x2={xm * 100} y2={ym * 100} />
<line className="angle-line" x1={xm * 100} y1={ym * 100} x2={x2 * 100} y2={y2 * 100} />
{arcPath && <path className="angle-arc" d={arcPath} />}
</svg>
<div
className="angle-badge"
style={{ left: `${labelPosition.x * 100}%`, top: `${labelPosition.y * 100}%` }}
onPointerDown={onPointerDown('label')}
>
{formatAngle(displayedAngle)}
</div>
<div
className="angle-handle angle-handle-end"
style={{ left: `${x1 * 100}%`, top: `${y1 * 100}%` }}
onPointerDown={onPointerDown('p1')}
>
A
</div>
<div
className="angle-handle angle-handle-mid"
style={{ left: `${xm * 100}%`, top: `${ym * 100}%` }}
onPointerDown={onPointerDown('mid')}
>
V
</div>
<div
className="angle-handle angle-handle-end"
style={{ left: `${x2 * 100}%`, top: `${y2 * 100}%` }}
onPointerDown={onPointerDown('p2')}
>
B
</div>
</div>
);
}

View File

@@ -7,6 +7,7 @@ const CrossSectionOverlay = lazy(() => import('./CrossSectionOverlay'));
const CropBoxOverlay = lazy(() => import('./CropBoxOverlay'));
const MaskPaintOverlay = lazy(() => import('./MaskPaintOverlay'));
const MarkupOverlay = lazy(() => import('./MarkupOverlay'));
const AngleMeasureOverlay = lazy(() => import('./AngleMeasureOverlay'));
import {
DATA_TYPES, SOCKET_WIDGET_TYPES, TYPE_COLORS, CAT_COLORS,
@@ -1018,6 +1019,8 @@ function CustomNode({ id, data }) {
? 'Mask'
: data.overlay?.kind === 'markup'
? 'Markup'
: data.overlay?.kind === 'angle_measure'
? 'Angle'
: data.overlay?.kind === 'crop_box'
? 'Crop'
: data.overlay?.kind === 'cursor_points'
@@ -1301,6 +1304,25 @@ function CustomNode({ id, data }) {
nodeId={id}
onWidgetChange={ctx.onWidgetChange}
/>
) : data.overlay.kind === 'angle_measure' ? (
<AngleMeasureOverlay
image={data.overlay.image}
x1={data.widgetValues.x1 ?? data.overlay.x1}
y1={data.widgetValues.y1 ?? data.overlay.y1}
xm={data.widgetValues.xm ?? data.overlay.xm}
ym={data.widgetValues.ym ?? data.overlay.ym}
x2={data.widgetValues.x2 ?? data.overlay.x2}
y2={data.widgetValues.y2 ?? data.overlay.y2}
labelDx={data.widgetValues.label_dx ?? data.overlay.label_dx ?? 0}
labelDy={data.widgetValues.label_dy ?? data.overlay.label_dy ?? 0}
angleDeg={data.overlay.angle_deg}
color={data.widgetValues.color ?? data.overlay.color ?? '#ff0000'}
lineThickness={connectedInputs?.has('line_thickness_input')
? (data.overlay.line_thickness ?? data.widgetValues.line_thickness ?? 1.35)
: (data.widgetValues.line_thickness ?? data.overlay.line_thickness ?? 1.35)}
nodeId={id}
onWidgetChange={ctx.onWidgetChange}
/>
) : (
<CrossSectionOverlay
image={data.overlay.image}

View File

@@ -0,0 +1,77 @@
function clamp01(value) {
return Math.max(0, Math.min(1, Number(value) || 0));
}
export function round3(value) {
return Number.parseFloat(Number(value).toFixed(3));
}
export function getAngleLabelBasePosition(x1, y1, xm, ym, x2, y2) {
const va = { x: Number(x1) - Number(xm), y: Number(y1) - Number(ym) };
const vb = { x: Number(x2) - Number(xm), y: Number(y2) - Number(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 { x: clamp01(xm), y: clamp01(Number(ym) - 0.14) };
}
const unit = {
x: (va.x / lenA) + (vb.x / lenB),
y: (va.y / lenA) + (vb.y / lenB),
};
const unitLength = Math.hypot(unit.x, unit.y);
const bisector = unitLength <= 1e-6
? { x: 0, y: -1 }
: { x: unit.x / unitLength, y: unit.y / unitLength };
return {
x: clamp01(Number(xm) + bisector.x * 0.14),
y: clamp01(Number(ym) + bisector.y * 0.14),
};
}
export function getAngleLabelPosition(points, labelDx = 0, labelDy = 0) {
const base = getAngleLabelBasePosition(points.x1, points.y1, points.xm, points.ym, points.x2, points.y2);
return {
x: clamp01(base.x + (Number(labelDx) || 0)),
y: clamp01(base.y + (Number(labelDy) || 0)),
};
}
export function moveAngleWidget(points, dx, dy) {
const nextDx = Number(dx) || 0;
const nextDy = Number(dy) || 0;
const xs = [points.x1, points.xm, points.x2];
const ys = [points.y1, points.ym, points.y2];
const minX = Math.min(...xs);
const maxX = Math.max(...xs);
const minY = Math.min(...ys);
const maxY = Math.max(...ys);
const clampedDx = Math.max(-minX, Math.min(1 - maxX, nextDx));
const clampedDy = Math.max(-minY, Math.min(1 - maxY, nextDy));
return {
x1: round3(clamp01(points.x1 + clampedDx)),
y1: round3(clamp01(points.y1 + clampedDy)),
xm: round3(clamp01(points.xm + clampedDx)),
ym: round3(clamp01(points.ym + clampedDy)),
x2: round3(clamp01(points.x2 + clampedDx)),
y2: round3(clamp01(points.y2 + clampedDy)),
};
}
export function measureAngleDegrees(x1, y1, xm, ym, x2, y2) {
const ax = Number(x1) - Number(xm);
const ay = Number(y1) - Number(ym);
const bx = Number(x2) - Number(xm);
const by = Number(y2) - Number(ym);
const lenA = Math.hypot(ax, ay);
const lenB = Math.hypot(bx, by);
if (lenA <= 1e-12 || lenB <= 1e-12) return 0;
const cosTheta = ((ax * bx) + (ay * by)) / (lenA * lenB);
const clamped = Math.max(-1, Math.min(1, cosTheta));
return Math.acos(clamped) * (180 / Math.PI);
}

View File

@@ -932,6 +932,105 @@ html, body, #root {
opacity: 0.9;
}
.angle-overlay {
--angle-line-color: #ff0000;
--angle-arc-color: rgb(255, 107, 107);
--angle-end-handle-color: #ff0000;
--angle-mid-handle-color: rgb(255, 184, 184);
--angle-badge-text-color: rgb(255, 184, 184);
--angle-badge-border-color: rgb(255, 82, 82);
--angle-line-thickness: 1.35;
--angle-arc-thickness: 1.05;
position: relative;
overflow: hidden;
user-select: none;
touch-action: none;
background: var(--bg-deep);
border: 1px solid var(--border-default);
border-radius: 6px;
}
.angle-image {
width: 100%;
display: block;
}
.angle-svg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.angle-line {
stroke: var(--angle-line-color);
stroke-width: var(--angle-line-thickness);
stroke-linecap: round;
}
.angle-arc {
fill: none;
stroke: var(--angle-arc-color);
stroke-width: var(--angle-arc-thickness);
stroke-dasharray: 5 3;
opacity: 0.95;
}
.angle-handle {
position: absolute;
width: 16px;
height: 16px;
border-radius: 50%;
transform: translate(-50%, -50%);
display: flex;
align-items: center;
justify-content: center;
font-size: 9px;
font-weight: 700;
color: var(--bg-deep);
cursor: grab;
box-shadow: 0 0 6px var(--marker-shadow);
z-index: 1;
}
.angle-handle:active {
cursor: grabbing;
}
.angle-handle-end {
background: var(--angle-end-handle-color);
border: 1px solid var(--marker-border);
}
.angle-handle-mid {
width: 18px;
height: 18px;
background: var(--angle-mid-handle-color);
border: 2px solid var(--marker-border);
}
.angle-badge {
position: absolute;
transform: translate(-50%, -50%);
padding: 3px 7px;
border-radius: 999px;
background: rgba(15, 23, 42, 0.9);
border: 1px solid var(--angle-badge-border-color);
color: var(--angle-badge-text-color);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.01em;
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.35);
cursor: grab;
user-select: none;
z-index: 1;
}
.angle-badge:active {
cursor: grabbing;
}
.lineplot-overlay {
width: 100%;
aspect-ratio: 32 / 22;

View File

@@ -0,0 +1,62 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
getAngleLabelBasePosition,
getAngleLabelPosition,
measureAngleDegrees,
moveAngleWidget,
} from '../src/angleMeasureGeometry.js';
test('measureAngleDegrees returns the included angle', () => {
assert.equal(measureAngleDegrees(0, 1, 0, 0, 1, 0), 90);
assert.ok(Math.abs(measureAngleDegrees(-1, 0, 0, 0, 1, 0) - 180) < 1e-9);
});
test('moveAngleWidget translates all points together', () => {
const moved = moveAngleWidget(
{ x1: 0.2, y1: 0.7, xm: 0.5, ym: 0.5, x2: 0.8, y2: 0.3 },
0.1,
-0.2,
);
assert.deepEqual(moved, {
x1: 0.3,
y1: 0.5,
xm: 0.6,
ym: 0.3,
x2: 0.9,
y2: 0.1,
});
});
test('moveAngleWidget clamps whole-widget drags to the image bounds', () => {
const moved = moveAngleWidget(
{ x1: 0.2, y1: 0.7, xm: 0.5, ym: 0.5, x2: 0.8, y2: 0.3 },
0.4,
-0.5,
);
assert.deepEqual(moved, {
x1: 0.4,
y1: 0.4,
xm: 0.7,
ym: 0.2,
x2: 1,
y2: 0,
});
});
test('getAngleLabelPosition follows the angle bisector and applies draggable offsets', () => {
const base = getAngleLabelBasePosition(0.2, 0.5, 0.5, 0.5, 0.5, 0.2);
assert.ok(base.x < 0.5);
assert.ok(base.y < 0.5);
const shifted = getAngleLabelPosition(
{ x1: 0.2, y1: 0.5, xm: 0.5, ym: 0.5, x2: 0.5, y2: 0.2 },
-0.1,
0.05,
);
assert.ok(Math.abs(shifted.x - (base.x - 0.1)) < 1e-9);
assert.ok(Math.abs(shifted.y - (base.y + 0.05)) < 1e-9);
});

View File

@@ -695,6 +695,91 @@ def test_scar_removal():
print(" PASS\n")
def test_angle_measure():
print("=== Test: AngleMeasure ===")
from backend.node_registry import get_node_info
from backend.nodes.angle_measure import AngleMeasure
from backend.data_types import ImageData
node = AngleMeasure()
assert get_node_info("AngleMeasure")["category"] == "Overlay"
required_inputs = AngleMeasure.INPUT_TYPES()["required"]
assert required_inputs["color"][1]["default"] == "#ff0000"
field = make_field(
data=np.zeros((32, 64), dtype=np.float64),
xreal=4.0,
yreal=2.0,
)
output, table = node.process(
field,
color="#c62828",
line_thickness=1.8,
x1=0.2,
y1=0.5,
xm=0.5,
ym=0.5,
x2=0.5,
y2=0.2,
label_dx=0.0,
label_dy=0.0,
)
rows = {row["quantity"]: row for row in table}
assert isinstance(output, DataField)
assert output is not field
assert len(output.overlays) == len(field.overlays) + 1
assert output.overlays[-1]["kind"] == "angle_measure"
assert output.overlays[-1]["color"] == "#c62828"
assert np.isclose(output.overlays[-1]["line_thickness"], 1.8)
assert np.isclose(rows["Arm A length"]["value"], 1.2)
assert np.isclose(rows["Arm B length"]["value"], 0.6)
assert np.isclose(rows["Angle"]["value"], 90.0)
assert rows["Angle"]["unit"] == "deg"
assert rows["Vertex x"]["unit"] == field.si_unit_xy
overridden_output, _ = node.process(
field,
color="not-a-color",
line_thickness=0.7,
x1=0.2,
y1=0.5,
xm=0.5,
ym=0.5,
x2=0.5,
y2=0.2,
label_dx=0.0,
label_dy=0.0,
line_thickness_input=2.4,
)
assert overridden_output.overlays[-1]["color"] == "#ff0000"
assert np.isclose(overridden_output.overlays[-1]["line_thickness"], 2.4)
image = np.zeros((50, 100, 3), dtype=np.uint8)
image_output, image_table = node.process(
image,
color="#ff0000",
line_thickness=1.25,
x1=0.25,
y1=0.5,
xm=0.5,
ym=0.5,
x2=0.5,
y2=0.25,
label_dx=0.0,
label_dy=0.0,
)
image_rows = {row["quantity"]: row for row in image_table}
assert isinstance(image_output, ImageData)
assert image_output.shape == image.shape
assert np.count_nonzero(np.asarray(image_output)) > 0
assert np.isclose(image_rows["Arm A length"]["value"], 24.75)
assert np.isclose(image_rows["Arm B length"]["value"], 12.25)
assert np.isclose(image_rows["Angle"]["value"], 90.0)
assert image_rows["Arm A length"]["unit"] == "px"
print(" PASS\n")
# =========================================================================
# Analysis (non-FFT)
# =========================================================================
@@ -2648,6 +2733,7 @@ if __name__ == "__main__":
test_fix_zero()
test_line_correction()
test_scar_removal()
test_angle_measure()
# Analysis
test_statistics()