From 7fcb868d72d5ee4594196f8c5f85d9dfa7b3400f Mon Sep 17 00:00:00 2001 From: ES-Alexander Date: Mon, 19 Jun 2023 08:44:27 +1000 Subject: [PATCH 01/11] visualize: tube_mesh: allow metadata at init --- fullcontrol/visualize/tube_mesh.py | 32 +++++++++++++++++++++++++----- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/fullcontrol/visualize/tube_mesh.py b/fullcontrol/visualize/tube_mesh.py index 5ee4643..b780cd1 100644 --- a/fullcontrol/visualize/tube_mesh.py +++ b/fullcontrol/visualize/tube_mesh.py @@ -18,6 +18,7 @@ def __init__( sides: int = 4, capped: bool = False, inplace_path: bool = False, + metadata: dict | None = None, ): ''' `path` should contain N points to 'draw' a tube along. @@ -38,6 +39,7 @@ def __init__( `inplace_path` is a flag specifying that `path` is already a valid numpy array of 3D points with float values, and will not be changed externally so can safely be used directly (instead of via a copy). + `metadata` is a dictionary of metadata relevant to the input path. ''' self.path_points = path if inplace_path else self.make_valid_path(path) @@ -53,6 +55,8 @@ def __init__( if self.capped: self.__init_endcaps() + self.metadata = metadata or {} # default to empty dictionary + def __init_mesh_points(self): corner_tangents = self.calculate_corner_tangents(self.path_points) @@ -180,7 +184,7 @@ def to_Mesh3d( ) -> go.Mesh3d: ''' `colors` should be either - - `None` (to use plotly's default / configure elsewhere) + - `None` (to use self.metadata / plotly's default / configure elsewhere) - a single color for all the tubes, - N colors denoting the color of each path point (with blends between), - N-1 colors denoting the (constant) color of each tube @@ -196,6 +200,11 @@ def to_Mesh3d( according to mesh3d.colorscale ''' mesh_kwargs = mesh_kwargs.copy() + # default to stored metadata (if available) + if colors is None: + colors = self.metadata.get('colors') + + # turn path colors into appropriate mesh colors if colors is None or isinstance(colors, str): mesh_kwargs['color'] = colors elif len(colors) == len(self.path_points): @@ -223,9 +232,10 @@ def plot(self, **mesh_kwargs): fig.update_scenes(aspectmode='data') # set equal axis aspect ratios fig.show() - def save(self, to: pathlib.Path | str, compressed=False): + def save_geometry(self, to: pathlib.Path | str, compressed=False): ''' `to` is the location to save to. Should use the `.npz` file extension. + NOTE: does NOT save `self.metadata`. ''' save = np.savez if not compressed else np.savez_compressed save( @@ -239,7 +249,7 @@ def save(self, to: pathlib.Path | str, compressed=False): ) @classmethod - def from_file(cls, file: pathlib.Path | str, *args, **kwargs): + def geometry_from_file(cls, file: pathlib.Path | str, *args, **kwargs): data = np.load(file, *args, **kwargs) out = cls.__new__(cls) for attribute, value in data.items(): @@ -339,7 +349,7 @@ def to_Mesh3d( ) -> go.Mesh3d: ''' `colors` should be either - - `None` (to use plotly's default / configure elsewhere) + - `None` (to use self.metadata / plotly's default / configure elsewhere) - a single color for all the tubes, - N colors denoting the color at each path point (with blends between), - N-1 colors denoting the (constant) color of each tube @@ -355,6 +365,11 @@ def to_Mesh3d( according to mesh3d.colorscale ''' N = len(self._path_points) + # default to stored metadata (if available) + if colors is None: + colors = self.metadata.get('colors') + + # turn high level path colors into low level path colors if colors is not None: if isinstance(colors, str): n = 1 @@ -469,7 +484,7 @@ def to_Mesh3d( ) -> go.Mesh3d: ''' `colors` should be either - - None (to use plotly's default, or configure elsewhere) + - `None` (to use self.metadata / plotly's default / configure elsewhere) - a single color for all the tubes, - N colors denoting the color at each path point (with blends between), -> ignores `corner_colors` @@ -491,6 +506,13 @@ def to_Mesh3d( or if `colors` is also `None` ''' N = len(self._path_points) + # default to stored metadata (if available) + if colors is None: + colors = self.metadata.get('colors') + if corner_colors is None: + corner_colors = self.metadata.get('corner_colors') + + # turn high level path colors into low level path colors if colors is not None: n = 1 if isinstance(colors, str) else len(colors) if n == N: From f831ef920fb3aa13f35bab3aa00d8050ec3bc53d Mon Sep 17 00:00:00 2001 From: ES-Alexander Date: Mon, 19 Jun 2023 09:59:20 +1000 Subject: [PATCH 02/11] visualize: tube_mesh: support (ASCII+binary) STL export - includes change to consistent anti-clockwise triangle point ordering --- fullcontrol/visualize/tube_mesh.py | 103 ++++++++++++++++++++++++++--- 1 file changed, 93 insertions(+), 10 deletions(-) diff --git a/fullcontrol/visualize/tube_mesh.py b/fullcontrol/visualize/tube_mesh.py index b780cd1..16b8299 100644 --- a/fullcontrol/visualize/tube_mesh.py +++ b/fullcontrol/visualize/tube_mesh.py @@ -3,6 +3,8 @@ from numbers import Real import pathlib # functionality +import struct +from datetime import datetime from itertools import chain, pairwise import plotly.graph_objects as go import numpy as np @@ -93,10 +95,14 @@ def __init_endcaps(self): path_start, path_end = len(self.mesh_points) - np.array([2,1]) cap_triangles = [] - for index_offset, path_point in ((0, path_start), - (self.num_cylinders*self.sides, path_end)): + for step, (index_offset, path_point) in enumerate( + ((0, path_start), + (self.num_cylinders*self.sides, path_end)) + ): + # flip end endcap triangles for correct orientation (anticlockwise point order) + step = -(step * 2 - 1) # 1 for start, -1 for end cap_triangles.append([ - [first+index_offset, second+index_offset, path_point] + [first+index_offset, second+index_offset, path_point][::step] for first, second in pairwise(chain(range(self.sides),(0,))) ]) @@ -171,12 +177,83 @@ def generate_tube_triangles(cls, sides, num_cylinders): def generate_cylinder_triangles(sides): return np.array(list( chain.from_iterable( - [[first,second,sides+first], - [second,sides+second,sides+first]] + # anticlockwise point order when viewed from outside + [[first, sides+first, second], + [second, sides+first, sides+second]] for first, second in pairwise(chain(range(sides),(0,))) ) )) + @property + def triangle_points(self): + ''' An array of the mesh points for each index of each triangle. ''' + if (cached := getattr(self, '_triangle_points', None)) is not None: + return cached + self._triangle_points = self.mesh_points[self.triangles.flatten()] + return self._triangle_points + + @property + def mesh_normals(self): + ''' Assumes `triangle_indices` are defined anti-clockwise. ''' + if (cached := getattr(self, '_mesh_normals', None)) is not None: + return cached + triangle_points = self.triangle_points + # anticlockwise point order when viewed from outside + first_vectors = triangle_points[1::3] - triangle_points[::3] + second_vectors = triangle_points[2::3] - triangle_points[::3] + self._mesh_normals = np.cross(first_vectors, second_vectors) + return self._mesh_normals + + def to_stl(self, path: pathlib.Path | str, type: str = 'ascii', overwrite=False): + if not self.capped: + print('WARNING! Non-manifold mesh - not using capped ends.') + path = pathlib.Path(path) # ensure a valid Path object + if not overwrite and path.is_file(): + path = path.with_stem( + f'{path.stem}__{datetime.today().strftime("%d-%m-%Y__%H-%M-%S")}' + ) + + { + 'ascii': self._to_ascii_stl, + 'binary': self._to_binary_stl, + }[type](path) + + def _to_ascii_stl(self, path: pathlib.Path): + with path.open('w') as out: + print(f'solid {path.stem} # Generated by FullControlXYZ', file=out) + for n, vs in zip(self.mesh_normals, self.triangle_points.reshape(-1,9)): + print( + f'facet normal {n[0]:e} {n[1]:e} {n[2]:e}', + ' outer loop', + f' vertex {vs[0]:e} {vs[1]:e} {vs[2]:e}', + f' vertex {vs[3]:e} {vs[4]:e} {vs[5]:e}', + f' vertex {vs[6]:e} {vs[7]:e} {vs[8]:e}', + ' endloop', + 'endfacet', + sep='\n', file=out + ) + print(f'endsolid {path.stem}', file=out) + + def _to_binary_stl(self, path: pathlib.Path): + UNITS = self.metadata.get('units', 'mm') + AUTHOR = self.metadata.get('author', None) + author = f'{AUTHOR=}' if AUTHOR is not None else '' + + with path.open('wb') as out: + header = bytearray([0]*80) + # header is arbitrary, but put some meaningful data into it + # TODO: support optional COLOR and MATERIAL fields + # 'COLOR' should be a 4-byte RGBA value, as a simple full-object color + # 'MATERIAL' should be diffuse reflection, specular highlight, ambient light + # as 4-byte RGBA values - preferred over COLOR + header_data = f'STL,{UNITS=},{author}SOFTWARE=FullControlXYZ'.encode('utf-8') + header[:len(header_data)] = header_data + out.write(header[:80]) # ensure header is valid + out.write(struct.pack(' Date: Thu, 22 Jun 2023 15:33:56 +1000 Subject: [PATCH 03/11] visualize: tube_mesh: add rectangular cross-section options --- fullcontrol/visualize/tube_mesh.py | 86 +++++++++++++++++++++++------- 1 file changed, 68 insertions(+), 18 deletions(-) diff --git a/fullcontrol/visualize/tube_mesh.py b/fullcontrol/visualize/tube_mesh.py index 16b8299..25278b3 100644 --- a/fullcontrol/visualize/tube_mesh.py +++ b/fullcontrol/visualize/tube_mesh.py @@ -2,6 +2,7 @@ from collections.abc import Sequence from numbers import Real import pathlib +import math # functionality import struct from datetime import datetime @@ -17,7 +18,10 @@ def __init__( path: np.ndarray | Sequence, widths: Real | np.ndarray | Sequence = 0.2, heights: Real | np.ndarray | Sequence | None = None, - sides: int = 4, + *, # make remaining arguments keyword-only + sides: int = 6, + rounding_strength: float = 1, + flat_sides: bool = True, capped: bool = False, inplace_path: bool = False, metadata: dict | None = None, @@ -33,8 +37,21 @@ def __init__( `heights` is like `widths`. If left as `None`, uses the `widths` value. `sides` determines the number of sides rendered for each tube. - Fewer sides render faster but look less rounded. + Fewer sides render faster but form a rougher cross-section approximation. + When set to an even number, the dimension between flat horizontal and/or + vertical sides is expanded such that the sides are flat against the + specified segment width/height as appropriate. Must be >= 2. Default 4. + `rounding_strength` determines whether the cross-section profile is + rectangular (0), elliptical (1), or somewhere in between. + Should generally be left at 1 unless sides >= 6, although for an exact + rectangle it's fine to use sides = 4. + `flat_sides` rotates the sample angles of the cross-section profile such + that if `sides` is a multiple of 2 the vertical sides will be flat, + and if `sides` is a multiple of 4 the horizontal sides will also be flat. + If `flat_sides` is set to False then the major and minor axis points will + be exactly the specified width and height, but horizontal or vertical + touching between tubes will be along a line instead of a full surface. `capped` is a flag specifying whether to generate triangles for a cap on each end of the path. Off by default, but useful for generating closed meshes (e.g. for an STL). @@ -50,6 +67,8 @@ def __init__( self.radial_heights = np.reshape(heights, (-1,1)) / 2 if heights is not None \ else self.radial_widths self.sides = sides + self.rounding_strength = rounding_strength + self.flat_sides = flat_sides self.capped = capped self.__init_mesh_points() @@ -70,19 +89,50 @@ def __init_mesh_points(self): heave_offsets /= np.linalg.norm(heave_offsets, axis=1, keepdims=True) heave_offsets *= self.radial_heights - # Combine as evenly spaced points around the circle - # NOTE: there's potential for optimisation here, if we want to add - # special cases for 2/4/... sided tubes, to reduce operations. - scale = 2 * np.pi / self.sides - point_offsets = np.dstack([ - np.cos(s*scale)*sway_offsets + np.sin(s*scale)*heave_offsets - for s in range(self.sides) - ]) + point_offsets = self.calculate_point_offsets(sway_offsets, heave_offsets) mesh_points = self.path_points[..., np.newaxis] + point_offsets # rearrange and reshape into a simple array of points self.mesh_points = mesh_points.swapaxes(1,2).reshape(-1,3) + def calculate_point_offsets(self, sway_offsets, heave_offsets): + ''' + Converts the path point sway and heave offset vectors into offset points. + + Returns a depth-stacked array of shape (len(self.path_points), 3, self.sides), + where each row represents the cross-section profile offset points for a + single point on the tube trajectory. + + Can be overridden via inheritance or monkey-patching to change the generated + cross-section profile. + ''' + scale = 2 * np.pi / self.sides + # correction factors to expand flat_sides cases out to full width/height as relevant + sway_expand = heave_expand = 1 + if self.flat_sides and self.sides % 2 == 0: # vertical sides are flat + sway_expand = 1 / abs(math.cos(scale * (0 + 1/2))**self.rounding_strength) + if self.sides % 4 == 0: # top and bottom are also flat + heave_expand = sway_expand + elif self.sides % 2 == 0: # only top and bottom are flat + heave_expand = 1 / abs(math.sin(scale * (self.sides // 4))**self.rounding_strength) + + # Combine with even proportions around the cross-section profile + # NOTE: there's potential for optimisation here, if we want to add + # special cases for 2/4/... sided tubes, to reduce operations. + point_offsets = [] + for s in range(self.sides): + s += self.flat_sides / 2 + # use math functions here because numpy datatypes don't fall gracefully to complex + sway_mult = math.cos(s*scale) + heave_mult = math.sin(s*scale) + # support super-ellipse (rounded-rectangle) cross-section + if self.rounding_strength != 1: + # take abs to get magnitude of complex value (due to fractional power) + sway_mult = np.sign(sway_mult) * abs(sway_mult ** self.rounding_strength) * sway_expand + heave_mult = np.sign(heave_mult) * abs(heave_mult ** self.rounding_strength) * heave_expand + point_offsets.append(sway_mult*sway_offsets + heave_mult*heave_offsets) + return np.dstack(point_offsets) + def __init_triangles(self): self.triangles = self.generate_tube_triangles(self.sides, self.num_cylinders) @@ -343,12 +393,12 @@ def __init__( widths: Real | np.ndarray | Sequence = 0.2, heights: Real | np.ndarray | Sequence | None = None, inplace_path: bool = False, - *args, **kwargs + **kwargs ): ''' `path`, `widths`, `heights`, and `inplace_path` are as described in `TubeMesh`. `deviation_threshold_degrees` is the minimum angle considered to be "sharp". - `*args` and `**kwargs` are passed directly to the `TubeMesh` constructor. + `**kwargs` are passed directly to the `TubeMesh` constructor. ''' # Store initial points internally self._path_points = path if inplace_path else self.make_valid_path(path) @@ -375,7 +425,7 @@ def __init__( if heights.size != 1: heights = self._duplicate_sharp_corner_rows(heights) - super().__init__(path, widths, heights, inplace_path=True *args, **kwargs) + super().__init__(path, widths, heights, inplace_path=True, **kwargs) def _duplicate_sharp_corner_rows(self, array: np.ndarray, offset=0): # TODO confirm offset behaviour @@ -481,7 +531,7 @@ def __init__( widths: Real | np.ndarray | Sequence = 0.2, heights: Real | np.ndarray | Sequence | None = None, inplace_path: bool = False, - *args, **kwargs + **kwargs ): ''' `path` should contain N points to 'draw' a tube along. @@ -506,7 +556,7 @@ def __init__( `inplace_path` is a flag specifying that `path` is already a valid numpy array of 3D points with float values, and will not be changed externally so can safely be used directly (instead of via a copy). - `*args` and `**kwargs` are passed directly to the `TubeMesh` constructor. + `**kwargs` are passed directly to the `TubeMesh` constructor. ''' # Store initial points internally self._path_points = path if inplace_path else self.make_valid_path(path) @@ -542,7 +592,7 @@ def __init__( f'{this_class} requires 1 or {N-1=} heights, not {H}' heights = heights.repeat(2, axis=0) - super().__init__(path, widths, heights, inplace_path=True *args, **kwargs) + super().__init__(path, widths, heights, inplace_path=True, **kwargs) @staticmethod def calculate_corner_tangents(path_points): @@ -623,7 +673,7 @@ def to_Mesh3d( """ path = np.array([ [0,0,0], - [5,0,1], + [5,0,0],#1], [10,0,2], [7,1,1.5], [11,1,3], @@ -646,7 +696,7 @@ def to_Mesh3d( offsets.append(offset) side, up = offsets - kwargs = dict(sides=6, inplace_path=True, capped=True) + kwargs = dict(sides=8, rounding_strength=0.5, inplace_path=True, capped=True) meshes = ( TubeMesh(path+side, widths=widths, heights=heights, **kwargs), TubeMesh(path+side+up, widths=widths, heights=heights, **kwargs), From 85cb654accbf2384d4c5c6aae23ac255669e5b24 Mon Sep 17 00:00:00 2001 From: ES-Alexander Date: Sat, 8 Jul 2023 19:33:01 +0200 Subject: [PATCH 04/11] visualize: tube_mesh: implement 'cut' transition option --- fullcontrol/visualize/tube_mesh.py | 46 +++++++++++++++++++----------- 1 file changed, 30 insertions(+), 16 deletions(-) diff --git a/fullcontrol/visualize/tube_mesh.py b/fullcontrol/visualize/tube_mesh.py index 25278b3..0bfbd12 100644 --- a/fullcontrol/visualize/tube_mesh.py +++ b/fullcontrol/visualize/tube_mesh.py @@ -527,7 +527,7 @@ def __init__( self, path: np.ndarray | Sequence, separation: Real | np.ndarray = 0, -# transition_type: str = 'widen', + transition_type: str = 'widen', widths: Real | np.ndarray | Sequence = 0.2, heights: Real | np.ndarray | Sequence | None = None, inplace_path: bool = False, @@ -544,10 +544,10 @@ def __init__( - a single non-negative numerical value that applies to all internal points - N-2 non-negative numbers to control each separation individually `transition_type` should be one of - - "widen" to rotate each tube such that a chamfer that's `tube_sep` long + - "widen" to rotate each tube such that a chamfer that's `separation` long can pass through the corner, maintaining the corner tangent - - "cut" (TODO) to cut off corners with a chamfer that's `tube_sep` long - Only applies if `tube_sep` is non-zero. + - "cut" (TODO) to cut off corners with a chamfer that's `separation` long + Only applies if `separation` is non-zero. `widths` should be either - a single numerical value that applies to all cylinders - N-1 numbers denoting the (constant) width of each elliptic cylinder @@ -566,17 +566,29 @@ def __init__( path = self._path_points.repeat(2, axis=0)[1:-1] # Handle tube separations (if relevant) - # TODO: allow `separation`="minimal", and calculate as appropriate for `transition_type` - if np.all(separation != 0): - offsets = super().calculate_corner_tangents(self._path_points)[1:-1] - offsets /= np.linalg.norm(offsets, axis=1, keepdims=True) - offsets *= np.reshape(tube_sep, (-1,1)) / 4 - - path[1:-1:2] -= offsets - path[2:-1:2] += offsets + # TODO: allow `separation`="minimal" + if np.any(separation != 0): + scale = np.reshape(separation, (-1,1)) / 2 + # determine offset directions + if transition_type == 'widen': + # shift path points back along corner tangent + offsets = super().calculate_corner_tangents(self._path_points)[1:-1] + # scale offsets to specified separation lengths + offsets /= np.linalg.norm(offsets, axis=1, keepdims=True) + offsets *= scale + pull_back = push_along = offsets + elif transition_type == 'cut': + # shift path points back along segment vectors + segment_vectors = np.diff(self._path_points, axis=0) + # scale offsets to specified separation lengths + segment_vectors /= np.linalg.norm(segment_vectors, axis=1, keepdims=True) + pull_back = segment_vectors[:-1] * scale + push_along = segment_vectors[1:] * scale + else: + raise ValueError("transition type should be one of 'cut'/'widen'.") -# if transition_type == 'cut': -# ... # TODO: shift path points back along the pairwise average + path[1:-1:2] -= pull_back + path[2:-1:2] += push_along this_class = self.__class__.__name__ widths = np.reshape(widths, (-1,1)) @@ -702,8 +714,10 @@ def to_Mesh3d( TubeMesh(path+side+up, widths=widths, heights=heights, **kwargs), FlowTubeMesh(path, widths=widths, heights=heights, **kwargs), FlowTubeMesh(path+up, widths=widths, heights=heights, **kwargs), - CylindersMesh(path-side, widths=widths[:-1], heights=heights[:-1], **kwargs), - CylindersMesh(path-side+up, widths=widths[:-1], heights=heights[:-1], **kwargs), + CylindersMesh(path-side, widths=widths[:-1], heights=heights[:-1], + separation=0.5, transition_type='cut', **kwargs), + CylindersMesh(path-side+up, widths=widths[:-1], heights=heights[:-1], + separation=0.5, **kwargs), ) t_mesh_data = perf_counter() print(f'DONE [{t_mesh_data - t_path_gen:.3f}s]\nGenerating plotly meshes... ', end='') From 1dfd3af000e372b03b6328290765e81f27131cad Mon Sep 17 00:00:00 2001 From: ES-Alexander Date: Sat, 19 Aug 2023 21:16:09 +1000 Subject: [PATCH 05/11] visualize: tube_mesh: refactor to split out STL triangle formatting --- fullcontrol/visualize/tube_mesh.py | 34 ++++++++++++++++++------------ 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/fullcontrol/visualize/tube_mesh.py b/fullcontrol/visualize/tube_mesh.py index 0bfbd12..0b0195b 100644 --- a/fullcontrol/visualize/tube_mesh.py +++ b/fullcontrol/visualize/tube_mesh.py @@ -98,7 +98,7 @@ def __init_mesh_points(self): def calculate_point_offsets(self, sway_offsets, heave_offsets): ''' Converts the path point sway and heave offset vectors into offset points. - + Returns a depth-stacked array of shape (len(self.path_points), 3, self.sides), where each row represents the cross-section profile offset points for a single point on the tube trajectory. @@ -118,7 +118,7 @@ def calculate_point_offsets(self, sway_offsets, heave_offsets): # Combine with even proportions around the cross-section profile # NOTE: there's potential for optimisation here, if we want to add - # special cases for 2/4/... sided tubes, to reduce operations. + # special cases for 2/4/... sided tubes, to reduce operations. point_offsets = [] for s in range(self.sides): s += self.flat_sides / 2 @@ -272,18 +272,25 @@ def _to_ascii_stl(self, path: pathlib.Path): with path.open('w') as out: print(f'solid {path.stem} # Generated by FullControlXYZ', file=out) for n, vs in zip(self.mesh_normals, self.triangle_points.reshape(-1,9)): - print( - f'facet normal {n[0]:e} {n[1]:e} {n[2]:e}', - ' outer loop', - f' vertex {vs[0]:e} {vs[1]:e} {vs[2]:e}', - f' vertex {vs[3]:e} {vs[4]:e} {vs[5]:e}', - f' vertex {vs[6]:e} {vs[7]:e} {vs[8]:e}', - ' endloop', - 'endfacet', - sep='\n', file=out - ) + print(self.format_stl_triangle(n, vs, binary=False), file=out) print(f'endsolid {path.stem}', file=out) + @staticmethod + def format_stl_triangle(n, vs, binary=True) -> bytes | str: + if binary: + attribute_byte_count = 0 # TODO: support facet color options + return struct.pack('<'+'f'*(3*(1+3))+'H', *n, *vs, attribute_byte_count) + else: # ascii + return '\n'.join(( + f'facet normal {n[0]:e} {n[1]:e} {n[2]:e}', + ' outer loop', + f' vertex {vs[0]:e} {vs[1]:e} {vs[2]:e}', + f' vertex {vs[3]:e} {vs[4]:e} {vs[5]:e}', + f' vertex {vs[6]:e} {vs[7]:e} {vs[8]:e}', + ' endloop', + 'endfacet' + )) + def _to_binary_stl(self, path: pathlib.Path): UNITS = self.metadata.get('units', 'mm') AUTHOR = self.metadata.get('author', None) @@ -301,8 +308,7 @@ def _to_binary_stl(self, path: pathlib.Path): out.write(header[:80]) # ensure header is valid out.write(struct.pack(' Date: Sun, 20 Aug 2023 01:33:18 +1000 Subject: [PATCH 06/11] visualize: tube_mesh: support multi-object STL output --- fullcontrol/visualize/tube_mesh.py | 201 +++++++++++++++++++---------- 1 file changed, 132 insertions(+), 69 deletions(-) diff --git a/fullcontrol/visualize/tube_mesh.py b/fullcontrol/visualize/tube_mesh.py index 0b0195b..a1e8995 100644 --- a/fullcontrol/visualize/tube_mesh.py +++ b/fullcontrol/visualize/tube_mesh.py @@ -11,7 +11,129 @@ import numpy as np -class TubeMesh: +class MeshExporter: + def __init__(self, metadata: dict | None = None, + bodies: list | None = None): + self.metadata = metadata or {} # default to empty dictionary + self._bodies = bodies or [self] + + @property + def triangle_points(self): + return NotImplemented + + @property + def mesh_normals(self): + ''' Assumes `triangle_indices` are defined anti-clockwise. ''' + if (cached := getattr(self, '_mesh_normals', None)) is not None: + return cached + triangle_points = self.triangle_points + # anticlockwise point order when viewed from outside + first_vectors = triangle_points[1::3] - triangle_points[::3] + second_vectors = triangle_points[2::3] - triangle_points[::3] + self._mesh_normals = np.cross(first_vectors, second_vectors) + return self._mesh_normals + + def to_stl(self, path: pathlib.Path | str, binary: bool = True, + overwrite: bool = False, combined_file: bool = True): + write_header = self._write_binary_stl_header if binary else lambda out: None + write_data = self._write_binary_stl_data if binary else self._write_ascii_stl_data + + path = pathlib.Path(path) + name = self.metadata.get('name', path.stem) + num_bodies = len(self._bodies) + # Calculate digits needed for zero-padding increments + digits = math.ceil(math.log10(num_bodies)) + 1 + mode = 'w' + 'b'*binary + if combined_file or num_bodies == 1: + if num_bodies > 1: + print('WARNING! Multi-object STL file - may not work in some softwares.') + + with self.valid_path(path, overwrite).open(mode) as out: + write_header(out) + for index, body in enumerate(self._bodies): + identifier = ( + index if binary + else (name if num_bodies == 1 else f'{name}_{index:>0{digits}}') + ) + write_data(out, body.mesh_normals, + body.triangle_points.reshape(-1,9), identifier) + else: + for index, body in enumerate(self._bodies): + file_path = self.valid_path(path.with_stem(f'{path.stem}_{index:>0{digits}}'), + overwrite) + with file_path.open(mode) as out: + identifier = 0 if binary else name + write_header(out) + write_data(out, body.mesh_normals, + body.triangle_points.reshape(-1,9), identifier) + + @staticmethod + def _write_ascii_stl_data(out, mesh_normals, triangle_points, solid_name: str = 'object'): + print(f'solid {solid_name} # Generated by FullControlXYZ', file=out) + for n, vs in zip(mesh_normals, triangle_points): + print( + f'facet normal {n[0]:e} {n[1]:e} {n[2]:e}', + ' outer loop', + f' vertex {vs[0]:e} {vs[1]:e} {vs[2]:e}', + f' vertex {vs[3]:e} {vs[4]:e} {vs[5]:e}', + f' vertex {vs[6]:e} {vs[7]:e} {vs[8]:e}', + ' endloop', + 'endfacet', + sep='\n', file=out) + print(f'endsolid {solid_name}', file=out) + + def _write_binary_stl_header(self, out): + UNITS = self.metadata.get('units', 'mm') + AUTHOR = self.metadata.get('author', None) + author = f'{AUTHOR=},' if AUTHOR is not None else '' + + header = bytearray([0]*80) + # header is arbitrary, but put some meaningful data into it + # TODO: support optional COLOR and MATERIAL fields + # 'COLOR' should be a 4-byte RGBA value, as a simple full-object color + # 'MATERIAL' should be diffuse reflection, specular highlight, ambient light + # as 4-byte RGBA values - preferred over COLOR + header_data = f'STL,{UNITS=},{author}SOFTWARE=FullControlXYZ'.encode('utf-8') + header[:len(header_data)] = header_data + out.write(header[:80]) # ensure header is valid + + @staticmethod + def _write_binary_stl_data(out, mesh_normals, triangle_points, solid_index: int = 0): + num_triangles = len(triangle_points) # one triangle per data row + if solid_index == 0: + out.write(struct.pack(' pathlib.Path: + path = pathlib.Path(path) # ensure a valid Path object + if not overwrite and path.is_file(): + path = path.with_stem( + f'{path.stem}__{datetime.today().strftime("%d-%m-%Y__%H-%M-%S")}' + ) + return path + + +class TubeMesh(MeshExporter): ''' A triangle mesh of conical tubes that follow a path of points. ''' def __init__( self, @@ -60,6 +182,7 @@ def __init__( so can safely be used directly (instead of via a copy). `metadata` is a dictionary of metadata relevant to the input path. ''' + super().__init__(metadata) self.path_points = path if inplace_path else self.make_valid_path(path) self.num_cylinders = len(path) - 1 @@ -76,8 +199,6 @@ def __init__( if self.capped: self.__init_endcaps() - self.metadata = metadata or {} # default to empty dictionary - def __init_mesh_points(self): corner_tangents = self.calculate_corner_tangents(self.path_points) @@ -242,73 +363,10 @@ def triangle_points(self): self._triangle_points = self.mesh_points[self.triangles.flatten()] return self._triangle_points - @property - def mesh_normals(self): - ''' Assumes `triangle_indices` are defined anti-clockwise. ''' - if (cached := getattr(self, '_mesh_normals', None)) is not None: - return cached - triangle_points = self.triangle_points - # anticlockwise point order when viewed from outside - first_vectors = triangle_points[1::3] - triangle_points[::3] - second_vectors = triangle_points[2::3] - triangle_points[::3] - self._mesh_normals = np.cross(first_vectors, second_vectors) - return self._mesh_normals - - def to_stl(self, path: pathlib.Path | str, type: str = 'ascii', overwrite=False): + def to_stl(self, path: pathlib.Path | str, **kwargs): if not self.capped: print('WARNING! Non-manifold mesh - not using capped ends.') - path = pathlib.Path(path) # ensure a valid Path object - if not overwrite and path.is_file(): - path = path.with_stem( - f'{path.stem}__{datetime.today().strftime("%d-%m-%Y__%H-%M-%S")}' - ) - - { - 'ascii': self._to_ascii_stl, - 'binary': self._to_binary_stl, - }[type](path) - - def _to_ascii_stl(self, path: pathlib.Path): - with path.open('w') as out: - print(f'solid {path.stem} # Generated by FullControlXYZ', file=out) - for n, vs in zip(self.mesh_normals, self.triangle_points.reshape(-1,9)): - print(self.format_stl_triangle(n, vs, binary=False), file=out) - print(f'endsolid {path.stem}', file=out) - - @staticmethod - def format_stl_triangle(n, vs, binary=True) -> bytes | str: - if binary: - attribute_byte_count = 0 # TODO: support facet color options - return struct.pack('<'+'f'*(3*(1+3))+'H', *n, *vs, attribute_byte_count) - else: # ascii - return '\n'.join(( - f'facet normal {n[0]:e} {n[1]:e} {n[2]:e}', - ' outer loop', - f' vertex {vs[0]:e} {vs[1]:e} {vs[2]:e}', - f' vertex {vs[3]:e} {vs[4]:e} {vs[5]:e}', - f' vertex {vs[6]:e} {vs[7]:e} {vs[8]:e}', - ' endloop', - 'endfacet' - )) - - def _to_binary_stl(self, path: pathlib.Path): - UNITS = self.metadata.get('units', 'mm') - AUTHOR = self.metadata.get('author', None) - author = f'{AUTHOR=}' if AUTHOR is not None else '' - - with path.open('wb') as out: - header = bytearray([0]*80) - # header is arbitrary, but put some meaningful data into it - # TODO: support optional COLOR and MATERIAL fields - # 'COLOR' should be a 4-byte RGBA value, as a simple full-object color - # 'MATERIAL' should be diffuse reflection, specular highlight, ambient light - # as 4-byte RGBA values - preferred over COLOR - header_data = f'STL,{UNITS=},{author}SOFTWARE=FullControlXYZ'.encode('utf-8') - header[:len(header_data)] = header_data - out.write(header[:80]) # ensure header is valid - out.write(struct.pack(' Date: Sun, 10 Sep 2023 21:39:46 +1000 Subject: [PATCH 07/11] fullcontrol: visualize: allow saving STL(s) when plotting --- fullcontrol/visualize/controls.py | 3 +++ fullcontrol/visualize/plotly.py | 17 +++++++++++++++-- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/fullcontrol/visualize/controls.py b/fullcontrol/visualize/controls.py index 06dcfb5..d4820c5 100644 --- a/fullcontrol/visualize/controls.py +++ b/fullcontrol/visualize/controls.py @@ -10,6 +10,9 @@ class PlotControls(BaseModel): style: Optional[str] = None # 'tube'/'line' tube_type: Optional[str] = None # 'flow'/'cylinders' tube_sides: Optional[int] = None + tube_stl_filename: Optional[str] = None + tube_stl_type: Optional[str] = 'binary' # 'binary'/'ascii' + tube_stls_combined: Optional[bool] = False zoom: Optional[float] = 1 hide_annotations: Optional[bool] = False hide_travel: Optional[bool] = False diff --git a/fullcontrol/visualize/plotly.py b/fullcontrol/visualize/plotly.py index d9c32e8..a244d6a 100644 --- a/fullcontrol/visualize/plotly.py +++ b/fullcontrol/visualize/plotly.py @@ -20,6 +20,10 @@ def plot(data: PlotData, controls: PlotControls): else: Mesh = FlowTubeMesh + saving_stl = bool(controls.tube_stl_filename) + if saving_stl: + meshes = [] + # generate line plots any_mesh_plots = False max_width = 0 @@ -54,14 +58,23 @@ def plot(data: PlotData, controls: PlotControls): heights = np.array(heights)[good_points] if Mesh == CylindersMesh: heights = heights[1:] - fig.add_trace(Mesh(path_points, widths=widths, heights=heights, sides=sides, capped=capped, inplace_path=True) - .to_Mesh3d(colors=colors_now)) + mesh = Mesh(path_points, widths=widths, heights=heights, sides=sides, capped=capped, inplace_path=True) + fig.add_trace(mesh.to_Mesh3d(colors=colors_now)) + if saving_stl: + meshes.append(mesh) any_mesh_plots = True max_width = max(max_width, local_max) elif not controls.hide_travel or path.extruder.on: fig.add_trace(go.Scatter3d(mode='lines', x=path.xvals, y=path.yvals, z=path.zvals, showlegend=False, line=dict(width=linewidth_now, color=colors_now))) + # handle STL saving + if saving_stl: + binary_file = controls.tube_stl_type.lower()=='binary' + metadata = {'name': 'extrusion'} + MeshExporter(metadata, meshes).to_stl(controls.tube_stl_filename, binary_file, + combined_file=controls.tube_stls_combined) + # find a bounding box, to create a plot with equally proportioned X Y Z scales (so a cuboid looks like a cuboid, not a cube) bounding_box_size = max(data.bounding_box.maxx-data.bounding_box.minx, data.bounding_box.maxy - data.bounding_box.miny, data.bounding_box.maxz-min(0, data.bounding_box.minz)) From ca7bed110ae57126b965d55106f57dc39102d82e Mon Sep 17 00:00:00 2001 From: ES-Alexander Date: Wed, 8 Nov 2023 14:01:54 +1000 Subject: [PATCH 08/11] avoid tracking STL files --- .gitignore | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.gitignore b/.gitignore index b6e4761..a2b29d7 100644 --- a/.gitignore +++ b/.gitignore @@ -127,3 +127,9 @@ dmypy.json # Pyre type checker .pyre/ + +# STL files +*.stl + +# MacOS file viewer metadata +**/.DS_Store From 82230912ab45ab4b376a5e41283395319d45ba87 Mon Sep 17 00:00:00 2001 From: andy g Date: Thu, 8 Feb 2024 01:56:40 +1100 Subject: [PATCH 09/11] refactor STL generation into lab.fullcontrol.geometry_model --- fullcontrol/visualize/controls.py | 7 +- fullcontrol/visualize/plotly.py | 95 ++++++++----------- fullcontrol/visualize/tube_mesh.py | 20 ++-- lab/fullcontrol/__init__.py | 2 + lab/fullcontrol/geometry_model/__init__.py | 0 lab/fullcontrol/geometry_model/controls.py | 26 +++++ .../geometry_model/steps2geometry.py | 46 +++++++++ lab/fullcontrol/transform.py | 11 +++ 8 files changed, 140 insertions(+), 67 deletions(-) create mode 100644 lab/fullcontrol/geometry_model/__init__.py create mode 100644 lab/fullcontrol/geometry_model/controls.py create mode 100644 lab/fullcontrol/geometry_model/steps2geometry.py create mode 100644 lab/fullcontrol/transform.py diff --git a/fullcontrol/visualize/controls.py b/fullcontrol/visualize/controls.py index d4820c5..9d4d0e5 100644 --- a/fullcontrol/visualize/controls.py +++ b/fullcontrol/visualize/controls.py @@ -8,11 +8,8 @@ class PlotControls(BaseModel): color_type: Optional[str] = 'z_gradient' line_width: Optional[float] = 2 style: Optional[str] = None # 'tube'/'line' - tube_type: Optional[str] = None # 'flow'/'cylinders' - tube_sides: Optional[int] = None - tube_stl_filename: Optional[str] = None - tube_stl_type: Optional[str] = 'binary' # 'binary'/'ascii' - tube_stls_combined: Optional[bool] = False + tube_type: Optional[str] = 'flow' # 'flow'/'cylinders' + tube_sides: Optional[int] = 4 zoom: Optional[float] = 1 hide_annotations: Optional[bool] = False hide_travel: Optional[bool] = False diff --git a/fullcontrol/visualize/plotly.py b/fullcontrol/visualize/plotly.py index a244d6a..2445c9c 100644 --- a/fullcontrol/visualize/plotly.py +++ b/fullcontrol/visualize/plotly.py @@ -2,9 +2,38 @@ import plotly.graph_objects as go from fullcontrol.visualize.plot_data import PlotData from fullcontrol.visualize.controls import PlotControls -from fullcontrol.visualize.tube_mesh import CylindersMesh, FlowTubeMesh +from fullcontrol.visualize.tube_mesh import CylindersMesh, FlowTubeMesh, MeshExporter +def generate_mesh(path, linewidth_now: float, Mesh: FlowTubeMesh, sides, rounding_strength, flat_sides, colors_now: list = None): + global local_max # allow external tracking for nice plot boundaries + path_points = np.array([path.xvals, path.yvals, path.zvals]).T + good_points = np.ones(len(path_points), dtype=bool) + dups = np.all(np.diff(path_points, axis=0)==0, axis=1) + if np.any(dups): + # remove successive duplicate points so TubeMesh can be generated + good_points[1:] = ~dups + # modify colors list so the new ones are accessible outside the function + colors_now[:] = np.array(colors_now, dtype=object)[good_points] + path_points = path_points[good_points] + capped = False + widths = path.widths + if not widths: # TODO: check whether it's ever reasonable for a user to not define the widths for their extrusion path + local_max = widths = linewidth_now/10 + else: + widths = np.array(widths)[good_points] + if Mesh == CylindersMesh: + widths = widths[1:] + local_max = max(widths) + heights = path.heights or None + if heights: + heights = np.array(heights)[good_points] + if Mesh == CylindersMesh: + heights = heights[1:] + return Mesh(path_points, widths=widths, heights=heights, sides=sides, capped=capped, inplace_path=True, + rounding_strength=rounding_strength, flat_sides=flat_sides) + + def plot(data: PlotData, controls: PlotControls): 'plot data for x y z lines with RGB colors and annotations. style of plot governed by controls' fig = go.Figure() @@ -17,64 +46,24 @@ def plot(data: PlotData, controls: PlotControls): if controls.tube_type is not None: Mesh = {'flow': FlowTubeMesh, 'cylinders': CylindersMesh}[controls.tube_type] - else: + else: # Fall back to FlowTubeMesh if no tube_type is explicitly specified Mesh = FlowTubeMesh - saving_stl = bool(controls.tube_stl_filename) - if saving_stl: - meshes = [] - # generate line plots - any_mesh_plots = False max_width = 0 for path in data.paths: colors_now = [f'rgb({color[0]*255:.2f}, {color[1]*255:.2f}, {color[2]*255:.2f})' for color in path.colors] linewidth_now = controls.line_width * \ 2 if path.extruder.on == True else controls.line_width*0.5 if path.extruder.on and controls.style == 'tube': - path_points = np.array([path.xvals, path.yvals, path.zvals]).T - good_points = np.ones(len(path_points), dtype=bool) - dups = np.all(np.diff(path_points, axis=0)==0, axis=1) - if np.any(dups): - # remove successive duplicate points so TubeMesh can be generated - good_points[1:] = ~dups - colors_now = np.array(colors_now, dtype=object)[good_points] - path_points = path_points[good_points] - num_path_points = len(path_points) - neat = bool(controls.neat_for_publishing) - # if automatic, dynamically reduce the number of sides when plotting large numbers of path points - sides = controls.tube_sides or (6 if num_path_points < 10 else 4 if num_path_points < 1_000_000 else 2) - capped = neat or num_path_points < 100 - widths = path.widths - if not widths: - local_max = widths = linewidth_now/10 - else: - widths = np.array(widths)[good_points] - if Mesh == CylindersMesh: - widths = widths[1:] - local_max = max(widths) - heights = path.heights or None - if heights: - heights = np.array(heights)[good_points] - if Mesh == CylindersMesh: - heights = heights[1:] - mesh = Mesh(path_points, widths=widths, heights=heights, sides=sides, capped=capped, inplace_path=True) + sides, rounding_strength, flat_sides = controls.tube_sides, 0.4, False + mesh = generate_mesh(path, linewidth_now, Mesh, sides, rounding_strength, flat_sides, colors_now) fig.add_trace(mesh.to_Mesh3d(colors=colors_now)) - if saving_stl: - meshes.append(mesh) - any_mesh_plots = True max_width = max(max_width, local_max) - elif not controls.hide_travel or path.extruder.on: + elif not controls.hide_travel or path.extruder.on: # plot travel lines for tube and line fig.add_trace(go.Scatter3d(mode='lines', x=path.xvals, y=path.yvals, z=path.zvals, showlegend=False, line=dict(width=linewidth_now, color=colors_now))) - # handle STL saving - if saving_stl: - binary_file = controls.tube_stl_type.lower()=='binary' - metadata = {'name': 'extrusion'} - MeshExporter(metadata, meshes).to_stl(controls.tube_stl_filename, binary_file, - combined_file=controls.tube_stls_combined) - # find a bounding box, to create a plot with equally proportioned X Y Z scales (so a cuboid looks like a cuboid, not a cube) bounding_box_size = max(data.bounding_box.maxx-data.bounding_box.minx, data.bounding_box.maxy - data.bounding_box.miny, data.bounding_box.maxz-min(0, data.bounding_box.minz)) @@ -117,14 +106,14 @@ def plot(data: PlotData, controls: PlotControls): camera = dict(eye=dict(x=-0.5/controls.zoom, y=-1/controls.zoom, z=-0.5+0.5/controls.zoom), center=dict(x=0, y=0, z=-0.5)) - fig.update_layout(template='plotly_dark', paper_bgcolor="black", scene_aspectmode='cube', scene=dict(annotations=annotations, - xaxis=dict(backgroundcolor="black", nticks=10, range=[ - data.bounding_box.midx-bounding_box_size/2, data.bounding_box.midx+bounding_box_size/2],), - yaxis=dict(backgroundcolor="black", nticks=10, range=[ - data.bounding_box.midy-bounding_box_size/2, data.bounding_box.midy+bounding_box_size/2],), - zaxis=dict(backgroundcolor="black", nticks=10, range=[min(0, data.bounding_box.minz), bounding_box_size],),), - scene_camera=camera, - width=800, height=500, margin=dict(l=10, r=10, b=10, t=10, pad=4)) + fig.update_layout(template='plotly_dark', paper_bgcolor="black", scene_aspectmode='cube', + scene=dict(annotations=annotations, + xaxis=dict(backgroundcolor="black", nticks=10, + range=[data.bounding_box.midx-bounding_box_size/2, data.bounding_box.midx+bounding_box_size/2],), + yaxis=dict(backgroundcolor="black", nticks=10, + range=[data.bounding_box.midy-bounding_box_size/2, data.bounding_box.midy+bounding_box_size/2],), + zaxis=dict(backgroundcolor="black", nticks=10, range=[min(0, data.bounding_box.minz), bounding_box_size],), + ), scene_camera=camera, width=800, height=500, margin=dict(l=10, r=10, b=10, t=10, pad=4)) if controls.hide_axes or controls.neat_for_publishing: for axis in ['xaxis', 'yaxis', 'zaxis']: fig.update_layout( diff --git a/fullcontrol/visualize/tube_mesh.py b/fullcontrol/visualize/tube_mesh.py index a1e8995..e42ae49 100644 --- a/fullcontrol/visualize/tube_mesh.py +++ b/fullcontrol/visualize/tube_mesh.py @@ -46,7 +46,7 @@ def to_stl(self, path: pathlib.Path | str, binary: bool = True, mode = 'w' + 'b'*binary if combined_file or num_bodies == 1: if num_bodies > 1: - print('WARNING! Multi-object STL file - may not work in some softwares.') + print("WARNING! Multi-object STL file - may not work in some softwares, nor with stl_type='binary'.") with self.valid_path(path, overwrite).open(mode) as out: write_header(out) @@ -126,6 +126,7 @@ def _write_binary_stl_data(out, mesh_normals, triangle_points, solid_index: int @staticmethod def valid_path(path: pathlib.Path | str, overwrite: bool = False) -> pathlib.Path: path = pathlib.Path(path) # ensure a valid Path object + # Add a timestamp if the file already exists and we don't want to overwrite it if not overwrite and path.is_file(): path = path.with_stem( f'{path.stem}__{datetime.today().strftime("%d-%m-%Y__%H-%M-%S")}' @@ -138,14 +139,14 @@ class TubeMesh(MeshExporter): def __init__( self, path: np.ndarray | Sequence, - widths: Real | np.ndarray | Sequence = 0.2, - heights: Real | np.ndarray | Sequence | None = None, + widths: Real | np.ndarray | Sequence, + heights: Real | np.ndarray | Sequence, *, # make remaining arguments keyword-only - sides: int = 6, - rounding_strength: float = 1, - flat_sides: bool = True, - capped: bool = False, - inplace_path: bool = False, + sides: int, + rounding_strength: float, + flat_sides: bool, + capped: bool, + inplace_path: bool, metadata: dict | None = None, ): ''' @@ -772,7 +773,8 @@ def to_Mesh3d( offsets.append(offset) side, up = offsets - kwargs = dict(sides=8, rounding_strength=0.5, inplace_path=True, capped=True) + kwargs = dict(sides=8, rounding_strength=0.5, flat_sides=False, + inplace_path=True, capped=True) meshes = ( TubeMesh(path+side, widths=widths, heights=heights, **kwargs), TubeMesh(path+side+up, widths=widths, heights=heights, **kwargs), diff --git a/lab/fullcontrol/__init__.py b/lab/fullcontrol/__init__.py index 523ca8f..35e661d 100644 --- a/lab/fullcontrol/__init__.py +++ b/lab/fullcontrol/__init__.py @@ -4,3 +4,5 @@ # separate import statement (e.g. like multiaxis stuff is currently) from lab.fullcontrol.geometry import * from lab.fullcontrol.p_r import setup_p, setup_r +from lab.fullcontrol.transform import transform +from lab.fullcontrol.geometry_model.controls import ModelControls diff --git a/lab/fullcontrol/geometry_model/__init__.py b/lab/fullcontrol/geometry_model/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lab/fullcontrol/geometry_model/controls.py b/lab/fullcontrol/geometry_model/controls.py new file mode 100644 index 0000000..b9957ae --- /dev/null +++ b/lab/fullcontrol/geometry_model/controls.py @@ -0,0 +1,26 @@ +from typing import Optional +from pydantic import BaseModel + + +class ModelControls(BaseModel): + ''' Controls to adjust the style of a generated 3D model. ''' + stl_filename: Optional[str] = '3d_model' + include_date: Optional[bool] = True + tube_shape: Optional[str] = 'rectangle' # 'rectangle'/'diamond'/'hexagon'/'octagon' + tube_type: Optional[str] = 'flow' # 'flow'/'cylinders' + stl_type: Optional[str] = 'ascii' # 'binary'/'ascii' + stls_combined: Optional[bool] = True + # initialization_data is information about initial printing conditions, which may be + # changed by the fullcontrol 'design', whereas the above attributes are never changed + # by the 'design'. + # Values passed for initialization_data overwrite the default initialization data of + # the printer. + initialization_data: Optional[dict] = {} + + def shape_properties(self): + return { + 'rectangle': (4, 0, True), + 'diamond': (4, 1, False), + 'hexagon': (6, 0.4, False), + 'octagon': (8, 0.4, True), + }[self.tube_shape] diff --git a/lab/fullcontrol/geometry_model/steps2geometry.py b/lab/fullcontrol/geometry_model/steps2geometry.py new file mode 100644 index 0000000..bb550ec --- /dev/null +++ b/lab/fullcontrol/geometry_model/steps2geometry.py @@ -0,0 +1,46 @@ +from fullcontrol.visualize.plot_data import PlotData +from lab.fullcontrol.geometry_model.controls import ModelControls +from fullcontrol.visualize.controls import PlotControls +from fullcontrol.visualize.plotly import generate_mesh + +def generate_stl(data: PlotData, controls: ModelControls): + from fullcontrol.visualize.tube_mesh import CylindersMesh, FlowTubeMesh, MeshExporter + + sides, rounding_strength, flat_sides = controls.shape_properties() + Mesh = {'flow': FlowTubeMesh, 'cylinders': CylindersMesh}[controls.tube_type] + meshes = [] + for path in data.paths: + if path.extruder.on: + meshes.append( + generate_mesh(path, 0, Mesh, sides, rounding_strength, flat_sides) + ) + + binary_file = controls.stl_type.lower() == 'binary' + MeshExporter({'name': 'extrusion'}, meshes).to_stl( + controls.stl_filename, binary_file, combined_file=controls.stls_combined + ) + +def reuse_visualize(steps: list, model_controls: ModelControls): + from fullcontrol.visualize.state import State + + plot_controls = PlotControls(tube_type=model_controls.tube_type, + initialization_data=model_controls.initialization_data) + state = State(steps, plot_controls) + plot_data = PlotData(steps, state) + for step in steps: + step.visualize(state, plot_data, plot_controls) + plot_data.cleanup() + return plot_data + +def geometry_model(steps: list, model_controls: ModelControls = ModelControls()): + ''' use the existing visualize function to get plot_data that is normally used to generate + the 3D model for visualization and is used here to generate the 3D model for stl output or similar + ''' + from datetime import datetime + + plot_data = reuse_visualize(steps, model_controls) + model_controls.stl_filename += '.stl' if not model_controls.include_date \ + else datetime.now().strftime("__%d-%m-%Y__%H-%M-%S.stl") + generate_stl(plot_data, model_controls) + + print("stl file created. remember to set ModelControls(tube_type='cylinders') for more accurate widths/heights but a less-smooth model than ModelControls(tube_type='flow') (default)") diff --git a/lab/fullcontrol/transform.py b/lab/fullcontrol/transform.py new file mode 100644 index 0000000..3118647 --- /dev/null +++ b/lab/fullcontrol/transform.py @@ -0,0 +1,11 @@ +from lab.fullcontrol.geometry_model.controls import ModelControls + +def transform(steps: list, result_type: str, controls: ModelControls = None): + ''' Transform a fullcontrol design (a list of function class instances) into result_type "3d_model". + Optionally, ModelControls can be passed to control how the 3D model is generated. + ''' + if result_type == '3d_model': # This is currently redundant, but maintained for consistency with fullcontrol.combinations.gcode_and_visualization.common in any future expansion to the result_type options here + from lab.fullcontrol.geometry_model.steps2geometry import geometry_model + if controls is not None: + return geometry_model(steps, controls) + return geometry_model(steps) From f7c14161430d5b45324e6842f2b3371e38c78969 Mon Sep 17 00:00:00 2001 From: andy g Date: Thu, 8 Feb 2024 02:06:02 +1100 Subject: [PATCH 10/11] docs: add STL generation notebook example --- docs/lab_stl_output.ipynb | 151 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 docs/lab_stl_output.ipynb diff --git a/docs/lab_stl_output.ipynb b/docs/lab_stl_output.ipynb new file mode 100644 index 0000000..72ea3c1 --- /dev/null +++ b/docs/lab_stl_output.ipynb @@ -0,0 +1,151 @@ +{ + "cells": [ + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# lab stl output\n", + "\n", + "the FullControl lab exists for things that aren't suitable for the main FullControl package yet, potentially due to complexity in terms of their concept, code, hardware requirements, computational requirements, etc.\n", + " \n", + "FullControl features/functions/classes in the lab may be more experimental in nature and should be used with caution, with an understanding that they may change in future updates\n", + "\n", + "at present, both the lab and the regular FullControl packages are under active development and the code and package structures may change considerably. some aspects currently in FullControl may move to lab and vice versa\n", + "\n", + "lab currently has three main aspects:\n", + "- geometry functions that supplement existing geometry functions in FullControl\n", + "- multi-axis demos\n", + "- stl output of the designed geometry with extudate heights and widths based on the designed `ExtrusionGeometry` \n", + "\n", + "this notebook briefly demonstrates stl-output functionality" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### FullControl lab import" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import fullcontrol as fc\n", + "import lab.fullcontrol as fclab" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### create a ***design***" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "EW, EH = 0.8, 0.3 # extrusion width and height\n", + "radius, layers = 10, 5\n", + "design_name = 'test_design'\n", + "steps = fc.helixZ(fc.Point(x=0, y=0, z=EH), radius, radius, 0, layers, EH, layers*32)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "##### transform the design to a 'plot' ***result*** to preview it" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "fc.transform(steps, 'plot', fc.PlotControls(style='tube', zoom=0.7,\n", + " initialization_data={'extrusion_width': EW, 'extrusion_height': EH}))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "##### ModelControls adjust how a ***design*** is transformed into a '3d_model' ***result***\n", + "\n", + "***designs*** are transformed into a 'plot' according to some default settings which can be overwritten with a PlotControls object with the following attributes (all demonstrated in this notebook):\n", + "\n", + "- `stl_filename` - string for filename (do not include '.stl')\n", + "- `include_date` - options: True/False (include dates/time-stamp in the stl filename)\n", + "- `tube_shape` - options: 'rectangle' / 'diamond' / 'hexagon' / 'octagon' - adjusts cross sectional shape of extrudates in the stl file\n", + " - note this is different format for controlling the design as opposed to `tube-sides` in a `PlotControls` object\n", + "- `tube_type` - options: 'flow'/'cylinders' - adjust how the plot transitions from line to line\n", + " - see the `PlotControls` tutorial for more info about this parameter\n", + "- `stl_type` - options: 'ascii'/'binary' - stl file format\n", + "- `stls_combined` - options: True/False - state whether designs containing multiple bodies are saved with all bodies in a single stl file\n", + "- `initialization_data` - define initial width/height of 3D lines with dictionary: {'extrusion_width': value, 'extrusion_height': value}" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "fclab.transform(steps, '3d_model', fclab.ModelControls(\n", + " stl_filename=design_name, \n", + " include_date=False, \n", + " tube_shape='rectangle',\n", + " tube_type= 'flow', \n", + " stl_type = 'ascii', \n", + " stls_combined = True, \n", + " initialization_data={'extrusion_width': EW, 'extrusion_height': EH}))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "#### colab\n", + "\n", + "if using google colab, the stl file can be downloaded from the file browser on the left-hand side or with:\n", + "\n", + "```\n", + "from google.colab import files\n", + "files.download(f'{design_name}.stl')\n", + "```\n", + "(assuming `include_date` is False)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "fc", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.3" + }, + "orig_nbformat": 4 + }, + "nbformat": 4, + "nbformat_minor": 2 +} From 2c5e06d3e56aa2021d1b987f45fd48327986648e Mon Sep 17 00:00:00 2001 From: ES-Alexander Date: Thu, 8 Feb 2024 03:01:21 +1100 Subject: [PATCH 11/11] fullcontrol: visualize: tube_mesh: fix N+1 FlowTubeMesh colour bug The bug prevented capped FlowTubeMeshes from having the cap colours specified independently of the tube colours, which was documented functionality. This commit fixes the issue. --- fullcontrol/visualize/tube_mesh.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fullcontrol/visualize/tube_mesh.py b/fullcontrol/visualize/tube_mesh.py index e42ae49..3d6bf14 100644 --- a/fullcontrol/visualize/tube_mesh.py +++ b/fullcontrol/visualize/tube_mesh.py @@ -578,7 +578,7 @@ def to_Mesh3d( path_colors = colors colors = np.empty((len(colors)+len(self._sharp_doubles),1), dtype=object) colors[0] = path_colors[0] - colors[1:] = self._duplicate_sharp_corner_rows(path_colors) + colors[1:] = self._duplicate_sharp_corner_rows(path_colors[1:]) if not isinstance(colors, str): colors = colors.flatten()