Source code for fstg_toolkit.app.core.geometry

# Copyright 2025 ICube (University of Strasbourg - CNRS)
# author: Julien PONTABRY (ICube)
#
# This software is a computer program whose purpose is to provide a toolkit
# to model, process and analyze the longitudinal reorganization of brain
# connectivity data, as functional MRI for instance.
#
# This software is governed by the CeCILL-B license under French law and
# abiding by the rules of distribution of free software. You can use,
# modify and/or redistribute the software under the terms of the CeCILL-B
# license as circulated by CEA, CNRS and INRIA at the following URL
# "http://www.cecill.info".
#
# As a counterpart to the access to the source code and rights to copy,
# modify and redistribute granted by the license, users are provided only
# with a limited warranty and the software's author, the holder of the
# economic rights, and the successive licensors have only limited
# liability.
#
# In this respect, the user's attention is drawn to the risks associated
# with loading, using, modifying and/or developing or reproducing the
# software by the user in light of its specific status of free software,
# that may mean that it is complicated to manipulate, and that also
# therefore means that it is reserved for developers and experienced
# professionals having in-depth computer knowledge. Users are therefore
# encouraged to load and test the software's suitability as regards their
# requirements in conditions enabling the security of their systems and/or
# data to be ensured and, more generally, to use and operate it in the
# same conditions as regards security.
#
# The fact that you are presently reading this means that you have had
# knowledge of the CeCILL-B license and that you accept its terms.

from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from functools import cached_property
from math import cos, sin

import numpy as np
from numpy import pi


