From a1a9a98d5372188f8bfb4e453889c719bad7f9c6 Mon Sep 17 00:00:00 2001 From: Richard Christie Date: Fri, 24 Oct 2025 13:48:55 +1300 Subject: [PATCH 01/24] Fix quadtrianglemesh imports --- src/scaffoldmaker/utils/quadtrianglemesh.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/scaffoldmaker/utils/quadtrianglemesh.py b/src/scaffoldmaker/utils/quadtrianglemesh.py index 012be6e6..0711a951 100644 --- a/src/scaffoldmaker/utils/quadtrianglemesh.py +++ b/src/scaffoldmaker/utils/quadtrianglemesh.py @@ -1,12 +1,9 @@ """ -Utilities for building 3-D triangle-topology meshes out of quad elements +Utilities for building 2-D triangle-shaped meshes out of quad elements with cubic Hermite serendipity interpolation. """ -from cmlibs.maths.vectorops import add, cross, div, dot, magnitude, mult, normalize, set_magnitude from scaffoldmaker.utils.interpolation import ( - DerivativeScalingMode, get_nway_point, getCubicHermiteCurvesLength, linearlyInterpolateVectors, - smoothCubicHermiteDerivativesLine) + DerivativeScalingMode, get_nway_point, linearlyInterpolateVectors, smoothCubicHermiteDerivativesLine) import copy -import math class QuadTriangleMesh: From 5862f6b8ef6917386afb15c34af9df3673b21487 Mon Sep 17 00:00:00 2001 From: Richard Christie Date: Mon, 3 Nov 2025 12:48:27 +1300 Subject: [PATCH 02/24] Make common HexTetrahedronMesh class for octant --- .../meshtypes/meshtype_3d_lung3.py | 4 +- src/scaffoldmaker/utils/ellipsoidmesh.py | 624 +----------------- src/scaffoldmaker/utils/hextetrahedronmesh.py | 624 ++++++++++++++++++ 3 files changed, 634 insertions(+), 618 deletions(-) create mode 100644 src/scaffoldmaker/utils/hextetrahedronmesh.py diff --git a/src/scaffoldmaker/meshtypes/meshtype_3d_lung3.py b/src/scaffoldmaker/meshtypes/meshtype_3d_lung3.py index c4107a1e..3efaace8 100644 --- a/src/scaffoldmaker/meshtypes/meshtype_3d_lung3.py +++ b/src/scaffoldmaker/meshtypes/meshtype_3d_lung3.py @@ -1,5 +1,5 @@ """ -Generates a lung scaffold by deforming a hemisphere. +Generates a lung scaffold by deforming an ellipsoid. """ from cmlibs.utils.zinc.field import find_or_create_field_coordinates from cmlibs.zinc.field import Field @@ -17,7 +17,7 @@ class MeshType_3d_lung3(Scaffold_base): """ - Generates a lung scaffold by deforming a hemisphere. + Generates a lung scaffold by deforming an ellipsoid. """ @classmethod diff --git a/src/scaffoldmaker/utils/ellipsoidmesh.py b/src/scaffoldmaker/utils/ellipsoidmesh.py index be6b307e..d97e42be 100644 --- a/src/scaffoldmaker/utils/ellipsoidmesh.py +++ b/src/scaffoldmaker/utils/ellipsoidmesh.py @@ -13,6 +13,7 @@ from scaffoldmaker.utils.interpolation import ( DerivativeScalingMode, get_nway_point, linearlyInterpolateVectors, sampleHermiteCurve, smoothCubicHermiteDerivativesLine) +from scaffoldmaker.utils.hextetrahedronmesh import HexTetrahedronMesh from scaffoldmaker.utils.quadtrianglemesh import QuadTriangleMesh import copy from enum import Enum @@ -282,6 +283,7 @@ def _build_ellipsoid_octant(self, half_counts, axis2_x_rotation_radians, axis3_x :param half_counts: Numbers of elements across octant 1, 2 and 3 directions. :param axis2_x_rotation_radians: Rotation of axis 2 about +x direction :param axis3_x_rotation_radians: Rotation of axis 3 about +x direction. + :return: HexTetrahedronMesh """ elements_count_q12 = half_counts[0] + half_counts[1] - 2 * self._trans_count elements_count_q13 = half_counts[0] + half_counts[2] - 2 * self._trans_count @@ -320,8 +322,12 @@ def _build_ellipsoid_octant(self, half_counts, axis2_x_rotation_radians, axis3_x else: evaluate_surface_d3_ellipsoid = lambda tx, td1, td2: set_magnitude(tx, dir_mag) - octant = EllipsoidOctantMesh(self._a, self._b, self._c, half_counts, self._trans_count, - nway_d_factor=self._nway_d_factor) + diag_counts = [ + half_counts[0] + half_counts[1] - 2 * self._trans_count, + half_counts[0] + half_counts[2] - 2 * self._trans_count, + half_counts[1] + half_counts[2] - 2 * self._trans_count + ] + octant = HexTetrahedronMesh(half_counts, diag_counts, nway_d_factor=self._nway_d_factor) # get outside curve from axis 1 to axis 2 abx, abd1, abd2 = sampleCurveOnEllipsoid( @@ -1185,617 +1191,3 @@ def generate_mesh(self, fieldmodule, coordinates, start_node_identifier=1, start last_nx_layer = nx_layer return node_identifier, element_identifier - -class EllipsoidOctantMesh: - """ - Generates one octant of an ellipsoid, 2-D surface or full 3-D volume. - 2-D outer surface is merely added from a QuadTriangleMesh. - 3-D volume requires 3 axis-aligned and outer surfaces to each be set from a QuadTriangleMesh, - then the interior can be built. - """ - - def __init__(self, a, b, c, element_counts, transition_element_count, nway_d_factor=0.6): - """ - A 2-D or 3-D Octant of an ellipsoid. - Coordinates nx are indexed in 1, 2, 3 directions from origin at index 0, 0, 0 - with holes around the corners and 3-way points. - :param a: Axis length (radius) in x direction. - :param b: Axis length (radius) in y direction. - :param c: Axis length (radius) in z direction. - :param element_counts: Number of elements across octant only in 1, 2, 3 axes. - :param transition_element_count: Number of transition elements around outside >= 1. - :param nway_d_factor: Value, normally from 0.5 to 1.0 giving n-way derivative magnitude as a proportion - of the minimum regular magnitude sampled to the n-way point. This reflects that distances from the mid-side - of a triangle to the centre are shorter, so the derivative in the middle must be smaller. - """ - assert all((count >= 2) for count in element_counts) - assert 1 <= transition_element_count <= (min(element_counts) - 1) - self._a = a - self._b = b - self._c = c - self._element_counts = element_counts - self._trans_count = transition_element_count - self._nway_d_factor = nway_d_factor - self._element_count12 = element_counts[0] + element_counts[1] - 2 * transition_element_count - self._element_count13 = element_counts[0] + element_counts[2] - 2 * transition_element_count - self._element_count23 = element_counts[1] + element_counts[2] - 2 * transition_element_count - # counts of elements to 3-way point opposite to 3 node at axis 1, axis 2, axis 3 - self._box_counts = [self._element_counts[i] - self._trans_count for i in range(3)] - none_parameters = [None] * 4 # x, d1, d2, d3 - self._nx = [] # shield mesh with holes over n3, n2, n1, d - for n3 in range(element_counts[2] + 1): - # index into transition zone - trans3 = transition_element_count + n3 - element_counts[2] - nx_layer = [] - for n2 in range(element_counts[1] + 1): - # index into transition zone - trans2 = transition_element_count + n2 - element_counts[1] - nx_row = [] - # s = "" - for n1 in range(element_counts[0] + 1): - # index into transition zone - trans1 = transition_element_count + n1 - element_counts[0] - if (((trans1 <= 0) and (trans2 <= 0) and (trans3 <= 0)) or - (trans1 == trans2 == trans3) or - ((trans1 < 0) and ((trans2 == trans3) or (trans2 < 0))) or - ((trans2 < 0) and ((trans3 == trans1) or (trans3 < 0))) or - ((trans3 < 0) and ((trans1 == trans2) or (trans1 < 0)))): - parameters = copy.copy(none_parameters) - # s += "[]" - else: - parameters = None - # s += " " - nx_row.append(parameters) - nx_layer.append(nx_row) - # print(s) - self._nx.append(nx_layer) - - def get_parameters(self): - """ - Get parameters array e.g. for copying to ellipsoid. - :return: Internal parameters array self._nx. Not to be modified. - """ - return self._nx - - def set_triangle_abc(self, trimesh: QuadTriangleMesh): - """ - Set parameters on the outer abc surface triangle of octant. - :param trimesh: Coordinates to set on outer surface. - """ - assert trimesh.get_element_count12() == self._element_count12 - assert trimesh.get_element_count13() == self._element_count13 - assert trimesh.get_element_count23() == self._element_count23 - start_indexes = [self._element_counts[0], 0, 0] - for n3 in range(self._box_counts[2]): - px, pd1, pd2, pd3 = trimesh.get_parameters12(n3) - self._set_coordinates_across([px, pd1, pd2, pd3], [[0, 1, 2, 3]], start_indexes, [[0, 1, 0], [-1, 0, 0]]) - start_indexes[2] += 1 - start_indexes = [0, 0, self._element_counts[2]] - for n2 in range(self._box_counts[1]): - px, pd1, pd2, pd3 = trimesh.get_parameters31(n2, self._box_counts[0] + 1) - self._set_coordinates_across([px, pd1, pd2, pd3], [[0, 1, 2, 3]], start_indexes, [[1, 0, 0]]) - start_indexes[1] += 1 - start_indexes = [0, self._element_counts[1], self._element_counts[2]] - px, pd1, pd2, pd3 = trimesh.get_parameters_diagonal() - self._set_coordinates_across([px, pd1, pd2, pd3], [[0, 1, 2, 3]], start_indexes, [[1, 0, 0]]) - - def set_triangle_abo(self, trimesh: QuadTriangleMesh): - """ - Set parameters on triangle 1-2-origin, an inner surface of octant. - :param trimesh: Triangle coordinate data with x, d1, d2, optional d3. - """ - assert trimesh.get_element_count12() == self._element_count12 - assert trimesh.get_element_count13() == self._element_counts[0] - assert trimesh.get_element_count23() == self._element_counts[1] - start_indexes = [self._element_counts[0], 0, 0] - for n0 in range(self._trans_count): - px, pd1, pd2, pd3 = trimesh.get_parameters12(n0) - self._set_coordinates_across([px, pd1, pd2, pd3], [[0, 1, -3, 2]], start_indexes, [[0, 1, 0], [-1, 0, 0]]) - start_indexes[0] -= 1 - start_indexes = [0, 0, 0] - for n2 in range(self._box_counts[1] + 1): - px, pd1, pd2, pd3 = (trimesh.get_parameters31(n2, self._box_counts[0] + 1) if (n2 < self._box_counts[1]) - else trimesh.get_parameters_diagonal()) - self._set_coordinates_across([px, pd1, pd2, pd3], [[0, 1, 2, 3]], start_indexes, [[1, 0, 0]]) - start_indexes[1] += 1 - - def set_triangle_aco(self, trimesh: QuadTriangleMesh): - """ - Set parameters on triangle 1-3-origin, an inner surface of octant. - :param trimesh: Triangle coordinate data with x, d1, d2, optional d3. - """ - assert trimesh.get_element_count12() == self._element_count13 - assert trimesh.get_element_count13() == self._element_counts[0] - assert trimesh.get_element_count23() == self._element_counts[2] - start_indexes = [self._element_counts[0], 0, 0] - for n0 in range(self._trans_count): - px, pd1, pd2, pd3 = trimesh.get_parameters12(n0) - self._set_coordinates_across( - [px, pd1, pd2, pd3], [[0, 2, -3, -1], [0, -1, -3, -2]], start_indexes, [[0, 0, 1], [-1, 0, 0]]) - start_indexes[0] -= 1 - start_indexes = [0, 0, 0] - for n3 in range(self._box_counts[2] + 1): - px, pd1, pd2, pd3 = (trimesh.get_parameters31(n3, self._box_counts[0] + 1) if (n3 < self._box_counts[2]) - else trimesh.get_parameters_diagonal()) - self._set_coordinates_across([px, pd1, pd2, pd3], [[0, 1, 3, -2]], start_indexes, [[1, 0, 0]]) - start_indexes[2] += 1 - - def set_triangle_bco(self, trimesh: QuadTriangleMesh): - """ - Set parameters on triangle 2-3-origin, an inner surface of octant. - :param trimesh: Triangle coordinate data with x, d1, d2, optional d3. - """ - assert trimesh.get_element_count12() == self._element_count23 - assert trimesh.get_element_count13() == self._element_counts[1] - assert trimesh.get_element_count23() == self._element_counts[2] - start_indexes = [0, self._element_counts[1], 0] - for n0 in range(self._trans_count): - px, pd1, pd2, pd3 = trimesh.get_parameters12(n0) - self._set_coordinates_across( - [px, pd1, pd2, pd3], [[0, 2, -3, -1], [0, -2, -3, 1]], start_indexes, [[0, 0, 1], [0, -1, 0]]) - start_indexes[1] -= 1 - start_indexes = [0, 0, 0] - for n3 in range(self._box_counts[2] + 1): - px, pd1, pd2, pd3 = (trimesh.get_parameters31(n3, self._box_counts[1] + 1) if (n3 < self._box_counts[2]) - else trimesh.get_parameters_diagonal()) - self._set_coordinates_across([px, pd1, pd2, pd3], [[0, 2, 3, 1]], start_indexes, [[0, 1, 0]]) - start_indexes[2] += 1 - - def _get_transitions(self, indexes): - """ - For each index direction, get False if in core or True if in transition zone. - :param indexes: Location indexes in 1, 2, 3 directions. - :return: Transition 1, 2, 3 directions. - """ - return [(indexes[i] - self._box_counts[i]) > 0 for i in range(3)] - - def _set_coordinates_across(self, parameters, parameter_indexes, start_indexes, index_increments, - skip_start=False, skip_end=False, blend=False): - """ - Insert parameters across the coordinates array. - :param parameters: List of lists of N node parameters e.g. [px, pd1, pd2, pd3] - :param parameter_indexes: Lists of parameter indexes where x=0, d1=1, d2=2, d3=3. Starts with first and - advances at transitions change, then stays on the last. Can be negative to invert vector. - e.g. [[0, 1, 2], [0, -2, 1]] for [x, d1, d2] then [x, -d2, d1] from first corner. - :param start_indexes: Indexes into nx array for start point. - :param index_increments: List of increments in indexes. Starts with first and uses next at each transition - change, then stays on the last. - :param skip_start: Set to True to skip the first value. - :param skip_end: Set to True to skip the last value. - :param blend: Set to True to blend parameters with any old parameters at locations. - """ - indexes = start_indexes - parameter_number = 0 - parameter_index = parameter_indexes[0] - increment_number = 0 - index_increment = index_increments[0] - start_n = 1 if skip_start else 0 - last_n = len(parameters[0]) - 1 - limit_n = len(parameters[0]) - (1 if skip_end else 0) - last_trans = self._get_transitions(indexes) - for n in range(start_n, limit_n): - if n > 0: - while True: - indexes = [indexes[c] + index_increment[c] for c in range(3)] - # skip over blank transition coordinates - if self._nx[indexes[2]][indexes[1]][indexes[0]]: - break - trans = self._get_transitions(indexes) - if last_trans and (trans != last_trans): - if parameter_number < (len(parameter_indexes) - 1): - parameter_number += 1 - parameter_index = parameter_indexes[parameter_number] - if increment_number < (len(index_increments) - 1): - increment_number += 1 - index_increment = index_increments[increment_number] - nx = self._nx[indexes[2]][indexes[1]][indexes[0]] - for parameter, spix in zip(parameters, parameter_index): - if not parameter[n]: - continue - new_parameter = [-d for d in parameter[n]] if (spix < 0) else copy.copy(parameter[n]) - pix = abs(spix) - if blend and nx[pix]: - if pix == 0: - # for fairness, move to surface before blending - if any(indexes[i] == self._element_counts[i] for i in range(3)): - new_parameter = moveCoordinatesToEllipsoidSurface(self._a, self._b, self._c, new_parameter) - new_parameter = [0.5 * (nx[pix][c] + new_parameter[c]) for c in range(3)] - else: - # harmonic mean to cope with significant element size differences on boundary - new_parameter = linearlyInterpolateVectors( - nx[pix], new_parameter, 0.5, magnitudeScalingMode=DerivativeScalingMode.HARMONIC_MEAN) - nx[pix] = new_parameter - last_trans = trans - - def _smooth_derivative_across(self, start_indexes, end_indexes, index_increments, derivative_indexes, - fix_start_direction=True, fix_end_direction=True, move_d_to_surface=False): - """ - Smooth derivatives across octant. - :param start_indexes: Indexes of first point. - :param end_indexes: Indexes of last point. - :param index_increments: List of increments in indexes. Starts with first and advances at transitions change, - then stays on the last. - :param derivative_indexes: List of signed derivative parameter index to set along where 1=d1, 2=d2, 3=d3. - Starts with first and advances at transitions change, then stays on the last. Can be negative to invert vector. - e.g. [1, -2] for d1 then -d2 from first transition change. - :param fix_start_direction: Set to True to keep the start direction but scale its magnitude. - :param fix_end_direction: Set to True to keep the end direction but scale its magnitude. - :param move_d_to_surface: Set to True to force derivatives to surface tangents at their current location. - Only use for smoothing over the outer surface of ellipsoid. - """ - indexes = start_indexes - derivative_number = 0 - derivative_index = derivative_indexes[0] - increment_number = 0 - index_increment = index_increments[0] - indexes_list = [] - derivative_index_list = [] - px = [] - pd = [] - n = 0 - last_trans = self._get_transitions(indexes) - while True: - if n > 0: - if indexes == end_indexes: - break - while True: - indexes = [indexes[i] + index_increment[i] for i in range(3)] - # skip over blank coordinates in transition zone - if self._nx[indexes[2]][indexes[1]][indexes[0]]: - break - trans = self._get_transitions(indexes) - if last_trans and (trans != last_trans): - if derivative_number < (len(derivative_indexes) - 1): - derivative_number += 1 - derivative_index = derivative_indexes[derivative_number] - if increment_number < (len(index_increments) - 1): - increment_number += 1 - index_increment = index_increments[increment_number] - parameters = self._nx[indexes[2]][indexes[1]][indexes[0]] - x = parameters[0] - indexes_list.append(copy.copy(indexes)) - spix = derivative_index - derivative_index_list.append(spix) - pix = abs(spix) - if parameters[pix]: - d = [-ad for ad in parameters[pix]] if (spix < 0) else parameters[pix] - else: - d = [0.0, 0.0, 0.0] - px.append(x) - pd.append(d) - n += 1 - last_trans = trans - sd = smoothCubicHermiteDerivativesLine( - px, pd, fixStartDirection=fix_start_direction, fixEndDirection=fix_end_direction) - if move_d_to_surface: - for n in range(1, len(sd) - 1): - sd[n] = self._move_d_to_surface(px[n], sd[n]) - sd = smoothCubicHermiteDerivativesLine(px, sd, fixAllDirections=True) - for n in range(len(sd)): - indexes = indexes_list[n] - spix = derivative_index_list[n] - new_derivative = [-d for d in sd[n]] if (spix < 0) else sd[n] - pix = abs(spix) - self._nx[indexes[2]][indexes[1]][indexes[0]][pix] = new_derivative - - def build_interior(self): - """ - Determine interior coordinates from surface coordinates. - """ - # determine 4-way point location from mean curves between side points linking to it - point12 = self._nx[0][self._box_counts[1]][self._box_counts[0]] - point13 = self._nx[self._box_counts[2]][0][self._box_counts[0]] - point23 = self._nx[self._box_counts[2]][self._box_counts[1]][0] - point123 = self._nx[self._element_counts[2]][self._element_counts[1]][self._element_counts[0]] - - x_4way, d_4way = get_nway_point( - [point23[0], point13[0], point12[0], point123[0]], - [point23[1], point13[2], point12[3], [-d for d in point123[3]]], - [self._box_counts[0], self._box_counts[1], self._box_counts[2], self._trans_count], - sampleHermiteCurve, nway_d_factor=self._nway_d_factor) - - # smooth sample from sides to 3-way points using end derivatives - min_weight = 1 # GRC revisit, remove? - ax, ad1 = sampleHermiteCurve( - point23[0], point23[1], None, x_4way, d_4way[0], None, self._box_counts[0], - start_weight=self._box_counts[0] + min_weight, end_weight=1.0 + min_weight, end_transition=True) - bx, bd2 = sampleHermiteCurve( - point13[0], point13[2], None, x_4way, d_4way[1], None, self._box_counts[1], - start_weight=self._box_counts[1] + min_weight, end_weight=1.0 + min_weight, end_transition=True) - cx, cd3 = sampleHermiteCurve( - point12[0], point12[3], None, x_4way, d_4way[2], None, self._box_counts[2], - start_weight=self._box_counts[2] + min_weight, end_weight=1.0 + min_weight, end_transition=True) - tx, td3 = sampleHermiteCurve( - point123[0], [-d for d in point123[3]], None, x_4way, d_4way[3], None, self._trans_count, - start_weight=self._trans_count + min_weight, end_weight=1.0 + min_weight, end_transition=True) - - self._set_coordinates_across([ax, ad1], [[0, 1]], [0, self._box_counts[1], self._box_counts[2]], [[1, 0, 0]]) - self._set_coordinates_across([bx, bd2], [[0, 2]], [self._box_counts[0], 0, self._box_counts[2]], [[0, 1, 0]]) - self._set_coordinates_across([cx, cd3], [[0, 3]], [self._box_counts[0], self._box_counts[1], 0], [[0, 0, 1]]) - self._set_coordinates_across([tx, td3], [[0, -3]], - [self._element_counts[0], self._element_counts[1], self._element_counts[2]], - [[-1, -1, -1]], skip_end=True) - - # sample up to 3-way lines connecting to 4-way point - for n3 in range(1, self._box_counts[2]): - point13 = self._nx[n3][0][self._box_counts[0]] - point23 = self._nx[n3][self._box_counts[1]][0] - point123 = self._nx[n3][self._element_counts[1]][self._element_counts[0]] - point_3way = self._nx[n3][self._box_counts[1]][self._box_counts[0]] - - x_3way, d_3way = get_nway_point( - [point23[0], point13[0], point123[0]], - [point23[1], point13[2], [-d for d in point123[3]]], - [self._box_counts[0], self._box_counts[1], self._trans_count], - sampleHermiteCurve, prescribed_x_nway=point_3way[0], nway_d_factor=self._nway_d_factor) - - ax, ad1, ad2 = sampleHermiteCurve( - point23[0], point23[1], point23[2], x_3way, d_3way[0], None, self._box_counts[0], - start_weight=self._box_counts[0] + min_weight, end_weight=1.0 + min_weight, end_transition=True) - ad1[-1] = d_3way[0] - ad2[-1] = d_3way[1] - bx, bd2, bd1 = sampleHermiteCurve( - point13[0], point13[2], point13[1], x_3way, d_3way[1], None, self._box_counts[1], - start_weight=self._box_counts[1] + min_weight, end_weight=1.0 + min_weight, end_transition=True) - bd1[-1] = d_3way[0] - bd2[-1] = d_3way[1] - tx, td3, td1 = sampleHermiteCurve( - point123[0], [-d for d in point123[3]], point123[1], x_3way, d_3way[2], None, - self._trans_count, start_weight=self._trans_count + min_weight, - end_weight=1.0 + min_weight, end_transition=True) - - self._set_coordinates_across([ax, ad1, ad2], [[0, 1, 2]], [0, self._box_counts[1], n3], [[1, 0, 0]]) - self._set_coordinates_across([bx, bd1, bd2], [[0, 1, 2]], [self._box_counts[0], 0, n3], [[0, 1, 0]]) - self._set_coordinates_across( - [tx, td1, td3], [[0, 1, -3]], [self._element_counts[0], self._element_counts[1], n3], [[-1, -1, 0]], - skip_end=True) - - for n2 in range(1, self._box_counts[1]): - point12 = self._nx[0][n2][self._box_counts[0]] - point23 = self._nx[self._box_counts[2]][n2][0] - point123 = self._nx[self._element_counts[2]][n2][self._element_counts[0]] - point_3way = self._nx[self._box_counts[2]][n2][self._box_counts[0]] - - x_3way, d_3way = get_nway_point( - [point23[0], point12[0], point123[0]], - [point23[1], point12[3], [-d for d in point123[3]]], - [self._box_counts[0], self._box_counts[2], self._trans_count], - sampleHermiteCurve, prescribed_x_nway=point_3way[0], nway_d_factor=self._nway_d_factor) - - ax, ad1, ad3 = sampleHermiteCurve( - point23[0], point23[1], point23[3], x_3way, d_3way[0], None, self._box_counts[0], - start_weight=self._box_counts[0] + min_weight, end_weight=1.0 + min_weight, end_transition=True) - ad1[-1] = d_3way[0] - ad3[-1] = d_3way[1] - bx, bd3, bd1 = sampleHermiteCurve( - point12[0], point12[3], point12[1], x_3way, d_3way[1], None, self._box_counts[2], - start_weight=self._box_counts[2] + min_weight, end_weight=1.0 + min_weight, end_transition=True) - bd1[-1] = d_3way[0] - bd3[-1] = d_3way[1] - tx, td3, td1 = sampleHermiteCurve( - point123[0], [-d for d in point123[3]], point123[1], x_3way, d_3way[2], None, - self._trans_count, start_weight=self._trans_count + min_weight, - end_weight=1.0 + min_weight, end_transition=True) - - self._set_coordinates_across( - [ax, ad1, ad3], [[0, 1, 3]], [0, n2, self._box_counts[2]], [[1, 0, 0]], blend=True) - self._set_coordinates_across( - [bx, bd1, bd3], [[0, 1, 3]], [self._box_counts[0], n2, 0], [[0, 0, 1]], blend=True) - self._set_coordinates_across( - [tx, td1, td3], [[0, 1, -3]], [self._element_counts[0], n2, self._element_counts[2]], [[-1, 0, -1]], - skip_end=True, blend=True) - - for n1 in range(1, self._box_counts[0]): - point12 = self._nx[0][self._box_counts[1]][n1] - point13 = self._nx[self._box_counts[2]][0][n1] - point123 = self._nx[self._element_counts[2]][self._element_counts[1]][n1] - point_3way = self._nx[self._box_counts[2]][self._box_counts[1]][n1] - - x_3way, d_3way = get_nway_point( - [point13[0], point12[0], point123[0]], - [point13[2], point12[3], [-d for d in point123[3]]], - [self._box_counts[0], self._box_counts[2], self._trans_count], - sampleHermiteCurve, prescribed_x_nway=point_3way[0], nway_d_factor=self._nway_d_factor) - - ax, ad2, ad3 = sampleHermiteCurve( - point13[0], point13[2], point13[3], x_3way, d_3way[0], None, self._box_counts[1], - start_weight=self._box_counts[1] + min_weight, end_weight=1.0 + min_weight, end_transition=True) - ad2[-1] = d_3way[0] - ad3[-1] = d_3way[1] - bx, bd3, bd2 = sampleHermiteCurve( - point12[0], point12[3], point12[2], x_3way, d_3way[1], None, self._box_counts[2], - start_weight=self._box_counts[2] + min_weight, end_weight=1.0 + min_weight, end_transition=True) - bd2[-1] = d_3way[0] - bd3[-1] = d_3way[1] - tx, td3, td2 = sampleHermiteCurve( - point123[0], [-d for d in point123[3]], point123[2], x_3way, d_3way[2], None, - self._trans_count, start_weight=self._trans_count + min_weight, - end_weight=1.0 + min_weight, end_transition=True) - - self._set_coordinates_across( - [ax, ad2, ad3], [[0, 2, 3]], [n1, 0, self._box_counts[2]], [[0, 1, 0]], blend=True) - self._set_coordinates_across( - [bx, bd2, bd3], [[0, 2, 3]], [n1, self._box_counts[1], 0], [[0, 0, 1]], blend=True) - self._set_coordinates_across( - [tx, td2, td3], [[0, 2, -3]], [n1, self._element_counts[1], self._element_counts[2]], - [[0, -1, -1]], skip_end=True, blend=True) - - for nt in range(1, self._trans_count): - point12 = self._nx[0][self._element_counts[1] - nt][self._element_counts[0] - nt] - point13 = self._nx[self._element_counts[2] - nt][0][self._element_counts[0] - nt] - point23 = self._nx[self._element_counts[2] - nt][self._element_counts[1] - nt][0] - point_3way = \ - self._nx[self._element_counts[2] - nt][self._element_counts[1] - nt][self._element_counts[0] - nt] - - x_3way, d_3way = get_nway_point( - [point23[0], point13[0], point12[0]], - [point23[1], point13[2], point12[2]], - [self._box_counts[0], self._box_counts[1], self._box_counts[2]], - sampleHermiteCurve, prescribed_x_nway=point_3way[0], nway_d_factor=self._nway_d_factor) - - ax, ad1, ad2 = sampleHermiteCurve( - point23[0], point23[1], point23[2], x_3way, d_3way[0], None, self._box_counts[0], - start_weight=self._box_counts[0] + min_weight, end_weight=1.0 + min_weight, end_transition=True) - ad1[-1] = d_3way[0] - ad2[-1] = d_3way[1] - bx, bd2, bd1 = sampleHermiteCurve( - point13[0], point13[2], point13[1], x_3way, d_3way[1], None, self._box_counts[1], - start_weight=self._box_counts[1] + min_weight, end_weight=1.0 + min_weight, end_transition=True) - bd1[-1] = d_3way[0] - bd2[-1] = d_3way[1] - cx, cd2, cd1 = sampleHermiteCurve( - point12[0], point12[2], point12[1], x_3way, d_3way[2], None, self._box_counts[2], - start_weight=self._box_counts[2] + min_weight, end_weight=1.0 + min_weight, - end_transition=True) - - self._set_coordinates_across( - [ax, ad1, ad2], [[0, 1, 2]], [0, self._element_counts[1] - nt, self._element_counts[2] - nt], - [[1, 0, 0]], blend=True) - self._set_coordinates_across( - [bx, bd1, bd2], [[0, 1, 2]], [self._element_counts[0] - nt, 0, self._element_counts[2] - nt], - [[0, 1, 0]], blend=True) - self._set_coordinates_across( - [cx, cd1, cd2], [[0, 1, 2]], [self._element_counts[0] - nt, self._element_counts[1] - nt, 0], - [[0, 0, 1]], skip_end=True, blend=True) - - # average point coordinates across 3 directions between side faces and surfaces to 4 3-way lines. - min_weight = 1 # GRC revisit, remove? - # 1-direction - for n2 in range(1, self._box_counts[1]): - for n3 in range(1, self._box_counts[2]): - start_indexes = [0, n2, n3] - corner_indexes = [self._box_counts[0], n2, n3] - end_indexes = [self._element_counts[0], n2, n3] - start = self._nx[start_indexes[2]][start_indexes[1]][start_indexes[0]] - corner = self._nx[corner_indexes[2]][corner_indexes[1]][corner_indexes[0]] - end = self._nx[end_indexes[2]][end_indexes[1]][end_indexes[0]] - px, _ = sampleHermiteCurve( - start[0], start[1], None, corner[0], corner[1], None, self._box_counts[0], - start_weight=self._box_counts[0] + min_weight, end_weight=1.0 + min_weight) - self._set_coordinates_across( - [px], [[0]], start_indexes, [[1, 0, 0]], skip_start=True, skip_end=True, blend=True) - px, _ = sampleHermiteCurve( - corner[0], corner[1], None, end[0], end[3], None, self._trans_count, - start_weight=1.0 + min_weight, end_weight=self._trans_count + min_weight) - self._set_coordinates_across( - [px], [[0]], corner_indexes, [[1, 0, 0]], skip_start=True, skip_end=True, blend=True) - for nt in range(1, self._trans_count): - start_indexes = [0, n2, self._box_counts[2] + nt] - corner_indexes = [self._box_counts[0] + nt, n2, self._box_counts[2] + nt] - end_indexes = [self._box_counts[0] + nt, n2, 0] - start = self._nx[start_indexes[2]][start_indexes[1]][start_indexes[0]] - corner = self._nx[corner_indexes[2]][corner_indexes[1]][corner_indexes[0]] - end = self._nx[end_indexes[2]][end_indexes[1]][end_indexes[0]] - px, _ = sampleHermiteCurve( - start[0], start[1], None, corner[0], corner[1], None, self._box_counts[0], - start_weight=self._box_counts[0] + min_weight, end_weight=1.0 + min_weight) - self._set_coordinates_across( - [px], [[0]], start_indexes, [[1, 0, 0]], skip_start=True, skip_end=True, blend=True) - px, _ = sampleHermiteCurve( - corner[0], corner[1], None, end[0], [-d for d in end[2]], None, self._box_counts[2], - start_weight=1.0 + min_weight, end_weight=self._box_counts[2] + min_weight) - self._set_coordinates_across( - [px], [[0]], corner_indexes, [[0, 0, -1]], skip_start=True, skip_end=True, blend=True) - # 2-direction - for n3 in range(1, self._box_counts[2]): - for n1 in range(1, self._box_counts[0]): - start_indexes = [n1, 0, n3] - corner_indexes = [n1, self._box_counts[1], n3] - end_indexes = [n1, self._element_counts[1], n3] - start = self._nx[start_indexes[2]][start_indexes[1]][start_indexes[0]] - corner = self._nx[corner_indexes[2]][corner_indexes[1]][corner_indexes[0]] - end = self._nx[end_indexes[2]][end_indexes[1]][end_indexes[0]] - px, _ = sampleHermiteCurve( - start[0], start[2], None, corner[0], corner[2], None, self._box_counts[1], - start_weight=self._box_counts[1] + min_weight, end_weight=1.0 + min_weight) - self._set_coordinates_across( - [px], [[0]], start_indexes, [[0, 1, 0]], skip_start=True, skip_end=True, blend=True) - px, _ = sampleHermiteCurve( - corner[0], corner[2], None, end[0], end[3], None, self._trans_count, - start_weight=1.0 + min_weight, end_weight=self._trans_count + min_weight) - self._set_coordinates_across( - [px], [[0]], corner_indexes, [[0, 1, 0]], skip_start=True, skip_end=True, blend=True) - for nt in range(1, self._trans_count): - start_indexes = [self._box_counts[0] + nt, 0, n3] - corner_indexes = [self._box_counts[0] + nt, self._box_counts[1] + nt, n3] - end_indexes = [0, self._box_counts[1] + nt, n3] - start = self._nx[start_indexes[2]][start_indexes[1]][start_indexes[0]] - corner = self._nx[corner_indexes[2]][corner_indexes[1]][corner_indexes[0]] - end = self._nx[end_indexes[2]][end_indexes[1]][end_indexes[0]] - px, _ = sampleHermiteCurve( - start[0], start[1], None, corner[0], corner[1], None, self._box_counts[1], - start_weight=self._box_counts[1] + min_weight, end_weight=1.0 + min_weight) - self._set_coordinates_across( - [px], [[0]], start_indexes, [[0, 1, 0]], skip_start=True, skip_end=True, blend=True) - px, _ = sampleHermiteCurve( - corner[0], corner[1], None, end[0], end[1], None, self._box_counts[0], - start_weight=1.0 + min_weight, end_weight=self._box_counts[0] + min_weight) - self._set_coordinates_across( - [px], [[0]], corner_indexes, [[-1, 0, 0]], skip_start=True, skip_end=True, blend=True) - # 3-direction - for n1 in range(1, self._box_counts[0]): - for n2 in range(1, self._box_counts[1]): - start_indexes = [n1, n2, 0] - corner_indexes = [n1, n2, self._box_counts[2]] - end_indexes = [n1, n2, self._element_counts[2]] - start = self._nx[start_indexes[2]][start_indexes[1]][start_indexes[0]] - corner = self._nx[corner_indexes[2]][corner_indexes[1]][corner_indexes[0]] - end = self._nx[end_indexes[2]][end_indexes[1]][end_indexes[0]] - px, _ = sampleHermiteCurve( - start[0], start[3], None, corner[0], corner[3], None, self._box_counts[2], - start_weight=self._box_counts[2] + min_weight, end_weight=1.0 + min_weight) - self._set_coordinates_across( - [px], [[0]], start_indexes, [[0, 0, 1]], skip_start=True, skip_end=True, blend=True) - px, _ = sampleHermiteCurve( - corner[0], corner[3], None, end[0], end[3], None, self._trans_count, - start_weight=1.0 + min_weight, end_weight=self._trans_count + min_weight) - self._set_coordinates_across( - [px], [[0]], corner_indexes, [[0, 0, 1]], skip_start=True, skip_end=True, blend=True) - for nt in range(1, self._trans_count): - start_indexes = [n1, self._box_counts[1] + nt, 0] - corner_indexes = [n1, self._box_counts[1] + nt, self._box_counts[2] + nt] - end_indexes = [n1, 0, self._box_counts[2] + nt] - start = self._nx[start_indexes[2]][start_indexes[1]][start_indexes[0]] - corner = self._nx[corner_indexes[2]][corner_indexes[1]][corner_indexes[0]] - end = self._nx[end_indexes[2]][end_indexes[1]][end_indexes[0]] - px, _ = sampleHermiteCurve( - start[0], start[2], None, corner[0], [-d for d in corner[2]], None, self._box_counts[2], - start_weight=self._box_counts[2] + min_weight, end_weight=1.0 + min_weight) - self._set_coordinates_across( - [px], [[0]], start_indexes, [[0, 0, 1]], skip_start=True, skip_end=True, blend=True) - px, _ = sampleHermiteCurve( - corner[0], [-d for d in corner[2]], None, end[0], [-d for d in end[2]], None, self._box_counts[1], - start_weight=1.0 + min_weight, end_weight=self._box_counts[1] + min_weight) - self._set_coordinates_across( - [px], [[0]], corner_indexes, [[0, -1, 0]], skip_start=True, skip_end=True, blend=True) - - # smooth 1-direction - for n2 in range(1, self._box_counts[1]): - for n3 in range(1, self._box_counts[2]): - self._smooth_derivative_across( - [0, n2, n3], [self._element_counts[0], n2, n3], - [[1, 0, 0]], [1, 3], fix_start_direction=True, fix_end_direction=True) - for nt in range(1, self._trans_count): - self._smooth_derivative_across( - [0, n2, self._box_counts[2] + nt], [self._box_counts[0] + nt, n2, 0], - [[1, 0, 0], [0, 0, -1]], [1, 1, -2], fix_start_direction=True, fix_end_direction=True) - # smooth 2-direction - for n3 in range(1, self._box_counts[2]): - for n1 in range(1, self._box_counts[0]): - self._smooth_derivative_across( - [n1, 0, n3], [n1, self._element_counts[1], n3], - [[0, 1, 0]], [2, 3], fix_start_direction=True, fix_end_direction=True) - for nt in range(1, self._trans_count): - self._smooth_derivative_across( - [self._box_counts[0] + nt, 0, n3], [0, self._box_counts[1] + nt, n3], - [[0, 1, 0], [-1, 0, 0]], [1], fix_start_direction=True, fix_end_direction=True) - # smooth 3-direction - for n1 in range(1, self._box_counts[0]): - for n2 in range(1, self._box_counts[1]): - self._smooth_derivative_across( - [n1, n2, 0], [n1, n2, self._element_counts[2]], - [[0, 0, 1]], [3], fix_start_direction=True, fix_end_direction=True) - for nt in range(1, self._trans_count): - self._smooth_derivative_across( - [n1, self._box_counts[1] + nt, 0], [n1, 0, self._box_counts[2] + nt], - [[0, 0, 1], [0, -1, 0]], [2, -2], fix_start_direction=True, fix_end_direction=True) diff --git a/src/scaffoldmaker/utils/hextetrahedronmesh.py b/src/scaffoldmaker/utils/hextetrahedronmesh.py new file mode 100644 index 00000000..a491bce0 --- /dev/null +++ b/src/scaffoldmaker/utils/hextetrahedronmesh.py @@ -0,0 +1,624 @@ +""" +Utilities for building 3-D tetrahedron-shaped meshes out of hexahedral elements with cubic Hermite serendipity +interpolation. +""" +from scaffoldmaker.utils.interpolation import ( + DerivativeScalingMode, get_nway_point, linearlyInterpolateVectors, sampleHermiteCurve, + smoothCubicHermiteDerivativesLine) +from scaffoldmaker.utils.quadtrianglemesh import QuadTriangleMesh +import copy + + +class HexTetrahedronMesh: + """ + Generates a tetrahedron mesh from c1-continuous hex (cube) elements, with a 3-way or 4-way points inside. + Tetrahedron is defined from 4 corner points 0-3, origin and other axis ends as right-handed axes. + Parameters on faces are set from QuadTriangleMesh objects. + 3-D parameters are interpolated from face parameters. + Some limitations on currently supported number of elements in directions are asserted in the constructor. + """ + + def __init__(self, axis_counts, diag_counts, nway_d_factor=0.6): + """ + :param axis_counts: Number of elements along 0-1, 0-2, 0-3 axes. + :param diag_counts: Number of elements along 1-2, 1-3, 2-3 diagonals. + Coordinates nx are indexed in 1, 2, 3 directions from origin at index 0, 0, 0 + with holes around the corners and 3-way or 4-way points. + :param nway_d_factor: Value, normally from 0.5 to 1.0 giving n-way derivative magnitude as a proportion + of the minimum regular magnitude sampled to the n-way point. This reflects that distances from the mid-side + of a triangle to the centre are shorter, so the derivative in the middle must be smaller. + """ + assert all((count >= 2) for count in axis_counts) + assert all((count >= 2) for count in diag_counts) + # check the faces have valid element counts around them + max_diag_count0 = axis_counts[0] + axis_counts[1] - 2 + assert any((diag_counts[0] == diag_count) for diag_count in range(max_diag_count0, 2, -2)) + max_diag_count1 = axis_counts[0] + axis_counts[2] - 2 + assert any((diag_counts[1] == diag_count) for diag_count in range(max_diag_count1, 2, -2)) + max_diag_count2 = axis_counts[1] + axis_counts[2] - 2 + assert any((diag_counts[2] == diag_count) for diag_count in range(max_diag_count2, 2, -2)) + max_diag_count3 = diag_counts[0] + diag_counts[1] - 2 + assert any((diag_counts[2] == diag_count) for diag_count in range(max_diag_count3, 2, -2)) + self._axis_counts = copy.copy(axis_counts) + self._diag_counts = copy.copy(diag_counts) + self._nway_d_factor = nway_d_factor + + # current limitation: + # only supports 4-way point for now, consistent with constant transition count away from origin + trans_count0 = (axis_counts[0] + axis_counts[1] - diag_counts[0]) // 2 + trans_count1 = (axis_counts[0] + axis_counts[2] - diag_counts[1]) // 2 + trans_count2 = (axis_counts[1] + axis_counts[2] - diag_counts[2]) // 2 + assert trans_count0 == trans_count1 == trans_count2 + self._trans_count = trans_count0 + + # counts of elements to 3-way point opposite to 3 node at axis 1, axis 2, axis 3 + self._box_counts = [self._axis_counts[i] - self._trans_count for i in range(3)] + none_parameters = [None] * 4 # x, d1, d2, d3 + self._nx = [] # shield mesh with holes over n3, n2, n1, d + for n3 in range(axis_counts[2] + 1): + # index into transition zone + trans3 = self._trans_count + n3 - axis_counts[2] + nx_layer = [] + for n2 in range(axis_counts[1] + 1): + # index into transition zone + trans2 = self._trans_count + n2 - axis_counts[1] + nx_row = [] + # s = "" + for n1 in range(axis_counts[0] + 1): + # index into transition zone + trans1 = self._trans_count + n1 - axis_counts[0] + if (((trans1 <= 0) and (trans2 <= 0) and (trans3 <= 0)) or + (trans1 == trans2 == trans3) or + ((trans1 < 0) and ((trans2 == trans3) or (trans2 < 0))) or + ((trans2 < 0) and ((trans3 == trans1) or (trans3 < 0))) or + ((trans3 < 0) and ((trans1 == trans2) or (trans1 < 0)))): + parameters = copy.copy(none_parameters) + # s += "[]" + else: + parameters = None + # s += " " + nx_row.append(parameters) + nx_layer.append(nx_row) + # print(s) + self._nx.append(nx_layer) + + def get_parameters(self): + """ + Get parameters array e.g. for copying to ellipsoid. + :return: Internal parameters array self._nx. Not to be modified. + """ + return self._nx + + def set_triangle_abc(self, trimesh: QuadTriangleMesh): + """ + Set parameters on the outer abc surface triangle of octant. + :param trimesh: Coordinates to set on outer surface. + """ + assert trimesh.get_element_count12() == self._diag_counts[0] + assert trimesh.get_element_count13() == self._diag_counts[1] + assert trimesh.get_element_count23() == self._diag_counts[2] + start_indexes = [self._axis_counts[0], 0, 0] + for n3 in range(self._box_counts[2]): + px, pd1, pd2, pd3 = trimesh.get_parameters12(n3) + self._set_coordinates_across([px, pd1, pd2, pd3], [[0, 1, 2, 3]], start_indexes, [[0, 1, 0], [-1, 0, 0]]) + start_indexes[2] += 1 + start_indexes = [0, 0, self._axis_counts[2]] + for n2 in range(self._box_counts[1]): + px, pd1, pd2, pd3 = trimesh.get_parameters31(n2, self._box_counts[0] + 1) + self._set_coordinates_across([px, pd1, pd2, pd3], [[0, 1, 2, 3]], start_indexes, [[1, 0, 0]]) + start_indexes[1] += 1 + start_indexes = [0, self._axis_counts[1], self._axis_counts[2]] + px, pd1, pd2, pd3 = trimesh.get_parameters_diagonal() + self._set_coordinates_across([px, pd1, pd2, pd3], [[0, 1, 2, 3]], start_indexes, [[1, 0, 0]]) + + def set_triangle_abo(self, trimesh: QuadTriangleMesh): + """ + Set parameters on triangle 1-2-origin, an inner surface of octant. + :param trimesh: Triangle coordinate data with x, d1, d2, optional d3. + """ + assert trimesh.get_element_count12() == self._diag_counts[0] + assert trimesh.get_element_count13() == self._axis_counts[0] + assert trimesh.get_element_count23() == self._axis_counts[1] + start_indexes = [self._axis_counts[0], 0, 0] + for n0 in range(self._trans_count): + px, pd1, pd2, pd3 = trimesh.get_parameters12(n0) + self._set_coordinates_across([px, pd1, pd2, pd3], [[0, 1, -3, 2]], start_indexes, [[0, 1, 0], [-1, 0, 0]]) + start_indexes[0] -= 1 + start_indexes = [0, 0, 0] + for n2 in range(self._box_counts[1] + 1): + px, pd1, pd2, pd3 = (trimesh.get_parameters31(n2, self._box_counts[0] + 1) if (n2 < self._box_counts[1]) + else trimesh.get_parameters_diagonal()) + self._set_coordinates_across([px, pd1, pd2, pd3], [[0, 1, 2, 3]], start_indexes, [[1, 0, 0]]) + start_indexes[1] += 1 + + def set_triangle_aco(self, trimesh: QuadTriangleMesh): + """ + Set parameters on triangle 1-3-origin, an inner surface of octant. + :param trimesh: Triangle coordinate data with x, d1, d2, optional d3. + """ + assert trimesh.get_element_count12() == self._diag_counts[1] + assert trimesh.get_element_count13() == self._axis_counts[0] + assert trimesh.get_element_count23() == self._axis_counts[2] + start_indexes = [self._axis_counts[0], 0, 0] + for n0 in range(self._trans_count): + px, pd1, pd2, pd3 = trimesh.get_parameters12(n0) + self._set_coordinates_across( + [px, pd1, pd2, pd3], [[0, 2, -3, -1], [0, -1, -3, -2]], start_indexes, [[0, 0, 1], [-1, 0, 0]]) + start_indexes[0] -= 1 + start_indexes = [0, 0, 0] + for n3 in range(self._box_counts[2] + 1): + px, pd1, pd2, pd3 = (trimesh.get_parameters31(n3, self._box_counts[0] + 1) if (n3 < self._box_counts[2]) + else trimesh.get_parameters_diagonal()) + self._set_coordinates_across([px, pd1, pd2, pd3], [[0, 1, 3, -2]], start_indexes, [[1, 0, 0]]) + start_indexes[2] += 1 + + def set_triangle_bco(self, trimesh: QuadTriangleMesh): + """ + Set parameters on triangle 2-3-origin, an inner surface of octant. + :param trimesh: Triangle coordinate data with x, d1, d2, optional d3. + """ + assert trimesh.get_element_count12() == self._diag_counts[2] + assert trimesh.get_element_count13() == self._axis_counts[1] + assert trimesh.get_element_count23() == self._axis_counts[2] + start_indexes = [0, self._axis_counts[1], 0] + for n0 in range(self._trans_count): + px, pd1, pd2, pd3 = trimesh.get_parameters12(n0) + self._set_coordinates_across( + [px, pd1, pd2, pd3], [[0, 2, -3, -1], [0, -2, -3, 1]], start_indexes, [[0, 0, 1], [0, -1, 0]]) + start_indexes[1] -= 1 + start_indexes = [0, 0, 0] + for n3 in range(self._box_counts[2] + 1): + px, pd1, pd2, pd3 = (trimesh.get_parameters31(n3, self._box_counts[1] + 1) if (n3 < self._box_counts[2]) + else trimesh.get_parameters_diagonal()) + self._set_coordinates_across([px, pd1, pd2, pd3], [[0, 2, 3, 1]], start_indexes, [[0, 1, 0]]) + start_indexes[2] += 1 + + def _get_transitions(self, indexes): + """ + For each index direction, get False if in core or True if in transition zone. + :param indexes: Location indexes in 1, 2, 3 directions. + :return: Transition 1, 2, 3 directions. + """ + return [(indexes[i] - self._box_counts[i]) > 0 for i in range(3)] + + def _set_coordinates_across(self, parameters, parameter_indexes, start_indexes, index_increments, + skip_start=False, skip_end=False, blend=False): + """ + Insert parameters across the coordinates array. + :param parameters: List of lists of N node parameters e.g. [px, pd1, pd2, pd3] + :param parameter_indexes: Lists of parameter indexes where x=0, d1=1, d2=2, d3=3. Starts with first and + advances at transitions change, then stays on the last. Can be negative to invert vector. + e.g. [[0, 1, 2], [0, -2, 1]] for [x, d1, d2] then [x, -d2, d1] from first corner. + :param start_indexes: Indexes into nx array for start point. + :param index_increments: List of increments in indexes. Starts with first and uses next at each transition + change, then stays on the last. + :param skip_start: Set to True to skip the first value. + :param skip_end: Set to True to skip the last value. + :param blend: Set to True to blend parameters with any old parameters at locations. + """ + indexes = start_indexes + parameter_number = 0 + parameter_index = parameter_indexes[0] + increment_number = 0 + index_increment = index_increments[0] + start_n = 1 if skip_start else 0 + last_n = len(parameters[0]) - 1 + limit_n = len(parameters[0]) - (1 if skip_end else 0) + last_trans = self._get_transitions(indexes) + for n in range(start_n, limit_n): + if n > 0: + while True: + indexes = [indexes[c] + index_increment[c] for c in range(3)] + # skip over blank transition coordinates + if self._nx[indexes[2]][indexes[1]][indexes[0]]: + break + trans = self._get_transitions(indexes) + if last_trans and (trans != last_trans): + if parameter_number < (len(parameter_indexes) - 1): + parameter_number += 1 + parameter_index = parameter_indexes[parameter_number] + if increment_number < (len(index_increments) - 1): + increment_number += 1 + index_increment = index_increments[increment_number] + nx = self._nx[indexes[2]][indexes[1]][indexes[0]] + for parameter, spix in zip(parameters, parameter_index): + if not parameter[n]: + continue + new_parameter = [-d for d in parameter[n]] if (spix < 0) else copy.copy(parameter[n]) + pix = abs(spix) + if blend and nx[pix]: + if pix == 0: + new_parameter = [0.5 * (nx[pix][c] + new_parameter[c]) for c in range(3)] + else: + # harmonic mean to cope with significant element size differences on boundary + new_parameter = linearlyInterpolateVectors( + nx[pix], new_parameter, 0.5, magnitudeScalingMode=DerivativeScalingMode.HARMONIC_MEAN) + nx[pix] = new_parameter + last_trans = trans + + def _smooth_derivative_across(self, start_indexes, end_indexes, index_increments, derivative_indexes, + fix_start_direction=True, fix_end_direction=True): + """ + Smooth derivatives across octant. + :param start_indexes: Indexes of first point. + :param end_indexes: Indexes of last point. + :param index_increments: List of increments in indexes. Starts with first and advances at transitions change, + then stays on the last. + :param derivative_indexes: List of signed derivative parameter index to set along where 1=d1, 2=d2, 3=d3. + Starts with first and advances at transitions change, then stays on the last. Can be negative to invert vector. + e.g. [1, -2] for d1 then -d2 from first transition change. + :param fix_start_direction: Set to True to keep the start direction but scale its magnitude. + :param fix_end_direction: Set to True to keep the end direction but scale its magnitude. + """ + indexes = start_indexes + derivative_number = 0 + derivative_index = derivative_indexes[0] + increment_number = 0 + index_increment = index_increments[0] + indexes_list = [] + derivative_index_list = [] + px = [] + pd = [] + n = 0 + last_trans = self._get_transitions(indexes) + while True: + if n > 0: + if indexes == end_indexes: + break + while True: + indexes = [indexes[i] + index_increment[i] for i in range(3)] + # skip over blank coordinates in transition zone + if self._nx[indexes[2]][indexes[1]][indexes[0]]: + break + trans = self._get_transitions(indexes) + if last_trans and (trans != last_trans): + if derivative_number < (len(derivative_indexes) - 1): + derivative_number += 1 + derivative_index = derivative_indexes[derivative_number] + if increment_number < (len(index_increments) - 1): + increment_number += 1 + index_increment = index_increments[increment_number] + parameters = self._nx[indexes[2]][indexes[1]][indexes[0]] + x = parameters[0] + indexes_list.append(copy.copy(indexes)) + spix = derivative_index + derivative_index_list.append(spix) + pix = abs(spix) + if parameters[pix]: + d = [-ad for ad in parameters[pix]] if (spix < 0) else parameters[pix] + else: + d = [0.0, 0.0, 0.0] + px.append(x) + pd.append(d) + n += 1 + last_trans = trans + sd = smoothCubicHermiteDerivativesLine( + px, pd, fixStartDirection=fix_start_direction, fixEndDirection=fix_end_direction) + for n in range(len(sd)): + indexes = indexes_list[n] + spix = derivative_index_list[n] + new_derivative = [-d for d in sd[n]] if (spix < 0) else sd[n] + pix = abs(spix) + self._nx[indexes[2]][indexes[1]][indexes[0]][pix] = new_derivative + + def build_interior(self): + """ + Determine interior coordinates from surface coordinates. + """ + # determine 4-way point location from mean curves between side points linking to it + point12 = self._nx[0][self._box_counts[1]][self._box_counts[0]] + point13 = self._nx[self._box_counts[2]][0][self._box_counts[0]] + point23 = self._nx[self._box_counts[2]][self._box_counts[1]][0] + point123 = self._nx[self._axis_counts[2]][self._axis_counts[1]][self._axis_counts[0]] + + x_4way, d_4way = get_nway_point( + [point23[0], point13[0], point12[0], point123[0]], + [point23[1], point13[2], point12[3], [-d for d in point123[3]]], + [self._box_counts[0], self._box_counts[1], self._box_counts[2], self._trans_count], + sampleHermiteCurve, nway_d_factor=self._nway_d_factor) + + # smooth sample from sides to 3-way points using end derivatives + min_weight = 1 # GRC revisit, remove? + ax, ad1 = sampleHermiteCurve( + point23[0], point23[1], None, x_4way, d_4way[0], None, self._box_counts[0], + start_weight=self._box_counts[0] + min_weight, end_weight=1.0 + min_weight, end_transition=True) + bx, bd2 = sampleHermiteCurve( + point13[0], point13[2], None, x_4way, d_4way[1], None, self._box_counts[1], + start_weight=self._box_counts[1] + min_weight, end_weight=1.0 + min_weight, end_transition=True) + cx, cd3 = sampleHermiteCurve( + point12[0], point12[3], None, x_4way, d_4way[2], None, self._box_counts[2], + start_weight=self._box_counts[2] + min_weight, end_weight=1.0 + min_weight, end_transition=True) + tx, td3 = sampleHermiteCurve( + point123[0], [-d for d in point123[3]], None, x_4way, d_4way[3], None, self._trans_count, + start_weight=self._trans_count + min_weight, end_weight=1.0 + min_weight, end_transition=True) + + self._set_coordinates_across([ax, ad1], [[0, 1]], [0, self._box_counts[1], self._box_counts[2]], [[1, 0, 0]]) + self._set_coordinates_across([bx, bd2], [[0, 2]], [self._box_counts[0], 0, self._box_counts[2]], [[0, 1, 0]]) + self._set_coordinates_across([cx, cd3], [[0, 3]], [self._box_counts[0], self._box_counts[1], 0], [[0, 0, 1]]) + self._set_coordinates_across([tx, td3], [[0, -3]], + [self._axis_counts[0], self._axis_counts[1], self._axis_counts[2]], + [[-1, -1, -1]], skip_end=True) + + # sample up to 3-way lines connecting to 4-way point + for n3 in range(1, self._box_counts[2]): + point13 = self._nx[n3][0][self._box_counts[0]] + point23 = self._nx[n3][self._box_counts[1]][0] + point123 = self._nx[n3][self._axis_counts[1]][self._axis_counts[0]] + point_3way = self._nx[n3][self._box_counts[1]][self._box_counts[0]] + + x_3way, d_3way = get_nway_point( + [point23[0], point13[0], point123[0]], + [point23[1], point13[2], [-d for d in point123[3]]], + [self._box_counts[0], self._box_counts[1], self._trans_count], + sampleHermiteCurve, prescribed_x_nway=point_3way[0], nway_d_factor=self._nway_d_factor) + + ax, ad1, ad2 = sampleHermiteCurve( + point23[0], point23[1], point23[2], x_3way, d_3way[0], None, self._box_counts[0], + start_weight=self._box_counts[0] + min_weight, end_weight=1.0 + min_weight, end_transition=True) + ad1[-1] = d_3way[0] + ad2[-1] = d_3way[1] + bx, bd2, bd1 = sampleHermiteCurve( + point13[0], point13[2], point13[1], x_3way, d_3way[1], None, self._box_counts[1], + start_weight=self._box_counts[1] + min_weight, end_weight=1.0 + min_weight, end_transition=True) + bd1[-1] = d_3way[0] + bd2[-1] = d_3way[1] + tx, td3, td1 = sampleHermiteCurve( + point123[0], [-d for d in point123[3]], point123[1], x_3way, d_3way[2], None, + self._trans_count, start_weight=self._trans_count + min_weight, + end_weight=1.0 + min_weight, end_transition=True) + + self._set_coordinates_across([ax, ad1, ad2], [[0, 1, 2]], [0, self._box_counts[1], n3], [[1, 0, 0]]) + self._set_coordinates_across([bx, bd1, bd2], [[0, 1, 2]], [self._box_counts[0], 0, n3], [[0, 1, 0]]) + self._set_coordinates_across( + [tx, td1, td3], [[0, 1, -3]], [self._axis_counts[0], self._axis_counts[1], n3], [[-1, -1, 0]], + skip_end=True) + + for n2 in range(1, self._box_counts[1]): + point12 = self._nx[0][n2][self._box_counts[0]] + point23 = self._nx[self._box_counts[2]][n2][0] + point123 = self._nx[self._axis_counts[2]][n2][self._axis_counts[0]] + point_3way = self._nx[self._box_counts[2]][n2][self._box_counts[0]] + + x_3way, d_3way = get_nway_point( + [point23[0], point12[0], point123[0]], + [point23[1], point12[3], [-d for d in point123[3]]], + [self._box_counts[0], self._box_counts[2], self._trans_count], + sampleHermiteCurve, prescribed_x_nway=point_3way[0], nway_d_factor=self._nway_d_factor) + + ax, ad1, ad3 = sampleHermiteCurve( + point23[0], point23[1], point23[3], x_3way, d_3way[0], None, self._box_counts[0], + start_weight=self._box_counts[0] + min_weight, end_weight=1.0 + min_weight, end_transition=True) + ad1[-1] = d_3way[0] + ad3[-1] = d_3way[1] + bx, bd3, bd1 = sampleHermiteCurve( + point12[0], point12[3], point12[1], x_3way, d_3way[1], None, self._box_counts[2], + start_weight=self._box_counts[2] + min_weight, end_weight=1.0 + min_weight, end_transition=True) + bd1[-1] = d_3way[0] + bd3[-1] = d_3way[1] + tx, td3, td1 = sampleHermiteCurve( + point123[0], [-d for d in point123[3]], point123[1], x_3way, d_3way[2], None, + self._trans_count, start_weight=self._trans_count + min_weight, + end_weight=1.0 + min_weight, end_transition=True) + + self._set_coordinates_across( + [ax, ad1, ad3], [[0, 1, 3]], [0, n2, self._box_counts[2]], [[1, 0, 0]], blend=True) + self._set_coordinates_across( + [bx, bd1, bd3], [[0, 1, 3]], [self._box_counts[0], n2, 0], [[0, 0, 1]], blend=True) + self._set_coordinates_across( + [tx, td1, td3], [[0, 1, -3]], [self._axis_counts[0], n2, self._axis_counts[2]], [[-1, 0, -1]], + skip_end=True, blend=True) + + for n1 in range(1, self._box_counts[0]): + point12 = self._nx[0][self._box_counts[1]][n1] + point13 = self._nx[self._box_counts[2]][0][n1] + point123 = self._nx[self._axis_counts[2]][self._axis_counts[1]][n1] + point_3way = self._nx[self._box_counts[2]][self._box_counts[1]][n1] + + x_3way, d_3way = get_nway_point( + [point13[0], point12[0], point123[0]], + [point13[2], point12[3], [-d for d in point123[3]]], + [self._box_counts[0], self._box_counts[2], self._trans_count], + sampleHermiteCurve, prescribed_x_nway=point_3way[0], nway_d_factor=self._nway_d_factor) + + ax, ad2, ad3 = sampleHermiteCurve( + point13[0], point13[2], point13[3], x_3way, d_3way[0], None, self._box_counts[1], + start_weight=self._box_counts[1] + min_weight, end_weight=1.0 + min_weight, end_transition=True) + ad2[-1] = d_3way[0] + ad3[-1] = d_3way[1] + bx, bd3, bd2 = sampleHermiteCurve( + point12[0], point12[3], point12[2], x_3way, d_3way[1], None, self._box_counts[2], + start_weight=self._box_counts[2] + min_weight, end_weight=1.0 + min_weight, end_transition=True) + bd2[-1] = d_3way[0] + bd3[-1] = d_3way[1] + tx, td3, td2 = sampleHermiteCurve( + point123[0], [-d for d in point123[3]], point123[2], x_3way, d_3way[2], None, + self._trans_count, start_weight=self._trans_count + min_weight, + end_weight=1.0 + min_weight, end_transition=True) + + self._set_coordinates_across( + [ax, ad2, ad3], [[0, 2, 3]], [n1, 0, self._box_counts[2]], [[0, 1, 0]], blend=True) + self._set_coordinates_across( + [bx, bd2, bd3], [[0, 2, 3]], [n1, self._box_counts[1], 0], [[0, 0, 1]], blend=True) + self._set_coordinates_across( + [tx, td2, td3], [[0, 2, -3]], [n1, self._axis_counts[1], self._axis_counts[2]], + [[0, -1, -1]], skip_end=True, blend=True) + + for nt in range(1, self._trans_count): + point12 = self._nx[0][self._axis_counts[1] - nt][self._axis_counts[0] - nt] + point13 = self._nx[self._axis_counts[2] - nt][0][self._axis_counts[0] - nt] + point23 = self._nx[self._axis_counts[2] - nt][self._axis_counts[1] - nt][0] + point_3way = \ + self._nx[self._axis_counts[2] - nt][self._axis_counts[1] - nt][self._axis_counts[0] - nt] + + x_3way, d_3way = get_nway_point( + [point23[0], point13[0], point12[0]], + [point23[1], point13[2], point12[2]], + [self._box_counts[0], self._box_counts[1], self._box_counts[2]], + sampleHermiteCurve, prescribed_x_nway=point_3way[0], nway_d_factor=self._nway_d_factor) + + ax, ad1, ad2 = sampleHermiteCurve( + point23[0], point23[1], point23[2], x_3way, d_3way[0], None, self._box_counts[0], + start_weight=self._box_counts[0] + min_weight, end_weight=1.0 + min_weight, end_transition=True) + ad1[-1] = d_3way[0] + ad2[-1] = d_3way[1] + bx, bd2, bd1 = sampleHermiteCurve( + point13[0], point13[2], point13[1], x_3way, d_3way[1], None, self._box_counts[1], + start_weight=self._box_counts[1] + min_weight, end_weight=1.0 + min_weight, end_transition=True) + bd1[-1] = d_3way[0] + bd2[-1] = d_3way[1] + cx, cd2, cd1 = sampleHermiteCurve( + point12[0], point12[2], point12[1], x_3way, d_3way[2], None, self._box_counts[2], + start_weight=self._box_counts[2] + min_weight, end_weight=1.0 + min_weight, + end_transition=True) + + self._set_coordinates_across( + [ax, ad1, ad2], [[0, 1, 2]], [0, self._axis_counts[1] - nt, self._axis_counts[2] - nt], + [[1, 0, 0]], blend=True) + self._set_coordinates_across( + [bx, bd1, bd2], [[0, 1, 2]], [self._axis_counts[0] - nt, 0, self._axis_counts[2] - nt], + [[0, 1, 0]], blend=True) + self._set_coordinates_across( + [cx, cd1, cd2], [[0, 1, 2]], [self._axis_counts[0] - nt, self._axis_counts[1] - nt, 0], + [[0, 0, 1]], skip_end=True, blend=True) + + # average point coordinates across 3 directions between side faces and surfaces to 4 3-way lines. + min_weight = 1 # GRC revisit, remove? + # 1-direction + for n2 in range(1, self._box_counts[1]): + for n3 in range(1, self._box_counts[2]): + start_indexes = [0, n2, n3] + corner_indexes = [self._box_counts[0], n2, n3] + end_indexes = [self._axis_counts[0], n2, n3] + start = self._nx[start_indexes[2]][start_indexes[1]][start_indexes[0]] + corner = self._nx[corner_indexes[2]][corner_indexes[1]][corner_indexes[0]] + end = self._nx[end_indexes[2]][end_indexes[1]][end_indexes[0]] + px, _ = sampleHermiteCurve( + start[0], start[1], None, corner[0], corner[1], None, self._box_counts[0], + start_weight=self._box_counts[0] + min_weight, end_weight=1.0 + min_weight) + self._set_coordinates_across( + [px], [[0]], start_indexes, [[1, 0, 0]], skip_start=True, skip_end=True, blend=True) + px, _ = sampleHermiteCurve( + corner[0], corner[1], None, end[0], end[3], None, self._trans_count, + start_weight=1.0 + min_weight, end_weight=self._trans_count + min_weight) + self._set_coordinates_across( + [px], [[0]], corner_indexes, [[1, 0, 0]], skip_start=True, skip_end=True, blend=True) + for nt in range(1, self._trans_count): + start_indexes = [0, n2, self._box_counts[2] + nt] + corner_indexes = [self._box_counts[0] + nt, n2, self._box_counts[2] + nt] + end_indexes = [self._box_counts[0] + nt, n2, 0] + start = self._nx[start_indexes[2]][start_indexes[1]][start_indexes[0]] + corner = self._nx[corner_indexes[2]][corner_indexes[1]][corner_indexes[0]] + end = self._nx[end_indexes[2]][end_indexes[1]][end_indexes[0]] + px, _ = sampleHermiteCurve( + start[0], start[1], None, corner[0], corner[1], None, self._box_counts[0], + start_weight=self._box_counts[0] + min_weight, end_weight=1.0 + min_weight) + self._set_coordinates_across( + [px], [[0]], start_indexes, [[1, 0, 0]], skip_start=True, skip_end=True, blend=True) + px, _ = sampleHermiteCurve( + corner[0], corner[1], None, end[0], [-d for d in end[2]], None, self._box_counts[2], + start_weight=1.0 + min_weight, end_weight=self._box_counts[2] + min_weight) + self._set_coordinates_across( + [px], [[0]], corner_indexes, [[0, 0, -1]], skip_start=True, skip_end=True, blend=True) + # 2-direction + for n3 in range(1, self._box_counts[2]): + for n1 in range(1, self._box_counts[0]): + start_indexes = [n1, 0, n3] + corner_indexes = [n1, self._box_counts[1], n3] + end_indexes = [n1, self._axis_counts[1], n3] + start = self._nx[start_indexes[2]][start_indexes[1]][start_indexes[0]] + corner = self._nx[corner_indexes[2]][corner_indexes[1]][corner_indexes[0]] + end = self._nx[end_indexes[2]][end_indexes[1]][end_indexes[0]] + px, _ = sampleHermiteCurve( + start[0], start[2], None, corner[0], corner[2], None, self._box_counts[1], + start_weight=self._box_counts[1] + min_weight, end_weight=1.0 + min_weight) + self._set_coordinates_across( + [px], [[0]], start_indexes, [[0, 1, 0]], skip_start=True, skip_end=True, blend=True) + px, _ = sampleHermiteCurve( + corner[0], corner[2], None, end[0], end[3], None, self._trans_count, + start_weight=1.0 + min_weight, end_weight=self._trans_count + min_weight) + self._set_coordinates_across( + [px], [[0]], corner_indexes, [[0, 1, 0]], skip_start=True, skip_end=True, blend=True) + for nt in range(1, self._trans_count): + start_indexes = [self._box_counts[0] + nt, 0, n3] + corner_indexes = [self._box_counts[0] + nt, self._box_counts[1] + nt, n3] + end_indexes = [0, self._box_counts[1] + nt, n3] + start = self._nx[start_indexes[2]][start_indexes[1]][start_indexes[0]] + corner = self._nx[corner_indexes[2]][corner_indexes[1]][corner_indexes[0]] + end = self._nx[end_indexes[2]][end_indexes[1]][end_indexes[0]] + px, _ = sampleHermiteCurve( + start[0], start[1], None, corner[0], corner[1], None, self._box_counts[1], + start_weight=self._box_counts[1] + min_weight, end_weight=1.0 + min_weight) + self._set_coordinates_across( + [px], [[0]], start_indexes, [[0, 1, 0]], skip_start=True, skip_end=True, blend=True) + px, _ = sampleHermiteCurve( + corner[0], corner[1], None, end[0], end[1], None, self._box_counts[0], + start_weight=1.0 + min_weight, end_weight=self._box_counts[0] + min_weight) + self._set_coordinates_across( + [px], [[0]], corner_indexes, [[-1, 0, 0]], skip_start=True, skip_end=True, blend=True) + # 3-direction + for n1 in range(1, self._box_counts[0]): + for n2 in range(1, self._box_counts[1]): + start_indexes = [n1, n2, 0] + corner_indexes = [n1, n2, self._box_counts[2]] + end_indexes = [n1, n2, self._axis_counts[2]] + start = self._nx[start_indexes[2]][start_indexes[1]][start_indexes[0]] + corner = self._nx[corner_indexes[2]][corner_indexes[1]][corner_indexes[0]] + end = self._nx[end_indexes[2]][end_indexes[1]][end_indexes[0]] + px, _ = sampleHermiteCurve( + start[0], start[3], None, corner[0], corner[3], None, self._box_counts[2], + start_weight=self._box_counts[2] + min_weight, end_weight=1.0 + min_weight) + self._set_coordinates_across( + [px], [[0]], start_indexes, [[0, 0, 1]], skip_start=True, skip_end=True, blend=True) + px, _ = sampleHermiteCurve( + corner[0], corner[3], None, end[0], end[3], None, self._trans_count, + start_weight=1.0 + min_weight, end_weight=self._trans_count + min_weight) + self._set_coordinates_across( + [px], [[0]], corner_indexes, [[0, 0, 1]], skip_start=True, skip_end=True, blend=True) + for nt in range(1, self._trans_count): + start_indexes = [n1, self._box_counts[1] + nt, 0] + corner_indexes = [n1, self._box_counts[1] + nt, self._box_counts[2] + nt] + end_indexes = [n1, 0, self._box_counts[2] + nt] + start = self._nx[start_indexes[2]][start_indexes[1]][start_indexes[0]] + corner = self._nx[corner_indexes[2]][corner_indexes[1]][corner_indexes[0]] + end = self._nx[end_indexes[2]][end_indexes[1]][end_indexes[0]] + px, _ = sampleHermiteCurve( + start[0], start[2], None, corner[0], [-d for d in corner[2]], None, self._box_counts[2], + start_weight=self._box_counts[2] + min_weight, end_weight=1.0 + min_weight) + self._set_coordinates_across( + [px], [[0]], start_indexes, [[0, 0, 1]], skip_start=True, skip_end=True, blend=True) + px, _ = sampleHermiteCurve( + corner[0], [-d for d in corner[2]], None, end[0], [-d for d in end[2]], None, self._box_counts[1], + start_weight=1.0 + min_weight, end_weight=self._box_counts[1] + min_weight) + self._set_coordinates_across( + [px], [[0]], corner_indexes, [[0, -1, 0]], skip_start=True, skip_end=True, blend=True) + + # smooth 1-direction + for n2 in range(1, self._box_counts[1]): + for n3 in range(1, self._box_counts[2]): + self._smooth_derivative_across( + [0, n2, n3], [self._axis_counts[0], n2, n3], + [[1, 0, 0]], [1, 3], fix_start_direction=True, fix_end_direction=True) + for nt in range(1, self._trans_count): + self._smooth_derivative_across( + [0, n2, self._box_counts[2] + nt], [self._box_counts[0] + nt, n2, 0], + [[1, 0, 0], [0, 0, -1]], [1, 1, -2], fix_start_direction=True, fix_end_direction=True) + # smooth 2-direction + for n3 in range(1, self._box_counts[2]): + for n1 in range(1, self._box_counts[0]): + self._smooth_derivative_across( + [n1, 0, n3], [n1, self._axis_counts[1], n3], + [[0, 1, 0]], [2, 3], fix_start_direction=True, fix_end_direction=True) + for nt in range(1, self._trans_count): + self._smooth_derivative_across( + [self._box_counts[0] + nt, 0, n3], [0, self._box_counts[1] + nt, n3], + [[0, 1, 0], [-1, 0, 0]], [1], fix_start_direction=True, fix_end_direction=True) + # smooth 3-direction + for n1 in range(1, self._box_counts[0]): + for n2 in range(1, self._box_counts[1]): + self._smooth_derivative_across( + [n1, n2, 0], [n1, n2, self._axis_counts[2]], + [[0, 0, 1]], [3], fix_start_direction=True, fix_end_direction=True) + for nt in range(1, self._trans_count): + self._smooth_derivative_across( + [n1, self._box_counts[1] + nt, 0], [n1, 0, self._box_counts[2] + nt], + [[0, 0, 1], [0, -1, 0]], [2, -2], fix_start_direction=True, fix_end_direction=True) From e278d9128ad38c98a78a9e04b60a7940ee69c390 Mon Sep 17 00:00:00 2001 From: Richard Christie Date: Wed, 5 Nov 2025 19:36:20 +1300 Subject: [PATCH 03/24] Modularise ellipsoid build --- .../meshtypes/meshtype_3d_lung4.py | 815 ++++++++++++++++++ src/scaffoldmaker/utils/eft_utils.py | 16 +- src/scaffoldmaker/utils/ellipsoidmesh.py | 397 +++------ src/scaffoldmaker/utils/hextetrahedronmesh.py | 30 +- src/scaffoldmaker/utils/interpolation.py | 7 +- tests/test_ellipsoid.py | 2 +- 6 files changed, 969 insertions(+), 298 deletions(-) create mode 100644 src/scaffoldmaker/meshtypes/meshtype_3d_lung4.py diff --git a/src/scaffoldmaker/meshtypes/meshtype_3d_lung4.py b/src/scaffoldmaker/meshtypes/meshtype_3d_lung4.py new file mode 100644 index 00000000..a35a5c79 --- /dev/null +++ b/src/scaffoldmaker/meshtypes/meshtype_3d_lung4.py @@ -0,0 +1,815 @@ +""" +Generates a lung scaffold with common hilum but open fissures with lobes built from 1/6 ellipsoid segments. +""" +from cmlibs.utils.zinc.field import find_or_create_field_coordinates +from cmlibs.zinc.field import Field + +from scaffoldmaker.annotation.annotationgroup import AnnotationGroup, findOrCreateAnnotationGroupForTerm, \ + getAnnotationGroupForTerm +from scaffoldmaker.annotation.lung_terms import get_lung_term +from scaffoldmaker.meshtypes.scaffold_base import Scaffold_base +from scaffoldmaker.utils.meshrefinement import MeshRefinement +from scaffoldmaker.utils.ellipsoidmesh import EllipsoidMesh +from scaffoldmaker.utils.zinc_utils import translate_nodeset_coordinates + +import math + + +class MeshType_3d_lung4(Scaffold_base): + """ + Generates a lung scaffold with common hilum but open fissures with lobes built from 1/6 ellipsoid segments. + """ + + @classmethod + def getName(cls): + return "3D Lung 4" + + @classmethod + def getParameterSetNames(cls): + return [ + "Default", + "Human 1 Coarse", + "Human 1 Medium", + "Human 1 Fine", + "Ellipsoid Coarse", + "Ellipsoid Medium", + "Ellipsoid Fine" + ] + + @classmethod + def getDefaultOptions(cls, parameterSetName="Default"): + options = {} + useParameterSetName = "Human 1 Coarse" if (parameterSetName == "Default") else parameterSetName + options["Left lung"] = True + options["Right lung"] = True + # options["Number of left lung lobes"] = 2 + options["Ellipsoid height"] = 1.0 + options["Ellipsoid dorsal-ventral size"] = 0.8 + options["Ellipsoid medial-lateral size"] = 0.5 + options["Left-right lung spacing"] = 0.6 + options["Refine"] = False + options["Refine number of elements"] = 4 + + if "Coarse" in useParameterSetName: + options["Number of elements lateral"] = 4 + options["Number of elements normal"] = 6 + options["Number of elements oblique"] = 6 + options["Number of transition elements"] = 1 + elif "Medium" in useParameterSetName: + options["Number of elements lateral"] = 4 + options["Number of elements normal"] = 10 + options["Number of elements oblique"] = 10 + options["Number of transition elements"] = 1 + elif "Fine" in useParameterSetName: + options["Number of elements lateral"] = 6 + options["Number of elements normal"] = 14 + options["Number of elements oblique"] = 14 + options["Number of transition elements"] = 1 + + if "Human" in useParameterSetName: + options["Base lateral edge sharpness factor"] = 0.8 + options["Ventral edge sharpness factor"] = 0.8 + options["Left oblique slope degrees"] = 60.0 + options["Right oblique slope degrees"] = 60.0 + options["Medial curvature"] = 3.0 + options["Medial curvature bias"] = 1.0 + options["Dorsal-ventral rotation degrees"] = 20.0 + options["Ventral-medial rotation degrees"] = 0.0 + else: + options["Base lateral edge sharpness factor"] = 0.0 + options["Ventral edge sharpness factor"] = 0.0 + options["Left oblique slope degrees"] = 0.0 + options["Right oblique slope degrees"] = 0.0 + options["Medial curvature"] = 0.0 + options["Medial curvature bias"] = 0.0 + options["Dorsal-ventral rotation degrees"] = 0.0 + options["Ventral-medial rotation degrees"] = 0.0 + + return options + + @classmethod + def getOrderedOptionNames(cls): + return [ + "Left lung", + "Right lung", + # "Number of left lung lobes", + "Number of elements lateral", + "Number of elements normal", + "Number of elements oblique", + "Number of transition elements", + "Ellipsoid height", + "Ellipsoid dorsal-ventral size", + "Ellipsoid medial-lateral size", + "Left-right lung spacing", + "Base lateral edge sharpness factor", + "Ventral edge sharpness factor", + "Medial curvature", + "Medial curvature bias", + "Dorsal-ventral rotation degrees", + "Ventral-medial rotation degrees", + "Left oblique slope degrees", + "Right oblique slope degrees", + "Refine", + "Refine number of elements" + ] + + @classmethod + def checkOptions(cls, options): + dependentChanges = False + # if options["Number of left lung lobes"] > 2: + # options["Number of left lung lobes"] = 2 + # elif options["Number of left lung lobes"] < 1: + # options["Number of left lung lobes"] = 0 + + max_transition_count = None + for key in [ + "Number of elements lateral", + "Number of elements normal", + "Number of elements oblique" + ]: + min_elements_count = 4 if key == "Number of elements lateral" else 6 + if options[key] < min_elements_count: + options[key] = min_elements_count + elif options[key] % 2: + options[key] += 1 + transition_count = (options[key] // 2) - 1 + if (max_transition_count is None) or (transition_count < max_transition_count): + max_transition_count = transition_count + + if options["Number of transition elements"] < 1: + options["Number of transition elements"] = 1 + elif options["Number of transition elements"] > max_transition_count: + options["Number of transition elements"] = max_transition_count + dependentChanges = True + + for dimension in [ + "Ellipsoid height", + "Ellipsoid dorsal-ventral size", + "Ellipsoid medial-lateral size" + ]: + if options[dimension] <= 0.0: + options[dimension] = 1.0 + + if options["Left-right lung spacing"] < 0.0: + options["Left-right lung spacing"] = 0.0 + + for dimension in [ + "Base lateral edge sharpness factor", + "Ventral edge sharpness factor", + "Medial curvature bias" + ]: + if options[dimension] < 0.0: + options[dimension] = 0.0 + elif options[dimension] > 1.0: + options[dimension] = 1.0 + + for angle in [ + "Dorsal-ventral rotation degrees", + "Ventral-medial rotation degrees" + ]: + if options[angle] < -90.0: + options[angle] = -90.0 + elif options[angle] > 90.0: + options[angle] = 90.0 + + if options['Refine number of elements'] < 1: + options['Refine number of elements'] = 1 + + return dependentChanges + + @classmethod + def generateBaseMesh(cls, region, options): + """ + Generate the base tricubic Hermite mesh. See also generateMesh(). + :param region: Zinc region to define model in. Must be empty. + :param options: Dict containing options. See getDefaultOptions(). + :return: list of AnnotationGroup, None + """ + isLeftLung = options["Left lung"] + isRightLung = options["Right lung"] + # numberOfLeftLung = options["Number of left lung lobes"] + numberOfLeftLung = 2 # This option is hidden until rodent lung scaffold is added. + + elementsCountLateral = options["Number of elements lateral"] + elementsCountNormal = options["Number of elements normal"] + elementsCountOblique = options["Number of elements oblique"] + elementsCountTransition = options["Number of transition elements"] + lungSpacing = options["Left-right lung spacing"] * 0.5 + baseSharpFactor = options["Base lateral edge sharpness factor"] + edgeSharpFactor = options["Ventral edge sharpness factor"] + ellipsoid_height = options["Ellipsoid height"] + ellipsoid_breadth = options["Ellipsoid dorsal-ventral size"] + ellipsoid_depth = options["Ellipsoid medial-lateral size"] + left_oblique_slope_radians = math.radians(options["Left oblique slope degrees"]) + right_oblique_slope_radians = math.radians(options["Right oblique slope degrees"]) + leftLungMedialCurvature = options["Medial curvature"] + lungMedialCurvatureBias = options["Medial curvature bias"] + rotateLeftLungY = options["Dorsal-ventral rotation degrees"] + rotateLeftLungZ = options["Ventral-medial rotation degrees"] + + fieldmodule = region.getFieldmodule() + coordinates = find_or_create_field_coordinates(fieldmodule) + nodes = fieldmodule.findNodesetByFieldDomainType(Field.DOMAIN_TYPE_NODES) + + # annotation groups & nodeset groups + lungGroup = AnnotationGroup(region, get_lung_term("lung")) + + leftLungGroup = AnnotationGroup(region, get_lung_term("left lung")) + rightLungGroup = AnnotationGroup(region, get_lung_term("right lung")) + + leftLateralLungGroup = AnnotationGroup(region, ["lateral left lung", ""]) + rightLateralLungGroup = AnnotationGroup(region, ["lateral right lung", ""]) + + leftMedialLungGroup = AnnotationGroup(region, ["medial left lung", ""]) + rightMedialLungGroup = AnnotationGroup(region, ["medial right lung", ""]) + + leftPosteriorLungGroup = AnnotationGroup(region, ("posterior left lung", "")) + rightPosteriorLungGroup = AnnotationGroup(region, ("posterior right lung", "")) + + lowerRightLungGroup = AnnotationGroup(region, get_lung_term("lower lobe of right lung")) + upperRightLungGroup = AnnotationGroup(region, get_lung_term("upper lobe of right lung")) + middleRightLungGroup = AnnotationGroup(region, get_lung_term("middle lobe of right lung")) + + leftBaseLungGroup = AnnotationGroup(region, ["base left lung", ""]) + rightBaseLungGroup = AnnotationGroup(region, ["base right lung", ""]) + + annotationGroups = [lungGroup, leftLungGroup, rightLungGroup, + leftLateralLungGroup, leftMedialLungGroup, leftBaseLungGroup, leftPosteriorLungGroup, + rightLateralLungGroup, rightMedialLungGroup, rightBaseLungGroup, rightPosteriorLungGroup, + lowerRightLungGroup, middleRightLungGroup, upperRightLungGroup] + + if numberOfLeftLung == 2: + lowerLeftLungGroup = AnnotationGroup(region, get_lung_term("lower lobe of left lung")) + upperLeftLungGroup = AnnotationGroup(region, get_lung_term("upper lobe of left lung")) + + annotationGroups += [lowerLeftLungGroup, upperLeftLungGroup] + + # Nodeset group + leftLungNodesetGroup = leftLungGroup.getNodesetGroup(nodes) + rightLungNodesetGroup = rightLungGroup.getNodesetGroup(nodes) + + elementCounts = [elementsCountLateral, elementsCountOblique, elementsCountNormal] + halfDepth = ellipsoid_depth * 0.5 + halfBreadth = ellipsoid_breadth * 0.5 + halfHeight = ellipsoid_height * 0.5 + surface_only = False + + leftLung, rightLung = 0, 1 + lungs = [lung for show, lung in [(isLeftLung, leftLung), (isRightLung, rightLung)] if show] + nodeIdentifier, elementIdentifier = 1, 1 + for lung in lungs: + oblique_slope_radians = left_oblique_slope_radians if lung == leftLung else right_oblique_slope_radians + axis2_x_rotation_radians = -oblique_slope_radians + axis3_x_rotation_radians = math.radians(90) - oblique_slope_radians + + + + + + ellipsoid = EllipsoidMesh(halfDepth, halfBreadth, halfHeight, elementCounts, elementsCountTransition, + axis2_x_rotation_radians, axis3_x_rotation_radians, surface_only) + + if lung == leftLung: + octant_group_lists = [] + for octant in range(8): + octant_group_list = [] + octant_group_list.append(lungGroup.getGroup()) + octant_group_list.append(leftLungGroup.getGroup()) + octant_group_list.append((leftMedialLungGroup if (octant & 1) else leftLateralLungGroup).getGroup()) + octant_group_list.append((leftBaseLungGroup if (octant & 2) else leftPosteriorLungGroup).getGroup()) + if numberOfLeftLung > 1: + octant_group_list.append((upperLeftLungGroup if (octant & 4) else lowerLeftLungGroup).getGroup()) + octant_group_lists.append(octant_group_list) + else: + octant_group_lists = [] + for octant in range(8): + octant_group_list = [] + octant_group_list.append(lungGroup.getGroup()) + octant_group_list.append(rightLungGroup.getGroup()) + octant_group_list.append((rightLateralLungGroup if (octant & 1) else rightMedialLungGroup).getGroup()) + octant_group_list.append((rightBaseLungGroup if (octant & 2) else rightPosteriorLungGroup).getGroup()) + if octant & 4: + octant_group_list.append((middleRightLungGroup if (octant & 2) else upperRightLungGroup).getGroup()) + else: + octant_group_list.append(lowerRightLungGroup.getGroup()) + octant_group_lists.append(octant_group_list) + + ellipsoid.set_octant_group_lists(octant_group_lists) + + ellipsoid.build() + nodeIdentifier, elementIdentifier = ellipsoid.generate_mesh(fieldmodule, coordinates, nodeIdentifier, elementIdentifier) + + for lung in lungs: + isLeft = True if lung == leftLung else False + lungNodeset = leftLungNodesetGroup if isLeft else rightLungNodesetGroup + spacing = -lungSpacing if lung == leftLung else lungSpacing + zOffset = -0.5 * ellipsoid_height + lungMedialCurvature = -leftLungMedialCurvature if isLeft else leftLungMedialCurvature + rotateLungAngleY = rotateLeftLungY if isLeft else -rotateLeftLungY + rotateLungAngleZ = rotateLeftLungZ if isLeft else -rotateLeftLungZ + + if edgeSharpFactor != 0.0: + taperLungEdge(edgeSharpFactor, fieldmodule, coordinates, lungNodeset, halfBreadth) + + if baseSharpFactor != 0.0: + taperLungEdge(baseSharpFactor, fieldmodule, coordinates, lungNodeset, halfHeight, isBase=True) + + dorsalVentralXi = getDorsalVentralXiField(fieldmodule, coordinates, halfBreadth) + if lungMedialCurvature != 0: + bendLungMeshAroundZAxis(lungMedialCurvature, fieldmodule, coordinates, lungNodeset, + stationaryPointXY=[0.0, 0.0], + bias=lungMedialCurvatureBias, + dorsalVentralXi=dorsalVentralXi) + + if rotateLungAngleY != 0.0: + rotateLungMeshAboutAxis(rotateLungAngleY, fieldmodule, coordinates, lungNodeset, axis=2) + + if rotateLungAngleZ != 0.0: + rotateLungMeshAboutAxis(rotateLungAngleZ, fieldmodule, coordinates, lungNodeset, axis=3) + + translate_nodeset_coordinates(lungNodeset, coordinates, [spacing, 0, -zOffset]) + + return annotationGroups, None + + + @classmethod + def refineMesh(cls, meshRefinement, options): + """ + Refine source mesh into separate region, with change of basis. + :param meshRefinement: MeshRefinement, which knows source and target region. + :param options: Dict containing options. See getDefaultOptions(). + """ + assert isinstance(meshRefinement, MeshRefinement) + refineElementsCount = options['Refine number of elements'] + meshRefinement.refineAllElementsCubeStandard3d(refineElementsCount, refineElementsCount, refineElementsCount) + + @classmethod + def defineFaceAnnotations(cls, region, options, annotationGroups): + """ + Add face annotation groups from the highest dimension mesh. + Must have defined faces and added subelements for highest dimension groups. + :param region: Zinc region containing model. + :param options: Dict containing options. See getDefaultOptions(). + :param annotationGroups: List of annotation groups for top-level elements. + New face annotation groups are appended to this list. + """ + return + # numberOfLeftLung = options['Number of left lung lobes'] + numberOfLeftLung = 2 + + fm = region.getFieldmodule() + mesh1d = fm.findMeshByDimension(1) + mesh2d = fm.findMeshByDimension(2) + + # 1D Annotation + is_exterior = fm.createFieldIsExterior() + + # Arbitrary terms - are removed from the annotation groups later + arbLobe_group = {} + arbLobe_exterior = {} + arbLobe_2dgroup = {} + arbTerms = ["upper lobe of left lung", "upper lobe of right lung"] + for arbTerm in arbTerms: + group = findOrCreateAnnotationGroupForTerm(annotationGroups, region, get_lung_term(arbTerm)) + group2d = group.getGroup() + group2d_exterior = fm.createFieldAnd(group2d, is_exterior) + arbLobe_group.update({arbTerm: group}) + arbLobe_2dgroup.update({arbTerm: group2d}) + arbLobe_exterior.update({arbTerm: group2d_exterior}) + + side_group = {} + side_exterior = {} + arbSideTerms = ["lateral left lung", "lateral right lung", + "medial left lung", "medial right lung", + "base left lung", "base right lung"] + for term in arbSideTerms: + group = findOrCreateAnnotationGroupForTerm(annotationGroups, region, [term, ""]) + group2d = group.getGroup() + group2d_exterior = fm.createFieldAnd(group2d, is_exterior) + side_group[term] = group + side_exterior[term] = group2d_exterior + + base_posterior_group = {} + base_posterior_group_exterior = {} + base_posterior_group_terms = ["base left lung", "base right lung", + "posterior left lung", "posterior right lung"] + for term in base_posterior_group_terms: + group = findOrCreateAnnotationGroupForTerm(annotationGroups, region, [term, ""]) + group2d = group.getGroup() + group2d_exterior = fm.createFieldAnd(group2d, is_exterior) + base_posterior_group[term] = group + base_posterior_group_exterior[term] = group2d_exterior + + # Exterior surfaces of lungs + surfaceTerms = [ + "left lung", + "lower lobe of left lung", + "upper lobe of left lung", + "right lung", + "lower lobe of right lung", + "middle lobe of right lung", + "upper lobe of right lung" + ] + subLeftLungTerms = ["lower lobe of left lung", "upper lobe of left lung"] + + lobe = {} + lobe_exterior = {} + for term in surfaceTerms: + if (numberOfLeftLung == 1) and (term in subLeftLungTerms): + continue + + group = getAnnotationGroupForTerm(annotationGroups, get_lung_term(term)) + group2d = group.getGroup() + group2d_exterior = fm.createFieldAnd(group2d, is_exterior) + + surfaceGroup = findOrCreateAnnotationGroupForTerm(annotationGroups, region, get_lung_term(term + " surface")) + surfaceGroup.getMeshGroup(mesh2d).addElementsConditional(group2d_exterior) + + lobe_exterior.update({term + " surface": group2d_exterior}) + + if "lobe of" in term: + lobe.update({term: group2d}) + + # lateral in the subgroup + for sideTerm in ['lateral surface of ', 'medial surface of ']: + surfaceGroup = findOrCreateAnnotationGroupForTerm(annotationGroups, region, get_lung_term(sideTerm + term)) + if ('lateral' in sideTerm) and ('left' in term): + surfaceGroup.getMeshGroup(mesh2d).addElementsConditional( + fm.createFieldAnd(group2d_exterior, side_exterior["lateral left lung"])) + elif ('lateral' in sideTerm) and ('right' in term): + surfaceGroup.getMeshGroup(mesh2d).addElementsConditional( + fm.createFieldAnd(group2d_exterior, side_exterior["lateral right lung"])) + elif ('medial' in sideTerm) and ('left' in term): + surfaceGroup.getMeshGroup(mesh2d).addElementsConditional( + fm.createFieldAnd(group2d_exterior, side_exterior["medial left lung"])) + elif ('medial' in sideTerm) and ('right' in term): + surfaceGroup.getMeshGroup(mesh2d).addElementsConditional( + fm.createFieldAnd(group2d_exterior, side_exterior["medial right lung"])) + + # Base surface of lungs (incl. lobes) + baseGroup = [] + baseTerms = [ + 'left lung surface', + 'lower lobe of right lung surface', + 'middle lobe of right lung surface', + 'right lung surface' + ] + + # Base of left lung + if numberOfLeftLung > 1: + baseTerms = ['lower lobe of left lung surface', 'upper lobe of left lung surface'] + baseTerms + + tempGroup = fm.createFieldAnd(lobe_exterior[baseTerms[0]], side_exterior["base left lung"]) + baseLeftLowerLung = fm.createFieldAnd(tempGroup, side_exterior["medial left lung"]) + baseGroup.append(baseLeftLowerLung) + + tempGroup = fm.createFieldAnd(lobe_exterior[baseTerms[1]], side_exterior["base left lung"]) + baseLeftUpperLung = fm.createFieldAnd(tempGroup, side_exterior["medial left lung"]) + baseGroup.append(baseLeftUpperLung) + + baseLeftLung = fm.createFieldOr(baseLeftLowerLung, baseLeftUpperLung) + baseGroup.append(baseLeftLung) + else: + baseLeftLung = side_exterior["base left lung"] + baseGroup.append(baseLeftLung) + + # Base of right lung + tempGroup = fm.createFieldAnd(lobe_exterior['lower lobe of right lung surface'], side_exterior["base right lung"]) + baseRightLowerLung = fm.createFieldAnd(tempGroup, side_exterior["medial right lung"]) + baseGroup.append(baseRightLowerLung) + + tempGroup = fm.createFieldAnd(lobe_exterior['middle lobe of right lung surface'], side_exterior["base right lung"]) + baseRightMiddleLung = fm.createFieldAnd(tempGroup, side_exterior["medial right lung"]) + baseGroup.append(baseRightMiddleLung) + + baseRightLung = fm.createFieldOr(baseRightLowerLung, baseRightMiddleLung) + baseGroup.append(baseRightLung) + + for term in baseTerms: + baseSurfaceGroup = findOrCreateAnnotationGroupForTerm(annotationGroups, region, get_lung_term("base of " + term)) + baseSurfaceGroup.getMeshGroup(mesh2d).addElementsConditional(baseGroup[baseTerms.index(term)]) + + # Fissures + fissureTerms = ["oblique fissure of right lung", "horizontal fissure of right lung"] + if numberOfLeftLung > 1: + fissureTerms.append("oblique fissure of left lung") + lobeFissureTerms = [ + "oblique fissure of lower lobe of left lung", + "oblique fissure of upper lobe of left lung", + "oblique fissure of lower lobe of right lung", + "oblique fissure of middle lobe of right lung", + "oblique fissure of upper lobe of right lung", + "horizontal fissure of middle lobe of right lung", + "horizontal fissure of upper lobe of right lung" + ] + for fissureTerm in fissureTerms: + if (fissureTerm == "oblique fissure of left lung") and (numberOfLeftLung > 1): + fissureGroup = fm.createFieldAnd(lobe["upper lobe of left lung"], lobe["lower lobe of left lung"]) + elif fissureTerm == "oblique fissure of right lung": + fissureGroup = fm.createFieldAnd( + fm.createFieldOr(lobe["middle lobe of right lung"], lobe["upper lobe of right lung"]), + lobe["lower lobe of right lung"]) + elif fissureTerm == "horizontal fissure of right lung": + fissureGroup = fm.createFieldAnd( + lobe["upper lobe of right lung"], lobe["middle lobe of right lung"]) + + fissureSurfaceGroup = findOrCreateAnnotationGroupForTerm(annotationGroups, region, get_lung_term(fissureTerm)) + fissureSurfaceGroup.getMeshGroup(mesh2d).addElementsConditional(fissureGroup) + fissureGroup_temp = fissureGroup + + for lobeFissureTerm in lobeFissureTerms: + temp_splitTerm = fissureTerm.split("of") + if (temp_splitTerm[0] in lobeFissureTerm) and (temp_splitTerm[1] in lobeFissureTerm): + if "oblique fissure of upper lobe of right lung" in lobeFissureTerm: + fissureGroup = fm.createFieldAnd(fissureGroup_temp, + arbLobe_2dgroup['upper lobe of right lung']) + elif "oblique fissure of middle lobe of right lung" in lobeFissureTerm: + fissureGroup = fm.createFieldAnd(fissureGroup_temp, fm.createFieldNot( + arbLobe_2dgroup['upper lobe of right lung'])) + fissureSurfaceGroup = findOrCreateAnnotationGroupForTerm(annotationGroups, region, get_lung_term(lobeFissureTerm)) + fissureSurfaceGroup.getMeshGroup(mesh2d).addElementsConditional(fissureGroup) + + # add fissures to lobe surface groups + if numberOfLeftLung > 1: + obliqueFissureOfLeftLungGroup = getAnnotationGroupForTerm( + annotationGroups, get_lung_term("oblique fissure of left lung")).getGroup() + for lobeSurfaceTerm in ("lower lobe of left lung surface", "upper lobe of left lung surface"): + lobeSurfaceGroup = getAnnotationGroupForTerm( + annotationGroups, get_lung_term(lobeSurfaceTerm)) + lobeSurfaceGroup.getMeshGroup(mesh2d).addElementsConditional(obliqueFissureOfLeftLungGroup) + horizontalFissureOfRightLungGroup = getAnnotationGroupForTerm( + annotationGroups, get_lung_term("horizontal fissure of right lung")).getGroup() + obliqueFissureOfRightLungGroup = getAnnotationGroupForTerm( + annotationGroups, get_lung_term("oblique fissure of right lung")).getGroup() + obliqueFissureOfMiddleLobeOfRightLungGroup = getAnnotationGroupForTerm( + annotationGroups, get_lung_term("oblique fissure of middle lobe of right lung")).getGroup() + obliqueFissureOfUpperLobeOfRightLungGroup = getAnnotationGroupForTerm( + annotationGroups, get_lung_term("oblique fissure of upper lobe of right lung")).getGroup() + lobeSurfaceGroup = getAnnotationGroupForTerm( + annotationGroups, get_lung_term("lower lobe of right lung surface")) + lobeSurfaceGroup.getMeshGroup(mesh2d).addElementsConditional(obliqueFissureOfRightLungGroup) + lobeSurfaceGroup = getAnnotationGroupForTerm( + annotationGroups, get_lung_term("middle lobe of right lung surface")) + lobeSurfaceGroup.getMeshGroup(mesh2d).addElementsConditional( + fm.createFieldOr(obliqueFissureOfMiddleLobeOfRightLungGroup, horizontalFissureOfRightLungGroup)) + lobeSurfaceGroup = getAnnotationGroupForTerm( + annotationGroups, get_lung_term("upper lobe of right lung surface")) + lobeSurfaceGroup.getMeshGroup(mesh2d).addElementsConditional( + fm.createFieldOr(obliqueFissureOfUpperLobeOfRightLungGroup, horizontalFissureOfRightLungGroup)) + + # 1D edges + edgeTerms = [ + "anterior edge", + "antero-posterior edge", + "base edge", + "lateral edge", + "medial edge", + "posterior edge" + ] + + fissureTerms = [ + "horizontal fissure", + "oblique fissure", + ] + + lobeTerms = [ + "lower lobe", + "middle lobe", + "upper lobe" + ] + + # Define mappings + edge_lobe_map = { + "anterior edge": ["middle"], + "antero-posterior edge": ["upper"], + "posterior edge": ["lower"] + } + surface_edge_terms = ["lateral edge", "medial edge", "base edge"] + + for lung in ["left lung", "right lung"]: + surfaces = {} + for surface_type in ["lateral", "medial", "base"]: + term = f"{surface_type} of {lung} surface" if surface_type == "base" else f"{surface_type} surface of {lung}" + group = findOrCreateAnnotationGroupForTerm(annotationGroups, region, get_lung_term(term)) + surfaces[surface_type] = group.getGroup() + + for edgeTerm in edgeTerms: + if edgeTerm in surface_edge_terms: + surface_type = edgeTerm.split()[0] # Extract "lateral", "medial", or "base" + + for fissure in fissureTerms: + if "horizontal" in fissure and ("left" in lung or "base" in edgeTerm): + continue + + fissureTerm = f"{fissure} of {lung}" + fissureGroup = findOrCreateAnnotationGroupForTerm(annotationGroups, region, + get_lung_term(fissureTerm)) + is_fissureGroup = fissureGroup.getGroup() + is_fissureGroup_exterior = fm.createFieldAnd(is_fissureGroup, is_exterior) + is_surfaceFissureGroup = fm.createFieldAnd(is_fissureGroup_exterior, surfaces[surface_type]) + + edgeTermFull = f"{edgeTerm} of oblique fissure" if "base" in edgeTerm else f"{edgeTerm} of {fissureTerm}" + edgeGroup = findOrCreateAnnotationGroupForTerm(annotationGroups, region, [edgeTermFull, ""]) + edgeGroup.getMeshGroup(mesh1d).addElementsConditional(is_surfaceFissureGroup) + + elif edgeTerm in edge_lobe_map: + for lobe in lobeTerms: + if (("middle" in lobe and "left" in lung) or + not any(valid_lobe in lobe for valid_lobe in edge_lobe_map[edgeTerm])): + continue + + lobeTerm = f"{lobe} of {lung}" + lobeGroup = findOrCreateAnnotationGroupForTerm(annotationGroups, region, + get_lung_term(lobeTerm)) + is_lobeGroup = lobeGroup.getGroup() + is_lobeGroup_exterior = fm.createFieldAnd(is_lobeGroup, is_exterior) + + is_edge = fm.createFieldAnd(is_lobeGroup_exterior, + fm.createFieldAnd(surfaces["medial"], surfaces["lateral"])) + + edgeTermFull = f"{edgeTerm} of {lobe} of {lung}" + edgeGroup = findOrCreateAnnotationGroupForTerm(annotationGroups, region, [edgeTermFull, ""]) + edgeGroup.getMeshGroup(mesh1d).addElementsConditional(is_edge) + + # Process lateral edges of the base (separate from main edge loop) + for lobe in lobeTerms: + if ("middle" in lobe and "left" in lung) or ("upper" in lobe and "right" in lung): + continue + + lobeTerm = f"base of {lobe} of {lung} surface" + lobeGroup = findOrCreateAnnotationGroupForTerm(annotationGroups, region, get_lung_term(lobeTerm)) + is_lobeGroup = lobeGroup.getGroup() + is_lobeGroup_exterior = fm.createFieldAnd(is_lobeGroup, is_exterior) + + sideTerm = f"lateral {lung}" + is_edge = fm.createFieldAnd(is_lobeGroup_exterior, side_exterior[sideTerm]) + + edgeTermFull = f"lateral edge of {lobeTerm.replace(' surface', '')}" + edgeGroup = findOrCreateAnnotationGroupForTerm(annotationGroups, region, [edgeTermFull, ""]) + edgeGroup.getMeshGroup(mesh1d).addElementsConditional(is_edge) + + # Medial edge of the base + halfBreadth = options["Ellipsoid dorsal-ventral size"] * -0.5 + coordinates = find_or_create_field_coordinates(fm) + + for lung in ["left lung", "right lung"]: + is_base = base_posterior_group_exterior[f"base {lung}"] + is_posterior = base_posterior_group_exterior[f"posterior {lung}"] + is_medial = side_exterior[f"medial {lung}"] + + slope_key = "Left oblique slope degrees" if "left" in lung else "Right oblique slope degrees" + rotationAngle = math.radians(90 - options[slope_key]) + + is_horizontal_edge = fm.createFieldAnd(fm.createFieldAnd(is_base, is_posterior), is_medial) + is_threshold = setBaseGroupThreshold(fm, coordinates, halfBreadth, rotationAngle) + is_edge = fm.createFieldAnd(is_horizontal_edge, is_threshold) + + edgeTerm = f"medial edge of base of lower lobe of {lung}" + edgeGroup = findOrCreateAnnotationGroupForTerm(annotationGroups, region, [edgeTerm, ""]) + edgeGroup.getMeshGroup(mesh1d).addElementsConditional(is_edge) + + # Remove unnecessary annotations + for group in [*side_group.values()]: + annotationGroups.remove(group) + + for key, group in base_posterior_group.items(): + if "posterior" in key: + annotationGroups.remove(group) + + +def rotateLungMeshAboutAxis(rotateAngle, fm, coordinates, lungNodesetGroup, axis): + """ + Rotates the lung mesh coordinates about a specified axis using the right-hand rule. + :param rotateAngle: Angle of rotation in degrees. + :param fm: Field module being worked with. + :param coordinates: The coordinate field, initially circular in y-z plane. + :param lungNodesetGroup: Zinc NodesetGroup containing nodes to transform. + :param axis: Axis of rotation. + :return: None + """ + if axis not in (2, 3): + raise ValueError("Axis must be 2 (y), or 3 (z).") + + rotateAngle = -math.radians(rotateAngle) # negative value due to right handed rule + + if axis == 2: + rotateMatrix = fm.createFieldConstant([math.cos(rotateAngle), 0.0, -math.sin(rotateAngle), + 0.0, 1.0, 0.0, + math.sin(rotateAngle), 0.0, math.cos(rotateAngle)]) + elif axis == 3: + rotateMatrix = fm.createFieldConstant([math.cos(rotateAngle), math.sin(rotateAngle), 0.0, + -math.sin(rotateAngle), math.cos(rotateAngle), 0.0, + 0.0, 0.0, 1.0]) + + rotated_coordinates = fm.createFieldMatrixMultiply(3, rotateMatrix, coordinates) + + fieldassignment = coordinates.createFieldassignment(rotated_coordinates) + fieldassignment.setNodeset(lungNodesetGroup) + fieldassignment.assign() + + +def getDorsalVentralXiField(fm, coordinates, halfBreadth): + """ + Get a field varying from 0.0 on dorsal tip to 1.0 on ventral tip on [-axisLength, axisLength] + :param fm: Field module being worked with. + :param coordinates: The coordinate field, initially circular in y-z plane. + :param halfBreadth: Half breadth of lung. + :return: Scalar Xi field. + """ + hl = fm.createFieldConstant(halfBreadth) + fl = fm.createFieldConstant(2.0 * halfBreadth) + y = fm.createFieldComponent(coordinates, 2) + return (y + hl) / fl + + +def bendLungMeshAroundZAxis(curvature, fm, coordinates, lungNodesetGroup, stationaryPointXY, bias=0.0, + dorsalVentralXi=None): + """ + Transform coordinates by bending with curvature about a centre point the radius in + x direction from stationaryPointXY. + :param curvature: 1/radius. Must be non-zero. + :param fm: Field module being worked with. + :param coordinates: The coordinate field, initially circular in y-z plane. + :param lungNodesetGroup: Zinc NodesetGroup containing nodes to transform. + :param stationaryPointXY: Coordinates x, y which are not displaced by bending. + :param bias: 0.0 for a simple bend through the whole length, up to 1.0 for no bend at dorsal end. + :param dorsalVentralXi: Field returned by getDorsalVentralXiField if bias > 0.0: + """ + radius = 1.0 / curvature + scale = fm.createFieldConstant([-1.0, -curvature, -1.0]) + centreOffset = [stationaryPointXY[0] - radius, stationaryPointXY[1], 0.0] + centreOfCurvature = fm.createFieldConstant(centreOffset) + polarCoordinates = (centreOfCurvature - coordinates) * scale + polarCoordinates.setCoordinateSystemType(Field.COORDINATE_SYSTEM_TYPE_CYLINDRICAL_POLAR) + rcCoordinates = fm.createFieldCoordinateTransformation(polarCoordinates) + rcCoordinates.setCoordinateSystemType(Field.COORDINATE_SYSTEM_TYPE_RECTANGULAR_CARTESIAN) + newCoordinates = rcCoordinates + centreOfCurvature + + if bias > 0.0: + one = fm.createFieldConstant(1.0) + xiS = (one - dorsalVentralXi) * fm.createFieldConstant(bias) + xiC = one - xiS + newCoordinates = (coordinates * xiS) + (newCoordinates * xiC) + + fieldassignment = coordinates.createFieldassignment(newCoordinates) + fieldassignment.setNodeset(lungNodesetGroup) + fieldassignment.assign() + + +def taperLungEdge(sharpeningFactor, fm, coordinates, lungNodesetGroup, halfValue, isBase=False): + """ + Applies a tapering transformation to the lung geometry to sharpen the anterior edge or the base. + If isBase is False, it sharpens the anterior edge (along the y-axis). + If isBase is True, it sharpens the base (along the z-axis), but only for nodes below a certain height. + :param sharpeningFactor: A value between 0 and 1, where 1 represents the maximum sharpness. + :param fm: Field module being worked with. + :param coordinates: The coordinate field. The anterior edge is towards the +y-axis, and the base is towards the + -z-axis. + :param lungNodesetGroup: Zinc NodesetGroup containing nodes to transform. + :param halfValue: Half value of lung breadth/height depending on isBase. + :param isBase: False if transforming the anterior edge, True if transforming the base of the lung. + """ + x = fm.createFieldComponent(coordinates, 1) + y = fm.createFieldComponent(coordinates, 2) + z = fm.createFieldComponent(coordinates, 3) + + coord_value = z if isBase else y + start_value = 0.5 * halfValue if isBase else -0.5 * halfValue + end_value = -1.1 * halfValue if isBase else 1.1 * halfValue + + start_value_field = fm.createFieldConstant(start_value) + end_value_field = fm.createFieldConstant(end_value) + + xi = (coord_value - start_value_field) / fm.createFieldConstant(end_value - start_value) + xi__2 = xi * xi + one = fm.createFieldConstant(1.0) + x_scale = one - fm.createFieldConstant(sharpeningFactor) * xi__2 + if isBase: + new_x = fm.createFieldIf(fm.createFieldLessThan(coord_value, start_value_field), x * x_scale, x) + else: + new_x = fm.createFieldIf(fm.createFieldGreaterThan(coord_value, start_value_field), x * x_scale, x) + new_coordinates = fm.createFieldConcatenate([new_x, y, z]) + + fieldassignment = coordinates.createFieldassignment(new_coordinates) + fieldassignment.setNodeset(lungNodesetGroup) + fieldassignment.assign() + + +def setBaseGroupThreshold(fm, coordinates, halfBreadth, rotateAngle): + """ + Creates a field to identify lung base elements based on y-coordinate threshold. + Elements with y-coordinates below 45% of the rotated half-breadth are considered part of the lung base region for + annotation purposes. + :param fm: Field module used for creating and managing fields. + :param coordinates: The coordinate field. + :param halfBreadth: Half breadth of lung. + :param rotateAngle: The angle of rotation of horizontal line in radians (90 - oblique fissure angle). + :return is_above_threshold: True for elements below the y-threshold (base region). + """ + y_component = fm.createFieldComponent(coordinates, [2]) + y_threshold = 0.45 * halfBreadth * math.cos(rotateAngle) + + y_threshold_field = fm.createFieldConstant(y_threshold) + is_above_threshold = fm.createFieldLessThan(y_component, y_threshold_field) + + return is_above_threshold diff --git a/src/scaffoldmaker/utils/eft_utils.py b/src/scaffoldmaker/utils/eft_utils.py index 2594eaac..a23408b5 100644 --- a/src/scaffoldmaker/utils/eft_utils.py +++ b/src/scaffoldmaker/utils/eft_utils.py @@ -701,20 +701,20 @@ def __init__(self): HermiteNodeLayout([[1.0, 0.0, 0.0], [0.0, -1.0, 0.0], [-1.0, 1.0, 0.0], [0.0, 0.0, -1.0], [0.0, 0.0, 1.0]]), HermiteNodeLayout([[-1.0, 0.0, 0.0], [0.0, -1.0, 0.0], [1.0, 1.0, 0.0], [0.0, 0.0, -1.0], [0.0, 0.0, 1.0]])] self._nodeLayout3WayPoints13 = [ - HermiteNodeLayout([[-1.0, 0.0, 0.0], [0.0, 0.0, -1.0], [1.0, 0.0, 1.0], [0.0, -1.0, 0.0], [0.0, 1.0, 0.0]]), - HermiteNodeLayout([[1.0, 0.0, 0.0], [0.0, 0.0, -1.0], [-1.0, 0.0, 1.0], [0.0, -1.0, 0.0], [0.0, 1.0, 0.0]]), + HermiteNodeLayout([[1.0, 0.0, 0.0], [0.0, 0.0, 1.0], [-1.0, 0.0, -1.0], [0.0, -1.0, 0.0], [0.0, 1.0, 0.0]]), + HermiteNodeLayout([[-1.0, 0.0, 0.0], [0.0, 0.0, 1.0], [1.0, 0.0, -1.0], [0.0, -1.0, 0.0], [0.0, 1.0, 0.0]]), HermiteNodeLayout([[1.0, 0.0, 0.0], [0.0, 0.0, -1.0], [-1.0, 0.0, 1.0], [0.0, -1.0, 0.0], [0.0, 1.0, 0.0]]), HermiteNodeLayout([[-1.0, 0.0, 0.0], [0.0, 0.0, -1.0], [1.0, 0.0, 1.0], [0.0, -1.0, 0.0], [0.0, 1.0, 0.0]])] self._nodeLayout3WayPoints23 = [ - HermiteNodeLayout([[0.0, 1.0, 0.0], [0.0, 0.0, -1.0], [0.0, -1.0, 1.0], [-1.0, 0.0, 0.0], [1.0, 0.0, 0.0]]), - HermiteNodeLayout([[0.0, -1.0, 0.0], [0.0, 0.0, -1.0], [0.0, 1.0, 1.0], [-1.0, 0.0, 0.0], [1.0, 0.0, 0.0]]), + HermiteNodeLayout([[0.0, 1.0, 0.0], [0.0, 0.0, 1.0], [0.0, -1.0, -1.0], [-1.0, 0.0, 0.0], [1.0, 0.0, 0.0]]), + HermiteNodeLayout([[0.0, -1.0, 0.0], [0.0, 0.0, 1.0], [0.0, 1.0, -1.0], [-1.0, 0.0, 0.0], [1.0, 0.0, 0.0]]), HermiteNodeLayout([[0.0, 1.0, 0.0], [0.0, 0.0, -1.0], [0.0, -1.0, 1.0], [-1.0, 0.0, 0.0], [1.0, 0.0, 0.0]]), HermiteNodeLayout([[0.0, -1.0, 0.0], [0.0, 0.0, -1.0], [0.0, 1.0, 1.0], [-1.0, 0.0, 0.0], [1.0, 0.0, 0.0]])] self._nodeLayout4WayPoints = [ - HermiteNodeLayout([[-1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, -1.0], [1.0, -1.0, 1.0]]), - HermiteNodeLayout([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, -1.0], [-1.0, -1.0, 1.0]]), - HermiteNodeLayout([[-1.0, 0.0, 0.0], [0.0, -1.0, 0.0], [0.0, 0.0, -1.0], [1.0, 1.0, 1.0]]), - HermiteNodeLayout([[1.0, 0.0, 0.0], [0.0, -1.0, 0.0], [0.0, 0.0, -1.0], [-1.0, 1.0, 1.0]]), + HermiteNodeLayout([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0], [-1.0, -1.0, -1.0]]), + HermiteNodeLayout([[-1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0], [1.0, -1.0, -1.0]]), + HermiteNodeLayout([[1.0, 0.0, 0.0], [0.0, -1.0, 0.0], [0.0, 0.0, 1.0], [-1.0, 1.0, -1.0]]), + HermiteNodeLayout([[-1.0, 0.0, 0.0], [0.0, -1.0, 0.0], [0.0, 0.0, 1.0], [1.0, 1.0, -1.0]]), HermiteNodeLayout([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, -1.0], [-1.0, -1.0, 1.0]]), HermiteNodeLayout([[-1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, -1.0], [1.0, -1.0, 1.0]]), HermiteNodeLayout([[1.0, 0.0, 0.0], [0.0, -1.0, 0.0], [0.0, 0.0, -1.0], [-1.0, 1.0, 1.0]]), diff --git a/src/scaffoldmaker/utils/ellipsoidmesh.py b/src/scaffoldmaker/utils/ellipsoidmesh.py index d97e42be..8abb3e2a 100644 --- a/src/scaffoldmaker/utils/ellipsoidmesh.py +++ b/src/scaffoldmaker/utils/ellipsoidmesh.py @@ -135,149 +135,24 @@ def set_surface_d3_mode(self, surface_d3_mode: EllipsoidSurfaceD3Mode): def build(self): """ - Determine coordinates and derivatives over and within ellipsoid. + Determine coordinates and derivatives over and within the full ellipsoid. """ half_counts = [count // 2 for count in self._element_counts] - box_counts = [half_counts[i] - self._trans_count for i in range(3)] - - octant1 = self._build_ellipsoid_octant(half_counts, self._axis2_x_rotation_radians, self._axis3_x_rotation_radians) - - # copy octant1 into ellipsoid - octant_parameters = octant1.get_parameters() - for n3 in range(half_counts[2] + 1): - octant_nx_layer = octant_parameters[n3] - nx_layer = self._nx[half_counts[2] + n3] - for n2 in range(half_counts[1] + 1): - octant_nx_row = octant_nx_layer[n2] - nx_row = nx_layer[half_counts[1] + n2] - for n1 in range(half_counts[0] + 1): - nx_row[half_counts[0] + n1] = copy.deepcopy(octant_nx_row[n1]) - - octant2 = self._build_ellipsoid_octant([half_counts[0], half_counts[2], half_counts[1]], - self._axis3_x_rotation_radians - math.pi, self._axis2_x_rotation_radians) - - # transfer parameters on bottom plane of octant1 to target location of octant2 for blending - n3 = half_counts[2] - for i2 in range(1, half_counts[1] + 1): - n2 = half_counts[1] + i2 - for i1 in range(half_counts[0] + 1): - n1 = half_counts[0] + i1 - box = (i1 <= box_counts[0]) and (i2 <= box_counts[1]) - parameters = self._nx[n3][n2][n1] - if not parameters or not parameters[0]: - continue - x, d1, d2, d3 = parameters - x = [x[0], -x[1], -x[2]] - d1 = [d1[0], -d1[1], -d1[2]] if box else [-d1[0], d1[1], d1[2]] - d2 = [-d2[0], d2[1], d2[2]] - d3 = ([-d3[0], d3[1], d3[2]] if box else [d3[0], -d3[1], -d3[2]]) if d3 else None - self._nx[n3][self._element_counts[1] - n2][n1] = [x, d1, d2, d3] - - # copy and mirror in y and z octant2 into ellipsoid, blending existing derivatives - octant_parameters = octant2.get_parameters() - for o3 in range(half_counts[1] + 1): - octant_nx_layer = octant_parameters[o3] - for o2 in range(half_counts[2] + 1): - octant_nx_row = octant_nx_layer[o2] - nx_row = self._nx[half_counts[2] + o2][half_counts[1] - o3] - box_row = (o2 <= box_counts[2]) and (o3 <= box_counts[1]) - top_transition = o2 > box_counts[2] - for o1 in range(half_counts[0] + 1): - octant_nx = octant_nx_row[o1] - if octant_nx and octant_nx[0]: - box = box_row and (o1 <= box_counts[0]) - x, d1, d2, d3 = octant_nx - x = [x[0], -x[1], -x[2]] - if top_transition: - if o3 > box_counts[1]: - d1 = [d1[0], -d1[1], -d1[2]] - d2 = [d2[0], -d2[1], -d2[2]] - # fix 3-way point case on transition 'corner': - if (o3 >= box_counts[1]) and (o1 >= box_counts[0]): - d2 = add(d1, d2) - else: - d1 = [-d1[0], d1[1], d1[2]] - d2 = [-d2[0], d2[1], d2[2]] - d3 = [d3[0], -d3[1], -d3[2]] if d3 else None - elif box: - d1 = [d1[0], -d1[1], -d1[2]] - d2, d3 = [-d3[0], d3[1], d3[2]], [d2[0], -d2[1], -d2[2]] - else: - if o3 > box_counts[1]: - d1 = [d1[0], -d1[1], -d1[2]] - d2 = [d2[0], -d2[1], -d2[2]] - else: - d1, d2 = [-d2[0], d2[1], d2[2]], [d1[0], -d1[1], -d1[2]] - d3 = [d3[0], -d3[1], -d3[2]] if d3 else None - new_nx = [x, d1, d2, d3] - nx = nx_row[half_counts[0] + o1] - # expect coordinates to be at the same location on boundaries - nx[0] = copy.copy(x) - for pix in range(1, 4): - d = new_nx[pix] - if nx[pix] and d: - # blend derivatives with harmonic mean magnitude; should already be in same direction - d = linearlyInterpolateVectors( - nx[pix], d, 0.5, magnitudeScalingMode=DerivativeScalingMode.HARMONIC_MEAN) - nx[pix] = copy.copy(d) - # transfer blended d2 derivatives on bottom plane of octant2 back to octant1 - n3 = half_counts[2] - for i2 in range(1, half_counts[1] + 1): - n2 = half_counts[1] + i2 - for i1 in range(half_counts[0] + 1): - n1 = half_counts[0] + i1 - box = (i1 <= box_counts[0]) and (i2 <= box_counts[1]) - parameters = self._nx[n3][self._element_counts[1] - n2][n1] - if not parameters or not parameters[0]: - continue - x, d1, d2, d3 = parameters - x = [x[0], -x[1], -x[2]] - d1 = [d1[0], -d1[1], -d1[2]] if box else [-d1[0], d1[1], d1[2]] - d2 = [-d2[0], d2[1], d2[2]] - d3 = ([-d3[0], d3[1], d3[2]] if box else [d3[0], -d3[1], -d3[2]]) if d3 else None - self._nx[n3][n2][n1] = [x, d1, d2, d3] + octant1 = self.build_octant(half_counts, self._axis2_x_rotation_radians, self._axis3_x_rotation_radians) + self.merge_octant(octant1, quadrant=0) + octant1.mirror_yz() + self.merge_octant(octant1, quadrant=2) - # mirror octants over x = 0 - for n3 in range(half_counts[2], self._element_counts[2] + 1): - for n2 in range(0, self._element_counts[1] + 1): - for n1 in range(half_counts[0] + 1, self._element_counts[0] + 1): - parameters = self._nx[n3][n2][n1] - if not parameters or not parameters[0]: - continue - x, d1, d2, d3 = parameters - x = [-x[0], x[1], x[2]] - d1 = [d1[0], -d1[1], -d1[2]] if d1 else None - d2 = [-d2[0], d2[1], d2[2]] if d2 else None - d3 = [-d3[0], d3[1], d3[2]] if d3 else None - self._nx[n3][n2][self._element_counts[0] - n1] = [x, d1, d2, d3] + octant2 = self.build_octant([half_counts[0], half_counts[2], half_counts[1]], + self._axis3_x_rotation_radians, self._axis2_x_rotation_radians + math.pi) + self.merge_octant(octant2, quadrant=1) + octant2.mirror_yz() + self.merge_octant(octant2, quadrant=3) - # flip top half about both y = 0 and z = 0 - for n3 in range(half_counts[2] + 1, self._element_counts[2] + 1): - for n2 in range(0, self._element_counts[1] + 1): - top_nx_row = self._nx[n3][n2] - box2 = (self._trans_count <= n2 <= - (self._element_counts[1] - self._trans_count)) - box_row = (n3 <= (half_counts[2] + box_counts[2])) and box2 - bottom_nx_row = self._nx[self._element_counts[2] - n3][self._element_counts[1] - n2] - bottom_transition = box2 and (n3 >= (half_counts[2] + box_counts[2])) - for n1 in range(self._element_counts[0] + 1): - parameters = top_nx_row[n1] - if parameters and parameters[0]: - box = box_row and (self._trans_count <= n1 <= - (self._element_counts[0] - self._trans_count)) - x, d1, d2, d3 = parameters - x = [x[0], -x[1], -x[2]] - d2 = [-d2[0], d2[1], d2[2]] if d2 else None - if box and not bottom_transition: - d1 = [d1[0], -d1[1], -d1[2]] - d3 = [-d3[0], d3[1], d3[2]] - else: - d1 = [-d1[0], d1[1], d1[2]] if d1 else None - d3 = [d3[0], -d3[1], -d3[2]] if d3 else None - bottom_nx_row[n1] = [x, d1, d2, d3] + self.copy_to_negative_axis1() - def _build_ellipsoid_octant(self, half_counts, axis2_x_rotation_radians, axis3_x_rotation_radians): + def build_octant(self, half_counts, axis2_x_rotation_radians, axis3_x_rotation_radians): """ Get coordinates of top, right, front octant with supplied angles. :param half_counts: Numbers of elements across octant 1, 2 and 3 directions. @@ -434,158 +309,114 @@ def _build_ellipsoid_octant(self, half_counts, axis2_x_rotation_radians, axis3_x return octant - def _next_increment_out_of_bounds(self, indexes, index_increment): - for c in range(3): - index = indexes[c] + index_increment[c] - if (index < 0) or (index > self._element_counts[c]): - return True - return False - - def _set_coordinates_around_rim(self, parameters, parameter_indexes, start_indexes, index_increments, - skip_start=False, skip_end=False, - blend_start=False, blend_middle=False, blend_end=False): + def merge_octant(self, octant: HexTetrahedronMesh, quadrant: int): """ - Insert parameters around the rim into the coordinates array. - :param parameters: List of lists of N node parameters e.g. [px, pd1, pd2] - :param parameter_indexes: Lists of parameter indexes where x=0, d1=1, d2=2, d3=3. Starts with first and - advances after each corner, then cycles back to first. Can be negative to invert vector. - e.g. [[0, 1, 2], [0, -2, 1]] for [x, d1, d2] then [x, -d2, d1] after first corner. - :param start_indexes: Index of first point. - :param index_increments: List of increments in indexes. Starts with first and after at each corner, then - cycles back to first. - :param skip_start: Set to True to skip the first value. - :param skip_end: Set to True to skip the last value. - :param blend_start: Set to True to blend parameters with any old parameters at start location. - :param blend_middle: Set to True to blend parameters with any old parameters at middle locations. - :param blend_end: Set to True to blend parameters with any old parameters at end location. + Merge octant parameters into ellipsoid in one of the 4 quadrants on +axis1. + :param octant: HexTetrahedronMesh + :param quadrant: 0 for +axis2, +axis3 increasing anticlockwise around +axis1 up to 3. """ - indexes = start_indexes - parameter_number = 0 - parameter_index = parameter_indexes[0] - increment_number = 0 - index_increment = index_increments[0] - start_n = 1 if skip_start else 0 - last_n = len(parameters[0]) - 1 - limit_n = len(parameters[0]) - (1 if skip_end else 0) - for n in range(start_n, limit_n): - if n > 0: - while True: - indexes = [indexes[c] + index_increment[c] for c in range(3)] - # skip over blank transition coordinates - if self._nx[indexes[2]][indexes[1]][indexes[0]]: - break - if self._next_increment_out_of_bounds(indexes, index_increment): - parameter_number += 1 - if parameter_number == len(parameter_indexes): - parameter_number = 0 - parameter_index = parameter_indexes[parameter_number] - increment_number += 1 - if increment_number == len(index_increments): - increment_number = 0 - index_increment = index_increments[increment_number] - nx = self._nx[indexes[2]][indexes[1]][indexes[0]] - for parameter, spix in zip(parameters, parameter_index): - new_parameter = [-d for d in parameter[n]] if (spix < 0) else copy.copy(parameter[n]) - pix = abs(spix) - if nx[pix] and ((blend_start and (n == 0)) or (blend_middle and (0 < n < last_n)) or - (blend_end and (n == last_n))): - if pix == 0: - # for fairness, move to surface before blending - new_parameter = moveCoordinatesToEllipsoidSurface(self._a, self._b, self._c, new_parameter) - new_parameter = [0.5 * (nx[pix][c] + new_parameter[c]) for c in range(3)] - else: - # harmonic mean to cope with significant element size differences on boundary - new_parameter = linearlyInterpolateVectors( - nx[pix], new_parameter, 0.5, magnitudeScalingMode=DerivativeScalingMode.HARMONIC_MEAN) - nx[pix] = new_parameter + assert 0 <= quadrant <= 3 + half_counts = [count // 2 for count in self._element_counts] + axis_counts = octant.get_axis_counts() + even_quadrant = quadrant in (0, 2) + if even_quadrant: + assert half_counts == axis_counts + else: + assert half_counts == [axis_counts[0], axis_counts[2], axis_counts[1]] + obox_counts = octant.get_box_counts() + # box_counts = [half_counts[i] - self._trans_count for i in range(3)] + octant_parameters = octant.get_parameters() - def _smooth_derivatives_around_rim(self, start_indexes, end_indexes, index_increments, - derivative_indexes, end_derivative_index, - fix_start_direction=True, fix_end_direction=True, - blend_start=False, blend_end=False): + for o3 in range(axis_counts[2] + 1): + for o2 in range(axis_counts[1] + 1): + ox_row = octant_parameters[o3][o2] + if even_quadrant: + if quadrant == 0: + n3 = half_counts[2] + o3 + n2 = half_counts[1] + o2 + else: # quadrant == 2: + n3 = half_counts[2] - o3 + n2 = half_counts[1] - o2 + else: + if quadrant == 1: + n3 = half_counts[2] + o2 + n2 = half_counts[1] - o3 + else: # if quadrant == 3: + n3 = half_counts[2] - o2 + n2 = half_counts[1] + o3 + transition3 = (n3 < self._trans_count) or (n3 > (self._element_counts[2] - self._trans_count)) + transition2 = (n2 < self._trans_count) or (n2 > (self._element_counts[1] - self._trans_count)) + # bottom_transition = n3 < self._trans_count + nx_row = self._nx[n3][n2] + obox_row = (o3 <= obox_counts[2]) and (o2 <= obox_counts[1]) + for o1 in range(axis_counts[0] + 1): + n1 = half_counts[0] + o1 + transition1 = n1 > (self._element_counts[0] - self._trans_count) + obox = obox_row and (o1 <= obox_counts[0]) + ox = ox_row[o1] + if ox and ox[0]: + x = copy.copy(ox[0]) + perm = None + if even_quadrant: + if quadrant == 0: + perm = [1, 2, 3] + else: # quadrant == 2: + perm = [1, -2, -3] if obox else [-1, -2, 3] + else: + if obox: + perm = [1, -3, 2] + elif transition3: + if transition2: + if transition1: + # fix 3-way point + ox = [ox[0], ox[1], add(ox[1], ox[2]), ox[3]] + perm = [1, 2, 3] + else: + perm = [-1, -2, 3] + else: + if transition1 and not transition2: + perm = [-2, 1, 3] + else: + perm = [1, 2, 3] + if quadrant == 3: + perm = [perm[0], -perm[1], -perm[2]] if obox else [-perm[0], -perm[1], perm[2]] + d1, d2, d3 = [copy.copy(ox[i]) if (i > 0) else [-d for d in ox[-i]] for i in perm] + new_nx = [x, d1, d2, d3] + # merge: + nx = nx_row[n1] + for i in range(4): + d = new_nx[i] + if i and nx[i]: + # blend derivatives with harmonic mean magnitude; should already be in same direction + d = linearlyInterpolateVectors( + nx[i], d, 0.5, magnitudeScalingMode=DerivativeScalingMode.HARMONIC_MEAN) + nx[i] = d + + def copy_to_negative_axis1(self): """ - Smooth derivatives around the rim in the coordinates array. - :param start_indexes: Indexes of first point. - :param end_indexes: Indexes of last point. - :param index_increments: List of increments in indexes. Starts with first and after at each corner, then - cycles back to first. - :param derivative_indexes: List of signed derivative parameter index to along where d1=1, d2=2, d3=3. - Starts with first and advances after each corner, then cycles back to first. Can be negative to invert vector. - e.g. [1, -2] for d1 then -d2 after first corner. - :param end_derivative_index: List of signed derivative indexes to apply on the last point - e.g. [1, -2] gives d1 - d2. - :param fix_start_direction: Set to True to keep the start direction but scale its magnitude. - :param fix_end_direction: Set to True to keep the end direction but scale its magnitude. - :param blend_start: Set to True to 50:50 blend parameters with any old parameters at start location. - :param blend_end: Set to True to 50:50 blend parameters with any old parameters at end location. + Copy parameters from +axis1 to -axis1. """ - indexes = start_indexes - derivative_number = 0 - derivative_index = derivative_indexes[0] - increment_number = 0 - index_increment = index_increments[0] - indexes_list = [] - derivative_index_list = [] - px = [] - pd = [] - n = 0 - while True: - if n > 0: - if indexes == end_indexes: - break - while True: - indexes = [indexes[c] + index_increment[c] for c in range(3)] - # skip over blank transition coordinates - if self._nx[indexes[2]][indexes[1]][indexes[0]]: - break - if self._next_increment_out_of_bounds(indexes, index_increment): - derivative_number += 1 - if derivative_number == len(derivative_indexes): - derivative_number = 0 - derivative_index = derivative_indexes[derivative_number] - increment_number += 1 - if increment_number == len(index_increments): - increment_number = 0 - index_increment = index_increments[increment_number] - parameters = self._nx[indexes[2]][indexes[1]][indexes[0]] - x = parameters[0] - use_derivative_index = end_derivative_index if (indexes == end_indexes) else [derivative_index] - indexes_list.append(copy.copy(indexes)) - derivative_index_list.append(copy.copy(use_derivative_index)) - d = [0.0, 0.0, 0.0] - for i in range(len(use_derivative_index)): - spix = use_derivative_index[i] - pix = abs(spix) - values = parameters[pix] - if values: - if spix < 0: - values = [-ad for ad in values] - d = add(d, values) - px.append(x) - pd.append(d) - n += 1 - sd = smoothCubicHermiteDerivativesLine( - px, pd, fixStartDirection=fix_start_direction, fixEndDirection=fix_end_direction, - fixEndDerivative=len(end_derivative_index) > 1) - for n in range(len(sd)): - sd[n] = moveDerivativeToEllipsoidSurface(self._a, self._b, self._c, px[n], sd[n]) - sd = smoothCubicHermiteDerivativesLine( - px, sd, fixAllDirections=True, fixEndDerivative=len(end_derivative_index) > 1) - last_n = len(sd) - 1 - for n in range(len(sd)): - indexes = indexes_list[n] - derivative_index = derivative_index_list[n] - parameters = self._nx[indexes[2]][indexes[1]][indexes[0]] - if len(derivative_index) == 1: - spix = derivative_index[0] - new_derivative = [-d for d in sd[n]] if (spix < 0) else sd[n] - pix = abs(spix) - if parameters[pix] and (blend_start and (n == 0)) or (blend_end and (n == last_n)): - new_derivative = linearlyInterpolateVectors(parameters[pix], new_derivative, 0.5) - parameters[pix] = new_derivative - # else: - # # not putting back values if summed parameters + half_counts0 = self._element_counts[0] // 2 + for n3 in range(self._element_counts[2] + 1): + nx_layer = self._nx[n3] + for n2 in range(self._element_counts[1] + 1): + nx_row = nx_layer[n2] + for n1 in range(half_counts0 + 1, self._element_counts[0] + 1): + nx = self._nx[n3][n2][n1] + if nx and nx[0]: + x, d1, d2, d3 = nx + x = [-x[0], x[1], x[2]] + d1 = [d1[0], -d1[1], -d1[2]] if d1 else None + d2 = [-d2[0], d2[1], d2[2]] if d2 else None + d3 = [-d3[0], d3[1], d3[2]] if d3 else None + nx_row[self._element_counts[0] - n1] = [x, d1, d2, d3] + def _next_increment_out_of_bounds(self, indexes, index_increment): + for c in range(3): + index = indexes[c] + index_increment[c] + if (index < 0) or (index > self._element_counts[c]): + return True + return False def _get_nid_to_node_layout_map_3d(self, node_layout_manager): """ diff --git a/src/scaffoldmaker/utils/hextetrahedronmesh.py b/src/scaffoldmaker/utils/hextetrahedronmesh.py index a491bce0..dc0be76f 100644 --- a/src/scaffoldmaker/utils/hextetrahedronmesh.py +++ b/src/scaffoldmaker/utils/hextetrahedronmesh.py @@ -32,13 +32,13 @@ def __init__(self, axis_counts, diag_counts, nway_d_factor=0.6): assert all((count >= 2) for count in diag_counts) # check the faces have valid element counts around them max_diag_count0 = axis_counts[0] + axis_counts[1] - 2 - assert any((diag_counts[0] == diag_count) for diag_count in range(max_diag_count0, 2, -2)) + assert any((diag_counts[0] == diag_count) for diag_count in range(max_diag_count0, 1, -2)) max_diag_count1 = axis_counts[0] + axis_counts[2] - 2 - assert any((diag_counts[1] == diag_count) for diag_count in range(max_diag_count1, 2, -2)) + assert any((diag_counts[1] == diag_count) for diag_count in range(max_diag_count1, 1, -2)) max_diag_count2 = axis_counts[1] + axis_counts[2] - 2 - assert any((diag_counts[2] == diag_count) for diag_count in range(max_diag_count2, 2, -2)) + assert any((diag_counts[2] == diag_count) for diag_count in range(max_diag_count2, 1, -2)) max_diag_count3 = diag_counts[0] + diag_counts[1] - 2 - assert any((diag_counts[2] == diag_count) for diag_count in range(max_diag_count3, 2, -2)) + assert any((diag_counts[2] == diag_count) for diag_count in range(max_diag_count3, 1, -2)) self._axis_counts = copy.copy(axis_counts) self._diag_counts = copy.copy(diag_counts) self._nway_d_factor = nway_d_factor @@ -82,6 +82,12 @@ def __init__(self, axis_counts, diag_counts, nway_d_factor=0.6): # print(s) self._nx.append(nx_layer) + def get_axis_counts(self): + return self._axis_counts + + def get_box_counts(self): + return self._box_counts + def get_parameters(self): """ Get parameters array e.g. for copying to ellipsoid. @@ -622,3 +628,19 @@ def build_interior(self): self._smooth_derivative_across( [n1, self._box_counts[1] + nt, 0], [n1, 0, self._box_counts[2] + nt], [[0, 0, 1], [0, -1, 0]], [2, -2], fix_start_direction=True, fix_end_direction=True) + + def mirror_yz(self): + """ + Mirror coordinates and derivatives about both y = 0 and z = 0 planes. + """ + for n3 in range(self._axis_counts[2] + 1): + nx_layer = self._nx[n3] + for n2 in range(self._axis_counts[1] + 1): + nx_row = nx_layer[n2] + for n1 in range(self._axis_counts[0] + 1): + nx = nx_row[n1] + if nx: + for d in nx: + if d: + d[1] = -d[1] + d[2] = -d[2] diff --git a/src/scaffoldmaker/utils/interpolation.py b/src/scaffoldmaker/utils/interpolation.py index 2b1c324a..38806d9d 100644 --- a/src/scaffoldmaker/utils/interpolation.py +++ b/src/scaffoldmaker/utils/interpolation.py @@ -1791,7 +1791,7 @@ def track_curve_side_direction(cx, cd1, start_direction, start_location, end_loc def linearlyInterpolateVectors(u, v, xi, magnitudeScalingMode=DerivativeScalingMode.ARITHMETIC_MEAN): """ - Linearly interpolate two vectors less than 180 degrees apart to get an in-between vector + Linearly interpolate two 3-component vectors less than 180 degrees apart to get an in-between vector rotated from direction of u to direction of v proportionatly to xi, with magnitude linearly interpolated by xi. :param u: First vector. :param v: Second vector from 0 to less than 180 degrees away from u. @@ -1820,7 +1820,10 @@ def linearlyInterpolateVectors(u, v, xi, magnitudeScalingMode=DerivativeScalingM cos_phi = math.cos(phi) sin_phi = math.sin(phi) axis1 = dirn_u - axis3 = normalize(cross(dirn_u, dirn_v)) + cross_u_v = cross(dirn_u, dirn_v) + if magnitude(cross_u_v) == 0.0: + return [0.0, 0.0, 0.0] + axis3 = normalize(cross_u_v) axis2 = cross(axis3, dirn_u) dirn = add(mult(axis1, cos_phi), mult(axis2, sin_phi)) return set_magnitude(dirn, mag) diff --git a/tests/test_ellipsoid.py b/tests/test_ellipsoid.py index ccf1ece4..e312ae05 100644 --- a/tests/test_ellipsoid.py +++ b/tests/test_ellipsoid.py @@ -170,7 +170,7 @@ def test_ellipsoid_3D(self): self.assertEqual(result, RESULT_OK) self.assertAlmostEqual(surface_area, 27.86848567909992, delta=TOL) # note exact ellipsoid volume is 4.0 / 3.0 * math.pi * a * b * c = 12.566370614359173 - self.assertAlmostEqual(volume, 12.557389634764395, delta=TOL) + self.assertAlmostEqual(volume, 12.557389634764352, delta=TOL) for annotation_group in annotation_groups: name = annotation_group.getName() From 8e94e99cd7d02ec14bd89e4c1e4dcbdfb44fcfaa Mon Sep 17 00:00:00 2001 From: Richard Christie Date: Wed, 12 Nov 2025 12:16:49 +1300 Subject: [PATCH 04/24] Add ellipsoid octant extension for lower lung --- .../meshtypes/meshtype_3d_ellipsoid1.py | 5 +- .../meshtypes/meshtype_3d_lung3.py | 4 +- .../meshtypes/meshtype_3d_lung4.py | 75 ++++++- src/scaffoldmaker/scaffolds.py | 2 + src/scaffoldmaker/utils/ellipsoidmesh.py | 203 +++++++++++++----- src/scaffoldmaker/utils/geometry.py | 77 +++++-- 6 files changed, 293 insertions(+), 73 deletions(-) diff --git a/src/scaffoldmaker/meshtypes/meshtype_3d_ellipsoid1.py b/src/scaffoldmaker/meshtypes/meshtype_3d_ellipsoid1.py index 78d30cb0..fe407301 100644 --- a/src/scaffoldmaker/meshtypes/meshtype_3d_ellipsoid1.py +++ b/src/scaffoldmaker/meshtypes/meshtype_3d_ellipsoid1.py @@ -129,8 +129,7 @@ def generateBaseMesh(cls, region, options): fieldmodule = region.getFieldmodule() coordinates = find_or_create_field_coordinates(fieldmodule) - ellipsoid = EllipsoidMesh(a, b, c, element_counts, transition_element_count, - axis2_x_rotation_radians, axis3_x_rotation_radians, surface_only) + ellipsoid = EllipsoidMesh(a, b, c, element_counts, transition_element_count, surface_only) left_group = AnnotationGroup(region, ("left", "")) right_group = AnnotationGroup(region, ("right", "")) @@ -157,7 +156,7 @@ def generateBaseMesh(cls, region, options): ellipsoid.set_nway_derivative_factor(nway_derivative_factor) ellipsoid.set_surface_d3_mode(surface_d3_mode) - ellipsoid.build() + ellipsoid.build(axis2_x_rotation_radians, axis3_x_rotation_radians) ellipsoid.generate_mesh(fieldmodule, coordinates) return annotation_groups, None diff --git a/src/scaffoldmaker/meshtypes/meshtype_3d_lung3.py b/src/scaffoldmaker/meshtypes/meshtype_3d_lung3.py index 3efaace8..9b9d1f7c 100644 --- a/src/scaffoldmaker/meshtypes/meshtype_3d_lung3.py +++ b/src/scaffoldmaker/meshtypes/meshtype_3d_lung3.py @@ -263,7 +263,7 @@ def generateBaseMesh(cls, region, options): axis3_x_rotation_radians = math.radians(90) - oblique_slope_radians ellipsoid = EllipsoidMesh(halfDepth, halfBreadth, halfHeight, elementCounts, elementsCountTransition, - axis2_x_rotation_radians, axis3_x_rotation_radians, surface_only) + surface_only) if lung == leftLung: octant_group_lists = [] @@ -292,7 +292,7 @@ def generateBaseMesh(cls, region, options): ellipsoid.set_octant_group_lists(octant_group_lists) - ellipsoid.build() + ellipsoid.build(axis2_x_rotation_radians, axis3_x_rotation_radians) nodeIdentifier, elementIdentifier = ellipsoid.generate_mesh(fieldmodule, coordinates, nodeIdentifier, elementIdentifier) for lung in lungs: diff --git a/src/scaffoldmaker/meshtypes/meshtype_3d_lung4.py b/src/scaffoldmaker/meshtypes/meshtype_3d_lung4.py index a35a5c79..49c9279b 100644 --- a/src/scaffoldmaker/meshtypes/meshtype_3d_lung4.py +++ b/src/scaffoldmaker/meshtypes/meshtype_3d_lung4.py @@ -9,9 +9,10 @@ from scaffoldmaker.annotation.lung_terms import get_lung_term from scaffoldmaker.meshtypes.scaffold_base import Scaffold_base from scaffoldmaker.utils.meshrefinement import MeshRefinement -from scaffoldmaker.utils.ellipsoidmesh import EllipsoidMesh +from scaffoldmaker.utils.ellipsoidmesh import EllipsoidMesh, EllipsoidSurfaceD3Mode from scaffoldmaker.utils.zinc_utils import translate_nodeset_coordinates +import copy import math @@ -257,6 +258,78 @@ def generateBaseMesh(cls, region, options): leftLung, rightLung = 0, 1 lungs = [lung for show, lung in [(isLeftLung, leftLung), (isRightLung, rightLung)] if show] nodeIdentifier, elementIdentifier = 1, 1 + + elementCounts = [elementsCountLateral, elementsCountOblique, elementsCountOblique] + lower_ellipsoid = EllipsoidMesh(halfDepth, halfBreadth, halfHeight, elementCounts, elementsCountTransition) + lower_ellipsoid.set_surface_d3_mode(EllipsoidSurfaceD3Mode.SURFACE_NORMAL_PLANE_PROJECTION) + upper_ellipsoid = EllipsoidMesh(halfDepth, halfBreadth, halfHeight, elementCounts, elementsCountTransition) + upper_ellipsoid.set_surface_d3_mode(EllipsoidSurfaceD3Mode.SURFACE_NORMAL_PLANE_PROJECTION) + pi__3 = math.pi / 3.0 + normal_face_factor = 1.0 + half_counts = [count // 2 for count in elementCounts] + octant1 = upper_ellipsoid.build_octant(half_counts, -pi__3, 0.0, normal_face_factor=normal_face_factor) + upper_ellipsoid.merge_octant(octant1, quadrant=3) + hilum_x = [] + ox = octant1.get_parameters() + box_count1 = octant1.get_box_counts()[0] + for n1 in range(elementCounts[0] + 1): + mirror_x = n1 < half_counts[0] + o1 = abs(n1 - half_counts[0]) + parameters = ox[0][0][o1] + obox = o1 <= box_count1 + parameters = [ + copy.copy(parameters[0]), + copy.copy(parameters[3 if obox else 2]), + [-d for d in parameters[2 if obox else 1]], + copy.copy(parameters[1 if obox else 3]) + ] + if mirror_x: + for i in range(3): + parameters[i][0] = -parameters[i][0] + hilum_x.append(parameters) + + octant2 = upper_ellipsoid.build_octant(half_counts, 0.0, pi__3, normal_face_factor=normal_face_factor) + upper_ellipsoid.merge_octant(octant2, quadrant=0) + octant3 = upper_ellipsoid.build_octant(half_counts, pi__3, 2.0 * pi__3, normal_face_factor=normal_face_factor) + upper_ellipsoid.merge_octant(octant3, quadrant=1) + upper_ellipsoid.copy_to_negative_axis1() + + lower_lobe_extension = 0.6 * halfHeight / math.cos(math.pi / 6.0) + lower_lobe_extension_elements_count = 2 + octant4 = lower_ellipsoid.build_octant(half_counts, 2.0 * pi__3, math.pi, + lower_lobe_extension, lower_lobe_extension_elements_count, + normal_face_factor=normal_face_factor) + # merge into separate lower ellipsoid to have space for extension elements + lower_ellipsoid_mesh = EllipsoidMesh( + halfDepth, halfBreadth, halfHeight, + [elementCounts[0], elementCounts[1], elementCounts[2] + 2 * lower_lobe_extension_elements_count], + elementsCountTransition) + lower_ellipsoid_mesh.set_surface_d3_mode(EllipsoidSurfaceD3Mode.SURFACE_NORMAL_PLANE_PROJECTION) + lower_ellipsoid_mesh.merge_octant(octant4, quadrant=1) + lower_ellipsoid_mesh.copy_to_negative_axis1() + + node_layout_manager = lower_ellipsoid.get_node_layout_manager() + node_layout_permuted = node_layout_manager.getNodeLayoutRegularPermuted(d3Defined=True) + for n1 in range(elementCounts[0] + 1): + lower_ellipsoid_mesh.set_node_parameters( + n1, half_counts[1], elementCounts[2] + 2 * lower_lobe_extension_elements_count - half_counts[2], hilum_x[n1], + node_layout=node_layout_permuted) + nodeIdentifier, elementIdentifier = lower_ellipsoid_mesh.generate_mesh( + fieldmodule, coordinates, nodeIdentifier, elementIdentifier) + + node_layout_manager = upper_ellipsoid.get_node_layout_manager() + node_layout_6way = node_layout_manager.getNodeLayout6Way12(d3Defined=True) + for n1 in range(elementCounts[0] + 1): + nid = lower_ellipsoid_mesh.get_node_identifier( + n1, half_counts[1], elementCounts[2] + 2 * lower_lobe_extension_elements_count - half_counts[2]) if (n1 >= half_counts[0]) else None + upper_ellipsoid.set_node_parameters(n1, half_counts[1], half_counts[2], hilum_x[n1], + nid, node_layout=node_layout_6way) + nodeIdentifier, elementIdentifier = upper_ellipsoid.generate_mesh( + fieldmodule, coordinates, nodeIdentifier, elementIdentifier) + + return annotationGroups, None + + for lung in lungs: oblique_slope_radians = left_oblique_slope_radians if lung == leftLung else right_oblique_slope_radians axis2_x_rotation_radians = -oblique_slope_radians diff --git a/src/scaffoldmaker/scaffolds.py b/src/scaffoldmaker/scaffolds.py index f6bd8a5b..ebebfb91 100644 --- a/src/scaffoldmaker/scaffolds.py +++ b/src/scaffoldmaker/scaffolds.py @@ -40,6 +40,7 @@ from scaffoldmaker.meshtypes.meshtype_3d_lung1 import MeshType_3d_lung1 from scaffoldmaker.meshtypes.meshtype_3d_lung2 import MeshType_3d_lung2 from scaffoldmaker.meshtypes.meshtype_3d_lung3 import MeshType_3d_lung3 +from scaffoldmaker.meshtypes.meshtype_3d_lung4 import MeshType_3d_lung4 from scaffoldmaker.meshtypes.meshtype_3d_musclefusiform1 import MeshType_3d_musclefusiform1 from scaffoldmaker.meshtypes.meshtype_3d_nerve1 import MeshType_3d_nerve1 from scaffoldmaker.meshtypes.meshtype_3d_ostium1 import MeshType_3d_ostium1 @@ -107,6 +108,7 @@ class Scaffolds(object): MeshType_3d_lung1, MeshType_3d_lung2, MeshType_3d_lung3, + MeshType_3d_lung4, MeshType_3d_musclefusiform1, MeshType_3d_nerve1, MeshType_3d_ostium1, diff --git a/src/scaffoldmaker/utils/ellipsoidmesh.py b/src/scaffoldmaker/utils/ellipsoidmesh.py index 8abb3e2a..4573f983 100644 --- a/src/scaffoldmaker/utils/ellipsoidmesh.py +++ b/src/scaffoldmaker/utils/ellipsoidmesh.py @@ -1,7 +1,7 @@ """ Utilities for building solid ellipsoid meshes from hexahedral elements. """ -from cmlibs.maths.vectorops import add, cross, div, magnitude, mult, set_magnitude, sub +from cmlibs.maths.vectorops import add, cross, div, dot, magnitude, mult, normalize, rejection, set_magnitude, sub from cmlibs.zinc.element import Element, Elementbasis from cmlibs.zinc.field import Field from cmlibs.zinc.node import Node @@ -9,7 +9,7 @@ from scaffoldmaker.utils.eft_utils import determineCubicHermiteSerendipityEft, HermiteNodeLayoutManager from scaffoldmaker.utils.geometry import ( getEllipsePointAtTrueAngle, getEllipseTangentAtPoint, moveCoordinatesToEllipsoidSurface, - moveDerivativeToEllipsoidSurface, sampleCurveOnEllipsoid) + moveDerivativeToEllipsoidSurface, moveDerivativeToEllipsoidSurfaceInPlane, sampleCurveOnEllipsoid) from scaffoldmaker.utils.interpolation import ( DerivativeScalingMode, get_nway_point, linearlyInterpolateVectors, sampleHermiteCurve, smoothCubicHermiteDerivativesLine) @@ -23,6 +23,7 @@ class EllipsoidSurfaceD3Mode(Enum): SURFACE_NORMAL = 1 # surface D3 are exact surface normals to ellipsoid OBLIQUE_DIRECTION = 2 # surface D3 are in direction of surface point on ellipsoid, gives flat oblique planes + SURFACE_NORMAL_PLANE_PROJECTION = 3 # Surface D3 are surface normals to ellipsoid projected onto planes from axis2 to axis3 class EllipsoidMesh: @@ -30,16 +31,13 @@ class EllipsoidMesh: Generates a solid ellipsoid of hexahedral elements with oblique cross axes suited to describing lung geometry. """ - def __init__(self, a, b, c, element_counts, transition_element_count, - axis2_x_rotation_radians, axis3_x_rotation_radians, surface_only=False): + def __init__(self, a, b, c, element_counts, transition_element_count, surface_only=False): """ :param a: Axis length (radius) in x direction. :param b: Axis length (radius) in y direction. :param c: Axis length (radius) in z direction. :param element_counts: Number of elements across full ellipse in a, b, c. :param transition_element_count: Number of transition elements around outside >= 1. - :param axis2_x_rotation_radians: Rotation of axis 2 about +x direction - :param axis3_x_rotation_radians: Rotation of axis 3 about +x direction. :param surface_only: Set to True to only make nodes and 2-D elements on the surface. """ assert all((count >= 4) and (count % 2 == 0) for count in element_counts) @@ -49,8 +47,6 @@ def __init__(self, a, b, c, element_counts, transition_element_count, self._c = c self._element_counts = element_counts self._trans_count = transition_element_count - self._axis2_x_rotation_radians = axis2_x_rotation_radians - self._axis3_x_rotation_radians = axis3_x_rotation_radians self._surface_only = surface_only self._nway_d_factor = 0.6 self._surface_d3_mode = EllipsoidSurfaceD3Mode.SURFACE_NORMAL @@ -96,6 +92,8 @@ def __init__(self, a, b, c, element_counts, transition_element_count, # print(s) self._nx.append(nx_layer) self._nids.append(nids_layer) + self._node_layout_manager = HermiteNodeLayoutManager() + self._prescribed_node_layouts = [] # list of (n1, n2, n3, node_layout) def set_box_transition_groups(self, box_group, transition_group): """ @@ -133,53 +131,74 @@ def set_surface_d3_mode(self, surface_d3_mode: EllipsoidSurfaceD3Mode): """ self._surface_d3_mode = surface_d3_mode - def build(self): + def build(self, axis2_x_rotation_radians, axis3_x_rotation_radians): """ Determine coordinates and derivatives over and within the full ellipsoid. + :param axis2_x_rotation_radians: Rotation of axis 2 about +x direction + :param axis3_x_rotation_radians: Rotation of axis 3 about +x direction. """ half_counts = [count // 2 for count in self._element_counts] - octant1 = self.build_octant(half_counts, self._axis2_x_rotation_radians, self._axis3_x_rotation_radians) + octant1 = self.build_octant(half_counts, axis2_x_rotation_radians, axis3_x_rotation_radians) self.merge_octant(octant1, quadrant=0) octant1.mirror_yz() self.merge_octant(octant1, quadrant=2) octant2 = self.build_octant([half_counts[0], half_counts[2], half_counts[1]], - self._axis3_x_rotation_radians, self._axis2_x_rotation_radians + math.pi) + axis3_x_rotation_radians, axis2_x_rotation_radians + math.pi) self.merge_octant(octant2, quadrant=1) octant2.mirror_yz() self.merge_octant(octant2, quadrant=3) self.copy_to_negative_axis1() - def build_octant(self, half_counts, axis2_x_rotation_radians, axis3_x_rotation_radians): + def build_octant(self, half_counts, axis2_x_rotation_radians, axis3_x_rotation_radians, + axis2_extension=0.0, axis2_extension_elements_count=0, normal_face_factor=0.0): """ Get coordinates of top, right, front octant with supplied angles. :param half_counts: Numbers of elements across octant 1, 2 and 3 directions. :param axis2_x_rotation_radians: Rotation of axis 2 about +x direction :param axis3_x_rotation_radians: Rotation of axis 3 about +x direction. + :param axis2_extension: Extension distance along axis2 beyond origin [0.0, 0.0, 0.0]. + :param axis2_extension_elements_count: If axis2_extension: number of elements beyond origin. + Note: included in half_counts[1]. + :param normal_face_factor: 0.0 for interpolated face normals, up to 1.0 for fully normal to axis surface. :return: HexTetrahedronMesh """ - elements_count_q12 = half_counts[0] + half_counts[1] - 2 * self._trans_count - elements_count_q13 = half_counts[0] + half_counts[2] - 2 * self._trans_count - elements_count_q23 = half_counts[1] + half_counts[2] - 2 * self._trans_count + assert ((axis2_extension == 0.0) and (axis2_extension_elements_count == 0)) or ( + (axis2_extension > 0.0) and (0 < axis2_extension_elements_count)) box_counts = [half_counts[i] - self._trans_count for i in range(3)] + cos_axis2 = math.cos(axis2_x_rotation_radians) + sin_axis2 = math.sin(axis2_x_rotation_radians) origin = [0.0, 0.0, 0.0] - axis1 = [self._a, 0.0, 0.0] + ext_origin = [0.0, -axis2_extension * cos_axis2, -axis2_extension * sin_axis2] + ext_axis1 = axis1 = [self._a, 0.0, 0.0] axis2 = [0.0] + getEllipsePointAtTrueAngle(self._b, self._c, axis2_x_rotation_radians) - axis3 = [0.0] + getEllipsePointAtTrueAngle(self._b, self._c, axis3_x_rotation_radians) + axis2_normal = normalize([0.0, axis2[2], -axis2[1]]) + ext_axis3 = axis3 = [0.0] + getEllipsePointAtTrueAngle(self._b, self._c, axis3_x_rotation_radians) + axis3_normal = normalize([0.0, axis3[2], -axis3[1]]) + if axis2_extension_elements_count: + assert axis2_extension < magnitude(axis2) # extension must not go outside ellipsoid + xb, xa = getEllipsePointAtTrueAngle(magnitude(axis2), self._a, math.pi / 2.0, [-axis2_extension, 0.0]) + ext_axis1 = [xa, xb * cos_axis2, xb * sin_axis2] + ext_axis3 = [0.0] + getEllipsePointAtTrueAngle(self._b, self._c, axis3_x_rotation_radians, ext_axis1[1:]) + axis_d1 = div(axis1, half_counts[0]) + ext_axis_d1 = div(sub(ext_axis1, ext_origin), half_counts[0]) axis_d2 = div(axis2, half_counts[1]) axis_d3 = div(axis3, half_counts[2]) + ext_axis_d3 = div(sub(ext_axis3, ext_origin), half_counts[2]) axis_md1 = [-d for d in axis_d1] axis_md2 = [-d for d in axis_d2] axis_md3 = [-d for d in axis_d3] - # magnitude of most derivatives indicating only direction so magnitude not known + ext_axis_md1 = [-d for d in ext_axis_d1] + + # most derivatives indicate only direction, so magnitude not known dir_mag = min(magnitude(axis_d1), magnitude(axis_d2), magnitude(axis_d3)) axis2_dt = set_magnitude([0.0] + getEllipseTangentAtPoint(self._b, self._c, axis2[1:]), magnitude(axis_d3)) - axis3_dt = set_magnitude([0.0] + getEllipseTangentAtPoint(self._b, self._c, axis3[1:]), magnitude(axis_d2)) - axis3_mdt = [-d for d in axis3_dt] + ext_axis3_dt = set_magnitude([0.0] + getEllipseTangentAtPoint(self._b, self._c, ext_axis3[1:]), magnitude(ext_axis_d3)) + ext_axis3_mdt = [-d for d in ext_axis3_dt] axis2_mag = magnitude(axis2) axis3_mag = magnitude(axis3) @@ -191,37 +210,77 @@ def build_octant(self, half_counts, axis2_x_rotation_radians, axis3_x_rotation_r start_weight, end_weight, overweighting, end_transition)) move_x_to_ellipsoid_surface = lambda x: moveCoordinatesToEllipsoidSurface(self._a, self._b, self._c, x) move_d_to_ellipsoid_surface = lambda x, d: moveDerivativeToEllipsoidSurface(self._a, self._b, self._c, x, d) + def evaluate_surface_d3_ellipsoid_plane(tx, td1, td2): + """ + Restrict d3 to be ellipsoid normal constrained be in plane interpolated from axis_d2 to axis_d3 at tx + relative to ext_origin. + :return: d3 with magnitude dir_mag. + """ + n = [tx[0] / (self._a * self._a), tx[1] / (self._b * self._b), tx[2] / (self._c * self._c)] + if dot(tx, axis3_normal) <= 1.0E-5: + if dot(tx, axis2_normal) >= -1.0E-5: + return set_magnitude(axis1, dir_mag) + else: + plane_normal = [0.0, axis3[2], -axis3[1]] + else: + plane_normal = [0.0, tx[2], -tx[1]] + normal = rejection(n, plane_normal) + return set_magnitude(normal, dir_mag) if self._surface_d3_mode == EllipsoidSurfaceD3Mode.SURFACE_NORMAL: evaluate_surface_d3_ellipsoid = lambda tx, td1, td2: set_magnitude( [tx[0] / (self._a * self._a), tx[1] / (self._b * self._b), tx[2] / (self._c * self._c)], dir_mag) - else: - evaluate_surface_d3_ellipsoid = lambda tx, td1, td2: set_magnitude(tx, dir_mag) + elif self._surface_d3_mode == EllipsoidSurfaceD3Mode.OBLIQUE_DIRECTION: + evaluate_surface_d3_ellipsoid=lambda tx, td1, td2: set_magnitude(tx, dir_mag) + else: # EllipsoidSurfaceD3Mode.SURFACE_NORMAL_PLANE_PROJECTION + evaluate_surface_d3_ellipsoid = evaluate_surface_d3_ellipsoid_plane + ext_half_counts = [ + half_counts[0], + half_counts[1] + axis2_extension_elements_count, + half_counts[2] + ] diag_counts = [ half_counts[0] + half_counts[1] - 2 * self._trans_count, half_counts[0] + half_counts[2] - 2 * self._trans_count, half_counts[1] + half_counts[2] - 2 * self._trans_count ] - octant = HexTetrahedronMesh(half_counts, diag_counts, nway_d_factor=self._nway_d_factor) + ext_diag_counts = [ + ext_half_counts[0] + ext_half_counts[1] - 2 * self._trans_count, + ext_half_counts[0] + ext_half_counts[2] - 2 * self._trans_count, + ext_half_counts[1] + ext_half_counts[2] - 2 * self._trans_count + ] + octant = HexTetrahedronMesh(ext_half_counts, ext_diag_counts, nway_d_factor=self._nway_d_factor) # get outside curve from axis 1 to axis 2 abx, abd1, abd2 = sampleCurveOnEllipsoid( self._a, self._b, self._c, axis1, axis_d2, axis_d3, axis2, axis_md1, axis2_dt, - elements_count_q12) + diag_counts[0]) + if axis2_extension_elements_count: + end_axis_d3 = moveDerivativeToEllipsoidSurfaceInPlane( + self._a, self._b, self._c, ext_axis1, [axis_d3[0], -axis_d3[2], axis_d3[1]], axis_d3) + ext_abx, ext_abd1, ext_abd2 = sampleCurveOnEllipsoid( + self._a, self._b, self._c, + axis1, [-d for d in axis_d2], axis_d3, + ext_axis1, None, end_axis_d3, # axis_d3 + axis2_extension_elements_count) + for i in range(1, axis2_extension_elements_count + 1): + abx.insert(0, ext_abx[i]) + abd1.insert(0, [-d for d in ext_abd1[i]]) + abd2.insert(0, ext_abd2[i]) # get outside curve from axis 1 to axis 3 acx, acd2, acd1 = sampleCurveOnEllipsoid( self._a, self._b, self._c, abx[0], abd2[0], abd1[0], - axis3, axis_md1, axis3_mdt, - elements_count_q13) + ext_axis3, ext_axis_md1, ext_axis3_mdt, + ext_diag_counts[1]) # get outside curve from axis 2 to axis 3 bcx, bcd2, bcd1 = sampleCurveOnEllipsoid( self._a, self._b, self._c, abx[-1], abd2[-1], abd1[-1], acx[-1], [-d for d in acd1[-1]], acd2[-1], - elements_count_q23) + ext_diag_counts[2]) # fix first/last derivatives abd2[0] = acd2[0] abd2[-1] = bcd2[0] @@ -229,7 +288,7 @@ def build_octant(self, half_counts, axis2_x_rotation_radians, axis3_x_rotation_r # make outer surface triangle of octant 1 triangle_abc = QuadTriangleMesh( - box_counts[0], box_counts[1], box_counts[2], + box_counts[0], box_counts[1] + axis2_extension_elements_count, box_counts[2], sample_curve_on_ellipsoid, move_x_to_ellipsoid_surface, move_d_to_ellipsoid_surface, self._nway_d_factor) triangle_abc.set_edge_parameters12(abx, abd1, abd2) triangle_abc.set_edge_parameters13(acx, acd1, acd2) @@ -247,16 +306,25 @@ def build_octant(self, half_counts, axis2_x_rotation_radians, axis3_x_rotation_r # build interior lines from axis1, axis2, axis3 to origin aox, aod2, aod1 = sampleHermiteCurve( - axis1, axis_md1, abd1[0], origin, axis_md1, axis_d2, elements_count=half_counts[0]) + ext_axis1, ext_axis_md1, abd1[0], ext_origin, ext_axis_md1, axis_d2, elements_count=half_counts[0]) box, bod2, bod3 = sampleHermiteCurve( axis2, axis_md2, bcd2[0], origin, axis_md2, axis_d3, elements_count=half_counts[1]) + if axis2_extension_elements_count: + ext_box, ext_bod2, ext_bod3 = sampleHermiteCurve( + box[-1], bod2[-1], bod3[-1], ext_origin, None, ext_axis_d3, + elements_count=axis2_extension_elements_count) + for i in range(1, axis2_extension_elements_count + 1): + box.append(ext_box[i]) + bod2.append(ext_bod2[i]) + bod3.append(ext_bod3[i]) bod1 = [abd1[-1]] + [axis_md1] * (len(box) - 1) cox, cod2, cod1 = sampleHermiteCurve( - axis3, axis_md3, [-d for d in acd1[-1]], origin, axis_md3, axis_md2, elements_count=half_counts[2]) + ext_axis3, axis_md3, [-d for d in acd1[-1]], ext_origin, axis_md3, bod2[-1], + elements_count=half_counts[2]) # make inner surface triangle 1-2-origin triangle_abo = QuadTriangleMesh( - box_counts[0], box_counts[1], self._trans_count, sampleHermiteCurve, + box_counts[0], box_counts[1] + axis2_extension_elements_count, self._trans_count, sampleHermiteCurve, nway_d_factor=self._nway_d_factor) abd3 = [[-d for d in evaluate_surface_d3_ellipsoid(x, None, None)] for x in abx] triangle_abo.set_edge_parameters12(abx, abd1, abd3, abd2) @@ -265,7 +333,8 @@ def build_octant(self, half_counts, axis2_x_rotation_radians, axis3_x_rotation_r triangle_abo.set_edge_parameters23(box, bod1, bod2, bod3) triangle_abo.build() triangle_abo.assign_d3(lambda tx, td1, td2: - linearlyInterpolateVectors(axis_d3, axis2_dt, magnitude(tx[1:]) / axis2_mag)) + linearlyInterpolateVectors(axis_d3, axis2_dt, magnitude(tx[1:]) / axis2_mag) + if (dot(tx, axis2) >= 0.0) else axis_d3) octant.set_triangle_abo(triangle_abo) # extract exact derivatives aod1 = triangle_abo.get_edge_parameters13()[1] @@ -284,14 +353,14 @@ def build_octant(self, half_counts, axis2_x_rotation_radians, axis3_x_rotation_r triangle_aco.set_edge_parameters23(cox, cod3, cod2, cod1) triangle_aco.build() triangle_aco.assign_d3(lambda tx, td1, td2: - linearlyInterpolateVectors(axis_md2, axis3_dt, magnitude(tx[1:]) / axis3_mag)) + linearlyInterpolateVectors(axis_md2, ext_axis3_dt, magnitude(tx[1:]) / axis3_mag)) octant.set_triangle_aco(triangle_aco) # extract exact derivatives cod3 = triangle_aco.get_edge_parameters23()[1] # make inner surface 2-3-origin triangle_bco = QuadTriangleMesh( - box_counts[1], box_counts[2], self._trans_count, sampleHermiteCurve, + box_counts[1] + axis2_extension_elements_count, box_counts[2], self._trans_count, sampleHermiteCurve, nway_d_factor=self._nway_d_factor) bcd3 = [bod2[0]] + [[-d for d in evaluate_surface_d3_ellipsoid(x, None, None)] for x in bcx[1:-1]] \ + [cod2[-1]] @@ -312,6 +381,7 @@ def build_octant(self, half_counts, axis2_x_rotation_radians, axis3_x_rotation_r def merge_octant(self, octant: HexTetrahedronMesh, quadrant: int): """ Merge octant parameters into ellipsoid in one of the 4 quadrants on +axis1. + Octant can be extended into its axis2 over regular elements of ellipsoid. :param octant: HexTetrahedronMesh :param quadrant: 0 for +axis2, +axis3 increasing anticlockwise around +axis1 up to 3. """ @@ -319,10 +389,11 @@ def merge_octant(self, octant: HexTetrahedronMesh, quadrant: int): half_counts = [count // 2 for count in self._element_counts] axis_counts = octant.get_axis_counts() even_quadrant = quadrant in (0, 2) - if even_quadrant: - assert half_counts == axis_counts - else: - assert half_counts == [axis_counts[0], axis_counts[2], axis_counts[1]] + assert half_counts[0] == axis_counts[0] + ext_count2 = (axis_counts[1] - half_counts[1]) if even_quadrant else 0 + ext_count3 = 0 if even_quadrant else (axis_counts[1] - half_counts[2]) + assert 0 <= ext_count2 < (half_counts[1] - self._trans_count) + assert 0 <= ext_count3 < (half_counts[2] - self._trans_count) obox_counts = octant.get_box_counts() # box_counts = [half_counts[i] - self._trans_count for i in range(3)] octant_parameters = octant.get_parameters() @@ -332,18 +403,18 @@ def merge_octant(self, octant: HexTetrahedronMesh, quadrant: int): ox_row = octant_parameters[o3][o2] if even_quadrant: if quadrant == 0: - n3 = half_counts[2] + o3 - n2 = half_counts[1] + o2 + n3 = half_counts[2] + o3 - ext_count3 + n2 = half_counts[1] + o2 - ext_count2 else: # quadrant == 2: - n3 = half_counts[2] - o3 - n2 = half_counts[1] - o2 + n3 = half_counts[2] - o3 + ext_count3 + n2 = half_counts[1] - o2 + ext_count2 else: if quadrant == 1: - n3 = half_counts[2] + o2 - n2 = half_counts[1] - o3 + n3 = half_counts[2] + o2 - ext_count3 + n2 = half_counts[1] - o3 + ext_count2 else: # if quadrant == 3: - n3 = half_counts[2] - o2 - n2 = half_counts[1] + o3 + n3 = half_counts[2] - o2 + ext_count3 + n2 = half_counts[1] + o3 - ext_count2 transition3 = (n3 < self._trans_count) or (n3 > (self._element_counts[2] - self._trans_count)) transition2 = (n2 < self._trans_count) or (n2 > (self._element_counts[1] - self._trans_count)) # bottom_transition = n3 < self._trans_count @@ -525,8 +596,38 @@ def _get_nid_to_node_layout_map_3d(self, node_layout_manager): nid_to_node_layout[nid] = node_layout_3way12[2] nid = self._nids[self._element_counts[2] - nt][self._element_counts[1] - nt][self._element_counts[0] - nt] nid_to_node_layout[nid] = node_layout_3way12[3] + # add prescribed node layouts + for n1, n2, n3, node_layout in self._prescribed_node_layouts: + nid = self._nids[n3][n2][n1] + nid_to_node_layout[nid] = node_layout + print(n1, n2, n3, "node", nid, "layout", node_layout) return nid_to_node_layout + def get_node_layout_manager(self): + return self._node_layout_manager + + def get_node_identifier(self, n1, n2, n3): + assert 0 <= n1 <= self._element_counts[0] + assert 0 <= n2 <= self._element_counts[1] + assert 0 <= n3 <= self._element_counts[2] + return self._nids[n3][n2][n1] + + def get_node_parameters(self, n1, n2, n3): + assert 0 <= n1 <= self._element_counts[0] + assert 0 <= n2 <= self._element_counts[1] + assert 0 <= n3 <= self._element_counts[2] + return self._nx[n3][n2][n1] + + def set_node_parameters(self, n1, n2, n3, parameters, nid=None, node_layout=None): + assert 0 <= n1 <= self._element_counts[0] + assert 0 <= n2 <= self._element_counts[1] + assert 0 <= n3 <= self._element_counts[2] + assert self._nx[n3][n2][n1] is not None + assert self._nids[n3][n2][n1] is None + self._nx[n3][n2][n1] = copy.deepcopy(parameters) + self._nids[n3][n2][n1] = nid + self._prescribed_node_layouts.append((n1, n2, n3, node_layout)) + def generate_mesh(self, fieldmodule, coordinates, start_node_identifier=1, start_element_identifier=1): """ After build() has been called, generate nodes and elements of ellipsoid. @@ -555,6 +656,8 @@ def generate_mesh(self, fieldmodule, coordinates, start_node_identifier=1, start for n3 in range(self._element_counts[2] + 1): for n2 in range(self._element_counts[1] + 1): for n1 in range(self._element_counts[0] + 1): + if self._nids[n3][n2][n1] is not None: + continue # prescribed node parameters = self._nx[n3][n2][n1] if not parameters: continue @@ -578,8 +681,8 @@ def generate_mesh(self, fieldmodule, coordinates, start_node_identifier=1, start mesh_dimension = 2 if self._surface_only else 3 mesh = fieldmodule.findMeshByDimension(mesh_dimension) element_identifier = start_element_identifier + # return node_identifier, element_identifier half_counts = [count // 2 for count in self._element_counts] - node_layout_manager = HermiteNodeLayoutManager() octant_mesh_group_lists = None if self._octant_group_lists: octant_mesh_group_lists = [] @@ -634,8 +737,8 @@ def generate_mesh(self, fieldmodule, coordinates, start_node_identifier=1, start element_identifier += 1 last_nids_row = nids_row # around sides - node_layout_permuted = node_layout_manager.getNodeLayoutRegularPermuted(d3Defined=False) - node_layout_triple_points = node_layout_manager.getNodeLayoutTriplePoint2D() + node_layout_permuted = self._node_layout_manager.getNodeLayoutRegularPermuted(d3Defined=False) + node_layout_triple_points = self._node_layout_manager.getNodeLayoutTriplePoint2D() index_increments = [[0, 1, 0], [-1, 0, 0], [0, -1, 0], [1, 0, 0]] increment_number = 0 index_increment = index_increments[0] @@ -749,7 +852,7 @@ def generate_mesh(self, fieldmodule, coordinates, start_node_identifier=1, start elementtemplate_special.setElementShapeType(Element.SHAPE_TYPE_CUBE) box_counts = [half_counts[i] - self._trans_count for i in range(3)] dbox_counts = [2 * box_counts[i] for i in range(3)] - nid_to_node_layout = self._get_nid_to_node_layout_map_3d(node_layout_manager) + nid_to_node_layout = self._get_nid_to_node_layout_map_3d(self._node_layout_manager) # bottom transition last_nids_layer = None last_nx_layer = None diff --git a/src/scaffoldmaker/utils/geometry.py b/src/scaffoldmaker/utils/geometry.py index 7d4025eb..d1767766 100644 --- a/src/scaffoldmaker/utils/geometry.py +++ b/src/scaffoldmaker/utils/geometry.py @@ -7,7 +7,8 @@ import copy import math -from cmlibs.maths.vectorops import add, distance, magnitude, mult, normalize, cross, set_magnitude, rejection +from cmlibs.maths.vectorops import ( + cross, distance, dot, magnitude, mult, normalize, projection, set_magnitude, rejection) from scaffoldmaker.utils.interpolation import ( computeCubicHermiteDerivativeScaling, computeHermiteLagrangeDerivativeScaling, getCubicHermiteArcLength, interpolateHermiteLagrangeDerivative, linearlyInterpolateVectors, sampleCubicHermiteCurves, @@ -150,31 +151,57 @@ def getEllipseRadiansToX(ax, bx, dx, initialTheta): return theta -def getEllipsePointAtTrueAngle(a, b, angle_radians): +def getEllipsePointAtTrueAngle(a, b, angle_radians, origin=[0.0, 0.0]): """ - Get coordinates of intersection point of ellipse centred at origin with line radiating from origin at an angle. + Get coordinates of intersection point of ellipse centred at [0.0, 0.0] with line radiating from origin at an angle. :param a: x/major axis length. :param b: y/minor axis length. :param angle_radians: Angle in radians starting at x axis, increasing towards y axis. - :return: [x, y] + :param origin: Origin angles radiate from. Must be inside ellipsoid. Default is ellipsoid centre [0.0, 0.0]. + :return: [x, y] on ellipse. """ # ellipse equation: x ** 2 / a ** 2 + y ** 2 / b ** 2 - 1 = 0 - cos_angle = math.cos(angle_radians) - sin_angle = math.sin(angle_radians) + aa = a * a + bb = b * b + assert (origin[0] * origin[0] / aa + (origin[1] * origin[1]) / bb) < 1.0 # normal to line direction: - ni = sin_angle - nj = -cos_angle - # line equation: ni * x + nj * y = 0 + # line equation: ni * x + nj * y - k = 0 + ni = math.sin(angle_radians) + nj = -math.cos(angle_radians) + k = dot([ni, nj], origin) + ii = ni * ni + jj = nj * nj + kk = k * k if math.fabs(nj) > math.fabs(ni): - # substitute y and solve for x - denominator = 1.0 / (a * a) + (ni * ni) / (nj * nj * b * b) - x = math.copysign(math.sqrt(1.0 / denominator), cos_angle) - y = (-ni / nj) * x + # substitute y and solve for x with quadratic equation + jj_bb = jj * bb + qa = 1.0 / aa + ii / jj_bb + qb = -2.0 * k * ni / jj_bb + qc = kk / jj_bb - 1.0 + det = qb * qb - 4.0 * qa * qc + sqrt_det = math.sqrt(det) + x1 = (-qb + sqrt_det) / (2.0 * qa) + x2 = (-qb - sqrt_det) / (2.0 * qa) + if x1 * nj < 0.0: + x = x1 + else: + x = x2 + y = (k - ni * x) / nj else: - # substitute y and solve for x - denominator = 1.0 / (b * b) + (nj * nj) / (ni * ni * a * a) - y = math.copysign(math.sqrt(1.0 / denominator), sin_angle) - x = (-nj / ni) * y + # substitute x and solve for y with quadratic equation + ii_aa = ii * aa + qa = 1.0 / bb + jj / ii_aa + qb = -2.0 * k * nj / ii_aa + qc = kk / ii_aa - 1.0 + det = qb * qb - 4.0 * qa * qc + sqrt_det = math.sqrt(det) + y1 = (-qb + sqrt_det) / (2.0 * qa) + y2 = (-qb - sqrt_det) / (2.0 * qa) + if y1 * ni > 0.0: + y = y1 + else: + y = y2 + x = (k - nj * y) / ni return [x, y] @@ -492,6 +519,22 @@ def moveDerivativeToEllipsoidSurface(a, b, c, x, start_d): return set_magnitude(rejection(start_d, n), magnitude(start_d)) +def moveDerivativeToEllipsoidSurfaceInPlane(a, b, c, x, pn, start_d): + """ + Convert derivative at point on surface of ellipsoid to be tangential to it and normal to pn. + :param a: x-axis length. + :param b: y-axis length. + :param c: z-axis length. + :param x: Coordinates on surface of ellipsoid. + :param pn: Direction normal to plane to force result to be in plane. + :param start_d: Derivative near tangential to ellipsoid surface at x. + :return: Derivative made tangential to surface with same magnitude. + """ + en = [2.0 * x[0] / (a * a), 2.0 * x[1] / (b * b), 2.0 * x[2] / (c * c)] + cp = cross(pn, en) + return set_magnitude(projection(start_d, cp), magnitude(start_d)) + + def sampleCurveOnEllipsoid(a, b, c, start_x, start_d1, start_d2, end_x, end_d1, end_d2, elements_count, start_weight=None, end_weight=None, overweighting=1.0, end_transition=False): """ From fcf27b7686894c0ab862df3b1e5a3c447a7a2179 Mon Sep 17 00:00:00 2001 From: Richard Christie Date: Wed, 12 Nov 2025 19:35:19 +1300 Subject: [PATCH 05/24] Add left/right lung and octant groups --- .../meshtypes/meshtype_3d_lung4.py | 348 +++++++++++------- 1 file changed, 209 insertions(+), 139 deletions(-) diff --git a/src/scaffoldmaker/meshtypes/meshtype_3d_lung4.py b/src/scaffoldmaker/meshtypes/meshtype_3d_lung4.py index 49c9279b..a9247bbe 100644 --- a/src/scaffoldmaker/meshtypes/meshtype_3d_lung4.py +++ b/src/scaffoldmaker/meshtypes/meshtype_3d_lung4.py @@ -1,6 +1,7 @@ """ Generates a lung scaffold with common hilum but open fissures with lobes built from 1/6 ellipsoid segments. """ +from cmlibs.maths.vectorops import magnitude from cmlibs.utils.zinc.field import find_or_create_field_coordinates from cmlibs.zinc.field import Field @@ -8,6 +9,7 @@ getAnnotationGroupForTerm from scaffoldmaker.annotation.lung_terms import get_lung_term from scaffoldmaker.meshtypes.scaffold_base import Scaffold_base +from scaffoldmaker.utils.geometry import getEllipsePointAtTrueAngle from scaffoldmaker.utils.meshrefinement import MeshRefinement from scaffoldmaker.utils.ellipsoidmesh import EllipsoidMesh, EllipsoidSurfaceD3Mode from scaffoldmaker.utils.zinc_utils import translate_nodeset_coordinates @@ -44,34 +46,34 @@ def getDefaultOptions(cls, parameterSetName="Default"): options["Left lung"] = True options["Right lung"] = True # options["Number of left lung lobes"] = 2 - options["Ellipsoid height"] = 1.0 - options["Ellipsoid dorsal-ventral size"] = 0.8 - options["Ellipsoid medial-lateral size"] = 0.5 - options["Left-right lung spacing"] = 0.6 + height = options["Ellipsoid height"] = 1.0 + depth = options["Ellipsoid dorsal-ventral size"] = 0.75 + options["Ellipsoid medial-lateral size"] = 0.45 + options["Left-right lung spacing"] = 0.5 + max_extension = magnitude(getEllipsePointAtTrueAngle(depth / 2.0, height / 2.0, math.pi / 3.0)) + options["Lower lobe extension"] = 0.3 options["Refine"] = False options["Refine number of elements"] = 4 if "Coarse" in useParameterSetName: options["Number of elements lateral"] = 4 - options["Number of elements normal"] = 6 + options["Number of elements lower extension"] = 2 options["Number of elements oblique"] = 6 options["Number of transition elements"] = 1 elif "Medium" in useParameterSetName: options["Number of elements lateral"] = 4 - options["Number of elements normal"] = 10 - options["Number of elements oblique"] = 10 + options["Number of elements lower extension"] = 3 + options["Number of elements oblique"] = 8 options["Number of transition elements"] = 1 elif "Fine" in useParameterSetName: options["Number of elements lateral"] = 6 - options["Number of elements normal"] = 14 - options["Number of elements oblique"] = 14 + options["Number of elements lower extension"] = 4 + options["Number of elements oblique"] = 12 options["Number of transition elements"] = 1 if "Human" in useParameterSetName: options["Base lateral edge sharpness factor"] = 0.8 options["Ventral edge sharpness factor"] = 0.8 - options["Left oblique slope degrees"] = 60.0 - options["Right oblique slope degrees"] = 60.0 options["Medial curvature"] = 3.0 options["Medial curvature bias"] = 1.0 options["Dorsal-ventral rotation degrees"] = 20.0 @@ -79,8 +81,6 @@ def getDefaultOptions(cls, parameterSetName="Default"): else: options["Base lateral edge sharpness factor"] = 0.0 options["Ventral edge sharpness factor"] = 0.0 - options["Left oblique slope degrees"] = 0.0 - options["Right oblique slope degrees"] = 0.0 options["Medial curvature"] = 0.0 options["Medial curvature bias"] = 0.0 options["Dorsal-ventral rotation degrees"] = 0.0 @@ -95,40 +95,39 @@ def getOrderedOptionNames(cls): "Right lung", # "Number of left lung lobes", "Number of elements lateral", - "Number of elements normal", + "Number of elements lower extension", "Number of elements oblique", "Number of transition elements", "Ellipsoid height", "Ellipsoid dorsal-ventral size", "Ellipsoid medial-lateral size", "Left-right lung spacing", + "Lower lobe extension", "Base lateral edge sharpness factor", "Ventral edge sharpness factor", "Medial curvature", "Medial curvature bias", "Dorsal-ventral rotation degrees", "Ventral-medial rotation degrees", - "Left oblique slope degrees", - "Right oblique slope degrees", "Refine", "Refine number of elements" ] @classmethod def checkOptions(cls, options): - dependentChanges = False - # if options["Number of left lung lobes"] > 2: - # options["Number of left lung lobes"] = 2 - # elif options["Number of left lung lobes"] < 1: - # options["Number of left lung lobes"] = 0 + dependent_changes = False max_transition_count = None + for key in [ + "Number of elements lower extension" + ]: + if options[key] < 1: + options[key] = 1 for key in [ "Number of elements lateral", - "Number of elements normal", "Number of elements oblique" ]: - min_elements_count = 4 if key == "Number of elements lateral" else 6 + min_elements_count = 4 if (key == "Number of elements lateral") else 6 if options[key] < min_elements_count: options[key] = min_elements_count elif options[key] % 2: @@ -141,15 +140,21 @@ def checkOptions(cls, options): options["Number of transition elements"] = 1 elif options["Number of transition elements"] > max_transition_count: options["Number of transition elements"] = max_transition_count - dependentChanges = True + dependent_changes = True - for dimension in [ + for key in [ "Ellipsoid height", "Ellipsoid dorsal-ventral size", - "Ellipsoid medial-lateral size" + "Ellipsoid medial-lateral size", + "Lower lobe extension" ]: - if options[dimension] <= 0.0: - options[dimension] = 1.0 + if options[key] <= 0.0: + options[key] = 1.0 + depth = options["Ellipsoid dorsal-ventral size"] + height = options["Ellipsoid height"] + max_extension = 0.99 * magnitude(getEllipsePointAtTrueAngle(depth / 2.0, height / 2.0, math.pi / 3.0)) + if options["Lower lobe extension"] > max_extension: + options["Lower lobe extension"] = max_extension if options["Left-right lung spacing"] < 0.0: options["Left-right lung spacing"] = 0.0 @@ -176,7 +181,7 @@ def checkOptions(cls, options): if options['Refine number of elements'] < 1: options['Refine number of elements'] = 1 - return dependentChanges + return dependent_changes @classmethod def generateBaseMesh(cls, region, options): @@ -186,25 +191,24 @@ def generateBaseMesh(cls, region, options): :param options: Dict containing options. See getDefaultOptions(). :return: list of AnnotationGroup, None """ - isLeftLung = options["Left lung"] - isRightLung = options["Right lung"] - # numberOfLeftLung = options["Number of left lung lobes"] - numberOfLeftLung = 2 # This option is hidden until rodent lung scaffold is added. - - elementsCountLateral = options["Number of elements lateral"] - elementsCountNormal = options["Number of elements normal"] - elementsCountOblique = options["Number of elements oblique"] - elementsCountTransition = options["Number of transition elements"] - lungSpacing = options["Left-right lung spacing"] * 0.5 - baseSharpFactor = options["Base lateral edge sharpness factor"] - edgeSharpFactor = options["Ventral edge sharpness factor"] + is_left_lung = options["Left lung"] + is_right_lung = options["Right lung"] + # number_of_left_lung_lobes = options["Number of left lung lobes"] + number_of_left_lung_lobes = 2 # This option is hidden until rodent lung scaffold is added. + + elements_count_lateral = options["Number of elements lateral"] + elements_count_lower_extension = options["Number of elements lower extension"] + elements_count_oblique = options["Number of elements oblique"] + elements_count_transition = options["Number of transition elements"] + lung_spacing = options["Left-right lung spacing"] * 0.5 + lower_lobe_extension_height = options["Lower lobe extension"] + base_sharpness_factor = options["Base lateral edge sharpness factor"] + ventral_sharpness_factor = options["Ventral edge sharpness factor"] ellipsoid_height = options["Ellipsoid height"] ellipsoid_breadth = options["Ellipsoid dorsal-ventral size"] ellipsoid_depth = options["Ellipsoid medial-lateral size"] - left_oblique_slope_radians = math.radians(options["Left oblique slope degrees"]) - right_oblique_slope_radians = math.radians(options["Right oblique slope degrees"]) - leftLungMedialCurvature = options["Medial curvature"] - lungMedialCurvatureBias = options["Medial curvature bias"] + medial_curvature = options["Medial curvature"] + medial_curvature_bias = options["Medial curvature bias"] rotateLeftLungY = options["Dorsal-ventral rotation degrees"] rotateLeftLungZ = options["Ventral-medial rotation degrees"] @@ -239,7 +243,7 @@ def generateBaseMesh(cls, region, options): rightLateralLungGroup, rightMedialLungGroup, rightBaseLungGroup, rightPosteriorLungGroup, lowerRightLungGroup, middleRightLungGroup, upperRightLungGroup] - if numberOfLeftLung == 2: + if number_of_left_lung_lobes == 2: lowerLeftLungGroup = AnnotationGroup(region, get_lung_term("lower lobe of left lung")) upperLeftLungGroup = AnnotationGroup(region, get_lung_term("upper lobe of left lung")) @@ -249,100 +253,166 @@ def generateBaseMesh(cls, region, options): leftLungNodesetGroup = leftLungGroup.getNodesetGroup(nodes) rightLungNodesetGroup = rightLungGroup.getNodesetGroup(nodes) - elementCounts = [elementsCountLateral, elementsCountOblique, elementsCountNormal] halfDepth = ellipsoid_depth * 0.5 halfBreadth = ellipsoid_breadth * 0.5 halfHeight = ellipsoid_height * 0.5 surface_only = False - leftLung, rightLung = 0, 1 - lungs = [lung for show, lung in [(isLeftLung, leftLung), (isRightLung, rightLung)] if show] + left_lung, right_lung = 0, 1 + lungs = [lung for show, lung in [(is_left_lung, left_lung), (is_right_lung, right_lung)] if show] nodeIdentifier, elementIdentifier = 1, 1 - elementCounts = [elementsCountLateral, elementsCountOblique, elementsCountOblique] - lower_ellipsoid = EllipsoidMesh(halfDepth, halfBreadth, halfHeight, elementCounts, elementsCountTransition) - lower_ellipsoid.set_surface_d3_mode(EllipsoidSurfaceD3Mode.SURFACE_NORMAL_PLANE_PROJECTION) - upper_ellipsoid = EllipsoidMesh(halfDepth, halfBreadth, halfHeight, elementCounts, elementsCountTransition) - upper_ellipsoid.set_surface_d3_mode(EllipsoidSurfaceD3Mode.SURFACE_NORMAL_PLANE_PROJECTION) - pi__3 = math.pi / 3.0 - normal_face_factor = 1.0 - half_counts = [count // 2 for count in elementCounts] - octant1 = upper_ellipsoid.build_octant(half_counts, -pi__3, 0.0, normal_face_factor=normal_face_factor) - upper_ellipsoid.merge_octant(octant1, quadrant=3) - hilum_x = [] - ox = octant1.get_parameters() - box_count1 = octant1.get_box_counts()[0] - for n1 in range(elementCounts[0] + 1): - mirror_x = n1 < half_counts[0] - o1 = abs(n1 - half_counts[0]) - parameters = ox[0][0][o1] - obox = o1 <= box_count1 - parameters = [ - copy.copy(parameters[0]), - copy.copy(parameters[3 if obox else 2]), - [-d for d in parameters[2 if obox else 1]], - copy.copy(parameters[1 if obox else 3]) - ] - if mirror_x: - for i in range(3): - parameters[i][0] = -parameters[i][0] - hilum_x.append(parameters) - - octant2 = upper_ellipsoid.build_octant(half_counts, 0.0, pi__3, normal_face_factor=normal_face_factor) - upper_ellipsoid.merge_octant(octant2, quadrant=0) - octant3 = upper_ellipsoid.build_octant(half_counts, pi__3, 2.0 * pi__3, normal_face_factor=normal_face_factor) - upper_ellipsoid.merge_octant(octant3, quadrant=1) - upper_ellipsoid.copy_to_negative_axis1() - - lower_lobe_extension = 0.6 * halfHeight / math.cos(math.pi / 6.0) - lower_lobe_extension_elements_count = 2 - octant4 = lower_ellipsoid.build_octant(half_counts, 2.0 * pi__3, math.pi, - lower_lobe_extension, lower_lobe_extension_elements_count, - normal_face_factor=normal_face_factor) - # merge into separate lower ellipsoid to have space for extension elements - lower_ellipsoid_mesh = EllipsoidMesh( - halfDepth, halfBreadth, halfHeight, - [elementCounts[0], elementCounts[1], elementCounts[2] + 2 * lower_lobe_extension_elements_count], - elementsCountTransition) - lower_ellipsoid_mesh.set_surface_d3_mode(EllipsoidSurfaceD3Mode.SURFACE_NORMAL_PLANE_PROJECTION) - lower_ellipsoid_mesh.merge_octant(octant4, quadrant=1) - lower_ellipsoid_mesh.copy_to_negative_axis1() - - node_layout_manager = lower_ellipsoid.get_node_layout_manager() - node_layout_permuted = node_layout_manager.getNodeLayoutRegularPermuted(d3Defined=True) - for n1 in range(elementCounts[0] + 1): - lower_ellipsoid_mesh.set_node_parameters( - n1, half_counts[1], elementCounts[2] + 2 * lower_lobe_extension_elements_count - half_counts[2], hilum_x[n1], - node_layout=node_layout_permuted) - nodeIdentifier, elementIdentifier = lower_ellipsoid_mesh.generate_mesh( - fieldmodule, coordinates, nodeIdentifier, elementIdentifier) - - node_layout_manager = upper_ellipsoid.get_node_layout_manager() - node_layout_6way = node_layout_manager.getNodeLayout6Way12(d3Defined=True) - for n1 in range(elementCounts[0] + 1): - nid = lower_ellipsoid_mesh.get_node_identifier( - n1, half_counts[1], elementCounts[2] + 2 * lower_lobe_extension_elements_count - half_counts[2]) if (n1 >= half_counts[0]) else None - upper_ellipsoid.set_node_parameters(n1, half_counts[1], half_counts[2], hilum_x[n1], - nid, node_layout=node_layout_6way) - nodeIdentifier, elementIdentifier = upper_ellipsoid.generate_mesh( - fieldmodule, coordinates, nodeIdentifier, elementIdentifier) - - return annotationGroups, None + for lung in lungs: + if lung == left_lung: + lower_octant_group_lists = [] + middle_octant_group_lists = None + upper_octant_group_lists = [] + for octant in range(8): + octant_group_list = [group.getGroup() for group in [lungGroup, leftLungGroup, lowerLeftLungGroup] + + [leftMedialLungGroup if (octant & 1) else leftLateralLungGroup]] + lower_octant_group_lists.append(octant_group_list) + octant_group_list = [group.getGroup() for group in [lungGroup, leftLungGroup, upperLeftLungGroup] + + [leftMedialLungGroup if (octant & 1) else leftLateralLungGroup]] + upper_octant_group_lists.append(octant_group_list) + else: + lower_octant_group_lists = [] + middle_octant_group_lists = [] + upper_octant_group_lists = [] + for octant in range(8): + octant_group_list = [group.getGroup() for group in [lungGroup, rightLungGroup, lowerRightLungGroup] + + [rightLateralLungGroup if (octant & 1) else rightMedialLungGroup]] + lower_octant_group_lists.append(octant_group_list) + octant_group_list = [group.getGroup() for group in [lungGroup, rightLungGroup, middleRightLungGroup] + + [rightLateralLungGroup if (octant & 1) else rightMedialLungGroup]] + middle_octant_group_lists.append(octant_group_list) + octant_group_list = [group.getGroup() for group in [lungGroup, rightLungGroup, upperRightLungGroup] + + [rightLateralLungGroup if (octant & 1) else rightMedialLungGroup]] + upper_octant_group_lists.append(octant_group_list) + + elementCounts = [elements_count_lateral, elements_count_oblique, elements_count_oblique] + lower_ellipsoid = EllipsoidMesh(halfDepth, halfBreadth, halfHeight, elementCounts, elements_count_transition) + lower_ellipsoid.set_surface_d3_mode(EllipsoidSurfaceD3Mode.SURFACE_NORMAL_PLANE_PROJECTION) + upper_ellipsoid = EllipsoidMesh(halfDepth, halfBreadth, halfHeight, elementCounts, elements_count_transition) + upper_ellipsoid.set_surface_d3_mode(EllipsoidSurfaceD3Mode.SURFACE_NORMAL_PLANE_PROJECTION) + if lung == right_lung: + middle_ellipsoid = EllipsoidMesh(halfDepth, halfBreadth, halfHeight, elementCounts, elements_count_transition) + middle_ellipsoid.set_surface_d3_mode(EllipsoidSurfaceD3Mode.SURFACE_NORMAL_PLANE_PROJECTION) + else: + middle_ellipsoid = upper_ellipsoid + pi__3 = math.pi / 3.0 + normal_face_factor = 1.0 + half_counts = [count // 2 for count in elementCounts] + octant1 = middle_ellipsoid.build_octant(half_counts, -pi__3, 0.0, normal_face_factor=normal_face_factor) + middle_ellipsoid.merge_octant(octant1, quadrant=3) + if lung == right_lung: + middle_ellipsoid.copy_to_negative_axis1() + + # save hilum coordinates for all other lobes + hilum_x = [] + ox = octant1.get_parameters() + box_count1 = octant1.get_box_counts()[0] + for n1 in range(elementCounts[0] + 1): + mirror_x = n1 < half_counts[0] + o1 = abs(n1 - half_counts[0]) + parameters = ox[0][0][o1] + obox = o1 <= box_count1 + parameters = [ + copy.copy(parameters[0]), + copy.copy(parameters[3 if obox else 2]), + [-d for d in parameters[2 if obox else 1]], + copy.copy(parameters[1 if obox else 3]) + ] + if mirror_x: + for i in range(3): + parameters[i][0] = -parameters[i][0] + hilum_x.append(parameters) + + octant2 = upper_ellipsoid.build_octant(half_counts, 0.0, pi__3, normal_face_factor=normal_face_factor) + upper_ellipsoid.merge_octant(octant2, quadrant=0) + octant3 = upper_ellipsoid.build_octant(half_counts, pi__3, 2.0 * pi__3, normal_face_factor=normal_face_factor) + upper_ellipsoid.merge_octant(octant3, quadrant=1) + upper_ellipsoid.copy_to_negative_axis1() + + lower_lobe_extension = 0.6 * halfHeight / math.cos(math.pi / 6.0) + octant4 = lower_ellipsoid.build_octant(half_counts, 2.0 * pi__3, math.pi, + lower_lobe_extension_height, elements_count_lower_extension, + normal_face_factor=normal_face_factor) + # merge into separate lower ellipsoid to have space for extension elements + lower_ellipsoid_mesh = EllipsoidMesh( + halfDepth, halfBreadth, halfHeight, + [elementCounts[0], elementCounts[1], elementCounts[2] + 2 * elements_count_lower_extension], + elements_count_transition) + lower_ellipsoid_mesh.set_surface_d3_mode(EllipsoidSurfaceD3Mode.SURFACE_NORMAL_PLANE_PROJECTION) + lower_ellipsoid_mesh.merge_octant(octant4, quadrant=1) + lower_ellipsoid_mesh.copy_to_negative_axis1() + + node_layout_manager = lower_ellipsoid.get_node_layout_manager() + node_layout_permuted = node_layout_manager.getNodeLayoutRegularPermuted(d3Defined=True) + for n1 in range(elementCounts[0] + 1): + lower_ellipsoid_mesh.set_node_parameters( + n1, half_counts[1], elementCounts[2] + 2 * elements_count_lower_extension - half_counts[2], hilum_x[n1], + node_layout=node_layout_permuted) + lower_ellipsoid_mesh.set_octant_group_lists(lower_octant_group_lists) + nodeIdentifier, elementIdentifier = lower_ellipsoid_mesh.generate_mesh( + fieldmodule, coordinates, nodeIdentifier, elementIdentifier) + + for ellipsoid in [middle_ellipsoid, upper_ellipsoid] if (lung == right_lung) else [upper_ellipsoid]: + node_layout_manager = ellipsoid.get_node_layout_manager() + node_layout_6way = node_layout_manager.getNodeLayout6Way12(d3Defined=True) + for n1 in range(elementCounts[0] + 1): + nid = lower_ellipsoid_mesh.get_node_identifier( + n1, half_counts[1], elementCounts[2] + 2 * elements_count_lower_extension - half_counts[2]) if (n1 >= half_counts[0]) else None + ellipsoid.set_node_parameters(n1, half_counts[1], half_counts[2], hilum_x[n1], + nid, node_layout=node_layout_6way) + ellipsoid.set_octant_group_lists( + middle_octant_group_lists if ((ellipsoid == middle_ellipsoid) and + (ellipsoid != upper_ellipsoid)) else upper_octant_group_lists) + nodeIdentifier, elementIdentifier = ellipsoid.generate_mesh( + fieldmodule, coordinates, nodeIdentifier, elementIdentifier) for lung in lungs: - oblique_slope_radians = left_oblique_slope_radians if lung == leftLung else right_oblique_slope_radians - axis2_x_rotation_radians = -oblique_slope_radians - axis3_x_rotation_radians = math.radians(90) - oblique_slope_radians + is_left = lung == left_lung + lungNodeset = leftLungNodesetGroup if is_left else rightLungNodesetGroup + spacing = -lung_spacing if is_left else lung_spacing + zOffset = -0.5 * ellipsoid_height + lungMedialCurvature = -medial_curvature if is_left else medial_curvature + rotateLungAngleY = rotateLeftLungY if is_left else -rotateLeftLungY + rotateLungAngleZ = rotateLeftLungZ if is_left else -rotateLeftLungZ + if ventral_sharpness_factor != 0.0: + taperLungEdge(ventral_sharpness_factor, fieldmodule, coordinates, lungNodeset, halfBreadth) + # if base_sharpness_factor != 0.0: + # taperLungEdge(base_sharpness_factor, fieldmodule, coordinates, lungNodeset, halfHeight, isBase=True) + dorsalVentralXi = getDorsalVentralXiField(fieldmodule, coordinates, halfBreadth) + if lungMedialCurvature != 0: + bendLungMeshAroundZAxis(lungMedialCurvature, fieldmodule, coordinates, lungNodeset, + stationaryPointXY=[0.0, 0.0], + bias=medial_curvature_bias, + dorsalVentralXi=dorsalVentralXi) + + # if rotateLungAngleY != 0.0: + # rotateLungMeshAboutAxis(rotateLungAngleY, fieldmodule, coordinates, lungNodeset, axis=2) + # if rotateLungAngleZ != 0.0: + # rotateLungMeshAboutAxis(rotateLungAngleZ, fieldmodule, coordinates, lungNodeset, axis=3) + + translate_nodeset_coordinates(lungNodeset, coordinates, [spacing, 0, -zOffset]) + + return annotationGroups, None + + + for lung in lungs: + oblique_slope_radians = left_oblique_slope_radians if lung == left_lung else right_oblique_slope_radians + axis2_x_rotation_radians = -oblique_slope_radians + axis3_x_rotation_radians = math.radians(90) - oblique_slope_radians - ellipsoid = EllipsoidMesh(halfDepth, halfBreadth, halfHeight, elementCounts, elementsCountTransition, + ellipsoid = EllipsoidMesh(halfDepth, halfBreadth, halfHeight, elementCounts, elements_count_transition, axis2_x_rotation_radians, axis3_x_rotation_radians, surface_only) - if lung == leftLung: + if lung == left_lung: octant_group_lists = [] for octant in range(8): octant_group_list = [] @@ -350,7 +420,7 @@ def generateBaseMesh(cls, region, options): octant_group_list.append(leftLungGroup.getGroup()) octant_group_list.append((leftMedialLungGroup if (octant & 1) else leftLateralLungGroup).getGroup()) octant_group_list.append((leftBaseLungGroup if (octant & 2) else leftPosteriorLungGroup).getGroup()) - if numberOfLeftLung > 1: + if number_of_left_lung_lobes > 1: octant_group_list.append((upperLeftLungGroup if (octant & 4) else lowerLeftLungGroup).getGroup()) octant_group_lists.append(octant_group_list) else: @@ -373,25 +443,25 @@ def generateBaseMesh(cls, region, options): nodeIdentifier, elementIdentifier = ellipsoid.generate_mesh(fieldmodule, coordinates, nodeIdentifier, elementIdentifier) for lung in lungs: - isLeft = True if lung == leftLung else False + isLeft = True if lung == left_lung else False lungNodeset = leftLungNodesetGroup if isLeft else rightLungNodesetGroup - spacing = -lungSpacing if lung == leftLung else lungSpacing + spacing = -lung_spacing if lung == left_lung else lung_spacing zOffset = -0.5 * ellipsoid_height - lungMedialCurvature = -leftLungMedialCurvature if isLeft else leftLungMedialCurvature + lungMedialCurvature = -medial_curvature if isLeft else medial_curvature rotateLungAngleY = rotateLeftLungY if isLeft else -rotateLeftLungY rotateLungAngleZ = rotateLeftLungZ if isLeft else -rotateLeftLungZ - if edgeSharpFactor != 0.0: - taperLungEdge(edgeSharpFactor, fieldmodule, coordinates, lungNodeset, halfBreadth) + if ventral_sharpness_factor != 0.0: + taperLungEdge(ventral_sharpness_factor, fieldmodule, coordinates, lungNodeset, halfBreadth) - if baseSharpFactor != 0.0: - taperLungEdge(baseSharpFactor, fieldmodule, coordinates, lungNodeset, halfHeight, isBase=True) + if base_sharpness_factor != 0.0: + taperLungEdge(base_sharpness_factor, fieldmodule, coordinates, lungNodeset, halfHeight, isBase=True) dorsalVentralXi = getDorsalVentralXiField(fieldmodule, coordinates, halfBreadth) if lungMedialCurvature != 0: bendLungMeshAroundZAxis(lungMedialCurvature, fieldmodule, coordinates, lungNodeset, stationaryPointXY=[0.0, 0.0], - bias=lungMedialCurvatureBias, + bias=medial_curvature_bias, dorsalVentralXi=dorsalVentralXi) if rotateLungAngleY != 0.0: @@ -427,8 +497,8 @@ def defineFaceAnnotations(cls, region, options, annotationGroups): New face annotation groups are appended to this list. """ return - # numberOfLeftLung = options['Number of left lung lobes'] - numberOfLeftLung = 2 + # number_of_left_lung_lobes = options['Number of left lung lobes'] + number_of_left_lung_lobes = 2 fm = region.getFieldmodule() mesh1d = fm.findMeshByDimension(1) @@ -488,7 +558,7 @@ def defineFaceAnnotations(cls, region, options, annotationGroups): lobe = {} lobe_exterior = {} for term in surfaceTerms: - if (numberOfLeftLung == 1) and (term in subLeftLungTerms): + if (number_of_left_lung_lobes == 1) and (term in subLeftLungTerms): continue group = getAnnotationGroupForTerm(annotationGroups, get_lung_term(term)) @@ -529,7 +599,7 @@ def defineFaceAnnotations(cls, region, options, annotationGroups): ] # Base of left lung - if numberOfLeftLung > 1: + if number_of_left_lung_lobes > 1: baseTerms = ['lower lobe of left lung surface', 'upper lobe of left lung surface'] + baseTerms tempGroup = fm.createFieldAnd(lobe_exterior[baseTerms[0]], side_exterior["base left lung"]) @@ -564,7 +634,7 @@ def defineFaceAnnotations(cls, region, options, annotationGroups): # Fissures fissureTerms = ["oblique fissure of right lung", "horizontal fissure of right lung"] - if numberOfLeftLung > 1: + if number_of_left_lung_lobes > 1: fissureTerms.append("oblique fissure of left lung") lobeFissureTerms = [ "oblique fissure of lower lobe of left lung", @@ -576,7 +646,7 @@ def defineFaceAnnotations(cls, region, options, annotationGroups): "horizontal fissure of upper lobe of right lung" ] for fissureTerm in fissureTerms: - if (fissureTerm == "oblique fissure of left lung") and (numberOfLeftLung > 1): + if (fissureTerm == "oblique fissure of left lung") and (number_of_left_lung_lobes > 1): fissureGroup = fm.createFieldAnd(lobe["upper lobe of left lung"], lobe["lower lobe of left lung"]) elif fissureTerm == "oblique fissure of right lung": fissureGroup = fm.createFieldAnd( @@ -603,7 +673,7 @@ def defineFaceAnnotations(cls, region, options, annotationGroups): fissureSurfaceGroup.getMeshGroup(mesh2d).addElementsConditional(fissureGroup) # add fissures to lobe surface groups - if numberOfLeftLung > 1: + if number_of_left_lung_lobes > 1: obliqueFissureOfLeftLungGroup = getAnnotationGroupForTerm( annotationGroups, get_lung_term("oblique fissure of left lung")).getGroup() for lobeSurfaceTerm in ("lower lobe of left lung surface", "upper lobe of left lung surface"): From 90679047e631b28eeab1a0b39fcee7f16c1f3264 Mon Sep 17 00:00:00 2001 From: Richard Christie Date: Fri, 14 Nov 2025 14:04:32 +1300 Subject: [PATCH 06/24] Define face annotations Fix common nodes on medial side --- .../meshtypes/meshtype_3d_lung4.py | 666 ++++++------------ src/scaffoldmaker/utils/ellipsoidmesh.py | 1 - 2 files changed, 208 insertions(+), 459 deletions(-) diff --git a/src/scaffoldmaker/meshtypes/meshtype_3d_lung4.py b/src/scaffoldmaker/meshtypes/meshtype_3d_lung4.py index a9247bbe..9d5018d2 100644 --- a/src/scaffoldmaker/meshtypes/meshtype_3d_lung4.py +++ b/src/scaffoldmaker/meshtypes/meshtype_3d_lung4.py @@ -3,10 +3,11 @@ """ from cmlibs.maths.vectorops import magnitude from cmlibs.utils.zinc.field import find_or_create_field_coordinates +from cmlibs.zinc.element import Element from cmlibs.zinc.field import Field -from scaffoldmaker.annotation.annotationgroup import AnnotationGroup, findOrCreateAnnotationGroupForTerm, \ - getAnnotationGroupForTerm +from scaffoldmaker.annotation.annotationgroup import ( + AnnotationGroup, findAnnotationGroupByName, findOrCreateAnnotationGroupForTerm, getAnnotationGroupForTerm) from scaffoldmaker.annotation.lung_terms import get_lung_term from scaffoldmaker.meshtypes.scaffold_base import Scaffold_base from scaffoldmaker.utils.geometry import getEllipsePointAtTrueAngle @@ -34,9 +35,7 @@ def getParameterSetNames(cls): "Human 1 Coarse", "Human 1 Medium", "Human 1 Fine", - "Ellipsoid Coarse", - "Ellipsoid Medium", - "Ellipsoid Fine" + "Ellipsoid" ] @classmethod @@ -45,22 +44,15 @@ def getDefaultOptions(cls, parameterSetName="Default"): useParameterSetName = "Human 1 Coarse" if (parameterSetName == "Default") else parameterSetName options["Left lung"] = True options["Right lung"] = True - # options["Number of left lung lobes"] = 2 height = options["Ellipsoid height"] = 1.0 - depth = options["Ellipsoid dorsal-ventral size"] = 0.75 - options["Ellipsoid medial-lateral size"] = 0.45 + depth = options["Ellipsoid dorsal-ventral size"] = 0.7 + options["Ellipsoid medial-lateral size"] = 0.4 options["Left-right lung spacing"] = 0.5 - max_extension = magnitude(getEllipsePointAtTrueAngle(depth / 2.0, height / 2.0, math.pi / 3.0)) options["Lower lobe extension"] = 0.3 options["Refine"] = False options["Refine number of elements"] = 4 - if "Coarse" in useParameterSetName: - options["Number of elements lateral"] = 4 - options["Number of elements lower extension"] = 2 - options["Number of elements oblique"] = 6 - options["Number of transition elements"] = 1 - elif "Medium" in useParameterSetName: + if "Medium" in useParameterSetName: options["Number of elements lateral"] = 4 options["Number of elements lower extension"] = 3 options["Number of elements oblique"] = 8 @@ -70,21 +62,20 @@ def getDefaultOptions(cls, parameterSetName="Default"): options["Number of elements lower extension"] = 4 options["Number of elements oblique"] = 12 options["Number of transition elements"] = 1 + else: + options["Number of elements lateral"] = 4 + options["Number of elements lower extension"] = 2 + options["Number of elements oblique"] = 6 + options["Number of transition elements"] = 1 if "Human" in useParameterSetName: - options["Base lateral edge sharpness factor"] = 0.8 options["Ventral edge sharpness factor"] = 0.8 options["Medial curvature"] = 3.0 options["Medial curvature bias"] = 1.0 - options["Dorsal-ventral rotation degrees"] = 20.0 - options["Ventral-medial rotation degrees"] = 0.0 else: - options["Base lateral edge sharpness factor"] = 0.0 options["Ventral edge sharpness factor"] = 0.0 options["Medial curvature"] = 0.0 options["Medial curvature bias"] = 0.0 - options["Dorsal-ventral rotation degrees"] = 0.0 - options["Ventral-medial rotation degrees"] = 0.0 return options @@ -103,12 +94,9 @@ def getOrderedOptionNames(cls): "Ellipsoid medial-lateral size", "Left-right lung spacing", "Lower lobe extension", - "Base lateral edge sharpness factor", "Ventral edge sharpness factor", "Medial curvature", "Medial curvature bias", - "Dorsal-ventral rotation degrees", - "Ventral-medial rotation degrees", "Refine", "Refine number of elements" ] @@ -160,7 +148,6 @@ def checkOptions(cls, options): options["Left-right lung spacing"] = 0.0 for dimension in [ - "Base lateral edge sharpness factor", "Ventral edge sharpness factor", "Medial curvature bias" ]: @@ -169,15 +156,6 @@ def checkOptions(cls, options): elif options[dimension] > 1.0: options[dimension] = 1.0 - for angle in [ - "Dorsal-ventral rotation degrees", - "Ventral-medial rotation degrees" - ]: - if options[angle] < -90.0: - options[angle] = -90.0 - elif options[angle] > 90.0: - options[angle] = 90.0 - if options['Refine number of elements'] < 1: options['Refine number of elements'] = 1 @@ -193,8 +171,6 @@ def generateBaseMesh(cls, region, options): """ is_left_lung = options["Left lung"] is_right_lung = options["Right lung"] - # number_of_left_lung_lobes = options["Number of left lung lobes"] - number_of_left_lung_lobes = 2 # This option is hidden until rodent lung scaffold is added. elements_count_lateral = options["Number of elements lateral"] elements_count_lower_extension = options["Number of elements lower extension"] @@ -202,15 +178,12 @@ def generateBaseMesh(cls, region, options): elements_count_transition = options["Number of transition elements"] lung_spacing = options["Left-right lung spacing"] * 0.5 lower_lobe_extension_height = options["Lower lobe extension"] - base_sharpness_factor = options["Base lateral edge sharpness factor"] ventral_sharpness_factor = options["Ventral edge sharpness factor"] ellipsoid_height = options["Ellipsoid height"] ellipsoid_breadth = options["Ellipsoid dorsal-ventral size"] ellipsoid_depth = options["Ellipsoid medial-lateral size"] medial_curvature = options["Medial curvature"] medial_curvature_bias = options["Medial curvature bias"] - rotateLeftLungY = options["Dorsal-ventral rotation degrees"] - rotateLeftLungZ = options["Ventral-medial rotation degrees"] fieldmodule = region.getFieldmodule() coordinates = find_or_create_field_coordinates(fieldmodule) @@ -222,32 +195,31 @@ def generateBaseMesh(cls, region, options): leftLungGroup = AnnotationGroup(region, get_lung_term("left lung")) rightLungGroup = AnnotationGroup(region, get_lung_term("right lung")) - leftLateralLungGroup = AnnotationGroup(region, ["lateral left lung", ""]) - rightLateralLungGroup = AnnotationGroup(region, ["lateral right lung", ""]) + leftLateralLungGroup = AnnotationGroup(region, ("lateral left lung", "")) + rightLateralLungGroup = AnnotationGroup(region, ("lateral right lung", "")) - leftMedialLungGroup = AnnotationGroup(region, ["medial left lung", ""]) - rightMedialLungGroup = AnnotationGroup(region, ["medial right lung", ""]) + leftMedialLungGroup = AnnotationGroup(region, ("medial left lung", "")) + rightMedialLungGroup = AnnotationGroup(region, ("medial right lung", "")) - leftPosteriorLungGroup = AnnotationGroup(region, ("posterior left lung", "")) - rightPosteriorLungGroup = AnnotationGroup(region, ("posterior right lung", "")) + leftAnteriorLungGroup = AnnotationGroup(region, ("anterior left lung", "")) + rightAnteriorLungGroup = AnnotationGroup(region, ("anterior right lung", "")) lowerRightLungGroup = AnnotationGroup(region, get_lung_term("lower lobe of right lung")) upperRightLungGroup = AnnotationGroup(region, get_lung_term("upper lobe of right lung")) middleRightLungGroup = AnnotationGroup(region, get_lung_term("middle lobe of right lung")) - leftBaseLungGroup = AnnotationGroup(region, ["base left lung", ""]) - rightBaseLungGroup = AnnotationGroup(region, ["base right lung", ""]) - - annotationGroups = [lungGroup, leftLungGroup, rightLungGroup, - leftLateralLungGroup, leftMedialLungGroup, leftBaseLungGroup, leftPosteriorLungGroup, - rightLateralLungGroup, rightMedialLungGroup, rightBaseLungGroup, rightPosteriorLungGroup, + annotation_groups = [lungGroup, leftLungGroup, rightLungGroup, + leftAnteriorLungGroup, leftLateralLungGroup, leftMedialLungGroup, + rightAnteriorLungGroup, rightLateralLungGroup, rightMedialLungGroup, lowerRightLungGroup, middleRightLungGroup, upperRightLungGroup] - if number_of_left_lung_lobes == 2: - lowerLeftLungGroup = AnnotationGroup(region, get_lung_term("lower lobe of left lung")) - upperLeftLungGroup = AnnotationGroup(region, get_lung_term("upper lobe of left lung")) + lowerLeftLungGroup = AnnotationGroup(region, get_lung_term("lower lobe of left lung")) + upperLeftLungGroup = AnnotationGroup(region, get_lung_term("upper lobe of left lung")) + annotation_groups += [lowerLeftLungGroup, upperLeftLungGroup] - annotationGroups += [lowerLeftLungGroup, upperLeftLungGroup] + box_group = AnnotationGroup(region, ("box", "")) + transition_group = AnnotationGroup(region, ("transition", "")) + annotation_groups += [box_group, transition_group] # Nodeset group leftLungNodesetGroup = leftLungGroup.getNodesetGroup(nodes) @@ -256,7 +228,6 @@ def generateBaseMesh(cls, region, options): halfDepth = ellipsoid_depth * 0.5 halfBreadth = ellipsoid_breadth * 0.5 halfHeight = ellipsoid_height * 0.5 - surface_only = False left_lung, right_lung = 0, 1 lungs = [lung for show, lung in [(is_left_lung, left_lung), (is_right_lung, right_lung)] if show] @@ -274,6 +245,8 @@ def generateBaseMesh(cls, region, options): lower_octant_group_lists.append(octant_group_list) octant_group_list = [group.getGroup() for group in [lungGroup, leftLungGroup, upperLeftLungGroup] + [leftMedialLungGroup if (octant & 1) else leftLateralLungGroup]] + if octant & 2: + octant_group_list.append(leftAnteriorLungGroup.getGroup()) upper_octant_group_lists.append(octant_group_list) else: lower_octant_group_lists = [] @@ -285,19 +258,26 @@ def generateBaseMesh(cls, region, options): lower_octant_group_lists.append(octant_group_list) octant_group_list = [group.getGroup() for group in [lungGroup, rightLungGroup, middleRightLungGroup] + [rightLateralLungGroup if (octant & 1) else rightMedialLungGroup]] + if octant & 2: + octant_group_list.append(rightAnteriorLungGroup.getGroup()) middle_octant_group_lists.append(octant_group_list) octant_group_list = [group.getGroup() for group in [lungGroup, rightLungGroup, upperRightLungGroup] + [rightLateralLungGroup if (octant & 1) else rightMedialLungGroup]] + if octant & 2: + octant_group_list.append(rightAnteriorLungGroup.getGroup()) upper_octant_group_lists.append(octant_group_list) elementCounts = [elements_count_lateral, elements_count_oblique, elements_count_oblique] lower_ellipsoid = EllipsoidMesh(halfDepth, halfBreadth, halfHeight, elementCounts, elements_count_transition) lower_ellipsoid.set_surface_d3_mode(EllipsoidSurfaceD3Mode.SURFACE_NORMAL_PLANE_PROJECTION) + lower_ellipsoid.set_box_transition_groups(box_group.getGroup(), transition_group.getGroup()) upper_ellipsoid = EllipsoidMesh(halfDepth, halfBreadth, halfHeight, elementCounts, elements_count_transition) upper_ellipsoid.set_surface_d3_mode(EllipsoidSurfaceD3Mode.SURFACE_NORMAL_PLANE_PROJECTION) + upper_ellipsoid.set_box_transition_groups(box_group.getGroup(), transition_group.getGroup()) if lung == right_lung: middle_ellipsoid = EllipsoidMesh(halfDepth, halfBreadth, halfHeight, elementCounts, elements_count_transition) middle_ellipsoid.set_surface_d3_mode(EllipsoidSurfaceD3Mode.SURFACE_NORMAL_PLANE_PROJECTION) + middle_ellipsoid.set_box_transition_groups(box_group.getGroup(), transition_group.getGroup()) else: middle_ellipsoid = upper_ellipsoid pi__3 = math.pi / 3.0 @@ -344,6 +324,7 @@ def generateBaseMesh(cls, region, options): [elementCounts[0], elementCounts[1], elementCounts[2] + 2 * elements_count_lower_extension], elements_count_transition) lower_ellipsoid_mesh.set_surface_d3_mode(EllipsoidSurfaceD3Mode.SURFACE_NORMAL_PLANE_PROJECTION) + lower_ellipsoid_mesh.set_box_transition_groups(box_group.getGroup(), transition_group.getGroup()) lower_ellipsoid_mesh.merge_octant(octant4, quadrant=1) lower_ellipsoid_mesh.copy_to_negative_axis1() @@ -361,8 +342,10 @@ def generateBaseMesh(cls, region, options): node_layout_manager = ellipsoid.get_node_layout_manager() node_layout_6way = node_layout_manager.getNodeLayout6Way12(d3Defined=True) for n1 in range(elementCounts[0] + 1): - nid = lower_ellipsoid_mesh.get_node_identifier( - n1, half_counts[1], elementCounts[2] + 2 * elements_count_lower_extension - half_counts[2]) if (n1 >= half_counts[0]) else None + nid = (lower_ellipsoid_mesh.get_node_identifier( + n1, half_counts[1], elementCounts[2] + 2 * elements_count_lower_extension - half_counts[2]) + if (((lung == left_lung) and (n1 >= half_counts[0])) or + ((lung == right_lung) and (n1 <= half_counts[0]))) else None) ellipsoid.set_node_parameters(n1, half_counts[1], half_counts[2], hilum_x[n1], nid, node_layout=node_layout_6way) ellipsoid.set_octant_group_lists( @@ -377,86 +360,10 @@ def generateBaseMesh(cls, region, options): spacing = -lung_spacing if is_left else lung_spacing zOffset = -0.5 * ellipsoid_height lungMedialCurvature = -medial_curvature if is_left else medial_curvature - rotateLungAngleY = rotateLeftLungY if is_left else -rotateLeftLungY - rotateLungAngleZ = rotateLeftLungZ if is_left else -rotateLeftLungZ - - if ventral_sharpness_factor != 0.0: - taperLungEdge(ventral_sharpness_factor, fieldmodule, coordinates, lungNodeset, halfBreadth) - - # if base_sharpness_factor != 0.0: - # taperLungEdge(base_sharpness_factor, fieldmodule, coordinates, lungNodeset, halfHeight, isBase=True) - - dorsalVentralXi = getDorsalVentralXiField(fieldmodule, coordinates, halfBreadth) - if lungMedialCurvature != 0: - bendLungMeshAroundZAxis(lungMedialCurvature, fieldmodule, coordinates, lungNodeset, - stationaryPointXY=[0.0, 0.0], - bias=medial_curvature_bias, - dorsalVentralXi=dorsalVentralXi) - - # if rotateLungAngleY != 0.0: - # rotateLungMeshAboutAxis(rotateLungAngleY, fieldmodule, coordinates, lungNodeset, axis=2) - - # if rotateLungAngleZ != 0.0: - # rotateLungMeshAboutAxis(rotateLungAngleZ, fieldmodule, coordinates, lungNodeset, axis=3) - - translate_nodeset_coordinates(lungNodeset, coordinates, [spacing, 0, -zOffset]) - - return annotationGroups, None - - - for lung in lungs: - oblique_slope_radians = left_oblique_slope_radians if lung == left_lung else right_oblique_slope_radians - axis2_x_rotation_radians = -oblique_slope_radians - axis3_x_rotation_radians = math.radians(90) - oblique_slope_radians - - ellipsoid = EllipsoidMesh(halfDepth, halfBreadth, halfHeight, elementCounts, elements_count_transition, - axis2_x_rotation_radians, axis3_x_rotation_radians, surface_only) - - if lung == left_lung: - octant_group_lists = [] - for octant in range(8): - octant_group_list = [] - octant_group_list.append(lungGroup.getGroup()) - octant_group_list.append(leftLungGroup.getGroup()) - octant_group_list.append((leftMedialLungGroup if (octant & 1) else leftLateralLungGroup).getGroup()) - octant_group_list.append((leftBaseLungGroup if (octant & 2) else leftPosteriorLungGroup).getGroup()) - if number_of_left_lung_lobes > 1: - octant_group_list.append((upperLeftLungGroup if (octant & 4) else lowerLeftLungGroup).getGroup()) - octant_group_lists.append(octant_group_list) - else: - octant_group_lists = [] - for octant in range(8): - octant_group_list = [] - octant_group_list.append(lungGroup.getGroup()) - octant_group_list.append(rightLungGroup.getGroup()) - octant_group_list.append((rightLateralLungGroup if (octant & 1) else rightMedialLungGroup).getGroup()) - octant_group_list.append((rightBaseLungGroup if (octant & 2) else rightPosteriorLungGroup).getGroup()) - if octant & 4: - octant_group_list.append((middleRightLungGroup if (octant & 2) else upperRightLungGroup).getGroup()) - else: - octant_group_list.append(lowerRightLungGroup.getGroup()) - octant_group_lists.append(octant_group_list) - - ellipsoid.set_octant_group_lists(octant_group_lists) - - ellipsoid.build() - nodeIdentifier, elementIdentifier = ellipsoid.generate_mesh(fieldmodule, coordinates, nodeIdentifier, elementIdentifier) - - for lung in lungs: - isLeft = True if lung == left_lung else False - lungNodeset = leftLungNodesetGroup if isLeft else rightLungNodesetGroup - spacing = -lung_spacing if lung == left_lung else lung_spacing - zOffset = -0.5 * ellipsoid_height - lungMedialCurvature = -medial_curvature if isLeft else medial_curvature - rotateLungAngleY = rotateLeftLungY if isLeft else -rotateLeftLungY - rotateLungAngleZ = rotateLeftLungZ if isLeft else -rotateLeftLungZ if ventral_sharpness_factor != 0.0: taperLungEdge(ventral_sharpness_factor, fieldmodule, coordinates, lungNodeset, halfBreadth) - if base_sharpness_factor != 0.0: - taperLungEdge(base_sharpness_factor, fieldmodule, coordinates, lungNodeset, halfHeight, isBase=True) - dorsalVentralXi = getDorsalVentralXiField(fieldmodule, coordinates, halfBreadth) if lungMedialCurvature != 0: bendLungMeshAroundZAxis(lungMedialCurvature, fieldmodule, coordinates, lungNodeset, @@ -464,15 +371,9 @@ def generateBaseMesh(cls, region, options): bias=medial_curvature_bias, dorsalVentralXi=dorsalVentralXi) - if rotateLungAngleY != 0.0: - rotateLungMeshAboutAxis(rotateLungAngleY, fieldmodule, coordinates, lungNodeset, axis=2) - - if rotateLungAngleZ != 0.0: - rotateLungMeshAboutAxis(rotateLungAngleZ, fieldmodule, coordinates, lungNodeset, axis=3) - translate_nodeset_coordinates(lungNodeset, coordinates, [spacing, 0, -zOffset]) - return annotationGroups, None + return annotation_groups, None @classmethod @@ -487,337 +388,186 @@ def refineMesh(cls, meshRefinement, options): meshRefinement.refineAllElementsCubeStandard3d(refineElementsCount, refineElementsCount, refineElementsCount) @classmethod - def defineFaceAnnotations(cls, region, options, annotationGroups): + def defineFaceAnnotations(cls, region, options, annotation_groups): """ Add face annotation groups from the highest dimension mesh. Must have defined faces and added subelements for highest dimension groups. :param region: Zinc region containing model. :param options: Dict containing options. See getDefaultOptions(). - :param annotationGroups: List of annotation groups for top-level elements. + :param annotation_groups: List of annotation groups for top-level elements. New face annotation groups are appended to this list. """ - return - # number_of_left_lung_lobes = options['Number of left lung lobes'] - number_of_left_lung_lobes = 2 - fm = region.getFieldmodule() mesh1d = fm.findMeshByDimension(1) mesh2d = fm.findMeshByDimension(2) - # 1D Annotation is_exterior = fm.createFieldIsExterior() + is_face_xi1_0 = fm.createFieldIsOnFace(Element.FACE_TYPE_XI1_0) + is_face_xi1_1 = fm.createFieldIsOnFace(Element.FACE_TYPE_XI1_1) + is_face_xi2_0 = fm.createFieldIsOnFace(Element.FACE_TYPE_XI2_0) + is_face_xi2_1 = fm.createFieldIsOnFace(Element.FACE_TYPE_XI2_1) + is_face_xi3_0 = fm.createFieldIsOnFace(Element.FACE_TYPE_XI3_0) + is_face_xi3_1 = fm.createFieldIsOnFace(Element.FACE_TYPE_XI3_1) + is_exterior_face_xi3_1 = fm.createFieldAnd(is_exterior, is_face_xi3_1) + + box_group = findAnnotationGroupByName(annotation_groups, "box") + is_box = box_group.getGroup() + transition_group = findAnnotationGroupByName(annotation_groups, "transition") + is_trans = transition_group.getGroup() + is_on_ellipsoid = fm.createFieldAnd(fm.createFieldAnd(is_exterior_face_xi3_1, is_trans), + fm.createFieldNot(is_box)) + + is_face_box20_trans10 = fm.createFieldOr( + fm.createFieldAnd(is_box, is_face_xi2_0), fm.createFieldAnd(is_trans, is_face_xi1_0)) + is_face_xi1_0_or_xi1_1_or_xi2_0 = fm.createFieldOr( + fm.createFieldOr(is_face_xi1_0, is_face_xi1_1), is_face_xi2_0) + is_face_xi1_0_or_xi1_1_or_xi2_1 = fm.createFieldOr( + fm.createFieldOr(is_face_xi1_0, is_face_xi1_1), is_face_xi2_1) + is_face_box21_trans11 = fm.createFieldOr( + fm.createFieldAnd(is_box, is_face_xi2_1), fm.createFieldAnd(is_trans, is_face_xi1_1)) + is_face_box30_trans20 = fm.createFieldOr( + fm.createFieldAnd(is_box, is_face_xi3_0), fm.createFieldAnd(is_trans, is_face_xi2_0)) + is_face_box31_trans21 = fm.createFieldOr( + fm.createFieldAnd(is_box, is_face_xi3_1), fm.createFieldAnd(is_trans, is_face_xi2_1)) + + is_lower_left = getAnnotationGroupForTerm(annotation_groups, get_lung_term("lower lobe of left lung")).getGroup() + is_upper_left = getAnnotationGroupForTerm(annotation_groups, get_lung_term("upper lobe of left lung")).getGroup() + is_anterior_left = findAnnotationGroupByName(annotation_groups, "anterior left lung").getGroup() + is_lateral_left = findAnnotationGroupByName(annotation_groups, "lateral left lung").getGroup() + is_medial_left = findAnnotationGroupByName(annotation_groups, "medial left lung").getGroup() + + is_lower_right = getAnnotationGroupForTerm(annotation_groups, get_lung_term("lower lobe of right lung")).getGroup() + is_middle_right = getAnnotationGroupForTerm(annotation_groups, get_lung_term("middle lobe of right lung")).getGroup() + is_upper_right = getAnnotationGroupForTerm(annotation_groups, get_lung_term("upper lobe of right lung")).getGroup() + is_anterior_right = findAnnotationGroupByName(annotation_groups, "anterior right lung").getGroup() + is_lateral_right = findAnnotationGroupByName(annotation_groups, "lateral right lung").getGroup() + is_medial_right = findAnnotationGroupByName(annotation_groups, "medial right lung").getGroup() + + face_term_conditionals_map = { + "base of lower lobe of left lung surface": (is_lower_left, is_exterior, is_face_box30_trans20), + "base of lower lobe of right lung surface": (is_lower_right, is_exterior, is_face_box30_trans20), + "base of middle lobe of right lung surface": (is_middle_right, is_exterior, is_face_xi1_0_or_xi1_1_or_xi2_0), + "base of upper lobe of left lung surface": (is_upper_left, is_exterior, is_face_xi1_0_or_xi1_1_or_xi2_0, is_anterior_left), + "horizontal fissure of middle lobe of right lung": (is_middle_right, is_exterior, is_face_box31_trans21), + "horizontal fissure of upper lobe of right lung": (is_upper_right, is_exterior, is_face_box30_trans20, is_anterior_right), + "lateral surface of left lung": (is_lateral_left, is_on_ellipsoid), + "lateral surface of lower lobe of left lung": (is_lower_left, is_on_ellipsoid, is_lateral_left), + "lateral surface of lower lobe of right lung": (is_lower_right, is_on_ellipsoid, is_lateral_right), + "lateral surface of middle lobe of right lung": (is_middle_right, is_on_ellipsoid, is_lateral_right), + "lateral surface of right lung": (is_lateral_right, is_on_ellipsoid), + "lateral surface of upper lobe of left lung": (is_upper_left, is_on_ellipsoid, is_lateral_left), + "lateral surface of upper lobe of right lung": (is_upper_right, is_on_ellipsoid, is_lateral_right), + "lower lobe of left lung surface": (is_lower_left, is_exterior), + "lower lobe of right lung surface": (is_lower_right, is_exterior), + "middle lobe of right lung surface": (is_middle_right, is_exterior), + "upper lobe of left lung surface": (is_upper_left, is_exterior), + "upper lobe of right lung surface": (is_upper_right, is_exterior), + "medial surface of left lung": (is_medial_left, is_on_ellipsoid), + "medial surface of lower lobe of left lung": (is_lower_left, is_on_ellipsoid, is_medial_left), + "medial surface of lower lobe of right lung": (is_lower_right, is_on_ellipsoid, is_medial_right), + "medial surface of middle lobe of right lung": (is_middle_right, is_on_ellipsoid, is_medial_right), + "medial surface of right lung": (is_medial_right, is_on_ellipsoid), + "medial surface of upper lobe of left lung": (is_upper_left, is_on_ellipsoid, is_medial_left), + "medial surface of upper lobe of right lung": (is_upper_right, is_on_ellipsoid, is_medial_right), + "oblique fissure of lower lobe of left lung": (is_lower_left, is_exterior, is_face_xi1_0_or_xi1_1_or_xi2_1), + "oblique fissure of lower lobe of right lung": (is_lower_right, is_exterior, is_face_xi1_0_or_xi1_1_or_xi2_1), + "oblique fissure of middle lobe of right lung": (is_middle_right, is_exterior, is_face_xi1_0_or_xi1_1_or_xi2_0), + "oblique fissure of upper lobe of left lung": (is_upper_left, is_exterior, fm.createFieldOr(is_face_xi1_0_or_xi1_1_or_xi2_0, is_face_box30_trans20)), + "oblique fissure of upper lobe of right lung": (is_upper_right, is_exterior, fm.createFieldAnd(is_face_box30_trans20, fm.createFieldNot(is_anterior_right))), + } + is_face_conditional = {} + for face_term, conditionals in face_term_conditionals_map.items(): + annotation_group = findOrCreateAnnotationGroupForTerm(annotation_groups, region, get_lung_term(face_term)) + group = annotation_group.getGroup() + conditional = conditionals[0] + for add_conditional in conditionals[1:]: + conditional = fm.createFieldAnd(conditional, add_conditional) + annotation_group.getMeshGroup(mesh2d).addElementsConditional(conditional) + print(conditional.isValid(), "Term:", face_term, "sizes", mesh2d.getSize(), group.getMeshGroup(mesh2d).isValid(), group.getMeshGroup(mesh2d).getSize(), group.getMeshGroup(mesh1d).getSize()) + annotation_group.addSubelements() + is_face_conditional[face_term] = group + + line_term_conditionals_map = { + "anterior edge of middle lobe of right lung": + (is_middle_right, is_on_ellipsoid, is_medial_right, is_lateral_right), + "antero-posterior edge of upper lobe of left lung": + (is_upper_left, is_on_ellipsoid, is_medial_left, is_lateral_left), + "antero-posterior edge of upper lobe of right lung": + (is_upper_right, is_on_ellipsoid, is_medial_right, is_lateral_right), + "base edge of oblique fissure of lower lobe of left lung": ( + is_face_conditional["base of lower lobe of left lung surface"], is_face_conditional["oblique fissure of lower lobe of left lung"]), + "base edge of oblique fissure of lower lobe of right lung": ( + is_face_conditional["base of lower lobe of right lung surface"], is_face_conditional["oblique fissure of lower lobe of right lung"]), + "lateral edge of base of lower lobe of left lung": ( + is_face_conditional["base of lower lobe of left lung surface"], is_lateral_left, is_on_ellipsoid), + "lateral edge of base of lower lobe of right lung": ( + is_face_conditional["base of lower lobe of right lung surface"], is_lateral_right, is_on_ellipsoid), + "lateral edge of base of middle lobe of right lung": ( + is_face_conditional["base of middle lobe of right lung surface"], is_lateral_right, is_on_ellipsoid), + "lateral edge of base of upper lobe of left lung": ( + is_face_conditional["base of upper lobe of left lung surface"], is_lateral_left, is_on_ellipsoid), + "lateral edge of horizontal fissure of middle lobe of right lung": ( + is_face_conditional["horizontal fissure of middle lobe of right lung"], is_lateral_right, is_on_ellipsoid), + "lateral edge of horizontal fissure of upper lobe of right lung": ( + is_face_conditional["horizontal fissure of upper lobe of right lung"], is_lateral_right, is_on_ellipsoid), + "lateral edge of oblique fissure of lower lobe of left lung": ( + is_face_conditional["oblique fissure of lower lobe of left lung"], is_lateral_left, is_on_ellipsoid), + "lateral edge of oblique fissure of lower lobe of right lung": ( + is_face_conditional["oblique fissure of lower lobe of right lung"], is_lateral_right, is_on_ellipsoid), + "lateral edge of oblique fissure of middle lobe of right lung": ( + is_face_conditional["oblique fissure of middle lobe of right lung"], is_lateral_right, is_on_ellipsoid), + "lateral edge of oblique fissure of upper lobe of left lung": ( + is_face_conditional["oblique fissure of upper lobe of left lung"], is_lateral_left, is_on_ellipsoid), + "lateral edge of oblique fissure of upper lobe of right lung": ( + is_face_conditional["oblique fissure of upper lobe of right lung"], is_lateral_right, is_on_ellipsoid), + "medial edge of base of lower lobe of left lung": ( + is_face_conditional["base of lower lobe of left lung surface"], is_medial_left, is_on_ellipsoid), + "medial edge of base of lower lobe of right lung": ( + is_face_conditional["base of lower lobe of right lung surface"], is_medial_right, is_on_ellipsoid), + "medial edge of base of middle lobe of right lung": ( + is_face_conditional["base of middle lobe of right lung surface"], is_medial_right, is_on_ellipsoid), + "medial edge of base of upper lobe of left lung": ( + is_face_conditional["base of upper lobe of left lung surface"], is_medial_left, is_on_ellipsoid), + "medial edge of horizontal fissure of middle lobe of right lung": ( + is_face_conditional["horizontal fissure of middle lobe of right lung"], is_medial_right, is_on_ellipsoid), + "medial edge of horizontal fissure of upper lobe of right lung": ( + is_face_conditional["horizontal fissure of upper lobe of right lung"], is_medial_right, is_on_ellipsoid), + "medial edge of oblique fissure of lower lobe of left lung": ( + is_face_conditional["oblique fissure of lower lobe of left lung"], is_medial_left, is_on_ellipsoid), + "medial edge of oblique fissure of lower lobe of right lung": ( + is_face_conditional["oblique fissure of lower lobe of right lung"], is_medial_right, is_on_ellipsoid), + "medial edge of oblique fissure of middle lobe of right lung": ( + is_face_conditional["oblique fissure of middle lobe of right lung"], is_medial_right, is_on_ellipsoid), + "medial edge of oblique fissure of upper lobe of left lung": ( + is_face_conditional["oblique fissure of upper lobe of left lung"], is_medial_left, is_on_ellipsoid), + "medial edge of oblique fissure of upper lobe of right lung": ( + is_face_conditional["oblique fissure of upper lobe of right lung"], is_medial_right, is_on_ellipsoid), + "posterior edge of lower lobe of left lung": + (is_lower_left, is_lateral_left, is_medial_left, is_on_ellipsoid), + "posterior edge of lower lobe of right lung": + (is_lower_right, is_lateral_right, is_medial_right, is_on_ellipsoid), - # Arbitrary terms - are removed from the annotation groups later - arbLobe_group = {} - arbLobe_exterior = {} - arbLobe_2dgroup = {} - arbTerms = ["upper lobe of left lung", "upper lobe of right lung"] - for arbTerm in arbTerms: - group = findOrCreateAnnotationGroupForTerm(annotationGroups, region, get_lung_term(arbTerm)) - group2d = group.getGroup() - group2d_exterior = fm.createFieldAnd(group2d, is_exterior) - arbLobe_group.update({arbTerm: group}) - arbLobe_2dgroup.update({arbTerm: group2d}) - arbLobe_exterior.update({arbTerm: group2d_exterior}) - - side_group = {} - side_exterior = {} - arbSideTerms = ["lateral left lung", "lateral right lung", - "medial left lung", "medial right lung", - "base left lung", "base right lung"] - for term in arbSideTerms: - group = findOrCreateAnnotationGroupForTerm(annotationGroups, region, [term, ""]) - group2d = group.getGroup() - group2d_exterior = fm.createFieldAnd(group2d, is_exterior) - side_group[term] = group - side_exterior[term] = group2d_exterior - - base_posterior_group = {} - base_posterior_group_exterior = {} - base_posterior_group_terms = ["base left lung", "base right lung", - "posterior left lung", "posterior right lung"] - for term in base_posterior_group_terms: - group = findOrCreateAnnotationGroupForTerm(annotationGroups, region, [term, ""]) - group2d = group.getGroup() - group2d_exterior = fm.createFieldAnd(group2d, is_exterior) - base_posterior_group[term] = group - base_posterior_group_exterior[term] = group2d_exterior - - # Exterior surfaces of lungs - surfaceTerms = [ - "left lung", - "lower lobe of left lung", - "upper lobe of left lung", - "right lung", - "lower lobe of right lung", - "middle lobe of right lung", - "upper lobe of right lung" - ] - subLeftLungTerms = ["lower lobe of left lung", "upper lobe of left lung"] - - lobe = {} - lobe_exterior = {} - for term in surfaceTerms: - if (number_of_left_lung_lobes == 1) and (term in subLeftLungTerms): - continue - - group = getAnnotationGroupForTerm(annotationGroups, get_lung_term(term)) - group2d = group.getGroup() - group2d_exterior = fm.createFieldAnd(group2d, is_exterior) - - surfaceGroup = findOrCreateAnnotationGroupForTerm(annotationGroups, region, get_lung_term(term + " surface")) - surfaceGroup.getMeshGroup(mesh2d).addElementsConditional(group2d_exterior) - - lobe_exterior.update({term + " surface": group2d_exterior}) - - if "lobe of" in term: - lobe.update({term: group2d}) - - # lateral in the subgroup - for sideTerm in ['lateral surface of ', 'medial surface of ']: - surfaceGroup = findOrCreateAnnotationGroupForTerm(annotationGroups, region, get_lung_term(sideTerm + term)) - if ('lateral' in sideTerm) and ('left' in term): - surfaceGroup.getMeshGroup(mesh2d).addElementsConditional( - fm.createFieldAnd(group2d_exterior, side_exterior["lateral left lung"])) - elif ('lateral' in sideTerm) and ('right' in term): - surfaceGroup.getMeshGroup(mesh2d).addElementsConditional( - fm.createFieldAnd(group2d_exterior, side_exterior["lateral right lung"])) - elif ('medial' in sideTerm) and ('left' in term): - surfaceGroup.getMeshGroup(mesh2d).addElementsConditional( - fm.createFieldAnd(group2d_exterior, side_exterior["medial left lung"])) - elif ('medial' in sideTerm) and ('right' in term): - surfaceGroup.getMeshGroup(mesh2d).addElementsConditional( - fm.createFieldAnd(group2d_exterior, side_exterior["medial right lung"])) - - # Base surface of lungs (incl. lobes) - baseGroup = [] - baseTerms = [ - 'left lung surface', - 'lower lobe of right lung surface', - 'middle lobe of right lung surface', - 'right lung surface' - ] - - # Base of left lung - if number_of_left_lung_lobes > 1: - baseTerms = ['lower lobe of left lung surface', 'upper lobe of left lung surface'] + baseTerms - - tempGroup = fm.createFieldAnd(lobe_exterior[baseTerms[0]], side_exterior["base left lung"]) - baseLeftLowerLung = fm.createFieldAnd(tempGroup, side_exterior["medial left lung"]) - baseGroup.append(baseLeftLowerLung) - - tempGroup = fm.createFieldAnd(lobe_exterior[baseTerms[1]], side_exterior["base left lung"]) - baseLeftUpperLung = fm.createFieldAnd(tempGroup, side_exterior["medial left lung"]) - baseGroup.append(baseLeftUpperLung) - - baseLeftLung = fm.createFieldOr(baseLeftLowerLung, baseLeftUpperLung) - baseGroup.append(baseLeftLung) - else: - baseLeftLung = side_exterior["base left lung"] - baseGroup.append(baseLeftLung) - - # Base of right lung - tempGroup = fm.createFieldAnd(lobe_exterior['lower lobe of right lung surface'], side_exterior["base right lung"]) - baseRightLowerLung = fm.createFieldAnd(tempGroup, side_exterior["medial right lung"]) - baseGroup.append(baseRightLowerLung) - - tempGroup = fm.createFieldAnd(lobe_exterior['middle lobe of right lung surface'], side_exterior["base right lung"]) - baseRightMiddleLung = fm.createFieldAnd(tempGroup, side_exterior["medial right lung"]) - baseGroup.append(baseRightMiddleLung) - - baseRightLung = fm.createFieldOr(baseRightLowerLung, baseRightMiddleLung) - baseGroup.append(baseRightLung) - - for term in baseTerms: - baseSurfaceGroup = findOrCreateAnnotationGroupForTerm(annotationGroups, region, get_lung_term("base of " + term)) - baseSurfaceGroup.getMeshGroup(mesh2d).addElementsConditional(baseGroup[baseTerms.index(term)]) - - # Fissures - fissureTerms = ["oblique fissure of right lung", "horizontal fissure of right lung"] - if number_of_left_lung_lobes > 1: - fissureTerms.append("oblique fissure of left lung") - lobeFissureTerms = [ - "oblique fissure of lower lobe of left lung", - "oblique fissure of upper lobe of left lung", - "oblique fissure of lower lobe of right lung", - "oblique fissure of middle lobe of right lung", - "oblique fissure of upper lobe of right lung", - "horizontal fissure of middle lobe of right lung", - "horizontal fissure of upper lobe of right lung" - ] - for fissureTerm in fissureTerms: - if (fissureTerm == "oblique fissure of left lung") and (number_of_left_lung_lobes > 1): - fissureGroup = fm.createFieldAnd(lobe["upper lobe of left lung"], lobe["lower lobe of left lung"]) - elif fissureTerm == "oblique fissure of right lung": - fissureGroup = fm.createFieldAnd( - fm.createFieldOr(lobe["middle lobe of right lung"], lobe["upper lobe of right lung"]), - lobe["lower lobe of right lung"]) - elif fissureTerm == "horizontal fissure of right lung": - fissureGroup = fm.createFieldAnd( - lobe["upper lobe of right lung"], lobe["middle lobe of right lung"]) - - fissureSurfaceGroup = findOrCreateAnnotationGroupForTerm(annotationGroups, region, get_lung_term(fissureTerm)) - fissureSurfaceGroup.getMeshGroup(mesh2d).addElementsConditional(fissureGroup) - fissureGroup_temp = fissureGroup - - for lobeFissureTerm in lobeFissureTerms: - temp_splitTerm = fissureTerm.split("of") - if (temp_splitTerm[0] in lobeFissureTerm) and (temp_splitTerm[1] in lobeFissureTerm): - if "oblique fissure of upper lobe of right lung" in lobeFissureTerm: - fissureGroup = fm.createFieldAnd(fissureGroup_temp, - arbLobe_2dgroup['upper lobe of right lung']) - elif "oblique fissure of middle lobe of right lung" in lobeFissureTerm: - fissureGroup = fm.createFieldAnd(fissureGroup_temp, fm.createFieldNot( - arbLobe_2dgroup['upper lobe of right lung'])) - fissureSurfaceGroup = findOrCreateAnnotationGroupForTerm(annotationGroups, region, get_lung_term(lobeFissureTerm)) - fissureSurfaceGroup.getMeshGroup(mesh2d).addElementsConditional(fissureGroup) - - # add fissures to lobe surface groups - if number_of_left_lung_lobes > 1: - obliqueFissureOfLeftLungGroup = getAnnotationGroupForTerm( - annotationGroups, get_lung_term("oblique fissure of left lung")).getGroup() - for lobeSurfaceTerm in ("lower lobe of left lung surface", "upper lobe of left lung surface"): - lobeSurfaceGroup = getAnnotationGroupForTerm( - annotationGroups, get_lung_term(lobeSurfaceTerm)) - lobeSurfaceGroup.getMeshGroup(mesh2d).addElementsConditional(obliqueFissureOfLeftLungGroup) - horizontalFissureOfRightLungGroup = getAnnotationGroupForTerm( - annotationGroups, get_lung_term("horizontal fissure of right lung")).getGroup() - obliqueFissureOfRightLungGroup = getAnnotationGroupForTerm( - annotationGroups, get_lung_term("oblique fissure of right lung")).getGroup() - obliqueFissureOfMiddleLobeOfRightLungGroup = getAnnotationGroupForTerm( - annotationGroups, get_lung_term("oblique fissure of middle lobe of right lung")).getGroup() - obliqueFissureOfUpperLobeOfRightLungGroup = getAnnotationGroupForTerm( - annotationGroups, get_lung_term("oblique fissure of upper lobe of right lung")).getGroup() - lobeSurfaceGroup = getAnnotationGroupForTerm( - annotationGroups, get_lung_term("lower lobe of right lung surface")) - lobeSurfaceGroup.getMeshGroup(mesh2d).addElementsConditional(obliqueFissureOfRightLungGroup) - lobeSurfaceGroup = getAnnotationGroupForTerm( - annotationGroups, get_lung_term("middle lobe of right lung surface")) - lobeSurfaceGroup.getMeshGroup(mesh2d).addElementsConditional( - fm.createFieldOr(obliqueFissureOfMiddleLobeOfRightLungGroup, horizontalFissureOfRightLungGroup)) - lobeSurfaceGroup = getAnnotationGroupForTerm( - annotationGroups, get_lung_term("upper lobe of right lung surface")) - lobeSurfaceGroup.getMeshGroup(mesh2d).addElementsConditional( - fm.createFieldOr(obliqueFissureOfUpperLobeOfRightLungGroup, horizontalFissureOfRightLungGroup)) - - # 1D edges - edgeTerms = [ - "anterior edge", - "antero-posterior edge", - "base edge", - "lateral edge", - "medial edge", - "posterior edge" - ] - - fissureTerms = [ - "horizontal fissure", - "oblique fissure", - ] - - lobeTerms = [ - "lower lobe", - "middle lobe", - "upper lobe" - ] - - # Define mappings - edge_lobe_map = { - "anterior edge": ["middle"], - "antero-posterior edge": ["upper"], - "posterior edge": ["lower"] } - surface_edge_terms = ["lateral edge", "medial edge", "base edge"] - - for lung in ["left lung", "right lung"]: - surfaces = {} - for surface_type in ["lateral", "medial", "base"]: - term = f"{surface_type} of {lung} surface" if surface_type == "base" else f"{surface_type} surface of {lung}" - group = findOrCreateAnnotationGroupForTerm(annotationGroups, region, get_lung_term(term)) - surfaces[surface_type] = group.getGroup() - - for edgeTerm in edgeTerms: - if edgeTerm in surface_edge_terms: - surface_type = edgeTerm.split()[0] # Extract "lateral", "medial", or "base" - - for fissure in fissureTerms: - if "horizontal" in fissure and ("left" in lung or "base" in edgeTerm): - continue - - fissureTerm = f"{fissure} of {lung}" - fissureGroup = findOrCreateAnnotationGroupForTerm(annotationGroups, region, - get_lung_term(fissureTerm)) - is_fissureGroup = fissureGroup.getGroup() - is_fissureGroup_exterior = fm.createFieldAnd(is_fissureGroup, is_exterior) - is_surfaceFissureGroup = fm.createFieldAnd(is_fissureGroup_exterior, surfaces[surface_type]) - - edgeTermFull = f"{edgeTerm} of oblique fissure" if "base" in edgeTerm else f"{edgeTerm} of {fissureTerm}" - edgeGroup = findOrCreateAnnotationGroupForTerm(annotationGroups, region, [edgeTermFull, ""]) - edgeGroup.getMeshGroup(mesh1d).addElementsConditional(is_surfaceFissureGroup) - - elif edgeTerm in edge_lobe_map: - for lobe in lobeTerms: - if (("middle" in lobe and "left" in lung) or - not any(valid_lobe in lobe for valid_lobe in edge_lobe_map[edgeTerm])): - continue - - lobeTerm = f"{lobe} of {lung}" - lobeGroup = findOrCreateAnnotationGroupForTerm(annotationGroups, region, - get_lung_term(lobeTerm)) - is_lobeGroup = lobeGroup.getGroup() - is_lobeGroup_exterior = fm.createFieldAnd(is_lobeGroup, is_exterior) - - is_edge = fm.createFieldAnd(is_lobeGroup_exterior, - fm.createFieldAnd(surfaces["medial"], surfaces["lateral"])) - - edgeTermFull = f"{edgeTerm} of {lobe} of {lung}" - edgeGroup = findOrCreateAnnotationGroupForTerm(annotationGroups, region, [edgeTermFull, ""]) - edgeGroup.getMeshGroup(mesh1d).addElementsConditional(is_edge) - - # Process lateral edges of the base (separate from main edge loop) - for lobe in lobeTerms: - if ("middle" in lobe and "left" in lung) or ("upper" in lobe and "right" in lung): - continue - - lobeTerm = f"base of {lobe} of {lung} surface" - lobeGroup = findOrCreateAnnotationGroupForTerm(annotationGroups, region, get_lung_term(lobeTerm)) - is_lobeGroup = lobeGroup.getGroup() - is_lobeGroup_exterior = fm.createFieldAnd(is_lobeGroup, is_exterior) - - sideTerm = f"lateral {lung}" - is_edge = fm.createFieldAnd(is_lobeGroup_exterior, side_exterior[sideTerm]) - - edgeTermFull = f"lateral edge of {lobeTerm.replace(' surface', '')}" - edgeGroup = findOrCreateAnnotationGroupForTerm(annotationGroups, region, [edgeTermFull, ""]) - edgeGroup.getMeshGroup(mesh1d).addElementsConditional(is_edge) - - # Medial edge of the base - halfBreadth = options["Ellipsoid dorsal-ventral size"] * -0.5 - coordinates = find_or_create_field_coordinates(fm) - - for lung in ["left lung", "right lung"]: - is_base = base_posterior_group_exterior[f"base {lung}"] - is_posterior = base_posterior_group_exterior[f"posterior {lung}"] - is_medial = side_exterior[f"medial {lung}"] - - slope_key = "Left oblique slope degrees" if "left" in lung else "Right oblique slope degrees" - rotationAngle = math.radians(90 - options[slope_key]) - - is_horizontal_edge = fm.createFieldAnd(fm.createFieldAnd(is_base, is_posterior), is_medial) - is_threshold = setBaseGroupThreshold(fm, coordinates, halfBreadth, rotationAngle) - is_edge = fm.createFieldAnd(is_horizontal_edge, is_threshold) - - edgeTerm = f"medial edge of base of lower lobe of {lung}" - edgeGroup = findOrCreateAnnotationGroupForTerm(annotationGroups, region, [edgeTerm, ""]) - edgeGroup.getMeshGroup(mesh1d).addElementsConditional(is_edge) - - # Remove unnecessary annotations - for group in [*side_group.values()]: - annotationGroups.remove(group) - - for key, group in base_posterior_group.items(): - if "posterior" in key: - annotationGroups.remove(group) + for line_term, conditionals in line_term_conditionals_map.items(): + annotation_group = findOrCreateAnnotationGroupForTerm(annotation_groups, region, (line_term, "")) + conditional = conditionals[0] + for add_conditional in conditionals[1:]: + conditional = fm.createFieldAnd(conditional, add_conditional) + annotation_group.getMeshGroup(mesh1d).addElementsConditional(conditional) + + # remove temporary annotation groups + for group_name in [ + "anterior left lung", + "anterior right lung", + "box", + "lateral left lung", + "lateral right lung", + "medial left lung", + "medial right lung", + "transition" + ]: + annotation_group = findAnnotationGroupByName(annotation_groups, group_name) + annotation_groups.remove(annotation_group) def rotateLungMeshAboutAxis(rotateAngle, fm, coordinates, lungNodesetGroup, axis): diff --git a/src/scaffoldmaker/utils/ellipsoidmesh.py b/src/scaffoldmaker/utils/ellipsoidmesh.py index 4573f983..b939ad2f 100644 --- a/src/scaffoldmaker/utils/ellipsoidmesh.py +++ b/src/scaffoldmaker/utils/ellipsoidmesh.py @@ -600,7 +600,6 @@ def _get_nid_to_node_layout_map_3d(self, node_layout_manager): for n1, n2, n3, node_layout in self._prescribed_node_layouts: nid = self._nids[n3][n2][n1] nid_to_node_layout[nid] = node_layout - print(n1, n2, n3, "node", nid, "layout", node_layout) return nid_to_node_layout def get_node_layout_manager(self): From 1119e2b47bb7f898925ac5d944b13e0299ac83fa Mon Sep 17 00:00:00 2001 From: Richard Christie Date: Mon, 17 Nov 2025 18:14:53 +1300 Subject: [PATCH 07/24] Define only annotations for lung sides being created Ensure right lung has same numbers for nodes/elements if no left lung --- .../meshtypes/meshtype_3d_lung4.py | 357 ++++++++++-------- 1 file changed, 207 insertions(+), 150 deletions(-) diff --git a/src/scaffoldmaker/meshtypes/meshtype_3d_lung4.py b/src/scaffoldmaker/meshtypes/meshtype_3d_lung4.py index 9d5018d2..3eb0b3ef 100644 --- a/src/scaffoldmaker/meshtypes/meshtype_3d_lung4.py +++ b/src/scaffoldmaker/meshtypes/meshtype_3d_lung4.py @@ -45,7 +45,7 @@ def getDefaultOptions(cls, parameterSetName="Default"): options["Left lung"] = True options["Right lung"] = True height = options["Ellipsoid height"] = 1.0 - depth = options["Ellipsoid dorsal-ventral size"] = 0.7 + depth = options["Ellipsoid dorsal-ventral size"] = 0.8 options["Ellipsoid medial-lateral size"] = 0.4 options["Left-right lung spacing"] = 0.5 options["Lower lobe extension"] = 0.3 @@ -169,8 +169,8 @@ def generateBaseMesh(cls, region, options): :param options: Dict containing options. See getDefaultOptions(). :return: list of AnnotationGroup, None """ - is_left_lung = options["Left lung"] - is_right_lung = options["Right lung"] + has_left_lung = options["Left lung"] + has_right_lung = options["Right lung"] elements_count_lateral = options["Number of elements lateral"] elements_count_lower_extension = options["Number of elements lower extension"] @@ -188,66 +188,79 @@ def generateBaseMesh(cls, region, options): fieldmodule = region.getFieldmodule() coordinates = find_or_create_field_coordinates(fieldmodule) nodes = fieldmodule.findNodesetByFieldDomainType(Field.DOMAIN_TYPE_NODES) + mesh = fieldmodule.findMeshByDimension(3) # annotation groups & nodeset groups lungGroup = AnnotationGroup(region, get_lung_term("lung")) - leftLungGroup = AnnotationGroup(region, get_lung_term("left lung")) - rightLungGroup = AnnotationGroup(region, get_lung_term("right lung")) - - leftLateralLungGroup = AnnotationGroup(region, ("lateral left lung", "")) - rightLateralLungGroup = AnnotationGroup(region, ("lateral right lung", "")) - - leftMedialLungGroup = AnnotationGroup(region, ("medial left lung", "")) - rightMedialLungGroup = AnnotationGroup(region, ("medial right lung", "")) - - leftAnteriorLungGroup = AnnotationGroup(region, ("anterior left lung", "")) - rightAnteriorLungGroup = AnnotationGroup(region, ("anterior right lung", "")) - - lowerRightLungGroup = AnnotationGroup(region, get_lung_term("lower lobe of right lung")) - upperRightLungGroup = AnnotationGroup(region, get_lung_term("upper lobe of right lung")) - middleRightLungGroup = AnnotationGroup(region, get_lung_term("middle lobe of right lung")) - - annotation_groups = [lungGroup, leftLungGroup, rightLungGroup, - leftAnteriorLungGroup, leftLateralLungGroup, leftMedialLungGroup, - rightAnteriorLungGroup, rightLateralLungGroup, rightMedialLungGroup, - lowerRightLungGroup, middleRightLungGroup, upperRightLungGroup] - - lowerLeftLungGroup = AnnotationGroup(region, get_lung_term("lower lobe of left lung")) - upperLeftLungGroup = AnnotationGroup(region, get_lung_term("upper lobe of left lung")) - annotation_groups += [lowerLeftLungGroup, upperLeftLungGroup] + if has_left_lung: + leftLungGroup = AnnotationGroup(region, get_lung_term("left lung")) + lowerLeftLungGroup = AnnotationGroup(region, get_lung_term("lower lobe of left lung")) + upperLeftLungGroup = AnnotationGroup(region, get_lung_term("upper lobe of left lung")) + leftAnteriorLungGroup = AnnotationGroup(region, ("anterior left lung", "")) + leftLateralLungGroup = AnnotationGroup(region, ("lateral left lung", "")) + leftMedialLungGroup = AnnotationGroup(region, ("medial left lung", "")) + left_annotation_groups = [leftLungGroup, lowerLeftLungGroup, upperLeftLungGroup, + leftAnteriorLungGroup, leftLateralLungGroup, leftMedialLungGroup] + else: + leftLungGroup = lowerLeftLungGroup = upperLeftLungGroup = None + leftLateralLungGroup = leftMedialLungGroup = leftAnteriorLungGroup = None + left_annotation_groups = [] + + if has_right_lung: + rightLungGroup = AnnotationGroup(region, get_lung_term("right lung")) + lowerRightLungGroup = AnnotationGroup(region, get_lung_term("lower lobe of right lung")) + middleRightLungGroup = AnnotationGroup(region, get_lung_term("middle lobe of right lung")) + upperRightLungGroup = AnnotationGroup(region, get_lung_term("upper lobe of right lung")) + rightAnteriorLungGroup = AnnotationGroup(region, ("anterior right lung", "")) + rightLateralLungGroup = AnnotationGroup(region, ("lateral right lung", "")) + rightMedialLungGroup = AnnotationGroup(region, ("medial right lung", "")) + right_annotation_groups = [rightLungGroup, lowerRightLungGroup, middleRightLungGroup, upperRightLungGroup, + rightAnteriorLungGroup, rightLateralLungGroup, rightMedialLungGroup] + else: + rightLungGroup = lowerRightLungGroup = middleRightLungGroup = upperRightLungGroup = None + rightAnteriorLungGroup = rightLateralLungGroup = rightMedialLungGroup = None + right_annotation_groups = [] box_group = AnnotationGroup(region, ("box", "")) transition_group = AnnotationGroup(region, ("transition", "")) - annotation_groups += [box_group, transition_group] + + annotation_groups = [lungGroup] + left_annotation_groups + right_annotation_groups + \ + [box_group, transition_group] # Nodeset group - leftLungNodesetGroup = leftLungGroup.getNodesetGroup(nodes) - rightLungNodesetGroup = rightLungGroup.getNodesetGroup(nodes) + leftLungNodesetGroup = leftLungGroup.getNodesetGroup(nodes) if leftLungGroup else None + rightLungNodesetGroup = rightLungGroup.getNodesetGroup(nodes) if rightLungGroup else None halfDepth = ellipsoid_depth * 0.5 halfBreadth = ellipsoid_breadth * 0.5 halfHeight = ellipsoid_height * 0.5 left_lung, right_lung = 0, 1 - lungs = [lung for show, lung in [(is_left_lung, left_lung), (is_right_lung, right_lung)] if show] + lungs = [lung for show, lung in [(has_left_lung, left_lung), (has_right_lung, right_lung)] if show] nodeIdentifier, elementIdentifier = 1, 1 - for lung in lungs: + # currently build left lung if right lung is being built to get correct node/element identifiers + lungs_construct = [left_lung, right_lung] if has_right_lung else [left_lung] if has_left_lung else [] + + for lung in lungs_construct: if lung == left_lung: - lower_octant_group_lists = [] + if has_left_lung: + lower_octant_group_lists = [] + upper_octant_group_lists = [] + for octant in range(8): + octant_group_list = [group.getGroup() for group in [lungGroup, leftLungGroup, lowerLeftLungGroup] + + [leftMedialLungGroup if (octant & 1) else leftLateralLungGroup]] + lower_octant_group_lists.append(octant_group_list) + octant_group_list = [group.getGroup() for group in [lungGroup, leftLungGroup, upperLeftLungGroup] + + [leftMedialLungGroup if (octant & 1) else leftLateralLungGroup]] + if octant & 2: + octant_group_list.append(leftAnteriorLungGroup.getGroup()) + upper_octant_group_lists.append(octant_group_list) + else: + lower_octant_group_lists = upper_octant_group_lists = None middle_octant_group_lists = None - upper_octant_group_lists = [] - for octant in range(8): - octant_group_list = [group.getGroup() for group in [lungGroup, leftLungGroup, lowerLeftLungGroup] + - [leftMedialLungGroup if (octant & 1) else leftLateralLungGroup]] - lower_octant_group_lists.append(octant_group_list) - octant_group_list = [group.getGroup() for group in [lungGroup, leftLungGroup, upperLeftLungGroup] + - [leftMedialLungGroup if (octant & 1) else leftLateralLungGroup]] - if octant & 2: - octant_group_list.append(leftAnteriorLungGroup.getGroup()) - upper_octant_group_lists.append(octant_group_list) else: lower_octant_group_lists = [] middle_octant_group_lists = [] @@ -401,6 +414,18 @@ def defineFaceAnnotations(cls, region, options, annotation_groups): mesh1d = fm.findMeshByDimension(1) mesh2d = fm.findMeshByDimension(2) + has_left_lung = options["Left lung"] + has_right_lung = options["Right lung"] + + if (has_right_lung) and (not has_left_lung): + # destroy left lung elements, faces, lines and nodes now to ensure persistent identifiers used on right + is_left = fm.createFieldNot( + getAnnotationGroupForTerm(annotation_groups, get_lung_term("right lung")).getGroup()) + nodes = fm.findNodesetByFieldDomainType(Field.DOMAIN_TYPE_NODES) + for mesh in [fm.findMeshByDimension(3), mesh2d, mesh1d]: + mesh.destroyElementsConditional(is_left) + nodes.destroyNodesConditional(is_left) + is_exterior = fm.createFieldIsExterior() is_face_xi1_0 = fm.createFieldIsOnFace(Element.FACE_TYPE_XI1_0) is_face_xi1_1 = fm.createFieldIsOnFace(Element.FACE_TYPE_XI1_1) @@ -430,51 +455,69 @@ def defineFaceAnnotations(cls, region, options, annotation_groups): is_face_box31_trans21 = fm.createFieldOr( fm.createFieldAnd(is_box, is_face_xi3_1), fm.createFieldAnd(is_trans, is_face_xi2_1)) - is_lower_left = getAnnotationGroupForTerm(annotation_groups, get_lung_term("lower lobe of left lung")).getGroup() - is_upper_left = getAnnotationGroupForTerm(annotation_groups, get_lung_term("upper lobe of left lung")).getGroup() - is_anterior_left = findAnnotationGroupByName(annotation_groups, "anterior left lung").getGroup() - is_lateral_left = findAnnotationGroupByName(annotation_groups, "lateral left lung").getGroup() - is_medial_left = findAnnotationGroupByName(annotation_groups, "medial left lung").getGroup() - - is_lower_right = getAnnotationGroupForTerm(annotation_groups, get_lung_term("lower lobe of right lung")).getGroup() - is_middle_right = getAnnotationGroupForTerm(annotation_groups, get_lung_term("middle lobe of right lung")).getGroup() - is_upper_right = getAnnotationGroupForTerm(annotation_groups, get_lung_term("upper lobe of right lung")).getGroup() - is_anterior_right = findAnnotationGroupByName(annotation_groups, "anterior right lung").getGroup() - is_lateral_right = findAnnotationGroupByName(annotation_groups, "lateral right lung").getGroup() - is_medial_right = findAnnotationGroupByName(annotation_groups, "medial right lung").getGroup() - - face_term_conditionals_map = { - "base of lower lobe of left lung surface": (is_lower_left, is_exterior, is_face_box30_trans20), - "base of lower lobe of right lung surface": (is_lower_right, is_exterior, is_face_box30_trans20), - "base of middle lobe of right lung surface": (is_middle_right, is_exterior, is_face_xi1_0_or_xi1_1_or_xi2_0), - "base of upper lobe of left lung surface": (is_upper_left, is_exterior, is_face_xi1_0_or_xi1_1_or_xi2_0, is_anterior_left), - "horizontal fissure of middle lobe of right lung": (is_middle_right, is_exterior, is_face_box31_trans21), - "horizontal fissure of upper lobe of right lung": (is_upper_right, is_exterior, is_face_box30_trans20, is_anterior_right), - "lateral surface of left lung": (is_lateral_left, is_on_ellipsoid), - "lateral surface of lower lobe of left lung": (is_lower_left, is_on_ellipsoid, is_lateral_left), - "lateral surface of lower lobe of right lung": (is_lower_right, is_on_ellipsoid, is_lateral_right), - "lateral surface of middle lobe of right lung": (is_middle_right, is_on_ellipsoid, is_lateral_right), - "lateral surface of right lung": (is_lateral_right, is_on_ellipsoid), - "lateral surface of upper lobe of left lung": (is_upper_left, is_on_ellipsoid, is_lateral_left), - "lateral surface of upper lobe of right lung": (is_upper_right, is_on_ellipsoid, is_lateral_right), - "lower lobe of left lung surface": (is_lower_left, is_exterior), - "lower lobe of right lung surface": (is_lower_right, is_exterior), - "middle lobe of right lung surface": (is_middle_right, is_exterior), - "upper lobe of left lung surface": (is_upper_left, is_exterior), - "upper lobe of right lung surface": (is_upper_right, is_exterior), - "medial surface of left lung": (is_medial_left, is_on_ellipsoid), - "medial surface of lower lobe of left lung": (is_lower_left, is_on_ellipsoid, is_medial_left), - "medial surface of lower lobe of right lung": (is_lower_right, is_on_ellipsoid, is_medial_right), - "medial surface of middle lobe of right lung": (is_middle_right, is_on_ellipsoid, is_medial_right), - "medial surface of right lung": (is_medial_right, is_on_ellipsoid), - "medial surface of upper lobe of left lung": (is_upper_left, is_on_ellipsoid, is_medial_left), - "medial surface of upper lobe of right lung": (is_upper_right, is_on_ellipsoid, is_medial_right), - "oblique fissure of lower lobe of left lung": (is_lower_left, is_exterior, is_face_xi1_0_or_xi1_1_or_xi2_1), - "oblique fissure of lower lobe of right lung": (is_lower_right, is_exterior, is_face_xi1_0_or_xi1_1_or_xi2_1), - "oblique fissure of middle lobe of right lung": (is_middle_right, is_exterior, is_face_xi1_0_or_xi1_1_or_xi2_0), - "oblique fissure of upper lobe of left lung": (is_upper_left, is_exterior, fm.createFieldOr(is_face_xi1_0_or_xi1_1_or_xi2_0, is_face_box30_trans20)), - "oblique fissure of upper lobe of right lung": (is_upper_right, is_exterior, fm.createFieldAnd(is_face_box30_trans20, fm.createFieldNot(is_anterior_right))), - } + face_term_conditionals_map = {} + + if has_left_lung: + is_lower_left = getAnnotationGroupForTerm(annotation_groups, get_lung_term("lower lobe of left lung")).getGroup() + is_upper_left = getAnnotationGroupForTerm(annotation_groups, get_lung_term("upper lobe of left lung")).getGroup() + is_anterior_left = findAnnotationGroupByName(annotation_groups, "anterior left lung").getGroup() + is_lateral_left = findAnnotationGroupByName(annotation_groups, "lateral left lung").getGroup() + is_medial_left = findAnnotationGroupByName(annotation_groups, "medial left lung").getGroup() + + left_face_term_conditionals_map = { + "base of lower lobe of left lung surface": (is_lower_left, is_exterior, is_face_box30_trans20), + "base of upper lobe of left lung surface": (is_upper_left, is_exterior, is_face_xi1_0_or_xi1_1_or_xi2_0, + is_anterior_left), + "lateral surface of left lung": (is_lateral_left, is_on_ellipsoid), + "lateral surface of lower lobe of left lung": (is_lower_left, is_on_ellipsoid, is_lateral_left), + "lateral surface of upper lobe of left lung": (is_upper_left, is_on_ellipsoid, is_lateral_left), + "lower lobe of left lung surface": (is_lower_left, is_exterior), + "upper lobe of left lung surface": (is_upper_left, is_exterior), + "medial surface of left lung": (is_medial_left, is_on_ellipsoid), + "medial surface of lower lobe of left lung": (is_lower_left, is_on_ellipsoid, is_medial_left), + "medial surface of upper lobe of left lung": (is_upper_left, is_on_ellipsoid, is_medial_left), + "oblique fissure of lower lobe of left lung": (is_lower_left, is_exterior, + is_face_xi1_0_or_xi1_1_or_xi2_1), + "oblique fissure of upper lobe of left lung": (is_upper_left, is_exterior, + fm.createFieldOr(is_face_xi1_0_or_xi1_1_or_xi2_0, + is_face_box30_trans20)), + } + face_term_conditionals_map.update(left_face_term_conditionals_map) + else: + left_face_term_conditionals_map = {} + + if has_right_lung: + is_lower_right = getAnnotationGroupForTerm(annotation_groups, get_lung_term("lower lobe of right lung")).getGroup() + is_middle_right = getAnnotationGroupForTerm(annotation_groups, get_lung_term("middle lobe of right lung")).getGroup() + is_upper_right = getAnnotationGroupForTerm(annotation_groups, get_lung_term("upper lobe of right lung")).getGroup() + is_anterior_right = findAnnotationGroupByName(annotation_groups, "anterior right lung").getGroup() + is_lateral_right = findAnnotationGroupByName(annotation_groups, "lateral right lung").getGroup() + is_medial_right = findAnnotationGroupByName(annotation_groups, "medial right lung").getGroup() + + right_face_term_conditionals_map = { + "base of lower lobe of right lung surface": (is_lower_right, is_exterior, is_face_box30_trans20), + "base of middle lobe of right lung surface": (is_middle_right, is_exterior, is_face_xi1_0_or_xi1_1_or_xi2_0), + "horizontal fissure of middle lobe of right lung": (is_middle_right, is_exterior, is_face_box31_trans21), + "horizontal fissure of upper lobe of right lung": (is_upper_right, is_exterior, is_face_box30_trans20, is_anterior_right), + "lateral surface of lower lobe of right lung": (is_lower_right, is_on_ellipsoid, is_lateral_right), + "lateral surface of middle lobe of right lung": (is_middle_right, is_on_ellipsoid, is_lateral_right), + "lateral surface of right lung": (is_lateral_right, is_on_ellipsoid), + "lateral surface of upper lobe of right lung": (is_upper_right, is_on_ellipsoid, is_lateral_right), + "lower lobe of right lung surface": (is_lower_right, is_exterior), + "middle lobe of right lung surface": (is_middle_right, is_exterior), + "upper lobe of right lung surface": (is_upper_right, is_exterior), + "medial surface of lower lobe of right lung": (is_lower_right, is_on_ellipsoid, is_medial_right), + "medial surface of middle lobe of right lung": (is_middle_right, is_on_ellipsoid, is_medial_right), + "medial surface of right lung": (is_medial_right, is_on_ellipsoid), + "medial surface of upper lobe of right lung": (is_upper_right, is_on_ellipsoid, is_medial_right), + "oblique fissure of lower lobe of right lung": (is_lower_right, is_exterior, is_face_xi1_0_or_xi1_1_or_xi2_1), + "oblique fissure of middle lobe of right lung": (is_middle_right, is_exterior, is_face_xi1_0_or_xi1_1_or_xi2_0), + "oblique fissure of upper lobe of right lung": (is_upper_right, is_exterior, fm.createFieldAnd(is_face_box30_trans20, fm.createFieldNot(is_anterior_right))), + } + face_term_conditionals_map.update(right_face_term_conditionals_map) + else: + right_face_term_conditionals_map = {} + is_face_conditional = {} for face_term, conditionals in face_term_conditionals_map.items(): annotation_group = findOrCreateAnnotationGroupForTerm(annotation_groups, region, get_lung_term(face_term)) @@ -487,67 +530,80 @@ def defineFaceAnnotations(cls, region, options, annotation_groups): annotation_group.addSubelements() is_face_conditional[face_term] = group - line_term_conditionals_map = { - "anterior edge of middle lobe of right lung": - (is_middle_right, is_on_ellipsoid, is_medial_right, is_lateral_right), - "antero-posterior edge of upper lobe of left lung": - (is_upper_left, is_on_ellipsoid, is_medial_left, is_lateral_left), - "antero-posterior edge of upper lobe of right lung": - (is_upper_right, is_on_ellipsoid, is_medial_right, is_lateral_right), - "base edge of oblique fissure of lower lobe of left lung": ( - is_face_conditional["base of lower lobe of left lung surface"], is_face_conditional["oblique fissure of lower lobe of left lung"]), - "base edge of oblique fissure of lower lobe of right lung": ( - is_face_conditional["base of lower lobe of right lung surface"], is_face_conditional["oblique fissure of lower lobe of right lung"]), - "lateral edge of base of lower lobe of left lung": ( - is_face_conditional["base of lower lobe of left lung surface"], is_lateral_left, is_on_ellipsoid), - "lateral edge of base of lower lobe of right lung": ( - is_face_conditional["base of lower lobe of right lung surface"], is_lateral_right, is_on_ellipsoid), - "lateral edge of base of middle lobe of right lung": ( - is_face_conditional["base of middle lobe of right lung surface"], is_lateral_right, is_on_ellipsoid), - "lateral edge of base of upper lobe of left lung": ( - is_face_conditional["base of upper lobe of left lung surface"], is_lateral_left, is_on_ellipsoid), - "lateral edge of horizontal fissure of middle lobe of right lung": ( - is_face_conditional["horizontal fissure of middle lobe of right lung"], is_lateral_right, is_on_ellipsoid), - "lateral edge of horizontal fissure of upper lobe of right lung": ( - is_face_conditional["horizontal fissure of upper lobe of right lung"], is_lateral_right, is_on_ellipsoid), - "lateral edge of oblique fissure of lower lobe of left lung": ( - is_face_conditional["oblique fissure of lower lobe of left lung"], is_lateral_left, is_on_ellipsoid), - "lateral edge of oblique fissure of lower lobe of right lung": ( - is_face_conditional["oblique fissure of lower lobe of right lung"], is_lateral_right, is_on_ellipsoid), - "lateral edge of oblique fissure of middle lobe of right lung": ( - is_face_conditional["oblique fissure of middle lobe of right lung"], is_lateral_right, is_on_ellipsoid), - "lateral edge of oblique fissure of upper lobe of left lung": ( - is_face_conditional["oblique fissure of upper lobe of left lung"], is_lateral_left, is_on_ellipsoid), - "lateral edge of oblique fissure of upper lobe of right lung": ( - is_face_conditional["oblique fissure of upper lobe of right lung"], is_lateral_right, is_on_ellipsoid), - "medial edge of base of lower lobe of left lung": ( - is_face_conditional["base of lower lobe of left lung surface"], is_medial_left, is_on_ellipsoid), - "medial edge of base of lower lobe of right lung": ( - is_face_conditional["base of lower lobe of right lung surface"], is_medial_right, is_on_ellipsoid), - "medial edge of base of middle lobe of right lung": ( - is_face_conditional["base of middle lobe of right lung surface"], is_medial_right, is_on_ellipsoid), - "medial edge of base of upper lobe of left lung": ( - is_face_conditional["base of upper lobe of left lung surface"], is_medial_left, is_on_ellipsoid), - "medial edge of horizontal fissure of middle lobe of right lung": ( - is_face_conditional["horizontal fissure of middle lobe of right lung"], is_medial_right, is_on_ellipsoid), - "medial edge of horizontal fissure of upper lobe of right lung": ( - is_face_conditional["horizontal fissure of upper lobe of right lung"], is_medial_right, is_on_ellipsoid), - "medial edge of oblique fissure of lower lobe of left lung": ( - is_face_conditional["oblique fissure of lower lobe of left lung"], is_medial_left, is_on_ellipsoid), - "medial edge of oblique fissure of lower lobe of right lung": ( - is_face_conditional["oblique fissure of lower lobe of right lung"], is_medial_right, is_on_ellipsoid), - "medial edge of oblique fissure of middle lobe of right lung": ( - is_face_conditional["oblique fissure of middle lobe of right lung"], is_medial_right, is_on_ellipsoid), - "medial edge of oblique fissure of upper lobe of left lung": ( - is_face_conditional["oblique fissure of upper lobe of left lung"], is_medial_left, is_on_ellipsoid), - "medial edge of oblique fissure of upper lobe of right lung": ( - is_face_conditional["oblique fissure of upper lobe of right lung"], is_medial_right, is_on_ellipsoid), - "posterior edge of lower lobe of left lung": - (is_lower_left, is_lateral_left, is_medial_left, is_on_ellipsoid), - "posterior edge of lower lobe of right lung": - (is_lower_right, is_lateral_right, is_medial_right, is_on_ellipsoid), - - } + line_term_conditionals_map = {} + + if has_left_lung: + left_line_term_conditionals_map = { + "antero-posterior edge of upper lobe of left lung": + (is_upper_left, is_on_ellipsoid, is_medial_left, is_lateral_left), + "base edge of oblique fissure of lower lobe of left lung": ( + is_face_conditional["base of lower lobe of left lung surface"], is_face_conditional["oblique fissure of lower lobe of left lung"]), + "lateral edge of base of lower lobe of left lung": ( + is_face_conditional["base of lower lobe of left lung surface"], is_lateral_left, is_on_ellipsoid), + "lateral edge of base of upper lobe of left lung": ( + is_face_conditional["base of upper lobe of left lung surface"], is_lateral_left, is_on_ellipsoid), + "lateral edge of oblique fissure of lower lobe of left lung": ( + is_face_conditional["oblique fissure of lower lobe of left lung"], is_lateral_left, is_on_ellipsoid), + "lateral edge of oblique fissure of upper lobe of left lung": ( + is_face_conditional["oblique fissure of upper lobe of left lung"], is_lateral_left, is_on_ellipsoid), + "medial edge of base of lower lobe of left lung": ( + is_face_conditional["base of lower lobe of left lung surface"], is_medial_left, is_on_ellipsoid), + "medial edge of base of upper lobe of left lung": ( + is_face_conditional["base of upper lobe of left lung surface"], is_medial_left, is_on_ellipsoid), + "medial edge of oblique fissure of lower lobe of left lung": ( + is_face_conditional["oblique fissure of lower lobe of left lung"], is_medial_left, is_on_ellipsoid), + "medial edge of oblique fissure of upper lobe of left lung": ( + is_face_conditional["oblique fissure of upper lobe of left lung"], is_medial_left, is_on_ellipsoid), + "posterior edge of lower lobe of left lung": + (is_lower_left, is_lateral_left, is_medial_left, is_on_ellipsoid), + } + line_term_conditionals_map.update(left_line_term_conditionals_map) + else: + left_line_term_conditionals_map = {} + + if has_right_lung: + right_line_term_conditionals_map = { + "anterior edge of middle lobe of right lung": + (is_middle_right, is_on_ellipsoid, is_medial_right, is_lateral_right), + "antero-posterior edge of upper lobe of right lung": + (is_upper_right, is_on_ellipsoid, is_medial_right, is_lateral_right), + "base edge of oblique fissure of lower lobe of right lung": ( + is_face_conditional["base of lower lobe of right lung surface"], is_face_conditional["oblique fissure of lower lobe of right lung"]), + "lateral edge of base of lower lobe of right lung": ( + is_face_conditional["base of lower lobe of right lung surface"], is_lateral_right, is_on_ellipsoid), + "lateral edge of base of middle lobe of right lung": ( + is_face_conditional["base of middle lobe of right lung surface"], is_lateral_right, is_on_ellipsoid), + "lateral edge of horizontal fissure of middle lobe of right lung": ( + is_face_conditional["horizontal fissure of middle lobe of right lung"], is_lateral_right, is_on_ellipsoid), + "lateral edge of horizontal fissure of upper lobe of right lung": ( + is_face_conditional["horizontal fissure of upper lobe of right lung"], is_lateral_right, is_on_ellipsoid), + "lateral edge of oblique fissure of lower lobe of right lung": ( + is_face_conditional["oblique fissure of lower lobe of right lung"], is_lateral_right, is_on_ellipsoid), + "lateral edge of oblique fissure of middle lobe of right lung": ( + is_face_conditional["oblique fissure of middle lobe of right lung"], is_lateral_right, is_on_ellipsoid), + "lateral edge of oblique fissure of upper lobe of right lung": ( + is_face_conditional["oblique fissure of upper lobe of right lung"], is_lateral_right, is_on_ellipsoid), + "medial edge of base of lower lobe of right lung": ( + is_face_conditional["base of lower lobe of right lung surface"], is_medial_right, is_on_ellipsoid), + "medial edge of base of middle lobe of right lung": ( + is_face_conditional["base of middle lobe of right lung surface"], is_medial_right, is_on_ellipsoid), + "medial edge of horizontal fissure of middle lobe of right lung": ( + is_face_conditional["horizontal fissure of middle lobe of right lung"], is_medial_right, is_on_ellipsoid), + "medial edge of horizontal fissure of upper lobe of right lung": ( + is_face_conditional["horizontal fissure of upper lobe of right lung"], is_medial_right, is_on_ellipsoid), + "medial edge of oblique fissure of lower lobe of right lung": ( + is_face_conditional["oblique fissure of lower lobe of right lung"], is_medial_right, is_on_ellipsoid), + "medial edge of oblique fissure of middle lobe of right lung": ( + is_face_conditional["oblique fissure of middle lobe of right lung"], is_medial_right, is_on_ellipsoid), + "medial edge of oblique fissure of upper lobe of right lung": ( + is_face_conditional["oblique fissure of upper lobe of right lung"], is_medial_right, is_on_ellipsoid), + "posterior edge of lower lobe of right lung": + (is_lower_right, is_lateral_right, is_medial_right, is_on_ellipsoid) + } + line_term_conditionals_map.update(right_line_term_conditionals_map) + else: + right_line_term_conditionals_map = {} + for line_term, conditionals in line_term_conditionals_map.items(): annotation_group = findOrCreateAnnotationGroupForTerm(annotation_groups, region, (line_term, "")) conditional = conditionals[0] @@ -567,7 +623,8 @@ def defineFaceAnnotations(cls, region, options, annotation_groups): "transition" ]: annotation_group = findAnnotationGroupByName(annotation_groups, group_name) - annotation_groups.remove(annotation_group) + if annotation_group: + annotation_groups.remove(annotation_group) def rotateLungMeshAboutAxis(rotateAngle, fm, coordinates, lungNodesetGroup, axis): From c3cca98bc935e85e030ac8268454b2bfe98b3fe6 Mon Sep 17 00:00:00 2001 From: Richard Christie Date: Fri, 21 Nov 2025 12:32:26 +1300 Subject: [PATCH 08/24] Add base concavity function --- .../meshtypes/meshtype_3d_lung4.py | 158 +++++++++++------- src/scaffoldmaker/utils/ellipsoidmesh.py | 3 +- 2 files changed, 99 insertions(+), 62 deletions(-) diff --git a/src/scaffoldmaker/meshtypes/meshtype_3d_lung4.py b/src/scaffoldmaker/meshtypes/meshtype_3d_lung4.py index 3eb0b3ef..f26f41c1 100644 --- a/src/scaffoldmaker/meshtypes/meshtype_3d_lung4.py +++ b/src/scaffoldmaker/meshtypes/meshtype_3d_lung4.py @@ -48,23 +48,24 @@ def getDefaultOptions(cls, parameterSetName="Default"): depth = options["Ellipsoid dorsal-ventral size"] = 0.8 options["Ellipsoid medial-lateral size"] = 0.4 options["Left-right lung spacing"] = 0.5 + options["Lower lobe base concavity"] = 0.1 options["Lower lobe extension"] = 0.3 options["Refine"] = False options["Refine number of elements"] = 4 if "Medium" in useParameterSetName: options["Number of elements lateral"] = 4 - options["Number of elements lower extension"] = 3 + options["Number of elements lower lobe extension"] = 3 options["Number of elements oblique"] = 8 options["Number of transition elements"] = 1 elif "Fine" in useParameterSetName: options["Number of elements lateral"] = 6 - options["Number of elements lower extension"] = 4 + options["Number of elements lower lobe extension"] = 4 options["Number of elements oblique"] = 12 options["Number of transition elements"] = 1 else: options["Number of elements lateral"] = 4 - options["Number of elements lower extension"] = 2 + options["Number of elements lower lobe extension"] = 2 options["Number of elements oblique"] = 6 options["Number of transition elements"] = 1 @@ -86,13 +87,14 @@ def getOrderedOptionNames(cls): "Right lung", # "Number of left lung lobes", "Number of elements lateral", - "Number of elements lower extension", + "Number of elements lower lobe extension", "Number of elements oblique", "Number of transition elements", "Ellipsoid height", "Ellipsoid dorsal-ventral size", "Ellipsoid medial-lateral size", "Left-right lung spacing", + "Lower lobe base concavity", "Lower lobe extension", "Ventral edge sharpness factor", "Medial curvature", @@ -107,7 +109,7 @@ def checkOptions(cls, options): max_transition_count = None for key in [ - "Number of elements lower extension" + "Number of elements lower lobe extension" ]: if options[key] < 1: options[key] = 1 @@ -133,11 +135,19 @@ def checkOptions(cls, options): for key in [ "Ellipsoid height", "Ellipsoid dorsal-ventral size", - "Ellipsoid medial-lateral size", - "Lower lobe extension" + "Ellipsoid medial-lateral size" ]: if options[key] <= 0.0: options[key] = 1.0 + for key in [ + "Lower lobe base concavity", + "Lower lobe extension" + ]: + if options[key] < 0.0: + options[key] = 0.0 + if options["Lower lobe extension"] == 0.0: + options["Number of elements lower lobe extension"] = 0 + dependent_changes = True depth = options["Ellipsoid dorsal-ventral size"] height = options["Ellipsoid height"] max_extension = 0.99 * magnitude(getEllipsePointAtTrueAngle(depth / 2.0, height / 2.0, math.pi / 3.0)) @@ -173,15 +183,16 @@ def generateBaseMesh(cls, region, options): has_right_lung = options["Right lung"] elements_count_lateral = options["Number of elements lateral"] - elements_count_lower_extension = options["Number of elements lower extension"] + elements_count_lower_extension = options["Number of elements lower lobe extension"] elements_count_oblique = options["Number of elements oblique"] elements_count_transition = options["Number of transition elements"] lung_spacing = options["Left-right lung spacing"] * 0.5 - lower_lobe_extension_height = options["Lower lobe extension"] + lower_lobe_extension = options["Lower lobe extension"] + lower_lobe_base_concavity = options["Lower lobe base concavity"] ventral_sharpness_factor = options["Ventral edge sharpness factor"] + ellipsoid_ml_size = options["Ellipsoid medial-lateral size"] + ellipsoid_dv_size = options["Ellipsoid dorsal-ventral size"] ellipsoid_height = options["Ellipsoid height"] - ellipsoid_breadth = options["Ellipsoid dorsal-ventral size"] - ellipsoid_depth = options["Ellipsoid medial-lateral size"] medial_curvature = options["Medial curvature"] medial_curvature_bias = options["Medial curvature bias"] @@ -232,9 +243,11 @@ def generateBaseMesh(cls, region, options): leftLungNodesetGroup = leftLungGroup.getNodesetGroup(nodes) if leftLungGroup else None rightLungNodesetGroup = rightLungGroup.getNodesetGroup(nodes) if rightLungGroup else None - halfDepth = ellipsoid_depth * 0.5 - halfBreadth = ellipsoid_breadth * 0.5 - halfHeight = ellipsoid_height * 0.5 + half_ml_size = ellipsoid_ml_size * 0.5 + half_dv_size = ellipsoid_dv_size * 0.5 + half_height = ellipsoid_height * 0.5 + + pi__3 = math.pi / 3.0 left_lung, right_lung = 0, 1 lungs = [lung for show, lung in [(has_left_lung, left_lung), (has_right_lung, right_lung)] if show] @@ -281,22 +294,20 @@ def generateBaseMesh(cls, region, options): upper_octant_group_lists.append(octant_group_list) elementCounts = [elements_count_lateral, elements_count_oblique, elements_count_oblique] - lower_ellipsoid = EllipsoidMesh(halfDepth, halfBreadth, halfHeight, elementCounts, elements_count_transition) + lower_ellipsoid = EllipsoidMesh(half_ml_size, half_dv_size, half_height, elementCounts, elements_count_transition) lower_ellipsoid.set_surface_d3_mode(EllipsoidSurfaceD3Mode.SURFACE_NORMAL_PLANE_PROJECTION) lower_ellipsoid.set_box_transition_groups(box_group.getGroup(), transition_group.getGroup()) - upper_ellipsoid = EllipsoidMesh(halfDepth, halfBreadth, halfHeight, elementCounts, elements_count_transition) + upper_ellipsoid = EllipsoidMesh(half_ml_size, half_dv_size, half_height, elementCounts, elements_count_transition) upper_ellipsoid.set_surface_d3_mode(EllipsoidSurfaceD3Mode.SURFACE_NORMAL_PLANE_PROJECTION) upper_ellipsoid.set_box_transition_groups(box_group.getGroup(), transition_group.getGroup()) if lung == right_lung: - middle_ellipsoid = EllipsoidMesh(halfDepth, halfBreadth, halfHeight, elementCounts, elements_count_transition) + middle_ellipsoid = EllipsoidMesh(half_ml_size, half_dv_size, half_height, elementCounts, elements_count_transition) middle_ellipsoid.set_surface_d3_mode(EllipsoidSurfaceD3Mode.SURFACE_NORMAL_PLANE_PROJECTION) middle_ellipsoid.set_box_transition_groups(box_group.getGroup(), transition_group.getGroup()) else: middle_ellipsoid = upper_ellipsoid - pi__3 = math.pi / 3.0 - normal_face_factor = 1.0 half_counts = [count // 2 for count in elementCounts] - octant1 = middle_ellipsoid.build_octant(half_counts, -pi__3, 0.0, normal_face_factor=normal_face_factor) + octant1 = middle_ellipsoid.build_octant(half_counts, -pi__3, 0.0) middle_ellipsoid.merge_octant(octant1, quadrant=3) if lung == right_lung: middle_ellipsoid.copy_to_negative_axis1() @@ -321,26 +332,24 @@ def generateBaseMesh(cls, region, options): parameters[i][0] = -parameters[i][0] hilum_x.append(parameters) - octant2 = upper_ellipsoid.build_octant(half_counts, 0.0, pi__3, normal_face_factor=normal_face_factor) + octant2 = upper_ellipsoid.build_octant(half_counts, 0.0, pi__3) upper_ellipsoid.merge_octant(octant2, quadrant=0) - octant3 = upper_ellipsoid.build_octant(half_counts, pi__3, 2.0 * pi__3, normal_face_factor=normal_face_factor) + octant3 = upper_ellipsoid.build_octant(half_counts, pi__3, 2.0 * pi__3) upper_ellipsoid.merge_octant(octant3, quadrant=1) upper_ellipsoid.copy_to_negative_axis1() - - lower_lobe_extension = 0.6 * halfHeight / math.cos(math.pi / 6.0) + octant4 = lower_ellipsoid.build_octant(half_counts, 2.0 * pi__3, math.pi, - lower_lobe_extension_height, elements_count_lower_extension, - normal_face_factor=normal_face_factor) + lower_lobe_extension, elements_count_lower_extension) # merge into separate lower ellipsoid to have space for extension elements lower_ellipsoid_mesh = EllipsoidMesh( - halfDepth, halfBreadth, halfHeight, + half_ml_size, half_dv_size, half_height, [elementCounts[0], elementCounts[1], elementCounts[2] + 2 * elements_count_lower_extension], elements_count_transition) lower_ellipsoid_mesh.set_surface_d3_mode(EllipsoidSurfaceD3Mode.SURFACE_NORMAL_PLANE_PROJECTION) lower_ellipsoid_mesh.set_box_transition_groups(box_group.getGroup(), transition_group.getGroup()) lower_ellipsoid_mesh.merge_octant(octant4, quadrant=1) lower_ellipsoid_mesh.copy_to_negative_axis1() - + node_layout_manager = lower_ellipsoid.get_node_layout_manager() node_layout_permuted = node_layout_manager.getNodeLayoutRegularPermuted(d3Defined=True) for n1 in range(elementCounts[0] + 1): @@ -371,20 +380,69 @@ def generateBaseMesh(cls, region, options): is_left = lung == left_lung lungNodeset = leftLungNodesetGroup if is_left else rightLungNodesetGroup spacing = -lung_spacing if is_left else lung_spacing - zOffset = -0.5 * ellipsoid_height lungMedialCurvature = -medial_curvature if is_left else medial_curvature + if lower_lobe_base_concavity > 0.0: + cos_pi__3 = math.cos(pi__3) + sin_pi__3 = math.sin(pi__3) + value0 = fieldmodule.createFieldConstant(0.0) + value1 = fieldmodule.createFieldConstant(1.0) + value2 = fieldmodule.createFieldConstant(2.0) + value3 = fieldmodule.createFieldConstant(3.0) + x = fieldmodule.createFieldComponent(coordinates, 1) + y = fieldmodule.createFieldComponent(coordinates, 2) + z = fieldmodule.createFieldComponent(coordinates, 3) + xx = x * x + yy = y * y + minus_c = fieldmodule.createFieldConstant(-half_height) + nz = z / minus_c + zfact = fieldmodule.createFieldSqrt(value1 - nz * nz) + a = fieldmodule.createFieldConstant(half_ml_size) * zfact + b = fieldmodule.createFieldConstant(half_dv_size) * zfact + aa = a * a + bb = b * b + r = fieldmodule.createFieldSqrt((xx / aa) + (yy / bb)) + rr = r * r + phi_r = value1 - rr + z_ext = -lower_lobe_extension * sin_pi__3 + e = z / fieldmodule.createFieldConstant(z_ext) + ee = e * e + eee = ee * e + phi_e = fieldmodule.createFieldIf(fieldmodule.createFieldLessThan(e, value0), value0, + value3 * ee - value2 * eee) + # reduce concavity to zero at oblique fissure + y_ext = fieldmodule.createFieldConstant(lower_lobe_extension * cos_pi__3) + y_max = b + y_ext + d = value1 + (y - e * y_ext) / y_max + dd = d * d + phi_y = value1 - dd + + phi = phi_e * phi_r * phi_y + delta_y = phi * fieldmodule.createFieldConstant(-lower_lobe_base_concavity * cos_pi__3 / sin_pi__3) + delta_z = phi * fieldmodule.createFieldConstant(lower_lobe_base_concavity) + # get span a at displaced z, to calculate delta_x + displ_nz = (z + delta_z) / minus_c + displ_zfact = fieldmodule.createFieldSqrt(value1 - displ_nz * displ_nz) + displ_a = fieldmodule.createFieldConstant(half_ml_size) * displ_zfact + delta_x = (x * displ_a / a - x) + displacement = fieldmodule.createFieldConcatenate([delta_x, delta_y, delta_z]) + new_coordinates = coordinates + displacement + fieldassignment = coordinates.createFieldassignment(new_coordinates) + lower_lung_group = lowerLeftLungGroup if (lung == left_lung) else lowerRightLungGroup + fieldassignment.setNodeset(lower_lung_group.getNodesetGroup(nodes)) + fieldassignment.assign() + if ventral_sharpness_factor != 0.0: - taperLungEdge(ventral_sharpness_factor, fieldmodule, coordinates, lungNodeset, halfBreadth) + taperLungEdge(ventral_sharpness_factor, fieldmodule, coordinates, lungNodeset, half_dv_size) - dorsalVentralXi = getDorsalVentralXiField(fieldmodule, coordinates, halfBreadth) + dorsalVentralXi = getDorsalVentralXiField(fieldmodule, coordinates, half_dv_size) if lungMedialCurvature != 0: bendLungMeshAroundZAxis(lungMedialCurvature, fieldmodule, coordinates, lungNodeset, stationaryPointXY=[0.0, 0.0], bias=medial_curvature_bias, dorsalVentralXi=dorsalVentralXi) - translate_nodeset_coordinates(lungNodeset, coordinates, [spacing, 0, -zOffset]) + translate_nodeset_coordinates(lungNodeset, coordinates, [spacing, 0.0, 0.0]) return annotation_groups, None @@ -442,14 +500,14 @@ def defineFaceAnnotations(cls, region, options, annotation_groups): is_on_ellipsoid = fm.createFieldAnd(fm.createFieldAnd(is_exterior_face_xi3_1, is_trans), fm.createFieldNot(is_box)) - is_face_box20_trans10 = fm.createFieldOr( - fm.createFieldAnd(is_box, is_face_xi2_0), fm.createFieldAnd(is_trans, is_face_xi1_0)) + # is_face_box20_trans10 = fm.createFieldOr( + # fm.createFieldAnd(is_box, is_face_xi2_0), fm.createFieldAnd(is_trans, is_face_xi1_0)) is_face_xi1_0_or_xi1_1_or_xi2_0 = fm.createFieldOr( fm.createFieldOr(is_face_xi1_0, is_face_xi1_1), is_face_xi2_0) is_face_xi1_0_or_xi1_1_or_xi2_1 = fm.createFieldOr( fm.createFieldOr(is_face_xi1_0, is_face_xi1_1), is_face_xi2_1) - is_face_box21_trans11 = fm.createFieldOr( - fm.createFieldAnd(is_box, is_face_xi2_1), fm.createFieldAnd(is_trans, is_face_xi1_1)) + # is_face_box21_trans11 = fm.createFieldOr( + # fm.createFieldAnd(is_box, is_face_xi2_1), fm.createFieldAnd(is_trans, is_face_xi1_1)) is_face_box30_trans20 = fm.createFieldOr( fm.createFieldAnd(is_box, is_face_xi3_0), fm.createFieldAnd(is_trans, is_face_xi2_0)) is_face_box31_trans21 = fm.createFieldOr( @@ -658,16 +716,16 @@ def rotateLungMeshAboutAxis(rotateAngle, fm, coordinates, lungNodesetGroup, axis fieldassignment.assign() -def getDorsalVentralXiField(fm, coordinates, halfBreadth): +def getDorsalVentralXiField(fm, coordinates, half_dv_size): """ Get a field varying from 0.0 on dorsal tip to 1.0 on ventral tip on [-axisLength, axisLength] :param fm: Field module being worked with. :param coordinates: The coordinate field, initially circular in y-z plane. - :param halfBreadth: Half breadth of lung. + :param half_dv_size: Half breadth of lung. :return: Scalar Xi field. """ - hl = fm.createFieldConstant(halfBreadth) - fl = fm.createFieldConstant(2.0 * halfBreadth) + hl = fm.createFieldConstant(half_dv_size) + fl = fm.createFieldConstant(2.0 * half_dv_size) y = fm.createFieldComponent(coordinates, 2) return (y + hl) / fl @@ -743,23 +801,3 @@ def taperLungEdge(sharpeningFactor, fm, coordinates, lungNodesetGroup, halfValue fieldassignment = coordinates.createFieldassignment(new_coordinates) fieldassignment.setNodeset(lungNodesetGroup) fieldassignment.assign() - - -def setBaseGroupThreshold(fm, coordinates, halfBreadth, rotateAngle): - """ - Creates a field to identify lung base elements based on y-coordinate threshold. - Elements with y-coordinates below 45% of the rotated half-breadth are considered part of the lung base region for - annotation purposes. - :param fm: Field module used for creating and managing fields. - :param coordinates: The coordinate field. - :param halfBreadth: Half breadth of lung. - :param rotateAngle: The angle of rotation of horizontal line in radians (90 - oblique fissure angle). - :return is_above_threshold: True for elements below the y-threshold (base region). - """ - y_component = fm.createFieldComponent(coordinates, [2]) - y_threshold = 0.45 * halfBreadth * math.cos(rotateAngle) - - y_threshold_field = fm.createFieldConstant(y_threshold) - is_above_threshold = fm.createFieldLessThan(y_component, y_threshold_field) - - return is_above_threshold diff --git a/src/scaffoldmaker/utils/ellipsoidmesh.py b/src/scaffoldmaker/utils/ellipsoidmesh.py index b939ad2f..9cf72446 100644 --- a/src/scaffoldmaker/utils/ellipsoidmesh.py +++ b/src/scaffoldmaker/utils/ellipsoidmesh.py @@ -153,7 +153,7 @@ def build(self, axis2_x_rotation_radians, axis3_x_rotation_radians): self.copy_to_negative_axis1() def build_octant(self, half_counts, axis2_x_rotation_radians, axis3_x_rotation_radians, - axis2_extension=0.0, axis2_extension_elements_count=0, normal_face_factor=0.0): + axis2_extension=0.0, axis2_extension_elements_count=0): """ Get coordinates of top, right, front octant with supplied angles. :param half_counts: Numbers of elements across octant 1, 2 and 3 directions. @@ -162,7 +162,6 @@ def build_octant(self, half_counts, axis2_x_rotation_radians, axis3_x_rotation_r :param axis2_extension: Extension distance along axis2 beyond origin [0.0, 0.0, 0.0]. :param axis2_extension_elements_count: If axis2_extension: number of elements beyond origin. Note: included in half_counts[1]. - :param normal_face_factor: 0.0 for interpolated face normals, up to 1.0 for fully normal to axis surface. :return: HexTetrahedronMesh """ assert ((axis2_extension == 0.0) and (axis2_extension_elements_count == 0)) or ( From 7e03e9907c9a06d024454b2553d2ce43cb5642e8 Mon Sep 17 00:00:00 2001 From: Richard Christie Date: Fri, 21 Nov 2025 16:20:24 +1300 Subject: [PATCH 09/24] Make function for base concavity --- .../meshtypes/meshtype_3d_lung4.py | 116 ++++++++++-------- 1 file changed, 68 insertions(+), 48 deletions(-) diff --git a/src/scaffoldmaker/meshtypes/meshtype_3d_lung4.py b/src/scaffoldmaker/meshtypes/meshtype_3d_lung4.py index f26f41c1..52a6323f 100644 --- a/src/scaffoldmaker/meshtypes/meshtype_3d_lung4.py +++ b/src/scaffoldmaker/meshtypes/meshtype_3d_lung4.py @@ -383,54 +383,10 @@ def generateBaseMesh(cls, region, options): lungMedialCurvature = -medial_curvature if is_left else medial_curvature if lower_lobe_base_concavity > 0.0: - cos_pi__3 = math.cos(pi__3) - sin_pi__3 = math.sin(pi__3) - value0 = fieldmodule.createFieldConstant(0.0) - value1 = fieldmodule.createFieldConstant(1.0) - value2 = fieldmodule.createFieldConstant(2.0) - value3 = fieldmodule.createFieldConstant(3.0) - x = fieldmodule.createFieldComponent(coordinates, 1) - y = fieldmodule.createFieldComponent(coordinates, 2) - z = fieldmodule.createFieldComponent(coordinates, 3) - xx = x * x - yy = y * y - minus_c = fieldmodule.createFieldConstant(-half_height) - nz = z / minus_c - zfact = fieldmodule.createFieldSqrt(value1 - nz * nz) - a = fieldmodule.createFieldConstant(half_ml_size) * zfact - b = fieldmodule.createFieldConstant(half_dv_size) * zfact - aa = a * a - bb = b * b - r = fieldmodule.createFieldSqrt((xx / aa) + (yy / bb)) - rr = r * r - phi_r = value1 - rr - z_ext = -lower_lobe_extension * sin_pi__3 - e = z / fieldmodule.createFieldConstant(z_ext) - ee = e * e - eee = ee * e - phi_e = fieldmodule.createFieldIf(fieldmodule.createFieldLessThan(e, value0), value0, - value3 * ee - value2 * eee) - # reduce concavity to zero at oblique fissure - y_ext = fieldmodule.createFieldConstant(lower_lobe_extension * cos_pi__3) - y_max = b + y_ext - d = value1 + (y - e * y_ext) / y_max - dd = d * d - phi_y = value1 - dd - - phi = phi_e * phi_r * phi_y - delta_y = phi * fieldmodule.createFieldConstant(-lower_lobe_base_concavity * cos_pi__3 / sin_pi__3) - delta_z = phi * fieldmodule.createFieldConstant(lower_lobe_base_concavity) - # get span a at displaced z, to calculate delta_x - displ_nz = (z + delta_z) / minus_c - displ_zfact = fieldmodule.createFieldSqrt(value1 - displ_nz * displ_nz) - displ_a = fieldmodule.createFieldConstant(half_ml_size) * displ_zfact - delta_x = (x * displ_a / a - x) - displacement = fieldmodule.createFieldConcatenate([delta_x, delta_y, delta_z]) - new_coordinates = coordinates + displacement - fieldassignment = coordinates.createFieldassignment(new_coordinates) - lower_lung_group = lowerLeftLungGroup if (lung == left_lung) else lowerRightLungGroup - fieldassignment.setNodeset(lower_lung_group.getNodesetGroup(nodes)) - fieldassignment.assign() + form_lower_lobe_base_concavity( + lower_lobe_base_concavity, lower_lobe_extension, half_ml_size, half_dv_size, half_height, + fieldmodule, nodes, coordinates, + lower_lobe_group=lowerLeftLungGroup if (lung == left_lung) else lowerRightLungGroup) if ventral_sharpness_factor != 0.0: taperLungEdge(ventral_sharpness_factor, fieldmodule, coordinates, lungNodeset, half_dv_size) @@ -764,6 +720,70 @@ def bendLungMeshAroundZAxis(curvature, fm, coordinates, lungNodesetGroup, statio fieldassignment.assign() +def form_lower_lobe_base_concavity(lower_lobe_base_concavity, lower_lobe_extension, + half_ml_size, half_dv_size, half_height, + fieldmodule, nodes, coordinates, lower_lobe_group): + """ + Reshape the base of the lower lobe to be concave, tapering off to the oblique fissure. + :param lower_lobe_base_concavity: Lower lobe base concavity distance. Note this is reduced by the taper. + :param lower_lobe_extension: Distance along oblique fissure below origin that lower lobe extends. + :param half_ml_size: Half medial-lateral ellipsoid size. + :param half_dv_size: Half dorsal-ventral ellipsoid size. + :param half_height: Half ellipsoid height. + :param fieldmodule: Fieldmodule owning fields to modify. + :param nodes: Nodeset to modify. + :param coordinates: Coordinate field to modify. + :param lower_lobe_group: Group to form concave base on: left or right lower lobe. + """ + pi__3 = math.pi / 3.0 + cos_pi__3 = math.cos(pi__3) + sin_pi__3 = math.sin(pi__3) + value0 = fieldmodule.createFieldConstant(0.0) + value1 = fieldmodule.createFieldConstant(1.0) + value2 = fieldmodule.createFieldConstant(2.0) + value3 = fieldmodule.createFieldConstant(3.0) + x = fieldmodule.createFieldComponent(coordinates, 1) + y = fieldmodule.createFieldComponent(coordinates, 2) + z = fieldmodule.createFieldComponent(coordinates, 3) + xx = x * x + yy = y * y + minus_c = fieldmodule.createFieldConstant(-half_height) + nz = z / minus_c + zfact = fieldmodule.createFieldSqrt(value1 - nz * nz) + a = fieldmodule.createFieldConstant(half_ml_size) * zfact + b = fieldmodule.createFieldConstant(half_dv_size) * zfact + aa = a * a + bb = b * b + r = fieldmodule.createFieldSqrt((xx / aa) + (yy / bb)) + rr = r * r + phi_r = value1 - rr + z_ext = -lower_lobe_extension * sin_pi__3 + e = z / fieldmodule.createFieldConstant(z_ext) + ee = e * e + eee = ee * e + phi_e = fieldmodule.createFieldIf(fieldmodule.createFieldLessThan(e, value0), value0, + value3 * ee - value2 * eee) + # taper concavity to zero at oblique fissure + y_ext = fieldmodule.createFieldConstant(lower_lobe_extension * cos_pi__3) + y_max = b + y_ext + d = value1 + (y - e * y_ext) / y_max + dd = d * d + phi_y = value1 - dd + phi = phi_e * phi_r * phi_y + delta_y = phi * fieldmodule.createFieldConstant(-lower_lobe_base_concavity * cos_pi__3 / sin_pi__3) + delta_z = phi * fieldmodule.createFieldConstant(lower_lobe_base_concavity) + # get span a at displaced z, to calculate delta_x + displ_nz = (z + delta_z) / minus_c + displ_zfact = fieldmodule.createFieldSqrt(value1 - displ_nz * displ_nz) + displ_a = fieldmodule.createFieldConstant(half_ml_size) * displ_zfact + delta_x = (x * displ_a / a - x) + displacement = fieldmodule.createFieldConcatenate([delta_x, delta_y, delta_z]) + new_coordinates = coordinates + displacement + fieldassignment = coordinates.createFieldassignment(new_coordinates) + fieldassignment.setNodeset(lower_lobe_group.getNodesetGroup(nodes)) + fieldassignment.assign() + + def taperLungEdge(sharpeningFactor, fm, coordinates, lungNodesetGroup, halfValue, isBase=False): """ Applies a tapering transformation to the lung geometry to sharpen the anterior edge or the base. From c2f5c9152223c12d6d90697c4ceff09fda6317bf Mon Sep 17 00:00:00 2001 From: Richard Christie Date: Wed, 26 Nov 2025 13:00:58 +1300 Subject: [PATCH 10/24] Clean up lung4 --- .../meshtypes/meshtype_3d_lung4.py | 359 +++++++++--------- 1 file changed, 171 insertions(+), 188 deletions(-) diff --git a/src/scaffoldmaker/meshtypes/meshtype_3d_lung4.py b/src/scaffoldmaker/meshtypes/meshtype_3d_lung4.py index 52a6323f..2dd4e96e 100644 --- a/src/scaffoldmaker/meshtypes/meshtype_3d_lung4.py +++ b/src/scaffoldmaker/meshtypes/meshtype_3d_lung4.py @@ -44,11 +44,10 @@ def getDefaultOptions(cls, parameterSetName="Default"): useParameterSetName = "Human 1 Coarse" if (parameterSetName == "Default") else parameterSetName options["Left lung"] = True options["Right lung"] = True - height = options["Ellipsoid height"] = 1.0 - depth = options["Ellipsoid dorsal-ventral size"] = 0.8 + options["Ellipsoid height"] = 1.0 + options["Ellipsoid dorsal-ventral size"] = 0.8 options["Ellipsoid medial-lateral size"] = 0.4 options["Left-right lung spacing"] = 0.5 - options["Lower lobe base concavity"] = 0.1 options["Lower lobe extension"] = 0.3 options["Refine"] = False options["Refine number of elements"] = 4 @@ -70,13 +69,13 @@ def getDefaultOptions(cls, parameterSetName="Default"): options["Number of transition elements"] = 1 if "Human" in useParameterSetName: - options["Ventral edge sharpness factor"] = 0.8 - options["Medial curvature"] = 3.0 - options["Medial curvature bias"] = 1.0 + options["Lower lobe base concavity"] = 0.1 + options["Ventral edge sharpness factor"] = 0.9 + options["Cardiac curvature"] = 2.0 else: + options["Lower lobe base concavity"] = 0.0 options["Ventral edge sharpness factor"] = 0.0 - options["Medial curvature"] = 0.0 - options["Medial curvature bias"] = 0.0 + options["Cardiac curvature"] = 0.0 return options @@ -97,8 +96,7 @@ def getOrderedOptionNames(cls): "Lower lobe base concavity", "Lower lobe extension", "Ventral edge sharpness factor", - "Medial curvature", - "Medial curvature bias", + "Cardiac curvature", "Refine", "Refine number of elements" ] @@ -141,7 +139,8 @@ def checkOptions(cls, options): options[key] = 1.0 for key in [ "Lower lobe base concavity", - "Lower lobe extension" + "Lower lobe extension", + "Cardiac curvature" ]: if options[key] < 0.0: options[key] = 0.0 @@ -158,8 +157,7 @@ def checkOptions(cls, options): options["Left-right lung spacing"] = 0.0 for dimension in [ - "Ventral edge sharpness factor", - "Medial curvature bias" + "Ventral edge sharpness factor" ]: if options[dimension] < 0.0: options[dimension] = 0.0 @@ -190,11 +188,10 @@ def generateBaseMesh(cls, region, options): lower_lobe_extension = options["Lower lobe extension"] lower_lobe_base_concavity = options["Lower lobe base concavity"] ventral_sharpness_factor = options["Ventral edge sharpness factor"] + cardiac_curvature = options["Cardiac curvature"] ellipsoid_ml_size = options["Ellipsoid medial-lateral size"] ellipsoid_dv_size = options["Ellipsoid dorsal-ventral size"] ellipsoid_height = options["Ellipsoid height"] - medial_curvature = options["Medial curvature"] - medial_curvature_bias = options["Medial curvature bias"] fieldmodule = region.getFieldmodule() coordinates = find_or_create_field_coordinates(fieldmodule) @@ -202,47 +199,44 @@ def generateBaseMesh(cls, region, options): mesh = fieldmodule.findMeshByDimension(3) # annotation groups & nodeset groups - lungGroup = AnnotationGroup(region, get_lung_term("lung")) + lung_group = AnnotationGroup(region, get_lung_term("lung")) if has_left_lung: - leftLungGroup = AnnotationGroup(region, get_lung_term("left lung")) - lowerLeftLungGroup = AnnotationGroup(region, get_lung_term("lower lobe of left lung")) - upperLeftLungGroup = AnnotationGroup(region, get_lung_term("upper lobe of left lung")) - leftAnteriorLungGroup = AnnotationGroup(region, ("anterior left lung", "")) - leftLateralLungGroup = AnnotationGroup(region, ("lateral left lung", "")) - leftMedialLungGroup = AnnotationGroup(region, ("medial left lung", "")) - left_annotation_groups = [leftLungGroup, lowerLeftLungGroup, upperLeftLungGroup, - leftAnteriorLungGroup, leftLateralLungGroup, leftMedialLungGroup] + left_lung_group = AnnotationGroup(region, get_lung_term("left lung")) + lower_left_lung_group = AnnotationGroup(region, get_lung_term("lower lobe of left lung")) + upper_left_lung_group = AnnotationGroup(region, get_lung_term("upper lobe of left lung")) + left_anterior_lung_group = AnnotationGroup(region, ("anterior left lung", "")) + left_lateral_lung_group = AnnotationGroup(region, ("lateral left lung", "")) + left_medial_lung_group = AnnotationGroup(region, ("medial left lung", "")) + left_annotation_groups = [left_lung_group, lower_left_lung_group, upper_left_lung_group, + left_anterior_lung_group, left_lateral_lung_group, left_medial_lung_group] else: - leftLungGroup = lowerLeftLungGroup = upperLeftLungGroup = None - leftLateralLungGroup = leftMedialLungGroup = leftAnteriorLungGroup = None + left_lung_group = lower_left_lung_group = upper_left_lung_group = None + left_lateral_lung_group = left_medial_lung_group = left_anterior_lung_group = None left_annotation_groups = [] if has_right_lung: - rightLungGroup = AnnotationGroup(region, get_lung_term("right lung")) - lowerRightLungGroup = AnnotationGroup(region, get_lung_term("lower lobe of right lung")) - middleRightLungGroup = AnnotationGroup(region, get_lung_term("middle lobe of right lung")) - upperRightLungGroup = AnnotationGroup(region, get_lung_term("upper lobe of right lung")) - rightAnteriorLungGroup = AnnotationGroup(region, ("anterior right lung", "")) - rightLateralLungGroup = AnnotationGroup(region, ("lateral right lung", "")) - rightMedialLungGroup = AnnotationGroup(region, ("medial right lung", "")) - right_annotation_groups = [rightLungGroup, lowerRightLungGroup, middleRightLungGroup, upperRightLungGroup, - rightAnteriorLungGroup, rightLateralLungGroup, rightMedialLungGroup] + right_lung_group = AnnotationGroup(region, get_lung_term("right lung")) + lower_right_lung_group = AnnotationGroup(region, get_lung_term("lower lobe of right lung")) + middle_right_lung_group = AnnotationGroup(region, get_lung_term("middle lobe of right lung")) + upper_right_lung_group = AnnotationGroup(region, get_lung_term("upper lobe of right lung")) + right_anterior_lung_group = AnnotationGroup(region, ("anterior right lung", "")) + right_lateral_lung_group = AnnotationGroup(region, ("lateral right lung", "")) + right_medial_lung_group = AnnotationGroup(region, ("medial right lung", "")) + right_annotation_groups = [ + right_lung_group, lower_right_lung_group, middle_right_lung_group, upper_right_lung_group, + right_anterior_lung_group, right_lateral_lung_group, right_medial_lung_group] else: - rightLungGroup = lowerRightLungGroup = middleRightLungGroup = upperRightLungGroup = None - rightAnteriorLungGroup = rightLateralLungGroup = rightMedialLungGroup = None + right_lung_group = lower_right_lung_group = middle_right_lung_group = upper_right_lung_group = None + right_anterior_lung_group = right_lateral_lung_group = right_medial_lung_group = None right_annotation_groups = [] box_group = AnnotationGroup(region, ("box", "")) transition_group = AnnotationGroup(region, ("transition", "")) - annotation_groups = [lungGroup] + left_annotation_groups + right_annotation_groups + \ + annotation_groups = [lung_group] + left_annotation_groups + right_annotation_groups + \ [box_group, transition_group] - # Nodeset group - leftLungNodesetGroup = leftLungGroup.getNodesetGroup(nodes) if leftLungGroup else None - rightLungNodesetGroup = rightLungGroup.getNodesetGroup(nodes) if rightLungGroup else None - half_ml_size = ellipsoid_ml_size * 0.5 half_dv_size = ellipsoid_dv_size * 0.5 half_height = ellipsoid_height * 0.5 @@ -251,7 +245,7 @@ def generateBaseMesh(cls, region, options): left_lung, right_lung = 0, 1 lungs = [lung for show, lung in [(has_left_lung, left_lung), (has_right_lung, right_lung)] if show] - nodeIdentifier, elementIdentifier = 1, 1 + node_identifier, element_identifier = 1, 1 # currently build left lung if right lung is being built to get correct node/element identifiers lungs_construct = [left_lung, right_lung] if has_right_lung else [left_lung] if has_left_lung else [] @@ -263,13 +257,15 @@ def generateBaseMesh(cls, region, options): lower_octant_group_lists = [] upper_octant_group_lists = [] for octant in range(8): - octant_group_list = [group.getGroup() for group in [lungGroup, leftLungGroup, lowerLeftLungGroup] + - [leftMedialLungGroup if (octant & 1) else leftLateralLungGroup]] + octant_group_list = [group.getGroup() for group in + [lung_group, left_lung_group, lower_left_lung_group] + + [left_medial_lung_group if (octant & 1) else left_lateral_lung_group]] lower_octant_group_lists.append(octant_group_list) - octant_group_list = [group.getGroup() for group in [lungGroup, leftLungGroup, upperLeftLungGroup] + - [leftMedialLungGroup if (octant & 1) else leftLateralLungGroup]] + octant_group_list = [group.getGroup() for group in + [lung_group, left_lung_group, upper_left_lung_group] + + [left_medial_lung_group if (octant & 1) else left_lateral_lung_group]] if octant & 2: - octant_group_list.append(leftAnteriorLungGroup.getGroup()) + octant_group_list.append(left_anterior_lung_group.getGroup()) upper_octant_group_lists.append(octant_group_list) else: lower_octant_group_lists = upper_octant_group_lists = None @@ -279,34 +275,40 @@ def generateBaseMesh(cls, region, options): middle_octant_group_lists = [] upper_octant_group_lists = [] for octant in range(8): - octant_group_list = [group.getGroup() for group in [lungGroup, rightLungGroup, lowerRightLungGroup] + - [rightLateralLungGroup if (octant & 1) else rightMedialLungGroup]] + octant_group_list = [group.getGroup() for group in + [lung_group, right_lung_group, lower_right_lung_group] + + [right_lateral_lung_group if (octant & 1) else right_medial_lung_group]] lower_octant_group_lists.append(octant_group_list) - octant_group_list = [group.getGroup() for group in [lungGroup, rightLungGroup, middleRightLungGroup] + - [rightLateralLungGroup if (octant & 1) else rightMedialLungGroup]] + octant_group_list = [group.getGroup() for group in + [lung_group, right_lung_group, middle_right_lung_group] + + [right_lateral_lung_group if (octant & 1) else right_medial_lung_group]] if octant & 2: - octant_group_list.append(rightAnteriorLungGroup.getGroup()) + octant_group_list.append(right_anterior_lung_group.getGroup()) middle_octant_group_lists.append(octant_group_list) - octant_group_list = [group.getGroup() for group in [lungGroup, rightLungGroup, upperRightLungGroup] + - [rightLateralLungGroup if (octant & 1) else rightMedialLungGroup]] + octant_group_list = [group.getGroup() for group in + [lung_group, right_lung_group, upper_right_lung_group] + + [right_lateral_lung_group if (octant & 1) else right_medial_lung_group]] if octant & 2: - octant_group_list.append(rightAnteriorLungGroup.getGroup()) + octant_group_list.append(right_anterior_lung_group.getGroup()) upper_octant_group_lists.append(octant_group_list) - elementCounts = [elements_count_lateral, elements_count_oblique, elements_count_oblique] - lower_ellipsoid = EllipsoidMesh(half_ml_size, half_dv_size, half_height, elementCounts, elements_count_transition) + element_counts = [elements_count_lateral, elements_count_oblique, elements_count_oblique] + lower_ellipsoid = EllipsoidMesh( + half_ml_size, half_dv_size, half_height, element_counts, elements_count_transition) lower_ellipsoid.set_surface_d3_mode(EllipsoidSurfaceD3Mode.SURFACE_NORMAL_PLANE_PROJECTION) lower_ellipsoid.set_box_transition_groups(box_group.getGroup(), transition_group.getGroup()) - upper_ellipsoid = EllipsoidMesh(half_ml_size, half_dv_size, half_height, elementCounts, elements_count_transition) + upper_ellipsoid = EllipsoidMesh( + half_ml_size, half_dv_size, half_height, element_counts, elements_count_transition) upper_ellipsoid.set_surface_d3_mode(EllipsoidSurfaceD3Mode.SURFACE_NORMAL_PLANE_PROJECTION) upper_ellipsoid.set_box_transition_groups(box_group.getGroup(), transition_group.getGroup()) if lung == right_lung: - middle_ellipsoid = EllipsoidMesh(half_ml_size, half_dv_size, half_height, elementCounts, elements_count_transition) + middle_ellipsoid = EllipsoidMesh( + half_ml_size, half_dv_size, half_height, element_counts, elements_count_transition) middle_ellipsoid.set_surface_d3_mode(EllipsoidSurfaceD3Mode.SURFACE_NORMAL_PLANE_PROJECTION) middle_ellipsoid.set_box_transition_groups(box_group.getGroup(), transition_group.getGroup()) else: middle_ellipsoid = upper_ellipsoid - half_counts = [count // 2 for count in elementCounts] + half_counts = [count // 2 for count in element_counts] octant1 = middle_ellipsoid.build_octant(half_counts, -pi__3, 0.0) middle_ellipsoid.merge_octant(octant1, quadrant=3) if lung == right_lung: @@ -316,7 +318,7 @@ def generateBaseMesh(cls, region, options): hilum_x = [] ox = octant1.get_parameters() box_count1 = octant1.get_box_counts()[0] - for n1 in range(elementCounts[0] + 1): + for n1 in range(element_counts[0] + 1): mirror_x = n1 < half_counts[0] o1 = abs(n1 - half_counts[0]) parameters = ox[0][0][o1] @@ -343,7 +345,7 @@ def generateBaseMesh(cls, region, options): # merge into separate lower ellipsoid to have space for extension elements lower_ellipsoid_mesh = EllipsoidMesh( half_ml_size, half_dv_size, half_height, - [elementCounts[0], elementCounts[1], elementCounts[2] + 2 * elements_count_lower_extension], + [element_counts[0], element_counts[1], element_counts[2] + 2 * elements_count_lower_extension], elements_count_transition) lower_ellipsoid_mesh.set_surface_d3_mode(EllipsoidSurfaceD3Mode.SURFACE_NORMAL_PLANE_PROJECTION) lower_ellipsoid_mesh.set_box_transition_groups(box_group.getGroup(), transition_group.getGroup()) @@ -352,20 +354,20 @@ def generateBaseMesh(cls, region, options): node_layout_manager = lower_ellipsoid.get_node_layout_manager() node_layout_permuted = node_layout_manager.getNodeLayoutRegularPermuted(d3Defined=True) - for n1 in range(elementCounts[0] + 1): + for n1 in range(element_counts[0] + 1): lower_ellipsoid_mesh.set_node_parameters( - n1, half_counts[1], elementCounts[2] + 2 * elements_count_lower_extension - half_counts[2], hilum_x[n1], - node_layout=node_layout_permuted) + n1, half_counts[1], element_counts[2] + 2 * elements_count_lower_extension - half_counts[2], + hilum_x[n1], node_layout=node_layout_permuted) lower_ellipsoid_mesh.set_octant_group_lists(lower_octant_group_lists) - nodeIdentifier, elementIdentifier = lower_ellipsoid_mesh.generate_mesh( - fieldmodule, coordinates, nodeIdentifier, elementIdentifier) + node_identifier, element_identifier = lower_ellipsoid_mesh.generate_mesh( + fieldmodule, coordinates, node_identifier, element_identifier) for ellipsoid in [middle_ellipsoid, upper_ellipsoid] if (lung == right_lung) else [upper_ellipsoid]: node_layout_manager = ellipsoid.get_node_layout_manager() node_layout_6way = node_layout_manager.getNodeLayout6Way12(d3Defined=True) - for n1 in range(elementCounts[0] + 1): + for n1 in range(element_counts[0] + 1): nid = (lower_ellipsoid_mesh.get_node_identifier( - n1, half_counts[1], elementCounts[2] + 2 * elements_count_lower_extension - half_counts[2]) + n1, half_counts[1], element_counts[2] + 2 * elements_count_lower_extension - half_counts[2]) if (((lung == left_lung) and (n1 >= half_counts[0])) or ((lung == right_lung) and (n1 <= half_counts[0]))) else None) ellipsoid.set_node_parameters(n1, half_counts[1], half_counts[2], hilum_x[n1], @@ -373,32 +375,28 @@ def generateBaseMesh(cls, region, options): ellipsoid.set_octant_group_lists( middle_octant_group_lists if ((ellipsoid == middle_ellipsoid) and (ellipsoid != upper_ellipsoid)) else upper_octant_group_lists) - nodeIdentifier, elementIdentifier = ellipsoid.generate_mesh( - fieldmodule, coordinates, nodeIdentifier, elementIdentifier) + node_identifier, element_identifier = ellipsoid.generate_mesh( + fieldmodule, coordinates, node_identifier, element_identifier) for lung in lungs: is_left = lung == left_lung - lungNodeset = leftLungNodesetGroup if is_left else rightLungNodesetGroup - spacing = -lung_spacing if is_left else lung_spacing - lungMedialCurvature = -medial_curvature if is_left else medial_curvature + lung_nodeset = (left_lung_group if is_left else right_lung_group).getNodesetGroup(nodes) if lower_lobe_base_concavity > 0.0: + lower_lobe_group = lower_left_lung_group if (lung == left_lung) else lower_right_lung_group form_lower_lobe_base_concavity( lower_lobe_base_concavity, lower_lobe_extension, half_ml_size, half_dv_size, half_height, - fieldmodule, nodes, coordinates, - lower_lobe_group=lowerLeftLungGroup if (lung == left_lung) else lowerRightLungGroup) + fieldmodule, coordinates, lower_lobe_group.getNodesetGroup(nodes)) if ventral_sharpness_factor != 0.0: - taperLungEdge(ventral_sharpness_factor, fieldmodule, coordinates, lungNodeset, half_dv_size) + taper_lung_edge(ventral_sharpness_factor, fieldmodule, coordinates, lung_nodeset, half_dv_size) - dorsalVentralXi = getDorsalVentralXiField(fieldmodule, coordinates, half_dv_size) - if lungMedialCurvature != 0: - bendLungMeshAroundZAxis(lungMedialCurvature, fieldmodule, coordinates, lungNodeset, - stationaryPointXY=[0.0, 0.0], - bias=medial_curvature_bias, - dorsalVentralXi=dorsalVentralXi) + if cardiac_curvature > 0.0: + curve_cardiac_anterior(-cardiac_curvature if is_left else cardiac_curvature, + half_ml_size, half_dv_size, half_height, fieldmodule, coordinates, lung_nodeset) - translate_nodeset_coordinates(lungNodeset, coordinates, [spacing, 0.0, 0.0]) + translate_nodeset_coordinates(lung_nodeset, coordinates, + [-lung_spacing if is_left else lung_spacing, 0.0, 0.0]) return annotation_groups, None @@ -411,8 +409,9 @@ def refineMesh(cls, meshRefinement, options): :param options: Dict containing options. See getDefaultOptions(). """ assert isinstance(meshRefinement, MeshRefinement) - refineElementsCount = options['Refine number of elements'] - meshRefinement.refineAllElementsCubeStandard3d(refineElementsCount, refineElementsCount, refineElementsCount) + refine_elements_count = options['Refine number of elements'] + meshRefinement.refineAllElementsCubeStandard3d( + refine_elements_count, refine_elements_count, refine_elements_count) @classmethod def defineFaceAnnotations(cls, region, options, annotation_groups): @@ -456,14 +455,10 @@ def defineFaceAnnotations(cls, region, options, annotation_groups): is_on_ellipsoid = fm.createFieldAnd(fm.createFieldAnd(is_exterior_face_xi3_1, is_trans), fm.createFieldNot(is_box)) - # is_face_box20_trans10 = fm.createFieldOr( - # fm.createFieldAnd(is_box, is_face_xi2_0), fm.createFieldAnd(is_trans, is_face_xi1_0)) is_face_xi1_0_or_xi1_1_or_xi2_0 = fm.createFieldOr( fm.createFieldOr(is_face_xi1_0, is_face_xi1_1), is_face_xi2_0) is_face_xi1_0_or_xi1_1_or_xi2_1 = fm.createFieldOr( fm.createFieldOr(is_face_xi1_0, is_face_xi1_1), is_face_xi2_1) - # is_face_box21_trans11 = fm.createFieldOr( - # fm.createFieldAnd(is_box, is_face_xi2_1), fm.createFieldAnd(is_trans, is_face_xi1_1)) is_face_box30_trans20 = fm.createFieldOr( fm.createFieldAnd(is_box, is_face_xi3_0), fm.createFieldAnd(is_trans, is_face_xi2_0)) is_face_box31_trans21 = fm.createFieldOr( @@ -498,7 +493,7 @@ def defineFaceAnnotations(cls, region, options, annotation_groups): } face_term_conditionals_map.update(left_face_term_conditionals_map) else: - left_face_term_conditionals_map = {} + is_lower_left = is_upper_left = is_lateral_left = is_medial_left = None if has_right_lung: is_lower_right = getAnnotationGroupForTerm(annotation_groups, get_lung_term("lower lobe of right lung")).getGroup() @@ -530,7 +525,7 @@ def defineFaceAnnotations(cls, region, options, annotation_groups): } face_term_conditionals_map.update(right_face_term_conditionals_map) else: - right_face_term_conditionals_map = {} + is_lower_right = is_middle_right = is_upper_right = is_lateral_right = is_medial_right = None is_face_conditional = {} for face_term, conditionals in face_term_conditionals_map.items(): @@ -540,7 +535,6 @@ def defineFaceAnnotations(cls, region, options, annotation_groups): for add_conditional in conditionals[1:]: conditional = fm.createFieldAnd(conditional, add_conditional) annotation_group.getMeshGroup(mesh2d).addElementsConditional(conditional) - print(conditional.isValid(), "Term:", face_term, "sizes", mesh2d.getSize(), group.getMeshGroup(mesh2d).isValid(), group.getMeshGroup(mesh2d).getSize(), group.getMeshGroup(mesh1d).getSize()) annotation_group.addSubelements() is_face_conditional[face_term] = group @@ -572,8 +566,6 @@ def defineFaceAnnotations(cls, region, options, annotation_groups): (is_lower_left, is_lateral_left, is_medial_left, is_on_ellipsoid), } line_term_conditionals_map.update(left_line_term_conditionals_map) - else: - left_line_term_conditionals_map = {} if has_right_lung: right_line_term_conditionals_map = { @@ -615,8 +607,6 @@ def defineFaceAnnotations(cls, region, options, annotation_groups): (is_lower_right, is_lateral_right, is_medial_right, is_on_ellipsoid) } line_term_conditionals_map.update(right_line_term_conditionals_map) - else: - right_line_term_conditionals_map = {} for line_term, conditionals in line_term_conditionals_map.items(): annotation_group = findOrCreateAnnotationGroupForTerm(annotation_groups, region, (line_term, "")) @@ -625,6 +615,25 @@ def defineFaceAnnotations(cls, region, options, annotation_groups): conditional = fm.createFieldAnd(conditional, add_conditional) annotation_group.getMeshGroup(mesh1d).addElementsConditional(conditional) + tweak_middle_lobe_tip_edges = True + if has_right_lung and tweak_middle_lobe_tip_edges: + # tweaks edges of base of middle lobe of right lung to include anterior edge of middle lobe + # as these edges of the base data can go sharply up the anterior edge + # but this is a difficult and undesirable fit for the middle lobe shape + is_anterior_edge = fm.findFieldByName("anterior edge of middle lobe of right lung").castGroup() + for base_edge_group_name in [ + "lateral edge of base of middle lobe of right lung", + "medial edge of base of middle lobe of right lung" + ]: + base_edge_group = fm.findFieldByName(base_edge_group_name).castGroup() + base_edge_group.getMeshGroup(mesh1d).addElementsConditional(is_anterior_edge) + is_medial_surface = fm.findFieldByName("medial surface of middle lobe of right lung").castGroup() + for base_surface_group_name in [ + "base of middle lobe of right lung surface" + ]: + base_surface_group = fm.findFieldByName(base_surface_group_name).castGroup() + base_surface_group.getMeshGroup(mesh2d).addElementsConditional(is_medial_surface) + # remove temporary annotation groups for group_name in [ "anterior left lung", @@ -641,88 +650,63 @@ def defineFaceAnnotations(cls, region, options, annotation_groups): annotation_groups.remove(annotation_group) -def rotateLungMeshAboutAxis(rotateAngle, fm, coordinates, lungNodesetGroup, axis): - """ - Rotates the lung mesh coordinates about a specified axis using the right-hand rule. - :param rotateAngle: Angle of rotation in degrees. - :param fm: Field module being worked with. - :param coordinates: The coordinate field, initially circular in y-z plane. - :param lungNodesetGroup: Zinc NodesetGroup containing nodes to transform. - :param axis: Axis of rotation. - :return: None - """ - if axis not in (2, 3): - raise ValueError("Axis must be 2 (y), or 3 (z).") - - rotateAngle = -math.radians(rotateAngle) # negative value due to right handed rule - - if axis == 2: - rotateMatrix = fm.createFieldConstant([math.cos(rotateAngle), 0.0, -math.sin(rotateAngle), - 0.0, 1.0, 0.0, - math.sin(rotateAngle), 0.0, math.cos(rotateAngle)]) - elif axis == 3: - rotateMatrix = fm.createFieldConstant([math.cos(rotateAngle), math.sin(rotateAngle), 0.0, - -math.sin(rotateAngle), math.cos(rotateAngle), 0.0, - 0.0, 0.0, 1.0]) - - rotated_coordinates = fm.createFieldMatrixMultiply(3, rotateMatrix, coordinates) - - fieldassignment = coordinates.createFieldassignment(rotated_coordinates) - fieldassignment.setNodeset(lungNodesetGroup) - fieldassignment.assign() - - -def getDorsalVentralXiField(fm, coordinates, half_dv_size): - """ - Get a field varying from 0.0 on dorsal tip to 1.0 on ventral tip on [-axisLength, axisLength] - :param fm: Field module being worked with. - :param coordinates: The coordinate field, initially circular in y-z plane. - :param half_dv_size: Half breadth of lung. - :return: Scalar Xi field. - """ - hl = fm.createFieldConstant(half_dv_size) - fl = fm.createFieldConstant(2.0 * half_dv_size) - y = fm.createFieldComponent(coordinates, 2) - return (y + hl) / fl - - -def bendLungMeshAroundZAxis(curvature, fm, coordinates, lungNodesetGroup, stationaryPointXY, bias=0.0, - dorsalVentralXi=None): +def curve_cardiac_anterior(curvature, half_ml_size, half_dv_size, half_height, fieldmodule, coordinates, nodeset): """ Transform coordinates by bending with curvature about a centre point the radius in x direction from stationaryPointXY. :param curvature: 1/radius. Must be non-zero. - :param fm: Field module being worked with. + :param half_ml_size: Half medial-lateral ellipsoid size. + :param half_dv_size: Half dorsal-ventral ellipsoid size. + :param half_height: Half ellipsoid height. + :param fieldmodule: Field module being worked with. :param coordinates: The coordinate field, initially circular in y-z plane. - :param lungNodesetGroup: Zinc NodesetGroup containing nodes to transform. - :param stationaryPointXY: Coordinates x, y which are not displaced by bending. - :param bias: 0.0 for a simple bend through the whole length, up to 1.0 for no bend at dorsal end. - :param dorsalVentralXi: Field returned by getDorsalVentralXiField if bias > 0.0: + :param nodeset: Zinc Nodeset containing nodes to transform. """ - radius = 1.0 / curvature - scale = fm.createFieldConstant([-1.0, -curvature, -1.0]) - centreOffset = [stationaryPointXY[0] - radius, stationaryPointXY[1], 0.0] - centreOfCurvature = fm.createFieldConstant(centreOffset) - polarCoordinates = (centreOfCurvature - coordinates) * scale - polarCoordinates.setCoordinateSystemType(Field.COORDINATE_SYSTEM_TYPE_CYLINDRICAL_POLAR) - rcCoordinates = fm.createFieldCoordinateTransformation(polarCoordinates) - rcCoordinates.setCoordinateSystemType(Field.COORDINATE_SYSTEM_TYPE_RECTANGULAR_CARTESIAN) - newCoordinates = rcCoordinates + centreOfCurvature - - if bias > 0.0: - one = fm.createFieldConstant(1.0) - xiS = (one - dorsalVentralXi) * fm.createFieldConstant(bias) - xiC = one - xiS - newCoordinates = (coordinates * xiS) + (newCoordinates * xiC) - - fieldassignment = coordinates.createFieldassignment(newCoordinates) - fieldassignment.setNodeset(lungNodesetGroup) + radius_of_curvature = 1.0 / curvature + x_offset = fieldmodule.createFieldConstant(radius_of_curvature) + + pi__3 = math.pi / 3.0 + cos_pi__3 = math.cos(pi__3) + sin_pi__3 = math.sin(pi__3) + value_small = fieldmodule.createFieldConstant(1.0E-10) + value05 = fieldmodule.createFieldConstant(0.5) + x = fieldmodule.createFieldComponent(coordinates, 1) + y = fieldmodule.createFieldComponent(coordinates, 2) + z = fieldmodule.createFieldComponent(coordinates, 3) + yz = fieldmodule.createFieldConcatenate([y, z]) + r = fieldmodule.createFieldMagnitude(yz) + # angle around hilum, not changing + theta = fieldmodule.createFieldAtan2(z, y) + cos_theta = fieldmodule.createFieldCos(theta) + sin_theta = fieldmodule.createFieldSin(theta) + # obliqueness factor as want maximum curvature at anterior oblique fissure, none opposite + tip_yz = getEllipsePointAtTrueAngle(half_dv_size, half_height, -pi__3) + double_max_o = fieldmodule.createFieldConstant(2.0 * magnitude(tip_yz)) + oblique = fieldmodule.createFieldConstant([cos_pi__3, -sin_pi__3]) + o = value05 + fieldmodule.createFieldDotProduct(yz, oblique) / double_max_o + phi_o = o * o # so curvature mostly at anterior edge + # radial curvature + alpha = phi_o * r / x_offset + cos_alpha = fieldmodule.createFieldCos(alpha) + sin_alpha = fieldmodule.createFieldSin(alpha) + + mod_x_offset = x_offset / phi_o + mod_offset_x = x + mod_x_offset + new_x = mod_offset_x * cos_alpha - mod_x_offset + + new_r = mod_offset_x * sin_alpha + new_y = new_r * cos_theta + new_z = new_r * sin_theta + new_coordinates = fieldmodule.createFieldIf(fieldmodule.createFieldLessThan(o, value_small), coordinates, + fieldmodule.createFieldConcatenate([new_x, new_y, new_z])) + fieldassignment = coordinates.createFieldassignment(new_coordinates) + fieldassignment.setNodeset(nodeset) fieldassignment.assign() def form_lower_lobe_base_concavity(lower_lobe_base_concavity, lower_lobe_extension, - half_ml_size, half_dv_size, half_height, - fieldmodule, nodes, coordinates, lower_lobe_group): + half_ml_size, half_dv_size, half_height, + fieldmodule, coordinates, nodeset): """ Reshape the base of the lower lobe to be concave, tapering off to the oblique fissure. :param lower_lobe_base_concavity: Lower lobe base concavity distance. Note this is reduced by the taper. @@ -731,9 +715,8 @@ def form_lower_lobe_base_concavity(lower_lobe_base_concavity, lower_lobe_extensi :param half_dv_size: Half dorsal-ventral ellipsoid size. :param half_height: Half ellipsoid height. :param fieldmodule: Fieldmodule owning fields to modify. - :param nodes: Nodeset to modify. :param coordinates: Coordinate field to modify. - :param lower_lobe_group: Group to form concave base on: left or right lower lobe. + :param nodeset: Nodeset to modify. """ pi__3 = math.pi / 3.0 cos_pi__3 = math.cos(pi__3) @@ -780,44 +763,44 @@ def form_lower_lobe_base_concavity(lower_lobe_base_concavity, lower_lobe_extensi displacement = fieldmodule.createFieldConcatenate([delta_x, delta_y, delta_z]) new_coordinates = coordinates + displacement fieldassignment = coordinates.createFieldassignment(new_coordinates) - fieldassignment.setNodeset(lower_lobe_group.getNodesetGroup(nodes)) + fieldassignment.setNodeset(nodeset) fieldassignment.assign() -def taperLungEdge(sharpeningFactor, fm, coordinates, lungNodesetGroup, halfValue, isBase=False): +def taper_lung_edge(sharpeningFactor, fieldmodule, coordinates, nodeset, halfValue, isBase=False): """ Applies a tapering transformation to the lung geometry to sharpen the anterior edge or the base. If isBase is False, it sharpens the anterior edge (along the y-axis). If isBase is True, it sharpens the base (along the z-axis), but only for nodes below a certain height. :param sharpeningFactor: A value between 0 and 1, where 1 represents the maximum sharpness. - :param fm: Field module being worked with. + :param fieldmodule: Field module being worked with. :param coordinates: The coordinate field. The anterior edge is towards the +y-axis, and the base is towards the -z-axis. - :param lungNodesetGroup: Zinc NodesetGroup containing nodes to transform. + :param nodeset: Zinc NodesetGroup containing nodes to transform. :param halfValue: Half value of lung breadth/height depending on isBase. :param isBase: False if transforming the anterior edge, True if transforming the base of the lung. """ - x = fm.createFieldComponent(coordinates, 1) - y = fm.createFieldComponent(coordinates, 2) - z = fm.createFieldComponent(coordinates, 3) + x = fieldmodule.createFieldComponent(coordinates, 1) + y = fieldmodule.createFieldComponent(coordinates, 2) + z = fieldmodule.createFieldComponent(coordinates, 3) coord_value = z if isBase else y start_value = 0.5 * halfValue if isBase else -0.5 * halfValue end_value = -1.1 * halfValue if isBase else 1.1 * halfValue - start_value_field = fm.createFieldConstant(start_value) - end_value_field = fm.createFieldConstant(end_value) + start_value_field = fieldmodule.createFieldConstant(start_value) + end_value_field = fieldmodule.createFieldConstant(end_value) - xi = (coord_value - start_value_field) / fm.createFieldConstant(end_value - start_value) + xi = (coord_value - start_value_field) / fieldmodule.createFieldConstant(end_value - start_value) xi__2 = xi * xi - one = fm.createFieldConstant(1.0) - x_scale = one - fm.createFieldConstant(sharpeningFactor) * xi__2 + one = fieldmodule.createFieldConstant(1.0) + x_scale = one - fieldmodule.createFieldConstant(sharpeningFactor) * xi__2 if isBase: - new_x = fm.createFieldIf(fm.createFieldLessThan(coord_value, start_value_field), x * x_scale, x) + new_x = fieldmodule.createFieldIf(fieldmodule.createFieldLessThan(coord_value, start_value_field), x * x_scale, x) else: - new_x = fm.createFieldIf(fm.createFieldGreaterThan(coord_value, start_value_field), x * x_scale, x) - new_coordinates = fm.createFieldConcatenate([new_x, y, z]) + new_x = fieldmodule.createFieldIf(fieldmodule.createFieldGreaterThan(coord_value, start_value_field), x * x_scale, x) + new_coordinates = fieldmodule.createFieldConcatenate([new_x, y, z]) fieldassignment = coordinates.createFieldassignment(new_coordinates) - fieldassignment.setNodeset(lungNodesetGroup) + fieldassignment.setNodeset(nodeset) fieldassignment.assign() From ad1ae2774381dcd87f202ae39e6b63a1b07f8ce4 Mon Sep 17 00:00:00 2001 From: Richard Christie Date: Wed, 26 Nov 2025 13:03:19 +1300 Subject: [PATCH 11/24] Add lung4 test --- tests/test_lung.py | 95 +++++++++++++++++++++++++++++----------------- 1 file changed, 61 insertions(+), 34 deletions(-) diff --git a/tests/test_lung.py b/tests/test_lung.py index 384c0fa0..69f7f966 100644 --- a/tests/test_lung.py +++ b/tests/test_lung.py @@ -12,6 +12,7 @@ from scaffoldmaker.meshtypes.meshtype_3d_lung1 import MeshType_3d_lung1 from scaffoldmaker.meshtypes.meshtype_3d_lung2 import MeshType_3d_lung2 from scaffoldmaker.meshtypes.meshtype_3d_lung3 import MeshType_3d_lung3 +from scaffoldmaker.meshtypes.meshtype_3d_lung4 import MeshType_3d_lung4 from scaffoldmaker.utils.meshrefinement import MeshRefinement from testutils import assertAlmostEqualList, check_annotation_term_ids @@ -1000,23 +1001,6 @@ def test_lung3_human(self): size = group.getMeshGroup(mesh1d).getSize() self.assertEqual(expectedSizes1d[name], size, name) - # # test finding a marker in scaffold - # markerGroup = fieldmodule.findFieldByName("marker").castGroup() - # markerNodes = markerGroup.getNodesetGroup(nodes) - # self.assertEqual(7, markerNodes.getSize()) - # markerName = fieldmodule.findFieldByName("marker_name") - # self.assertTrue(markerName.isValid()) - # markerLocation = fieldmodule.findFieldByName("marker_location") - # self.assertTrue(markerLocation.isValid()) - # # test apex marker point - # cache = fieldmodule.createFieldcache() - # node = findNodeWithName(markerNodes, markerName, "apex of left lung") - # self.assertTrue(node.isValid()) - # cache.setNode(node) - # element, xi = markerLocation.evaluateMeshLocation(cache, 3) - # self.assertEqual(40, element.getIdentifier()) - # assertAlmostEqualList(self, xi, [ 0.0, 1.0, 1.0 ], 1.0E-10) - # refine 2x2x2 and check result # need to use original annotation groups to get temporaries annotationGroups = originalAnnotationGroups @@ -1062,23 +1046,66 @@ def test_lung3_human(self): size = group.getMeshGroup(mesh2d).getSize() self.assertEqual(expectedSizes2d[name] * (refineNumberOfElements ** 2), size, name) - # # test finding a marker in refined scaffold - # markerGroup = refineFieldmodule.findFieldByName("marker").castGroup() - # refinedNodes = refineFieldmodule.findNodesetByFieldDomainType(Field.DOMAIN_TYPE_NODES) - # markerNodes = markerGroup.getNodesetGroup(refinedNodes) - # self.assertEqual(7, markerNodes.getSize()) - # markerName = refineFieldmodule.findFieldByName("marker_name") - # self.assertTrue(markerName.isValid()) - # markerLocation = refineFieldmodule.findFieldByName("marker_location") - # self.assertTrue(markerLocation.isValid()) - # # test apex marker point - # cache = refineFieldmodule.createFieldcache() - # node = findNodeWithName(markerNodes, markerName, "apex of left lung") - # self.assertTrue(node.isValid()) - # cache.setNode(node) - # element, xi = markerLocation.evaluateMeshLocation(cache, 3) - # self.assertEqual(319, element.getIdentifier()) - # assertAlmostEqualList(self, xi, [ 0.0, 1.0, 1.0 ], 1.0E-10) + def test_lung4_human(self): + """ + Test creation of human lung scaffold lung4 with open fissures. + """ + scaffold = MeshType_3d_lung4 + parameter_set_names = scaffold.getParameterSetNames() + self.assertEqual(parameter_set_names, + ["Default", "Human 1 Coarse", "Human 1 Medium", "Human 1 Fine", "Ellipsoid"]) + options = scaffold.getDefaultOptions("Human 1 Coarse") + self.assertEqual(16, len(options)) + self.assertFalse(scaffold.checkOptions(options)) + + context = Context("Test") + region = context.getDefaultRegion() + self.assertTrue(region.isValid()) + annotation_groups, _ = scaffold.generateMesh(region, options) + self.assertEqual(67, len(annotation_groups)) + + fieldmodule = region.getFieldmodule() + fieldcache = fieldmodule.createFieldcache() + coordinates = fieldmodule.findFieldByName("coordinates").castFiniteElement() + self.assertTrue(coordinates.isValid()) + + expected_mesh_sizes = { + 'mesh3d': (232, 0.23530885292594117), + 'mesh2d': (830, 9.319471884650373), + 'mesh1d': (1003, 110.40052051549354), + 'left lung.mesh3d': (116, 0.11765435261543447), + 'lower lobe of left lung.mesh3d': (44, 0.055981613269369804), + 'upper lobe of left lung.mesh3d': (72, 0.061672739346064646), + 'right lung.mesh3d': (116, 0.11765450031050663), + 'lower lobe of right lung.mesh3d': (44, 0.05598161326936981), + 'middle lobe of right lung.mesh3d': (24, 0.015908839886146862), + 'upper lobe of right lung.mesh3d': (48, 0.04576404715499016), + 'oblique fissure of lower lobe of left lung.mesh2d': (18, 0.23573101582930447), + 'oblique fissure of upper lobe of left lung.mesh2d': (20, 0.26116146713690136), + 'horizontal fissure of middle lobe of right lung.mesh2d': (10, 0.08506039275120462), + 'horizontal fissure of upper lobe of right lung.mesh2d': (10, 0.08506039275120465), + 'oblique fissure of lower lobe of right lung.mesh2d': (18, 0.2357310158293046), + 'oblique fissure of middle lobe of right lung.mesh2d': (10, 0.11847510573774353), + 'oblique fissure of upper lobe of right lung.mesh2d': (10, 0.1426863613991579), + 'posterior edge of lower lobe of left lung.mesh1d': (6, 0.7217563496699031), + 'posterior edge of lower lobe of right lung.mesh1d': (6, 0.7217563496699031) + } + TOL = 1.0E-6 + with ChangeManager(fieldmodule): + one = fieldmodule.createFieldConstant(1.0) + for mesh_name, expected_sizes in expected_mesh_sizes.items(): + expected_size, expected_val = expected_sizes + mesh_group = fieldmodule.findMeshByName(mesh_name) + size = mesh_group.getSize() + self.assertEqual(size, expected_size) + # volume/area/length + val_integral = fieldmodule.createFieldMeshIntegral(one, coordinates, mesh_group) + val_integral.setNumbersOfPoints(4) + result, val = val_integral.evaluateReal(fieldcache, 1) + self.assertEqual(result, RESULT_OK) + self.assertAlmostEqual(val, expected_val, delta=TOL) + print(mesh_name, size, val) + if __name__ == "__main__": unittest.main() From 0594906acb21f187cb5f759675befd7fe57a4049 Mon Sep 17 00:00:00 2001 From: Richard Christie Date: Mon, 1 Dec 2025 23:39:39 +1300 Subject: [PATCH 12/24] Fix coordinates and derivatives on fissures --- src/scaffoldmaker/scaffoldpackage.py | 2 + src/scaffoldmaker/utils/ellipsoidmesh.py | 57 +++++++++++++++--- src/scaffoldmaker/utils/geometry.py | 2 +- src/scaffoldmaker/utils/interpolation.py | 2 +- src/scaffoldmaker/utils/quadtrianglemesh.py | 67 +++++++++++++++------ tests/test_lung.py | 40 ++++++------ 6 files changed, 119 insertions(+), 51 deletions(-) diff --git a/src/scaffoldmaker/scaffoldpackage.py b/src/scaffoldmaker/scaffoldpackage.py index fdc887dd..3fda757a 100644 --- a/src/scaffoldmaker/scaffoldpackage.py +++ b/src/scaffoldmaker/scaffoldpackage.py @@ -291,6 +291,8 @@ def deleteElementsInRanges(self, region, deleteElementRanges): self._region = region fm = self._region.getFieldmodule() mesh = get_highest_dimension_mesh(fm) + if not mesh: + return meshDimension = mesh.getDimension() nodes = fm.findNodesetByFieldDomainType(Field.DOMAIN_TYPE_NODES) with ChangeManager(fm): diff --git a/src/scaffoldmaker/utils/ellipsoidmesh.py b/src/scaffoldmaker/utils/ellipsoidmesh.py index 9cf72446..49316dd9 100644 --- a/src/scaffoldmaker/utils/ellipsoidmesh.py +++ b/src/scaffoldmaker/utils/ellipsoidmesh.py @@ -170,18 +170,29 @@ def build_octant(self, half_counts, axis2_x_rotation_radians, axis3_x_rotation_r cos_axis2 = math.cos(axis2_x_rotation_radians) sin_axis2 = math.sin(axis2_x_rotation_radians) + cos_axis3 = math.cos(axis3_x_rotation_radians) + sin_axis3 = math.sin(axis3_x_rotation_radians) + origin = [0.0, 0.0, 0.0] ext_origin = [0.0, -axis2_extension * cos_axis2, -axis2_extension * sin_axis2] ext_axis1 = axis1 = [self._a, 0.0, 0.0] axis2 = [0.0] + getEllipsePointAtTrueAngle(self._b, self._c, axis2_x_rotation_radians) + axis2_mag = magnitude(axis2) axis2_normal = normalize([0.0, axis2[2], -axis2[1]]) ext_axis3 = axis3 = [0.0] + getEllipsePointAtTrueAngle(self._b, self._c, axis3_x_rotation_radians) axis3_normal = normalize([0.0, axis3[2], -axis3[1]]) if axis2_extension_elements_count: - assert axis2_extension < magnitude(axis2) # extension must not go outside ellipsoid - xb, xa = getEllipsePointAtTrueAngle(magnitude(axis2), self._a, math.pi / 2.0, [-axis2_extension, 0.0]) + assert axis2_extension < axis2_mag # extension must not go outside ellipsoid + xb, xa = getEllipsePointAtTrueAngle(axis2_mag, self._a, math.pi / 2.0, [-axis2_extension, 0.0]) ext_axis1 = [xa, xb * cos_axis2, xb * sin_axis2] ext_axis3 = [0.0] + getEllipsePointAtTrueAngle(self._b, self._c, axis3_x_rotation_radians, ext_axis1[1:]) + ext_axis3m = [0.0] + getEllipsePointAtTrueAngle(self._b, self._c, axis3_x_rotation_radians + math.pi, ext_axis1[1:]) + centre_mod_axis3 = mult(add(ext_axis3, ext_axis3m), 0.5) + mod_axis3 = sub(ext_axis3, centre_mod_axis3) + mag_mod_axis3 = magnitude(mod_axis3) + else: + centre_mod_axis3 = origin + mag_mod_axis3 = magnitude(axis3) axis_d1 = div(axis1, half_counts[0]) ext_axis_d1 = div(sub(ext_axis1, ext_origin), half_counts[0]) @@ -327,13 +338,27 @@ def evaluate_surface_d3_ellipsoid_plane(tx, td1, td2): nway_d_factor=self._nway_d_factor) abd3 = [[-d for d in evaluate_surface_d3_ellipsoid(x, None, None)] for x in abx] triangle_abo.set_edge_parameters12(abx, abd1, abd3, abd2) - aod3 = [abd2[0]] + [axis_d3] * (len(aox) - 1) + count = len(aox) - 1 + aod3 = [linearlyInterpolateVectors(abd2[0], axis_d3, i / count) for i in range(count + 1)] + triangle_abo.set_edge_parameters13(aox, aod1, aod2, aod3) triangle_abo.set_edge_parameters23(box, bod1, bod2, bod3) - triangle_abo.build() - triangle_abo.assign_d3(lambda tx, td1, td2: - linearlyInterpolateVectors(axis_d3, axis2_dt, magnitude(tx[1:]) / axis2_mag) - if (dot(tx, axis2) >= 0.0) else axis_d3) + triangle_abo.build(regular_count2=axis2_extension_elements_count) + aa = self._a * self._a + bb = axis2_mag * axis2_mag # of axis2 ellipse + def evaluate_surface_d3_abo(tx, td1, td2): + y = tx[1] * cos_axis2 + tx[2] * sin_axis2 + yy = y * y + if yy >= bb: + return axis2_dt # tip of ellipse + centre_d3 = (linearlyInterpolateVectors(axis_d3, axis2_dt, magnitude(tx[1:]) / axis2_mag) + if (y >= 0.0) else axis_d3) + xx = aa * (1.0 - yy / bb) + x = math.sqrt(xx) + side_x = [x, tx[1], tx[2]] + side_d3 = moveDerivativeToEllipsoidSurface(self._a, self._b, self._c, side_x, centre_d3) + return linearlyInterpolateVectors(centre_d3, side_d3, tx[0] / x) + triangle_abo.assign_d3(evaluate_surface_d3_abo) octant.set_triangle_abo(triangle_abo) # extract exact derivatives aod1 = triangle_abo.get_edge_parameters13()[1] @@ -351,8 +376,22 @@ def evaluate_surface_d3_ellipsoid_plane(tx, td1, td2): cod3 = [acd2[-1]] + [axis_md1] * (len(cox) - 1) triangle_aco.set_edge_parameters23(cox, cod3, cod2, cod1) triangle_aco.build() - triangle_aco.assign_d3(lambda tx, td1, td2: - linearlyInterpolateVectors(axis_md2, ext_axis3_dt, magnitude(tx[1:]) / axis3_mag)) + aa = self._a * self._a + bb = mag_mod_axis3 * mag_mod_axis3 # mod_axis3 ellipse + def evaluate_surface_d3_aco(tx, td1, td2): + mx = sub(tx, centre_mod_axis3) + y = mx[1] * cos_axis3 + mx[2] * sin_axis3 + yy = y * y + if yy >= bb: + return ext_axis3_dt # tip of ellipse + centre_d3 = (linearlyInterpolateVectors(axis_md2, ext_axis3_dt, magnitude(mx[1:]) / axis3_mag) + if (y >= 0.0) else axis_md2) + xx = aa * (1.0 - yy / bb) + x = math.sqrt(xx) + side_x = [x, tx[1], tx[2]] + side_d3 = moveDerivativeToEllipsoidSurface(self._a, self._b, self._c, side_x, centre_d3) + return linearlyInterpolateVectors(centre_d3, side_d3, tx[0] / x) + triangle_aco.assign_d3(evaluate_surface_d3_aco) octant.set_triangle_aco(triangle_aco) # extract exact derivatives cod3 = triangle_aco.get_edge_parameters23()[1] diff --git a/src/scaffoldmaker/utils/geometry.py b/src/scaffoldmaker/utils/geometry.py index d1767766..0b522f26 100644 --- a/src/scaffoldmaker/utils/geometry.py +++ b/src/scaffoldmaker/utils/geometry.py @@ -557,7 +557,7 @@ def sampleCurveOnEllipsoid(a, b, c, start_x, start_d1, start_d2, end_x, end_d1, by distance from the other end. :param overweighting: Multiplier of arc length to use with initial curve to exaggerate end derivatives. :param end_transition: If supplied with end_d1, modify size of last element to fit end_d1. - :return: x[], d1[], d2[] + :return: x[], d1[]{, d2[] if start_d2 supplied} """ assert (not end_transition) or end_d1 end_d1_mag = magnitude(end_d1) if end_d1 else None diff --git a/src/scaffoldmaker/utils/interpolation.py b/src/scaffoldmaker/utils/interpolation.py index 38806d9d..2799f214 100644 --- a/src/scaffoldmaker/utils/interpolation.py +++ b/src/scaffoldmaker/utils/interpolation.py @@ -1844,7 +1844,7 @@ def sampleHermiteCurve(start_x, start_d1, start_d2, end_x, end_d1, end_d2, eleme :param start_weight, end_weight: Optional relative weights for start/end d1. :param overweighting: Multiplier of arc length to use with initial curve to exaggerate end derivatives. :param end_transition: If supplied with end_d1, modify size of last element to fit end_d1. - :return: x[], d1[], d2[] + :return: x[], d1[]{, d2[] if start_d2 supplied} """ assert (not end_transition) or end_d1 end_d1_mag = magnitude(end_d1) if end_d1 else None diff --git a/src/scaffoldmaker/utils/quadtrianglemesh.py b/src/scaffoldmaker/utils/quadtrianglemesh.py index 0711a951..ad7429e5 100644 --- a/src/scaffoldmaker/utils/quadtrianglemesh.py +++ b/src/scaffoldmaker/utils/quadtrianglemesh.py @@ -408,30 +408,50 @@ def _smooth_derivative_across(self, start_indexes, end_indexes, index_increments pix = abs(spix) self._nx[indexes[1]][indexes[0]][pix] = new_derivative - def build(self): + def build(self, regular_count2=0): """ Determine interior coordinates from edge coordinates. + :param regular_count2: Optional number of rows of regular box elements in 2 direction. """ + assert 0 <= regular_count2 <= (self._box_count2 - 1) + if regular_count2: + # sample regular row on common boundary with triangle + pointr1 = self._nx[0][regular_count2] + pointr2 = self._nx[self._element_count13][regular_count2] + rx, rd2, rd1 = self._sample_curve( + pointr1[0], pointr1[2], pointr1[1], + pointr2[0], [-d for d in pointr2[1]], pointr2[2], + self._element_count13) + self._set_coordinates_across([rx, rd1, rd2], [[0, 1, 2], [0, 2, 1]], [regular_count2, 0], [[0, 1]], + skip_start=True, skip_end=True) + # sample point coordinates of regular rows parallel to triangle + for r in range(1, regular_count2): + pointr1 = self._nx[0][r] + pointr2 = self._nx[self._element_count13][r] + px, _ = self._sample_curve( + pointr1[0], pointr1[2], None, pointr2[0], [-d for d in pointr2[1]], None, self._element_count13) + self._set_coordinates_across([px], [[0]], [r, 0], [[0, 1]], skip_start=True, skip_end=True) + # determine 3-way point location from mean curves between side points linking to it point12 = self._nx[0][self._box_count2] - point13 = self._nx[self._box_count3][0] + point13 = self._nx[self._box_count3][regular_count2] point23 = self._nx[self._element_count13][self._element_count12] - x_3way, d_3way = get_nway_point( [point23[0], point13[0], point12[0]], [point23[1], point13[2], point12[2]], - [self._box_count1, self._box_count2, self._box_count3], + [self._box_count1, self._box_count2 - regular_count2, self._box_count3], self._sample_curve, self._move_x_to_surface, nway_d_factor=self._3_way_d_factor) # smooth sample from sides to 3-way points using end derivatives - min_weight = 1 # GRC revisit, remove? + min_weight = 1.0 # arbitrary but looks good ax, ad1, ad2 = self._sample_curve( point23[0], point23[1], point23[2], x_3way, d_3way[0], None, self._box_count1, start_weight=self._box_count1 + min_weight, end_weight=1.0 + min_weight, end_transition=True) bx, bd2, bd1 = self._sample_curve( - point13[0], point13[2], point13[1], x_3way, d_3way[1], None, self._box_count2, - start_weight=self._box_count2 + min_weight, end_weight=1.0 + min_weight, end_transition=True) + point13[0], point13[2], point13[1], x_3way, d_3way[1], None, self._box_count2 - regular_count2, + start_weight=self._box_count2 - regular_count2 + min_weight, end_weight=1.0 + min_weight, + end_transition=True) cx, cd2, cd1 = self._sample_curve( point12[0], point12[2], point12[1], x_3way, d_3way[2], None, self._box_count3, start_weight=self._box_count3 + min_weight, end_weight=1.0 + min_weight, end_transition=True) @@ -439,13 +459,13 @@ def build(self): bd1[-1] = ad1[-1] self._set_coordinates_across([ax, ad1, ad2], [[0, 1, 2]], [self._element_count12, self._element_count13], [[-1, -1]]) - self._set_coordinates_across([bx, bd1, bd2], [[0, 1, 2]], [0, self._box_count3], [[1, 0]]) + self._set_coordinates_across([bx, bd1, bd2], [[0, 1, 2]], [regular_count2, self._box_count3], [[1, 0]]) self._set_coordinates_across([cx, cd1, cd2], [[0, 1, 2]], [self._box_count2, 0], [[0, 1]], skip_end=True) - # average point coordinates across 2 directions between edges and 3-way lines. + # average point coordinates across 2 directions between triangle edges and 3-way lines. # 1-2 curves - min_weight = 1 # GRC revisit, remove? - start_indexes = [0, 0] + min_weight = 1.0 # arbitrary but looks good + start_indexes = [regular_count2, 0] corner_indexes = [self._box_count2, 0] end_indexes = [self._element_count12, 0] for i in range(1, self._box_count3): @@ -456,8 +476,8 @@ def build(self): corner = self._nx[corner_indexes[1]][corner_indexes[0]] end = self._nx[end_indexes[1]][end_indexes[0]] px, _ = self._sample_curve( - start[0], start[1], None, corner[0], corner[1], None, self._box_count2, - start_weight=self._box_count2 + min_weight, end_weight=1.0 + min_weight) + start[0], start[1], None, corner[0], corner[1], None, self._box_count2 - regular_count2, + start_weight=self._box_count2 - regular_count2 + min_weight, end_weight=1.0 + min_weight) self._set_coordinates_across( [px], [[0]], start_indexes, [[1, 0]], skip_start=True, skip_end=True) px, _ = self._sample_curve( @@ -466,10 +486,10 @@ def build(self): self._set_coordinates_across( [px], [[0]], corner_indexes, [[1, 0]], skip_start=True, skip_end=True) # 1-3 curves - start_indexes = [0, 0] - corner_indexes = [0, self._box_count3] - end_indexes = [0, self._element_count13] - for i in range(1, self._box_count2): + start_indexes = [regular_count2, 0] + corner_indexes = [regular_count2, self._box_count3] + end_indexes = [regular_count2, self._element_count13] + for i in range(regular_count2 + 1, self._box_count2): start_indexes[0] += 1 corner_indexes[0] += 1 end_indexes[0] += 1 @@ -489,7 +509,7 @@ def build(self): # 2-3 curves start_indexes = [self._element_count12, 0] corner_indexes = [self._element_count12, self._element_count13] - end_indexes = [0, self._element_count13] + end_indexes = [regular_count2, self._element_count13] for i in range(1, self._box_count1): start_indexes[0] -= 1 corner_indexes[0] -= 1 @@ -504,8 +524,8 @@ def build(self): self._set_coordinates_across( [px], [[0]], start_indexes, [[0, 1]], skip_start=True, skip_end=True, blend=True) px, _ = self._sample_curve( - corner[0], [-d for d in corner[2]], None, end[0], [-d for d in end[2]], None, self._box_count2, - start_weight=1.0 + min_weight, end_weight=self._box_count2 + min_weight) + corner[0], [-d for d in corner[2]], None, end[0], [-d for d in end[2]], None, self._box_count2 - regular_count2, + start_weight=1.0 + min_weight, end_weight=self._box_count2 - regular_count2 + min_weight) self._set_coordinates_across( [px], [[0]], corner_indexes, [[-1, 0]], skip_start=True, skip_end=True, blend=True) @@ -517,6 +537,13 @@ def build(self): end_indexes[1] += 1 self._smooth_derivative_across(start_indexes, end_indexes, [[1, 0]], [1], fix_start_direction=True, fix_end_direction=True) + if regular_count2: + # need to smooth d2 through regular elements to 3-way point + start_indexes[1] += 1 + end_indexes[0] = regular_count2 + end_indexes[1] += 1 + self._smooth_derivative_across(start_indexes, end_indexes, [[1, 0]], [2], + fix_start_direction=True, fix_end_direction=True) # smooth 1-3 curves start_indexes = [0, 0] end_indexes = [0, self._element_count13] diff --git a/tests/test_lung.py b/tests/test_lung.py index 69f7f966..aa522ce0 100644 --- a/tests/test_lung.py +++ b/tests/test_lung.py @@ -931,10 +931,10 @@ def test_lung3_human(self): fieldcache = fieldmodule.createFieldcache() result, surfaceArea = surfaceAreaField.evaluateReal(fieldcache, 1) self.assertEqual(result, RESULT_OK) - self.assertAlmostEqual(surfaceArea, 4.887248605193398, delta=tol) result, volume = volumeField.evaluateReal(fieldcache, 1) self.assertEqual(result, RESULT_OK) - self.assertAlmostEqual(volume, 0.301840881252553, delta=tol) + self.assertAlmostEqual(surfaceArea, 4.88745261466682, delta=tol) + self.assertAlmostEqual(volume, 0.301840862866859, delta=tol) # check some annotationGroups: expectedSizes3d = { @@ -1070,25 +1070,26 @@ def test_lung4_human(self): self.assertTrue(coordinates.isValid()) expected_mesh_sizes = { - 'mesh3d': (232, 0.23530885292594117), - 'mesh2d': (830, 9.319471884650373), - 'mesh1d': (1003, 110.40052051549354), - 'left lung.mesh3d': (116, 0.11765435261543447), - 'lower lobe of left lung.mesh3d': (44, 0.055981613269369804), - 'upper lobe of left lung.mesh3d': (72, 0.061672739346064646), - 'right lung.mesh3d': (116, 0.11765450031050663), - 'lower lobe of right lung.mesh3d': (44, 0.05598161326936981), - 'middle lobe of right lung.mesh3d': (24, 0.015908839886146862), - 'upper lobe of right lung.mesh3d': (48, 0.04576404715499016), - 'oblique fissure of lower lobe of left lung.mesh2d': (18, 0.23573101582930447), - 'oblique fissure of upper lobe of left lung.mesh2d': (20, 0.26116146713690136), - 'horizontal fissure of middle lobe of right lung.mesh2d': (10, 0.08506039275120462), + + 'mesh3d': (232, 0.23531518999948317), + 'mesh2d': (830, 9.322931842916137), + 'mesh1d': (1003, 110.4277660475001), + 'left lung.mesh3d': (116, 0.11765752113724519), + 'lower lobe of left lung.mesh3d': (44, 0.055984781964438096), + 'upper lobe of left lung.mesh3d': (72, 0.06167273917280671), + 'right lung.mesh3d': (116, 0.11765766886223888), + 'lower lobe of right lung.mesh3d': (44, 0.05598478196443805), + 'middle lobe of right lung.mesh3d': (24, 0.015908839863426272), + 'upper lobe of right lung.mesh3d': (48, 0.04576404703437447), + 'oblique fissure of lower lobe of left lung.mesh2d': (18, 0.23573085010104664), + 'oblique fissure of upper lobe of left lung.mesh2d': (20, 0.26116146713690147), + 'horizontal fissure of middle lobe of right lung.mesh2d': (10, 0.08506039275120454), 'horizontal fissure of upper lobe of right lung.mesh2d': (10, 0.08506039275120465), - 'oblique fissure of lower lobe of right lung.mesh2d': (18, 0.2357310158293046), + 'oblique fissure of lower lobe of right lung.mesh2d': (18, 0.23573085010104675), 'oblique fissure of middle lobe of right lung.mesh2d': (10, 0.11847510573774353), - 'oblique fissure of upper lobe of right lung.mesh2d': (10, 0.1426863613991579), + 'oblique fissure of upper lobe of right lung.mesh2d': (10, 0.14268636139915797), 'posterior edge of lower lobe of left lung.mesh1d': (6, 0.7217563496699031), - 'posterior edge of lower lobe of right lung.mesh1d': (6, 0.7217563496699031) + 'posterior edge of lower lobe of right lung.mesh1d': (6, 0.7217563496699028) } TOL = 1.0E-6 with ChangeManager(fieldmodule): @@ -1103,8 +1104,7 @@ def test_lung4_human(self): val_integral.setNumbersOfPoints(4) result, val = val_integral.evaluateReal(fieldcache, 1) self.assertEqual(result, RESULT_OK) - self.assertAlmostEqual(val, expected_val, delta=TOL) - print(mesh_name, size, val) + self.assertAlmostEqual(val, expected_val, msg=mesh_name, delta=TOL) if __name__ == "__main__": From 3a947dd760c31eb91a5577b137b9a65a798b8545 Mon Sep 17 00:00:00 2001 From: Richard Christie Date: Thu, 4 Dec 2025 12:17:13 +1300 Subject: [PATCH 13/24] Handle different surface nodes at same location in mesh refinement --- .../meshtype_3d_heartarterialroot1.py | 8 +- src/scaffoldmaker/meshtypes/scaffold_base.py | 2 + src/scaffoldmaker/utils/meshrefinement.py | 252 +++++++++++++++--- src/scaffoldmaker/utils/octree.py | 60 +++-- tests/test_uterus.py | 4 +- 5 files changed, 254 insertions(+), 72 deletions(-) diff --git a/src/scaffoldmaker/meshtypes/meshtype_3d_heartarterialroot1.py b/src/scaffoldmaker/meshtypes/meshtype_3d_heartarterialroot1.py index 97bbdd55..d00be9c6 100644 --- a/src/scaffoldmaker/meshtypes/meshtype_3d_heartarterialroot1.py +++ b/src/scaffoldmaker/meshtypes/meshtype_3d_heartarterialroot1.py @@ -129,7 +129,7 @@ def generateBaseMesh(cls, region, options, baseCentre=[ 0.0, 0.0, 0.0 ], axisSid # AnnotationGroup(region, get_heart_term("posterior cusp of aortic valve")), # AnnotationGroup(region, get_heart_term("right cusp of aortic valve")), # AnnotationGroup(region, get_heart_term("left cusp of aortic valve")) ] - cuspGroup = AnnotationGroup(region, get_heart_term("aortic valve leaflet")), + cuspGroup = AnnotationGroup(region, get_heart_term("aortic valve leaflet")) cuspGroups = [cuspGroup] * 3 else: arterialRootGroup = AnnotationGroup(region, get_heart_term("root of pulmonary trunk")) @@ -138,7 +138,7 @@ def generateBaseMesh(cls, region, options, baseCentre=[ 0.0, 0.0, 0.0 ], axisSid # AnnotationGroup(region, get_heart_term("right cusp of pulmonary valve")), # AnnotationGroup(region, get_heart_term("anterior cusp of pulmonary valve")), # AnnotationGroup(region, get_heart_term("left cusp of pulmonary valve")) ] - cuspGroup = AnnotationGroup(region, get_heart_term("pulmonary valve leaflet")), + cuspGroup = AnnotationGroup(region, get_heart_term("pulmonary valve leaflet")) cuspGroups = [cuspGroup] * 3 allGroups = [ arterialRootGroup ] # groups that all elements in scaffold will go in @@ -528,11 +528,9 @@ def refineMesh(cls, meshrefinement, options): numberInXi2 = refineElementsCountSurface numberInXi3 = refineElementsCountThroughWall for cusp in range(3): - lastShareNodeIds = lastShareNodeCoordinates = None for e in range(2): element = meshrefinement._sourceElementiterator.next() - lastShareNodeIds, lastShareNodeCoordinates = meshrefinement.refineElementCubeStandard3d(element, numberInXi1, numberInXi2, numberInXi3, - addNewNodesToOctree=False, shareNodeIds=lastShareNodeIds, shareNodeCoordinates=lastShareNodeCoordinates) + meshrefinement.refineElementCubeStandard3d(element, numberInXi1, numberInXi2, numberInXi3) def getSemilunarValveSinusPoints(centre, axisSide1, axisSide2, radius, sinusRadialDisplacement, startMidCusp, elementsCountAround = 6): diff --git a/src/scaffoldmaker/meshtypes/scaffold_base.py b/src/scaffoldmaker/meshtypes/scaffold_base.py index c6d0a7a8..ad540ffa 100644 --- a/src/scaffoldmaker/meshtypes/scaffold_base.py +++ b/src/scaffoldmaker/meshtypes/scaffold_base.py @@ -147,6 +147,8 @@ def generateMesh(cls, region, options): if options.get('Refine'): baseRegion = region.createRegion() annotationGroups = cls.generateBaseMesh(baseRegion, options)[0] + # need faces to determine shared or boundary nodes during mesh refinement + baseRegion.getFieldmodule().defineAllFaces() meshrefinement = MeshRefinement(baseRegion, region, annotationGroups) cls.refineMesh(meshrefinement, options) annotationGroups = meshrefinement.getAnnotationGroups() diff --git a/src/scaffoldmaker/utils/meshrefinement.py b/src/scaffoldmaker/utils/meshrefinement.py index 5b835e82..76d4a67d 100644 --- a/src/scaffoldmaker/utils/meshrefinement.py +++ b/src/scaffoldmaker/utils/meshrefinement.py @@ -1,19 +1,19 @@ """ Class for refining a mesh from one region to another. """ -from __future__ import division - -import math - from cmlibs.utils.zinc.field import findOrCreateFieldCoordinates, findOrCreateFieldGroup, \ findOrCreateFieldStoredMeshLocation, findOrCreateFieldStoredString +from cmlibs.utils.zinc.general import ChangeManager from cmlibs.zinc.element import Element, Elementbasis from cmlibs.zinc.field import Field from cmlibs.zinc.node import Node -from cmlibs.zinc.result import RESULT_OK as ZINC_OK +from cmlibs.zinc.result import RESULT_OK from scaffoldmaker.annotation.annotationgroup import AnnotationGroup from scaffoldmaker.utils.octree import Octree +import copy +import math + class MeshRefinement: """ @@ -35,22 +35,26 @@ def __init__(self, sourceRegion, targetRegion, sourceAnnotationGroups=[]): sourceNodes = self._sourceFm.findNodesetByFieldDomainType(Field.DOMAIN_TYPE_NODES) minimumsField = self._sourceFm.createFieldNodesetMinimum(self._sourceCoordinates, sourceNodes) result, minimums = minimumsField.evaluateReal(self._sourceCache, 3) - assert result == ZINC_OK, 'MeshRefinement failed to get minimum coordinates' + assert result == RESULT_OK, 'MeshRefinement failed to get minimum coordinates' maximumsField = self._sourceFm.createFieldNodesetMaximum(self._sourceCoordinates, sourceNodes) result, maximums = maximumsField.evaluateReal(self._sourceCache, 3) - assert result == ZINC_OK, 'MeshRefinement failed to get maximum coordinates' + assert result == RESULT_OK, 'MeshRefinement failed to get maximum coordinates' xrange = [(maximums[i] - minimums[i]) for i in range(3)] edgeTolerance = 0.5 * (max(xrange)) if edgeTolerance == 0.0: edgeTolerance = 1.0 minimums = [(minimums[i] - edgeTolerance) for i in range(3)] maximums = [(maximums[i] + edgeTolerance) for i in range(3)] - minimumsField = None - maximumsField = None + del minimumsField + del maximumsField self._sourceMesh = self._sourceFm.findMeshByDimension(3) + self._sourceFaceMesh = self._sourceFm.findMeshByDimension(2) + self._sourceLineMesh = self._sourceFm.findMeshByDimension(1) self._sourceNodes = self._sourceFm.findNodesetByFieldDomainType(Field.DOMAIN_TYPE_NODES) self._sourceElementiterator = self._sourceMesh.createElementiterator() self._octree = Octree(minimums, maximums) + self._tolerance = self._octree.getTolerance() + self._is_exterior_field = self._sourceFm.createFieldIsExterior() self._targetRegion = targetRegion self._targetFm = targetRegion.getFieldmodule() @@ -121,62 +125,234 @@ def __del__(self): def getAnnotationGroups(self): return self._annotationGroups - def refineElementCubeStandard3d(self, sourceElement, numberInXi1, numberInXi2, numberInXi3, - addNewNodesToOctree=True, shareNodeIds=None, shareNodeCoordinates=None): + def _face_add_line_ids_ending_in_x(self, face, x, line_ids: set): + """ + Add identifiers of lines on face element with either end's coordinates within tolerance of x to supplied set. + :param face: Zinc 2-D face element. + :param x: 3 component coordinates list. + :param line_ids: Set of line identifiers. + """ + for f in range(face.getNumberOfFaces()): + line = face.getFaceElement(f + 1) + if line.isValid(): + line_id = line.getIdentifier() + if line_id not in line_ids: + # add line if it has coordinates within tolerance of x at either end + for xi in (0.0, 1.0): + self._sourceCache.setMeshLocation(line, [xi]) + result, line_x = self._sourceCoordinates.evaluateReal(self._sourceCache, 3) + if result == RESULT_OK: + for c, line_c in zip(x, line_x): + if math.fabs(c - line_c) > self._tolerance: + break + else: + line_ids.add(line_id) + break + + def _get_connected_exterior_face_ids(self, faces, x): + """ + Get connected exterior face element identifiers with common lines to any pairs in faces list. + :param faces: List of at least 2 faces with common lines. + :param x: Coordinates of point; used only for > 2 faces. + :return: List of exterior boundary face identifiers from lowest to highest. + """ + initial_face_count = len(faces) + assert initial_face_count > 1 + + # add faces passed in to set + # get common lines between them + face_ids = set() + face_line_ids = [] + for face in faces: + face_ids.add(face.getIdentifier()) + tmp_line_ids = [] + for i in range(face.getNumberOfFaces()): + line = face.getFaceElement(i + 1) + if line.isValid(): + tmp_line_ids.append(line.getIdentifier()) + face_line_ids.append(tmp_line_ids) + new_line_ids = set() + # if there is a single line between 2 faces can do less work later, but not if there are collpased faces + single_line = initial_face_count < 3 + for f1 in range(len(faces) - 1): + for f2 in range(f1 + 1, len(faces)): + add_count = 0 + for line_id in face_line_ids[f1]: + if line_id in face_line_ids[f2]: + new_line_ids.add(line_id) + add_count += 1 + if add_count == 0: + # assume collapsed face, so add all lines from both faces ending in x at either end + single_line = False + for fi in (f1, f2): + self._face_add_line_ids_ending_in_x(faces[fi], x, new_line_ids) + line_ids = copy.copy(new_line_ids) + + while True: + # ensure all parent elements of common lines are in face_ids set + new_face_ids = [] + for line_id in new_line_ids: + line = self._sourceLineMesh.findElementByIdentifier(line_id) + for p in range(line.getNumberOfParents()): + face = line.getParentElement(p + 1) + face_id = face.getIdentifier() + if face_id not in face_ids: + new_face_ids.append(face_id) + face_ids.update(new_face_ids) + + if single_line or (not new_face_ids): + break + + new_line_ids.clear() + for face_id in new_face_ids: + face = self._sourceFaceMesh.findElementByIdentifier(face_id) + self._face_add_line_ids_ending_in_x(face, x, new_line_ids) + line_ids.update(new_line_ids) + + exterior_face_ids = [] + for face_id in face_ids: + face = self._sourceFaceMesh.findElementByIdentifier(face_id) + self._sourceCache.setElement(face) + result, value = self._is_exterior_field.evaluateReal(self._sourceCache, 1) + if (result == RESULT_OK) and (value != 0.0): + exterior_face_ids.append(face_id) + + exterior_face_ids.sort() + return exterior_face_ids + + cube_mid_face_xi = [ + [0.0, 0.5, 0.5], + [1.0, 0.5, 0.5], + [0.5, 0.0, 0.5], + [0.5, 1.0, 0.5], + [0.5, 0.5, 0.0], + [0.5, 0.5, 1.0] + ] + + square_mid_edge_xi = [ + [0.0, 0.5], + [1.0, 0.5], + [0.5, 0.0], + [0.5, 1.0] + ] + + def refineElementCubeStandard3d(self, sourceElement, numberInXi1, numberInXi2, numberInXi3): """ Refine cube sourceElement to numberInXi1*numberInXi2*numberInXi3 linear cube sub-elements, evenly spaced in xi. - :param addNewNodesToOctree: If True (default) add newly created nodes to - octree to be found when refining later elements. Set to False when nodes are at the - same location and not intended to be shared. - :param shareNodeIds, shareNodeCoordinates: Arrays of identifiers and coordinates of - nodes which may be shared in refining this element. If supplied, these are preferentially - used ahead of points in the octree. Used to control merging with known nodes, e.g. - those returned by this function for elements which used addNewNodesToOctree=False. :return: Node identifiers, node coordinates used in refinement of sourceElement. """ - assert (shareNodeIds and shareNodeCoordinates) or (not shareNodeIds and not shareNodeCoordinates), \ - 'refineElementCubeStandard3d. Must supply both of shareNodeIds and shareNodeCoordinates, or neither' - shareNodesCount = len(shareNodeIds) if shareNodeIds else 0 + # element_identifier = sourceElement.getIdentifier() + # print("refine element", element_identifier) meshGroups = [] for sourceAndTargetMeshGroup in self._sourceAndTargetMeshGroups: if sourceAndTargetMeshGroup[0].containsElement(sourceElement): meshGroups.append(sourceAndTargetMeshGroup[1]) + + faces = [None] * 6 + exterior_faces = [False] * 6 # whether face is on exterior boundary of mesh + if sourceElement.getNumberOfFaces() == 6: + for f in range(6): + face = sourceElement.getFaceElement(f + 1) + if face and face.isValid(): + faces[f] = face + self._sourceCache.setElement(face) + result, value = self._is_exterior_field.evaluateReal(self._sourceCache, 1) + exterior_faces[f] = (result == RESULT_OK) and (value != 0.0) + else: + faces[f] = None + # collapsed elements have no face, so get all faces which are adjacent to collapsed face + if (None in faces) and any(face is not None for face in faces): + for f, face in enumerate(faces): + if not face: + # get coordinates at centre of face + self._sourceCache.setMeshLocation(sourceElement, self.cube_mid_face_xi[f]) + result, mid_face_x = self._sourceCoordinates.evaluateReal(self._sourceCache, 3) + if result != RESULT_OK: + continue + # get list of all faces with mid-edge coordinate within tolerance of mid_face_x + adjacent_faces = [] + for f2, face2 in enumerate(faces): + if face2 and not isinstance(face2, list): + for xi in self.square_mid_edge_xi: + self._sourceCache.setMeshLocation(face2, xi) + result, mid_edge_x = self._sourceCoordinates.evaluateReal(self._sourceCache, 3) + if result != RESULT_OK: + continue + for c in range(3): + if math.fabs(mid_edge_x[c] - mid_face_x[c]) > self._tolerance: + break + else: + adjacent_faces.append(face2) + if exterior_faces[f2]: + exterior_faces[f] = True + break + if adjacent_faces: + faces[f] = adjacent_faces + + # 6 faces above + 1 extra face for not_a_face_index + not_a_face_index = 6 + faces.append(None) + exterior_faces.append(False) + # create nodes nids = [] nx = [] xi = [0.0, 0.0, 0.0] tol = self._octree._tolerance for k in range(numberInXi3 + 1): - kExterior = (k == 0) or (k == numberInXi3) + k_face_index = 4 if (k == 0) else 5 if (k == numberInXi3) else not_a_face_index + k_face = faces[k_face_index] + k_exterior = exterior_faces[k_face_index] xi[2] = k / numberInXi3 for j in range(numberInXi2 + 1): - jExterior = kExterior or (j == 0) or (j == numberInXi2) + j_face_index = 2 if (j == 0) else 3 if (j == numberInXi2) else not_a_face_index + j_face = faces[j_face_index] + j_exterior = exterior_faces[j_face_index] xi[1] = j / numberInXi2 for i in range(numberInXi1 + 1): - iExterior = jExterior or (i == 0) or (i == numberInXi1) + i_face_index = 0 if (i == 0) else 1 if (i == numberInXi1) else not_a_face_index + i_face = faces[i_face_index] + i_exterior = exterior_faces[i_face_index] xi[0] = i / numberInXi1 self._sourceCache.setMeshLocation(sourceElement, xi) result, x = self._sourceCoordinates.evaluateReal(self._sourceCache, 3) - # only exterior points are ever common: + shareable = False + surface_face_ids = True # since None is used for no extra data in Octree + + connected_faces = [] + for face in (i_face, j_face, k_face): + if face: + for tmp_face in face if isinstance(face, list) else [face]: + if tmp_face not in connected_faces: + connected_faces.append(tmp_face) + face_count = len(connected_faces) + if face_count > 0: + shareable = True + exterior_count = (i_exterior, j_exterior, k_exterior).count(True) + if face_count == 1: + if exterior_count == 1: + shareable = False # nodes only belong to this element + # else interior + else: + surface_face_ids = self._get_connected_exterior_face_ids(connected_faces, x) + if not surface_face_ids: + surface_face_ids = True nodeId = None - if iExterior: - if shareNodeIds: - for n in range(shareNodesCount): - if (math.fabs(shareNodeCoordinates[n][0] - x[0]) <= tol) and \ - (math.fabs(shareNodeCoordinates[n][1] - x[1]) <= tol) and \ - (math.fabs(shareNodeCoordinates[n][2] - x[2]) <= tol): - nodeId = shareNodeIds[n] - break - if nodeId is None: - nodeId = self._octree.findObjectByCoordinates(x) + if shareable: + nodeId, extra_data = self._octree.findObjectByCoordinates(x, surface_face_ids) + # if nodeId: + # print("Found existing node", nodeId, extra_data, "at", x) if nodeId is None: node = self._targetNodes.createNode(self._nodeIdentifier, self._nodetemplate) self._targetCache.setNode(node) result = self._targetCoordinates.setNodeParameters(self._targetCache, -1, Node.VALUE_LABEL_VALUE, 1, x) nodeId = self._nodeIdentifier - if iExterior and addNewNodesToOctree: - self._octree.addObjectAtCoordinates(x, nodeId) + if shareable: + # print("Add shareable node", nodeId, surface_face_ids, "at", x) + self._octree.addObjectAtCoordinates(x, (nodeId, surface_face_ids)) + # else: + # print("Add unique node", nodeId, surface_face_ids, "at", x) self._nodeIdentifier += 1 nids.append(nodeId) nx.append(x) @@ -192,7 +368,7 @@ def refineElementCubeStandard3d(self, sourceElement, numberInXi1, numberInXi2, n enids = [nids[bni], nids[bni + 1], nids[bni + oj], nids[bni + oj + 1], nids[bni + ok], nids[bni + ok + 1], nids[bni + ok + oj], nids[bni + ok + oj + 1]] result = element.setNodesByIdentifier(self._targetEft, enids) - # if result != ZINC_OK: + # if result != RESULT_OK: # print('Element', self._elementIdentifier, result, enids) self._elementIdentifier += 1 diff --git a/src/scaffoldmaker/utils/octree.py b/src/scaffoldmaker/utils/octree.py index ebde76ec..f8cd87c8 100644 --- a/src/scaffoldmaker/utils/octree.py +++ b/src/scaffoldmaker/utils/octree.py @@ -1,6 +1,6 @@ -''' +""" Octree for searching for objects by coordinates -''' +""" from __future__ import division import copy @@ -8,16 +8,16 @@ class Octree: - ''' + """ Octree for searching for objects by coordinates - ''' + """ def __init__(self, minimums, maximums, tolerance = None): - ''' + """ :param minimums: List of 3 minimum coordinate values. Caller to include any edge allowance. :param maximums: List of 3 maximum coordinate values. Caller to include any edge allowance. :param tolerance: If supplied, tolerance to use, or None to compute as 1.0E-6*diagonal. - ''' + """ self._dimension = 3 self._dimensionPower2 = 1 << self._dimension self._maxObjects = 20 @@ -34,26 +34,29 @@ def __init__(self, minimums, maximums, tolerance = None): # exactly 2^self._dimension children, cycling in lowest x index fastest self._children = None - - def _findObjectByCoordinates(self, x): - ''' + def _findObjectByCoordinates(self, x, extra_data): + """ Find closest existing object with |x - ox| < tolerance. :param x: 3 coordinates in a list. + :param extra_data: Extra data to compare with 2nd component of stored tuple (object, extra_data) or None if + not a tuple with extra data. Value must resolve to True, or be None. :return: nearest distance, nearest object or None, None if none found. - ''' + """ nearestDistance = None - nearestObject = None + nearestObject = (None, None) if extra_data else None if self._coordinatesObjects is not None: for coordinatesObject in self._coordinatesObjects: # cheaply determine if in 2*tolerance sized box around object - inBox = True + cox = coordinatesObject[0] for c in range(self._dimension): - if math.fabs(x[c] - coordinatesObject[0][c]) > self._tolerance: - inBox = False + if math.fabs(x[c] - cox[c]) > self._tolerance: break - if inBox: + else: + if extra_data: + if extra_data != coordinatesObject[1][1]: + continue # extra data does not match # now test exact distance - distance = math.sqrt(sum((x[i] - coordinatesObject[0][i])*(x[i] - coordinatesObject[0][i]) for i in range(self._dimension))) + distance = math.sqrt(sum((x[c] - cox[c])*(x[c] - cox[c]) for c in range(self._dimension))) if (distance < self._tolerance) and ((nearestDistance is None) or (distance < nearestDistance)): nearestDistance = distance nearestObject = coordinatesObject[1] @@ -70,31 +73,33 @@ def _findObjectByCoordinates(self, x): inBoundsPlusTolerance = False break if inBoundsPlusTolerance: - distance, obj = self._children[i]._findObjectByCoordinates(x) + distance, obj = self._children[i]._findObjectByCoordinates(x, extra_data) if (distance is not None) and ((nearestDistance is None) or (distance < nearestDistance)): nearestDistance = distance nearestObject = obj return nearestDistance, nearestObject - - def findObjectByCoordinates(self, x): - ''' + def findObjectByCoordinates(self, x, extra_data=None): + """ Find closest existing object with |x - ox| < tolerance. :param x: 3 coordinates in a list. - :return: nearest object or None if not found. - ''' - nearestDistance, nearestObject = self._findObjectByCoordinates(x) + :param extra_data: Optional extra data to compare with 2nd component of stored tuple (object, extra_data). + Default/None means no tuple, no extra data. + :return: nearest object (or object tuple) or None (or (None, None) if extra_data) if not found. + """ + nearestDistance, nearestObject = self._findObjectByCoordinates(x, extra_data) return nearestObject def addObjectAtCoordinates(self, x, obj): - ''' + """ Add object at coordinates to octree. Caller must have received None result for findObjectByCoordinates() first! Assumes caller has verified x is within range of Octree. :param x: 3 coordinates in a list. - :param obj: object to store with coordinates. - ''' + :param obj: object to store with coordinates. Must be a tuple of (object, extra data) if needing to match + extra data when searching octree. + """ if self._coordinatesObjects is not None: if len(self._coordinatesObjects) < self._maxObjects: self._coordinatesObjects.append( (copy.deepcopy(x), obj) ) @@ -124,3 +129,6 @@ def addObjectAtCoordinates(self, x, obj): if x[c] > centre[c]: i += 1 << c self._children[i].addObjectAtCoordinates(x, obj) + + def getTolerance(self): + return self._tolerance diff --git a/tests/test_uterus.py b/tests/test_uterus.py index 756bb347..c666e111 100644 --- a/tests/test_uterus.py +++ b/tests/test_uterus.py @@ -121,9 +121,7 @@ def test_uterus1(self): continue annotationGroups.remove(annotationGroup) self.assertEqual(22, len(annotationGroups)) - # also remove all faces and lines as not needed for refinement - mesh2d.destroyAllElements() - mesh1d.destroyAllElements() + # must keep all faces and lines as used for refinement refineRegion = region.createRegion() refineFieldmodule = refineRegion.getFieldmodule() From 74efc68f351d369c0135340823c0deb00fc05882 Mon Sep 17 00:00:00 2001 From: Richard Christie Date: Thu, 4 Dec 2025 12:19:06 +1300 Subject: [PATCH 14/24] Add lung4 refinement test for open fissures Allow as low as 4 oblique elements in lung4 scaffold. --- .../meshtypes/meshtype_3d_lung4.py | 6 +- tests/test_lung.py | 107 +++++++++++++++++- 2 files changed, 105 insertions(+), 8 deletions(-) diff --git a/src/scaffoldmaker/meshtypes/meshtype_3d_lung4.py b/src/scaffoldmaker/meshtypes/meshtype_3d_lung4.py index 2dd4e96e..5d27c422 100644 --- a/src/scaffoldmaker/meshtypes/meshtype_3d_lung4.py +++ b/src/scaffoldmaker/meshtypes/meshtype_3d_lung4.py @@ -84,7 +84,6 @@ def getOrderedOptionNames(cls): return [ "Left lung", "Right lung", - # "Number of left lung lobes", "Number of elements lateral", "Number of elements lower lobe extension", "Number of elements oblique", @@ -115,9 +114,8 @@ def checkOptions(cls, options): "Number of elements lateral", "Number of elements oblique" ]: - min_elements_count = 4 if (key == "Number of elements lateral") else 6 - if options[key] < min_elements_count: - options[key] = min_elements_count + if options[key] < 4: + options[key] = 4 elif options[key] % 2: options[key] += 1 transition_count = (options[key] // 2) - 1 diff --git a/tests/test_lung.py b/tests/test_lung.py index aa522ce0..f0f31b17 100644 --- a/tests/test_lung.py +++ b/tests/test_lung.py @@ -2,6 +2,7 @@ import math import unittest +from cmlibs.utils.zinc.field import find_or_create_field_group from cmlibs.utils.zinc.finiteelement import evaluateFieldNodesetRange, findNodeWithName from cmlibs.utils.zinc.general import ChangeManager from cmlibs.zinc.context import Context @@ -1060,17 +1061,44 @@ def test_lung4_human(self): context = Context("Test") region = context.getDefaultRegion() + fieldmodule = region.getFieldmodule() self.assertTrue(region.isValid()) - annotation_groups, _ = scaffold.generateMesh(region, options) + annotation_groups, _ = scaffold.generateBaseMesh(region, options) + base_annotation_groups = copy.copy(annotation_groups) + self.assertEqual(16, len(base_annotation_groups)) + fieldmodule.defineAllFaces() + for annotation_group in annotation_groups: + annotation_group.addSubelements() + scaffold.defineFaceAnnotations(region, options, annotation_groups) + for annotation_group in annotation_groups: + if annotation_group not in base_annotation_groups: + annotation_group.addSubelements() self.assertEqual(67, len(annotation_groups)) - fieldmodule = region.getFieldmodule() fieldcache = fieldmodule.createFieldcache() coordinates = fieldmodule.findFieldByName("coordinates").castFiniteElement() self.assertTrue(coordinates.isValid()) + nodes = fieldmodule.findNodesetByFieldDomainType(Field.DOMAIN_TYPE_NODES) + self.assertEqual(407, nodes.getSize()) + intersection_group_names = [ + ("lower lobe of left lung", "upper lobe of left lung"), + ("lower lobe of right lung", "middle lobe of right lung"), + ("lower lobe of right lung", "upper lobe of right lung"), + ("middle lobe of right lung", "upper lobe of right lung") + ] + # check number of common nodes at hilum + with ChangeManager(fieldmodule): + for group_name1, group_name2 in intersection_group_names: + group = fieldmodule.createFieldGroup() + nodeset_group = group.createNodesetGroup(nodes) + nodeset_group.addNodesConditional( + fieldmodule.createFieldAnd(fieldmodule.findFieldByName(group_name1), + fieldmodule.findFieldByName(group_name2))) + self.assertEqual(3, nodeset_group.getSize()) + del nodeset_group + del group expected_mesh_sizes = { - 'mesh3d': (232, 0.23531518999948317), 'mesh2d': (830, 9.322931842916137), 'mesh1d': (1003, 110.4277660475001), @@ -1098,7 +1126,7 @@ def test_lung4_human(self): expected_size, expected_val = expected_sizes mesh_group = fieldmodule.findMeshByName(mesh_name) size = mesh_group.getSize() - self.assertEqual(size, expected_size) + self.assertEqual(size, expected_size, msg=mesh_name) # volume/area/length val_integral = fieldmodule.createFieldMeshIntegral(one, coordinates, mesh_group) val_integral.setNumbersOfPoints(4) @@ -1106,6 +1134,77 @@ def test_lung4_human(self): self.assertEqual(result, RESULT_OK) self.assertAlmostEqual(val, expected_val, msg=mesh_name, delta=TOL) + # refine 2x2x2 and check result + refine_region = region.createRegion() + refine_fieldmodule = refine_region.getFieldmodule() + options['Refine'] = True + options['Refine number of elements'] = 2 + meshrefinement = MeshRefinement(region, refine_region, base_annotation_groups) + scaffold.refineMesh(meshrefinement, options) + refine_annotation_groups = meshrefinement.getAnnotationGroups() + self.assertEqual(16, len(refine_annotation_groups)) + refine_fieldmodule.defineAllFaces() + for annotation_group in refine_annotation_groups: + annotation_group.addSubelements() + old_annotation_groups = copy.copy(refine_annotation_groups) + scaffold.defineFaceAnnotations(refine_region, options, refine_annotation_groups) + for annotation_group in refine_annotation_groups: + if annotation_group not in old_annotation_groups: + annotation_group.addSubelements() + self.assertEqual(67, len(refine_annotation_groups)) + + refine_fieldcache = refine_fieldmodule.createFieldcache() + refine_coordinates = refine_fieldmodule.findFieldByName("coordinates").castFiniteElement() + self.assertTrue(refine_coordinates.isValid()) + refine_nodes = refine_fieldmodule.findNodesetByFieldDomainType(Field.DOMAIN_TYPE_NODES) + self.assertEqual(2472, refine_nodes.getSize()) + # check number of common nodes at hilum + with ChangeManager(refine_fieldmodule): + for group_name1, group_name2 in intersection_group_names: + group = refine_fieldmodule.createFieldGroup() + nodeset_group = group.createNodesetGroup(refine_nodes) + nodeset_group.addNodesConditional( + refine_fieldmodule.createFieldAnd(refine_fieldmodule.findFieldByName(group_name1), + refine_fieldmodule.findFieldByName(group_name2))) + self.assertEqual(5, nodeset_group.getSize()) + del nodeset_group + del group + + expected_refine_mesh_sizes = { + 'mesh3d': (1856, 0.230336480247037), + 'mesh2d': (6104, 16.332327494330084), + 'mesh1d': (6718, 358.30880646477294), + 'left lung.mesh3d': (928, 0.1151678888674793), + 'lower lobe of left lung.mesh3d': (352, 0.0548394588491132), + 'upper lobe of left lung.mesh3d': (576, 0.060328430018365395), + 'right lung.mesh3d': (928, 0.11516859137956122), + 'lower lobe of right lung.mesh3d': (352, 0.054839458849113155), + 'middle lobe of right lung.mesh3d': (192, 0.015593500770872093), + 'upper lobe of right lung.mesh3d': (384, 0.04473563175957532), + 'oblique fissure of lower lobe of left lung.mesh2d': (72, 0.23303251949030654), + 'oblique fissure of upper lobe of left lung.mesh2d': (80, 0.25693609525575456), + 'horizontal fissure of middle lobe of right lung.mesh2d': (40, 0.08446588867418067), + 'horizontal fissure of upper lobe of right lung.mesh2d': (40, 0.08446588867418048), + 'oblique fissure of lower lobe of right lung.mesh2d': (72, 0.23303251949030673), + 'oblique fissure of middle lobe of right lung.mesh2d': (40, 0.11647413723834865), + 'oblique fissure of upper lobe of right lung.mesh2d': (40, 0.14046195801740563), + 'posterior edge of lower lobe of left lung.mesh1d': (12, 0.7213630968439506), + 'posterior edge of lower lobe of right lung.mesh1d': (12, 0.7213630968439503), + } + with ChangeManager(refine_fieldmodule): + one = refine_fieldmodule.createFieldConstant(1.0) + for mesh_name, expected_sizes in expected_refine_mesh_sizes.items(): + expected_size, expected_val = expected_sizes + mesh_group = refine_fieldmodule.findMeshByName(mesh_name) + size = mesh_group.getSize() + self.assertEqual(size, expected_size, msg=mesh_name) + # volume/area/length + val_integral = refine_fieldmodule.createFieldMeshIntegral(one, refine_coordinates, mesh_group) + val_integral.setNumbersOfPoints(4) + result, val = val_integral.evaluateReal(refine_fieldcache, 1) + self.assertEqual(result, RESULT_OK) + self.assertAlmostEqual(val, expected_val, msg=mesh_name, delta=TOL) + if __name__ == "__main__": unittest.main() From 89b7eea7a9d186b69b488d70eb992c8cb9aac843 Mon Sep 17 00:00:00 2001 From: Richard Christie Date: Thu, 4 Dec 2025 12:30:42 +1300 Subject: [PATCH 15/24] Make null face check safe for swig variants --- src/scaffoldmaker/utils/meshrefinement.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/scaffoldmaker/utils/meshrefinement.py b/src/scaffoldmaker/utils/meshrefinement.py index 76d4a67d..8b790f95 100644 --- a/src/scaffoldmaker/utils/meshrefinement.py +++ b/src/scaffoldmaker/utils/meshrefinement.py @@ -251,6 +251,7 @@ def refineElementCubeStandard3d(self, sourceElement, numberInXi1, numberInXi2, n faces = [None] * 6 exterior_faces = [False] * 6 # whether face is on exterior boundary of mesh + null_face_count = 0 if sourceElement.getNumberOfFaces() == 6: for f in range(6): face = sourceElement.getFaceElement(f + 1) @@ -261,8 +262,10 @@ def refineElementCubeStandard3d(self, sourceElement, numberInXi1, numberInXi2, n exterior_faces[f] = (result == RESULT_OK) and (value != 0.0) else: faces[f] = None + null_face_count += 1 # collapsed elements have no face, so get all faces which are adjacent to collapsed face - if (None in faces) and any(face is not None for face in faces): + # check there is at least one valid face and one null face + if 0 < null_face_count < 6: for f, face in enumerate(faces): if not face: # get coordinates at centre of face From 48d75bde0500184cf4c2b2b6714455a3696b34f4 Mon Sep 17 00:00:00 2001 From: Richard Christie Date: Fri, 5 Dec 2025 11:02:09 +1300 Subject: [PATCH 16/24] Fix lateral/medial edge groups on refined mesh --- .../meshtypes/meshtype_3d_lung4.py | 59 ++++++++++--------- tests/test_lung.py | 21 ++++++- 2 files changed, 51 insertions(+), 29 deletions(-) diff --git a/src/scaffoldmaker/meshtypes/meshtype_3d_lung4.py b/src/scaffoldmaker/meshtypes/meshtype_3d_lung4.py index 5d27c422..90bd2a68 100644 --- a/src/scaffoldmaker/meshtypes/meshtype_3d_lung4.py +++ b/src/scaffoldmaker/meshtypes/meshtype_3d_lung4.py @@ -450,6 +450,7 @@ def defineFaceAnnotations(cls, region, options, annotation_groups): is_box = box_group.getGroup() transition_group = findAnnotationGroupByName(annotation_groups, "transition") is_trans = transition_group.getGroup() + # this works correctly for faces, but gets extra layers for lines on base and fissures which are exterior: is_on_ellipsoid = fm.createFieldAnd(fm.createFieldAnd(is_exterior_face_xi3_1, is_trans), fm.createFieldNot(is_box)) @@ -539,70 +540,74 @@ def defineFaceAnnotations(cls, region, options, annotation_groups): line_term_conditionals_map = {} if has_left_lung: + is_left_lateral_surface = is_face_conditional["lateral surface of left lung"] + is_left_medial_surface = is_face_conditional["medial surface of left lung"] left_line_term_conditionals_map = { "antero-posterior edge of upper lobe of left lung": - (is_upper_left, is_on_ellipsoid, is_medial_left, is_lateral_left), + (is_upper_left, is_left_lateral_surface, is_left_medial_surface), "base edge of oblique fissure of lower lobe of left lung": ( is_face_conditional["base of lower lobe of left lung surface"], is_face_conditional["oblique fissure of lower lobe of left lung"]), "lateral edge of base of lower lobe of left lung": ( - is_face_conditional["base of lower lobe of left lung surface"], is_lateral_left, is_on_ellipsoid), + is_face_conditional["base of lower lobe of left lung surface"], is_left_lateral_surface), "lateral edge of base of upper lobe of left lung": ( - is_face_conditional["base of upper lobe of left lung surface"], is_lateral_left, is_on_ellipsoid), + is_face_conditional["base of upper lobe of left lung surface"], is_left_lateral_surface), "lateral edge of oblique fissure of lower lobe of left lung": ( - is_face_conditional["oblique fissure of lower lobe of left lung"], is_lateral_left, is_on_ellipsoid), + is_face_conditional["oblique fissure of lower lobe of left lung"], is_left_lateral_surface), "lateral edge of oblique fissure of upper lobe of left lung": ( - is_face_conditional["oblique fissure of upper lobe of left lung"], is_lateral_left, is_on_ellipsoid), + is_face_conditional["oblique fissure of upper lobe of left lung"], is_left_lateral_surface), "medial edge of base of lower lobe of left lung": ( - is_face_conditional["base of lower lobe of left lung surface"], is_medial_left, is_on_ellipsoid), + is_face_conditional["base of lower lobe of left lung surface"], is_left_medial_surface), "medial edge of base of upper lobe of left lung": ( - is_face_conditional["base of upper lobe of left lung surface"], is_medial_left, is_on_ellipsoid), + is_face_conditional["base of upper lobe of left lung surface"], is_left_medial_surface), "medial edge of oblique fissure of lower lobe of left lung": ( - is_face_conditional["oblique fissure of lower lobe of left lung"], is_medial_left, is_on_ellipsoid), + is_face_conditional["oblique fissure of lower lobe of left lung"], is_left_medial_surface), "medial edge of oblique fissure of upper lobe of left lung": ( - is_face_conditional["oblique fissure of upper lobe of left lung"], is_medial_left, is_on_ellipsoid), + is_face_conditional["oblique fissure of upper lobe of left lung"], is_left_medial_surface), "posterior edge of lower lobe of left lung": - (is_lower_left, is_lateral_left, is_medial_left, is_on_ellipsoid), + (is_lower_left, is_left_lateral_surface, is_left_medial_surface), } line_term_conditionals_map.update(left_line_term_conditionals_map) if has_right_lung: + is_right_lateral_surface = is_face_conditional["lateral surface of right lung"] + is_right_medial_surface = is_face_conditional["medial surface of right lung"] right_line_term_conditionals_map = { "anterior edge of middle lobe of right lung": - (is_middle_right, is_on_ellipsoid, is_medial_right, is_lateral_right), + (is_middle_right, is_right_lateral_surface, is_right_medial_surface), "antero-posterior edge of upper lobe of right lung": - (is_upper_right, is_on_ellipsoid, is_medial_right, is_lateral_right), + (is_upper_right, is_right_lateral_surface, is_right_medial_surface), "base edge of oblique fissure of lower lobe of right lung": ( is_face_conditional["base of lower lobe of right lung surface"], is_face_conditional["oblique fissure of lower lobe of right lung"]), "lateral edge of base of lower lobe of right lung": ( - is_face_conditional["base of lower lobe of right lung surface"], is_lateral_right, is_on_ellipsoid), + is_face_conditional["base of lower lobe of right lung surface"], is_right_lateral_surface), "lateral edge of base of middle lobe of right lung": ( - is_face_conditional["base of middle lobe of right lung surface"], is_lateral_right, is_on_ellipsoid), + is_face_conditional["base of middle lobe of right lung surface"], is_right_lateral_surface), "lateral edge of horizontal fissure of middle lobe of right lung": ( - is_face_conditional["horizontal fissure of middle lobe of right lung"], is_lateral_right, is_on_ellipsoid), + is_face_conditional["horizontal fissure of middle lobe of right lung"], is_right_lateral_surface), "lateral edge of horizontal fissure of upper lobe of right lung": ( - is_face_conditional["horizontal fissure of upper lobe of right lung"], is_lateral_right, is_on_ellipsoid), + is_face_conditional["horizontal fissure of upper lobe of right lung"], is_right_lateral_surface), "lateral edge of oblique fissure of lower lobe of right lung": ( - is_face_conditional["oblique fissure of lower lobe of right lung"], is_lateral_right, is_on_ellipsoid), + is_face_conditional["oblique fissure of lower lobe of right lung"], is_right_lateral_surface), "lateral edge of oblique fissure of middle lobe of right lung": ( - is_face_conditional["oblique fissure of middle lobe of right lung"], is_lateral_right, is_on_ellipsoid), + is_face_conditional["oblique fissure of middle lobe of right lung"], is_right_lateral_surface), "lateral edge of oblique fissure of upper lobe of right lung": ( - is_face_conditional["oblique fissure of upper lobe of right lung"], is_lateral_right, is_on_ellipsoid), + is_face_conditional["oblique fissure of upper lobe of right lung"], is_right_lateral_surface), "medial edge of base of lower lobe of right lung": ( - is_face_conditional["base of lower lobe of right lung surface"], is_medial_right, is_on_ellipsoid), + is_face_conditional["base of lower lobe of right lung surface"], is_right_medial_surface), "medial edge of base of middle lobe of right lung": ( - is_face_conditional["base of middle lobe of right lung surface"], is_medial_right, is_on_ellipsoid), + is_face_conditional["base of middle lobe of right lung surface"], is_right_medial_surface), "medial edge of horizontal fissure of middle lobe of right lung": ( - is_face_conditional["horizontal fissure of middle lobe of right lung"], is_medial_right, is_on_ellipsoid), + is_face_conditional["horizontal fissure of middle lobe of right lung"], is_right_medial_surface), "medial edge of horizontal fissure of upper lobe of right lung": ( - is_face_conditional["horizontal fissure of upper lobe of right lung"], is_medial_right, is_on_ellipsoid), + is_face_conditional["horizontal fissure of upper lobe of right lung"], is_right_medial_surface), "medial edge of oblique fissure of lower lobe of right lung": ( - is_face_conditional["oblique fissure of lower lobe of right lung"], is_medial_right, is_on_ellipsoid), + is_face_conditional["oblique fissure of lower lobe of right lung"], is_right_medial_surface), "medial edge of oblique fissure of middle lobe of right lung": ( - is_face_conditional["oblique fissure of middle lobe of right lung"], is_medial_right, is_on_ellipsoid), + is_face_conditional["oblique fissure of middle lobe of right lung"], is_right_medial_surface), "medial edge of oblique fissure of upper lobe of right lung": ( - is_face_conditional["oblique fissure of upper lobe of right lung"], is_medial_right, is_on_ellipsoid), + is_face_conditional["oblique fissure of upper lobe of right lung"], is_right_medial_surface), "posterior edge of lower lobe of right lung": - (is_lower_right, is_lateral_right, is_medial_right, is_on_ellipsoid) + (is_lower_right, is_right_lateral_surface, is_right_medial_surface) } line_term_conditionals_map.update(right_line_term_conditionals_map) diff --git a/tests/test_lung.py b/tests/test_lung.py index f0f31b17..49e8c64b 100644 --- a/tests/test_lung.py +++ b/tests/test_lung.py @@ -2,7 +2,6 @@ import math import unittest -from cmlibs.utils.zinc.field import find_or_create_field_group from cmlibs.utils.zinc.finiteelement import evaluateFieldNodesetRange, findNodeWithName from cmlibs.utils.zinc.general import ChangeManager from cmlibs.zinc.context import Context @@ -1111,12 +1110,21 @@ def test_lung4_human(self): 'upper lobe of right lung.mesh3d': (48, 0.04576404703437447), 'oblique fissure of lower lobe of left lung.mesh2d': (18, 0.23573085010104664), 'oblique fissure of upper lobe of left lung.mesh2d': (20, 0.26116146713690147), + # base of middle lobe includes medial surface + 'base of middle lobe of right lung surface.mesh2d': (18, 0.2036121963903865), 'horizontal fissure of middle lobe of right lung.mesh2d': (10, 0.08506039275120454), 'horizontal fissure of upper lobe of right lung.mesh2d': (10, 0.08506039275120465), 'oblique fissure of lower lobe of right lung.mesh2d': (18, 0.23573085010104675), 'oblique fissure of middle lobe of right lung.mesh2d': (10, 0.11847510573774353), 'oblique fissure of upper lobe of right lung.mesh2d': (10, 0.14268636139915797), + 'lateral edge of base of lower lobe of left lung.mesh1d': (3, 0.6163117802059481), + 'medial edge of base of lower lobe of left lung.mesh1d': (3, 0.5174206016915209), 'posterior edge of lower lobe of left lung.mesh1d': (6, 0.7217563496699031), + 'lateral edge of base of lower lobe of right lung.mesh1d': (3, 0.6163117802059481), + 'medial edge of base of lower lobe of right lung.mesh1d': (3, 0.5174206016915209), + # base of middle lobe includes anterior edge + 'lateral edge of base of middle lobe of right lung.mesh1d': (7, 1.0751126241895612), + 'medial edge of base of middle lobe of right lung.mesh1d': (7, 0.8432549927571157), 'posterior edge of lower lobe of right lung.mesh1d': (6, 0.7217563496699028) } TOL = 1.0E-6 @@ -1183,13 +1191,22 @@ def test_lung4_human(self): 'upper lobe of right lung.mesh3d': (384, 0.04473563175957532), 'oblique fissure of lower lobe of left lung.mesh2d': (72, 0.23303251949030654), 'oblique fissure of upper lobe of left lung.mesh2d': (80, 0.25693609525575456), + # base of middle lobe includes medial surface + 'base of middle lobe of right lung surface.mesh2d': (72, 0.2007412231447152), 'horizontal fissure of middle lobe of right lung.mesh2d': (40, 0.08446588867418067), 'horizontal fissure of upper lobe of right lung.mesh2d': (40, 0.08446588867418048), 'oblique fissure of lower lobe of right lung.mesh2d': (72, 0.23303251949030673), 'oblique fissure of middle lobe of right lung.mesh2d': (40, 0.11647413723834865), 'oblique fissure of upper lobe of right lung.mesh2d': (40, 0.14046195801740563), + 'lateral edge of base of lower lobe of left lung.mesh1d': (6, 0.6113060310659696), + 'medial edge of base of lower lobe of left lung.mesh1d': (6, 0.5145764175197279), 'posterior edge of lower lobe of left lung.mesh1d': (12, 0.7213630968439506), - 'posterior edge of lower lobe of right lung.mesh1d': (12, 0.7213630968439503), + 'lateral edge of base of lower lobe of right lung.mesh1d': (6, 0.6113060310659696), + 'medial edge of base of lower lobe of right lung.mesh1d': (6, 0.5145764175197279), + # base of middle lobe includes anterior edge + 'lateral edge of base of middle lobe of right lung.mesh1d': (14, 1.0700457132854544), + 'medial edge of base of middle lobe of right lung.mesh1d': (14, 0.8398626858403571), + 'posterior edge of lower lobe of right lung.mesh1d': (12, 0.7213630968439503) } with ChangeManager(refine_fieldmodule): one = refine_fieldmodule.createFieldConstant(1.0) From 4a98173f217c714db1fca58bad1b28e2c4a8d0f4 Mon Sep 17 00:00:00 2001 From: Richard Christie Date: Tue, 9 Dec 2025 19:48:33 +1300 Subject: [PATCH 17/24] Remove middle lobe base-medial surface tweak --- src/scaffoldmaker/meshtypes/meshtype_3d_lung4.py | 6 ------ tests/test_lung.py | 4 ++-- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/src/scaffoldmaker/meshtypes/meshtype_3d_lung4.py b/src/scaffoldmaker/meshtypes/meshtype_3d_lung4.py index 90bd2a68..6a03584b 100644 --- a/src/scaffoldmaker/meshtypes/meshtype_3d_lung4.py +++ b/src/scaffoldmaker/meshtypes/meshtype_3d_lung4.py @@ -630,12 +630,6 @@ def defineFaceAnnotations(cls, region, options, annotation_groups): ]: base_edge_group = fm.findFieldByName(base_edge_group_name).castGroup() base_edge_group.getMeshGroup(mesh1d).addElementsConditional(is_anterior_edge) - is_medial_surface = fm.findFieldByName("medial surface of middle lobe of right lung").castGroup() - for base_surface_group_name in [ - "base of middle lobe of right lung surface" - ]: - base_surface_group = fm.findFieldByName(base_surface_group_name).castGroup() - base_surface_group.getMeshGroup(mesh2d).addElementsConditional(is_medial_surface) # remove temporary annotation groups for group_name in [ diff --git a/tests/test_lung.py b/tests/test_lung.py index 49e8c64b..c30a2bba 100644 --- a/tests/test_lung.py +++ b/tests/test_lung.py @@ -1111,7 +1111,7 @@ def test_lung4_human(self): 'oblique fissure of lower lobe of left lung.mesh2d': (18, 0.23573085010104664), 'oblique fissure of upper lobe of left lung.mesh2d': (20, 0.26116146713690147), # base of middle lobe includes medial surface - 'base of middle lobe of right lung surface.mesh2d': (18, 0.2036121963903865), + 'base of middle lobe of right lung surface.mesh2d': (10, 0.11847510573774353), 'horizontal fissure of middle lobe of right lung.mesh2d': (10, 0.08506039275120454), 'horizontal fissure of upper lobe of right lung.mesh2d': (10, 0.08506039275120465), 'oblique fissure of lower lobe of right lung.mesh2d': (18, 0.23573085010104675), @@ -1192,7 +1192,7 @@ def test_lung4_human(self): 'oblique fissure of lower lobe of left lung.mesh2d': (72, 0.23303251949030654), 'oblique fissure of upper lobe of left lung.mesh2d': (80, 0.25693609525575456), # base of middle lobe includes medial surface - 'base of middle lobe of right lung surface.mesh2d': (72, 0.2007412231447152), + 'base of middle lobe of right lung surface.mesh2d': (40, 0.11647413723834865), 'horizontal fissure of middle lobe of right lung.mesh2d': (40, 0.08446588867418067), 'horizontal fissure of upper lobe of right lung.mesh2d': (40, 0.08446588867418048), 'oblique fissure of lower lobe of right lung.mesh2d': (72, 0.23303251949030673), From 9194529c15dff6c1f9d6e4014b35fa13b6e2c4f7 Mon Sep 17 00:00:00 2001 From: Richard Christie Date: Tue, 9 Dec 2025 22:44:32 +1300 Subject: [PATCH 18/24] Support no lower lobe extension Review fixes --- .../meshtypes/meshtype_3d_lung4.py | 23 ++++++++----------- src/scaffoldmaker/utils/ellipsoidmesh.py | 21 +++++++++++------ 2 files changed, 24 insertions(+), 20 deletions(-) diff --git a/src/scaffoldmaker/meshtypes/meshtype_3d_lung4.py b/src/scaffoldmaker/meshtypes/meshtype_3d_lung4.py index 6a03584b..20d8e67b 100644 --- a/src/scaffoldmaker/meshtypes/meshtype_3d_lung4.py +++ b/src/scaffoldmaker/meshtypes/meshtype_3d_lung4.py @@ -105,11 +105,6 @@ def checkOptions(cls, options): dependent_changes = False max_transition_count = None - for key in [ - "Number of elements lower lobe extension" - ]: - if options[key] < 1: - options[key] = 1 for key in [ "Number of elements lateral", "Number of elements oblique" @@ -128,13 +123,13 @@ def checkOptions(cls, options): options["Number of transition elements"] = max_transition_count dependent_changes = True - for key in [ - "Ellipsoid height", - "Ellipsoid dorsal-ventral size", - "Ellipsoid medial-lateral size" + for key, default in [ + ("Ellipsoid height", 1.0), + ("Ellipsoid dorsal-ventral size", 0.8), + ("Ellipsoid medial-lateral size", 0.4) ]: if options[key] <= 0.0: - options[key] = 1.0 + options[key] = default for key in [ "Lower lobe base concavity", "Lower lobe extension", @@ -145,6 +140,9 @@ def checkOptions(cls, options): if options["Lower lobe extension"] == 0.0: options["Number of elements lower lobe extension"] = 0 dependent_changes = True + elif options["Number of elements lower lobe extension"] < 1: + options["Number of elements lower lobe extension"] = 1 + dependent_changes = True depth = options["Ellipsoid dorsal-ventral size"] height = options["Ellipsoid height"] max_extension = 0.99 * magnitude(getEllipsePointAtTrueAngle(depth / 2.0, height / 2.0, math.pi / 3.0)) @@ -380,7 +378,7 @@ def generateBaseMesh(cls, region, options): is_left = lung == left_lung lung_nodeset = (left_lung_group if is_left else right_lung_group).getNodesetGroup(nodes) - if lower_lobe_base_concavity > 0.0: + if (lower_lobe_base_concavity > 0.0) and (lower_lobe_extension > 0.0): lower_lobe_group = lower_left_lung_group if (lung == left_lung) else lower_right_lung_group form_lower_lobe_base_concavity( lower_lobe_base_concavity, lower_lobe_extension, half_ml_size, half_dv_size, half_height, @@ -398,7 +396,6 @@ def generateBaseMesh(cls, region, options): return annotation_groups, None - @classmethod def refineMesh(cls, meshRefinement, options): """ @@ -766,7 +763,7 @@ def form_lower_lobe_base_concavity(lower_lobe_base_concavity, lower_lobe_extensi def taper_lung_edge(sharpeningFactor, fieldmodule, coordinates, nodeset, halfValue, isBase=False): """ - Applies a tapering transformation to the lung geometry to sharpen the anterior edge or the base. + Apply a tapering transformation to the lung geometry to sharpen the anterior edge or the base. If isBase is False, it sharpens the anterior edge (along the y-axis). If isBase is True, it sharpens the base (along the z-axis), but only for nodes below a certain height. :param sharpeningFactor: A value between 0 and 1, where 1 represents the maximum sharpness. diff --git a/src/scaffoldmaker/utils/ellipsoidmesh.py b/src/scaffoldmaker/utils/ellipsoidmesh.py index 49316dd9..4503963f 100644 --- a/src/scaffoldmaker/utils/ellipsoidmesh.py +++ b/src/scaffoldmaker/utils/ellipsoidmesh.py @@ -10,9 +10,7 @@ from scaffoldmaker.utils.geometry import ( getEllipsePointAtTrueAngle, getEllipseTangentAtPoint, moveCoordinatesToEllipsoidSurface, moveDerivativeToEllipsoidSurface, moveDerivativeToEllipsoidSurfaceInPlane, sampleCurveOnEllipsoid) -from scaffoldmaker.utils.interpolation import ( - DerivativeScalingMode, get_nway_point, linearlyInterpolateVectors, sampleHermiteCurve, - smoothCubicHermiteDerivativesLine) +from scaffoldmaker.utils.interpolation import DerivativeScalingMode, linearlyInterpolateVectors, sampleHermiteCurve from scaffoldmaker.utils.hextetrahedronmesh import HexTetrahedronMesh from scaffoldmaker.utils.quadtrianglemesh import QuadTriangleMesh import copy @@ -23,7 +21,8 @@ class EllipsoidSurfaceD3Mode(Enum): SURFACE_NORMAL = 1 # surface D3 are exact surface normals to ellipsoid OBLIQUE_DIRECTION = 2 # surface D3 are in direction of surface point on ellipsoid, gives flat oblique planes - SURFACE_NORMAL_PLANE_PROJECTION = 3 # Surface D3 are surface normals to ellipsoid projected onto planes from axis2 to axis3 + # Surface D3 are surface normals to ellipsoid projected onto radial planes transitioning between axis2 and axis3 + SURFACE_NORMAL_PLANE_PROJECTION = 3 class EllipsoidMesh: @@ -222,9 +221,12 @@ def build_octant(self, half_counts, axis2_x_rotation_radians, axis3_x_rotation_r move_d_to_ellipsoid_surface = lambda x, d: moveDerivativeToEllipsoidSurface(self._a, self._b, self._c, x, d) def evaluate_surface_d3_ellipsoid_plane(tx, td1, td2): """ - Restrict d3 to be ellipsoid normal constrained be in plane interpolated from axis_d2 to axis_d3 at tx - relative to ext_origin. - :return: d3 with magnitude dir_mag. + Restrict d3 to be the ellipsoid normal constrained to be in radial planes from ext_origin through tx, + varying between axis_d2 and axis_d3. + :param tx: Coordinates of a point on the ellipsoid surface in the octant. + :param td1: Unused point d1. + :param td2: Unused point d2. + :return: Radial plane constrained ellipsoid normal d3 with magnitude dir_mag. """ n = [tx[0] / (self._a * self._a), tx[1] / (self._b * self._b), tx[2] / (self._c * self._c)] if dot(tx, axis3_normal) <= 1.0E-5: @@ -521,6 +523,11 @@ def copy_to_negative_axis1(self): nx_row[self._element_counts[0] - n1] = [x, d1, d2, d3] def _next_increment_out_of_bounds(self, indexes, index_increment): + """ + :param indexes: List of 3 current indexes in the EllipsoidMesh nodes block: [n1, n2, n3]. + :param index_increment: Increments to the 3 indexes. + :return: True if adding index_increment to indexes puts the index outside the nodes block. + """ for c in range(3): index = indexes[c] + index_increment[c] if (index < 0) or (index > self._element_counts[c]): From 4ca29eae622553826a69d01fe677d196b8a9463d Mon Sep 17 00:00:00 2001 From: Richard Christie Date: Wed, 10 Dec 2025 17:06:04 +1300 Subject: [PATCH 19/24] Add lung marker points --- .../meshtypes/meshtype_3d_lung4.py | 94 ++++++++++++++++++- src/scaffoldmaker/utils/zinc_utils.py | 21 +++++ tests/test_lung.py | 12 +-- 3 files changed, 120 insertions(+), 7 deletions(-) diff --git a/src/scaffoldmaker/meshtypes/meshtype_3d_lung4.py b/src/scaffoldmaker/meshtypes/meshtype_3d_lung4.py index 20d8e67b..827f46c7 100644 --- a/src/scaffoldmaker/meshtypes/meshtype_3d_lung4.py +++ b/src/scaffoldmaker/meshtypes/meshtype_3d_lung4.py @@ -11,9 +11,10 @@ from scaffoldmaker.annotation.lung_terms import get_lung_term from scaffoldmaker.meshtypes.scaffold_base import Scaffold_base from scaffoldmaker.utils.geometry import getEllipsePointAtTrueAngle +from scaffoldmaker.utils.interpolation import getNearestLocationOnCurve from scaffoldmaker.utils.meshrefinement import MeshRefinement from scaffoldmaker.utils.ellipsoidmesh import EllipsoidMesh, EllipsoidSurfaceD3Mode -from scaffoldmaker.utils.zinc_utils import translate_nodeset_coordinates +from scaffoldmaker.utils.zinc_utils import get_mesh_first_element_with_node, translate_nodeset_coordinates import copy import math @@ -190,6 +191,7 @@ def generateBaseMesh(cls, region, options): ellipsoid_height = options["Ellipsoid height"] fieldmodule = region.getFieldmodule() + fieldcache = fieldmodule.createFieldcache() coordinates = find_or_create_field_coordinates(fieldmodule) nodes = fieldmodule.findNodesetByFieldDomainType(Field.DOMAIN_TYPE_NODES) mesh = fieldmodule.findMeshByDimension(3) @@ -238,6 +240,7 @@ def generateBaseMesh(cls, region, options): half_height = ellipsoid_height * 0.5 pi__3 = math.pi / 3.0 + sin_pi__3 = math.sin(pi__3) left_lung, right_lung = 0, 1 lungs = [lung for show, lung in [(has_left_lung, left_lung), (has_right_lung, right_lung)] if show] @@ -245,6 +248,7 @@ def generateBaseMesh(cls, region, options): # currently build left lung if right lung is being built to get correct node/element identifiers lungs_construct = [left_lung, right_lung] if has_right_lung else [left_lung] if has_left_lung else [] + marker_name_element_xi = [] for lung in lungs_construct: @@ -374,6 +378,94 @@ def generateBaseMesh(cls, region, options): node_identifier, element_identifier = ellipsoid.generate_mesh( fieldmodule, coordinates, node_identifier, element_identifier) + # find elements for marker points + if ((lung == left_lung) and has_left_lung) or ((lung == right_lung) and has_right_lung): + nid = upper_ellipsoid.get_node_identifier( + elements_count_lateral // 2, 0, element_counts[2]) + group = upper_left_lung_group if (lung == left_lung) else upper_right_lung_group + if nid and group: + marker_name_element_xi.append(( + "apex of left lung" if (lung == left_lung) else "apex of right lung", + get_mesh_first_element_with_node( + group.getMeshGroup(mesh), coordinates, nodes.findNodeByIdentifier(nid)), + [1.0, 1.0, 1.0])) + nid = lower_ellipsoid_mesh.get_node_identifier( + elements_count_lateral // 2, 0, elements_count_oblique // 2 + elements_count_lower_extension) + group = lower_left_lung_group if (lung == left_lung) else lower_right_lung_group + if nid and group: + marker_name_element_xi.append(( + "dorsal base of left lung" if (lung == left_lung) else "dorsal base of right lung", + get_mesh_first_element_with_node( + group.getMeshGroup(mesh), coordinates, nodes.findNodeByIdentifier(nid)), + [1.0, 0.0, 1.0])) + if lung == right_lung: + nid = middle_ellipsoid.get_node_identifier( + elements_count_lateral, elements_count_oblique // 2, elements_count_oblique // 2) + group = middle_right_lung_group + if nid and group: + marker_name_element_xi.append(( + "laterodorsal tip of middle lobe of right lung", + get_mesh_first_element_with_node( + group.getMeshGroup(mesh), coordinates, nodes.findNodeByIdentifier(nid)), + [0.0, 1.0, 1.0])) + # need to find element xi where medial base edge crosses 0.0 + group = lower_left_lung_group if (lung == left_lung) else lower_right_lung_group + if group: + n1 = elements_count_lateral if (lung == left_lung) else 0 + n3 = elements_count_oblique // 2 + elements_count_lower_extension + last_n2 = None + last_nid = None + last_nx = None + last_nd1 = None + for n2 in range(0, elements_count_oblique // 2 + 1): + nid = lower_ellipsoid_mesh.get_node_identifier(n1, n2, n3) + if nid: + nx, nd1 = lower_ellipsoid_mesh.get_node_parameters(n1, n2, n3)[:2] + if last_nid: + if (last_nx[1] <= 0.0) and (nx[1] >= 0.0): + element = get_mesh_first_element_with_node( + group.getMeshGroup(mesh), coordinates, nodes.findNodeByIdentifier( + nid if ((lung == left_lung) or (last_n2 == 0)) else last_nid)) + if element.isValid(): + # xi1 goes counter-clockwise from above so different for left and right + if lung == left_lung: + curve_location, x = getNearestLocationOnCurve( + [last_nx[1:2], nx[1:2]], [last_nd1[1:2], nd1[1:2]], [0.0], + startLocation=(0, -last_nx[1] / (nx[1] - last_nx[1]))) + else: + curve_location, x = getNearestLocationOnCurve( + [nx[1:2], last_nx[1:2]], [nd1[1:2], last_nd1[1:2]], [0.0], + startLocation=(0, nx[1] / (nx[1] - last_nx[1]))) + marker_name_element_xi.append(( + "medial base of left lung" if (lung == left_lung) else + "medial base of right lung", + element, [curve_location[1], 0.0, 1.0])) + break + last_n2 = n2 + last_nid = nid + last_nx = nx + last_nd1 = nd1 + ventral_ellipsoid = upper_ellipsoid if (lung == left_lung) else middle_ellipsoid + nid = ventral_ellipsoid.get_node_identifier( + elements_count_lateral // 2, elements_count_oblique // 2, 0) + group = upper_left_lung_group if (lung == left_lung) else middle_right_lung_group + if nid and group: + marker_name_element_xi.append(( + "ventral base of left lung" if (lung == left_lung) else "ventral base of right lung", + get_mesh_first_element_with_node( + group.getMeshGroup(mesh), coordinates, nodes.findNodeByIdentifier(nid)), + [0.0, 0.0, 1.0])) + + # marker points; make after regular nodes so higher node numbers + + lung_nodeset = lung_group.getNodesetGroup(nodes) + for marker_name, element, xi in marker_name_element_xi: + annotation_group = findOrCreateAnnotationGroupForTerm( + annotation_groups, region, get_lung_term(marker_name), isMarker=True) + marker_node = annotation_group.createMarkerNode(node_identifier, element=element, xi=xi) + lung_nodeset.addNode(marker_node) + node_identifier += 1 + for lung in lungs: is_left = lung == left_lung lung_nodeset = (left_lung_group if is_left else right_lung_group).getNodesetGroup(nodes) diff --git a/src/scaffoldmaker/utils/zinc_utils.py b/src/scaffoldmaker/utils/zinc_utils.py index dcf2cd03..126d85aa 100644 --- a/src/scaffoldmaker/utils/zinc_utils.py +++ b/src/scaffoldmaker/utils/zinc_utils.py @@ -134,6 +134,27 @@ def mesh_destroy_elements_and_nodes_by_identifiers(mesh, element_identifiers): return +def get_mesh_first_element_with_node(mesh, field, node): + """ + Assumes all components of field have the same Elementfieldtemplate. + :param mesh: A Zinc Mesh or MeshGroup. + :param field: Field to query, since nodes are mapped by field. + :param node: A Zinc Node + :return: Zinc Element or None + """ + elementiterator = mesh.createElementiterator() + element = elementiterator.next() + while element.isValid(): + eft = element.getElementfieldtemplate(field, -1) + if eft.isValid(): + node_count = eft.getNumberOfLocalNodes() + for n in range(1, node_count + 1): + if element.getNode(eft, n) == node: + return element + element = elementiterator.next() + return None + + def get_nodeset_field_parameters(nodeset, field, only_value_labels=None): """ Returns parameters of field from nodes in nodeset in identifier order. diff --git a/tests/test_lung.py b/tests/test_lung.py index c30a2bba..5d74dd27 100644 --- a/tests/test_lung.py +++ b/tests/test_lung.py @@ -1064,7 +1064,7 @@ def test_lung4_human(self): self.assertTrue(region.isValid()) annotation_groups, _ = scaffold.generateBaseMesh(region, options) base_annotation_groups = copy.copy(annotation_groups) - self.assertEqual(16, len(base_annotation_groups)) + self.assertEqual(25, len(base_annotation_groups)) fieldmodule.defineAllFaces() for annotation_group in annotation_groups: annotation_group.addSubelements() @@ -1072,13 +1072,13 @@ def test_lung4_human(self): for annotation_group in annotation_groups: if annotation_group not in base_annotation_groups: annotation_group.addSubelements() - self.assertEqual(67, len(annotation_groups)) + self.assertEqual(76, len(annotation_groups)) fieldcache = fieldmodule.createFieldcache() coordinates = fieldmodule.findFieldByName("coordinates").castFiniteElement() self.assertTrue(coordinates.isValid()) nodes = fieldmodule.findNodesetByFieldDomainType(Field.DOMAIN_TYPE_NODES) - self.assertEqual(407, nodes.getSize()) + self.assertEqual(416, nodes.getSize()) intersection_group_names = [ ("lower lobe of left lung", "upper lobe of left lung"), ("lower lobe of right lung", "middle lobe of right lung"), @@ -1150,7 +1150,7 @@ def test_lung4_human(self): meshrefinement = MeshRefinement(region, refine_region, base_annotation_groups) scaffold.refineMesh(meshrefinement, options) refine_annotation_groups = meshrefinement.getAnnotationGroups() - self.assertEqual(16, len(refine_annotation_groups)) + self.assertEqual(25, len(refine_annotation_groups)) refine_fieldmodule.defineAllFaces() for annotation_group in refine_annotation_groups: annotation_group.addSubelements() @@ -1159,13 +1159,13 @@ def test_lung4_human(self): for annotation_group in refine_annotation_groups: if annotation_group not in old_annotation_groups: annotation_group.addSubelements() - self.assertEqual(67, len(refine_annotation_groups)) + self.assertEqual(76, len(refine_annotation_groups)) refine_fieldcache = refine_fieldmodule.createFieldcache() refine_coordinates = refine_fieldmodule.findFieldByName("coordinates").castFiniteElement() self.assertTrue(refine_coordinates.isValid()) refine_nodes = refine_fieldmodule.findNodesetByFieldDomainType(Field.DOMAIN_TYPE_NODES) - self.assertEqual(2472, refine_nodes.getSize()) + self.assertEqual(2481, refine_nodes.getSize()) # check number of common nodes at hilum with ChangeManager(refine_fieldmodule): for group_name1, group_name2 in intersection_group_names: From ba195b14a03302bcb0ae175672c37464308aeb99 Mon Sep 17 00:00:00 2001 From: Richard Christie Date: Wed, 10 Dec 2025 22:41:01 +1300 Subject: [PATCH 20/24] Stop refine making marker groups for all markers --- .../meshtypes/meshtype_3d_esophagus1.py | 16 +++--------- src/scaffoldmaker/utils/meshrefinement.py | 22 ++++++---------- tests/test_lung.py | 25 ++++++++++++++++++- 3 files changed, 36 insertions(+), 27 deletions(-) diff --git a/src/scaffoldmaker/meshtypes/meshtype_3d_esophagus1.py b/src/scaffoldmaker/meshtypes/meshtype_3d_esophagus1.py index 1227765b..255b8235 100644 --- a/src/scaffoldmaker/meshtypes/meshtype_3d_esophagus1.py +++ b/src/scaffoldmaker/meshtypes/meshtype_3d_esophagus1.py @@ -505,10 +505,6 @@ def createEsophagusMesh3d(region, options, networkLayout, nextNodeIdentifier, ne markerLocation = findOrCreateFieldStoredMeshLocation(fm, mesh, name="marker_location") nodes = fm.findNodesetByFieldDomainType(Field.DOMAIN_TYPE_NODES) - markerPoints = markerGroup.getOrCreateNodesetGroup(nodes) - markerTemplateInternal = nodes.createNodetemplate() - markerTemplateInternal.defineField(markerName) - markerTemplateInternal.defineField(markerLocation) markerNames = ["proximodorsal midpoint on serosa of upper esophageal sphincter", "proximoventral midpoint on serosa of upper esophageal sphincter", @@ -532,19 +528,15 @@ def createEsophagusMesh3d(region, options, networkLayout, nextNodeIdentifier, ne [0.0, 1.0, 1.0], [xi1Pi, 1.0, 1.0]] + esophagusNodesetGroup = esophagusGroup.getNodesetGroup(nodes) for n in range(len(markerNames)): markerGroup = findOrCreateAnnotationGroupForTerm(annotationGroups, region, - get_esophagus_term(markerNames[n])) + get_esophagus_term(markerNames[n]), isMarker=True) markerElement = mesh.findElementByIdentifier(markerElementIdentifiers[n]) markerXi = markerXis[n] - cache.setMeshLocation(markerElement, markerXi) - markerPoint = markerPoints.createNode(nodeIdentifier, markerTemplateInternal) + markerNode = markerGroup.createMarkerNode(nodeIdentifier, element=markerElement, xi=markerXi) + esophagusNodesetGroup.addNode(markerNode) nodeIdentifier += 1 - cache.setNode(markerPoint) - markerName.assignString(cache, markerGroup.getName()) - markerLocation.assignMeshLocation(cache, markerElement, markerXi) - for group in [esophagusGroup, markerGroup]: - group.getNodesetGroup(nodes).addNode(markerPoint) fm.endChange() diff --git a/src/scaffoldmaker/utils/meshrefinement.py b/src/scaffoldmaker/utils/meshrefinement.py index 8b790f95..6fac3313 100644 --- a/src/scaffoldmaker/utils/meshrefinement.py +++ b/src/scaffoldmaker/utils/meshrefinement.py @@ -8,7 +8,7 @@ from cmlibs.zinc.field import Field from cmlibs.zinc.node import Node from cmlibs.zinc.result import RESULT_OK -from scaffoldmaker.annotation.annotationgroup import AnnotationGroup +from scaffoldmaker.annotation.annotationgroup import AnnotationGroup, findAnnotationGroupByName from scaffoldmaker.utils.octree import Octree import copy @@ -79,15 +79,13 @@ def __init__(self, sourceRegion, targetRegion, sourceAnnotationGroups=[]): self._sourceAnnotationGroups = sourceAnnotationGroups self._annotationGroups = [] self._sourceAndTargetMeshGroups = [] - self._sourceAndTargetNodesetGroups = [] for sourceAnnotationGroup in sourceAnnotationGroups: - targetAnnotationGroup = AnnotationGroup(self._targetRegion, sourceAnnotationGroup.getTerm()) + targetAnnotationGroup = AnnotationGroup( + self._targetRegion, sourceAnnotationGroup.getTerm(), isMarker=sourceAnnotationGroup.isMarker()) self._annotationGroups.append(targetAnnotationGroup) # assume have only highest dimension element or node/point annotation groups: if sourceAnnotationGroup.hasMeshGroup(self._sourceMesh): self._sourceAndTargetMeshGroups.append((sourceAnnotationGroup.getMeshGroup(self._sourceMesh), targetAnnotationGroup.getMeshGroup(self._targetMesh))) - else: - self._sourceAndTargetNodesetGroups.append((sourceAnnotationGroup.getNodesetGroup(self._sourceNodes), targetAnnotationGroup.getNodesetGroup(self._targetNodes))) # prepare element -> marker point list map self.elementMarkerMap = {} @@ -387,10 +385,10 @@ def refineElementCubeStandard3d(self, sourceElement, numberInXi1, numberInXi2, n targetXi = [0.0] * 3 for marker in markerList: markerName, sourceXi, sourceNodeIdentifier = marker - sourceNode = self._sourceNodes.findNodeByIdentifier(sourceNodeIdentifier) - node = self._targetMarkerNodes.createNode(self._nodeIdentifier, self._targetMarkerTemplate) - self._targetCache.setNode(node) - self._targetMarkerName.assignString(self._targetCache, markerName) + annotationGroup = findAnnotationGroupByName(self._annotationGroups, markerName) + if not annotationGroup: + print("Could not find annotation group", markerName) + continue # determine which sub-element, targetXi that sourceXi maps to targetElementIdentifier = startElementIdentifier for i in range(3): @@ -403,12 +401,8 @@ def refineElementCubeStandard3d(self, sourceElement, numberInXi1, numberInXi2, n targetXi[i] = 1.0 targetElementIdentifier += el * elementOffset[i] targetElement = self._targetMesh.findElementByIdentifier(targetElementIdentifier) - result = self._targetMarkerLocation.assignMeshLocation(self._targetCache, targetElement, targetXi) + annotationGroup.createMarkerNode(self._nodeIdentifier, element=targetElement, xi=targetXi) self._nodeIdentifier += 1 - # add new node to matching annotation groups the previous one was in - for sourceAndTargetNodesetGroup in self._sourceAndTargetNodesetGroups: - if sourceAndTargetNodesetGroup[0].containsNode(sourceNode): - sourceAndTargetNodesetGroup[1].addNode(node) return nids, nx diff --git a/tests/test_lung.py b/tests/test_lung.py index 5d74dd27..0f4da9ff 100644 --- a/tests/test_lung.py +++ b/tests/test_lung.py @@ -1142,7 +1142,18 @@ def test_lung4_human(self): self.assertEqual(result, RESULT_OK) self.assertAlmostEqual(val, expected_val, msg=mesh_name, delta=TOL) - # refine 2x2x2 and check result + # check markers + marker_group = fieldmodule.findFieldByName("marker").castGroup() + marker_nodes = marker_group.getNodesetGroup(nodes) + self.assertEqual(9, marker_nodes.getSize()) + for annotation_group in annotation_groups: + if annotation_group.isMarker(): + group_field = annotation_group.getGroup() + self.assertEqual(group_field, marker_group) + null_group = fieldmodule.findFieldByName(annotation_group.getName()).castGroup() + self.assertFalse(null_group.isValid()) + + # refine 2x2x2 and check results refine_region = region.createRegion() refine_fieldmodule = refine_region.getFieldmodule() options['Refine'] = True @@ -1222,6 +1233,18 @@ def test_lung4_human(self): self.assertEqual(result, RESULT_OK) self.assertAlmostEqual(val, expected_val, msg=mesh_name, delta=TOL) + # check refine markers + refine_marker_group = refine_fieldmodule.findFieldByName("marker").castGroup() + refine_marker_nodes = refine_marker_group.getNodesetGroup(refine_nodes) + self.assertEqual(9, refine_marker_nodes.getSize()) + for annotation_group in refine_annotation_groups: + if annotation_group.isMarker(): + group_field = annotation_group.getGroup() + self.assertEqual(group_field, refine_marker_group) + null_group = refine_fieldmodule.findFieldByName(annotation_group.getName()).castGroup() + self.assertFalse(null_group.isValid()) + + if __name__ == "__main__": unittest.main() From d261bd118d77685eea7222b0ab46a98eebd952ed Mon Sep 17 00:00:00 2001 From: Richard Christie Date: Thu, 11 Dec 2025 11:24:10 +1300 Subject: [PATCH 21/24] Fix uterus markers being added to wrong groups --- src/scaffoldmaker/meshtypes/meshtype_3d_uterus1.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/scaffoldmaker/meshtypes/meshtype_3d_uterus1.py b/src/scaffoldmaker/meshtypes/meshtype_3d_uterus1.py index 94330329..2fa698f0 100644 --- a/src/scaffoldmaker/meshtypes/meshtype_3d_uterus1.py +++ b/src/scaffoldmaker/meshtypes/meshtype_3d_uterus1.py @@ -1931,8 +1931,6 @@ def generateBaseMesh(cls, region, options): get_uterus_term(allMarkers[i]), isMarker=True) markerNode = group.createMarkerNode(nodeIdentifier, element=element_junction, xi=xi_junction) nodeIdentifier = markerNode.getIdentifier() + 1 - for group in annotationGroups: - group.getNodesetGroup(nodes).addNode(markerNode) annotationGroups.remove(fundusSerosa) annotationGroups.remove(cervixSerosa) From 98911b72f5a8e39cee692badd26b189688d38f08 Mon Sep 17 00:00:00 2001 From: Richard Christie Date: Thu, 11 Dec 2025 11:24:54 +1300 Subject: [PATCH 22/24] Add lung 4 scaffold documentation --- docs/_images/lung4_human1_coarse.jpg | Bin 0 -> 119384 bytes docs/_images/lung4_left_right_fitted.jpg | Bin 0 -> 107274 bytes docs/scaffolds/lung.rst | 148 +--------------------- docs/scaffolds/lung2.rst | 150 +++++++++++++++++++++++ docs/scaffolds/lung4.rst | 98 +++++++++++++++ 5 files changed, 254 insertions(+), 142 deletions(-) create mode 100644 docs/_images/lung4_human1_coarse.jpg create mode 100644 docs/_images/lung4_left_right_fitted.jpg create mode 100644 docs/scaffolds/lung2.rst create mode 100644 docs/scaffolds/lung4.rst diff --git a/docs/_images/lung4_human1_coarse.jpg b/docs/_images/lung4_human1_coarse.jpg new file mode 100644 index 0000000000000000000000000000000000000000..862b8800a089b250ace6087591f7f7df73b949b6 GIT binary patch literal 119384 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

(hronG&Z^vf0l?=dx0%{in*7R-i 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 z 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

    -x8! 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

    3I)^y?o$ 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 literal 0 HcmV?d00001 diff --git a/docs/scaffolds/lung.rst b/docs/scaffolds/lung.rst index 89df71b6..6d56ee9f 100644 --- a/docs/scaffolds/lung.rst +++ b/docs/scaffolds/lung.rst @@ -1,148 +1,12 @@ Lung Scaffold ============= -The current recommended lung scaffold is ``3D Lung 2`` built from ``class MeshType_3d_Lung2``; -the human variant is shown in :numref:`fig-scaffoldmaker-human-lung`. +For human lung studies the most recent :doc:`lung4` with open fissures is recommended. +It supports higher, variable resolution meshes. -.. _fig-scaffoldmaker-human-lung: +For other species including mouse, rat and pig with accessory lobes and either open or closed fissures, :doc:`lung2` can be used. -.. figure:: ../_images/scaffoldmaker_human_lung.jpg - :align: center +.. toctree:: - Human lung scaffold. - -The lung scaffold is a 3-D volumetric model of the lungs representing left lung, right lung and its lobes. -Depending on the species the left lung may have lower and upper lobes, while the right lung has lower, middle and -upper lobes, and non-human right lungs usually have a 4th accessory lobe (also known as the diaphragmatic lobe). - -The lung scaffold is only a representation of the volumetric spaces of the lobes and does not include representations -of the pulmonary airway, blood vessels or alveoli. - -.. note:: - - The lung scaffold contains two (or more) independent meshes for the left lung, right lung, and accessory lobes, and - optionally has open fissures so the lower, middle and upper lobes of each side can be independent meshes. - -Variants --------- - -The lung scaffold is provided with parameter sets for the following four species, which differ in shape, and in -particular have different numbers of lobes: - -* Human (2 lobes in the left, 3 lobes in the right lung) -* Mouse (1 lobe in the left, 4 lobes in the right lung) -* Pig (2 lobes in the left, 4 lobes in the right lung) -* Rat (1 lobe in the left, 4 lobes in the right lung) - -These variants' geometry and annotations are best viewed in the **Scaffold Creator** tool in the ABI Mapping Tools. -On the web, the latest published generic lung scaffold variants can be viewed on the -`SPARC Portal `_ by searching for ``lung``, filtering for anatomical models, selecting a variant -and viewing the scaffold in its Gallery tab or via the `Organ Scaffolds -`_ help article. - -The lung scaffold script generates the scaffold mesh and geometry from an idealization of their shapes. The left and -right lung (excluding accessory lobe) are generated as half ellipsoids which are then reshaped by smooth functions for -which parameters are provided on the scaffold, to give approximately realistic geometry for the species. -The accessory lobe is similarly created as a triangular prism and reshaped. - -The generic lung scaffolds are parameterized and fitted to segmentation data from CT and MRI images from the following -sources: human (`Osanlouy et al. `_), mouse -(`Beichel et al. `_), pig -(`Lee et al. `_), rat -(`NeuroRat V4.0 `_). - -[A special ``Material`` parameter set is provided to allow new species' parameters to be developed from the material -coordinates definition (see below).] -These parameters were carefully tuned for each species, and it is not recommended that these be edited. - -An advanced optional feature is to check *Open fissures* (set parameter to ``true``) which separates the lobes into -independent meshes allowing elements on opposite sides of each fissure to move independently. - -Coordinates ------------ - -The lung scaffold defines both geometric and material coordinates. - -The geometric ``coordinates`` field gives an approximate, idealized unit-scale representation of the lung shape for the -species, which is intended to be fitted to actual data for a specimen. - -The material coordinates field ``lung coordinates`` defines a highly idealized coordinate system to give -permanent locations for embedding structures in the lungs, defined as 2 half ellipsoids for the left and right lung -(excluding accessory lobe) and a triangular wedge for the accesory lobe, if present. These can be viewed by visualising -this field in the *Display* tab of **Scaffold Creator** or by switching to the special ``Material`` parameter set. - -The lung scaffold supports limited refinement/resampling by checking *Refine* (set parameter to ``true``) with chosen -*Refine number of elements* parameter. Be aware that only the ``coordinates`` field is currently defined on the refined -mesh (but annotations are transferred). - -Annotations ------------ - -Important anatomical regions of the lungs are defined by groups of elements (or faces, edges and nodes/points) and -annotated with standard term names and identifiers from a controlled vocabulary. - -Annotated 3-dimensional volume regions are defined by groups of 3-D elements including (using only one of the items -separated by slash /): - -* left/right lung -* lower/middle/upper lobe of left/right lung -* lung -* right lung accessory lobe - -**Terms for volume regions such as the above are not to be used for digitized contours!** They are used for applying -different material properties in models and the strain/curvature penalty (stiffness) parameters in fitting. - -Annotated 2-dimensional surface regions are defined for matching annotated contours digitized from medical images -including (where ``surface`` is the outside boundary on the meshes and using only one of the items separated by slash -/): - -* base of left/right lung surface -* base of lower lobe of left/right lung surface -* base of middle lobe of right lung surface -* base of upper lobe of left lung surface -* horizontal fissure of left/right lung -* horizontal fissure of lower/middle/upper lobe of left/right lung -* lateral/medial surface of left/right lung -* lateral/medial surface of lower/middle/upper lobe of left/right lung -* left/right lung surface -* lower/middle/upper lobe of left/right lung surface -* oblique fissure of left/right lung -* oblique fissure of lower/middle/upper lobe of left/right lung -* right lung accessory lobe surface -* base of right lung accessory lobe surface -* left/right/dorsal/ventral surface of right lung accessory lobe - -Annotated 1-dimensional line regions are defined for matching annotated contours digitized from medical images including -(using only one of the items separated by slash /): - -* anterior border of left/right lung - -Several fiducial marker points are defined on the lung scaffold, of which the followings are potentially usable when -digitizing: - -* apex of left/right lung -* laterodorsal tip of middle lobe of right lung -* medial/ventral base of left/right lung -* dorsal/ventral apex of right lung accessory lobe -* left/right dorsal/ventral base of right lung accessory lobe - -**Digitization tips to assist fitting:** - -1. A proper lung model requires accurate location of all surfaces of the lobes. This requires digitizing the fissures -and exterior surfaces of the lungs. It's not a requirement to use the most specific annotation group for a surface -(e.g. for a particular lobe instead of the whole left/right lung) but it may make fitting more efficient. - -2. Digitize any fiducial markers you can identify as these are gold standard locations which can be highly weighted in -the fit. - -3. The sharp anterior edges of the left/right lungs are difficult to fit. At a minimum it's important to have data -marker points for the ``ventral base of left/right lung`` fiducial markers. To properly fit the rest of the edges it's -best to have data points/contours along these edges annotated with the 1-D ``anterior border of left/right lung`` -terms, which can be weighted highly in the fit. Annotating with lateral/medial surfaces may work in some cases, but just -using lung/lobe surface groups can be problematic; in both cases there may not be enough data to definitively pull the -edge into position during the fit. - -4. For fitting a lung scaffold with open fissures, annotate fissure data points/contours which clearly belong to only -one lobe with the fissure term specific to that lobe. Where fissures are too close to distinguish the lobe they are on, -annotate digitized data the generic fissure term (not for a specific lobe). Doing this allows the same data to fit all -lobes correctly. + lung2 + lung4 diff --git a/docs/scaffolds/lung2.rst b/docs/scaffolds/lung2.rst new file mode 100644 index 00000000..f6f9c68e --- /dev/null +++ b/docs/scaffolds/lung2.rst @@ -0,0 +1,150 @@ +Lung 2 Scaffold +=============== + +The ``3D Lung 2`` Scaffold built from ``class MeshType_3d_lung2`` has variants for human, mouse, rat and pig, the non-human models having an accessory lobe. +Open fissures are optionally supported. + +The human variant which is shown in :numref:`fig-scaffoldmaker-human-lung2`, but note :doc:`lung4` is now preferred for human lung studies. + +.. _fig-scaffoldmaker-human-lung2: + +.. figure:: ../_images/scaffoldmaker_human_lung.jpg + :align: center + + Human lung scaffold. + +The lung scaffold is a 3-D volumetric model of the lungs representing left lung, right lung and its lobes. +Depending on the species the left lung may have lower and upper lobes, while the right lung has lower, middle and +upper lobes, and non-human right lungs usually have a 4th accessory lobe (also known as the diaphragmatic lobe). + +The lung scaffold is only a representation of the volumetric spaces of the lobes and does not include representations +of the pulmonary airway, blood vessels or alveoli. + +.. note:: + + The lung scaffold contains two (or more) independent meshes for the left lung, right lung, and accessory lobes, and + optionally has open fissures so the lower, middle and upper lobes of each side can be independent meshes. + +Variants +-------- + +The lung scaffold is provided with parameter sets for the following four species, which differ in shape, and in +particular have different numbers of lobes: + +* Human (2 lobes in the left, 3 lobes in the right lung) +* Mouse (1 lobe in the left, 4 lobes in the right lung) +* Pig (2 lobes in the left, 4 lobes in the right lung) +* Rat (1 lobe in the left, 4 lobes in the right lung) + +These variants' geometry and annotations are best viewed in the **Scaffold Creator** tool in the ABI Mapping Tools. +On the web, the latest published generic lung scaffold variants can be viewed on the +`SPARC Portal `_ by searching for ``lung``, filtering for anatomical models, selecting a variant +and viewing the scaffold in its Gallery tab or via the `Organ Scaffolds +`_ help article. + +The lung scaffold script generates the scaffold mesh and geometry from an idealization of their shapes. The left and +right lung (excluding accessory lobe) are generated as half ellipsoids which are then reshaped by smooth functions for +which parameters are provided on the scaffold, to give approximately realistic geometry for the species. +The accessory lobe is similarly created as a triangular prism and reshaped. + +The generic lung scaffolds are parameterized and fitted to segmentation data from CT and MRI images from the following +sources: human (`Osanlouy et al. `_), mouse +(`Beichel et al. `_), pig +(`Lee et al. `_), rat +(`NeuroRat V4.0 `_). + +[A special ``Material`` parameter set is provided to allow new species' parameters to be developed from the material +coordinates definition (see below).] +These parameters were carefully tuned for each species, and it is not recommended that these be edited. + +An advanced optional feature is to check *Open fissures* (set parameter to ``true``) which separates the lobes into +independent meshes allowing elements on opposite sides of each fissure to move independently. + +Coordinates +----------- + +The lung scaffold defines both geometric and material coordinates. + +The geometric ``coordinates`` field gives an approximate, idealized unit-scale representation of the lung shape for the +species, which is intended to be fitted to actual data for a specimen. + +The material coordinates field ``lung coordinates`` defines a highly idealized coordinate system to give +permanent locations for embedding structures in the lungs, defined as 2 half ellipsoids for the left and right lung +(excluding accessory lobe) and a triangular wedge for the accesory lobe, if present. These can be viewed by visualising +this field in the *Display* tab of **Scaffold Creator** or by switching to the special ``Material`` parameter set. + +The lung scaffold supports limited refinement/resampling by checking *Refine* (set parameter to ``true``) with chosen +*Refine number of elements* parameter. Be aware that only the ``coordinates`` field is currently defined on the refined +mesh (but annotations are transferred). + +Annotations +----------- + +Important anatomical regions of the lungs are defined by groups of elements (or faces, edges and nodes/points) and +annotated with standard term names and identifiers from a controlled vocabulary. + +Annotated 3-dimensional volume regions are defined by groups of 3-D elements including (using only one of the items +separated by slash /): + +* left/right lung +* lower/middle/upper lobe of left/right lung +* lung +* right lung accessory lobe + +**Terms for volume regions such as the above are not to be used for digitized contours!** They are used for applying +different material properties in models and the strain/curvature penalty (stiffness) parameters in fitting. + +Annotated 2-dimensional surface regions are defined for matching annotated contours digitized from medical images +including (where ``surface`` is the outside boundary on the meshes and using only one of the items separated by slash +/): + +* base of left/right lung surface +* base of lower lobe of left/right lung surface +* base of middle lobe of right lung surface +* base of upper lobe of left lung surface +* horizontal fissure of left/right lung +* horizontal fissure of lower/middle/upper lobe of left/right lung +* lateral/medial surface of left/right lung +* lateral/medial surface of lower/middle/upper lobe of left/right lung +* left/right lung surface +* lower/middle/upper lobe of left/right lung surface +* oblique fissure of left/right lung +* oblique fissure of lower/middle/upper lobe of left/right lung +* right lung accessory lobe surface +* base of right lung accessory lobe surface +* left/right/dorsal/ventral surface of right lung accessory lobe + +Annotated 1-dimensional line regions are defined for matching annotated contours digitized from medical images including +(using only one of the items separated by slash /): + +* anterior border of left/right lung + +Several fiducial marker points are defined on the lung scaffold, of which the followings are potentially usable when +digitizing: + +* apex of left/right lung +* laterodorsal tip of middle lobe of right lung +* medial/ventral base of left/right lung +* dorsal/ventral apex of right lung accessory lobe +* left/right dorsal/ventral base of right lung accessory lobe + +**Digitization tips to assist fitting:** + +1. A proper lung model requires accurate location of all surfaces of the lobes. This requires digitizing the fissures +and exterior surfaces of the lungs. It's not a requirement to use the most specific annotation group for a surface +(e.g. for a particular lobe instead of the whole left/right lung) but it may make fitting more efficient. + +2. Digitize any fiducial markers you can identify as these are gold standard locations which can be highly weighted in +the fit. + +3. The sharp anterior edges of the left/right lungs are difficult to fit. At a minimum it's important to have data +marker points for the ``ventral base of left/right lung`` fiducial markers. To properly fit the rest of the edges it's +best to have data points/contours along these edges annotated with the 1-D ``anterior border of left/right lung`` +terms, which can be weighted highly in the fit. Annotating with lateral/medial surfaces may work in some cases, but just +using lung/lobe surface groups can be problematic; in both cases there may not be enough data to definitively pull the +edge into position during the fit. + +4. For fitting a lung scaffold with open fissures, annotate fissure data points/contours which clearly belong to only +one lobe with the fissure term specific to that lobe. Where fissures are too close to distinguish the lobe they are on, +annotate digitized data the generic fissure term (not for a specific lobe). Doing this allows the same data to fit all +lobes correctly. diff --git a/docs/scaffolds/lung4.rst b/docs/scaffolds/lung4.rst new file mode 100644 index 00000000..f9a5b11d --- /dev/null +++ b/docs/scaffolds/lung4.rst @@ -0,0 +1,98 @@ +Lung 4 Scaffold +=============== + +The `3D Lung 4` Scaffold built from ``class MeshType_3d_lung4`` builds a volumetric model of the left and right human lungs with open fissures between lobes, but with a line of common nodes joining lobes of each lung on the hilum axis. + +It is built in an idealised form on an ellipsoid and shaped slightly to give it a sharp anterior edge, concave base and slight curvature around the heart. +The scaffold mesh consists of all hexahedral (cube) elements without any collapsed faces/edges, with geometry interpolated by smooth tricubic Hermite Serendipity basis functions. The resolution / element density of the scaffold's mesh is fully controllable without changing its shape. It is suitable for fitting to a wide range of human lung shapes. + +.. _fig-scaffoldmaker-lung4-human: + +.. figure:: ../_images/lung4_human1_coarse.jpg + :align: center + + 3D Lung 4 Scaffold with `Human 1 Coarse` parameter set. The oblique fissures of the left and right lungs and the horizontal fissure of the right lung are shaded. + +.. _fig-scaffoldmaker-lung4-fitted-left-right: + +.. figure:: ../_images/lung4_left_right_fitted.jpg + :align: center + + Lung 4 Scaffold fitted to left and right human lung data. Lower lobes are blue, right middle lobe is pink and upper lobes are green. + +The lung scaffold is only a representation of the volumetric spaces of the lobes and does not include representations of the pulmonary airways, blood vessels or alveoli. + +Variants +-------- + +This scaffold currently only provides parameters for human lungs, but coarse, medium and fine mesh resolutions are provided in the idealised human shape, plus an unmodified ellipsoid. + +Scaffold parameters are presented to allow other numbers of elements and shapes, but for a given population study it's expected that all fitted subjects start from the same scaffold. Lungs have quite a lot of variability in shape between subjects, so in the early stages of fitting it may be preferred to perform a gross shape change, resolve most non-linearities over 3 or more fit steps/iterations, and select `Update reference state` on the last of these iterations. + +Coordinates +----------- + +The lung scaffold defines only a geometric ``coordinates`` field which gives an approximate, idealized unit-scale representation of the lung shapes, which is intended to be fitted to actual data for a subject. + +It is planned in future to use the coordinates generated for the ``Ellipsoid`` parameter set as common material coordinates (i.e. ``lung coordinates``) across all subjects, to use for embedding data within the lobes. + +The lung scaffold supports limited refinement/resampling to trilinear elements by checking *Refine* (set parameter to ``True``) with chosen +*Refine number of elements* parameter. Be aware that only the ``coordinates`` field is currently defined on the refined +mesh (but annotations are transferred). + +Annotations +----------- + +Important anatomical regions of the lungs are defined by groups of elements (or faces, edges and nodes/points) and +annotated with standard term names and identifiers from a controlled vocabulary. + +Annotated 3-dimensional volume regions are defined by groups of 3-D elements including (using only one of the items +separated by slash /, and noting the middle lobe is only on the right so the upper lobe of the right lung never touches the base): + +* lung (everything) +* left/right lung +* lower/middle/upper lobe of left/right lung + +.. note:: + + Terms for volume regions such as the above are not to be used for digitized contours. They are used for applying different material properties in models and the strain/curvature penalty (stiffness) parameters in fitting. + + +Annotated 2-dimensional surface regions are defined for matching annotated contours digitized from medical images +including: + +* base of lower/middle/upper lobe of left/right lung surface +* horizontal fissure of middle/upper lobe of right lung +* lateral/medial surface of left/right lung +* lateral/medial surface of lower/middle/upper lobe of left/right lung +* lower/middle/upper lobe of left/right lung surface +* oblique fissure of lower/middle/upper lobe of left/right lung + +.. note:: + + 1. Horizontal and oblique fissure surfaces (and edges below) are labelled specifically for the lobe touching them. For fitting, data points on the fissures must be duplicated for each side to keep the respective surfaces together on the fissure data. + 2. The surfaces of the oblique fissure of the left-upper lobe below the hilum, and the right-middle lobe are the same as the base of the left-upper and right-middle lobes, respectively. This is to allow material to slide in or out of the fissure as there are huge variations of relative fissure and base sizes across lung populations. This is seen in :numref:`fig-scaffoldmaker-lung4-fitted-left-right` where the base of the middle lobe is very large on the right. + +Annotated 1-dimensional line regions are defined for matching annotated contours digitized from medical images including: + +* anterior edge of middle lobe of right lung +* antero-posterior edge of upper lobe of left/right lung +* base edge of oblique fissure of lower lobe of left/right lung +* lateral/medial edge of most surface groups listed earlier +* posterior edge of lower lobe of left/right lung + +.. note:: + + 1. For fitting, it is a good idea to give edge data higher weights e.g. 10x default weights to pull material into some of the sharp extremeties. + 2. For fitting, edges of the fissures may need to be weighted higher to help constrain them to stay together, particularly the medial edges of the oblique fissure of the lower/upper lobe of the left lung, as the medial surface of the left lung can be quite distorted. + +Several fiducial marker points are defined on the lung scaffold, as shown in :numref:`fig-scaffoldmaker-lung4-human`: + +* apex of left/right lung +* dorsal/medial/ventral base of left/right lung +* laterodorsal tip of middle lobe of right lung + +.. note:: + + 1. It is not recommended that these fiducial markers be used in the fitting process as their positions are somewhat amorphous and without them the fit will smoothly spread out elements between fissures and prescribed edges. Fitting with markers and high weights may cause severe distortions. + 2. These points are very useful for subsequent processing as they can help align fitted scaffolds for PCA and other tasks. From a099d1ccccf2dcd6bcb55a6e3b3f562c1fe69986 Mon Sep 17 00:00:00 2001 From: Richard Christie Date: Thu, 11 Dec 2025 13:32:05 +1300 Subject: [PATCH 23/24] Ensure same element/face/line/node identifiers for left/right when built alone --- .../meshtypes/meshtype_3d_lung4.py | 77 +++++++++++-------- tests/test_lung.py | 74 ++++++++++++++++++ 2 files changed, 117 insertions(+), 34 deletions(-) diff --git a/src/scaffoldmaker/meshtypes/meshtype_3d_lung4.py b/src/scaffoldmaker/meshtypes/meshtype_3d_lung4.py index 827f46c7..96c36710 100644 --- a/src/scaffoldmaker/meshtypes/meshtype_3d_lung4.py +++ b/src/scaffoldmaker/meshtypes/meshtype_3d_lung4.py @@ -246,11 +246,11 @@ def generateBaseMesh(cls, region, options): lungs = [lung for show, lung in [(has_left_lung, left_lung), (has_right_lung, right_lung)] if show] node_identifier, element_identifier = 1, 1 - # currently build left lung if right lung is being built to get correct node/element identifiers - lungs_construct = [left_lung, right_lung] if has_right_lung else [left_lung] if has_left_lung else [] marker_name_element_xi = [] - for lung in lungs_construct: + # need to build both left and right lungs then delete any not used so element, face, line, node numbers + # are the same if only left or right + for lung in [left_lung, right_lung]: if lung == left_lung: if has_left_lung: @@ -271,26 +271,29 @@ def generateBaseMesh(cls, region, options): lower_octant_group_lists = upper_octant_group_lists = None middle_octant_group_lists = None else: - lower_octant_group_lists = [] - middle_octant_group_lists = [] - upper_octant_group_lists = [] - for octant in range(8): - octant_group_list = [group.getGroup() for group in - [lung_group, right_lung_group, lower_right_lung_group] + - [right_lateral_lung_group if (octant & 1) else right_medial_lung_group]] - lower_octant_group_lists.append(octant_group_list) - octant_group_list = [group.getGroup() for group in - [lung_group, right_lung_group, middle_right_lung_group] + - [right_lateral_lung_group if (octant & 1) else right_medial_lung_group]] - if octant & 2: - octant_group_list.append(right_anterior_lung_group.getGroup()) - middle_octant_group_lists.append(octant_group_list) - octant_group_list = [group.getGroup() for group in - [lung_group, right_lung_group, upper_right_lung_group] + - [right_lateral_lung_group if (octant & 1) else right_medial_lung_group]] - if octant & 2: - octant_group_list.append(right_anterior_lung_group.getGroup()) - upper_octant_group_lists.append(octant_group_list) + if has_right_lung: + lower_octant_group_lists = [] + middle_octant_group_lists = [] + upper_octant_group_lists = [] + for octant in range(8): + octant_group_list = [group.getGroup() for group in + [lung_group, right_lung_group, lower_right_lung_group] + + [right_lateral_lung_group if (octant & 1) else right_medial_lung_group]] + lower_octant_group_lists.append(octant_group_list) + octant_group_list = [group.getGroup() for group in + [lung_group, right_lung_group, middle_right_lung_group] + + [right_lateral_lung_group if (octant & 1) else right_medial_lung_group]] + if octant & 2: + octant_group_list.append(right_anterior_lung_group.getGroup()) + middle_octant_group_lists.append(octant_group_list) + octant_group_list = [group.getGroup() for group in + [lung_group, right_lung_group, upper_right_lung_group] + + [right_lateral_lung_group if (octant & 1) else right_medial_lung_group]] + if octant & 2: + octant_group_list.append(right_anterior_lung_group.getGroup()) + upper_octant_group_lists.append(octant_group_list) + else: + lower_octant_group_lists = middle_octant_group_lists = upper_octant_group_lists = None element_counts = [elements_count_lateral, elements_count_oblique, elements_count_oblique] lower_ellipsoid = EllipsoidMesh( @@ -455,15 +458,20 @@ def generateBaseMesh(cls, region, options): get_mesh_first_element_with_node( group.getMeshGroup(mesh), coordinates, nodes.findNodeByIdentifier(nid)), [0.0, 0.0, 1.0])) + else: + # need to make sure marker node identifiers are consistent if left/right not used + for i in range(4 if (lung == left_lung) else 5): + marker_name_element_xi.append(("dummy", None, None)) # marker points; make after regular nodes so higher node numbers lung_nodeset = lung_group.getNodesetGroup(nodes) for marker_name, element, xi in marker_name_element_xi: - annotation_group = findOrCreateAnnotationGroupForTerm( - annotation_groups, region, get_lung_term(marker_name), isMarker=True) - marker_node = annotation_group.createMarkerNode(node_identifier, element=element, xi=xi) - lung_nodeset.addNode(marker_node) + if element: # skip dummy markers for missing left/right, but increment node_identifier always + annotation_group = findOrCreateAnnotationGroupForTerm( + annotation_groups, region, get_lung_term(marker_name), isMarker=True) + marker_node = annotation_group.createMarkerNode(node_identifier, element=element, xi=xi) + lung_nodeset.addNode(marker_node) node_identifier += 1 for lung in lungs: @@ -517,14 +525,15 @@ def defineFaceAnnotations(cls, region, options, annotation_groups): has_left_lung = options["Left lung"] has_right_lung = options["Right lung"] - if (has_right_lung) and (not has_left_lung): - # destroy left lung elements, faces, lines and nodes now to ensure persistent identifiers used on right - is_left = fm.createFieldNot( - getAnnotationGroupForTerm(annotation_groups, get_lung_term("right lung")).getGroup()) - nodes = fm.findNodesetByFieldDomainType(Field.DOMAIN_TYPE_NODES) + if (not has_left_lung) or (not has_right_lung): + # destroy elements, faces, lines and nodes now to ensure persistent identifiers when only one side + # these are any objects not in the "lung" group: + not_lung = fm.createFieldNot(getAnnotationGroupForTerm(annotation_groups, get_lung_term("lung")).getGroup()) for mesh in [fm.findMeshByDimension(3), mesh2d, mesh1d]: - mesh.destroyElementsConditional(is_left) - nodes.destroyNodesConditional(is_left) + mesh.destroyElementsConditional(not_lung) + nodes = fm.findNodesetByFieldDomainType(Field.DOMAIN_TYPE_NODES) + nodes.destroyNodesConditional(not_lung) + del nodes is_exterior = fm.createFieldIsExterior() is_face_xi1_0 = fm.createFieldIsOnFace(Element.FACE_TYPE_XI1_0) diff --git a/tests/test_lung.py b/tests/test_lung.py index 0f4da9ff..ec7afa15 100644 --- a/tests/test_lung.py +++ b/tests/test_lung.py @@ -4,6 +4,7 @@ from cmlibs.utils.zinc.finiteelement import evaluateFieldNodesetRange, findNodeWithName from cmlibs.utils.zinc.general import ChangeManager +from cmlibs.utils.zinc.group import mesh_group_to_identifier_ranges, nodeset_group_to_identifier_ranges from cmlibs.zinc.context import Context from cmlibs.zinc.field import Field from cmlibs.zinc.result import RESULT_OK @@ -1244,6 +1245,79 @@ def test_lung4_human(self): null_group = refine_fieldmodule.findFieldByName(annotation_group.getName()).castGroup() self.assertFalse(null_group.isValid()) + def test_lung4_human_left(self): + """ + Test creation of human lung scaffold lung4 left side only, that objects have expected identifiers. + """ + scaffold = MeshType_3d_lung4 + options = scaffold.getDefaultOptions("Human 1 Coarse") + options["Number of elements oblique"] = 4 # minimum, for speed + options["Right lung"] = False + + context = Context("Test") + region = context.getDefaultRegion() + fieldmodule = region.getFieldmodule() + self.assertTrue(region.isValid()) + annotation_groups, _ = scaffold.generateMesh(region, options) + self.assertEqual(31, len(annotation_groups)) + # check no right annotations + for annotation_group in annotation_groups: + name = annotation_group.getName() + self.assertFalse("right" in name) + # get ranges of identifiers in meshes and nodes + expected_domain_count_ranges = [ + ("mesh3d", 44, [[1, 44]]), + ("mesh2d", 164, [[1, 164]]), + ("mesh1d", 208, [[1, 208]]), + ("nodes", 93, [[1, 89], [187, 190]]), + ("marker.nodes", 4, [[187, 190]]) + ] + for domain_name, expected_count, expected_ranges in expected_domain_count_ranges: + if "mesh" in domain_name: + domain = fieldmodule.findMeshByName(domain_name) + ranges = mesh_group_to_identifier_ranges(domain) + else: + domain = fieldmodule.findNodesetByName(domain_name) + ranges = nodeset_group_to_identifier_ranges(domain) + self.assertEqual(domain.getSize(), expected_count) + self.assertEqual(ranges, expected_ranges) + + def test_lung4_human_right(self): + """ + Test creation of human lung scaffold lung4 left side only, that objects have expected identifiers. + """ + scaffold = MeshType_3d_lung4 + options = scaffold.getDefaultOptions("Human 1 Coarse") + options["Number of elements oblique"] = 4 # minimum, for speed + options["Left lung"] = False + + context = Context("Test") + region = context.getDefaultRegion() + fieldmodule = region.getFieldmodule() + self.assertTrue(region.isValid()) + annotation_groups, _ = scaffold.generateMesh(region, options) + self.assertEqual(46, len(annotation_groups)) + # check no right annotations + for annotation_group in annotation_groups: + name = annotation_group.getName() + self.assertFalse("left" in name) + # get ranges of identifiers in meshes and nodes + expected_domain_count_ranges = [ + ("mesh3d", 44, [[45, 88]]), + ("mesh2d", 170, [[165, 334]]), + ("mesh1d", 222, [[209, 430]]), + ("nodes", 102, [[90, 186], [191, 195]]), + ("marker.nodes", 5, [[191, 195]]) + ] + for domain_name, expected_count, expected_ranges in expected_domain_count_ranges: + if "mesh" in domain_name: + domain = fieldmodule.findMeshByName(domain_name) + ranges = mesh_group_to_identifier_ranges(domain) + else: + domain = fieldmodule.findNodesetByName(domain_name) + ranges = nodeset_group_to_identifier_ranges(domain) + self.assertEqual(domain.getSize(), expected_count, msg=domain_name) + self.assertEqual(ranges, expected_ranges, msg=domain_name) if __name__ == "__main__": From 11a6af1bcbaac1750d2d0d8407ce538beff06418 Mon Sep 17 00:00:00 2001 From: Richard Christie Date: Thu, 11 Dec 2025 16:33:55 +1300 Subject: [PATCH 24/24] Add refined marker nodes to other groups originals were in Put marker nodes in left/right lung groups too. --- .../meshtypes/meshtype_3d_lung4.py | 25 +++++++++++-------- src/scaffoldmaker/utils/meshrefinement.py | 15 +++++++++-- tests/test_lung.py | 5 ++++ 3 files changed, 32 insertions(+), 13 deletions(-) diff --git a/src/scaffoldmaker/meshtypes/meshtype_3d_lung4.py b/src/scaffoldmaker/meshtypes/meshtype_3d_lung4.py index 96c36710..f316fe34 100644 --- a/src/scaffoldmaker/meshtypes/meshtype_3d_lung4.py +++ b/src/scaffoldmaker/meshtypes/meshtype_3d_lung4.py @@ -463,17 +463,6 @@ def generateBaseMesh(cls, region, options): for i in range(4 if (lung == left_lung) else 5): marker_name_element_xi.append(("dummy", None, None)) - # marker points; make after regular nodes so higher node numbers - - lung_nodeset = lung_group.getNodesetGroup(nodes) - for marker_name, element, xi in marker_name_element_xi: - if element: # skip dummy markers for missing left/right, but increment node_identifier always - annotation_group = findOrCreateAnnotationGroupForTerm( - annotation_groups, region, get_lung_term(marker_name), isMarker=True) - marker_node = annotation_group.createMarkerNode(node_identifier, element=element, xi=xi) - lung_nodeset.addNode(marker_node) - node_identifier += 1 - for lung in lungs: is_left = lung == left_lung lung_nodeset = (left_lung_group if is_left else right_lung_group).getNodesetGroup(nodes) @@ -494,6 +483,20 @@ def generateBaseMesh(cls, region, options): translate_nodeset_coordinates(lung_nodeset, coordinates, [-lung_spacing if is_left else lung_spacing, 0.0, 0.0]) + # marker points; make after regular nodes so higher node numbers + lung_nodeset = lung_group.getNodesetGroup(nodes) + for marker_name, element, xi in marker_name_element_xi: + if element: # skip dummy markers for missing left/right, but increment node_identifier always + annotation_group = findOrCreateAnnotationGroupForTerm( + annotation_groups, region, get_lung_term(marker_name), isMarker=True) + marker_node = annotation_group.createMarkerNode(node_identifier, element=element, xi=xi) + if 'left' in marker_name: + left_lung_group.getNodesetGroup(nodes).addNode(marker_node) + else: + right_lung_group.getNodesetGroup(nodes).addNode(marker_node) + lung_nodeset.addNode(marker_node) + node_identifier += 1 + return annotation_groups, None @classmethod diff --git a/src/scaffoldmaker/utils/meshrefinement.py b/src/scaffoldmaker/utils/meshrefinement.py index 6fac3313..d5d2c0a5 100644 --- a/src/scaffoldmaker/utils/meshrefinement.py +++ b/src/scaffoldmaker/utils/meshrefinement.py @@ -170,7 +170,7 @@ def _get_connected_exterior_face_ids(self, faces, x): tmp_line_ids.append(line.getIdentifier()) face_line_ids.append(tmp_line_ids) new_line_ids = set() - # if there is a single line between 2 faces can do less work later, but not if there are collpased faces + # if there is a single line between 2 faces can do less work later, but not if there are collapsed faces single_line = initial_face_count < 3 for f1 in range(len(faces) - 1): for f2 in range(f1 + 1, len(faces)): @@ -401,7 +401,18 @@ def refineElementCubeStandard3d(self, sourceElement, numberInXi1, numberInXi2, n targetXi[i] = 1.0 targetElementIdentifier += el * elementOffset[i] targetElement = self._targetMesh.findElementByIdentifier(targetElementIdentifier) - annotationGroup.createMarkerNode(self._nodeIdentifier, element=targetElement, xi=targetXi) + markerNode = annotationGroup.createMarkerNode( + self._nodeIdentifier, element=targetElement, xi=targetXi) + # add marker node to target annotation groups for any non-marker source annotation groups + # the source node was in. (Marker annotation groups all share the same 'marker' group.) + for sourceAnnotationGroup in self._sourceAnnotationGroups: + if not sourceAnnotationGroup.isMarker(): + if sourceAnnotationGroup.getNodesetGroup(self._sourceNodes).findNodeByIdentifier( + sourceNodeIdentifier).isValid(): + targetAnnotationGroup = findAnnotationGroupByName( + self._annotationGroups, sourceAnnotationGroup.getName()) + if targetAnnotationGroup: + targetAnnotationGroup.getNodesetGroup(self._targetNodes).addNode(markerNode) self._nodeIdentifier += 1 return nids, nx diff --git a/tests/test_lung.py b/tests/test_lung.py index ec7afa15..ddb0f4f9 100644 --- a/tests/test_lung.py +++ b/tests/test_lung.py @@ -1237,11 +1237,16 @@ def test_lung4_human(self): # check refine markers refine_marker_group = refine_fieldmodule.findFieldByName("marker").castGroup() refine_marker_nodes = refine_marker_group.getNodesetGroup(refine_nodes) + refine_lung_nodes = refine_fieldmodule.findFieldByName("lung").castGroup().getNodesetGroup(refine_nodes) self.assertEqual(9, refine_marker_nodes.getSize()) for annotation_group in refine_annotation_groups: if annotation_group.isMarker(): + marker_node = annotation_group.getMarkerNode() + self.assertTrue(marker_node.isValid()) group_field = annotation_group.getGroup() self.assertEqual(group_field, refine_marker_group) + self.assertTrue(refine_marker_nodes.containsNode(marker_node)) + self.assertTrue(refine_lung_nodes.containsNode(marker_node)) null_group = refine_fieldmodule.findFieldByName(annotation_group.getName()).castGroup() self.assertFalse(null_group.isValid())