[docs] @dataclass(frozen=True) class Arc: """An arc on the unit circle defined by start and end angles (in radians). Parameters ---------- begin: float Start angle in radians. end: float End angle in radians. """ begin: float end: float @cached_property def angle(self) -> float: """Angular span of the arc in radians (``end - begin``).""" return self.end - self.begin
[docs] def sample(self, radius: float, angular_res: float = pi/64) -> np.ndarray: """Sample points along the arc at the given radius. Parameters ---------- radius: float Radial distance from the origin. angular_res: float, optional Angular resolution between samples (default ``π/64``). Returns ------- numpy.ndarray Complex-valued array of sampled points (real=x, imag=y). """ n_samples = max(int(round(self.angle / angular_res)), 2) theta = np.linspace(self.begin, self.end, n_samples) return radius * np.exp(1j * theta)
[docs] @staticmethod def from_proportions(proportions: list[float], begin: float = 0, length: float = 2*pi, gap_size: float = 0.005) -> list['Arc']: """Create a list of arcs from proportional sizes with uniform gaps. Parameters ---------- proportions: list[float] Relative sizes of each arc. Need not sum to 1. begin: float, optional Starting angle in radians (default 0). length: float, optional Total angular length to distribute (default ``2π``). gap_size: float, optional Gap between arcs as a fraction of the full circle (default 0.005). Returns ------- list[Arc] One :class:`Arc` per proportion, laid out consecutively with gaps. """ gap = 2*pi * gap_size if length < 2*pi: length += gap arcs = [] for prop in proportions: end = begin + prop * length - gap arcs.append(Arc(begin, end)) begin = end + gap return arcs
[docs] @dataclass(frozen=True) class Ribbon: """A curved ribbon connecting two angular positions on the unit circle via a quadratic Bezier. Parameters ---------- begin: float Angle (radians) of the ribbon's start point on the circle. end: float Angle (radians) of the ribbon's end point on the circle. """ begin: float end: float @staticmethod def __pol2cart(theta: float) -> np.complex128: """Convert a polar angle to a unit-circle Cartesian complex number.""" return np.exp(1j * theta) @cached_property def __begin_cart(self) -> np.complex128: """Cartesian position of the start point on the unit circle.""" return self.__pol2cart(self.begin) @cached_property def __end_cart(self) -> np.complex128: """Cartesian position of the end point on the unit circle.""" return self.__pol2cart(self.end) @cached_property def __middle(self) -> np.complex128: """Midpoint between the start and end Cartesian positions (used as Bezier control).""" return (self.__begin_cart + self.__end_cart) / 2 @staticmethod def __bezier_quad(a: np.complex128, b: np.complex128, c: np.complex128, t: np.ndarray) -> np.ndarray: """Evaluate a quadratic Bezier curve. Parameters ---------- a: complex Start point. b: complex End point. c: complex Control point. t: numpy.ndarray Parameter values in [0, 1]. Returns ------- numpy.ndarray Array of shape ``(2, len(t))`` with x- and y-coordinates. """ x = (1-t)**2 * a.real + 2*(1-t)*t * c.real + t**2 * b.real y = (1 - t) ** 2 * a.imag + 2 * (1 - t) * t * c.imag + t**2 * b.imag return np.array([x, y])
[docs] def sample(self, radius: float = 1.0, origin: tuple[float, float] = (0, 0), strength: float = 0.3, nb_points: int = 50) -> np.ndarray: """Sample points along the ribbon curve. Parameters ---------- radius: float, optional Circle radius at which the ribbon endpoints sit (default 1.0). origin: tuple[float, float], optional Centre of the circle (default ``(0, 0)``). strength: float, optional Pull strength of the Bezier control point towards the origin (default 0.3). nb_points: int, optional Number of sample points along the curve (default 50). Returns ------- numpy.ndarray Array of shape ``(2, nb_points)`` with x- and y-coordinates. """ origin = origin[0] + 1j*origin[1] return self.__bezier_quad( self.__begin_cart * radius + origin, self.__end_cart * radius + origin, self.__middle * strength + origin, np.linspace(0, 1, nb_points))
[docs] @dataclass(frozen=True) class Line: """A straight line segment defined by a length, orientation, and origin. Parameters ---------- length: float Length of the segment. orientation: float Angle of the segment in radians (measured from the positive x-axis). origin: tuple[float, float], optional Start point of the segment (default ``(0, 0)``). """ length: float orientation: float origin: tuple[float, float] = (0, 0) @cached_property def terminus(self) -> tuple[float, float]: """End point of the line segment computed from length and orientation.""" x = self.length * cos(self.orientation) + self.origin[0] y = self.length * sin(self.orientation) + self.origin[1] return x, y
[docs] def sample(self, offset: float = 0) -> np.ndarray: """Sample the two endpoints of the line, optionally perpendicular-shifted. Parameters ---------- offset: float, optional Perpendicular offset from the centreline (default 0). Returns ------- numpy.ndarray Array of shape ``(2, 2)`` containing the start and end points. """ cos_theta = cos(self.orientation) sin_theta = sin(self.orientation) ox = -offset * sin_theta + self.origin[0] oy = offset * cos_theta + self.origin[1] x = self.length * cos_theta - offset * sin_theta + self.origin[0] y = self.length * sin_theta + offset * cos_theta + self.origin[1] return np.array([(ox, oy), (x, y)])
[docs] @staticmethod def from_proportions(proportions: list[float], total_length: float, orientation: float, origin: tuple[float, float] = (0, 0), gap_length: float = 0.25) -> list['Line']: """Create proportionally-sized line segments laid end-to-end with gaps. Parameters ---------- proportions: list[float] Relative sizes of each segment. total_length: float Total available length (gaps included). orientation: float Shared orientation angle in radians. origin: tuple[float, float], optional Starting point for the first segment (default ``(0, 0)``). gap_length: float, optional Fixed gap length between consecutive segments (default 0.25). Returns ------- list[Line] One :class:`Line` per proportion, laid out consecutively with gaps. """ lines = [] usable_length = total_length - gap_length * (len(proportions) - 1) terminus = origin for prop in proportions: line = Line(prop * usable_length, orientation, origin=terminus) lines.append(line) gap = Line(gap_length, orientation, origin=line.terminus) terminus = gap.terminus return lines
[docs] @dataclass(frozen=True) class Path: """A closed polygonal path defined by a sequence of 2-D points. Parameters ---------- points: list[tuple[float, float]], optional Ordered list of ``(x, y)`` vertices (default empty list). """ points: list[tuple[float, float]] = field(default_factory=lambda: [])
[docs] def to_svg(self) -> str: """Serialise the path as an SVG path ``d`` attribute string. Returns ------- str An SVG path string starting with ``M``, joined by ``L`` segments, and closed with ``Z``. """ path = "M " path += " L".join(f"{point[0]},{point[1]}" for point in self.points) path += " Z" return path
[docs] @staticmethod def from_components(x: list[float], y: list[float]) -> 'Path': """Create a :class:`Path` from separate x and y coordinate lists. Parameters ---------- x: list[float] x-coordinates of the vertices. y: list[float] y-coordinates of the vertices. Returns ------- Path A :class:`Path` whose points are ``zip(x, y)``. """ return Path(points=list(zip(x, y)))
[docs] @dataclass(frozen=True) class Shape(ABC): """Abstract base class for geometric shapes that can be converted to a :class:`Path`."""
[docs] @abstractmethod def to_path(self) -> Path: """Convert the shape to a :class:`Path` suitable for SVG rendering. Returns ------- Path The closed polygonal approximation of the shape. """ raise RuntimeError("Not meant to be instantiated")
[docs] @dataclass(frozen=True) class ArcShape(Shape): """A filled arc shape with a given radius and thickness. Parameters ---------- arc: Arc The angular extent of the shape. thickness: float Radial thickness of the filled band. radius: float Inner radius of the shape. """ arc: Arc thickness: float radius: float @cached_property def interior_edge(self) -> np.ndarray: """Sampled points along the inner arc edge.""" return self.arc.sample(self.radius) @cached_property def exterior_edge(self) -> np.ndarray: """Sampled points along the outer arc edge.""" return self.arc.sample(self.radius + self.thickness)
[docs] def to_path(self) -> Path: """Convert to a closed :class:`Path` by joining exterior and reversed interior edges.""" x = self.exterior_edge.real.tolist() + self.interior_edge.real.tolist()[::-1] y = self.exterior_edge.imag.tolist() + self.interior_edge.imag.tolist()[::-1] return Path.from_components(x, y)
[docs] @dataclass(frozen=True) class RibbonShape(Shape): """A filled ribbon bounded by two Bézier curves and two arc caps. Parameters ---------- left_ribbon: Ribbon The left boundary ribbon (connects ``arc_source.begin`` to ``arc_target.begin``). right_ribbon: Ribbon The right boundary ribbon (connects ``arc_source.end`` to ``arc_target.end``). radius: float Circle radius for the ribbon endpoints. strength: float Bezier control-point strength for both ribbons. """ left_ribbon: Ribbon right_ribbon: Ribbon radius: float strength: float
[docs] def to_path(self) -> Path: """Convert to a closed :class:`Path` by joining both ribbon curves and arc caps. The path traces: left ribbon (Bezier) → arc cap at target end → right ribbon reversed (Bezier) → arc cap at source end (reversed). """ left_samples = self.left_ribbon.sample(self.radius, strength=self.strength) right_samples = self.right_ribbon.sample(self.radius, strength=self.strength) cap_end = Arc(self.left_ribbon.end, self.right_ribbon.end).sample(self.radius) cap_begin = Arc(self.left_ribbon.begin, self.right_ribbon.begin).sample(self.radius) x = (left_samples[0].tolist() + cap_end.real.tolist() + right_samples[0].tolist()[::-1] + cap_begin.real.tolist()[::-1]) y = (left_samples[1].tolist() + cap_end.imag.tolist() + right_samples[1].tolist()[::-1] + cap_begin.imag.tolist()[::-1]) return Path.from_components(x, y)
[docs] @dataclass(frozen=True) class LineShape(Shape): """A filled rectangular shape centred on a line segment. Parameters ---------- line: Line The centreline of the shape. thickness: float Total perpendicular width of the shape. """ line: Line thickness: float @cached_property def __half_thickness(self) -> float: """Half of the total thickness, used as the perpendicular offset for each edge.""" return self.thickness / 2 @cached_property def left_edge(self) -> np.ndarray: """Sampled start and end points of the left edge (negative offset side).""" return self.line.sample(-self.__half_thickness) @cached_property def right_edge(self) -> np.ndarray: """Sampled start and end points of the right edge (positive offset side).""" return self.line.sample(self.__half_thickness)
[docs] def to_path(self) -> Path: """Convert to a closed :class:`Path` by joining left and reversed right edges.""" x_left, y_left = self.left_edge.T x_right, y_right = self.right_edge.T x = x_left.tolist() + x_right.tolist()[::-1] y = y_left.tolist() + y_right.tolist()[::-1] return Path.from_components(x, y)