diff --git a/scripts/addons/cam/async_op.py b/scripts/addons/cam/async_op.py index cdba9000..fd761965 100644 --- a/scripts/addons/cam/async_op.py +++ b/scripts/addons/cam/async_op.py @@ -12,7 +12,23 @@ import bpy @types.coroutine def progress_async(text, n=None, value_type='%'): - """Function for Reporting During the Script, Works for Background Operations in the Header.""" + """Report progress during script execution for background operations. + + This function is designed to provide progress updates while a script is + running, particularly for background operations. It yields a dictionary + containing the progress information, which includes the text description + of the progress, an optional numeric value, and the type of value being + reported. If an exception is thrown during the operation, it will be + raised for handling. + + Args: + text (str): A message indicating the current progress. + n (optional): An optional numeric value representing the progress. + value_type (str?): A string indicating the type of value being reported (default is '%'). + + Raises: + Exception: If an exception is thrown during the operation. + """ throw_exception = yield ('progress', {'text': text, 'n': n, "value_type": value_type}) if throw_exception is not None: raise throw_exception @@ -30,6 +46,22 @@ class AsyncOperatorMixin: self._is_cancelled = False def modal(self, context, event): + """Handle modal operations for a Blender event. + + This function processes events in a modal operator. It checks for + specific event types, such as TIMER and ESC, and performs actions + accordingly. If the event type is TIMER, it attempts to execute a tick + function, managing the timer and status text in the Blender workspace. + If an exception occurs during the tick execution, it handles the error + gracefully by removing the timer and reporting the error. The function + also allows for cancellation of the operation when the ESC key is + pressed. + + Args: + context (bpy.context): The current Blender context. + event (bpy.types.Event): The event being processed. + """ + if bpy.app.background: return {'PASS_THROUGH'} @@ -58,6 +90,24 @@ class AsyncOperatorMixin: return {'PASS_THROUGH'} def show_progress(self, context, text, n, value_type): + """Display the progress of a task in the workspace and console. + + This function updates the status text in the Blender workspace to show + the current progress of a task. It formats the progress message based on + the provided parameters and outputs it to both the Blender interface and + the standard output. If the value of `n` is not None, it includes the + formatted number and value type in the progress message; otherwise, it + simply displays the provided text. + + Args: + context: The context in which the progress is displayed (typically + the Blender context). + text (str): A message indicating the task being performed. + n (float or None): The current progress value to be displayed. + value_type (str): A string representing the type of value (e.g., + percentage, units). + """ + if n is not None: progress_text = f"{text}: {n:.2f}{value_type}" else: @@ -67,6 +117,27 @@ class AsyncOperatorMixin: sys.stdout.flush() def tick(self, context): + """Execute a tick of the coroutine and handle its progress. + + This method checks if the coroutine is initialized; if not, it + initializes it by calling `execute_async` with the provided context. It + then attempts to send a signal to the coroutine to either continue its + execution or handle cancellation. If the coroutine is cancelled, it + raises a `StopIteration` exception. The method also processes messages + from the coroutine, displaying progress or other messages as needed. + + Args: + context: The context in which the coroutine is executed. + + Returns: + bool: True if the tick was processed successfully, False if the coroutine has + completed. + + Raises: + StopIteration: If the coroutine has completed its execution. + Exception: If an unexpected error occurs during the execution of the tick. + """ + if self.coroutine == None: self.coroutine = self.execute_async(context) try: @@ -86,6 +157,21 @@ class AsyncOperatorMixin: print("Exception Thrown in Tick:", e) def execute(self, context): + """Execute the modal operation based on the context. + + This function checks if the application is running in the background. If + it is, it continuously ticks until the operation is complete. If not, it + sets up a timer for the modal operation and adds the modal handler to + the window manager, allowing the operation to run in a modal state. + + Args: + context (bpy.types.Context): The context in which the operation is executed. + + Returns: + dict: A dictionary indicating the status of the operation, either + {'FINISHED'} if completed or {'RUNNING_MODAL'} if running in modal. + """ + if bpy.app.background: # running in background - don't run as modal, # otherwise tests all fail @@ -105,6 +191,17 @@ class AsyncTestOperator(bpy.types.Operator, AsyncOperatorMixin): bl_options = {'REGISTER', 'UNDO', 'BLOCKING'} async def execute_async(self, context): + """Execute an asynchronous operation with a progress indicator. + + This function runs a loop 100 times, calling an asynchronous function to + report progress for each iteration. It is designed to be used in an + asynchronous context where the progress of a task needs to be tracked + and reported. + + Args: + context: The context in which the asynchronous operation is executed. + """ + for x in range(100): await progress_async("Async test:", x) diff --git a/scripts/addons/cam/basrelief.py b/scripts/addons/cam/basrelief.py index 3a8d6afa..914679b3 100644 --- a/scripts/addons/cam/basrelief.py +++ b/scripts/addons/cam/basrelief.py @@ -47,7 +47,26 @@ def copy_compbuf_data(inbuf, outbuf): outbuf[:] = inbuf[:] -def restrictbuf(inbuf, outbuf): # scale down array.... +def restrictbuf(inbuf, outbuf): + """Restrict the resolution of an input buffer to match an output buffer. + + This function scales down the input buffer `inbuf` to fit the dimensions + of the output buffer `outbuf`. It computes the average of the + neighboring pixels in the input buffer to create a downsampled version + in the output buffer. The method used for downsampling can vary based on + the dimensions of the input and output buffers, utilizing either a + simple averaging method or a more complex numpy-based approach. + + Args: + inbuf (numpy.ndarray): The input buffer to be downsampled, expected to be + a 2D array. + outbuf (numpy.ndarray): The output buffer where the downsampled result will + be stored, also expected to be a 2D array. + + Returns: + None: The function modifies `outbuf` in place. + """ + # scale down array.... inx = inbuf.shape[0] iny = inbuf.shape[1] @@ -132,6 +151,21 @@ def restrictbuf(inbuf, outbuf): # scale down array.... def prolongate(inbuf, outbuf): + """Prolongate an input buffer to a larger output buffer. + + This function takes an input buffer and enlarges it to fit the + dimensions of the output buffer. It uses different methods to achieve + this based on the scaling factors derived from the input and output + dimensions. The function can handle specific cases where the scaling + factors are exactly 0.5, as well as a general case that applies a + bilinear interpolation technique for resizing. + + Args: + inbuf (numpy.ndarray): The input buffer to be enlarged, expected to be a 2D array. + outbuf (numpy.ndarray): The output buffer where the enlarged data will be stored, + expected to be a 2D array of larger dimensions than inbuf. + """ + inx = inbuf.shape[0] iny = inbuf.shape[1] @@ -221,6 +255,24 @@ def idx(r, c, cols): # smooth u using f at level def smooth(U, F, linbcgiterations, planar): + """Smooth a matrix U using a filter F at a specified level. + + This function applies a smoothing operation on the input matrix U using + the filter F. It utilizes the linear Biconjugate Gradient method for the + smoothing process. The number of iterations for the linear BCG method is + specified by linbcgiterations, and the planar parameter indicates + whether the operation is to be performed in a planar manner. + + Args: + U (numpy.ndarray): The input matrix to be smoothed. + F (numpy.ndarray): The filter used for smoothing. + linbcgiterations (int): The number of iterations for the linear BCG method. + planar (bool): A flag indicating whether to perform the operation in a planar manner. + + Returns: + None: This function modifies the input matrix U in place. + """ + iter = 0 err = 0 @@ -234,6 +286,24 @@ def smooth(U, F, linbcgiterations, planar): def calculate_defect(D, U, F): + """Calculate the defect of a grid based on the input fields. + + This function computes the defect values for a grid by comparing the + input field `F` with the values in the grid `U`. The defect is + calculated using finite difference approximations, taking into account + the neighboring values in the grid. The results are stored in the output + array `D`, which is modified in place. + + Args: + D (ndarray): A 2D array where the defect values will be stored. + U (ndarray): A 2D array representing the current state of the grid. + F (ndarray): A 2D array representing the target field to compare against. + + Returns: + None: The function modifies the array `D` in place and does not return a + value. + """ + sx = F.shape[0] sy = F.shape[1] @@ -277,6 +347,40 @@ def add_correction(U, C): def solve_pde_multigrid(F, U, vcycleiterations, linbcgiterations, smoothiterations, mins, levels, useplanar, planar): + """Solve a partial differential equation using a multigrid method. + + This function implements a multigrid algorithm to solve a given partial + differential equation (PDE). It operates on a grid of varying + resolutions, applying smoothing and correction steps iteratively to + converge towards the solution. The algorithm consists of several key + phases: restriction of the right-hand side to coarser grids, solving on + the coarsest grid, and then interpolating corrections back to finer + grids. The process is repeated for a specified number of V-cycle + iterations. + + Args: + F (numpy.ndarray): The right-hand side of the PDE represented as a 2D array. + U (numpy.ndarray): The initial guess for the solution, which will be updated in place. + vcycleiterations (int): The number of V-cycle iterations to perform. + linbcgiterations (int): The number of iterations for the linear solver used in smoothing. + smoothiterations (int): The number of smoothing iterations to apply at each level. + mins (int): Minimum grid size (not used in the current implementation). + levels (int): The number of levels in the multigrid hierarchy. + useplanar (bool): A flag indicating whether to use planar information during the solution + process. + planar (numpy.ndarray): A 2D array indicating planar information for the grid. + + Returns: + None: The function modifies the input array U in place to contain the final + solution. + + Note: + The function assumes that the input arrays F and U have compatible + shapes + and that the planar array is appropriately defined for the problem + context. + """ + xmax = F.shape[0] ymax = F.shape[1] @@ -422,6 +526,23 @@ def asolve(b, x): def atimes(x, res): + """Apply a discrete Laplacian operator to a 2D array. + + This function computes the discrete Laplacian of a given 2D array `x` + and stores the result in the `res` array. The Laplacian is calculated + using finite difference methods, which involve summing the values of + neighboring elements and applying specific boundary conditions for the + edges and corners of the array. + + Args: + x (numpy.ndarray): A 2D array representing the input values. + res (numpy.ndarray): A 2D array where the result will be stored. It must have the same shape + as `x`. + + Returns: + None: The result is stored directly in the `res` array. + """ + res[1:-1, 1:-1] = x[:-2, 1:-1]+x[2:, 1:-1] + \ x[1:-1, :-2]+x[1:-1, 2:] - 4*x[1:-1, 1:-1] # sides @@ -437,6 +558,26 @@ def atimes(x, res): def snrm(n, sx, itol): + """Calculate the square root of the sum of squares or the maximum absolute + value. + + This function computes a value based on the input parameters. If the + tolerance level (itol) is less than or equal to 3, it calculates the + square root of the sum of squares of the input array (sx). If the + tolerance level is greater than 3, it returns the maximum absolute value + from the input array. + + Args: + n (int): An integer parameter, though it is not used in the current + implementation. + sx (numpy.ndarray): A numpy array of numeric values. + itol (int): An integer that determines which calculation to perform. + + Returns: + float: The square root of the sum of squares if itol <= 3, otherwise the + maximum absolute value. + """ + if (itol <= 3): temp = sx*sx @@ -453,6 +594,32 @@ def snrm(n, sx, itol): def linbcg(n, b, x, itol, tol, itmax, iter, err, rows, cols, planar): + """Solve a linear system using the Biconjugate Gradient Method. + + This function implements the Biconjugate Gradient Method as described in + Numerical Recipes in C. It iteratively refines the solution to a linear + system of equations defined by the matrix-vector product. The method is + particularly useful for large, sparse systems where direct methods are + inefficient. The function takes various parameters to control the + iteration process and convergence criteria. + + Args: + n (int): The size of the linear system. + b (numpy.ndarray): The right-hand side vector of the linear system. + x (numpy.ndarray): The initial guess for the solution vector. + itol (int): The type of norm to use for convergence checks. + tol (float): The tolerance for convergence. + itmax (int): The maximum number of iterations allowed. + iter (int): The current iteration count (should be initialized to 0). + err (float): The error estimate (should be initialized). + rows (int): The number of rows in the matrix. + cols (int): The number of columns in the matrix. + planar (bool): A flag indicating if the problem is planar. + + Returns: + None: The solution is stored in the input array `x`. + """ + p = numpy.zeros((cols, rows)) pp = numpy.zeros((cols, rows)) @@ -548,6 +715,18 @@ def linbcg(n, b, x, itol, tol, itmax, iter, err, rows, cols, planar): def numpysave(a, iname): + """Save a NumPy array as an image file in OpenEXR format. + + This function takes a NumPy array and saves it as an image file using + Blender's rendering capabilities. It configures the image settings to + use the OpenEXR format with black and white color mode and a color depth + of 32 bits. The rendered image is saved to the specified filename. + + Args: + a (numpy.ndarray): The NumPy array to be saved as an image. + iname (str): The filename (including path) where the image will be saved. + """ + inamebase = bpy.path.basename(iname) i = numpytoimage(a, inamebase) @@ -562,6 +741,24 @@ def numpysave(a, iname): def numpytoimage(a, iname): + """Convert a NumPy array to a Blender image. + + This function takes a NumPy array and converts it into a Blender image. + It first checks if an image with the specified name and dimensions + already exists in Blender. If it does, that image is used; otherwise, a + new image is created with the specified name and dimensions. The + function then reshapes the NumPy array to match the image format and + assigns the pixel data to the image. + + Args: + a (numpy.ndarray): A 2D NumPy array representing the pixel data of the image. + iname (str): The name to assign to the Blender image. + + Returns: + bpy.types.Image: The Blender image created or modified with the pixel data from the NumPy + array. + """ + t = time.time() print('Numpy to Image - Here') t = time.time() @@ -592,6 +789,25 @@ def numpytoimage(a, iname): def imagetonumpy(i): + """Convert an image to a NumPy array. + + This function takes an image object and converts its pixel data into a + NumPy array. It first retrieves the pixel data from the image, then + reshapes and rearranges it to match the image's dimensions. The + resulting array is structured such that the height and width of the + image are preserved, and the color channels are appropriately ordered. + + Args: + i (Image): An image object that contains pixel data. + + Returns: + numpy.ndarray: A 2D NumPy array representing the pixel data of the image. + + Note: + The function optimizes performance by directly accessing pixel data + instead of using slower methods. + """ + t = time.time() inc = 0 @@ -618,6 +834,23 @@ def imagetonumpy(i): def tonemap(i, exponent): + """Apply tone mapping to an image array. + + This function performs tone mapping on the input image array by first + filtering out values that are excessively high, which may indicate that + the depth buffer was not written correctly. It then normalizes the + values between the minimum and maximum heights, and finally applies an + exponentiation to adjust the brightness of the image. + + Args: + i (numpy.ndarray): A numpy array representing the image data. + exponent (float): The exponent used for adjusting the brightness + of the normalized image. + + Returns: + None: The function modifies the input array in place. + """ + # if depth buffer never got written it gets set # to a great big value (10000000000.0) # filter out anything within an order of magnitude of it @@ -631,11 +864,43 @@ def tonemap(i, exponent): def vert(column, row, z, XYscaling, Zscaling): - """ Create a Single Vert """ + """Create a single vertex in 3D space. + + This function calculates the 3D coordinates of a vertex based on the + provided column and row values, as well as scaling factors for the X-Y + and Z dimensions. The resulting coordinates are scaled accordingly to + fit within a specified 3D space. + + Args: + column (float): The column value representing the X coordinate. + row (float): The row value representing the Y coordinate. + z (float): The Z coordinate value. + XYscaling (float): The scaling factor for the X and Y coordinates. + Zscaling (float): The scaling factor for the Z coordinate. + + Returns: + tuple: A tuple containing the scaled X, Y, and Z coordinates. + """ return column * XYscaling, row * XYscaling, z * Zscaling def buildMesh(mesh_z, br): + """Build a 3D mesh from a height map and apply transformations. + + This function constructs a 3D mesh based on the provided height map + (mesh_z) and applies various transformations such as scaling and + positioning based on the parameters defined in the br object. It first + removes any existing BasReliefMesh objects from the scene, then creates + a new mesh from the height data, and finally applies decimation if the + specified ratio is within acceptable limits. + + Args: + mesh_z (numpy.ndarray): A 2D array representing the height values + for the mesh vertices. + br (object): An object containing properties for width, height, + thickness, justification, and decimation ratio. + """ + global rows global size scale = 1 @@ -710,6 +975,26 @@ def buildMesh(mesh_z, br): def renderScene(width, height, bit_diameter, passes_per_radius, make_nodes, view_layer): + """Render a scene using Blender's Cycles engine. + + This function switches the rendering engine to Cycles, sets up the + necessary nodes for depth rendering if specified, and configures the + render resolution based on the provided parameters. It ensures that the + scene is in object mode before rendering and restores the original + rendering engine after the process is complete. + + Args: + width (int): The width of the render in pixels. + height (int): The height of the render in pixels. + bit_diameter (float): The diameter used to calculate the number of passes. + passes_per_radius (int): The number of passes per radius for rendering. + make_nodes (bool): A flag indicating whether to create render nodes. + view_layer (str): The name of the view layer to be rendered. + + Returns: + None: This function does not return any value. + """ + print("Rendering Scene") scene = bpy.context.scene # make sure we're in object mode or else bad things happen @@ -755,6 +1040,47 @@ def renderScene(width, height, bit_diameter, passes_per_radius, make_nodes, view def problemAreas(br): + """Process image data to identify problem areas based on silhouette + thresholds. + + This function analyzes an image and computes gradients to detect and + recover silhouettes based on specified parameters. It utilizes various + settings from the provided `br` object to adjust the processing, + including silhouette thresholds, scaling factors, and iterations for + smoothing and recovery. The function also handles image scaling and + applies a gradient mask if specified. The resulting data is then + converted back into an image format for further use. + + Args: + br (object): An object containing various parameters for processing, including: + - use_image_source (bool): Flag to determine if a specific image source + should be used. + - source_image_name (str): Name of the source image if + `use_image_source` is True. + - silhouette_threshold (float): Threshold for silhouette detection. + - recover_silhouettes (bool): Flag to indicate if silhouettes should be + recovered. + - silhouette_scale (float): Scaling factor for silhouette recovery. + - min_gridsize (int): Minimum grid size for processing. + - smooth_iterations (int): Number of iterations for smoothing. + - vcycle_iterations (int): Number of iterations for V-cycle processing. + - linbcg_iterations (int): Number of iterations for linear BCG + processing. + - use_planar (bool): Flag to indicate if planar processing should be + used. + - gradient_scaling_mask_use (bool): Flag to indicate if a gradient + scaling mask should be used. + - gradient_scaling_mask_name (str): Name of the gradient scaling mask + image. + - depth_exponent (float): Exponent for depth adjustment. + - silhouette_exponent (int): Exponent for silhouette recovery. + - attenuation (float): Attenuation factor for processing. + + Returns: + None: The function does not return a value but processes the image data and + saves the result. + """ + t = time.time() if br.use_image_source: i = bpy.data.images[br.source_image_name] @@ -841,6 +1167,50 @@ def problemAreas(br): def relief(br): + """Process an image to enhance relief features. + + This function takes an input image and applies various processing + techniques to enhance the relief features based on the provided + parameters. It utilizes gradient calculations, silhouette recovery, and + optional detail enhancement through Fourier transforms. The processed + image is then used to build a mesh representation. + + Args: + br (object): An object containing various parameters for the relief processing, + including: + - use_image_source (bool): Whether to use a specified image source. + - source_image_name (str): The name of the source image. + - silhouette_threshold (float): Threshold for silhouette detection. + - recover_silhouettes (bool): Flag to indicate if silhouettes should be + recovered. + - silhouette_scale (float): Scale factor for silhouette recovery. + - min_gridsize (int): Minimum grid size for processing. + - smooth_iterations (int): Number of iterations for smoothing. + - vcycle_iterations (int): Number of iterations for V-cycle processing. + - linbcg_iterations (int): Number of iterations for linear BCG + processing. + - use_planar (bool): Flag to indicate if planar processing should be + used. + - gradient_scaling_mask_use (bool): Flag to indicate if a gradient + scaling mask should be used. + - gradient_scaling_mask_name (str): Name of the gradient scaling mask + image. + - depth_exponent (float): Exponent for depth adjustment. + - attenuation (float): Attenuation factor for the processing. + - detail_enhancement_use (bool): Flag to indicate if detail enhancement + should be applied. + - detail_enhancement_freq (float): Frequency for detail enhancement. + - detail_enhancement_amount (float): Amount of detail enhancement to + apply. + + Returns: + None: The function processes the image and builds a mesh but does not return a + value. + + Raises: + ReliefError: If the input image is blank or invalid. + """ + t = time.time() if br.use_image_source: @@ -1223,10 +1593,41 @@ class BASRELIEF_Panel(bpy.types.Panel): # self.layout.menu("CAM_CUTTER_MT_presets", text="CAM Cutter") @classmethod def poll(cls, context): + """Check if the current render engine is compatible. + + This class method checks whether the render engine specified in the + provided context is included in the list of compatible engines. It + accesses the render settings from the context and verifies if the engine + is part of the predefined compatible engines. + + Args: + context (Context): The context containing the scene and render settings. + + Returns: + bool: True if the render engine is compatible, False otherwise. + """ + rd = context.scene.render return rd.engine in cls.COMPAT_ENGINES def draw(self, context): + """Draw the user interface for the bas relief settings. + + This method constructs the layout for the bas relief settings in the + Blender user interface. It includes various properties and options that + allow users to configure the bas relief calculations, such as selecting + images, adjusting parameters, and setting justification options. The + layout is dynamically updated based on user selections, providing a + comprehensive interface for manipulating bas relief settings. + + Args: + context (bpy.context): The context in which the UI is being drawn. + + Returns: + None: This method does not return any value; it modifies the layout + directly. + """ + layout = self.layout # print(dir(layout)) s = bpy.context.scene @@ -1307,6 +1708,21 @@ class DoBasRelief(bpy.types.Operator): processes = [] def execute(self, context): + """Execute the relief rendering process based on the provided context. + + This function retrieves the scene and its associated bas relief + settings. It checks if an image source is being used and sets the view + layer name accordingly. The function then attempts to render the scene + and generate the relief. If any errors occur during these processes, + they are reported, and the operation is canceled. + + Args: + context: The context in which the function is executed. + + Returns: + dict: A dictionary indicating the result of the operation, either + """ + s = bpy.context.scene br = s.basreliefsettings if not br.use_image_source and br.view_layer_name == "": @@ -1340,6 +1756,23 @@ class ProblemAreas(bpy.types.Operator): # return context.active_object is not None def execute(self, context): + """Execute the operation related to the bas relief settings in the current + scene. + + This method retrieves the current scene from the Blender context and + accesses the bas relief settings. It then calls the `problemAreas` + function to perform operations related to those settings. The method + concludes by returning a status indicating that the operation has + finished successfully. + + Args: + context (bpy.context): The current Blender context, which provides access + + Returns: + dict: A dictionary with a status key indicating the operation result, + specifically {'FINISHED'}. + """ + s = bpy.context.scene br = s.basreliefsettings problemAreas(br) @@ -1347,6 +1780,19 @@ class ProblemAreas(bpy.types.Operator): def get_panels(): + """Retrieve a tuple of panel settings and related components. + + This function returns a tuple containing various components related to + Bas Relief settings. The components include BasReliefsettings, + BASRELIEF_Panel, DoBasRelief, and ProblemAreas, which are likely used in + the context of a graphical user interface or a specific application + domain. + + Returns: + tuple: A tuple containing the BasReliefsettings, BASRELIEF_Panel, + DoBasRelief, and ProblemAreas components. + """ + return( BasReliefsettings, BASRELIEF_Panel, @@ -1356,6 +1802,17 @@ def get_panels(): def register(): + """Register the necessary classes and properties for the add-on. + + This function registers all the panels defined in the add-on by + iterating through the list of panels returned by the `get_panels()` + function. It also adds a new property, `basreliefsettings`, to the + `Scene` type, which is a pointer property that references the + `BasReliefsettings` class. This setup is essential for the proper + functioning of the add-on, allowing users to access and modify the + settings related to bas relief. + """ + for p in get_panels(): bpy.utils.register_class(p) s = bpy.types.Scene @@ -1365,6 +1822,15 @@ def register(): def unregister(): + """Unregister all panels and remove basreliefsettings from the Scene type. + + This function iterates through all registered panels and unregisters + each one using Blender's utility functions. Additionally, it removes the + basreliefsettings attribute from the Scene type, ensuring that any + settings related to bas relief are no longer accessible in the current + Blender session. + """ + for p in get_panels(): bpy.utils.unregister_class(p) s = bpy.types.Scene diff --git a/scripts/addons/cam/bridges.py b/scripts/addons/cam/bridges.py index 026e50e1..28c5d915 100644 --- a/scripts/addons/cam/bridges.py +++ b/scripts/addons/cam/bridges.py @@ -22,6 +22,25 @@ from . import simple def addBridge(x, y, rot, sizex, sizey): + """Add a bridge mesh object to the scene. + + This function creates a bridge by adding a primitive plane to the + Blender scene, adjusting its dimensions, and then converting it into a + curve. The bridge is positioned based on the provided coordinates and + rotation. The size of the bridge is determined by the `sizex` and + `sizey` parameters. + + Args: + x (float): The x-coordinate for the bridge's location. + y (float): The y-coordinate for the bridge's location. + rot (float): The rotation angle around the z-axis in radians. + sizex (float): The width of the bridge. + sizey (float): The height of the bridge. + + Returns: + bpy.types.Object: The created bridge object. + """ + bpy.ops.mesh.primitive_plane_add(size=sizey*2, calc_uvs=True, enter_editmode=False, align='WORLD', location=(0, 0, 0), rotation=(0, 0, 0)) b = bpy.context.active_object @@ -43,7 +62,25 @@ def addBridge(x, y, rot, sizex, sizey): def addAutoBridges(o): - """Attempt to Add Auto Bridges as Set of Curves""" + """Attempt to add auto bridges as a set of curves. + + This function creates a collection of bridges based on the provided + object. It checks if a collection for bridges already exists; if not, it + creates a new one. The function then iterates through the objects in the + input object, processing curves and meshes to generate bridge + geometries. For each geometry, it calculates the necessary points and + adds bridges at various orientations based on the geometry's bounds. + + Args: + o (object): An object containing properties such as + bridges_collection_name, bridges_width, and cutter_diameter, + along with a list of objects to process. + + Returns: + None: This function does not return a value but modifies the + Blender context by adding bridge objects to the specified + collection. + """ utils.getOperationSources(o) bridgecollectionname = o.bridges_collection_name if bridgecollectionname == '' or bpy.data.collections.get(bridgecollectionname) is None: @@ -84,6 +121,20 @@ def addAutoBridges(o): def getBridgesPoly(o): + """Generate and prepare bridge polygons from a Blender object. + + This function checks if the provided object has an attribute for bridge + polygons. If not, it retrieves the bridge collection, selects all curve + objects within that collection, duplicates them, and joins them into a + single object. The resulting shape is then converted to a Shapely + geometry. The function buffers the resulting polygon to account for the + cutter diameter and prepares the boundary and polygon for further + processing. + + Args: + o (object): An object containing properties related to bridge + """ + if not hasattr(o, 'bridgespolyorig'): bridgecollectionname = o.bridges_collection_name bridgecollection = bpy.data.collections[bridgecollectionname] @@ -109,7 +160,25 @@ def getBridgesPoly(o): def useBridges(ch, o): - """This Adds Bridges to Chunks, Takes the Bridge-objects Collection and Uses the Curves Inside It as Bridges.""" + """Add bridges to chunks using a collection of bridge objects. + + This function takes a collection of bridge objects and uses the curves + within it to create bridges over the specified chunks. It calculates the + necessary points for the bridges based on the height and geometry of the + chunks and the bridge objects. The function also handles intersections + with the bridge polygon and adjusts the points accordingly. Finally, it + generates a mesh for the bridges and converts it into a curve object in + Blender. + + Args: + ch (Chunk): The chunk object to which bridges will be added. + o (ObjectOptions): An object containing options such as bridge height, + collection name, and other parameters. + + Returns: + None: The function modifies the chunk object in place and does not return a + value. + """ bridgecollectionname = o.bridges_collection_name bridgecollection = bpy.data.collections[bridgecollectionname] if len(bridgecollection.objects) > 0: @@ -263,6 +332,21 @@ def useBridges(ch, o): def auto_cut_bridge(o): + """Automatically processes a bridge collection. + + This function retrieves a bridge collection by its name from the + provided object and checks if there are any objects within that + collection. If there are objects present, it prints "bridges" to the + console. This function is useful for managing and processing bridge + collections in a 3D environment. + + Args: + o (object): An object that contains the attribute + + Returns: + None: This function does not return any value. + """ + bridgecollectionname = o.bridges_collection_name bridgecollection = bpy.data.collections[bridgecollectionname] if len(bridgecollection.objects) > 0: diff --git a/scripts/addons/cam/collision.py b/scripts/addons/cam/collision.py index dafd4656..3f360180 100644 --- a/scripts/addons/cam/collision.py +++ b/scripts/addons/cam/collision.py @@ -30,8 +30,21 @@ from .simple import ( def getCutterBullet(o): - """Cutter for Rigidbody Simulation Collisions - Note that Everything Is 100x Bigger for Simulation Precision.""" + """Create a cutter for Rigidbody simulation collisions. + + This function generates a 3D cutter object based on the specified cutter + type and parameters. It supports various cutter types including 'END', + 'BALLNOSE', 'VCARVE', 'CYLCONE', 'BALLCONE', and 'CUSTOM'. The function + also applies rigid body physics to the created cutter for realistic + simulation in Blender. + + Args: + o (object): An object containing properties such as cutter_type, cutter_diameter, + cutter_tip_angle, ball_radius, and cutter_object_name. + + Returns: + bpy.types.Object: The created cutter object with rigid body properties applied. + """ s = bpy.context.scene if s.objects.get('cutter') is not None: @@ -161,6 +174,21 @@ def getCutterBullet(o): def subdivideLongEdges(ob, threshold): + """Subdivide edges of a mesh object that exceed a specified length. + + This function iteratively checks the edges of a given mesh object and + subdivides those that are longer than a specified threshold. The process + involves toggling the edit mode of the object, selecting the long edges, + and applying a subdivision operation. The function continues to + subdivide until no edges exceed the threshold. + + Args: + ob (bpy.types.Object): The Blender object containing the mesh to be + subdivided. + threshold (float): The length threshold above which edges will be + subdivided. + """ + print('Subdividing Long Edges') m = ob.data scale = (ob.scale.x + ob.scale.y + ob.scale.z) / 3 @@ -205,7 +233,21 @@ def subdivideLongEdges(ob, threshold): # def prepareBulletCollision(o): - """Prepares All Objects Needed for Sampling with Bullet Collision""" + """Prepares all objects needed for sampling with Bullet collision. + + This function sets up the Bullet physics simulation by preparing the + specified objects for collision detection. It begins by cleaning up any + existing rigid bodies that are not part of the 'machine' object. Then, + it duplicates the collision objects, converts them to mesh if they are + curves or fonts, and applies necessary modifiers. The function also + handles the subdivision of long edges and configures the rigid body + properties for each object. Finally, it scales the 'machine' objects to + the simulation scale and steps through the simulation frames to ensure + that all objects are up to date. + + Args: + o (Object): An object containing properties and settings for + """ progress('Preparing Collisions') print(o.name) @@ -283,6 +325,22 @@ def prepareBulletCollision(o): def cleanupBulletCollision(o): + """Clean up bullet collision objects in the scene. + + This function checks for the presence of a 'machine' object in the + Blender scene and removes any rigid body objects that are not part of + the 'machine'. If the 'machine' object is present, it scales the machine + objects up to the simulation scale and adjusts their locations + accordingly. + + Args: + o: An object that may be used in the cleanup process (specific usage not + detailed). + + Returns: + None: This function does not return a value. + """ + if bpy.data.objects.find('machine') > -1: machinepresent = True else: @@ -303,7 +361,27 @@ def cleanupBulletCollision(o): def getSampleBullet(cutter, x, y, radius, startz, endz): - """Collision Test for 3 Axis Milling. Is Simplified Compared to the Full 3D Test""" + """Perform a collision test for a 3-axis milling cutter. + + This function simplifies the collision detection process compared to a + full 3D test. It utilizes the Blender Python API to perform a convex + sweep test on the cutter's position within a specified 3D space. The + function checks for collisions between the cutter and other objects in + the scene, adjusting for the cutter's radius to determine the effective + position of the cutter tip. + + Args: + cutter (object): The milling cutter object used for the collision test. + x (float): The x-coordinate of the cutter's position. + y (float): The y-coordinate of the cutter's position. + radius (float): The radius of the cutter, used to adjust the collision detection. + startz (float): The starting z-coordinate for the collision test. + endz (float): The ending z-coordinate for the collision test. + + Returns: + float: The adjusted z-coordinate of the cutter tip if a collision is detected; + otherwise, returns a value 10 units below the specified endz. + """ scene = bpy.context.scene pos = scene.rigidbody_world.convex_sweep_test(cutter, (x * BULLET_SCALE, y * BULLET_SCALE, startz * BULLET_SCALE), (x * BULLET_SCALE, y * BULLET_SCALE, endz * BULLET_SCALE)) @@ -317,7 +395,27 @@ def getSampleBullet(cutter, x, y, radius, startz, endz): def getSampleBulletNAxis(cutter, startpoint, endpoint, rotation, cutter_compensation): - """Fully 3D Collision Test for N-Axis Milling""" + """Perform a fully 3D collision test for N-Axis milling. + + This function computes the collision detection between a cutter and a + specified path in a 3D space. It takes into account the cutter's + rotation and compensation to accurately determine if a collision occurs + during the milling process. The function uses Bullet physics for the + collision detection and returns the adjusted position of the cutter if a + collision is detected. + + Args: + cutter (object): The cutter object used in the milling operation. + startpoint (Vector): The starting point of the milling path. + endpoint (Vector): The ending point of the milling path. + rotation (Euler): The rotation applied to the cutter. + cutter_compensation (float): The compensation factor for the cutter's position. + + Returns: + Vector or None: The adjusted position of the cutter if a collision is + detected; + otherwise, returns None. + """ cutterVec = Vector((0, 0, 1)) * cutter_compensation # cutter compensation vector - cutter physics object has center in the middle, while cam needs the tip position. cutterVec.rotate(Euler(rotation)) diff --git a/scripts/addons/cam/curvecamcreate.py b/scripts/addons/cam/curvecamcreate.py index 848f470d..d7e489e3 100644 --- a/scripts/addons/cam/curvecamcreate.py +++ b/scripts/addons/cam/curvecamcreate.py @@ -104,6 +104,19 @@ class CamCurveHatch(Operator): return context.active_object is not None and context.active_object.type in ['CURVE', 'FONT'] def draw(self, context): + """Draw the layout properties for the given context. + + This method sets up the user interface layout by adding various + properties such as angle, distance, offset, height, and pocket type. + Depending on the selected pocket type, it conditionally adds additional + properties like hull and contour. This allows for a dynamic and + customizable interface based on user selections. + + Args: + context: The context in which the layout is drawn, typically + provided by the calling environment. + """ + layout = self.layout layout.prop(self, 'angle') layout.prop(self, 'distance') @@ -123,6 +136,25 @@ class CamCurveHatch(Operator): layout.prop(self, 'contour') def execute(self, context): + """Execute the crosshatch generation process based on the provided context. + + This method performs a series of operations to create a crosshatch + pattern from the active object in the given context. It begins by + removing any existing crosshatch elements, setting the object's origin, + and determining its dimensions. Depending on the specified parameters, + it generates a convex hull, calculates the necessary coordinates for the + crosshatch lines, and applies transformations such as rotation and + translation. The method also handles intersections with specified bounds + or curves and can create contours based on additional settings. + + Args: + context (bpy.context): The Blender context containing the active object + + Returns: + dict: A dictionary indicating the completion status of the operation, + typically {'FINISHED'}. + """ + simple.remove_multiple("crosshatch") ob = context.active_object ob.select_set(True) @@ -289,6 +321,18 @@ class CamCurvePlate(Operator): ) def draw(self, context): + """Draw the UI layout for plate properties. + + This method creates a user interface layout for configuring various + properties of a plate, including its type, dimensions, hole + specifications, and resolution. It dynamically adds properties to the + layout based on the selected plate type, allowing users to input + relevant parameters. + + Args: + context: The context in which the UI is being drawn. + """ + layout = self.layout layout.prop(self, 'plate_type') layout.prop(self, 'width') @@ -304,6 +348,23 @@ class CamCurvePlate(Operator): layout.prop(self, 'radius') def execute(self, context): + """Execute the creation of a plate based on specified parameters. + + This function generates a plate shape in Blender based on the defined + attributes such as width, height, radius, and plate type. It supports + different plate types including rounded, oval, cove, and bevel. The + function also handles the creation of holes in the plate if specified. + It utilizes Blender's curve operations to create the geometry and + applies various transformations to achieve the desired shape. + + Args: + context (bpy.context): The Blender context in which the operation is performed. + + Returns: + dict: A dictionary indicating the result of the operation, typically + {'FINISHED'} if successful. + """ + left = -self.width / 2 + self.radius bottom = -self.height / 2 + self.radius right = -left @@ -536,6 +597,25 @@ class CamCurveFlatCone(Operator): ) def execute(self, context): + """Execute the construction of a geometric shape in Blender. + + This method performs a series of operations to create a geometric shape + based on specified dimensions and parameters. It calculates various + dimensions needed for the shape, including height and angles, and then + uses Blender's operations to create segments, rectangles, and ellipses. + The function also handles the positioning and rotation of these shapes + within the 3D space of Blender. + + Args: + context: The context in which the operation is executed, typically containing + information about the current + scene and active objects in Blender. + + Returns: + dict: A dictionary indicating the completion status of the operation, + typically {'FINISHED'}. + """ + y = self.small_d / 2 z = self.large_d / 2 x = self.height @@ -654,6 +734,22 @@ class CamCurveMortise(Operator): return context.active_object is not None and (context.active_object.type in ['CURVE', 'FONT']) def execute(self, context): + """Execute the joinery process based on the provided context. + + This function performs a series of operations to duplicate the active + object, convert it to a mesh, and then process its geometry to create + joinery features. It extracts vertex coordinates, converts them into a + LineString data structure, and applies either variable or fixed finger + joinery based on the specified parameters. The function also handles the + creation of flexible sides and pockets if required. + + Args: + context (bpy.context): The context in which the operation is executed. + + Returns: + dict: A dictionary indicating the completion status of the operation. + """ + o1 = bpy.context.active_object bpy.context.object.data.resolution_u = 60 @@ -783,6 +879,25 @@ class CamCurveInterlock(Operator): ) def execute(self, context): + """Execute the joinery operation based on the selected objects in the + context. + + This function checks the selected objects in the provided context and + performs different operations depending on the type of the active + object. If the active object is a curve or font and there are selected + objects, it duplicates the object, converts it to a mesh, and processes + its vertices to create a LineString representation. The function then + calculates lengths and applies distributed interlock joinery based on + the specified parameters. If no valid objects are selected, it defaults + to a single interlock operation at the cursor's location. + + Args: + context (bpy.context): The context containing selected objects and active object. + + Returns: + dict: A dictionary indicating the operation's completion status. + """ + print(len(context.selected_objects), "selected object", context.selected_objects) if len(context.selected_objects) > 0 and (context.active_object.type in ['CURVE', 'FONT']): @@ -928,6 +1043,20 @@ class CamCurveDrawer(Operator): ) def draw(self, context): + """Draw the user interface properties for the object. + + This method is responsible for rendering the layout of various + properties related to the object's dimensions and specifications. It + adds properties such as depth, width, height, finger size, finger + tolerance, finger inset, drawer plate thickness, drawer hole diameter, + drawer hole offset, and overcut diameter to the layout. The overcut + diameter property is only added if the overcut option is enabled. + + Args: + context: The context in which the drawing occurs, typically containing + information about the current state and environment. + """ + layout = self.layout layout.prop(self, 'depth') layout.prop(self, 'width') @@ -943,6 +1072,25 @@ class CamCurveDrawer(Operator): layout.prop(self, 'overcut_diameter') def execute(self, context): + """Execute the drawer creation process in Blender. + + This method orchestrates the creation of a drawer by calculating the + necessary dimensions for the finger joints, creating the base plate, and + generating the drawer components such as the back, front, sides, and + bottom. It utilizes various helper functions to perform operations like + boolean differences and transformations to achieve the desired geometry. + The method also handles the placement of the drawer components in the 3D + space. + + Args: + context (bpy.context): The Blender context that provides access to the current scene and + objects. + + Returns: + dict: A dictionary indicating the completion status of the operation, + typically {'FINISHED'}. + """ + height_finger_amt = int(joinery.finger_amount( self.height, self.finger_size)) height_finger = (self.height + 0.0004) / height_finger_amt @@ -1271,6 +1419,24 @@ class CamCurvePuzzle(Operator): ) def draw(self, context): + """Draws the user interface layout for interlock type properties. + + This method is responsible for creating and displaying the layout of + various properties related to different interlock types in the user + interface. It dynamically adjusts the layout based on the selected + interlock type, allowing users to input relevant parameters such as + dimensions, tolerances, and other characteristics specific to the chosen + interlock type. + + Args: + context: The context in which the layout is being drawn, typically + provided by the user interface framework. + + Returns: + None: This method does not return any value; it modifies the layout + directly. + """ + layout = self.layout layout.prop(self, 'interlock_type') layout.label(text='Puzzle Joint Definition') @@ -1330,6 +1496,25 @@ class CamCurvePuzzle(Operator): layout.prop(self, 'overcut_diameter') def execute(self, context): + """Execute the puzzle joinery process based on the provided context. + + This method processes the selected objects in the given context to + perform various types of puzzle joinery operations. It first checks if + there are any selected objects and if the active object is a curve. If + so, it duplicates the object, applies transformations, and converts it + to a mesh. The method then extracts vertex coordinates and performs + different joinery operations based on the specified interlock type. + Supported interlock types include 'FINGER', 'JOINT', 'BAR', 'ARC', + 'CURVEBARCURVE', 'CURVEBAR', 'MULTIANGLE', 'T', 'CURVET', 'CORNER', + 'TILE', and 'OPENCURVE'. + + Args: + context (Context): The context containing selected objects and the active object. + + Returns: + dict: A dictionary indicating the completion status of the operation. + """ + curve_detected = False print(len(context.selected_objects), "selected object", context.selected_objects) @@ -1544,6 +1729,19 @@ class CamCurveGear(Operator): ) def draw(self, context): + """Draw the user interface properties for gear settings. + + This method sets up the layout for various gear parameters based on the + selected gear type. It dynamically adds properties to the layout for + different gear types, allowing users to input specific values for gear + design. The properties include gear type, tooth spacing, tooth amount, + hole diameter, pressure angle, and backlash. Additional properties are + displayed if the gear type is 'PINION' or 'RACK'. + + Args: + context: The context in which the layout is being drawn. + """ + layout = self.layout layout.prop(self, 'gear_type') layout.prop(self, 'tooth_spacing') @@ -1561,6 +1759,23 @@ class CamCurveGear(Operator): layout.prop(self, 'rack_tooth_per_hole') def execute(self, context): + """Execute the gear generation process based on the specified gear type. + + This method checks the type of gear to be generated (either 'PINION' or + 'RACK') and calls the appropriate function from the `involute_gear` + module to create the gear or rack with the specified parameters. The + parameters include tooth spacing, number of teeth, hole diameter, + pressure angle, clearance, backlash, rim size, hub diameter, and spoke + amount for pinion gears, and additional parameters for rack gears. + + Args: + context: The context in which the execution is taking place. + + Returns: + dict: A dictionary indicating that the operation has finished with a key + 'FINISHED'. + """ + if self.gear_type == 'PINION': involute_gear.gear(mm_per_tooth=self.tooth_spacing, number_of_teeth=self.tooth_amount, hole_diameter=self.hole_diameter, pressure_angle=self.pressure_angle, diff --git a/scripts/addons/cam/engine.py b/scripts/addons/cam/engine.py index ec92eba1..816ba42f 100644 --- a/scripts/addons/cam/engine.py +++ b/scripts/addons/cam/engine.py @@ -35,6 +35,20 @@ class CNCCAM_ENGINE(RenderEngine): def get_panels(): + """Retrieve a list of panels for the Blender UI. + + This function compiles a list of UI panels that are compatible with the + Blender rendering engine. It excludes certain predefined panels that are + not relevant for the current context. The function checks all subclasses + of the `bpy.types.Panel` and includes those that have the + `COMPAT_ENGINES` attribute set to include 'BLENDER_RENDER', provided + they are not in the exclusion list. + + Returns: + list: A list of panel classes that are compatible with the + Blender rendering engine, excluding specified panels. + """ + exclude_panels = { 'RENDER_PT_eevee_performance', 'RENDER_PT_opengl_sampling', diff --git a/scripts/addons/cam/gcodeimportparser.py b/scripts/addons/cam/gcodeimportparser.py index 643ce635..119fa03a 100644 --- a/scripts/addons/cam/gcodeimportparser.py +++ b/scripts/addons/cam/gcodeimportparser.py @@ -14,6 +14,24 @@ np.set_printoptions(suppress=True) # suppress scientific notation in subdivide def import_gcode(context, filepath): + """Import G-code data into the scene. + + This function reads G-code from a specified file and processes it + according to the settings defined in the context. It utilizes the + GcodeParser to parse the file and classify segments of the model. + Depending on the options set in the scene, it may subdivide the model + and draw it with or without layer splitting. The time taken for the + import process is printed to the console. + + Args: + context (Context): The context containing the scene and tool settings. + filepath (str): The path to the G-code file to be imported. + + Returns: + dict: A dictionary indicating the import status, typically + {'FINISHED'}. + """ + print("Running read_some_data...") scene = context.scene @@ -38,7 +56,28 @@ def import_gcode(context, filepath): return {'FINISHED'} -def segments_to_meshdata(segments): # edges only on extrusion +def segments_to_meshdata(segments): + """Convert a list of segments into mesh data consisting of vertices and + edges. + + This function processes a list of segment objects, extracting the + coordinates of vertices and defining edges based on the styles of the + segments. It identifies when to add vertices and edges based on whether + the segments are in 'extrude' or 'travel' styles. The resulting mesh + data can be used for 3D modeling or rendering applications. + + Args: + segments (list): A list of segment objects, each containing 'style' and + 'coords' attributes. + + Returns: + tuple: A tuple containing two elements: + - list: A list of vertices, where each vertex is represented as a + list of coordinates [X, Y, Z]. + - list: A list of edges, where each edge is represented as a list + of indices corresponding to the vertices. + """ + # edges only on extrusion segs = segments verts = [] edges = [] @@ -70,6 +109,31 @@ def segments_to_meshdata(segments): # edges only on extrusion def obj_from_pydata(name, verts, edges=None, close=True, collection_name=None): + """Create a Blender object from provided vertex and edge data. + + This function generates a mesh object in Blender using the specified + vertices and edges. If edges are not provided, it automatically creates + a chain of edges connecting the vertices. The function also allows for + the option to close the mesh by connecting the last vertex back to the + first. Additionally, it can place the created object into a specified + collection within the Blender scene. The object is scaled down to a + smaller size for better visibility in the Blender environment. + + Args: + name (str): The name of the object to be created. + verts (list): A list of vertex coordinates, where each vertex is represented as a + tuple of (x, y, z). + edges (list?): A list of edges defined by pairs of vertex indices. Defaults to None. + close (bool?): Whether to close the mesh by connecting the last vertex to the first. + Defaults to True. + collection_name (str?): The name of the collection to which the object should be added. Defaults + to None. + + Returns: + None: The function does not return a value; it creates an object in the + Blender scene. + """ + if edges is None: # join vertices into one uninterrupted chain of edges. edges = [[i, i + 1] for i in range(len(verts) - 1)] @@ -109,6 +173,21 @@ class GcodeParser: self.model = GcodeModel(self) def parseFile(self, path): + """Parse a G-code file and update the model. + + This function reads a G-code file line by line, increments a line + counter for each line, and processes each line using the `parseLine` + method. The function assumes that the file is well-formed and that each + line can be parsed without errors. After processing all lines, it + returns the updated model. + + Args: + path (str): The file path to the G-code file to be parsed. + + Returns: + model: The updated model after parsing the G-code file. + """ + # read the gcode file with open(path, 'r') as f: # init line counter @@ -124,6 +203,16 @@ class GcodeParser: return self.model def parseLine(self): + """Parse a line of G-code and execute the corresponding command. + + This method processes a line of G-code by stripping comments, cleaning + the command, and identifying the command code and its arguments. It + handles specific G-code commands and invokes the appropriate parsing + method if available. If the command is unsupported, it prints an error + message. The method also manages tool numbers and coordinates based on + the parsed command. + """ + # strip comments: bits = self.line.split(';', 1) if (len(bits) > 1): @@ -172,6 +261,22 @@ class GcodeParser: print("Unsupported gcode " + str(code)) def parseArgs(self, args): + """Parse command-line arguments into a dictionary. + + This function takes a string of arguments, splits it into individual + components, and maps each component's first character to its + corresponding numeric value. If a numeric value cannot be converted from + the string, it defaults to 1. The resulting dictionary contains the + first characters as keys and their associated numeric values as values. + + Args: + args (str): A string of space-separated arguments, where each argument + consists of a letter followed by a numeric value. + + Returns: + dict: A dictionary mapping each letter to its corresponding numeric value. + """ + dic = {} if args: bits = args.split() @@ -208,6 +313,20 @@ class GcodeParser: print("[WARN] Line %d: %s (Text:'%s')" % (self.lineNb, msg, self.line)) def error(self, msg): + """Log an error message and raise an exception. + + This method prints an error message to the console, including the line + number, the provided message, and the text associated with the error. + After logging the error, it raises a generic Exception with the same + message format. + + Args: + msg (str): The error message to be logged. + + Raises: + Exception: Always raises an Exception with the formatted error message. + """ + print("[ERROR] Line %d: %s (Text:'%s')" % (self.lineNb, msg, self.line)) raise Exception("[ERROR] Line %d: %s (Text:'%s')" % (self.lineNb, msg, self.line)) @@ -240,6 +359,21 @@ class GcodeModel: self.layers = [] def do_G1(self, args, type): + """Perform a rapid or controlled movement based on the provided arguments. + + This method updates the current coordinates based on the input + arguments, either in relative or absolute terms. It constructs a segment + representing the movement and adds it to the model if there are changes + in the XYZ coordinates. The function handles unknown axes by issuing a + warning and ensures that the segment is only added if there are actual + changes in position. + + Args: + args (dict): A dictionary containing movement parameters for each axis. + type (str): The type of movement (e.g., 'G0' for rapid move, 'G1' for controlled + move). + """ + # G0/G1: Rapid/Controlled move # clone previous coords coords = dict(self.relative) @@ -289,6 +423,20 @@ class GcodeModel: self.relative = coords def do_G92(self, args): + """Set the current position of the axes without moving. + + This method updates the current coordinates for the specified axes based + on the provided arguments. If no axes are mentioned, it sets all axes + (X, Y, Z) to zero. The method adjusts the offset values by transferring + the difference between the relative and specified values for each axis. + If an unknown axis is provided, a warning is issued. + + Args: + args (dict): A dictionary containing axis names as keys + (e.g., 'X', 'Y', 'Z') and their corresponding + position values as float. + """ + # G92: Set Position # this changes the current coords, without moving, so do not generate a segment @@ -305,6 +453,25 @@ class GcodeModel: self.warn("Unknown axis '%s'" % axis) def do_M163(self, args): + """Update the color settings for a specific segment based on given + parameters. + + This method modifies the color attributes of an object by updating the + CMYKW values for a specified segment. It first creates a new list from + the existing color attribute to avoid reference issues. The method then + extracts the index and weight from the provided arguments and updates + the color list accordingly. Additionally, it retrieves RGB values from + the last comment and applies them to the color list. + + Args: + args (dict): A dictionary containing the parameters for the operation. + - 'S' (int): The index of the segment to update. + - 'P' (float): The weight to set for the CMYKW color component. + + Returns: + None: This method does not return a value; it modifies the object's state. + """ + col = list( self.color) # list() creates new list, otherwise you just change reference and all segs have same color extr_idx = int(args['S']) # e.g. M163 S0 P1 @@ -332,6 +499,21 @@ class GcodeModel: self.parser.error(msg) def classifySegments(self): + """Classify segments into layers based on their coordinates and extrusion + style. + + This method processes a list of segments, determining their extrusion + style (travel, retract, restore, or extrude) based on the movement of + the coordinates and the state of the extruder. It organizes the segments + into layers, which are used for later rendering. The classification is + based on changes in the Z-coordinate and the extruder's position. The + function initializes the coordinates and iterates through each segment, + checking for movements in the X, Y, and Z directions. It identifies when + a new layer begins based on changes in the Z-coordinate and the + extruder's state. Segments are then grouped into layers for further + processing. Raises: None + """ + # start model at 0, act as prev_coords coords = { @@ -393,6 +575,25 @@ class GcodeModel: coords = seg.coords def subdivide(self, subd_threshold): + """Subdivide segments based on a specified threshold. + + This method processes a list of segments and subdivides them into + smaller segments if the distance between consecutive segments exceeds + the given threshold. The subdivision is performed by interpolating + points between the original segment's coordinates, ensuring that the + resulting segments maintain the original order and properties. This is + particularly useful for manipulating attributes such as color and + continuous deformation in graphical representations. + + Args: + subd_threshold (float): The distance threshold for subdividing segments. + Segments with a distance greater than this value + will be subdivided. + + Returns: + None: The method modifies the instance's segments attribute in place. + """ + # smart subdivide # divide edge if > subd_threshold # do it in parser to keep index order of vertex and travel/extrude info @@ -457,6 +658,19 @@ class GcodeModel: # create blender curve and vertex_info in text file(coords, style, color...) def draw(self, split_layers=False): + """Draws a mesh from segments and layers. + + This function creates a Blender curve and vertex information in a text + file, which includes coordinates, style, and color. If the + `split_layers` parameter is set to True, it processes each layer + individually, generating vertices and edges for each layer. If False, it + processes the segments as a whole. + + Args: + split_layers (bool): A flag indicating whether to split the drawing into + separate layers or not. + """ + if split_layers: i = 0 for layer in self.layers: @@ -482,6 +696,17 @@ class Segment: self.layerIdx = None def __str__(self): + """Return a string representation of the object. + + This method constructs a string that includes the coordinates, line + number, style, layer index, and color of the object. It formats these + attributes into a readable string format for easier debugging and + logging. + + Returns: + str: A formatted string representing the object's attributes. + """ + return " i[1:-1, 1:-1] - i[2:, 1:-1] @@ -225,6 +377,28 @@ def getOffsetImageCavities(o, i): # for pencil operation mainly # search edges for pencil strategy, another try. def imageEdgeSearch_online(o, ar, zimage): + """Search for edges in an image using a pencil strategy. + + This function implements an edge detection algorithm that simulates a + pencil-like movement across the image represented by a 2D array. It + identifies white pixels and builds chunks of points based on the + detected edges. The algorithm iteratively explores possible directions + to find and track the edges until a specified condition is met, such as + exhausting the available white pixels or reaching a maximum number of + tests. + + Args: + o (object): An object containing parameters such as min, max coordinates, cutter + diameter, + border width, and optimisation settings. + ar (numpy.ndarray): A 2D array representing the image where edge detection is to be + performed. + zimage (numpy.ndarray): A 2D array representing the z-coordinates corresponding to the image. + + Returns: + list: A list of chunks representing the detected edges in the image. + """ + minx, miny, minz, maxx, maxy, maxz = o.min.x, o.min.y, o.min.z, o.max.x, o.max.y, o.max.z r = ceil((o.cutter_diameter/12)/o.optimisation.pixsize) # was commented coef = 0.75 @@ -350,6 +524,23 @@ def imageEdgeSearch_online(o, ar, zimage): async def crazyPath(o): + """Execute a greedy adaptive algorithm for path planning. + + This function prepares an area based on the provided object `o`, + calculates the dimensions of the area, and initializes a mill image and + cutter array. The dimensions are determined by the maximum and minimum + coordinates of the object, adjusted by the simulation detail and border + width. The function is currently a stub and requires further + implementation. + + Args: + o (object): An object containing properties such as max, min, optimisation, and + borderwidth. + + Returns: + None: This function does not return a value. + """ + # TODO: try to do something with this stuff, it's just a stub. It should be a greedy adaptive algorithm. # started another thing below. await prepareArea(o) @@ -365,6 +556,25 @@ async def crazyPath(o): def buildStroke(start, end, cutterArray): + """Build a stroke array based on start and end points. + + This function generates a 2D stroke array that represents a stroke from + a starting point to an ending point. It calculates the length of the + stroke and creates a grid that is filled based on the positions defined + by the start and end coordinates. The function uses a cutter array to + determine how the stroke interacts with the grid. + + Args: + start (tuple): A tuple representing the starting coordinates (x, y, z). + end (tuple): A tuple representing the ending coordinates (x, y, z). + cutterArray: An object that contains size information used to modify + the stroke array. + + Returns: + numpy.ndarray: A 2D array representing the stroke, filled with + calculated values based on the input parameters. + """ + strokelength = max(abs(end[0] - start[0]), abs(end[1] - start[1])) size_x = abs(end[0] - start[0]) + cutterArray.size[0] size_y = abs(end[1] - start[1]) + cutterArray.size[0] @@ -394,6 +604,26 @@ def testStrokeBinary(img, stroke): def crazyStrokeImage(o): + """Generate a toolpath for a milling operation using a crazy stroke + strategy. + + This function computes a path for a milling cutter based on the provided + parameters and the offset image. It utilizes a circular cutter + representation and evaluates potential cutting positions based on + various thresholds. The algorithm iteratively tests different angles and + lengths for the cutter's movement until the desired cutting area is + achieved or the maximum number of tests is reached. + + Args: + o (object): An object containing parameters such as cutter diameter, + optimization settings, movement type, and thresholds for + determining cutting effectiveness. + + Returns: + list: A list of chunks representing the computed toolpath for the milling + operation. + """ + # this surprisingly works, and can be used as a basis for something similar to adaptive milling strategy. minx, miny, minz, maxx, maxy, maxz = o.min.x, o.min.y, o.min.z, o.max.x, o.max.y, o.max.z @@ -577,6 +807,27 @@ def crazyStrokeImage(o): def crazyStrokeImageBinary(o, ar, avoidar): + """Perform a milling operation using a binary image representation. + + This function implements a strategy for milling by navigating through a + binary image. It starts from a defined point and attempts to move in + various directions, evaluating the cutter load to determine the + appropriate path. The algorithm continues until it either exhausts the + available pixels to cut or reaches a predefined limit on the number of + tests. The function modifies the input array to represent the areas that + have been milled and returns the generated path as a list of chunks. + + Args: + o (object): An object containing parameters for the milling operation, including + cutter diameter, thresholds, and movement type. + ar (numpy.ndarray): A 2D binary array representing the image to be milled. + avoidar (numpy.ndarray): A 2D binary array indicating areas to avoid during milling. + + Returns: + list: A list of chunks representing the path taken during the milling + operation. + """ + # this surprisingly works, and can be used as a basis for something similar to adaptive milling strategy. # works like this: # start 'somewhere' @@ -845,6 +1096,28 @@ def crazyStrokeImageBinary(o, ar, avoidar): def imageToChunks(o, image, with_border=False): + """Convert an image into chunks based on detected edges. + + This function processes a given image to identify edges and convert them + into polychunks, which are essentially collections of connected edge + segments. It utilizes the properties of the input object `o` to + determine the boundaries and size of the chunks. The function can + optionally include borders in the edge detection process. The output is + a list of chunks that represent the detected polygons in the image. + + Args: + o (object): An object containing properties such as min, max, borderwidth, + and optimisation settings. + image (numpy.ndarray): A 2D array representing the image to be processed, + expected to be in a format compatible with uint8. + with_border (bool?): A flag indicating whether to include borders + in the edge detection. Defaults to False. + + Returns: + list: A list of chunks, where each chunk is represented as a collection of + points that outline the detected edges in the image. + """ + t = time.time() minx, miny, minz, maxx, maxy, maxz = o.min.x, o.min.y, o.min.z, o.max.x, o.max.y, o.max.z pixsize = o.optimisation.pixsize @@ -1018,6 +1291,24 @@ def imageToChunks(o, image, with_border=False): def imageToShapely(o, i, with_border=False): + """Convert an image to Shapely polygons. + + This function takes an image and converts it into a series of Shapely + polygon objects. It first processes the image into chunks and then + transforms those chunks into polygon geometries. The `with_border` + parameter allows for the inclusion of borders in the resulting polygons. + + Args: + o: The input image to be processed. + i: Additional input parameters for processing the image. + with_border (bool): A flag indicating whether to include + borders in the resulting polygons. Defaults to False. + + Returns: + list: A list of Shapely polygon objects created from the + image chunks. + """ + polychunks = imageToChunks(o, i, with_border) polys = chunksToShapely(polychunks) @@ -1025,6 +1316,24 @@ def imageToShapely(o, i, with_border=False): def getSampleImage(s, sarray, minz): + """Get a sample image value from a 2D array based on given coordinates. + + This function retrieves a value from a 2D array by performing bilinear + interpolation based on the provided coordinates. It checks if the + coordinates are within the bounds of the array and calculates the + interpolated value accordingly. If the coordinates are out of bounds, it + returns -10. + + Args: + s (tuple): A tuple containing the x and y coordinates (float). + sarray (numpy.ndarray): A 2D array from which to sample the image values. + minz (float): A minimum threshold value (not used in the current implementation). + + Returns: + float: The interpolated value from the 2D array, or -10 if the coordinates are + out of bounds. + """ + x = s[0] y = s[1] if (x < 0 or x > len(sarray) - 1) or (y < 0 or y > len(sarray[0]) - 1): @@ -1050,6 +1359,25 @@ def getSampleImage(s, sarray, minz): def getResolution(o): + """Calculate the resolution based on the dimensions of an object. + + This function computes the resolution in both x and y directions by + determining the width and height of the object, adjusting for pixel size + and border width. The resolution is calculated by dividing the + dimensions by the pixel size and adding twice the border width to each + dimension. + + Args: + o (object): An object with attributes `max`, `min`, `optimisation`, + and `borderwidth`. The `max` and `min` attributes should + have `x` and `y` properties representing the coordinates, + while `optimisation` should have a `pixsize` attribute. + + Returns: + None: This function does not return a value; it performs calculations + to determine resolution. + """ + sx = o.max.x - o.min.x sy = o.max.y - o.min.y @@ -1061,6 +1389,25 @@ def getResolution(o): def _backup_render_settings(pairs): + """Backup the render settings of Blender objects. + + This function iterates over a list of pairs consisting of owners and + their corresponding structure names. It retrieves the properties of each + structure and stores them in a backup list. If the structure is a + Blender object, it saves all its properties that do not start with an + underscore. For simple values, it directly appends them to the + properties list. This is useful for preserving render settings that + Blender does not allow direct access to during rendering. + + Args: + pairs (list): A list of tuples where each tuple contains an owner and a structure + name. + + Returns: + list: A list containing the backed-up properties of the specified Blender + objects. + """ + properties = [] for owner, struct_name in pairs: obj = getattr(owner, struct_name) @@ -1077,6 +1424,22 @@ def _backup_render_settings(pairs): def _restore_render_settings(pairs, properties): + """Restore render settings for a given owner and structure. + + This function takes pairs of owners and structure names along with their + corresponding properties. It iterates through these pairs, retrieves the + appropriate object from the owner using the structure name, and sets the + properties on the object. If the object is an instance of + `bpy.types.bpy_struct`, it updates its attributes; otherwise, it + directly sets the value on the owner. + + Args: + pairs (list): A list of tuples where each tuple contains an owner and a structure + name. + properties (list): A list of dictionaries containing property names and their corresponding + values. + """ + for (owner, struct_name), obj_value in zip(pairs, properties): obj = getattr(owner, struct_name) if isinstance(obj, bpy.types.bpy_struct): @@ -1087,6 +1450,22 @@ def _restore_render_settings(pairs, properties): def renderSampleImage(o): + """Render a sample image based on the provided object settings. + + This function generates a Z-buffer image for a given object by either + rendering it from scratch or loading an existing image from the cache. + It handles different geometry sources and applies various settings to + ensure the image is rendered correctly. The function also manages backup + and restoration of render settings to maintain the scene's integrity + during the rendering process. + + Args: + o (object): An object containing various properties and settings + + Returns: + numpy.ndarray: The generated or loaded Z-buffer image as a NumPy array. + """ + t = time.time() progress('Getting Z-Buffer') # print(o.zbuffer_image) @@ -1285,6 +1664,21 @@ def renderSampleImage(o): # return numpy.array([]) async def prepareArea(o): + """Prepare the area for rendering by processing the offset image. + + This function handles the preparation of the area by rendering a sample + image and managing the offset image based on the provided options. It + checks if the offset image needs to be updated and loads it if + necessary. If the inverse option is set, it adjusts the samples + accordingly before calling the offsetArea function. Finally, it saves + the processed offset image. + + Args: + o (object): An object containing various properties and methods + required for preparing the area, including flags for + updating the offset image and rendering options. + """ + # if not o.use_exact: renderSampleImage(o) samples = o.zbuffer_image @@ -1307,6 +1701,23 @@ async def prepareArea(o): def getCutterArray(operation, pixsize): + """Generate a cutter array based on the specified operation and pixel size. + + This function calculates a 2D array representing the cutter shape based + on the cutter type defined in the operation object. The cutter can be of + various types such as 'END', 'BALL', 'VCARVE', 'CYLCONE', 'BALLCONE', or + 'CUSTOM'. The function uses geometric calculations to fill the array + with appropriate values based on the cutter's dimensions and properties. + + Args: + operation (object): An object containing properties of the cutter, including + cutter type, diameter, tip angle, and other relevant parameters. + pixsize (float): The size of each pixel in the generated cutter array. + + Returns: + numpy.ndarray: A 2D array filled with values representing the cutter shape. + """ + type = operation.cutter_type # print('generating cutter') r = operation.cutter_diameter / 2 + operation.skin # /operation.pixsize diff --git a/scripts/addons/cam/involute_gear.py b/scripts/addons/cam/involute_gear.py index 17d71ab9..1aebb803 100644 --- a/scripts/addons/cam/involute_gear.py +++ b/scripts/addons/cam/involute_gear.py @@ -110,6 +110,35 @@ def gear_q6(b, s, t, d): def gear(mm_per_tooth=0.003, number_of_teeth=5, hole_diameter=0.003175, pressure_angle=0.3488, clearance=0.0, backlash=0.0, rim_size=0.0005, hub_diameter=0.006, spokes=4): + """Generate a 3D gear model based on specified parameters. + + This function creates a 3D representation of a gear using the provided + parameters such as the circular pitch, number of teeth, hole diameter, + pressure angle, clearance, backlash, rim size, hub diameter, and the + number of spokes. The gear is constructed by calculating various radii + and angles based on the input parameters and then using geometric + operations to form the final shape. The resulting gear is named + according to its specifications. + + Args: + mm_per_tooth (float): The circular pitch of the gear in millimeters (default is 0.003). + number_of_teeth (int): The total number of teeth on the gear (default is 5). + hole_diameter (float): The diameter of the central hole in millimeters (default is 0.003175). + pressure_angle (float): The angle that controls the shape of the tooth sides in radians (default + is 0.3488). + clearance (float): The gap between the top of a tooth and the bottom of a valley on a + meshing gear in millimeters (default is 0.0). + backlash (float): The gap between two meshing teeth along the circumference of the pitch + circle in millimeters (default is 0.0). + rim_size (float): The size of the rim around the gear in millimeters (default is 0.0005). + hub_diameter (float): The diameter of the hub in millimeters (default is 0.006). + spokes (int): The number of spokes on the gear (default is 4). + + Returns: + None: This function does not return a value but modifies the Blender scene to + include the generated gear model. + """ + simple.deselect() p = mm_per_tooth * number_of_teeth / pi / 2 # radius of pitch circle c = p + mm_per_tooth / pi - clearance # radius of outer circle @@ -203,6 +232,26 @@ def gear(mm_per_tooth=0.003, number_of_teeth=5, hole_diameter=0.003175, def rack(mm_per_tooth=0.01, number_of_teeth=11, height=0.012, pressure_angle=0.3488, backlash=0.0, hole_diameter=0.003175, tooth_per_hole=4): + """Generate a rack gear profile based on specified parameters. + + This function creates a rack gear by calculating the geometry based on + the provided parameters such as millimeters per tooth, number of teeth, + height, pressure angle, backlash, hole diameter, and teeth per hole. It + constructs the gear shape using the Shapely library and duplicates the + tooth to create the full rack. If a hole diameter is specified, it also + creates holes along the rack. The resulting gear is named based on the + input parameters. + + Args: + mm_per_tooth (float): The distance in millimeters for each tooth. Default is 0.01. + number_of_teeth (int): The total number of teeth on the rack. Default is 11. + height (float): The height of the rack. Default is 0.012. + pressure_angle (float): The pressure angle in radians. Default is 0.3488. + backlash (float): The backlash distance in millimeters. Default is 0.0. + hole_diameter (float): The diameter of the holes in millimeters. Default is 0.003175. + tooth_per_hole (int): The number of teeth per hole. Default is 4. + """ + simple.deselect() mm_per_tooth *= 1000 a = mm_per_tooth / pi # addendum diff --git a/scripts/addons/cam/opencamlib/oclSample.py b/scripts/addons/cam/opencamlib/oclSample.py index 9416ac34..76f1b3d1 100644 --- a/scripts/addons/cam/opencamlib/oclSample.py +++ b/scripts/addons/cam/opencamlib/oclSample.py @@ -1,110 +1,149 @@ -"""BlenderCAM 'oclSample.py' - -Functions to sample mesh or curve data for OpenCAMLib processing. -""" - -from math import ( - radians, - tan -) - -try: - import ocl -except ImportError: - try: - import opencamlib as ocl - except ImportError: - pass - -try: - from bl_ext.blender_org.stl_format_legacy import blender_utils -except ImportError: - pass - -import mathutils - -from ..simple import activate -from ..exception import CamException -from ..async_op import progress_async - -OCL_SCALE = 1000.0 - -_PREVIOUS_OCL_MESH = None - - -def get_oclSTL(operation): - me = None - oclSTL = ocl.STLSurf() - found_mesh = False - for collision_object in operation.objects: - activate(collision_object) - if collision_object.type == "MESH" or collision_object.type == "CURVE" or collision_object.type == "FONT" or collision_object.type == "SURFACE": - found_mesh = True - global_matrix = mathutils.Matrix.Identity(4) - faces = blender_utils.faces_from_mesh( - collision_object, global_matrix, operation.use_modifiers) - for face in faces: - t = ocl.Triangle(ocl.Point(face[0][0]*OCL_SCALE, face[0][1]*OCL_SCALE, (face[0][2]+operation.skin)*OCL_SCALE), - ocl.Point(face[1][0]*OCL_SCALE, face[1][1]*OCL_SCALE, - (face[1][2]+operation.skin)*OCL_SCALE), - ocl.Point(face[2][0]*OCL_SCALE, face[2][1]*OCL_SCALE, (face[2][2]+operation.skin)*OCL_SCALE)) - oclSTL.addTriangle(t) - # FIXME needs to work with collections - if not found_mesh: - raise CamException( - "This Operation Requires a Mesh or Curve Object or Equivalent (e.g. Text, Volume).") - return oclSTL - - -async def ocl_sample(operation, chunks, use_cached_mesh=False): - global _PREVIOUS_OCL_MESH - - op_cutter_type = operation.cutter_type - op_cutter_diameter = operation.cutter_diameter - op_minz = operation.minz - op_cutter_tip_angle = radians(operation.cutter_tip_angle)/2 - if op_cutter_type == "VCARVE": - cutter_length = (op_cutter_diameter/tan(op_cutter_tip_angle))/2 - else: - cutter_length = 10 - - cutter = None - - if op_cutter_type == 'END': - cutter = ocl.CylCutter((op_cutter_diameter + operation.skin * 2) * 1000, cutter_length) - elif op_cutter_type == 'BALLNOSE': - cutter = ocl.BallCutter((op_cutter_diameter + operation.skin * 2) * 1000, cutter_length) - elif op_cutter_type == 'VCARVE': - cutter = ocl.ConeCutter((op_cutter_diameter + operation.skin * 2) - * 1000, op_cutter_tip_angle, cutter_length) - elif op_cutter_type == 'CYLCONE': - cutter = ocl.CylConeCutter((operation.cylcone_diameter/2+operation.skin)*2000, - (op_cutter_diameter + operation.skin * 2) * 1000, op_cutter_tip_angle) - elif op_cutter_type == 'BALLCONE': - cutter = ocl.BallConeCutter((operation.ball_radius + operation.skin) * 2000, - (op_cutter_diameter + operation.skin * 2) * 1000, op_cutter_tip_angle) - elif op_cutter_type == 'BULLNOSE': - cutter = ocl.BullCutter((op_cutter_diameter + operation.skin * 2) * - 1000, operation.bull_corner_radius*1000, cutter_length) - else: - print("Cutter Unsupported: {0}\n".format(op_cutter_type)) - quit() - - bdc = ocl.BatchDropCutter() - if use_cached_mesh and _PREVIOUS_OCL_MESH is not None: - oclSTL = _PREVIOUS_OCL_MESH - else: - oclSTL = get_oclSTL(operation) - _PREVIOUS_OCL_MESH = oclSTL - bdc.setSTL(oclSTL) - bdc.setCutter(cutter) - - for chunk in chunks: - for coord in chunk.get_points_np(): - bdc.appendPoint(ocl.CLPoint(coord[0] * 1000, coord[1] * 1000, op_minz * 1000)) - await progress_async("OpenCAMLib Sampling") - bdc.run() - - cl_points = bdc.getCLPoints() - - return cl_points +"""BlenderCAM 'oclSample.py' + +Functions to sample mesh or curve data for OpenCAMLib processing. +""" + +from math import ( + radians, + tan +) + +try: + import ocl +except ImportError: + try: + import opencamlib as ocl + except ImportError: + pass + +try: + from bl_ext.blender_org.stl_format_legacy import blender_utils +except ImportError: + pass + +import mathutils + +from ..simple import activate +from ..exception import CamException +from ..async_op import progress_async + +OCL_SCALE = 1000.0 + +_PREVIOUS_OCL_MESH = None + + +def get_oclSTL(operation): + """Get the oclSTL representation from the provided operation. + + This function iterates through the objects in the given operation and + constructs an oclSTL object by extracting triangle data from mesh, + curve, font, or surface objects. It activates each object and checks its + type to determine if it can be processed. If no valid objects are found, + it raises an exception. + + Args: + operation (Operation): An object containing a collection of objects + + Returns: + ocl.STLSurf: An oclSTL object containing the triangles derived from + the valid objects. + + Raises: + CamException: If no mesh, curve, or equivalent object is found in + """ + me = None + oclSTL = ocl.STLSurf() + found_mesh = False + for collision_object in operation.objects: + activate(collision_object) + if collision_object.type == "MESH" or collision_object.type == "CURVE" or collision_object.type == "FONT" or collision_object.type == "SURFACE": + found_mesh = True + global_matrix = mathutils.Matrix.Identity(4) + faces = blender_utils.faces_from_mesh( + collision_object, global_matrix, operation.use_modifiers) + for face in faces: + t = ocl.Triangle(ocl.Point(face[0][0]*OCL_SCALE, face[0][1]*OCL_SCALE, (face[0][2]+operation.skin)*OCL_SCALE), + ocl.Point(face[1][0]*OCL_SCALE, face[1][1]*OCL_SCALE, + (face[1][2]+operation.skin)*OCL_SCALE), + ocl.Point(face[2][0]*OCL_SCALE, face[2][1]*OCL_SCALE, (face[2][2]+operation.skin)*OCL_SCALE)) + oclSTL.addTriangle(t) + # FIXME needs to work with collections + if not found_mesh: + raise CamException( + "This Operation Requires a Mesh or Curve Object or Equivalent (e.g. Text, Volume).") + return oclSTL + + +async def ocl_sample(operation, chunks, use_cached_mesh=False): + """Sample points using a specified cutter and operation. + + This function takes an operation and a list of chunks, and samples + points based on the specified cutter type and its parameters. It + supports various cutter types such as 'END', 'BALLNOSE', 'VCARVE', + 'CYLCONE', 'BALLCONE', and 'BULLNOSE'. The function can also utilize a + cached mesh for efficiency. The sampled points are returned after + processing all chunks. + + Args: + operation (Operation): An object containing the cutter type, diameter, + minimum Z value, tip angle, and other relevant parameters. + chunks (list): A list of chunk objects that contain point data to be + processed. + use_cached_mesh (bool): A flag indicating whether to use a cached mesh + if available. Defaults to False. + + Returns: + list: A list of sampled CL points generated by the cutter. + """ + + global _PREVIOUS_OCL_MESH + + op_cutter_type = operation.cutter_type + op_cutter_diameter = operation.cutter_diameter + op_minz = operation.minz + op_cutter_tip_angle = radians(operation.cutter_tip_angle)/2 + if op_cutter_type == "VCARVE": + cutter_length = (op_cutter_diameter/tan(op_cutter_tip_angle))/2 + else: + cutter_length = 10 + + cutter = None + + if op_cutter_type == 'END': + cutter = ocl.CylCutter((op_cutter_diameter + operation.skin * 2) * 1000, cutter_length) + elif op_cutter_type == 'BALLNOSE': + cutter = ocl.BallCutter((op_cutter_diameter + operation.skin * 2) * 1000, cutter_length) + elif op_cutter_type == 'VCARVE': + cutter = ocl.ConeCutter((op_cutter_diameter + operation.skin * 2) + * 1000, op_cutter_tip_angle, cutter_length) + elif op_cutter_type == 'CYLCONE': + cutter = ocl.CylConeCutter((operation.cylcone_diameter/2+operation.skin)*2000, + (op_cutter_diameter + operation.skin * 2) * 1000, op_cutter_tip_angle) + elif op_cutter_type == 'BALLCONE': + cutter = ocl.BallConeCutter((operation.ball_radius + operation.skin) * 2000, + (op_cutter_diameter + operation.skin * 2) * 1000, op_cutter_tip_angle) + elif op_cutter_type == 'BULLNOSE': + cutter = ocl.BullCutter((op_cutter_diameter + operation.skin * 2) * + 1000, operation.bull_corner_radius*1000, cutter_length) + else: + print("Cutter Unsupported: {0}\n".format(op_cutter_type)) + quit() + + bdc = ocl.BatchDropCutter() + if use_cached_mesh and _PREVIOUS_OCL_MESH is not None: + oclSTL = _PREVIOUS_OCL_MESH + else: + oclSTL = get_oclSTL(operation) + _PREVIOUS_OCL_MESH = oclSTL + bdc.setSTL(oclSTL) + bdc.setCutter(cutter) + + for chunk in chunks: + for coord in chunk.get_points_np(): + bdc.appendPoint(ocl.CLPoint(coord[0] * 1000, coord[1] * 1000, op_minz * 1000)) + await progress_async("OpenCAMLib Sampling") + bdc.run() + + cl_points = bdc.getCLPoints() + + return cl_points diff --git a/scripts/addons/cam/opencamlib/opencamlib.py b/scripts/addons/cam/opencamlib/opencamlib.py index 9e74a4ac..94427a3a 100644 --- a/scripts/addons/cam/opencamlib/opencamlib.py +++ b/scripts/addons/cam/opencamlib/opencamlib.py @@ -1,210 +1,349 @@ -"""BlenderCAM 'oclSample.py' - -Functions used by OpenCAMLib sampling. -""" - -import os -from subprocess import call -import tempfile - -import numpy as np -try: - import ocl -except ImportError: - try: - import opencamlib as ocl - except ImportError: - pass - -import bpy - -from ..constants import BULLET_SCALE -from ..simple import activate -from .. import utils -from ..cam_chunk import camPathChunk -from ..async_op import progress_async -from .oclSample import ( - get_oclSTL, - ocl_sample -) - -OCL_SCALE = 1000.0 - -PYTHON_BIN = None - - -def pointSamplesFromOCL(points, samples): - for index, point in enumerate(points): - point[2] = samples[index].z / OCL_SCALE - - -def chunkPointSamplesFromOCL(chunks, samples): - s_index = 0 - for ch in chunks: - ch_points = ch.count() - z_vals = np.array([p.z for p in samples[s_index:s_index+ch_points]]) - z_vals /= OCL_SCALE - ch.setZ(z_vals) - s_index += ch_points - # p_index = 0 - # for point in ch.points: - # if len(point) == 2 or point[2] != 2: - # z_sample = samples[s_index].z / OCL_SCALE - # ch.points[p_index] = (point[0], point[1], z_sample) - # # print(str(point[2])) - # else: - # ch.points[p_index] = (point[0], point[1], 1) - # p_index += 1 - # s_index += 1 - - -def chunkPointsResampleFromOCL(chunks, samples): - s_index = 0 - for ch in chunks: - ch_points = ch.count() - z_vals = np.array([p.z for p in samples[s_index:s_index+ch_points]]) - z_vals /= OCL_SCALE - ch.setZ(z_vals) - s_index += ch_points - - # s_index = 0 - # for ch in chunks: - # p_index = 0 - # for point in ch.points: - # if len(point) == 2 or point[2] != 2: - # z_sample = samples[s_index].z / OCL_SCALE - # ch.points[p_index] = (point[0], point[1], z_sample) - # # print(str(point[2])) - # else: - # ch.points[p_index] = (point[0], point[1], 1) - # p_index += 1 - # s_index += 1 - - -def exportModelsToSTL(operation): - file_number = 0 - for collision_object in operation.objects: - activate(collision_object) - bpy.ops.object.duplicate(linked=False) - # collision_object = bpy.context.scene.objects.active - # bpy.context.scene.objects.selected = collision_object - file_name = os.path.join(tempfile.gettempdir(), "model{0}.stl".format(str(file_number))) - bpy.ops.object.transform_apply(location=True, rotation=True, scale=True) - bpy.ops.transform.resize(value=(OCL_SCALE, OCL_SCALE, OCL_SCALE), constraint_axis=(False, False, False), - orient_type='GLOBAL', mirror=False, use_proportional_edit=False, - proportional_edit_falloff='SMOOTH', proportional_size=1, snap=False, - snap_target='CLOSEST', snap_point=(0, 0, 0), snap_align=False, snap_normal=(0, 0, 0), - texture_space=False, release_confirm=False) - bpy.ops.object.transform_apply(location=True, rotation=True, scale=True) - bpy.ops.export_mesh.stl(check_existing=True, filepath=file_name, filter_glob="*.stl", use_selection=True, - ascii=False, use_mesh_modifiers=True, axis_forward='Y', axis_up='Z', global_scale=1.0) - bpy.ops.object.delete() - file_number += 1 - - -async def oclSamplePoints(operation, points): - samples = await ocl_sample(operation, points) - pointSamplesFromOCL(points, samples) - - -async def oclSample(operation, chunks): - samples = await ocl_sample(operation, chunks) - chunkPointSamplesFromOCL(chunks, samples) - - -async def oclResampleChunks(operation, chunks_to_resample, use_cached_mesh): - tmp_chunks = list() - tmp_chunks.append(camPathChunk(inpoints=[])) - for chunk, i_start, i_length in chunks_to_resample: - tmp_chunks[0].extend(chunk.get_points_np()[i_start:i_start+i_length]) - print(i_start, i_length, len(tmp_chunks[0].points)) - - samples = await ocl_sample(operation, tmp_chunks, use_cached_mesh=use_cached_mesh) - - sample_index = 0 - for chunk, i_start, i_length in chunks_to_resample: - z = np.array([p.z for p in samples[sample_index:sample_index+i_length]]) / OCL_SCALE - pts = chunk.get_points_np() - pt_z = pts[i_start:i_start+i_length, 2] - pt_z = np.where(z > pt_z, z, pt_z) - - sample_index += i_length - # for p_index in range(i_start, i_start + i_length): - # z = samples[sample_index].z / OCL_SCALE - # sample_index += 1 - # if z > chunk.points[p_index][2]: - # chunk.points[p_index][2] = z - - -def oclWaterlineLayerHeights(operation): - layers = [] - l_last = operation.minz - l_step = operation.stepdown - l_first = operation.maxz - l_step - l_depth = l_first - while l_depth > (l_last + 0.0000001): - layers.append(l_depth) - l_depth -= l_step - layers.append(l_last) - return layers - - -# def oclGetMedialAxis(operation, chunks): -# oclWaterlineHeightsToOCL(operation) -# operationSettingsToOCL(operation) -# curvesToOCL(operation) -# call([PYTHON_BIN, os.path.join(bpy.utils.script_path_pref(), "addons", "cam", "opencamlib", "ocl.py")]) -# waterlineChunksFromOCL(operation, chunks) - - -async def oclGetWaterline(operation, chunks): - layers = oclWaterlineLayerHeights(operation) - oclSTL = get_oclSTL(operation) - - op_cutter_type = operation.cutter_type - op_cutter_diameter = operation.cutter_diameter - op_minz = operation.minz - if op_cutter_type == "VCARVE": - op_cutter_tip_angle = operation['cutter_tip_angle'] - - cutter = None - # TODO: automatically determine necessary cutter length depending on object size - cutter_length = 150 - - if op_cutter_type == 'END': - cutter = ocl.CylCutter((op_cutter_diameter + operation.skin * 2) * 1000, cutter_length) - elif op_cutter_type == 'BALLNOSE': - cutter = ocl.BallCutter((op_cutter_diameter + operation.skin * 2) * 1000, cutter_length) - elif op_cutter_type == 'VCARVE': - cutter = ocl.ConeCutter((op_cutter_diameter + operation.skin * 2) - * 1000, op_cutter_tip_angle, cutter_length) - else: - print("Cutter unsupported: {0}\n".format(op_cutter_type)) - quit() - - waterline = ocl.Waterline() - waterline.setSTL(oclSTL) - waterline.setCutter(cutter) - waterline.setSampling(0.1) # TODO: add sampling setting to UI - last_pos = [0, 0, 0] - for count, height in enumerate(layers): - layer_chunks = [] - await progress_async("Waterline", int((100*count)/len(layers))) - waterline.reset() - waterline.setZ(height * OCL_SCALE) - waterline.run2() - wl_loops = waterline.getLoops() - for l in wl_loops: - inpoints = [] - for p in l: - inpoints.append((p.x / OCL_SCALE, p.y / OCL_SCALE, p.z / OCL_SCALE)) - inpoints.append(inpoints[0]) - chunk = camPathChunk(inpoints=inpoints) - chunk.closed = True - layer_chunks.append(chunk) - # sort chunks so that ordering is stable - chunks.extend(await utils.sortChunks(layer_chunks, operation, last_pos=last_pos)) - if len(chunks) > 0: - last_pos = chunks[-1].get_point(-1) - -# def oclFillMedialAxis(operation): +"""BlenderCAM 'oclSample.py' + +Functions used by OpenCAMLib sampling. +""" + +import os +from subprocess import call +import tempfile + +import numpy as np +try: + import ocl +except ImportError: + try: + import opencamlib as ocl + except ImportError: + pass + +import bpy + +from ..constants import BULLET_SCALE +from ..simple import activate +from .. import utils +from ..cam_chunk import camPathChunk +from ..async_op import progress_async +from .oclSample import ( + get_oclSTL, + ocl_sample +) + +OCL_SCALE = 1000.0 + +PYTHON_BIN = None + + +def pointSamplesFromOCL(points, samples): + """Update the z-coordinate of points based on corresponding sample values. + + This function iterates over a list of points and updates the + z-coordinate of each point using the z value from the corresponding + sample. The z value is scaled by a predefined constant, OCL_SCALE. It is + assumed that the length of the points list matches the length of the + samples list. + + Args: + points (list): A list of points, where each point is expected to be + a list or array with at least three elements. + samples (list): A list of sample objects, where each sample is + expected to have a z attribute. + """ + for index, point in enumerate(points): + point[2] = samples[index].z / OCL_SCALE + + +def chunkPointSamplesFromOCL(chunks, samples): + """Chunk point samples from OCL. + + This function processes a list of chunks and corresponding samples, + extracting the z-values from the samples and scaling them according to a + predefined constant (OCL_SCALE). It sets the scaled z-values for each + chunk based on the number of points in that chunk. + + Args: + chunks (list): A list of chunk objects that have a method `count()` + and a method `setZ()`. + samples (list): A list of sample objects from which z-values are + extracted. + """ + s_index = 0 + for ch in chunks: + ch_points = ch.count() + z_vals = np.array([p.z for p in samples[s_index:s_index+ch_points]]) + z_vals /= OCL_SCALE + ch.setZ(z_vals) + s_index += ch_points + # p_index = 0 + # for point in ch.points: + # if len(point) == 2 or point[2] != 2: + # z_sample = samples[s_index].z / OCL_SCALE + # ch.points[p_index] = (point[0], point[1], z_sample) + # # print(str(point[2])) + # else: + # ch.points[p_index] = (point[0], point[1], 1) + # p_index += 1 + # s_index += 1 + + +def chunkPointsResampleFromOCL(chunks, samples): + """Resample the Z values of points in chunks based on provided samples. + + This function iterates through a list of chunks and resamples the Z + values of the points in each chunk using the corresponding samples. It + first counts the number of points in each chunk, then extracts the Z + values from the samples, scales them by a predefined constant + (OCL_SCALE), and sets the resampled Z values back to the chunk. + + Args: + chunks (list): A list of chunk objects, each containing points that need + to be resampled. + samples (list): A list of sample objects from which Z values are extracted. + """ + s_index = 0 + for ch in chunks: + ch_points = ch.count() + z_vals = np.array([p.z for p in samples[s_index:s_index+ch_points]]) + z_vals /= OCL_SCALE + ch.setZ(z_vals) + s_index += ch_points + + # s_index = 0 + # for ch in chunks: + # p_index = 0 + # for point in ch.points: + # if len(point) == 2 or point[2] != 2: + # z_sample = samples[s_index].z / OCL_SCALE + # ch.points[p_index] = (point[0], point[1], z_sample) + # # print(str(point[2])) + # else: + # ch.points[p_index] = (point[0], point[1], 1) + # p_index += 1 + # s_index += 1 + + +def exportModelsToSTL(operation): + """Export models to STL format. + + This function takes an operation containing a collection of collision + objects and exports each object as an STL file. It duplicates each + object, applies transformations, and resizes them according to a + predefined scale before exporting them to the temporary directory. The + exported files are named sequentially as "model0.stl", "model1.stl", + etc. After exporting, the function deletes the duplicated objects to + clean up the scene. + + Args: + operation: An object containing a collection of collision objects to be exported. + """ + file_number = 0 + for collision_object in operation.objects: + activate(collision_object) + bpy.ops.object.duplicate(linked=False) + # collision_object = bpy.context.scene.objects.active + # bpy.context.scene.objects.selected = collision_object + file_name = os.path.join(tempfile.gettempdir(), "model{0}.stl".format(str(file_number))) + bpy.ops.object.transform_apply(location=True, rotation=True, scale=True) + bpy.ops.transform.resize(value=(OCL_SCALE, OCL_SCALE, OCL_SCALE), constraint_axis=(False, False, False), + orient_type='GLOBAL', mirror=False, use_proportional_edit=False, + proportional_edit_falloff='SMOOTH', proportional_size=1, snap=False, + snap_target='CLOSEST', snap_point=(0, 0, 0), snap_align=False, snap_normal=(0, 0, 0), + texture_space=False, release_confirm=False) + bpy.ops.object.transform_apply(location=True, rotation=True, scale=True) + bpy.ops.export_mesh.stl(check_existing=True, filepath=file_name, filter_glob="*.stl", use_selection=True, + ascii=False, use_mesh_modifiers=True, axis_forward='Y', axis_up='Z', global_scale=1.0) + bpy.ops.object.delete() + file_number += 1 + + +async def oclSamplePoints(operation, points): + """Sample points using an operation and process the results. + + This asynchronous function takes an operation and a set of points, + samples the points using the specified operation, and then processes the + sampled points. The function relies on an external sampling function and + a processing function to handle the sampling and post-processing of the + data. + + Args: + operation (str): The operation to be performed on the points. + points (list): A list of points to be sampled. + """ + + samples = await ocl_sample(operation, points) + pointSamplesFromOCL(points, samples) + + +async def oclSample(operation, chunks): + """Perform an operation on a set of chunks and process the resulting + samples. + + This asynchronous function calls the `ocl_sample` function to obtain + samples based on the provided operation and chunks. After retrieving the + samples, it processes them using the `chunkPointSamplesFromOCL` + function. This is useful for handling large datasets in a chunked + manner, allowing for efficient sampling and processing. + + Args: + operation (str): The operation to be performed on the chunks. + chunks (list): A list of data chunks to be processed. + + Returns: + None: This function does not return a value. + """ + + samples = await ocl_sample(operation, chunks) + chunkPointSamplesFromOCL(chunks, samples) + + +async def oclResampleChunks(operation, chunks_to_resample, use_cached_mesh): + """Resample chunks of data using OpenCL operations. + + This function takes a list of chunks to resample and performs an OpenCL + sampling operation on them. It first prepares a temporary chunk that + collects points from the specified chunks. Then, it calls the + `ocl_sample` function to perform the sampling operation. After obtaining + the samples, it updates the z-coordinates of the points in each chunk + based on the sampled values. + + Args: + operation (OperationType): The OpenCL operation to be performed. + chunks_to_resample (list): A list of tuples, where each tuple contains + a chunk object and its corresponding start index and length for + resampling. + use_cached_mesh (bool): A flag indicating whether to use cached mesh + data during the sampling process. + + Returns: + None: This function does not return a value but modifies the input + chunks in place. + """ + + tmp_chunks = list() + tmp_chunks.append(camPathChunk(inpoints=[])) + for chunk, i_start, i_length in chunks_to_resample: + tmp_chunks[0].extend(chunk.get_points_np()[i_start:i_start+i_length]) + print(i_start, i_length, len(tmp_chunks[0].points)) + + samples = await ocl_sample(operation, tmp_chunks, use_cached_mesh=use_cached_mesh) + + sample_index = 0 + for chunk, i_start, i_length in chunks_to_resample: + z = np.array([p.z for p in samples[sample_index:sample_index+i_length]]) / OCL_SCALE + pts = chunk.get_points_np() + pt_z = pts[i_start:i_start+i_length, 2] + pt_z = np.where(z > pt_z, z, pt_z) + + sample_index += i_length + # for p_index in range(i_start, i_start + i_length): + # z = samples[sample_index].z / OCL_SCALE + # sample_index += 1 + # if z > chunk.points[p_index][2]: + # chunk.points[p_index][2] = z + + +def oclWaterlineLayerHeights(operation): + """Generate a list of waterline layer heights for a given operation. + + This function calculates the heights of waterline layers based on the + specified parameters of the operation. It starts from the maximum height + and decrements by a specified step until it reaches the minimum height. + The resulting list of heights can be used for further processing in + operations that require layered depth information. + + Args: + operation (object): An object containing the properties `minz`, + `maxz`, and `stepdown` which define the + minimum height, maximum height, and step size + for layer generation, respectively. + + Returns: + list: A list of waterline layer heights from maximum to minimum. + """ + layers = [] + l_last = operation.minz + l_step = operation.stepdown + l_first = operation.maxz - l_step + l_depth = l_first + while l_depth > (l_last + 0.0000001): + layers.append(l_depth) + l_depth -= l_step + layers.append(l_last) + return layers + + +# def oclGetMedialAxis(operation, chunks): +# oclWaterlineHeightsToOCL(operation) +# operationSettingsToOCL(operation) +# curvesToOCL(operation) +# call([PYTHON_BIN, os.path.join(bpy.utils.script_path_pref(), "addons", "cam", "opencamlib", "ocl.py")]) +# waterlineChunksFromOCL(operation, chunks) + + +async def oclGetWaterline(operation, chunks): + """Generate waterline paths for a given machining operation. + + This function calculates the waterline paths based on the provided + machining operation and its parameters. It determines the appropriate + cutter type and dimensions, sets up the waterline object with the + corresponding STL file, and processes each layer to generate the + machining paths. The resulting paths are stored in the provided chunks + list. The function also handles different cutter types, including end + mills, ball nose cutters, and V-carve cutters. + + Args: + operation (Operation): An object representing the machining operation, + containing details such as cutter type, diameter, and minimum Z height. + chunks (list): A list that will be populated with the generated + machining path chunks. + """ + + layers = oclWaterlineLayerHeights(operation) + oclSTL = get_oclSTL(operation) + + op_cutter_type = operation.cutter_type + op_cutter_diameter = operation.cutter_diameter + op_minz = operation.minz + if op_cutter_type == "VCARVE": + op_cutter_tip_angle = operation['cutter_tip_angle'] + + cutter = None + # TODO: automatically determine necessary cutter length depending on object size + cutter_length = 150 + + if op_cutter_type == 'END': + cutter = ocl.CylCutter((op_cutter_diameter + operation.skin * 2) * 1000, cutter_length) + elif op_cutter_type == 'BALLNOSE': + cutter = ocl.BallCutter((op_cutter_diameter + operation.skin * 2) * 1000, cutter_length) + elif op_cutter_type == 'VCARVE': + cutter = ocl.ConeCutter((op_cutter_diameter + operation.skin * 2) + * 1000, op_cutter_tip_angle, cutter_length) + else: + print("Cutter unsupported: {0}\n".format(op_cutter_type)) + quit() + + waterline = ocl.Waterline() + waterline.setSTL(oclSTL) + waterline.setCutter(cutter) + waterline.setSampling(0.1) # TODO: add sampling setting to UI + last_pos = [0, 0, 0] + for count, height in enumerate(layers): + layer_chunks = [] + await progress_async("Waterline", int((100*count)/len(layers))) + waterline.reset() + waterline.setZ(height * OCL_SCALE) + waterline.run2() + wl_loops = waterline.getLoops() + for l in wl_loops: + inpoints = [] + for p in l: + inpoints.append((p.x / OCL_SCALE, p.y / OCL_SCALE, p.z / OCL_SCALE)) + inpoints.append(inpoints[0]) + chunk = camPathChunk(inpoints=inpoints) + chunk.closed = True + layer_chunks.append(chunk) + # sort chunks so that ordering is stable + chunks.extend(await utils.sortChunks(layer_chunks, operation, last_pos=last_pos)) + if len(chunks) > 0: + last_pos = chunks[-1].get_point(-1) + +# def oclFillMedialAxis(operation): diff --git a/scripts/addons/cam/ops.py b/scripts/addons/cam/ops.py index 3714048e..d06400e4 100644 --- a/scripts/addons/cam/ops.py +++ b/scripts/addons/cam/ops.py @@ -52,7 +52,24 @@ class threadCom: # object passed to threads to read background process stdout i def threadread(tcom): - """Reads Stdout of Background Process, Done This Way to Have It Non-blocking""" + """Reads the standard output of a background process in a non-blocking + manner. + + This function reads a line from the standard output of a background + process associated with the provided `tcom` object. It searches for a + specific substring that indicates progress information, and if found, + extracts that information and assigns it to the `outtext` attribute of + the `tcom` object. This allows for real-time monitoring of the + background process's output without blocking the main thread. + + Args: + tcom (object): An object that has a `proc` attribute with a `stdout` + stream from which to read the output. + + Returns: + None: This function does not return a value; it modifies the `tcom` + object in place. + """ inline = tcom.proc.stdout.readline() inline = str(inline) s = inline.find('progress{') @@ -63,7 +80,19 @@ def threadread(tcom): @bpy.app.handlers.persistent def timer_update(context): - """Monitoring of Background Processes""" + """Monitor background processes related to camera path calculations. + + This function checks the status of background processes that are + responsible for calculating camera paths. It retrieves the current + processes and monitors their state. If a process has finished, it + updates the corresponding camera operation and reloads the necessary + paths. If the process is still running, it restarts the associated + thread to continue monitoring. + + Args: + context: The context in which the function is called, typically + containing information about the current scene and operations. + """ text = '' s = bpy.context.scene if hasattr(bpy.ops.object.calculate_cam_paths_background.__class__, 'cam_processes'): @@ -104,6 +133,22 @@ class PathsBackground(Operator): bl_options = {'REGISTER', 'UNDO'} def execute(self, context): + """Execute the camera operation in the background. + + This method initiates a background process to perform camera operations + based on the current scene and active camera operation. It sets up the + necessary paths for the script and starts a subprocess to handle the + camera computations. Additionally, it manages threading to ensure that + the main thread remains responsive while the background operation is + executed. + + Args: + context: The context in which the operation is executed. + + Returns: + dict: A dictionary indicating the completion status of the operation. + """ + s = bpy.context.scene o = s.cam_operations[s.cam_active_operation] self.operation = o @@ -139,6 +184,22 @@ class KillPathsBackground(Operator): bl_options = {'REGISTER', 'UNDO'} def execute(self, context): + """Execute the camera operation in the given context. + + This method retrieves the active camera operation from the scene and + checks if there are any ongoing processes related to camera path + calculations. If such processes exist and match the current operation, + they are terminated. The method then marks the operation as not + computing and returns a status indicating that the execution has + finished. + + Args: + context: The context in which the operation is executed. + + Returns: + dict: A dictionary with a status key indicating the result of the execution. + """ + s = bpy.context.scene o = s.cam_operations[s.cam_active_operation] self.operation = o @@ -156,6 +217,28 @@ class KillPathsBackground(Operator): async def _calc_path(operator, context): + """Calculate the path for a given operator and context. + + This function processes the current scene's camera operations based on + the specified operator and context. It handles different geometry + sources, checks for valid operation parameters, and manages the + visibility of objects and collections. The function also retrieves the + path using an asynchronous operation and handles any exceptions that may + arise during this process. If the operation is invalid or if certain + conditions are not met, appropriate error messages are reported to the + operator. + + Args: + operator (bpy.types.Operator): The operator that initiated the path calculation. + context (bpy.types.Context): The context in which the operation is executed. + + Returns: + tuple: A tuple indicating the status of the operation. + Returns {'FINISHED', True} if successful, + {'FINISHED', False} if there was an error, + or {'CANCELLED', False} if the operation was cancelled. + """ + s = bpy.context.scene o = s.cam_operations[s.cam_active_operation] if o.geometry_source == 'OBJECT': @@ -226,6 +309,20 @@ class CalculatePath(Operator, AsyncOperatorMixin): @classmethod def poll(cls, context): + """Check if the current camera operation is valid. + + This method checks the active camera operation in the given context and + determines if it is valid. It retrieves the active operation from the + scene's camera operations and validates it using the `isValid` function. + If the operation is valid, it returns True; otherwise, it returns False. + + Args: + context (Context): The context containing the scene and camera operations. + + Returns: + bool: True if the active camera operation is valid, False otherwise. + """ + s = context.scene o = s.cam_operations[s.cam_active_operation] if o is not None: @@ -234,6 +331,20 @@ class CalculatePath(Operator, AsyncOperatorMixin): return False async def execute_async(self, context): + """Execute an asynchronous calculation of a path. + + This method performs an asynchronous operation to calculate a path based + on the provided context. It awaits the result of the calculation and + prints the success status along with the return value. The return value + can be used for further processing or analysis. + + Args: + context (Any): The context in which the path calculation is to be executed. + + Returns: + Any: The result of the path calculation. + """ + (retval, success) = await _calc_path(self, context) print(f"CALCULATED PATH (success={success},retval={retval}") return retval @@ -246,6 +357,22 @@ class PathsAll(Operator): bl_options = {'REGISTER', 'UNDO'} def execute(self, context): + """Execute camera operations in the current Blender context. + + This function iterates through the camera operations defined in the + current scene and executes the background calculation for each + operation. It sets the active camera operation index and prints the name + of each operation being processed. This is typically used in a Blender + add-on or script to automate camera path calculations. + + Args: + context (bpy.context): The current Blender context. + + Returns: + dict: A dictionary indicating the completion status of the operation, + typically {'FINISHED'}. + """ + i = 0 for o in bpy.context.scene.cam_operations: bpy.context.scene.cam_active_operation = i @@ -257,6 +384,17 @@ class PathsAll(Operator): return {'FINISHED'} def draw(self, context): + """Draws the user interface elements for the operation selection. + + This method utilizes the Blender layout system to create a property + search interface for selecting operations related to camera + functionalities. It links the current instance's operation property to + the available camera operations defined in the Blender scene. + + Args: + context (bpy.context): The context in which the drawing occurs, + """ + layout = self.layout layout.prop_search(self, "operation", bpy.context.scene, "cam_operations") @@ -269,6 +407,20 @@ class CamPackObjects(Operator): bl_options = {'REGISTER', 'UNDO'} def execute(self, context): + """Execute the operation in the given context. + + This function sets the Blender object mode to 'OBJECT', retrieves the + currently selected objects, and calls the `packCurves` function from the + `pack` module. It is typically used to finalize operations on selected + objects in Blender. + + Args: + context: The context in which the operation is executed. + + Returns: + dict: A dictionary indicating the completion status of the operation. + """ + bpy.ops.object.mode_set(mode='OBJECT') # force object mode obs = bpy.context.selected_objects pack.packCurves() @@ -287,6 +439,21 @@ class CamSliceObjects(Operator): bl_options = {'REGISTER', 'UNDO'} def execute(self, context): + """Execute the slicing operation on the active Blender object. + + This function retrieves the currently active object in the Blender + context and performs a slicing operation on it using the `sliceObject` + function from the `cam` module. The operation is intended to modify the + object based on the slicing logic defined in the external module. + + Args: + context: The context in which the operation is executed. + + Returns: + dict: A dictionary indicating the result of the operation, + typically containing the key 'FINISHED' upon successful execution. + """ + from cam import slice ob = bpy.context.active_object slice.sliceObject(ob) @@ -297,7 +464,20 @@ class CamSliceObjects(Operator): def getChainOperations(chain): - """Return Chain Operations, Currently Chain Object Can't Store Operations Directly Due to Blender Limitations""" + """Return chain operations associated with a given chain object. + + This function iterates through the operations of the provided chain + object and retrieves the corresponding operations from the current + scene's camera operations in Blender. Due to limitations in Blender, + chain objects cannot store operations directly, so this function serves + to extract and return the relevant operations for further processing. + + Args: + chain (object): The chain object from which to retrieve operations. + + Returns: + list: A list of operations associated with the given chain object. + """ chop = [] for cho in chain.operations: for so in bpy.context.scene.cam_operations: @@ -314,11 +494,40 @@ class PathsChain(Operator, AsyncOperatorMixin): @classmethod def poll(cls, context): + """Check the validity of the active camera chain in the given context. + + This method retrieves the active camera chain from the scene and checks + its validity using the `isChainValid` function. It returns a boolean + value indicating whether the camera chain is valid or not. + + Args: + context (Context): The context containing the scene and camera chain information. + + Returns: + bool: True if the active camera chain is valid, False otherwise. + """ + s = context.scene chain = s.cam_chains[s.cam_active_chain] return isChainValid(chain, context)[0] async def execute_async(self, context): + """Execute asynchronous operations for camera path calculations. + + This method sets the object mode for the Blender scene and processes a + series of camera operations defined in the active camera chain. It + reports the progress of each operation and handles any exceptions that + may occur during the path calculation. After successful calculations, it + exports the resulting mesh data to a specified G-code file. + + Args: + context (bpy.context): The Blender context containing scene and + + Returns: + dict: A dictionary indicating the result of the operation, + typically {'FINISHED'}. + """ + s = context.scene bpy.ops.object.mode_set(mode='OBJECT') # force object mode chain = s.cam_chains[s.cam_active_chain] @@ -353,11 +562,41 @@ class PathExportChain(Operator): @classmethod def poll(cls, context): + """Check the validity of the active camera chain in the given context. + + This method retrieves the currently active camera chain from the scene + context and checks its validity using the `isChainValid` function. It + returns a boolean indicating whether the active camera chain is valid or + not. + + Args: + context (object): The context containing the scene and camera chain information. + + Returns: + bool: True if the active camera chain is valid, False otherwise. + """ + s = context.scene chain = s.cam_chains[s.cam_active_chain] return isChainValid(chain, context)[0] def execute(self, context): + """Execute the camera path export process. + + This function retrieves the active camera chain from the current scene + and gathers the mesh data associated with the operations of that chain. + It then exports the G-code path using the specified filename and the + collected mesh data. The function is designed to be called within the + context of a Blender operator. + + Args: + context (bpy.context): The context in which the operator is executed. + + Returns: + dict: A dictionary indicating the completion status of the operation, + typically {'FINISHED'}. + """ + s = bpy.context.scene chain = s.cam_chains[s.cam_active_chain] @@ -380,6 +619,22 @@ class PathExport(Operator): bl_options = {'REGISTER', 'UNDO'} def execute(self, context): + """Execute the camera operation and export the G-code path. + + This method retrieves the active camera operation from the current scene + and exports the corresponding G-code path to a specified filename. It + prints the filename and relevant operation details to the console for + debugging purposes. The G-code path is generated based on the camera + path data associated with the active operation. + + Args: + context: The context in which the operation is executed. + + Returns: + dict: A dictionary indicating the completion status of the operation, + typically {'FINISHED'}. + """ + s = bpy.context.scene operation = s.cam_operations[s.cam_active_operation] @@ -407,6 +662,24 @@ class CAMSimulate(Operator, AsyncOperatorMixin): ) async def execute_async(self, context): + """Execute an asynchronous simulation operation based on the active camera + operation. + + This method retrieves the current scene and the active camera operation. + It constructs the operation name and checks if the corresponding object + exists in the Blender data. If it does, it attempts to run the + simulation asynchronously. If the simulation is cancelled, it returns a + cancellation status. If the object does not exist, it reports an error + and returns a finished status. + + Args: + context: The context in which the operation is executed. + + Returns: + dict: A dictionary indicating the status of the operation, either + {'CANCELLED'} or {'FINISHED'}. + """ + s = bpy.context.scene operation = s.cam_operations[s.cam_active_operation] @@ -423,6 +696,18 @@ class CAMSimulate(Operator, AsyncOperatorMixin): return {'FINISHED'} def draw(self, context): + """Draws the user interface for selecting camera operations. + + This method creates a layout element in the user interface that allows + users to search and select a specific camera operation from a list of + available operations defined in the current scene. It utilizes the + Blender Python API to integrate with the UI. + + Args: + context: The context in which the drawing occurs, typically + provided by Blender's UI system. + """ + layout = self.layout layout.prop_search(self, "operation", bpy.context.scene, "cam_operations") @@ -437,6 +722,20 @@ class CAMSimulateChain(Operator, AsyncOperatorMixin): @classmethod def poll(cls, context): + """Check the validity of the active camera chain in the scene. + + This method retrieves the currently active camera chain from the scene's + camera chains and checks its validity using the `isChainValid` function. + It returns a boolean indicating whether the active camera chain is + valid. + + Args: + context (object): The context containing the scene and its properties. + + Returns: + bool: True if the active camera chain is valid, False otherwise. + """ + s = context.scene chain = s.cam_chains[s.cam_active_chain] return isChainValid(chain, context)[0] @@ -448,6 +747,23 @@ class CAMSimulateChain(Operator, AsyncOperatorMixin): ) async def execute_async(self, context): + """Execute an asynchronous simulation for a specified camera chain. + + This method retrieves the active camera chain from the current Blender + scene and determines the operations associated with that chain. It + checks if all operations are valid and can be simulated. If valid, it + proceeds to execute the simulation asynchronously. If any operation is + invalid, it logs a message and returns a finished status without + performing the simulation. + + Args: + context: The context in which the operation is executed. + + Returns: + dict: A dictionary indicating the status of the operation, either + operation completed successfully. + """ + s = bpy.context.scene chain = s.cam_chains[s.cam_active_chain] chainops = getChainOperations(chain) @@ -468,6 +784,18 @@ class CAMSimulateChain(Operator, AsyncOperatorMixin): return {'FINISHED'} def draw(self, context): + """Draw the user interface for selecting camera operations. + + This function creates a user interface element that allows the user to + search and select a specific camera operation from a list of available + operations in the current scene. It utilizes the Blender Python API to + create a property search layout. + + Args: + context: The context in which the drawing occurs, typically containing + information about the current scene and UI elements. + """ + layout = self.layout layout.prop_search(self, "operation", bpy.context.scene, "cam_operations") @@ -484,6 +812,21 @@ class CamChainAdd(Operator): return context.scene is not None def execute(self, context): + """Execute the camera chain creation in the given context. + + This function adds a new camera chain to the current scene in Blender. + It updates the active camera chain index and assigns a name and filename + to the newly created chain. The function is intended to be called within + a Blender operator context. + + Args: + context: The context in which the operation is executed. + + Returns: + dict: A dictionary indicating the operation's completion status, + specifically returning {'FINISHED'} upon successful execution. + """ + # main(context) s = bpy.context.scene s.cam_chains.add() @@ -507,6 +850,20 @@ class CamChainRemove(Operator): return context.scene is not None def execute(self, context): + """Execute the camera chain removal process. + + This function removes the currently active camera chain from the scene + and decrements the active camera chain index if it is greater than zero. + It modifies the Blender context to reflect these changes. + + Args: + context: The context in which the function is executed. + + Returns: + dict: A dictionary indicating the status of the operation, + specifically {'FINISHED'} upon successful execution. + """ + bpy.context.scene.cam_chains.remove(bpy.context.scene.cam_active_chain) if bpy.context.scene.cam_active_chain > 0: bpy.context.scene.cam_active_chain -= 1 @@ -525,6 +882,21 @@ class CamChainOperationAdd(Operator): return context.scene is not None def execute(self, context): + """Execute an operation in the active camera chain. + + This function retrieves the active camera chain from the current scene + and adds a new operation to it. It increments the active operation index + and assigns the name of the currently selected camera operation to the + newly added operation. This is typically used in the context of managing + camera operations in a 3D environment. + + Args: + context: The context in which the operation is executed. + + Returns: + dict: A dictionary indicating the execution status, typically {'FINISHED'}. + """ + s = bpy.context.scene chain = s.cam_chains[s.cam_active_chain] s = bpy.context.scene @@ -545,6 +917,22 @@ class CamChainOperationUp(Operator): return context.scene is not None def execute(self, context): + """Execute the operation to move the active camera operation in the chain. + + This function retrieves the current scene and the active camera chain. + If there is an active operation (i.e., its index is greater than 0), it + moves the operation one step up in the chain by adjusting the indices + accordingly. After moving the operation, it updates the active operation + index to reflect the change. + + Args: + context: The context in which the operation is executed. + + Returns: + dict: A dictionary indicating the result of the operation, + specifically returning {'FINISHED'} upon successful execution. + """ + s = bpy.context.scene chain = s.cam_chains[s.cam_active_chain] a = chain.active_operation @@ -565,6 +953,21 @@ class CamChainOperationDown(Operator): return context.scene is not None def execute(self, context): + """Execute the operation to move the active camera operation in the chain. + + This function retrieves the current scene and the active camera chain. + It checks if the active operation can be moved down in the list of + operations. If so, it moves the active operation one position down and + updates the active operation index accordingly. + + Args: + context: The context in which the operation is executed. + + Returns: + dict: A dictionary indicating the result of the operation, + specifically {'FINISHED'} when the operation completes successfully. + """ + s = bpy.context.scene chain = s.cam_chains[s.cam_active_chain] a = chain.active_operation @@ -585,6 +988,23 @@ class CamChainOperationRemove(Operator): return context.scene is not None def execute(self, context): + """Execute the operation to remove the active operation from the camera + chain. + + This method accesses the current scene and retrieves the active camera + chain. It then removes the currently active operation from that chain + and adjusts the index of the active operation accordingly. If the active + operation index becomes negative, it resets it to zero to ensure it + remains within valid bounds. + + Args: + context: The context in which the operation is executed. + + Returns: + dict: A dictionary indicating the execution status, typically + containing {'FINISHED'} upon successful completion. + """ + s = bpy.context.scene chain = s.cam_chains[s.cam_active_chain] chain.operations.remove(chain.active_operation) @@ -595,7 +1015,13 @@ class CamChainOperationRemove(Operator): def fixUnits(): - """Sets up Units for BlenderCAM""" + """Set up units for BlenderCAM. + + This function configures the unit settings for the current Blender + scene. It sets the rotation system to degrees and the scale length to + 1.0, ensuring that the units are appropriately configured for use within + BlenderCAM. + """ s = bpy.context.scene s.unit_settings.system_rotation = 'DEGREES' @@ -615,6 +1041,19 @@ class CamOperationAdd(Operator): return context.scene is not None def execute(self, context): + """Execute the camera operation based on the active object in the scene. + + This method retrieves the active object from the Blender context and + performs operations related to camera settings. It checks if an object + is selected and retrieves its bounding box dimensions. If no object is + found, it reports an error and cancels the operation. If an object is + present, it adds a new camera operation to the scene, sets its + properties, and ensures that a machine area object is present. + + Args: + context: The context in which the operation is executed. + """ + s = bpy.context.scene fixUnits() @@ -652,6 +1091,25 @@ class CamOperationCopy(Operator): return context.scene is not None def execute(self, context): + """Execute the camera operation in the given context. + + This method handles the execution of camera operations within the + Blender scene. It first checks if there are any camera operations + available. If not, it returns a cancellation status. If there are + operations, it copies the active operation, increments the active + operation index, and updates the name and filename of the new operation. + The function also ensures that the new operation's name is unique by + appending a copy suffix or incrementing a numeric suffix. + + Args: + context: The context in which the operation is executed. + + Returns: + dict: A dictionary indicating the status of the operation, + either {'CANCELLED'} if no operations are available or + {'FINISHED'} if the operation was successfully executed. + """ + # main(context) scene = bpy.context.scene @@ -703,6 +1161,24 @@ class CamOperationRemove(Operator): return context.scene is not None def execute(self, context): + """Execute the camera operation in the given context. + + This function performs the active camera operation by deleting the + associated object from the scene. It checks if there are any camera + operations available and handles the deletion of the active operation's + object. If the active operation is removed, it updates the active + operation index accordingly. Additionally, it manages a dictionary that + tracks hidden objects. + + Args: + context (bpy.context): The Blender context containing the scene and operations. + + Returns: + dict: A dictionary indicating the result of the operation, either + {'CANCELLED'} if no operations are available or {'FINISHED'} if the + operation was successfully executed. + """ + scene = context.scene try: if len(scene.cam_operations) == 0: @@ -748,6 +1224,23 @@ class CamOperationMove(Operator): return context.scene is not None def execute(self, context): + """Execute a camera operation based on the specified direction. + + This method modifies the active camera operation in the Blender context + based on the direction specified. If the direction is 'UP', it moves the + active operation up in the list, provided it is not already at the top. + Conversely, if the direction is not 'UP', it moves the active operation + down in the list, as long as it is not at the bottom. The method updates + the active operation index accordingly. + + Args: + context: The context in which the operation is executed. + + Returns: + dict: A dictionary indicating the operation has finished, with + the key 'FINISHED'. + """ + # main(context) a = bpy.context.scene.cam_active_operation cops = bpy.context.scene.cam_operations @@ -775,6 +1268,22 @@ class CamOrientationAdd(Operator): return context.scene is not None def execute(self, context): + """Execute the camera orientation operation in Blender. + + This function retrieves the active camera operation from the current + scene, creates an empty object to represent the camera orientation, and + adds it to a specified group. The empty object is named based on the + operation's name and the current count of objects in the group. The size + of the empty object is set to a predefined value for visibility. + + Args: + context: The context in which the operation is executed. + + Returns: + dict: A dictionary indicating the operation's completion status, + typically {'FINISHED'}. + """ + s = bpy.context.scene a = s.cam_active_operation o = s.cam_operations[a] @@ -802,6 +1311,21 @@ class CamBridgesAdd(Operator): return context.scene is not None def execute(self, context): + """Execute the camera operation in the given context. + + This function retrieves the active camera operation from the current + scene and adds automatic bridges to it. It is typically called within + the context of a Blender operator to perform specific actions related to + camera operations. + + Args: + context: The context in which the operation is executed. + + Returns: + dict: A dictionary indicating the result of the operation, typically + containing the key 'FINISHED' to signify successful completion. + """ + s = bpy.context.scene a = s.cam_active_operation o = s.cam_operations[a] diff --git a/scripts/addons/cam/pack.py b/scripts/addons/cam/pack.py index cda5f0e4..8a3595b3 100644 --- a/scripts/addons/cam/pack.py +++ b/scripts/addons/cam/pack.py @@ -40,6 +40,24 @@ from . import ( def srotate(s, r, x, y): + """Rotate a polygon's coordinates around a specified point. + + This function takes a polygon and rotates its exterior coordinates + around a given point (x, y) by a specified angle (r) in radians. It uses + the Euler rotation to compute the new coordinates for each point in the + polygon's exterior. The resulting coordinates are then used to create a + new polygon. + + Args: + s (shapely.geometry.Polygon): The polygon to be rotated. + r (float): The angle of rotation in radians. + x (float): The x-coordinate of the point around which to rotate. + y (float): The y-coordinate of the point around which to rotate. + + Returns: + shapely.geometry.Polygon: A new polygon with the rotated coordinates. + """ + ncoords = [] e = Euler((0, 0, r)) for p in s.exterior.coords: @@ -53,6 +71,23 @@ def srotate(s, r, x, y): def packCurves(): + """Pack selected curves into a defined area based on specified settings. + + This function organizes selected curve objects in Blender by packing + them into a specified area defined by the camera pack settings. It + calculates the optimal positions for each curve while considering + parameters such as sheet size, fill direction, distance, tolerance, and + rotation. The function utilizes geometric operations to ensure that the + curves do not overlap and fit within the defined boundaries. The packed + curves are then transformed and their properties are updated + accordingly. The function performs the following steps: 1. Activates + speedup features if available. 2. Retrieves packing settings from the + current scene. 3. Processes each selected object to create polygons from + curves. 4. Attempts to place each polygon within the defined area while + avoiding overlaps and respecting the specified fill direction. 5. + Outputs the final arrangement of polygons. + """ + if speedups.available: speedups.enable() t = time.time() diff --git a/scripts/addons/cam/pattern.py b/scripts/addons/cam/pattern.py index 516e895e..92dd9885 100644 --- a/scripts/addons/cam/pattern.py +++ b/scripts/addons/cam/pattern.py @@ -30,6 +30,26 @@ from .simple import progress def getPathPatternParallel(o, angle): + """Generate path chunks for parallel movement based on object dimensions + and angle. + + This function calculates a series of path chunks for a given object, + taking into account its dimensions and the specified angle. It utilizes + both a traditional method and an alternative algorithm (currently + disabled) to generate these paths. The paths are constructed by + iterating over calculated vectors and applying transformations based on + the object's properties. The resulting path chunks can be used for + various movement types, including conventional and climb movements. + + Args: + o (object): An object containing properties such as dimensions and movement type. + angle (float): The angle to rotate the path generation. + + Returns: + list: A list of path chunks generated based on the object's dimensions and + angle. + """ + zlevel = 1 pathd = o.dist_between_paths pathstep = o.dist_along_paths @@ -133,6 +153,25 @@ def getPathPatternParallel(o, angle): def getPathPattern(operation): + """Generate a path pattern based on the specified operation strategy. + + This function constructs a path pattern for a given operation by + analyzing its parameters and applying different strategies such as + 'PARALLEL', 'CROSS', 'BLOCK', 'SPIRAL', 'CIRCLES', and 'OUTLINEFILL'. + Each strategy dictates how the path is built, utilizing various + geometric calculations and conditions to ensure the path adheres to the + specified operational constraints. The function also handles the + orientation and direction of the path based on the movement settings + provided in the operation. + + Args: + operation (object): An object containing parameters for path generation, + including strategy, movement type, and geometric bounds. + + Returns: + list: A list of path chunks representing the generated path pattern. + """ + o = operation t = time.time() progress('Building Path Pattern') @@ -391,6 +430,23 @@ def getPathPattern(operation): def getPathPattern4axis(operation): + """Generate path patterns for a specified operation along a rotary axis. + + This function constructs a series of path chunks based on the provided + operation's parameters, including the rotary axis, strategy, and + dimensions. It calculates the necessary angles and positions for the + cutter based on the specified strategy (PARALLELR, PARALLEL, or HELIX) + and generates the corresponding path chunks for machining operations. + + Args: + operation (object): An object containing parameters for the machining operation, + including min and max coordinates, rotary axis configuration, + distance settings, and movement strategy. + + Returns: + list: A list of path chunks generated for the specified operation. + """ + o = operation t = time.time() progress('Building Path Pattern') diff --git a/scripts/addons/cam/polygon_utils_cam.py b/scripts/addons/cam/polygon_utils_cam.py index b622bc7f..4cc9d574 100644 --- a/scripts/addons/cam/polygon_utils_cam.py +++ b/scripts/addons/cam/polygon_utils_cam.py @@ -20,6 +20,22 @@ SHAPELY = True def Circle(r, np): + """Generate a circle defined by a given radius and number of points. + + This function creates a polygon representing a circle by generating a + list of points based on the specified radius and the number of points + (np). It uses vector rotation to calculate the coordinates of each point + around the circle. The resulting points are then used to create a + polygon object. + + Args: + r (float): The radius of the circle. + np (int): The number of points to generate around the circle. + + Returns: + spolygon.Polygon: A polygon object representing the circle. + """ + c = [] v = Vector((r, 0, 0)) e = Euler((0, 0, 2.0 * pi / np)) @@ -32,6 +48,23 @@ def Circle(r, np): def shapelyRemoveDoubles(p, optimize_threshold): + """Remove duplicate points from the boundary of a shape. + + This function simplifies the boundary of a given shape by removing + duplicate points using the Ramer-Douglas-Peucker algorithm. It iterates + through each contour of the shape, applies the simplification, and adds + the resulting contours to a new shape. The optimization threshold can be + adjusted to control the level of simplification. + + Args: + p (Shape): The shape object containing boundaries to be simplified. + optimize_threshold (float): A threshold value that influences the + simplification process. + + Returns: + Shape: A new shape object with simplified boundaries. + """ + optimize_threshold *= 0.000001 soptions = ['distance', 'distance', 0.0, 5, optimize_threshold, 5, optimize_threshold] @@ -53,6 +86,24 @@ def shapelyRemoveDoubles(p, optimize_threshold): def shapelyToMultipolygon(anydata): + """Convert a Shapely geometry to a MultiPolygon. + + This function takes a Shapely geometry object and converts it to a + MultiPolygon. If the input geometry is already a MultiPolygon, it + returns it as is. If the input is a Polygon and not empty, it wraps the + Polygon in a MultiPolygon. If the input is an empty Polygon, it returns + an empty MultiPolygon. For any other geometry type, it prints a message + indicating that the conversion was aborted and returns an empty + MultiPolygon. + + Args: + anydata (shapely.geometry.base.BaseGeometry): A Shapely geometry object + + Returns: + shapely.geometry.MultiPolygon: A MultiPolygon representation of the input + geometry. + """ + if anydata.geom_type == 'MultiPolygon': return anydata elif anydata.geom_type == 'Polygon': @@ -66,6 +117,24 @@ def shapelyToMultipolygon(anydata): def shapelyToCoords(anydata): + """Convert a Shapely geometry object to a list of coordinates. + + This function takes a Shapely geometry object and extracts its + coordinates based on the geometry type. It handles various types of + geometries including Polygon, MultiPolygon, LineString, MultiLineString, + and GeometryCollection. If the geometry is empty or of type MultiPoint, + it returns an empty list. The coordinates are returned in a nested list + format, where each sublist corresponds to the exterior or interior + coordinates of the geometries. + + Args: + anydata (shapely.geometry.base.BaseGeometry): A Shapely geometry object + + Returns: + list: A list of coordinates extracted from the input geometry. + The structure of the list depends on the geometry type. + """ + p = anydata seq = [] # print(p.type) @@ -116,6 +185,24 @@ def shapelyToCoords(anydata): def shapelyToCurve(name, p, z): + """Create a 3D curve object in Blender from a Shapely geometry. + + This function takes a Shapely geometry and converts it into a 3D curve + object in Blender. It extracts the coordinates from the Shapely geometry + and creates a new curve object with the specified name. The curve is + created in the 3D space at the given z-coordinate, with a default weight + for the points. + + Args: + name (str): The name of the curve object to be created. + p (shapely.geometry): A Shapely geometry object from which to extract + coordinates. + z (float): The z-coordinate for all points of the curve. + + Returns: + bpy.types.Object: The newly created curve object in Blender. + """ + import bpy import bmesh from bpy_extras import object_utils diff --git a/scripts/addons/cam/puzzle_joinery.py b/scripts/addons/cam/puzzle_joinery.py index 91ee1c29..eadeec86 100644 --- a/scripts/addons/cam/puzzle_joinery.py +++ b/scripts/addons/cam/puzzle_joinery.py @@ -24,6 +24,24 @@ DT = 1.025 def finger(diameter, stem=2): + """Create a joint shape based on the specified diameter and stem. + + This function generates a 3D joint shape using Blender's curve + operations. It calculates the dimensions of a rectangle and an ellipse + based on the provided diameter and stem parameters. The function then + creates these shapes, duplicates and mirrors them, and performs boolean + operations to form the final joint shape. The resulting object is named + and cleaned up to ensure no overlapping vertices remain. + + Args: + diameter (float): The diameter of the tool for joint creation. + stem (float?): The amount of radius the stem or neck of the joint will have. Defaults + to 2. + + Returns: + None: This function does not return any value. + """ + # diameter = diameter of the tool for joint creation # DT = Bit diameter tolerance # stem = amount of radius the stem or neck of the joint will have @@ -77,6 +95,22 @@ def finger(diameter, stem=2): def fingers(diameter, inside, amount=1, stem=1): + """Create a specified number of fingers for a joint tool. + + This function generates a set of fingers based on the provided diameter + and tolerance values. It calculates the necessary translations for + positioning the fingers and duplicates them if more than one is + required. Additionally, it creates a receptacle using a silhouette + offset from the fingers, allowing for precise joint creation. + + Args: + diameter (float): The diameter of the tool used for joint creation. + inside (float): The tolerance in the joint receptacle. + amount (int?): The number of fingers to create. Defaults to 1. + stem (float?): The amount of radius the stem or neck of the joint will have. Defaults + to 1. + """ + # diameter = diameter of the tool for joint creation # inside = Tolerance in the joint receptacle global DT # Bit diameter tolerance @@ -107,6 +141,28 @@ def fingers(diameter, inside, amount=1, stem=1): def twistf(name, length, diameter, tolerance, twist, tneck, tthick, twist_keep=False): + """Add a twist lock to a receptacle. + + This function modifies the receptacle by adding a twist lock feature if + the `twist` parameter is set to True. It performs several operations + including interlocking the twist, rotating the object, and moving it to + the correct position. If `twist_keep` is True, it duplicates the twist + lock for further modifications. The function utilizes parameters such as + length, diameter, tolerance, and thickness to accurately create the + twist lock. + + Args: + name (str): The name of the receptacle to be modified. + length (float): The length of the receptacle. + diameter (float): The diameter of the receptacle. + tolerance (float): The tolerance value for the twist lock. + twist (bool): A flag indicating whether to add a twist lock. + tneck (float): The neck thickness for the twist lock. + tthick (float): The thickness of the twist lock. + twist_keep (bool?): A flag indicating whether to keep the twist + lock after duplication. Defaults to False. + """ + # add twist lock to receptacle if twist: joinery.interlock_twist(length, tthick, tolerance, cx=0, cy=0, rotation=0, percentage=tneck) @@ -123,6 +179,34 @@ def twistf(name, length, diameter, tolerance, twist, tneck, tthick, twist_keep=F def twistm(name, length, diameter, tolerance, twist, tneck, tthick, angle, twist_keep=False, x=0, y=0): + """Add a twist lock to a male connector. + + This function modifies the geometry of a male connector by adding a + twist lock feature. It utilizes various parameters to determine the + dimensions and positioning of the twist lock. If the `twist_keep` + parameter is set to True, it duplicates the twist lock for further + modifications. The function also allows for adjustments in position + through the `x` and `y` parameters. + + Args: + name (str): The name of the connector to be modified. + length (float): The length of the connector. + diameter (float): The diameter of the connector. + tolerance (float): The tolerance level for the twist lock. + twist (bool): A flag indicating whether to add a twist lock. + tneck (float): The neck thickness for the twist lock. + tthick (float): The thickness of the twist lock. + angle (float): The angle at which to rotate the twist lock. + twist_keep (bool?): A flag indicating whether to keep the twist lock duplicate. Defaults to + False. + x (float?): The x-coordinate for positioning. Defaults to 0. + y (float?): The y-coordinate for positioning. Defaults to 0. + + Returns: + None: This function modifies the state of the connector but does not return a + value. + """ + # add twist lock to male connector global DT if twist: @@ -143,6 +227,39 @@ def twistm(name, length, diameter, tolerance, twist, tneck, tthick, angle, twist def bar(width, thick, diameter, tolerance, amount=0, stem=1, twist=False, tneck=0.5, tthick=0.01, twist_keep=False, twist_line=False, twist_line_amount=2, which='MF'): + """Create a bar with specified dimensions and joint features. + + This function generates a bar with customizable parameters such as + width, thickness, and joint characteristics. It can automatically + determine the number of fingers in the joint if the amount is set to + zero. The function also supports various options for twisting and neck + dimensions, allowing for flexible design of the bar according to the + specified parameters. The resulting bar can be manipulated further based + on the provided options. + + Args: + width (float): The length of the bar. + thick (float): The thickness of the bar. + diameter (float): The diameter of the tool used for joint creation. + tolerance (float): The tolerance in the joint. + amount (int?): The number of fingers in the joint; 0 means auto-generate. Defaults to + 0. + stem (float?): The radius of the stem or neck of the joint. Defaults to 1. + twist (bool?): Whether to add a twist lock. Defaults to False. + tneck (float?): The percentage the twist neck will have compared to thickness. Defaults + to 0.5. + tthick (float?): The thickness of the twist material. Defaults to 0.01. + twist_keep (bool?): Whether to keep the twist feature. Defaults to False. + twist_line (bool?): Whether to add a twist line. Defaults to False. + twist_line_amount (int?): The amount for the twist line. Defaults to 2. + which (str?): Specifies the type of joint; options are 'M', 'F', 'MF', 'MM', 'FF'. + Defaults to 'MF'. + + Returns: + None: This function does not return a value but modifies the state of the 3D + model in Blender. + """ + # width = length of the bar # thick = thickness of the bar @@ -203,6 +320,36 @@ def bar(width, thick, diameter, tolerance, amount=0, stem=1, twist=False, tneck= def arc(radius, thick, angle, diameter, tolerance, amount=0, stem=1, twist=False, tneck=0.5, tthick=0.01, twist_keep=False, which='MF'): + """Generate an arc with specified parameters. + + This function creates a 3D arc based on the provided radius, thickness, + angle, and other parameters. It handles the generation of fingers for + the joint and applies twisting features if specified. The function also + manages the orientation and positioning of the generated arc in a 3D + space. + + Args: + radius (float): The radius of the curve. + thick (float): The thickness of the bar. + angle (float): The angle of the arc (must not be zero). + diameter (float): The diameter of the tool for joint creation. + tolerance (float): Tolerance in the joint. + amount (int?): The amount of fingers in the joint; 0 means auto-generate. Defaults to + 0. + stem (float?): The amount of radius the stem or neck of the joint will have. Defaults + to 1. + twist (bool?): Whether to add a twist lock. Defaults to False. + tneck (float?): Percentage the twist neck will have compared to thickness. Defaults to + 0.5. + tthick (float?): Thickness of the twist material. Defaults to 0.01. + twist_keep (bool?): Whether to keep the twist. Defaults to False. + which (str?): Specifies which joint to generate ('M', 'F', 'MF'). Defaults to 'MF'. + + Returns: + None: This function does not return a value but modifies the 3D scene + directly. + """ + # radius = radius of the curve # thick = thickness of the bar # angle = angle of the arc @@ -289,6 +436,41 @@ def arc(radius, thick, angle, diameter, tolerance, amount=0, stem=1, twist=False def arcbararc(length, radius, thick, angle, angleb, diameter, tolerance, amount=0, stem=1, twist=False, tneck=0.5, tthick=0.01, which='MF', twist_keep=False, twist_line=False, twist_line_amount=2): + """Generate an arc bar joint with specified parameters. + + This function creates a joint consisting of male and female sections + based on the provided parameters. It adjusts the length to account for + the radius and thickness, generates a base rectangle, and then + constructs the male and/or female sections as specified. Additionally, + it can create a twist lock feature if required. The function utilizes + Blender's bpy operations to manipulate 3D objects. + + Args: + length (float): The total width of the segments including 2 * radius and thickness. + radius (float): The radius of the curve. + thick (float): The thickness of the bar. + angle (float): The angle of the female part. + angleb (float): The angle of the male part. + diameter (float): The diameter of the tool for joint creation. + tolerance (float): Tolerance in the joint. + amount (int?): The number of fingers in the joint; 0 means auto-generate. Defaults to + 0. + stem (float?): The amount of radius the stem or neck of the joint will have. Defaults + to 1. + twist (bool?): Whether to add a twist lock feature. Defaults to False. + tneck (float?): Percentage the twist neck will have compared to thickness. Defaults to + 0.5. + tthick (float?): Thickness of the twist material. Defaults to 0.01. + which (str?): Specifies which joint to generate ('M', 'F', or 'MF'). Defaults to 'MF'. + twist_keep (bool?): Whether to keep the twist after creation. Defaults to False. + twist_line (bool?): Whether to create a twist line feature. Defaults to False. + twist_line_amount (int?): Amount for the twist line feature. Defaults to 2. + + Returns: + None: This function does not return a value but modifies the Blender scene + directly. + """ + # length is the total width of the segments including 2 * radius and thick # radius = radius of the curve # thick = thickness of the bar @@ -345,6 +527,36 @@ def arcbararc(length, radius, thick, angle, angleb, diameter, tolerance, amount= def arcbar(length, radius, thick, angle, diameter, tolerance, amount=0, stem=1, twist=False, tneck=0.5, tthick=0.01, twist_keep=False, which='MF', twist_line=False, twist_line_amount=2): + """Generate an arc bar joint based on specified parameters. + + This function constructs an arc bar joint by generating male and female + sections according to the specified parameters such as length, radius, + thickness, and joint type. The function adjusts the length to account + for the radius and thickness of the bar and creates the appropriate + geometric shapes for the joint. It also includes options for twisting + and adjusting the neck thickness of the joint. + + Args: + length (float): The total width of the segments including 2 * radius and thickness. + radius (float): The radius of the curve. + thick (float): The thickness of the bar. + angle (float): The angle of the female part. + diameter (float): The diameter of the tool for joint creation. + tolerance (float): Tolerance in the joint. + amount (int?): The number of fingers in the joint; 0 means auto-generate. Defaults to + 0. + stem (float?): The amount of radius the stem or neck of the joint will have. Defaults + to 1. + twist (bool?): Whether to add a twist lock. Defaults to False. + tneck (float?): Percentage the twist neck will have compared to thickness. Defaults to + 0.5. + tthick (float?): Thickness of the twist material. Defaults to 0.01. + twist_keep (bool?): Whether to keep the twist. Defaults to False. + which (str?): Specifies which joint to generate ('M', 'F', 'MF'). Defaults to 'MF'. + twist_line (bool?): Whether to include a twist line. Defaults to False. + twist_line_amount (int?): Amount of twist line. Defaults to 2. + """ + # length is the total width of the segments including 2 * radius and thick # radius = radius of the curve # thick = thickness of the bar @@ -403,6 +615,39 @@ def arcbar(length, radius, thick, angle, diameter, tolerance, amount=0, stem=1, def multiangle(radius, thick, angle, diameter, tolerance, amount=0, stem=1, twist=False, tneck=0.5, tthick=0.01, combination='MFF'): + """Generate a multi-angle joint based on specified parameters. + + This function creates a multi-angle joint by generating various + geometric shapes using the provided parameters such as radius, + thickness, angle, diameter, and tolerance. It utilizes Blender's + operations to create and manipulate curves, resulting in a joint that + can be customized with different combinations of male and female parts. + The function also allows for automatic generation of the number of + fingers in the joint and includes options for twisting and neck + dimensions. + + Args: + radius (float): The radius of the curve. + thick (float): The thickness of the bar. + angle (float): The angle of the female part. + diameter (float): The diameter of the tool for joint creation. + tolerance (float): Tolerance in the joint. + amount (int?): The amount of fingers in the joint; 0 means auto-generate. Defaults to + 0. + stem (float?): The amount of radius the stem or neck of the joint will have. Defaults + to 1. + twist (bool?): Indicates if a twist lock addition is required. Defaults to False. + tneck (float?): Percentage the twist neck will have compared to thickness. Defaults to + 0.5. + tthick (float?): Thickness of the twist material. Defaults to 0.01. + combination (str?): Specifies which joint to generate ('M', 'F', 'MF', 'MFF', 'MMF'). + Defaults to 'MFF'. + + Returns: + None: This function does not return a value but performs operations in + Blender. + """ + # length is the total width of the segments including 2 * radius and thick # radius = radius of the curve # thick = thickness of the bar @@ -452,6 +697,33 @@ def multiangle(radius, thick, angle, diameter, tolerance, amount=0, stem=1, twis def t(length, thick, diameter, tolerance, amount=0, stem=1, twist=False, tneck=0.5, tthick=0.01, combination='MF', base_gender='M', corner=False): + """Generate a 3D model based on specified parameters. + + This function creates a 3D model by manipulating geometric shapes based + on the provided parameters. It handles different combinations of shapes + and orientations based on the specified gender and corner options. The + function utilizes several helper functions to perform operations such as + moving, duplicating, and uniting shapes to form the final model. + + Args: + length (float): The length of the model. + thick (float): The thickness of the model. + diameter (float): The diameter of the model. + tolerance (float): The tolerance level for the model dimensions. + amount (int?): The amount of material to use. Defaults to 0. + stem (int?): The stem value for the model. Defaults to 1. + twist (bool?): Whether to apply a twist to the model. Defaults to False. + tneck (float?): The neck thickness. Defaults to 0.5. + tthick (float?): The thickness for the neck. Defaults to 0.01. + combination (str?): The combination type ('MF', 'F', 'M'). Defaults to 'MF'. + base_gender (str?): The base gender for the model ('M' or 'F'). Defaults to 'M'. + corner (bool?): Whether to apply corner adjustments. Defaults to False. + + Returns: + None: This function does not return a value but modifies the 3D model + directly. + """ + if corner: if combination == 'MF': base_gender = 'M' @@ -502,6 +774,35 @@ def t(length, thick, diameter, tolerance, amount=0, stem=1, twist=False, tneck=0 def curved_t(length, thick, radius, diameter, tolerance, amount=0, stem=1, twist=False, tneck=0.5, tthick=0.01, combination='MF', base_gender='M'): + """Create a curved shape based on specified parameters. + + This function generates a 3D curved shape using the provided dimensions + and characteristics. It utilizes the `bar` and `arc` functions to create + the desired geometry and applies transformations such as mirroring and + union operations to achieve the final shape. The function also allows + for customization based on the gender specification, which influences + the shape's design. + + Args: + length (float): The length of the bar. + thick (float): The thickness of the bar. + radius (float): The radius of the arc. + diameter (float): The diameter used in arc creation. + tolerance (float): The tolerance level for the shape. + amount (int?): The amount parameter for the shape generation. Defaults to 0. + stem (int?): The stem parameter for the shape generation. Defaults to 1. + twist (bool?): A flag indicating whether to apply a twist to the shape. Defaults to + False. + tneck (float?): The neck thickness parameter. Defaults to 0.5. + tthick (float?): The thickness parameter for the neck. Defaults to 0.01. + combination (str?): The combination type for the shape. Defaults to 'MF'. + base_gender (str?): The base gender for the shape design. Defaults to 'M'. + + Returns: + None: This function does not return a value but modifies the 3D model in the + environment. + """ + bar(length, thick, diameter, tolerance, amount=amount, stem=stem, twist=twist, tneck=tneck, tthick=tthick, which=combination) simple.active_name('tmpbar') @@ -543,6 +844,32 @@ def curved_t(length, thick, radius, diameter, tolerance, amount=0, stem=1, twist def mitre(length, thick, angle, angleb, diameter, tolerance, amount=0, stem=1, twist=False, tneck=0.5, tthick=0.01, which='MF'): + """Generate a mitre joint based on specified parameters. + + This function creates a 3D representation of a mitre joint using + Blender's bpy.ops.curve.simple operations. It generates a base rectangle + and cutout shapes, then constructs male and female sections of the joint + based on the provided angles and dimensions. The function allows for + customization of various parameters such as thickness, diameter, + tolerance, and the number of fingers in the joint. The resulting joint + can be either male, female, or a combination of both. + + Args: + length (float): The total width of the segments including 2 * radius and thickness. + thick (float): The thickness of the bar. + angle (float): The angle of the female part. + angleb (float): The angle of the male part. + diameter (float): The diameter of the tool for joint creation. + tolerance (float): Tolerance in the joint. + amount (int?): Amount of fingers in the joint; 0 means auto-generate. Defaults to 0. + stem (float?): Amount of radius the stem or neck of the joint will have. Defaults to 1. + twist (bool?): Indicates if a twist lock addition is required. Defaults to False. + tneck (float?): Percentage the twist neck will have compared to thickness. Defaults to + 0.5. + tthick (float?): Thickness of the twist material. Defaults to 0.01. + which (str?): Specifies which joint to generate ('M', 'F', 'MF'). Defaults to 'MF'. + """ + # length is the total width of the segments including 2 * radius and thick # radius = radius of the curve # thick = thickness of the bar @@ -632,6 +959,41 @@ def mitre(length, thick, angle, angleb, diameter, tolerance, amount=0, stem=1, t def open_curve(line, thick, diameter, tolerance, amount=0, stem=1, twist=False, t_neck=0.5, t_thick=0.01, twist_amount=1, which='MF', twist_keep=False): + """Open a curve and add puzzle connectors with optional twist lock + connectors. + + This function takes a shapely LineString and creates an open curve with + specified parameters such as thickness, diameter, tolerance, and twist + options. It generates puzzle connectors at the ends of the curve and can + optionally add twist lock connectors along the curve. The function also + handles the creation of the joint based on the provided parameters, + ensuring that the resulting geometry meets the specified design + requirements. + + Args: + line (LineString): A shapely LineString representing the path of the curve. + thick (float): The thickness of the bar used in the joint. + diameter (float): The diameter of the tool for joint creation. + tolerance (float): The tolerance in the joint. + amount (int?): The number of fingers in the joint; 0 means auto-generate. Defaults to + 0. + stem (float?): The amount of radius the stem or neck of the joint will have. Defaults + to 1. + twist (bool?): Whether to add twist lock connectors. Defaults to False. + t_neck (float?): The percentage the twist neck will have compared to thickness. Defaults + to 0.5. + t_thick (float?): The thickness of the twist material. Defaults to 0.01. + twist_amount (int?): The amount of twist distributed on the curve, not counting joint twists. + Defaults to 1. + which (str?): Specifies the type of joint; options include 'M', 'F', 'MF', 'MM', 'FF'. + Defaults to 'MF'. + twist_keep (bool?): Whether to keep the twist lock connectors. Defaults to False. + + Returns: + None: This function does not return a value but modifies the geometry in the + Blender context. + """ + # puts puzzle connectors at the end of an open curve # optionally puts twist lock connectors at the puzzle connection # optionally puts twist lock connectors along the open curve @@ -710,6 +1072,26 @@ def open_curve(line, thick, diameter, tolerance, amount=0, stem=1, twist=False, def tile(diameter, tolerance, tile_x_amount, tile_y_amount, stem=1): + """Create a tile shape based on specified dimensions and parameters. + + This function calculates the dimensions of a tile based on the provided + diameter and tolerance, as well as the number of tiles in the x and y + directions. It constructs the tile shape by creating a base and adding + features such as fingers for interlocking. The function also handles + transformations such as moving, rotating, and performing boolean + operations to achieve the desired tile geometry. + + Args: + diameter (float): The diameter of the tile. + tolerance (float): The tolerance to be applied to the tile dimensions. + tile_x_amount (int): The number of tiles along the x-axis. + tile_y_amount (int): The number of tiles along the y-axis. + stem (int?): A parameter affecting the tile's features. Defaults to 1. + + Returns: + None: This function does not return a value but modifies global state. + """ + global DT diameter = diameter * DT width = ((tile_x_amount) * (4 + 2 * (stem-1)) + 1) * diameter diff --git a/scripts/addons/cam/simple.py b/scripts/addons/cam/simple.py index 388324a0..74b3eb05 100644 --- a/scripts/addons/cam/simple.py +++ b/scripts/addons/cam/simple.py @@ -1,367 +1,847 @@ -"""BlenderCAM 'simple.py' © 2012 Vilem Novak - -Various helper functions, less complex than those found in the 'utils' files. -""" - -from math import ( - hypot, - pi, -) -import os -import string -import sys -import time - -from shapely.geometry import Polygon - -import bpy -from mathutils import Vector - -from .constants import BULLET_SCALE - - -def tuple_add(t, t1): # add two tuples as Vectors - return t[0] + t1[0], t[1] + t1[1], t[2] + t1[2] - - -def tuple_sub(t, t1): # sub two tuples as Vectors - return t[0] - t1[0], t[1] - t1[1], t[2] - t1[2] - - -def tuple_mul(t, c): # multiply two tuples with a number - return t[0] * c, t[1] * c, t[2] * c - - -def tuple_length(t): # get length of vector, but passed in as tuple. - return Vector(t).length - - -# timing functions for optimisation purposes... -def timinginit(): - return [0, 0] - - -def timingstart(tinf): - t = time.time() - tinf[1] = t - - -def timingadd(tinf): - t = time.time() - tinf[0] += t - tinf[1] - - -def timingprint(tinf): - print('time ' + str(tinf[0]) + 'seconds') - - -def progress(text, n=None): - """Function for Reporting During the Script, Works for Background Operations in the Header.""" - text = str(text) - if n is None: - n = '' - else: - n = ' ' + str(int(n * 1000) / 1000) + '%' - sys.stdout.write('progress{%s%s}\n' % (text, n)) - sys.stdout.flush() - - -def activate(o): - """Makes an Object Active, Used Many Times in Blender""" - s = bpy.context.scene - bpy.ops.object.select_all(action='DESELECT') - o.select_set(state=True) - s.objects[o.name].select_set(state=True) - bpy.context.view_layer.objects.active = o - - -def dist2d(v1, v2): - """Distance Between Two Points in 2D""" - return hypot((v1[0] - v2[0]), (v1[1] - v2[1])) - - -def delob(ob): - """Object Deletion for Multiple Uses""" - activate(ob) - bpy.ops.object.delete(use_global=False) - - -def dupliob(o, pos): - """Helper Function for Visualising Cutter Positions in Bullet Simulation""" - activate(o) - bpy.ops.object.duplicate() - s = 1.0 / BULLET_SCALE - bpy.ops.transform.resize(value=(s, s, s), constraint_axis=(False, False, False), orient_type='GLOBAL', - mirror=False, use_proportional_edit=False, proportional_edit_falloff='SMOOTH', - proportional_size=1) - o = bpy.context.active_object - bpy.ops.rigidbody.object_remove() - o.location = pos - - -def addToGroup(ob, groupname): - activate(ob) - if bpy.data.groups.get(groupname) is None: - bpy.ops.group.create(name=groupname) - else: - bpy.ops.object.group_link(group=groupname) - - -def compare(v1, v2, vmiddle, e): - """Comparison for Optimisation of Paths""" - # e=0.0001 - v1 = Vector(v1) - v2 = Vector(v2) - vmiddle = Vector(vmiddle) - vect1 = v2 - v1 - vect2 = vmiddle - v1 - vect1.normalize() - vect1 *= vect2.length - v = vect2 - vect1 - if v.length < e: - return True - return False - - -def isVerticalLimit(v1, v2, limit): - """Test Path Segment on Verticality Threshold, for protect_vertical Option""" - z = abs(v1[2] - v2[2]) - # verticality=0.05 - # this will be better. - # - # print(a) - if z > 0: - v2d = Vector((0, 0, -1)) - v3d = Vector((v1[0] - v2[0], v1[1] - v2[1], v1[2] - v2[2])) - a = v3d.angle(v2d) - if a > pi / 2: - a = abs(a - pi) - # print(a) - if a < limit: - # print(abs(v1[0]-v2[0])/z) - # print(abs(v1[1]-v2[1])/z) - if v1[2] > v2[2]: - v1 = (v2[0], v2[1], v1[2]) - return v1, v2 - else: - v2 = (v1[0], v1[1], v2[2]) - return v1, v2 - return v1, v2 - - -def getCachePath(o): - fn = bpy.data.filepath - l = len(bpy.path.basename(fn)) - bn = bpy.path.basename(fn)[:-6] - print('fn-l:', fn[:-l]) - print('bn:', bn) - - iname = fn[:-l] + 'temp_cam' + os.sep + bn + '_' + o.name - return iname - - -def getSimulationPath(): - fn = bpy.data.filepath - l = len(bpy.path.basename(fn)) - iname = fn[:-l] + 'temp_cam' + os.sep - return iname - - -def safeFileName(name): # for export gcode - valid_chars = "-_.()%s%s" % (string.ascii_letters, string.digits) - filename = ''.join(c for c in name if c in valid_chars) - return filename - - -def strInUnits(x, precision=5): - if bpy.context.scene.unit_settings.system == 'METRIC': - return str(round(x * 1000, precision)) + ' mm ' - elif bpy.context.scene.unit_settings.system == 'IMPERIAL': - return str(round(x * 1000 / 25.4, precision)) + "'' " - else: - return str(x) - - -# select multiple object starting with name -def select_multiple(name): - scene = bpy.context.scene - bpy.ops.object.select_all(action='DESELECT') - for ob in scene.objects: # join pocket curve calculations - if ob.name.startswith(name): - ob.select_set(True) - else: - ob.select_set(False) - - -# join multiple objects starting with 'name' renaming final object as 'name' -def join_multiple(name): - select_multiple(name) - bpy.ops.object.join() - bpy.context.active_object.name = name # rename object - - -# remove multiple objects starting with 'name'.... useful for fixed name operation -def remove_multiple(name): - scene = bpy.context.scene - bpy.ops.object.select_all(action='DESELECT') - for ob in scene.objects: - if ob.name.startswith(name): - ob.select_set(True) - bpy.ops.object.delete() - - -def deselect(): - bpy.ops.object.select_all(action='DESELECT') - - -# makes the object with the name active -def make_active(name): - ob = bpy.context.scene.objects[name] - bpy.ops.object.select_all(action='DESELECT') - bpy.context.view_layer.objects.active = ob - ob.select_set(True) - - -# change the name of the active object -def active_name(name): - bpy.context.active_object.name = name - - -# renames and makes active name and makes it active -def rename(name, name2): - make_active(name) - bpy.context.active_object.name = name2 - - -# boolean union of objects starting with name result is object name. -# all objects starting with name will be deleted and the result will be name -def union(name): - select_multiple(name) - bpy.ops.object.curve_boolean(boolean_type='UNION') - active_name('unionboolean') - remove_multiple(name) - rename('unionboolean', name) - - -def intersect(name): - select_multiple(name) - bpy.ops.object.curve_boolean(boolean_type='INTERSECT') - active_name('intersection') - -# boolean difference of objects starting with name result is object from basename. -# all objects starting with name will be deleted and the result will be basename - - -def difference(name, basename): - # name is the series to select - # basename is what the base you want to cut including name - select_multiple(name) - bpy.context.view_layer.objects.active = bpy.data.objects[basename] - bpy.ops.object.curve_boolean(boolean_type='DIFFERENCE') - active_name('booleandifference') - remove_multiple(name) - rename('booleandifference', basename) - - -# duplicate active object or duplicate move -# if x or y not the default, duplicate move will be executed -def duplicate(x=0, y=0): - if x == 0 and y == 0: - bpy.ops.object.duplicate() - else: - bpy.ops.object.duplicate_move(OBJECT_OT_duplicate={"linked": False, "mode": 'TRANSLATION'}, - TRANSFORM_OT_translate={"value": (x, y, 0.0)}) - - -# Mirror active object along the x axis -def mirrorx(): - bpy.ops.transform.mirror(orient_type='GLOBAL', orient_matrix=((1, 0, 0), (0, 1, 0), (0, 0, 1)), - orient_matrix_type='GLOBAL', constraint_axis=(True, False, False)) - - -# mirror active object along y axis -def mirrory(): - bpy.ops.transform.mirror(orient_type='GLOBAL', orient_matrix=((1, 0, 0), (0, 1, 0), (0, 0, 1)), - orient_matrix_type='GLOBAL', constraint_axis=(False, True, False)) - - -# move active object and apply translation -def move(x=0.0, y=0.0): - bpy.ops.transform.translate(value=(x, y, 0.0)) - bpy.ops.object.transform_apply(location=True) - - -# Rotate active object and apply rotation -def rotate(angle): - bpy.context.object.rotation_euler[2] = angle - bpy.ops.object.transform_apply(rotation=True) - - -# remove doubles -def remove_doubles(): - bpy.ops.object.curve_remove_doubles() - - -# Add overcut to active object -def add_overcut(diametre, overcut=True): - if overcut: - name = bpy.context.active_object.name - bpy.ops.object.curve_overcuts(diameter=diametre, threshold=pi/2.05) - overcut_name = bpy.context.active_object.name - make_active(name) - bpy.ops.object.delete() - rename(overcut_name, name) - remove_doubles() - - -# add bounding rectangtle to curve -def add_bound_rectangle(xmin, ymin, xmax, ymax, name='bounds_rectangle'): - # xmin = minimum corner x value - # ymin = minimum corner y value - # xmax = maximum corner x value - # ymax = maximum corner y value - # name = name of the resulting object - xsize = xmax - xmin - ysize = ymax - ymin - - bpy.ops.curve.simple(align='WORLD', location=(xmin + xsize/2, ymin + ysize/2, 0), rotation=(0, 0, 0), - Simple_Type='Rectangle', - Simple_width=xsize, Simple_length=ysize, use_cyclic_u=True, edit_mode=False, shape='3D') - bpy.ops.object.transform_apply(location=True) - active_name(name) - - -def add_rectangle(width, height, center_x=True, center_y=True): - x_offset = width / 2 - y_offset = height / 2 - - if center_x: - x_offset = 0 - if center_y: - y_offset = 0 - - bpy.ops.curve.simple(align='WORLD', location=(x_offset, y_offset, 0), rotation=(0, 0, 0), - Simple_Type='Rectangle', - Simple_width=width, Simple_length=height, use_cyclic_u=True, edit_mode=False, shape='3D') - bpy.ops.object.transform_apply(location=True) - active_name('simple_rectangle') - - -# Returns coords from active object -def active_to_coords(): - bpy.ops.object.duplicate() - obj = bpy.context.active_object - bpy.ops.object.convert(target='MESH') - active_name("_tmp_mesh") - - coords = [] - for v in obj.data.vertices: # extract X,Y coordinates from the vertices data - coords.append((v.co.x, v.co.y)) - remove_multiple('_tmp_mesh') - return coords - - -# returns shapely polygon from active object -def active_to_shapely_poly(): - # convert coordinates to shapely Polygon datastructure - return Polygon(active_to_coords()) +"""BlenderCAM 'simple.py' © 2012 Vilem Novak + +Various helper functions, less complex than those found in the 'utils' files. +""" + +from math import ( + hypot, + pi, +) +import os +import string +import sys +import time + +from shapely.geometry import Polygon + +import bpy +from mathutils import Vector + +from .constants import BULLET_SCALE + + +def tuple_add(t, t1): # add two tuples as Vectors + """Add two tuples as vectors. + + This function takes two tuples, each representing a vector in three- + dimensional space, and returns a new tuple that is the element-wise sum + of the two input tuples. It assumes that both tuples contain exactly + three numeric elements. + + Args: + t (tuple): A tuple containing three numeric values representing the first vector. + t1 (tuple): A tuple containing three numeric values representing the second vector. + + Returns: + tuple: A tuple containing three numeric values that represent the sum of the + input vectors. + """ + return t[0] + t1[0], t[1] + t1[1], t[2] + t1[2] + + +def tuple_sub(t, t1): # sub two tuples as Vectors + """Subtract two tuples element-wise. + + This function takes two tuples of three elements each and performs an + element-wise subtraction, treating the tuples as vectors. The result is + a new tuple containing the differences of the corresponding elements + from the input tuples. + + Args: + t (tuple): A tuple containing three numeric values. + t1 (tuple): A tuple containing three numeric values. + + Returns: + tuple: A tuple containing the results of the element-wise subtraction. + """ + return t[0] - t1[0], t[1] - t1[1], t[2] - t1[2] + + +def tuple_mul(t, c): # multiply two tuples with a number + """Multiply each element of a tuple by a given number. + + This function takes a tuple containing three elements and a numeric + value, then multiplies each element of the tuple by the provided number. + The result is returned as a new tuple containing the multiplied values. + + Args: + t (tuple): A tuple containing three numeric values. + c (numeric): A number by which to multiply each element of the tuple. + + Returns: + tuple: A new tuple containing the results of the multiplication. + """ + return t[0] * c, t[1] * c, t[2] * c + + +def tuple_length(t): # get length of vector, but passed in as tuple. + """Get the length of a vector represented as a tuple. + + This function takes a tuple as input, which represents the coordinates + of a vector, and returns its length by creating a Vector object from the + tuple. The length is calculated using the appropriate mathematical + formula for vector length. + + Args: + t (tuple): A tuple representing the coordinates of the vector. + + Returns: + float: The length of the vector. + """ + return Vector(t).length + + +# timing functions for optimisation purposes... +def timinginit(): + """Initialize timing metrics. + + This function sets up the initial state for timing functions by + returning a list containing two zero values. These values can be used to + track elapsed time or other timing-related metrics in subsequent + operations. + + Returns: + list: A list containing two zero values, representing the + initial timing metrics. + """ + return [0, 0] + + +def timingstart(tinf): + """Start timing by recording the current time. + + This function updates the second element of the provided list with the + current time in seconds since the epoch. It is useful for tracking the + start time of an operation or process. + + Args: + tinf (list): A list where the second element will be updated + with the current time. + """ + t = time.time() + tinf[1] = t + + +def timingadd(tinf): + """Update the timing information. + + This function updates the first element of the `tinf` list by adding the + difference between the current time and the second element of the list. + It is typically used to track elapsed time in a timing context. + + Args: + tinf (list): A list where the first element is updated with the + """ + t = time.time() + tinf[0] += t - tinf[1] + + +def timingprint(tinf): + """Print the timing information. + + This function takes a tuple containing timing information and prints it + in a formatted string. It specifically extracts the first element of the + tuple, which is expected to represent time, and appends the string + 'seconds' to it before printing. + + Args: + tinf (tuple): A tuple where the first element is expected to be a numeric value + representing time. + + Returns: + None: This function does not return any value; it only prints output to the + console. + """ + print('time ' + str(tinf[0]) + 'seconds') + + +def progress(text, n=None): + """Report progress during script execution. + + This function outputs a progress message to the standard output. It is + designed to work for background operations and provides a formatted + string that includes the specified text and an optional numeric progress + value. If the numeric value is provided, it is formatted as a + percentage. + + Args: + text (str): The message to display as progress. + n (float?): A float representing the progress as a + fraction (0.0 to 1.0). If not provided, no percentage will + be displayed. + + Returns: + None: This function does not return a value; it only prints + to the standard output. + """ + text = str(text) + if n is None: + n = '' + else: + n = ' ' + str(int(n * 1000) / 1000) + '%' + sys.stdout.write('progress{%s%s}\n' % (text, n)) + sys.stdout.flush() + + +def activate(o): + """Makes an object active in Blender. + + This function sets the specified object as the active object in the + current Blender scene. It first deselects all objects, then selects the + given object and makes it the active object in the view layer. This is + useful for operations that require a specific object to be active, such + as transformations or modifications. + + Args: + o (bpy.types.Object): The Blender object to be activated. + """ + s = bpy.context.scene + bpy.ops.object.select_all(action='DESELECT') + o.select_set(state=True) + s.objects[o.name].select_set(state=True) + bpy.context.view_layer.objects.active = o + + +def dist2d(v1, v2): + """Calculate the distance between two points in 2D space. + + This function computes the Euclidean distance between two points + represented by their coordinates in a 2D plane. It uses the Pythagorean + theorem to calculate the distance based on the differences in the x and + y coordinates of the points. + + Args: + v1 (tuple): A tuple representing the coordinates of the first point (x1, y1). + v2 (tuple): A tuple representing the coordinates of the second point (x2, y2). + + Returns: + float: The Euclidean distance between the two points. + """ + return hypot((v1[0] - v2[0]), (v1[1] - v2[1])) + + +def delob(ob): + """Delete an object in Blender for multiple uses. + + This function activates the specified object and then deletes it using + Blender's built-in operations. It is designed to facilitate the deletion + of objects within the Blender environment, ensuring that the object is + active before performing the deletion operation. + + Args: + ob (Object): The Blender object to be deleted. + """ + activate(ob) + bpy.ops.object.delete(use_global=False) + + +def dupliob(o, pos): + """Helper function for visualizing cutter positions in bullet simulation. + + This function duplicates the specified object and resizes it according + to a predefined scale factor. It also removes any existing rigidbody + properties from the duplicated object and sets its location to the + specified position. This is useful for managing multiple cutter + positions in a bullet simulation environment. + + Args: + o (Object): The object to be duplicated. + pos (Vector): The new position to place the duplicated object. + """ + activate(o) + bpy.ops.object.duplicate() + s = 1.0 / BULLET_SCALE + bpy.ops.transform.resize(value=(s, s, s), constraint_axis=(False, False, False), orient_type='GLOBAL', + mirror=False, use_proportional_edit=False, proportional_edit_falloff='SMOOTH', + proportional_size=1) + o = bpy.context.active_object + bpy.ops.rigidbody.object_remove() + o.location = pos + + +def addToGroup(ob, groupname): + """Add an object to a specified group in Blender. + + This function activates the given object and checks if the specified + group exists in Blender's data. If the group does not exist, it creates + a new group with the provided name. If the group already exists, it + links the object to that group. + + Args: + ob (Object): The object to be added to the group. + groupname (str): The name of the group to which the object will be added. + """ + activate(ob) + if bpy.data.groups.get(groupname) is None: + bpy.ops.group.create(name=groupname) + else: + bpy.ops.object.group_link(group=groupname) + + +def compare(v1, v2, vmiddle, e): + """Comparison for optimization of paths. + + This function compares two vectors and checks if the distance between a + calculated vector and a reference vector is less than a specified + threshold. It normalizes the vector difference and scales it by the + length of another vector to determine if the resulting vector is within + the specified epsilon value. + + Args: + v1 (Vector): The first vector for comparison. + v2 (Vector): The second vector for comparison. + vmiddle (Vector): The middle vector used for calculating the + reference vector. + e (float): The threshold value for comparison. + + Returns: + bool: True if the distance is less than the threshold, + otherwise False. + """ + # e=0.0001 + v1 = Vector(v1) + v2 = Vector(v2) + vmiddle = Vector(vmiddle) + vect1 = v2 - v1 + vect2 = vmiddle - v1 + vect1.normalize() + vect1 *= vect2.length + v = vect2 - vect1 + if v.length < e: + return True + return False + + +def isVerticalLimit(v1, v2, limit): + """Test Path Segment on Verticality Threshold for protect_vertical option. + + This function evaluates the verticality of a path segment defined by two + points, v1 and v2, based on a specified limit. It calculates the angle + between the vertical vector and the vector formed by the two points. If + the angle is within the defined limit, it adjusts the vertical position + of either v1 or v2 to ensure that the segment adheres to the verticality + threshold. + + Args: + v1 (tuple): A 3D point represented as a tuple (x, y, z). + v2 (tuple): A 3D point represented as a tuple (x, y, z). + limit (float): The angle threshold for determining verticality. + + Returns: + tuple: The adjusted 3D points v1 and v2 after evaluating the verticality. + """ + z = abs(v1[2] - v2[2]) + # verticality=0.05 + # this will be better. + # + # print(a) + if z > 0: + v2d = Vector((0, 0, -1)) + v3d = Vector((v1[0] - v2[0], v1[1] - v2[1], v1[2] - v2[2])) + a = v3d.angle(v2d) + if a > pi / 2: + a = abs(a - pi) + # print(a) + if a < limit: + # print(abs(v1[0]-v2[0])/z) + # print(abs(v1[1]-v2[1])/z) + if v1[2] > v2[2]: + v1 = (v2[0], v2[1], v1[2]) + return v1, v2 + else: + v2 = (v1[0], v1[1], v2[2]) + return v1, v2 + return v1, v2 + + +def getCachePath(o): + """Get the cache path for a given object. + + This function constructs a cache path based on the current Blender + file's filepath and the name of the provided object. It retrieves the + base name of the file, removes the last six characters, and appends a + specified directory and the object's name to create a complete cache + path. + + Args: + o (Object): The Blender object for which the cache path is being generated. + + Returns: + str: The constructed cache path as a string. + """ + fn = bpy.data.filepath + l = len(bpy.path.basename(fn)) + bn = bpy.path.basename(fn)[:-6] + print('fn-l:', fn[:-l]) + print('bn:', bn) + + iname = fn[:-l] + 'temp_cam' + os.sep + bn + '_' + o.name + return iname + + +def getSimulationPath(): + """Get the simulation path for temporary camera files. + + This function retrieves the file path of the current Blender project and + constructs a new path for temporary camera files by appending 'temp_cam' + to the directory of the current file. The constructed path is returned + as a string. + + Returns: + str: The path to the temporary camera directory. + """ + fn = bpy.data.filepath + l = len(bpy.path.basename(fn)) + iname = fn[:-l] + 'temp_cam' + os.sep + return iname + + +def safeFileName(name): # for export gcode + """Generate a safe file name from the given string. + + This function takes a string input and removes any characters that are + not considered valid for file names. The valid characters include + letters, digits, and a few special characters. The resulting string can + be used safely as a file name for exporting purposes. + + Args: + name (str): The input string to be sanitized into a safe file name. + + Returns: + str: A sanitized version of the input string that contains only valid + characters for a file name. + """ + valid_chars = "-_.()%s%s" % (string.ascii_letters, string.digits) + filename = ''.join(c for c in name if c in valid_chars) + return filename + + +def strInUnits(x, precision=5): + """Convert a value to a string representation in the current unit system. + + This function takes a numeric value and converts it to a string + formatted according to the unit system set in the Blender context. If + the unit system is metric, the value is converted to millimeters. If the + unit system is imperial, the value is converted to inches. The precision + of the output can be specified. + + Args: + x (float): The numeric value to be converted. + precision (int?): The number of decimal places to round to. + Defaults to 5. + + Returns: + str: The string representation of the value in the appropriate units. + """ + if bpy.context.scene.unit_settings.system == 'METRIC': + return str(round(x * 1000, precision)) + ' mm ' + elif bpy.context.scene.unit_settings.system == 'IMPERIAL': + return str(round(x * 1000 / 25.4, precision)) + "'' " + else: + return str(x) + + +# select multiple object starting with name +def select_multiple(name): + """Select multiple objects in the scene based on their names. + + This function deselects all objects in the current Blender scene and + then selects all objects whose names start with the specified prefix. It + iterates through all objects in the scene and checks if their names + begin with the given string. If they do, those objects are selected; + otherwise, they are deselected. + + Args: + name (str): The prefix used to select objects in the scene. + """ + scene = bpy.context.scene + bpy.ops.object.select_all(action='DESELECT') + for ob in scene.objects: # join pocket curve calculations + if ob.name.startswith(name): + ob.select_set(True) + else: + ob.select_set(False) + + +# join multiple objects starting with 'name' renaming final object as 'name' +def join_multiple(name): + """Join multiple objects and rename the final object. + + This function selects multiple objects in the Blender context, joins + them into a single object, and renames the resulting object to the + specified name. It is assumed that the objects to be joined are already + selected in the Blender interface. + + Args: + name (str): The new name for the joined object. + """ + select_multiple(name) + bpy.ops.object.join() + bpy.context.active_object.name = name # rename object + + +# remove multiple objects starting with 'name'.... useful for fixed name operation +def remove_multiple(name): + """Remove multiple objects from the scene based on their name prefix. + + This function deselects all objects in the current Blender scene and + then iterates through all objects. If an object's name starts with the + specified prefix, it selects that object and deletes it from the scene. + This is useful for operations that require removing multiple objects + with a common naming convention. + + Args: + name (str): The prefix of the object names to be removed. + """ + scene = bpy.context.scene + bpy.ops.object.select_all(action='DESELECT') + for ob in scene.objects: + if ob.name.startswith(name): + ob.select_set(True) + bpy.ops.object.delete() + + +def deselect(): + """Deselect all objects in the current Blender context. + + This function utilizes the Blender Python API to deselect all objects in + the current scene. It is useful for clearing selections before + performing other operations on objects. Raises: None + """ + bpy.ops.object.select_all(action='DESELECT') + + +# makes the object with the name active +def make_active(name): + """Make an object active in the Blender scene. + + This function takes the name of an object and sets it as the active + object in the current Blender scene. It first deselects all objects, + then selects the specified object and makes it active, allowing for + further operations to be performed on it. + + Args: + name (str): The name of the object to be made active. + """ + ob = bpy.context.scene.objects[name] + bpy.ops.object.select_all(action='DESELECT') + bpy.context.view_layer.objects.active = ob + ob.select_set(True) + + +# change the name of the active object +def active_name(name): + """Change the name of the active object in Blender. + + This function sets the name of the currently active object in the + Blender context to the specified name. It directly modifies the `name` + attribute of the active object, allowing users to rename objects + programmatically. + + Args: + name (str): The new name to assign to the active object. + """ + bpy.context.active_object.name = name + + +# renames and makes active name and makes it active +def rename(name, name2): + """Rename an object and make it active. + + This function renames an object in the Blender context and sets it as + the active object. It first calls the `make_active` function to ensure + the object is active, then updates the name of the active object to the + new name provided. + + Args: + name (str): The current name of the object to be renamed. + name2 (str): The new name to assign to the active object. + """ + make_active(name) + bpy.context.active_object.name = name2 + + +# boolean union of objects starting with name result is object name. +# all objects starting with name will be deleted and the result will be name +def union(name): + """Perform a boolean union operation on objects. + + This function selects multiple objects that start with the given name, + performs a boolean union operation on them using Blender's operators, + and then renames the resulting object to the specified name. After the + operation, it removes the original objects that were used in the union + process. + + Args: + name (str): The base name of the objects to be unioned. + """ + select_multiple(name) + bpy.ops.object.curve_boolean(boolean_type='UNION') + active_name('unionboolean') + remove_multiple(name) + rename('unionboolean', name) + + +def intersect(name): + """Perform an intersection operation on a curve object. + + This function selects multiple objects based on the provided name and + then executes a boolean operation to create an intersection of the + selected objects. The resulting intersection is then named accordingly. + + Args: + name (str): The name of the object(s) to be selected for the intersection. + """ + select_multiple(name) + bpy.ops.object.curve_boolean(boolean_type='INTERSECT') + active_name('intersection') + +# boolean difference of objects starting with name result is object from basename. +# all objects starting with name will be deleted and the result will be basename + + +def difference(name, basename): + """Perform a boolean difference operation on objects. + + This function selects a series of objects specified by `name` and + performs a boolean difference operation with the object specified by + `basename`. After the operation, the resulting object is renamed to + 'booleandifference'. The original objects specified by `name` are + deleted after the operation. + + Args: + name (str): The name of the series of objects to select for the operation. + basename (str): The name of the base object to perform the boolean difference with. + """ + # name is the series to select + # basename is what the base you want to cut including name + select_multiple(name) + bpy.context.view_layer.objects.active = bpy.data.objects[basename] + bpy.ops.object.curve_boolean(boolean_type='DIFFERENCE') + active_name('booleandifference') + remove_multiple(name) + rename('booleandifference', basename) + + +# duplicate active object or duplicate move +# if x or y not the default, duplicate move will be executed +def duplicate(x=0, y=0): + """Duplicate an active object or move it based on the provided coordinates. + + This function duplicates the currently active object in Blender. If both + x and y are set to their default values (0), the object is duplicated in + place. If either x or y is non-zero, the object is duplicated and moved + by the specified x and y offsets. + + Args: + x (float?): The x-coordinate offset for the duplication. + Defaults to 0. + y (float?): The y-coordinate offset for the duplication. + Defaults to 0. + """ + if x == 0 and y == 0: + bpy.ops.object.duplicate() + else: + bpy.ops.object.duplicate_move(OBJECT_OT_duplicate={"linked": False, "mode": 'TRANSLATION'}, + TRANSFORM_OT_translate={"value": (x, y, 0.0)}) + + +# Mirror active object along the x axis +def mirrorx(): + """Mirror the active object along the x-axis. + + This function utilizes Blender's operator to mirror the currently active + object in the 3D view along the x-axis. It sets the orientation to + global and applies the transformation based on the specified orientation + matrix and constraint axis. + """ + bpy.ops.transform.mirror(orient_type='GLOBAL', orient_matrix=((1, 0, 0), (0, 1, 0), (0, 0, 1)), + orient_matrix_type='GLOBAL', constraint_axis=(True, False, False)) + + +# mirror active object along y axis +def mirrory(): + """Mirror the active object along the Y axis. + + This function uses Blender's operator to perform a mirror transformation + on the currently active object in the scene. The mirroring is done with + respect to the global coordinate system, specifically along the Y axis. + This can be useful for creating symmetrical objects or for correcting + the orientation of an object in a 3D environment. Raises: None + """ + bpy.ops.transform.mirror(orient_type='GLOBAL', orient_matrix=((1, 0, 0), (0, 1, 0), (0, 0, 1)), + orient_matrix_type='GLOBAL', constraint_axis=(False, True, False)) + + +# move active object and apply translation +def move(x=0.0, y=0.0): + """Move the active object in the 3D space by applying a translation. + + This function translates the active object in Blender's 3D view by the + specified x and y values. It uses Blender's built-in operations to + perform the translation and then applies the transformation to the + object's location. + + Args: + x (float?): The distance to move the object along the x-axis. Defaults to 0.0. + y (float?): The distance to move the object along the y-axis. Defaults to 0.0. + """ + bpy.ops.transform.translate(value=(x, y, 0.0)) + bpy.ops.object.transform_apply(location=True) + + +# Rotate active object and apply rotation +def rotate(angle): + """Rotate the active object by a specified angle. + + This function modifies the rotation of the currently active object in + the Blender context by setting its Z-axis rotation to the given angle. + After updating the rotation, it applies the transformation to ensure + that the changes are saved to the object's data. + + Args: + angle (float): The angle in radians to rotate the active object + around the Z-axis. + """ + bpy.context.object.rotation_euler[2] = angle + bpy.ops.object.transform_apply(rotation=True) + + +# remove doubles +def remove_doubles(): + """Remove duplicate vertices from the selected curve object. + + This function utilizes the Blender Python API to remove duplicate + vertices from the currently selected curve object in the Blender + environment. It is essential for cleaning up geometry and ensuring that + the curve behaves as expected without unnecessary complexity. + """ + bpy.ops.object.curve_remove_doubles() + + +# Add overcut to active object +def add_overcut(diametre, overcut=True): + """Add overcut to the active object. + + This function adds an overcut to the currently active object in the + Blender context. If the `overcut` parameter is set to True, it performs + a series of operations including creating a curve overcut with the + specified diameter, deleting the original object, and renaming the new + object to match the original. The function also ensures that any + duplicate vertices are removed from the resulting object. + + Args: + diametre (float): The diameter to be used for the overcut. + overcut (bool): A flag indicating whether to apply the overcut. Defaults to True. + """ + if overcut: + name = bpy.context.active_object.name + bpy.ops.object.curve_overcuts(diameter=diametre, threshold=pi/2.05) + overcut_name = bpy.context.active_object.name + make_active(name) + bpy.ops.object.delete() + rename(overcut_name, name) + remove_doubles() + + +# add bounding rectangtle to curve +def add_bound_rectangle(xmin, ymin, xmax, ymax, name='bounds_rectangle'): + """Add a bounding rectangle to a curve. + + This function creates a rectangle defined by the minimum and maximum x + and y coordinates provided as arguments. The rectangle is added to the + scene at the center of the defined bounds. The resulting rectangle is + named according to the 'name' parameter. + + Args: + xmin (float): The minimum x-coordinate of the rectangle. + ymin (float): The minimum y-coordinate of the rectangle. + xmax (float): The maximum x-coordinate of the rectangle. + ymax (float): The maximum y-coordinate of the rectangle. + name (str?): The name of the resulting rectangle object. Defaults to + 'bounds_rectangle'. + """ + # xmin = minimum corner x value + # ymin = minimum corner y value + # xmax = maximum corner x value + # ymax = maximum corner y value + # name = name of the resulting object + xsize = xmax - xmin + ysize = ymax - ymin + + bpy.ops.curve.simple(align='WORLD', location=(xmin + xsize/2, ymin + ysize/2, 0), rotation=(0, 0, 0), + Simple_Type='Rectangle', + Simple_width=xsize, Simple_length=ysize, use_cyclic_u=True, edit_mode=False, shape='3D') + bpy.ops.object.transform_apply(location=True) + active_name(name) + + +def add_rectangle(width, height, center_x=True, center_y=True): + """Add a rectangle to the scene. + + This function creates a rectangle in the 3D space using the specified + width and height. The rectangle can be centered at the origin or offset + based on the provided parameters. If `center_x` or `center_y` is set to + True, the rectangle will be positioned at the center of the specified + dimensions; otherwise, it will be positioned based on the offsets. + + Args: + width (float): The width of the rectangle. + height (float): The height of the rectangle. + center_x (bool?): If True, centers the rectangle along the x-axis. Defaults to True. + center_y (bool?): If True, centers the rectangle along the y-axis. Defaults to True. + """ + x_offset = width / 2 + y_offset = height / 2 + + if center_x: + x_offset = 0 + if center_y: + y_offset = 0 + + bpy.ops.curve.simple(align='WORLD', location=(x_offset, y_offset, 0), rotation=(0, 0, 0), + Simple_Type='Rectangle', + Simple_width=width, Simple_length=height, use_cyclic_u=True, edit_mode=False, shape='3D') + bpy.ops.object.transform_apply(location=True) + active_name('simple_rectangle') + + +# Returns coords from active object +def active_to_coords(): + """Convert the active object to a list of its vertex coordinates. + + This function duplicates the currently active object in the Blender + context, converts it to a mesh, and extracts the X and Y coordinates of + its vertices. After extracting the coordinates, it removes the temporary + mesh object created during the process. The resulting list contains + tuples of (x, y) coordinates for each vertex in the active object. + + Returns: + list: A list of tuples, each containing the X and Y coordinates of the + vertices from the active object. + """ + bpy.ops.object.duplicate() + obj = bpy.context.active_object + bpy.ops.object.convert(target='MESH') + active_name("_tmp_mesh") + + coords = [] + for v in obj.data.vertices: # extract X,Y coordinates from the vertices data + coords.append((v.co.x, v.co.y)) + remove_multiple('_tmp_mesh') + return coords + + +# returns shapely polygon from active object +def active_to_shapely_poly(): + """Convert the active object to a Shapely polygon. + + This function retrieves the coordinates of the currently active object + and converts them into a Shapely Polygon data structure. It is useful + for geometric operations and spatial analysis using the Shapely library. + + Returns: + Polygon: A Shapely Polygon object created from the active object's coordinates. + """ + # convert coordinates to shapely Polygon datastructure + return Polygon(active_to_coords()) diff --git a/scripts/addons/cam/simulation.py b/scripts/addons/cam/simulation.py index 08880467..22d862c4 100644 --- a/scripts/addons/cam/simulation.py +++ b/scripts/addons/cam/simulation.py @@ -1,319 +1,387 @@ -"""BlenderCAM 'simulation.py' © 2012 Vilem Novak - -Functions to generate a mesh simulation from CAM Chain / Operation data. -""" - -import math -import time - -import numpy as np - -import bpy -from mathutils import Vector - -from .async_op import progress_async -from .image_utils import ( - getCutterArray, - numpysave, -) -from .simple import getSimulationPath -from .utils import ( - getBoundsMultiple, - getOperationSources, -) - - -def createSimulationObject(name, operations, i): - oname = 'csim_' + name - - o = operations[0] - - if oname in bpy.data.objects: - ob = bpy.data.objects[oname] - else: - bpy.ops.mesh.primitive_plane_add( - align='WORLD', enter_editmode=False, location=(0, 0, 0), rotation=(0, 0, 0)) - ob = bpy.context.active_object - ob.name = oname - - bpy.ops.object.modifier_add(type='SUBSURF') - ss = ob.modifiers[-1] - ss.subdivision_type = 'SIMPLE' - ss.levels = 6 - ss.render_levels = 6 - bpy.ops.object.modifier_add(type='SUBSURF') - ss = ob.modifiers[-1] - ss.subdivision_type = 'SIMPLE' - ss.levels = 4 - ss.render_levels = 3 - bpy.ops.object.modifier_add(type='DISPLACE') - - ob.location = ((o.max.x + o.min.x) / 2, (o.max.y + o.min.y) / 2, o.min.z) - ob.scale.x = (o.max.x - o.min.x) / 2 - ob.scale.y = (o.max.y - o.min.y) / 2 - print(o.max.x, o.min.x) - print(o.max.y, o.min.y) - print('bounds') - disp = ob.modifiers[-1] - disp.direction = 'Z' - disp.texture_coords = 'LOCAL' - disp.mid_level = 0 - - if oname in bpy.data.textures: - t = bpy.data.textures[oname] - - t.type = 'IMAGE' - disp.texture = t - - t.image = i - else: - bpy.ops.texture.new() - for t in bpy.data.textures: - if t.name == 'Texture': - t.type = 'IMAGE' - t.name = oname - t = t.type_recast() - t.type = 'IMAGE' - t.image = i - disp.texture = t - ob.hide_render = True - bpy.ops.object.shade_smooth() - - -async def doSimulation(name, operations): - """Perform Simulation of Operations. Currently only for 3 Axis""" - for o in operations: - getOperationSources(o) - limits = getBoundsMultiple( - operations) # this is here because some background computed operations still didn't have bounds data - i = await generateSimulationImage(operations, limits) -# cp = getCachePath(operations[0])[:-len(operations[0].name)] + name - cp = getSimulationPath()+name - print('cp=', cp) - iname = cp + '_sim.exr' - - numpysave(i, iname) - i = bpy.data.images.load(iname) - createSimulationObject(name, operations, i) - - -async def generateSimulationImage(operations, limits): - minx, miny, minz, maxx, maxy, maxz = limits - # print(minx,miny,minz,maxx,maxy,maxz) - sx = maxx - minx - sy = maxy - miny - - o = operations[0] # getting sim detail and others from first op. - simulation_detail = o.optimisation.simulation_detail - borderwidth = o.borderwidth - resx = math.ceil(sx / simulation_detail) + 2 * borderwidth - resy = math.ceil(sy / simulation_detail) + 2 * borderwidth - - # create array in which simulation happens, similar to an image to be painted in. - si = np.full(shape=(resx, resy), fill_value=maxz, dtype=float) - - num_operations = len(operations) - - start_time = time.time() - - for op_count, o in enumerate(operations): - ob = bpy.data.objects["cam_path_{}".format(o.name)] - m = ob.data - verts = m.vertices - - if o.do_simulation_feedrate: - kname = 'feedrates' - m.use_customdata_edge_crease = True - - if m.shape_keys is None or m.shape_keys.key_blocks.find(kname) == -1: - ob.shape_key_add() - if len(m.shape_keys.key_blocks) == 1: - ob.shape_key_add() - shapek = m.shape_keys.key_blocks[-1] - shapek.name = kname - else: - shapek = m.shape_keys.key_blocks[kname] - shapek.data[0].co = (0.0, 0, 0) - - totalvolume = 0.0 - - cutterArray = getCutterArray(o, simulation_detail) - cutterArray = -cutterArray - lasts = verts[1].co - perc = -1 - vtotal = len(verts) - dropped = 0 - - xs = 0 - ys = 0 - - for i, vert in enumerate(verts): - if perc != int(100 * i / vtotal): - perc = int(100 * i / vtotal) - total_perc = (perc + op_count*100) / num_operations - await progress_async(f'Simulation', int(total_perc)) - - if i > 0: - volume = 0 - volume_partial = 0 - s = vert.co - v = s - lasts - - l = v.length - if (lasts.z < maxz or s.z < maxz) and not ( - v.x == 0 and v.y == 0 and v.z > 0): # only simulate inside material, and exclude lift-ups - if ( - v.x == 0 and v.y == 0 and v.z < 0): - # if the cutter goes straight down, we don't have to interpolate. - pass - - elif v.length > simulation_detail: # and not : - - v.length = simulation_detail - lastxs = xs - lastys = ys - while v.length < l: - xs = int((lasts.x + v.x - minx) / simulation_detail + - borderwidth + simulation_detail / 2) - # -middle - ys = int((lasts.y + v.y - miny) / simulation_detail + - borderwidth + simulation_detail / 2) - # -middle - z = lasts.z + v.z - # print(z) - if lastxs != xs or lastys != ys: - volume_partial = simCutterSpot( - xs, ys, z, cutterArray, si, o.do_simulation_feedrate) - if o.do_simulation_feedrate: - totalvolume += volume - volume += volume_partial - lastxs = xs - lastys = ys - else: - dropped += 1 - v.length += simulation_detail - - xs = int((s.x - minx) / simulation_detail + - borderwidth + simulation_detail / 2) # -middle - ys = int((s.y - miny) / simulation_detail + - borderwidth + simulation_detail / 2) # -middle - volume_partial = simCutterSpot( - xs, ys, s.z, cutterArray, si, o.do_simulation_feedrate) - if o.do_simulation_feedrate: # compute volumes and write data into shapekey. - volume += volume_partial - totalvolume += volume - if l > 0: - load = volume / l - else: - load = 0 - - # this will show the shapekey as debugging graph and will use same data to estimate parts - # with heavy load - if l != 0: - shapek.data[i].co.y = (load) * 0.000002 - else: - shapek.data[i].co.y = shapek.data[i - 1].co.y - shapek.data[i].co.x = shapek.data[i - 1].co.x + l * 0.04 - shapek.data[i].co.z = 0 - lasts = s - - # print('dropped '+str(dropped)) - if o.do_simulation_feedrate: # smoothing ,but only backward! - xcoef = shapek.data[len(shapek.data) - 1].co.x / len(shapek.data) - for a in range(0, 10): - # print(shapek.data[-1].co) - nvals = [] - val1 = 0 # - val2 = 0 - w1 = 0 # - w2 = 0 - - for i, d in enumerate(shapek.data): - val = d.co.y - - if i > 1: - d1 = shapek.data[i - 1].co - val1 = d1.y - if d1.x - d.co.x != 0: - w1 = 1 / (abs(d1.x - d.co.x) / xcoef) - - if i < len(shapek.data) - 1: - d2 = shapek.data[i + 1].co - val2 = d2.y - if d2.x - d.co.x != 0: - w2 = 1 / (abs(d2.x - d.co.x) / xcoef) - - # print(val,val1,val2,w1,w2) - - val = (val + val1 * w1 + val2 * w2) / (1.0 + w1 + w2) - nvals.append(val) - for i, d in enumerate(shapek.data): - d.co.y = nvals[i] - - # apply mapping - convert the values to actual feedrates. - total_load = 0 - max_load = 0 - for i, d in enumerate(shapek.data): - total_load += d.co.y - max_load = max(max_load, d.co.y) - normal_load = total_load / len(shapek.data) - - thres = 0.5 - - scale_graph = 0.05 # warning this has to be same as in export in utils!!!! - - totverts = len(shapek.data) - for i, d in enumerate(shapek.data): - if d.co.y > normal_load: - d.co.z = scale_graph * max(0.3, normal_load / d.co.y) - else: - d.co.z = scale_graph * 1 - if i < totverts - 1: - m.edges[i].crease = d.co.y / (normal_load * 4) - - si = si[borderwidth:-borderwidth, borderwidth:-borderwidth] - si += -minz - - await progress_async("Simulated:", time.time()-start_time, 's') - return si - - -def simCutterSpot(xs, ys, z, cutterArray, si, getvolume=False): - """Simulates a Cutter Cutting Into Stock, Taking Away the Volume, - and Optionally Returning the Volume that Has Been Milled. This Is Now Used for Feedrate Tweaking.""" - m = int(cutterArray.shape[0] / 2) - size = cutterArray.shape[0] - # whole cutter in image there - if xs > m and xs < si.shape[0] - m and ys > m and ys < si.shape[1] - m: - if getvolume: - volarray = si[xs - m:xs - m + size, ys - m:ys - m + size].copy() - si[xs - m:xs - m + size, ys - m:ys - m + size] = np.minimum(si[xs - m:xs - m + size, ys - m:ys - m + size], - cutterArray + z) - if getvolume: - volarray = si[xs - m:xs - m + size, ys - m:ys - m + size] - volarray - vsum = abs(volarray.sum()) - # print(vsum) - return vsum - - elif xs > -m and xs < si.shape[0] + m and ys > -m and ys < si.shape[1] + m: - # part of cutter in image, for extra large cutters - - startx = max(0, xs - m) - starty = max(0, ys - m) - endx = min(si.shape[0], xs - m + size) - endy = min(si.shape[0], ys - m + size) - castartx = max(0, m - xs) - castarty = max(0, m - ys) - caendx = min(size, si.shape[0] - xs + m) - caendy = min(size, si.shape[1] - ys + m) - - if getvolume: - volarray = si[startx:endx, starty:endy].copy() - si[startx:endx, starty:endy] = np.minimum(si[startx:endx, starty:endy], - cutterArray[castartx:caendx, castarty:caendy] + z) - if getvolume: - volarray = si[startx:endx, starty:endy] - volarray - vsum = abs(volarray.sum()) - # print(vsum) - return vsum - return 0 +"""BlenderCAM 'simulation.py' © 2012 Vilem Novak + +Functions to generate a mesh simulation from CAM Chain / Operation data. +""" + +import math +import time + +import numpy as np + +import bpy +from mathutils import Vector + +from .async_op import progress_async +from .image_utils import ( + getCutterArray, + numpysave, +) +from .simple import getSimulationPath +from .utils import ( + getBoundsMultiple, + getOperationSources, +) + + +def createSimulationObject(name, operations, i): + """Create a simulation object in Blender. + + This function creates a simulation object in Blender with the specified + name and operations. If an object with the given name already exists, it + retrieves that object; otherwise, it creates a new plane object and + applies several modifiers to it. The function also sets the object's + location and scale based on the provided operations and assigns a + texture to the object. + + Args: + name (str): The name of the simulation object to be created. + operations (list): A list of operation objects that contain bounding box information. + i: The image to be used as a texture for the simulation object. + """ + oname = 'csim_' + name + + o = operations[0] + + if oname in bpy.data.objects: + ob = bpy.data.objects[oname] + else: + bpy.ops.mesh.primitive_plane_add( + align='WORLD', enter_editmode=False, location=(0, 0, 0), rotation=(0, 0, 0)) + ob = bpy.context.active_object + ob.name = oname + + bpy.ops.object.modifier_add(type='SUBSURF') + ss = ob.modifiers[-1] + ss.subdivision_type = 'SIMPLE' + ss.levels = 6 + ss.render_levels = 6 + bpy.ops.object.modifier_add(type='SUBSURF') + ss = ob.modifiers[-1] + ss.subdivision_type = 'SIMPLE' + ss.levels = 4 + ss.render_levels = 3 + bpy.ops.object.modifier_add(type='DISPLACE') + + ob.location = ((o.max.x + o.min.x) / 2, (o.max.y + o.min.y) / 2, o.min.z) + ob.scale.x = (o.max.x - o.min.x) / 2 + ob.scale.y = (o.max.y - o.min.y) / 2 + print(o.max.x, o.min.x) + print(o.max.y, o.min.y) + print('bounds') + disp = ob.modifiers[-1] + disp.direction = 'Z' + disp.texture_coords = 'LOCAL' + disp.mid_level = 0 + + if oname in bpy.data.textures: + t = bpy.data.textures[oname] + + t.type = 'IMAGE' + disp.texture = t + + t.image = i + else: + bpy.ops.texture.new() + for t in bpy.data.textures: + if t.name == 'Texture': + t.type = 'IMAGE' + t.name = oname + t = t.type_recast() + t.type = 'IMAGE' + t.image = i + disp.texture = t + ob.hide_render = True + bpy.ops.object.shade_smooth() + + +async def doSimulation(name, operations): + """Perform simulation of operations for a 3-axis system. + + This function iterates through a list of operations, retrieves the + necessary sources for each operation, and computes the bounds for the + operations. It then generates a simulation image based on the operations + and their limits, saves the image to a specified path, and finally + creates a simulation object in Blender using the generated image. + + Args: + name (str): The name to be used for the simulation object. + operations (list): A list of operations to be simulated. + """ + for o in operations: + getOperationSources(o) + limits = getBoundsMultiple( + operations) # this is here because some background computed operations still didn't have bounds data + i = await generateSimulationImage(operations, limits) +# cp = getCachePath(operations[0])[:-len(operations[0].name)] + name + cp = getSimulationPath()+name + print('cp=', cp) + iname = cp + '_sim.exr' + + numpysave(i, iname) + i = bpy.data.images.load(iname) + createSimulationObject(name, operations, i) + + +async def generateSimulationImage(operations, limits): + """Generate a simulation image based on provided operations and limits. + + This function creates a 2D simulation image by processing a series of + operations that define how the simulation should be conducted. It uses + the limits provided to determine the boundaries of the simulation area. + The function calculates the necessary resolution for the simulation + image based on the specified simulation detail and border width. It + iterates through each operation, simulating the effect of each operation + on the image, and updates the shape keys of the corresponding Blender + object to reflect the simulation results. The final output is a 2D array + representing the simulated image. + + Args: + operations (list): A list of operation objects that contain details + about the simulation, including feed rates and other parameters. + limits (tuple): A tuple containing the minimum and maximum coordinates + (minx, miny, minz, maxx, maxy, maxz) that define the simulation + boundaries. + + Returns: + np.ndarray: A 2D array representing the simulated image. + """ + + minx, miny, minz, maxx, maxy, maxz = limits + # print(minx,miny,minz,maxx,maxy,maxz) + sx = maxx - minx + sy = maxy - miny + + o = operations[0] # getting sim detail and others from first op. + simulation_detail = o.optimisation.simulation_detail + borderwidth = o.borderwidth + resx = math.ceil(sx / simulation_detail) + 2 * borderwidth + resy = math.ceil(sy / simulation_detail) + 2 * borderwidth + + # create array in which simulation happens, similar to an image to be painted in. + si = np.full(shape=(resx, resy), fill_value=maxz, dtype=float) + + num_operations = len(operations) + + start_time = time.time() + + for op_count, o in enumerate(operations): + ob = bpy.data.objects["cam_path_{}".format(o.name)] + m = ob.data + verts = m.vertices + + if o.do_simulation_feedrate: + kname = 'feedrates' + m.use_customdata_edge_crease = True + + if m.shape_keys is None or m.shape_keys.key_blocks.find(kname) == -1: + ob.shape_key_add() + if len(m.shape_keys.key_blocks) == 1: + ob.shape_key_add() + shapek = m.shape_keys.key_blocks[-1] + shapek.name = kname + else: + shapek = m.shape_keys.key_blocks[kname] + shapek.data[0].co = (0.0, 0, 0) + + totalvolume = 0.0 + + cutterArray = getCutterArray(o, simulation_detail) + cutterArray = -cutterArray + lasts = verts[1].co + perc = -1 + vtotal = len(verts) + dropped = 0 + + xs = 0 + ys = 0 + + for i, vert in enumerate(verts): + if perc != int(100 * i / vtotal): + perc = int(100 * i / vtotal) + total_perc = (perc + op_count*100) / num_operations + await progress_async(f'Simulation', int(total_perc)) + + if i > 0: + volume = 0 + volume_partial = 0 + s = vert.co + v = s - lasts + + l = v.length + if (lasts.z < maxz or s.z < maxz) and not ( + v.x == 0 and v.y == 0 and v.z > 0): # only simulate inside material, and exclude lift-ups + if ( + v.x == 0 and v.y == 0 and v.z < 0): + # if the cutter goes straight down, we don't have to interpolate. + pass + + elif v.length > simulation_detail: # and not : + + v.length = simulation_detail + lastxs = xs + lastys = ys + while v.length < l: + xs = int((lasts.x + v.x - minx) / simulation_detail + + borderwidth + simulation_detail / 2) + # -middle + ys = int((lasts.y + v.y - miny) / simulation_detail + + borderwidth + simulation_detail / 2) + # -middle + z = lasts.z + v.z + # print(z) + if lastxs != xs or lastys != ys: + volume_partial = simCutterSpot( + xs, ys, z, cutterArray, si, o.do_simulation_feedrate) + if o.do_simulation_feedrate: + totalvolume += volume + volume += volume_partial + lastxs = xs + lastys = ys + else: + dropped += 1 + v.length += simulation_detail + + xs = int((s.x - minx) / simulation_detail + + borderwidth + simulation_detail / 2) # -middle + ys = int((s.y - miny) / simulation_detail + + borderwidth + simulation_detail / 2) # -middle + volume_partial = simCutterSpot( + xs, ys, s.z, cutterArray, si, o.do_simulation_feedrate) + if o.do_simulation_feedrate: # compute volumes and write data into shapekey. + volume += volume_partial + totalvolume += volume + if l > 0: + load = volume / l + else: + load = 0 + + # this will show the shapekey as debugging graph and will use same data to estimate parts + # with heavy load + if l != 0: + shapek.data[i].co.y = (load) * 0.000002 + else: + shapek.data[i].co.y = shapek.data[i - 1].co.y + shapek.data[i].co.x = shapek.data[i - 1].co.x + l * 0.04 + shapek.data[i].co.z = 0 + lasts = s + + # print('dropped '+str(dropped)) + if o.do_simulation_feedrate: # smoothing ,but only backward! + xcoef = shapek.data[len(shapek.data) - 1].co.x / len(shapek.data) + for a in range(0, 10): + # print(shapek.data[-1].co) + nvals = [] + val1 = 0 # + val2 = 0 + w1 = 0 # + w2 = 0 + + for i, d in enumerate(shapek.data): + val = d.co.y + + if i > 1: + d1 = shapek.data[i - 1].co + val1 = d1.y + if d1.x - d.co.x != 0: + w1 = 1 / (abs(d1.x - d.co.x) / xcoef) + + if i < len(shapek.data) - 1: + d2 = shapek.data[i + 1].co + val2 = d2.y + if d2.x - d.co.x != 0: + w2 = 1 / (abs(d2.x - d.co.x) / xcoef) + + # print(val,val1,val2,w1,w2) + + val = (val + val1 * w1 + val2 * w2) / (1.0 + w1 + w2) + nvals.append(val) + for i, d in enumerate(shapek.data): + d.co.y = nvals[i] + + # apply mapping - convert the values to actual feedrates. + total_load = 0 + max_load = 0 + for i, d in enumerate(shapek.data): + total_load += d.co.y + max_load = max(max_load, d.co.y) + normal_load = total_load / len(shapek.data) + + thres = 0.5 + + scale_graph = 0.05 # warning this has to be same as in export in utils!!!! + + totverts = len(shapek.data) + for i, d in enumerate(shapek.data): + if d.co.y > normal_load: + d.co.z = scale_graph * max(0.3, normal_load / d.co.y) + else: + d.co.z = scale_graph * 1 + if i < totverts - 1: + m.edges[i].crease = d.co.y / (normal_load * 4) + + si = si[borderwidth:-borderwidth, borderwidth:-borderwidth] + si += -minz + + await progress_async("Simulated:", time.time()-start_time, 's') + return si + + +def simCutterSpot(xs, ys, z, cutterArray, si, getvolume=False): + """Simulates a cutter cutting into stock and optionally returns the volume + removed. + + This function takes the position of a cutter and modifies a stock image + by simulating the cutting process. It updates the stock image based on + the cutter's dimensions and position, ensuring that the stock does not + go below a certain level defined by the cutter's height. If requested, + it also calculates and returns the volume of material that has been + milled away. + + Args: + xs (int): The x-coordinate of the cutter's position. + ys (int): The y-coordinate of the cutter's position. + z (float): The height of the cutter. + cutterArray (numpy.ndarray): A 2D array representing the cutter's shape. + si (numpy.ndarray): A 2D array representing the stock image to be modified. + getvolume (bool?): If True, the function returns the volume removed. Defaults to False. + + Returns: + float: The volume of material removed if `getvolume` is True; otherwise, + returns 0. + """ + m = int(cutterArray.shape[0] / 2) + size = cutterArray.shape[0] + # whole cutter in image there + if xs > m and xs < si.shape[0] - m and ys > m and ys < si.shape[1] - m: + if getvolume: + volarray = si[xs - m:xs - m + size, ys - m:ys - m + size].copy() + si[xs - m:xs - m + size, ys - m:ys - m + size] = np.minimum(si[xs - m:xs - m + size, ys - m:ys - m + size], + cutterArray + z) + if getvolume: + volarray = si[xs - m:xs - m + size, ys - m:ys - m + size] - volarray + vsum = abs(volarray.sum()) + # print(vsum) + return vsum + + elif xs > -m and xs < si.shape[0] + m and ys > -m and ys < si.shape[1] + m: + # part of cutter in image, for extra large cutters + + startx = max(0, xs - m) + starty = max(0, ys - m) + endx = min(si.shape[0], xs - m + size) + endy = min(si.shape[0], ys - m + size) + castartx = max(0, m - xs) + castarty = max(0, m - ys) + caendx = min(size, si.shape[0] - xs + m) + caendy = min(size, si.shape[1] - ys + m) + + if getvolume: + volarray = si[startx:endx, starty:endy].copy() + si[startx:endx, starty:endy] = np.minimum(si[startx:endx, starty:endy], + cutterArray[castartx:caendx, castarty:caendy] + z) + if getvolume: + volarray = si[startx:endx, starty:endy] - volarray + vsum = abs(volarray.sum()) + # print(vsum) + return vsum + return 0 diff --git a/scripts/addons/cam/slice.py b/scripts/addons/cam/slice.py index b1ea19a5..a3db7054 100644 --- a/scripts/addons/cam/slice.py +++ b/scripts/addons/cam/slice.py @@ -17,7 +17,25 @@ from . import ( ) -def slicing2d(ob, height): # April 2020 Alain Pelletier +def slicing2d(ob, height): + """Slice a 3D object at a specified height and convert it to a curve. + + This function applies transformations to the given object, switches to + edit mode, selects all vertices, and performs a bisect operation to + slice the object at the specified height. After slicing, it resets the + object's location and applies transformations again before converting + the object to a curve. If the conversion fails (for instance, if the + mesh was empty), the function deletes the mesh and returns False. + Otherwise, it returns True. + + Args: + ob (bpy.types.Object): The Blender object to be sliced and converted. + height (float): The height at which to slice the object. + + Returns: + bool: True if the conversion to curve was successful, False otherwise. + """ + # April 2020 Alain Pelletier # let's slice things bpy.ops.object.transform_apply(location=True, rotation=False, scale=False) bpy.ops.object.mode_set(mode='EDIT') # force edit mode @@ -38,7 +56,25 @@ def slicing2d(ob, height): # April 2020 Alain Pelletier return True -def slicing3d(ob, start, end): # April 2020 Alain Pelletier +def slicing3d(ob, start, end): + """Slice a 3D object along specified planes. + + This function applies transformations to a given object and slices it in + the Z-axis between two specified values, `start` and `end`. It first + ensures that the object is in edit mode and selects all vertices before + performing the slicing operations using the `bisect` method. After + slicing, it resets the object's location and applies the transformations + to maintain the changes. + + Args: + ob (Object): The 3D object to be sliced. + start (float): The starting Z-coordinate for the slice. + end (float): The ending Z-coordinate for the slice. + + Returns: + bool: True if the slicing operation was successful. + """ + # April 2020 Alain Pelletier # let's slice things bpy.ops.object.transform_apply(location=True, rotation=False, scale=False) bpy.ops.object.mode_set(mode='EDIT') # force edit mode @@ -59,7 +95,20 @@ def slicing3d(ob, start, end): # April 2020 Alain Pelletier return True -def sliceObject(ob): # April 2020 Alain Pelletier +def sliceObject(ob): + """Slice a 3D object into layers based on a specified thickness. + + This function takes a 3D object and slices it into multiple layers + according to the specified thickness. It creates a new collection for + the slices and optionally creates text labels for each slice if the + indexes parameter is set. The slicing can be done in either 2D or 3D + based on the user's selection. The function also handles the positioning + of the slices based on the object's bounding box. + + Args: + ob (bpy.types.Object): The 3D object to be sliced. + """ + # April 2020 Alain Pelletier # get variables from menu thickness = bpy.context.scene.cam_slice.slice_distance slice3d = bpy.context.scene.cam_slice.slice_3d diff --git a/scripts/addons/cam/strategy.py b/scripts/addons/cam/strategy.py index e4ca2795..8282efda 100644 --- a/scripts/addons/cam/strategy.py +++ b/scripts/addons/cam/strategy.py @@ -1,1016 +1,1188 @@ -"""BlenderCAM 'strategy.py' © 2012 Vilem Novak - -Strategy functionality of BlenderCAM - e.g. Cutout, Parallel, Spiral, Waterline -The functions here are called with operators defined in 'ops.py' -""" - -from math import ( - ceil, - pi, - radians, - sqrt, - tan, -) -import sys -import time - -import shapely -from shapely.geometry import polygon as spolygon -from shapely.geometry import Point # Double check this import! -from shapely import geometry as sgeometry -from shapely import affinity - -import bpy -from bpy_extras import object_utils -from mathutils import ( - Euler, - Vector -) - -from .bridges import useBridges -from .cam_chunk import ( - camPathChunk, - chunksRefine, - chunksRefineThreshold, - curveToChunks, - limitChunks, - optimizeChunk, - parentChildDist, - parentChildPoly, - setChunksZ, - shapelyToChunks, -) -from .collision import cleanupBulletCollision -from .exception import CamException -from .polygon_utils_cam import Circle, shapelyToCurve -from .simple import ( - activate, - delob, - join_multiple, - progress, - remove_multiple, -) -from .utils import ( - Add_Pocket, - checkEqual, - extendChunks5axis, - getObjectOutline, - getObjectSilhouete, - getOperationSilhouete, - getOperationSources, - Helix, - # Point, - sampleChunksNAxis, - sortChunks, - unique, -) - - -SHAPELY = True - - -# cutout strategy is completely here: -async def cutout(o): - max_depth = checkminz(o) - cutter_angle = radians(o.cutter_tip_angle / 2) - c_offset = o.cutter_diameter / 2 # cutter offset - print("cuttertype:", o.cutter_type, "max_depth:", max_depth) - if o.cutter_type == 'VCARVE': - c_offset = -max_depth * tan(cutter_angle) - elif o.cutter_type == 'CYLCONE': - c_offset = -max_depth * tan(cutter_angle) + o.cylcone_diameter / 2 - elif o.cutter_type == 'BALLCONE': - c_offset = -max_depth * tan(cutter_angle) + o.ball_radius - elif o.cutter_type == 'BALLNOSE': - r = o.cutter_diameter / 2 - print("cutter radius:", r, " skin", o.skin) - if -max_depth < r: - c_offset = sqrt(r ** 2 - (r + max_depth) ** 2) - print("offset:", c_offset) - if c_offset > o.cutter_diameter / 2: - c_offset = o.cutter_diameter / 2 - c_offset += o.skin # add skin for profile - if o.straight: - join = 2 - else: - join = 1 - print('Operation: Cutout') - offset = True - - for ob in o.objects: - if ob.type == 'CURVE': - if ob.data.splines[0].type == 'BEZIER': - activate(ob) - bpy.ops.object.curve_remove_doubles(merg_distance=0.0001, keep_bezier=True) - else: - bpy.ops.object.curve_remove_doubles() - - if o.cut_type == 'ONLINE' and o.onlycurves: # is separate to allow open curves :) - print('separate') - chunksFromCurve = [] - for ob in o.objects: - chunksFromCurve.extend(curveToChunks(ob, o.use_modifiers)) - - # chunks always have polys now - # for ch in chunksFromCurve: - # # print(ch.points) - - # if len(ch.points) > 2: - # ch.poly = chunkToShapely(ch) - - # p.addContour(ch.poly) - else: - chunksFromCurve = [] - if o.cut_type == 'ONLINE': - p = getObjectOutline(0, o, True) - - else: - offset = True - if o.cut_type == 'INSIDE': - offset = False - - p = getObjectOutline(c_offset, o, offset) - if o.outlines_count > 1: - for i in range(1, o.outlines_count): - chunksFromCurve.extend(shapelyToChunks(p, -1)) - path_distance = o.dist_between_paths - if o.cut_type == "INSIDE": - path_distance *= -1 - p = p.buffer(distance=path_distance, resolution=o.optimisation.circle_detail, join_style=join, - mitre_limit=2) - - chunksFromCurve.extend(shapelyToChunks(p, -1)) - if o.outlines_count > 1 and o.movement.insideout == 'OUTSIDEIN': - chunksFromCurve.reverse() - - # parentChildPoly(chunksFromCurve,chunksFromCurve,o) - chunksFromCurve = limitChunks(chunksFromCurve, o) - if not o.dont_merge: - parentChildPoly(chunksFromCurve, chunksFromCurve, o) - if o.outlines_count == 1: - chunksFromCurve = await sortChunks(chunksFromCurve, o) - - if (o.movement.type == 'CLIMB' and o.movement.spindle_rotation == 'CCW') or ( - o.movement.type == 'CONVENTIONAL' and o.movement.spindle_rotation == 'CW'): - for ch in chunksFromCurve: - ch.reverse() - - if o.cut_type == 'INSIDE': # there would bee too many conditions above, - # so for now it gets reversed once again when inside cutting. - for ch in chunksFromCurve: - ch.reverse() - - layers = getLayers(o, o.maxz, checkminz(o)) - extendorder = [] - - if o.first_down: # each shape gets either cut all the way to bottom, - # or every shape gets cut 1 layer, then all again. has to create copies, - # because same chunks are worked with on more layers usually - for chunk in chunksFromCurve: - dir_switch = False # needed to avoid unnecessary lifting of cutter with open chunks - # and movement set to "MEANDER" - for layer in layers: - chunk_copy = chunk.copy() - if dir_switch: - chunk_copy.reverse() - extendorder.append([chunk_copy, layer]) - if (not chunk.closed) and o.movement.type == "MEANDER": - dir_switch = not dir_switch - else: - for layer in layers: - for chunk in chunksFromCurve: - extendorder.append([chunk.copy(), layer]) - - for chl in extendorder: # Set Z for all chunks - chunk = chl[0] - layer = chl[1] - print(layer[1]) - chunk.setZ(layer[1]) - - chunks = [] - - if o.use_bridges: # add bridges to chunks - print('Using Bridges') - remove_multiple(o.name+'_cut_bridges') - print("Old Briddge Cut Removed") - - bridgeheight = min(o.max.z, o.min.z + abs(o.bridges_height)) - - for chl in extendorder: - chunk = chl[0] - layer = chl[1] - if layer[1] < bridgeheight: - useBridges(chunk, o) - - if o.profile_start > 0: - print("Cutout Change Profile Start") - for chl in extendorder: - chunk = chl[0] - if chunk.closed: - chunk.changePathStart(o) - - # Lead in - if o.lead_in > 0.0 or o.lead_out > 0: - print("Cutout Lead-in") - for chl in extendorder: - chunk = chl[0] - if chunk.closed: - chunk.breakPathForLeadinLeadout(o) - chunk.leadContour(o) - - if o.movement.ramp: # add ramps or simply add chunks - for chl in extendorder: - chunk = chl[0] - layer = chl[1] - if chunk.closed: - chunk.rampContour(layer[0], layer[1], o) - chunks.append(chunk) - else: - chunk.rampZigZag(layer[0], layer[1], o) - chunks.append(chunk) - else: - for chl in extendorder: - chunks.append(chl[0]) - - chunksToMesh(chunks, o) - - -async def curve(o): - print('Operation: Curve') - pathSamples = [] - getOperationSources(o) - if not o.onlycurves: - raise CamException("All Objects Must Be Curves for This Operation.") - - for ob in o.objects: - # make the chunks from curve here - pathSamples.extend(curveToChunks(ob)) - # sort before sampling - pathSamples = await sortChunks(pathSamples, o) - pathSamples = chunksRefine(pathSamples, o) # simplify - - # layers here - if o.use_layers: - layers = getLayers(o, o.maxz, round(checkminz(o), 6)) - # layers is a list of lists [[0.00,l1],[l1,l2],[l2,l3]] containg the start and end of each layer - extendorder = [] - chunks = [] - for layer in layers: - for ch in pathSamples: - # include layer information to chunk list - extendorder.append([ch.copy(), layer]) - - for chl in extendorder: # Set offset Z for all chunks according to the layer information, - chunk = chl[0] - layer = chl[1] - print('layer: ' + str(layer[1])) - chunk.offsetZ(o.maxz * 2 - o.minz + layer[1]) - chunk.clampZ(o.minz) # safety to not cut lower than minz - # safety, not higher than free movement height - chunk.clampmaxZ(o.movement.free_height) - - for chl in extendorder: # strip layer information from extendorder and transfer them to chunks - chunks.append(chl[0]) - - chunksToMesh(chunks, o) # finish by converting to mesh - - else: # no layers, old curve - for ch in pathSamples: - ch.clampZ(o.minz) # safety to not cut lower than minz - # safety, not higher than free movement height - ch.clampmaxZ(o.movement.free_height) - chunksToMesh(pathSamples, o) - - -async def proj_curve(s, o): - print('Operation: Projected Curve') - pathSamples = [] - chunks = [] - ob = bpy.data.objects[o.curve_object] - pathSamples.extend(curveToChunks(ob)) - - targetCurve = s.objects[o.curve_object1] - - from cam import cam_chunk - if targetCurve.type != 'CURVE': - raise CamException('Projection Target and Source Have to Be Curve Objects!') - - if 1: - extend_up = 0.1 - extend_down = 0.04 - tsamples = curveToChunks(targetCurve) - for chi, ch in enumerate(pathSamples): - cht = tsamples[chi].get_points() - ch.depth = 0 - ch_points = ch.get_points() - for i, s in enumerate(ch_points): - # move the points a bit - ep = Vector(cht[i]) - sp = Vector(ch_points[i]) - # extend startpoint - vecs = sp - ep - vecs.normalize() - vecs *= extend_up - sp += vecs - ch.startpoints.append(sp) - - # extend endpoint - vece = sp - ep - vece.normalize() - vece *= extend_down - ep -= vece - ch.endpoints.append(ep) - - ch.rotations.append((0, 0, 0)) - - vec = sp - ep - ch.depth = min(ch.depth, -vec.length) - ch_points[i] = sp.copy() - ch.set_points(ch_points) - layers = getLayers(o, 0, ch.depth) - - chunks.extend(sampleChunksNAxis(o, pathSamples, layers)) - chunksToMesh(chunks, o) - - -async def pocket(o): - print('Operation: Pocket') - scene = bpy.context.scene - - remove_multiple("3D_poc") - - max_depth = checkminz(o) + o.skin - cutter_angle = radians(o.cutter_tip_angle / 2) - c_offset = o.cutter_diameter / 2 - if o.cutter_type == 'VCARVE': - c_offset = -max_depth * tan(cutter_angle) - elif o.cutter_type == 'CYLCONE': - c_offset = -max_depth * tan(cutter_angle) + o.cylcone_diameter / 2 - elif o.cutter_type == 'BALLCONE': - c_offset = -max_depth * tan(cutter_angle) + o.ball_radius - if c_offset > o.cutter_diameter / 2: - c_offset = o.cutter_diameter / 2 - - c_offset += o.skin # add skin - print("Cutter Offset", c_offset) - for ob in o.objects: - if ob.type == 'CURVE': - if ob.data.splines[0].type == 'BEZIER': - activate(ob) - bpy.ops.object.curve_remove_doubles(merg_distance=0.0001, keep_bezier=True) - else: - bpy.ops.object.curve_remove_doubles() - - p = getObjectOutline(c_offset, o, False) - approxn = (min(o.max.x - o.min.x, o.max.y - o.min.y) / o.dist_between_paths) / 2 - print("Approximative:" + str(approxn)) - print(o) - - i = 0 - chunks = [] - chunksFromCurve = [] - lastchunks = [] - centers = None - firstoutline = p # for testing in the end. - prest = p.buffer(-c_offset, o.optimisation.circle_detail) - - while not p.is_empty: - if o.pocketToCurve: - # make a curve starting with _3dpocket - shapelyToCurve('3dpocket', p, 0.0) - - nchunks = shapelyToChunks(p, o.min.z) - # print("nchunks") - pnew = p.buffer(-o.dist_between_paths, o.optimisation.circle_detail) - if pnew.is_empty: - - # test if the last curve will leave material - pt = p.buffer(-c_offset, o.optimisation.circle_detail) - if not pt.is_empty: - pnew = pt - # print("pnew") - - nchunks = limitChunks(nchunks, o) - chunksFromCurve.extend(nchunks) - parentChildDist(lastchunks, nchunks, o) - lastchunks = nchunks - - percent = int(i / approxn * 100) - progress('Outlining Polygons ', percent) - p = pnew - - i += 1 - - # if (o.poc)#TODO inside outside! - if (o.movement.type == 'CLIMB' and o.movement.spindle_rotation == 'CW') or ( - o.movement.type == 'CONVENTIONAL' and o.movement.spindle_rotation == 'CCW'): - for ch in chunksFromCurve: - ch.reverse() - - chunksFromCurve = await sortChunks(chunksFromCurve, o) - - chunks = [] - layers = getLayers(o, o.maxz, checkminz(o)) - - for l in layers: - lchunks = setChunksZ(chunksFromCurve, l[1]) - if o.movement.ramp: - for ch in lchunks: - ch.zstart = l[0] - ch.zend = l[1] - - # helix_enter first try here TODO: check if helix radius is not out of operation area. - if o.movement.helix_enter: - helix_radius = c_offset * o.movement.helix_diameter * 0.01 # 90 percent of cutter radius - helix_circumference = helix_radius * pi * 2 - - revheight = helix_circumference * tan(o.movement.ramp_in_angle) - for chi, ch in enumerate(lchunks): - if not chunksFromCurve[chi].children: - # TODO:intercept closest next point when it should stay low - p = ch.get_point(0) - # first thing to do is to check if helix enter can really enter. - checkc = Circle(helix_radius + c_offset, o.optimisation.circle_detail) - checkc = affinity.translate(checkc, p[0], p[1]) - covers = False - for poly in o.silhouete.geoms: - if poly.contains(checkc): - covers = True - break - - if covers: - revolutions = (l[0] - p[2]) / revheight - # print(revolutions) - h = Helix(helix_radius, o.optimisation.circle_detail, l[0], p, revolutions) - # invert helix if not the typical direction - if (o.movement.type == 'CONVENTIONAL' and o.movement.spindle_rotation == 'CW') or ( - o.movement.type == 'CLIMB' and o.movement.spindle_rotation == 'CCW'): - nhelix = [] - for v in h: - nhelix.append((2 * p[0] - v[0], v[1], v[2])) - h = nhelix - ch.extend(h, at_index=0) -# ch.points = h + ch.points - - else: - o.info.warnings += 'Helix entry did not fit! \n ' - ch.closed = True - ch.rampZigZag(l[0], l[1], o) - # Arc retract here first try: - # TODO: check for entry and exit point before actual computing... will be much better. - if o.movement.retract_tangential: - # TODO: fix this for CW and CCW! - for chi, ch in enumerate(lchunks): - # print(chunksFromCurve[chi]) - # print(chunksFromCurve[chi].parents) - if chunksFromCurve[chi].parents == [] or len(chunksFromCurve[chi].parents) == 1: - - revolutions = 0.25 - v1 = Vector(ch.get_point(-1)) - i = -2 - v2 = Vector(ch.get_point(i)) - v = v1 - v2 - while v.length == 0: - i = i - 1 - v2 = Vector(ch.get_point(i)) - v = v1 - v2 - - v.normalize() - rotangle = Vector((v.x, v.y)).angle_signed(Vector((1, 0))) - e = Euler((0, 0, pi / 2.0)) # TODO:#CW CLIMB! - v.rotate(e) - p = v1 + v * o.movement.retract_radius - center = p - p = (p.x, p.y, p.z) - - # progress(str((v1,v,p))) - h = Helix(o.movement.retract_radius, o.optimisation.circle_detail, - p[2] + o.movement.retract_height, p, revolutions) - - # angle to rotate whole retract move - e = Euler((0, 0, rotangle + pi)) - rothelix = [] - c = [] # polygon for outlining and checking collisions. - for p in h: # rotate helix to go from tangent of vector - v1 = Vector(p) - - v = v1 - center - v.x = -v.x # flip it here first... - v.rotate(e) - p = center + v - rothelix.append(p) - c.append((p[0], p[1])) - - c = sgeometry.Polygon(c) - # print('çoutline') - # print(c) - coutline = c.buffer(c_offset, o.optimisation.circle_detail) - # print(h) - # print('çoutline') - # print(coutline) - # polyToMesh(coutline,0) - rothelix.reverse() - - covers = False - for poly in o.silhouete.geoms: - if poly.contains(coutline): - covers = True - break - - if covers: - ch.extend(rothelix) - - chunks.extend(lchunks) - - if o.movement.ramp: - for ch in chunks: - ch.rampZigZag(ch.zstart, ch.get_point(0)[2], o) - - if o.first_down: - if o.pocket_option == "OUTSIDE": - chunks.reverse() - chunks = await sortChunks(chunks, o) - - if o.pocketToCurve: # make curve instead of a path - join_multiple("3dpocket") - - else: - chunksToMesh(chunks, o) # make normal pocket path - - -async def drill(o): - print('Operation: Drill') - chunks = [] - for ob in o.objects: - activate(ob) - - bpy.ops.object.duplicate_move(OBJECT_OT_duplicate={"linked": False, "mode": 'TRANSLATION'}, - TRANSFORM_OT_translate={"value": (0, 0, 0), - "constraint_axis": (False, False, False), - "orient_type": 'GLOBAL', "mirror": False, - "use_proportional_edit": False, - "proportional_edit_falloff": 'SMOOTH', - "proportional_size": 1, "snap": False, - "snap_target": 'CLOSEST', "snap_point": (0, 0, 0), - "snap_align": False, "snap_normal": (0, 0, 0), - "texture_space": False, "release_confirm": False}) - # bpy.ops.collection.objects_remove_all() - bpy.ops.object.parent_clear(type='CLEAR_KEEP_TRANSFORM') - - ob = bpy.context.active_object - if ob.type == 'CURVE': - ob.data.dimensions = '3D' - try: - bpy.ops.object.transform_apply(location=True, rotation=False, scale=False) - bpy.ops.object.transform_apply(location=False, rotation=True, scale=False) - bpy.ops.object.transform_apply(location=False, rotation=False, scale=True) - - except: - pass - l = ob.location - - if ob.type == 'CURVE': - - for c in ob.data.splines: - maxx, minx, maxy, miny, maxz, minz = -10000, 10000, -10000, 10000, -10000, 10000 - for p in c.points: - if o.drill_type == 'ALL_POINTS': - chunks.append(camPathChunk([(p.co.x + l.x, p.co.y + l.y, p.co.z + l.z)])) - minx = min(p.co.x, minx) - maxx = max(p.co.x, maxx) - miny = min(p.co.y, miny) - maxy = max(p.co.y, maxy) - minz = min(p.co.z, minz) - maxz = max(p.co.z, maxz) - for p in c.bezier_points: - if o.drill_type == 'ALL_POINTS': - chunks.append(camPathChunk([(p.co.x + l.x, p.co.y + l.y, p.co.z + l.z)])) - minx = min(p.co.x, minx) - maxx = max(p.co.x, maxx) - miny = min(p.co.y, miny) - maxy = max(p.co.y, maxy) - minz = min(p.co.z, minz) - maxz = max(p.co.z, maxz) - cx = (maxx + minx) / 2 - cy = (maxy + miny) / 2 - cz = (maxz + minz) / 2 - - center = (cx, cy) - aspect = (maxx - minx) / (maxy - miny) - if (1.3 > aspect > 0.7 and o.drill_type == 'MIDDLE_SYMETRIC') or o.drill_type == 'MIDDLE_ALL': - chunks.append(camPathChunk([(center[0] + l.x, center[1] + l.y, cz + l.z)])) - - elif ob.type == 'MESH': - for v in ob.data.vertices: - chunks.append(camPathChunk([(v.co.x + l.x, v.co.y + l.y, v.co.z + l.z)])) - delob(ob) # delete temporary object with applied transforms - - layers = getLayers(o, o.maxz, checkminz(o)) - - chunklayers = [] - for layer in layers: - for chunk in chunks: - # If using object for minz then use z from points in object - if o.minz_from == 'OBJECT': - z = chunk.get_point(0)[2] - else: # using operation minz - z = o.minz - # only add a chunk layer if the chunk z point is in or lower than the layer - if z <= layer[0]: - if z <= layer[1]: - z = layer[1] - # perform peck drill - newchunk = chunk.copy() - newchunk.setZ(z) - chunklayers.append(newchunk) - # retract tool to maxz (operation depth start in ui) - newchunk = chunk.copy() - newchunk.setZ(o.maxz) - chunklayers.append(newchunk) - - chunklayers = await sortChunks(chunklayers, o) - chunksToMesh(chunklayers, o) - - -async def medial_axis(o): - print('Operation: Medial Axis') - - remove_multiple("medialMesh") - - from .voronoi import Site, computeVoronoiDiagram - - chunks = [] - - gpoly = spolygon.Polygon() - angle = o.cutter_tip_angle - slope = tan(pi * (90 - angle / 2) / 180) # angle in degrees - # slope = tan((pi-angle)/2) #angle in radian - new_cutter_diameter = o.cutter_diameter - m_o_ob = o.object_name - if o.cutter_type == 'VCARVE': - angle = o.cutter_tip_angle - # start the max depth calc from the "start depth" of the operation. - maxdepth = o.maxz - slope * o.cutter_diameter / 2 - o.skin - # don't cut any deeper than the "end depth" of the operation. - if maxdepth < o.minz: - maxdepth = o.minz - # the effective cutter diameter can be reduced from it's max - # since we will be cutting shallower than the original maxdepth - # without this, the curve is calculated as if the diameter was at the original maxdepth and we get the bit - # pulling away from the desired cut surface - new_cutter_diameter = (maxdepth - o.maxz) / (- slope) * 2 - elif o.cutter_type == 'BALLNOSE': - maxdepth = - new_cutter_diameter / 2 - o.skin - else: - raise CamException("Only Ballnose and V-carve Cutters Are Supported for Medial Axis.") - # remember resolutions of curves, to refine them, - # otherwise medial axis computation yields too many branches in curved parts - resolutions_before = [] - - for ob in o.objects: - if ob.type == 'CURVE': - if ob.data.splines[0].type == 'BEZIER': - activate(ob) - bpy.ops.object.curve_remove_doubles(merg_distance=0.0001, keep_bezier=True) - else: - bpy.ops.object.curve_remove_doubles() - - for ob in o.objects: - if ob.type == 'CURVE' or ob.type == 'FONT': - resolutions_before.append(ob.data.resolution_u) - if ob.data.resolution_u < 64: - ob.data.resolution_u = 64 - - polys = getOperationSilhouete(o) - if isinstance(polys, list): - if len(polys) == 1 and isinstance(polys[0], shapely.MultiPolygon): - mpoly = polys[0] - else: - mpoly = sgeometry.MultiPolygon(polys) - elif isinstance(polys, shapely.MultiPolygon): - # just a multipolygon - mpoly = polys - else: - raise CamException("Failed Getting Object Silhouette. Is Input Curve Closed?") - - mpoly_boundary = mpoly.boundary - ipol = 0 - for poly in mpoly.geoms: - ipol = ipol + 1 - schunks = shapelyToChunks(poly, -1) - schunks = chunksRefineThreshold(schunks, o.medial_axis_subdivision, - o.medial_axis_threshold) # chunksRefine(schunks,o) - - verts = [] - for ch in schunks: - verts.extend(ch.get_points()) - # for pt in ch.get_points(): - # # pvoro = Site(pt[0], pt[1]) - # verts.append(pt) # (pt[0], pt[1]), pt[2]) - # verts= points#[[vert.x, vert.y, vert.z] for vert in vertsPts] - nDupli, nZcolinear = unique(verts) - nVerts = len(verts) - print(str(nDupli) + " Duplicates Points Ignored") - print(str(nZcolinear) + " Z Colinear Points Excluded") - if nVerts < 3: - print("Not Enough Points") - return {'FINISHED'} - # Check colinear - xValues = [pt[0] for pt in verts] - yValues = [pt[1] for pt in verts] - if checkEqual(xValues) or checkEqual(yValues): - print("Points Are Colinear") - return {'FINISHED'} - # Create diagram - print("Tesselation... (" + str(nVerts) + " Points)") - xbuff, ybuff = 5, 5 # % - zPosition = 0 - vertsPts = [Point(vert[0], vert[1], vert[2]) for vert in verts] - # vertsPts= [Point(vert[0], vert[1]) for vert in verts] - - pts, edgesIdx = computeVoronoiDiagram( - vertsPts, xbuff, ybuff, polygonsOutput=False, formatOutput=True) - - # pts=[[pt[0], pt[1], zPosition] for pt in pts] - newIdx = 0 - vertr = [] - filteredPts = [] - print('Filter Points') - ipts = 0 - for p in pts: - ipts = ipts + 1 - if ipts % 500 == 0: - sys.stdout.write('\r') - # the exact output you're looking for: - prog_message = "Points: " + str(ipts) + " / " + str(len(pts)) + " " + str( - round(100 * ipts / len(pts))) + "%" - sys.stdout.write(prog_message) - sys.stdout.flush() - - if not poly.contains(sgeometry.Point(p)): - vertr.append((True, -1)) - else: - vertr.append((False, newIdx)) - if o.cutter_type == 'VCARVE': - # start the z depth calc from the "start depth" of the operation. - z = o.maxz - mpoly.boundary.distance(sgeometry.Point(p)) * slope - if z < maxdepth: - z = maxdepth - elif o.cutter_type == 'BALL' or o.cutter_type == 'BALLNOSE': - d = mpoly_boundary.distance(sgeometry.Point(p)) - r = new_cutter_diameter / 2.0 - if d >= r: - z = -r - else: - # print(r, d) - z = -r + sqrt(r * r - d * d) - else: - z = 0 # - # print(mpoly.distance(sgeometry.Point(0,0))) - # if(z!=0):print(z) - filteredPts.append((p[0], p[1], z)) - newIdx += 1 - - print('Filter Edges') - filteredEdgs = [] - ledges = [] - for e in edgesIdx: - do = True - # p1 = pts[e[0]] - # p2 = pts[e[1]] - # print(p1,p2,len(vertr)) - if vertr[e[0]][0]: # exclude edges with allready excluded points - do = False - elif vertr[e[1]][0]: - do = False - if do: - filteredEdgs.append((vertr[e[0]][1], vertr[e[1]][1])) - ledges.append(sgeometry.LineString( - (filteredPts[vertr[e[0]][1]], filteredPts[vertr[e[1]][1]]))) - # print(ledges[-1].has_z) - - bufpoly = poly.buffer(-new_cutter_diameter / 2, resolution=64) - - lines = shapely.ops.linemerge(ledges) - # print(lines.type) - - if bufpoly.type == 'Polygon' or bufpoly.type == 'MultiPolygon': - lines = lines.difference(bufpoly) - chunks.extend(shapelyToChunks(bufpoly, maxdepth)) - chunks.extend(shapelyToChunks(lines, 0)) - - # generate a mesh from the medial calculations - if o.add_mesh_for_medial: - shapelyToCurve('medialMesh', lines, 0.0) - bpy.ops.object.convert(target='MESH') - - oi = 0 - for ob in o.objects: - if ob.type == 'CURVE' or ob.type == 'FONT': - ob.data.resolution_u = resolutions_before[oi] - oi += 1 - - # bpy.ops.object.join() - chunks = await sortChunks(chunks, o) - - layers = getLayers(o, o.maxz, o.min.z) - - chunklayers = [] - - for layer in layers: - for chunk in chunks: - if chunk.isbelowZ(layer[0]): - newchunk = chunk.copy() - newchunk.clampZ(layer[1]) - chunklayers.append(newchunk) - - if o.first_down: - chunklayers = await sortChunks(chunklayers, o) - - if o.add_mesh_for_medial: # make curve instead of a path - join_multiple("medialMesh") - - chunksToMesh(chunklayers, o) - # add pocket operation for medial if add pocket checked - if o.add_pocket_for_medial: - # o.add_pocket_for_medial = False - # export medial axis parameter to pocket op - Add_Pocket(None, maxdepth, m_o_ob, new_cutter_diameter) - - -def getLayers(operation, startdepth, enddepth): - """Returns a List of Layers Bounded by Startdepth and Enddepth - Uses Operation.stepdown to Determine Number of Layers. - """ - if startdepth < enddepth: - raise CamException("Start Depth Is Lower than End Depth. " - "if You Have Set a Custom Depth End, It Must Be Lower than Depth Start, " - "and Should Usually Be Negative. Set This in the CAM Operation Area Panel.") - if operation.use_layers: - layers = [] - n = ceil((startdepth - enddepth) / operation.stepdown) - print("Start " + str(startdepth) + " End " + str(enddepth) + " n " + str(n)) - - layerstart = operation.maxz - for x in range(0, n): - layerend = round(max(startdepth - ((x + 1) * operation.stepdown), enddepth), 6) - if int(layerstart * 10 ** 8) != int(layerend * 10 ** 8): - # it was possible that with precise same end of operation, - # last layer was done 2x on exactly same level... - layers.append([layerstart, layerend]) - layerstart = layerend - else: - layers = [[round(startdepth, 6), round(enddepth, 6)]] - - return layers - - -def chunksToMesh(chunks, o): - """Convert Sampled Chunks to Path, Optimization of Paths""" - t = time.time() - s = bpy.context.scene - m = s.cam_machine - verts = [] - - free_height = o.movement.free_height # o.max.z + - - if o.machine_axes == '3': - if m.use_position_definitions: - origin = (m.starting_position.x, m.starting_position.y, m.starting_position.z) # dhull - else: - origin = (0, 0, free_height) - - verts = [origin] - if o.machine_axes != '3': - verts_rotations = [] # (0,0,0) - if (o.machine_axes == '5' and o.strategy5axis == 'INDEXED') or ( - o.machine_axes == '4' and o.strategy4axis == 'INDEXED'): - extendChunks5axis(chunks, o) - - if o.array: - nchunks = [] - for x in range(0, o.array_x_count): - for y in range(0, o.array_y_count): - print(x, y) - for ch in chunks: - ch = ch.copy() - ch.shift(x * o.array_x_distance, y * o.array_y_distance, 0) - nchunks.append(ch) - chunks = nchunks - - progress('Building Paths from Chunks') - e = 0.0001 - lifted = True - - for chi in range(0, len(chunks)): - - ch = chunks[chi] - # print(chunks) - # print (ch) - # TODO: there is a case where parallel+layers+zigzag ramps send empty chunks here... - if ch.count() > 0: - # print(len(ch.points)) - nverts = [] - if o.optimisation.optimize: - ch = optimizeChunk(ch, o) - - # lift and drop - - if lifted: # did the cutter lift before? if yes, put a new position above of the first point of next chunk. - if o.machine_axes == '3' or (o.machine_axes == '5' and o.strategy5axis == 'INDEXED') or ( - o.machine_axes == '4' and o.strategy4axis == 'INDEXED'): - v = (ch.get_point(0)[0], ch.get_point(0)[1], free_height) - else: # otherwise, continue with the next chunk without lifting/dropping - v = ch.startpoints[0] # startpoints=retract points - verts_rotations.append(ch.rotations[0]) - verts.append(v) - - # add whole chunk - verts.extend(ch.get_points()) - - # add rotations for n-axis - if o.machine_axes != '3': - verts_rotations.extend(ch.rotations) - - lift = True - # check if lifting should happen - if chi < len(chunks) - 1 and chunks[chi + 1].count() > 0: - # TODO: remake this for n axis, and this check should be somewhere else... - last = Vector(ch.get_point(-1)) - first = Vector(chunks[chi + 1].get_point(0)) - vect = first - last - if (o.machine_axes == '3' and (o.strategy == 'PARALLEL' or o.strategy == 'CROSS') - and vect.z == 0 and vect.length < o.dist_between_paths * 2.5) \ - or (o.machine_axes == '4' and vect.length < o.dist_between_paths * 2.5): - # case of neighbouring paths - lift = False - # case of stepdown by cutting. - if abs(vect.x) < e and abs(vect.y) < e: - lift = False - - if lift: - if o.machine_axes == '3' or (o.machine_axes == '5' and o.strategy5axis == 'INDEXED') or ( - o.machine_axes == '4' and o.strategy4axis == 'INDEXED'): - v = (ch.get_point(-1)[0], ch.get_point(-1)[1], free_height) - else: - v = ch.startpoints[-1] - verts_rotations.append(ch.rotations[-1]) - verts.append(v) - lifted = lift - # print(verts_rotations) - if o.optimisation.use_exact and not o.optimisation.use_opencamlib: - cleanupBulletCollision(o) - print(time.time() - t) - t = time.time() - - # actual blender object generation starts here: - edges = [] - for a in range(0, len(verts) - 1): - edges.append((a, a + 1)) - - oname = "cam_path_{}".format(o.name) - - mesh = bpy.data.meshes.new(oname) - mesh.name = oname - mesh.from_pydata(verts, edges, []) - - if oname in s.objects: - s.objects[oname].data = mesh - ob = s.objects[oname] - else: - ob = object_utils.object_data_add(bpy.context, mesh, operator=None) - - if o.machine_axes != '3': - # store rotations into shape keys, only way to store large arrays with correct floating point precision - # - object/mesh attributes can only store array up to 32000 intems. - - ob.shape_key_add() - ob.shape_key_add() - shapek = mesh.shape_keys.key_blocks[1] - shapek.name = 'rotations' - print(len(shapek.data)) - print(len(verts_rotations)) - - # TODO: optimize this. this is just rewritten too many times... - for i, co in enumerate(verts_rotations): - shapek.data[i].co = co - - print(time.time() - t) - - ob.location = (0, 0, 0) - o.path_object_name = oname - - # parent the path object to source object if object mode - if (o.geometry_source == 'OBJECT') and o.parent_path_to_object: - activate(o.objects[0]) - ob.select_set(state=True, view_layer=None) - bpy.ops.object.parent_set(type='OBJECT', keep_transform=True) - else: - ob.select_set(state=True, view_layer=None) - - -def checkminz(o): - if o.minz_from == 'MATERIAL': - return o.min.z - else: - return o.minz +"""BlenderCAM 'strategy.py' © 2012 Vilem Novak + +Strategy functionality of BlenderCAM - e.g. Cutout, Parallel, Spiral, Waterline +The functions here are called with operators defined in 'ops.py' +""" + +from math import ( + ceil, + pi, + radians, + sqrt, + tan, +) +import sys +import time + +import shapely +from shapely.geometry import polygon as spolygon +from shapely.geometry import Point # Double check this import! +from shapely import geometry as sgeometry +from shapely import affinity + +import bpy +from bpy_extras import object_utils +from mathutils import ( + Euler, + Vector +) + +from .bridges import useBridges +from .cam_chunk import ( + camPathChunk, + chunksRefine, + chunksRefineThreshold, + curveToChunks, + limitChunks, + optimizeChunk, + parentChildDist, + parentChildPoly, + setChunksZ, + shapelyToChunks, +) +from .collision import cleanupBulletCollision +from .exception import CamException +from .polygon_utils_cam import Circle, shapelyToCurve +from .simple import ( + activate, + delob, + join_multiple, + progress, + remove_multiple, +) +from .utils import ( + Add_Pocket, + checkEqual, + extendChunks5axis, + getObjectOutline, + getObjectSilhouete, + getOperationSilhouete, + getOperationSources, + Helix, + # Point, + sampleChunksNAxis, + sortChunks, + unique, +) + + +SHAPELY = True + + +# cutout strategy is completely here: +async def cutout(o): + """Perform a cutout operation based on the provided parameters. + + This function calculates the necessary cutter offset based on the cutter + type and its parameters. It processes a list of objects to determine how + to cut them based on their geometry and the specified cutting type. The + function handles different cutter types such as 'VCARVE', 'CYLCONE', + 'BALLCONE', and 'BALLNOSE', applying specific calculations for each. It + also manages the layering and movement strategies for the cutting + operation, including options for lead-ins, ramps, and bridges. + + Args: + o (object): An object containing parameters for the cutout operation, + including cutter type, diameter, depth, and other settings. + + Returns: + None: This function does not return a value but performs operations + on the provided object. + """ + + max_depth = checkminz(o) + cutter_angle = radians(o.cutter_tip_angle / 2) + c_offset = o.cutter_diameter / 2 # cutter offset + print("cuttertype:", o.cutter_type, "max_depth:", max_depth) + if o.cutter_type == 'VCARVE': + c_offset = -max_depth * tan(cutter_angle) + elif o.cutter_type == 'CYLCONE': + c_offset = -max_depth * tan(cutter_angle) + o.cylcone_diameter / 2 + elif o.cutter_type == 'BALLCONE': + c_offset = -max_depth * tan(cutter_angle) + o.ball_radius + elif o.cutter_type == 'BALLNOSE': + r = o.cutter_diameter / 2 + print("cutter radius:", r, " skin", o.skin) + if -max_depth < r: + c_offset = sqrt(r ** 2 - (r + max_depth) ** 2) + print("offset:", c_offset) + if c_offset > o.cutter_diameter / 2: + c_offset = o.cutter_diameter / 2 + c_offset += o.skin # add skin for profile + if o.straight: + join = 2 + else: + join = 1 + print('Operation: Cutout') + offset = True + + for ob in o.objects: + if ob.type == 'CURVE': + if ob.data.splines[0].type == 'BEZIER': + activate(ob) + bpy.ops.object.curve_remove_doubles(merg_distance=0.0001, keep_bezier=True) + else: + bpy.ops.object.curve_remove_doubles() + + if o.cut_type == 'ONLINE' and o.onlycurves: # is separate to allow open curves :) + print('separate') + chunksFromCurve = [] + for ob in o.objects: + chunksFromCurve.extend(curveToChunks(ob, o.use_modifiers)) + + # chunks always have polys now + # for ch in chunksFromCurve: + # # print(ch.points) + + # if len(ch.points) > 2: + # ch.poly = chunkToShapely(ch) + + # p.addContour(ch.poly) + else: + chunksFromCurve = [] + if o.cut_type == 'ONLINE': + p = getObjectOutline(0, o, True) + + else: + offset = True + if o.cut_type == 'INSIDE': + offset = False + + p = getObjectOutline(c_offset, o, offset) + if o.outlines_count > 1: + for i in range(1, o.outlines_count): + chunksFromCurve.extend(shapelyToChunks(p, -1)) + path_distance = o.dist_between_paths + if o.cut_type == "INSIDE": + path_distance *= -1 + p = p.buffer(distance=path_distance, resolution=o.optimisation.circle_detail, join_style=join, + mitre_limit=2) + + chunksFromCurve.extend(shapelyToChunks(p, -1)) + if o.outlines_count > 1 and o.movement.insideout == 'OUTSIDEIN': + chunksFromCurve.reverse() + + # parentChildPoly(chunksFromCurve,chunksFromCurve,o) + chunksFromCurve = limitChunks(chunksFromCurve, o) + if not o.dont_merge: + parentChildPoly(chunksFromCurve, chunksFromCurve, o) + if o.outlines_count == 1: + chunksFromCurve = await sortChunks(chunksFromCurve, o) + + if (o.movement.type == 'CLIMB' and o.movement.spindle_rotation == 'CCW') or ( + o.movement.type == 'CONVENTIONAL' and o.movement.spindle_rotation == 'CW'): + for ch in chunksFromCurve: + ch.reverse() + + if o.cut_type == 'INSIDE': # there would bee too many conditions above, + # so for now it gets reversed once again when inside cutting. + for ch in chunksFromCurve: + ch.reverse() + + layers = getLayers(o, o.maxz, checkminz(o)) + extendorder = [] + + if o.first_down: # each shape gets either cut all the way to bottom, + # or every shape gets cut 1 layer, then all again. has to create copies, + # because same chunks are worked with on more layers usually + for chunk in chunksFromCurve: + dir_switch = False # needed to avoid unnecessary lifting of cutter with open chunks + # and movement set to "MEANDER" + for layer in layers: + chunk_copy = chunk.copy() + if dir_switch: + chunk_copy.reverse() + extendorder.append([chunk_copy, layer]) + if (not chunk.closed) and o.movement.type == "MEANDER": + dir_switch = not dir_switch + else: + for layer in layers: + for chunk in chunksFromCurve: + extendorder.append([chunk.copy(), layer]) + + for chl in extendorder: # Set Z for all chunks + chunk = chl[0] + layer = chl[1] + print(layer[1]) + chunk.setZ(layer[1]) + + chunks = [] + + if o.use_bridges: # add bridges to chunks + print('Using Bridges') + remove_multiple(o.name+'_cut_bridges') + print("Old Briddge Cut Removed") + + bridgeheight = min(o.max.z, o.min.z + abs(o.bridges_height)) + + for chl in extendorder: + chunk = chl[0] + layer = chl[1] + if layer[1] < bridgeheight: + useBridges(chunk, o) + + if o.profile_start > 0: + print("Cutout Change Profile Start") + for chl in extendorder: + chunk = chl[0] + if chunk.closed: + chunk.changePathStart(o) + + # Lead in + if o.lead_in > 0.0 or o.lead_out > 0: + print("Cutout Lead-in") + for chl in extendorder: + chunk = chl[0] + if chunk.closed: + chunk.breakPathForLeadinLeadout(o) + chunk.leadContour(o) + + if o.movement.ramp: # add ramps or simply add chunks + for chl in extendorder: + chunk = chl[0] + layer = chl[1] + if chunk.closed: + chunk.rampContour(layer[0], layer[1], o) + chunks.append(chunk) + else: + chunk.rampZigZag(layer[0], layer[1], o) + chunks.append(chunk) + else: + for chl in extendorder: + chunks.append(chl[0]) + + chunksToMesh(chunks, o) + + +async def curve(o): + """Process and convert curve objects into mesh chunks. + + This function takes an operation object and processes the curves + contained within it. It first checks if all objects are curves; if not, + it raises an exception. The function then converts the curves into + chunks, sorts them, and refines them. If layers are to be used, it + applies layer information to the chunks, adjusting their Z-offsets + accordingly. Finally, it converts the processed chunks into a mesh. + + Args: + o (Operation): An object containing operation parameters, including a list of + objects, flags for layer usage, and movement constraints. + + Returns: + None: This function does not return a value; it performs operations on the + input. + + Raises: + CamException: If not all objects in the operation are curves. + """ + + print('Operation: Curve') + pathSamples = [] + getOperationSources(o) + if not o.onlycurves: + raise CamException("All Objects Must Be Curves for This Operation.") + + for ob in o.objects: + # make the chunks from curve here + pathSamples.extend(curveToChunks(ob)) + # sort before sampling + pathSamples = await sortChunks(pathSamples, o) + pathSamples = chunksRefine(pathSamples, o) # simplify + + # layers here + if o.use_layers: + layers = getLayers(o, o.maxz, round(checkminz(o), 6)) + # layers is a list of lists [[0.00,l1],[l1,l2],[l2,l3]] containg the start and end of each layer + extendorder = [] + chunks = [] + for layer in layers: + for ch in pathSamples: + # include layer information to chunk list + extendorder.append([ch.copy(), layer]) + + for chl in extendorder: # Set offset Z for all chunks according to the layer information, + chunk = chl[0] + layer = chl[1] + print('layer: ' + str(layer[1])) + chunk.offsetZ(o.maxz * 2 - o.minz + layer[1]) + chunk.clampZ(o.minz) # safety to not cut lower than minz + # safety, not higher than free movement height + chunk.clampmaxZ(o.movement.free_height) + + for chl in extendorder: # strip layer information from extendorder and transfer them to chunks + chunks.append(chl[0]) + + chunksToMesh(chunks, o) # finish by converting to mesh + + else: # no layers, old curve + for ch in pathSamples: + ch.clampZ(o.minz) # safety to not cut lower than minz + # safety, not higher than free movement height + ch.clampmaxZ(o.movement.free_height) + chunksToMesh(pathSamples, o) + + +async def proj_curve(s, o): + """Project a curve onto another curve object. + + This function takes a source object and a target object, both of which + are expected to be curve objects. It projects the points of the source + curve onto the target curve, adjusting the start and end points based on + specified extensions. The resulting projected points are stored in the + source object's path samples. + + Args: + s (object): The source object containing the curve to be projected. + o (object): An object containing references to the curve objects + involved in the projection. + + Returns: + None: This function does not return a value; it modifies the + source object's path samples in place. + + Raises: + CamException: If the target curve is not of type 'CURVE'. + """ + + print('Operation: Projected Curve') + pathSamples = [] + chunks = [] + ob = bpy.data.objects[o.curve_object] + pathSamples.extend(curveToChunks(ob)) + + targetCurve = s.objects[o.curve_object1] + + from cam import cam_chunk + if targetCurve.type != 'CURVE': + raise CamException('Projection Target and Source Have to Be Curve Objects!') + + if 1: + extend_up = 0.1 + extend_down = 0.04 + tsamples = curveToChunks(targetCurve) + for chi, ch in enumerate(pathSamples): + cht = tsamples[chi].get_points() + ch.depth = 0 + ch_points = ch.get_points() + for i, s in enumerate(ch_points): + # move the points a bit + ep = Vector(cht[i]) + sp = Vector(ch_points[i]) + # extend startpoint + vecs = sp - ep + vecs.normalize() + vecs *= extend_up + sp += vecs + ch.startpoints.append(sp) + + # extend endpoint + vece = sp - ep + vece.normalize() + vece *= extend_down + ep -= vece + ch.endpoints.append(ep) + + ch.rotations.append((0, 0, 0)) + + vec = sp - ep + ch.depth = min(ch.depth, -vec.length) + ch_points[i] = sp.copy() + ch.set_points(ch_points) + layers = getLayers(o, 0, ch.depth) + + chunks.extend(sampleChunksNAxis(o, pathSamples, layers)) + chunksToMesh(chunks, o) + + +async def pocket(o): + """Perform pocketing operation based on the provided parameters. + + This function executes a pocketing operation using the specified + parameters from the object `o`. It calculates the cutter offset based on + the cutter type and depth, processes curves, and generates the necessary + chunks for the pocketing operation. The function also handles various + movement types and optimizations, including helix entry and retract + movements. + + Args: + o (object): An object containing parameters for the pocketing + + Returns: + None: The function modifies the scene and generates geometry + based on the pocketing operation. + """ + + print('Operation: Pocket') + scene = bpy.context.scene + + remove_multiple("3D_poc") + + max_depth = checkminz(o) + o.skin + cutter_angle = radians(o.cutter_tip_angle / 2) + c_offset = o.cutter_diameter / 2 + if o.cutter_type == 'VCARVE': + c_offset = -max_depth * tan(cutter_angle) + elif o.cutter_type == 'CYLCONE': + c_offset = -max_depth * tan(cutter_angle) + o.cylcone_diameter / 2 + elif o.cutter_type == 'BALLCONE': + c_offset = -max_depth * tan(cutter_angle) + o.ball_radius + if c_offset > o.cutter_diameter / 2: + c_offset = o.cutter_diameter / 2 + + c_offset += o.skin # add skin + print("Cutter Offset", c_offset) + for ob in o.objects: + if ob.type == 'CURVE': + if ob.data.splines[0].type == 'BEZIER': + activate(ob) + bpy.ops.object.curve_remove_doubles(merg_distance=0.0001, keep_bezier=True) + else: + bpy.ops.object.curve_remove_doubles() + + p = getObjectOutline(c_offset, o, False) + approxn = (min(o.max.x - o.min.x, o.max.y - o.min.y) / o.dist_between_paths) / 2 + print("Approximative:" + str(approxn)) + print(o) + + i = 0 + chunks = [] + chunksFromCurve = [] + lastchunks = [] + centers = None + firstoutline = p # for testing in the end. + prest = p.buffer(-c_offset, o.optimisation.circle_detail) + + while not p.is_empty: + if o.pocketToCurve: + # make a curve starting with _3dpocket + shapelyToCurve('3dpocket', p, 0.0) + + nchunks = shapelyToChunks(p, o.min.z) + # print("nchunks") + pnew = p.buffer(-o.dist_between_paths, o.optimisation.circle_detail) + if pnew.is_empty: + + # test if the last curve will leave material + pt = p.buffer(-c_offset, o.optimisation.circle_detail) + if not pt.is_empty: + pnew = pt + # print("pnew") + + nchunks = limitChunks(nchunks, o) + chunksFromCurve.extend(nchunks) + parentChildDist(lastchunks, nchunks, o) + lastchunks = nchunks + + percent = int(i / approxn * 100) + progress('Outlining Polygons ', percent) + p = pnew + + i += 1 + + # if (o.poc)#TODO inside outside! + if (o.movement.type == 'CLIMB' and o.movement.spindle_rotation == 'CW') or ( + o.movement.type == 'CONVENTIONAL' and o.movement.spindle_rotation == 'CCW'): + for ch in chunksFromCurve: + ch.reverse() + + chunksFromCurve = await sortChunks(chunksFromCurve, o) + + chunks = [] + layers = getLayers(o, o.maxz, checkminz(o)) + + for l in layers: + lchunks = setChunksZ(chunksFromCurve, l[1]) + if o.movement.ramp: + for ch in lchunks: + ch.zstart = l[0] + ch.zend = l[1] + + # helix_enter first try here TODO: check if helix radius is not out of operation area. + if o.movement.helix_enter: + helix_radius = c_offset * o.movement.helix_diameter * 0.01 # 90 percent of cutter radius + helix_circumference = helix_radius * pi * 2 + + revheight = helix_circumference * tan(o.movement.ramp_in_angle) + for chi, ch in enumerate(lchunks): + if not chunksFromCurve[chi].children: + # TODO:intercept closest next point when it should stay low + p = ch.get_point(0) + # first thing to do is to check if helix enter can really enter. + checkc = Circle(helix_radius + c_offset, o.optimisation.circle_detail) + checkc = affinity.translate(checkc, p[0], p[1]) + covers = False + for poly in o.silhouete.geoms: + if poly.contains(checkc): + covers = True + break + + if covers: + revolutions = (l[0] - p[2]) / revheight + # print(revolutions) + h = Helix(helix_radius, o.optimisation.circle_detail, l[0], p, revolutions) + # invert helix if not the typical direction + if (o.movement.type == 'CONVENTIONAL' and o.movement.spindle_rotation == 'CW') or ( + o.movement.type == 'CLIMB' and o.movement.spindle_rotation == 'CCW'): + nhelix = [] + for v in h: + nhelix.append((2 * p[0] - v[0], v[1], v[2])) + h = nhelix + ch.extend(h, at_index=0) +# ch.points = h + ch.points + + else: + o.info.warnings += 'Helix entry did not fit! \n ' + ch.closed = True + ch.rampZigZag(l[0], l[1], o) + # Arc retract here first try: + # TODO: check for entry and exit point before actual computing... will be much better. + if o.movement.retract_tangential: + # TODO: fix this for CW and CCW! + for chi, ch in enumerate(lchunks): + # print(chunksFromCurve[chi]) + # print(chunksFromCurve[chi].parents) + if chunksFromCurve[chi].parents == [] or len(chunksFromCurve[chi].parents) == 1: + + revolutions = 0.25 + v1 = Vector(ch.get_point(-1)) + i = -2 + v2 = Vector(ch.get_point(i)) + v = v1 - v2 + while v.length == 0: + i = i - 1 + v2 = Vector(ch.get_point(i)) + v = v1 - v2 + + v.normalize() + rotangle = Vector((v.x, v.y)).angle_signed(Vector((1, 0))) + e = Euler((0, 0, pi / 2.0)) # TODO:#CW CLIMB! + v.rotate(e) + p = v1 + v * o.movement.retract_radius + center = p + p = (p.x, p.y, p.z) + + # progress(str((v1,v,p))) + h = Helix(o.movement.retract_radius, o.optimisation.circle_detail, + p[2] + o.movement.retract_height, p, revolutions) + + # angle to rotate whole retract move + e = Euler((0, 0, rotangle + pi)) + rothelix = [] + c = [] # polygon for outlining and checking collisions. + for p in h: # rotate helix to go from tangent of vector + v1 = Vector(p) + + v = v1 - center + v.x = -v.x # flip it here first... + v.rotate(e) + p = center + v + rothelix.append(p) + c.append((p[0], p[1])) + + c = sgeometry.Polygon(c) + # print('çoutline') + # print(c) + coutline = c.buffer(c_offset, o.optimisation.circle_detail) + # print(h) + # print('çoutline') + # print(coutline) + # polyToMesh(coutline,0) + rothelix.reverse() + + covers = False + for poly in o.silhouete.geoms: + if poly.contains(coutline): + covers = True + break + + if covers: + ch.extend(rothelix) + + chunks.extend(lchunks) + + if o.movement.ramp: + for ch in chunks: + ch.rampZigZag(ch.zstart, ch.get_point(0)[2], o) + + if o.first_down: + if o.pocket_option == "OUTSIDE": + chunks.reverse() + chunks = await sortChunks(chunks, o) + + if o.pocketToCurve: # make curve instead of a path + join_multiple("3dpocket") + + else: + chunksToMesh(chunks, o) # make normal pocket path + + +async def drill(o): + """Perform a drilling operation on the specified objects. + + This function iterates through the objects in the provided context, + activating each object and applying transformations. It duplicates the + objects and processes them based on their type (CURVE or MESH). For + CURVE objects, it calculates the bounding box and center points of the + splines and bezier points, and generates chunks based on the specified + drill type. For MESH objects, it generates chunks from the vertices. The + function also manages layers and chunk depths for the drilling + operation. + + Args: + o (object): An object containing properties and methods required + for the drilling operation, including a list of + objects to drill, drill type, and depth parameters. + + Returns: + None: This function does not return a value but performs operations + that modify the state of the Blender context. + """ + + print('Operation: Drill') + chunks = [] + for ob in o.objects: + activate(ob) + + bpy.ops.object.duplicate_move(OBJECT_OT_duplicate={"linked": False, "mode": 'TRANSLATION'}, + TRANSFORM_OT_translate={"value": (0, 0, 0), + "constraint_axis": (False, False, False), + "orient_type": 'GLOBAL', "mirror": False, + "use_proportional_edit": False, + "proportional_edit_falloff": 'SMOOTH', + "proportional_size": 1, "snap": False, + "snap_target": 'CLOSEST', "snap_point": (0, 0, 0), + "snap_align": False, "snap_normal": (0, 0, 0), + "texture_space": False, "release_confirm": False}) + # bpy.ops.collection.objects_remove_all() + bpy.ops.object.parent_clear(type='CLEAR_KEEP_TRANSFORM') + + ob = bpy.context.active_object + if ob.type == 'CURVE': + ob.data.dimensions = '3D' + try: + bpy.ops.object.transform_apply(location=True, rotation=False, scale=False) + bpy.ops.object.transform_apply(location=False, rotation=True, scale=False) + bpy.ops.object.transform_apply(location=False, rotation=False, scale=True) + + except: + pass + l = ob.location + + if ob.type == 'CURVE': + + for c in ob.data.splines: + maxx, minx, maxy, miny, maxz, minz = -10000, 10000, -10000, 10000, -10000, 10000 + for p in c.points: + if o.drill_type == 'ALL_POINTS': + chunks.append(camPathChunk([(p.co.x + l.x, p.co.y + l.y, p.co.z + l.z)])) + minx = min(p.co.x, minx) + maxx = max(p.co.x, maxx) + miny = min(p.co.y, miny) + maxy = max(p.co.y, maxy) + minz = min(p.co.z, minz) + maxz = max(p.co.z, maxz) + for p in c.bezier_points: + if o.drill_type == 'ALL_POINTS': + chunks.append(camPathChunk([(p.co.x + l.x, p.co.y + l.y, p.co.z + l.z)])) + minx = min(p.co.x, minx) + maxx = max(p.co.x, maxx) + miny = min(p.co.y, miny) + maxy = max(p.co.y, maxy) + minz = min(p.co.z, minz) + maxz = max(p.co.z, maxz) + cx = (maxx + minx) / 2 + cy = (maxy + miny) / 2 + cz = (maxz + minz) / 2 + + center = (cx, cy) + aspect = (maxx - minx) / (maxy - miny) + if (1.3 > aspect > 0.7 and o.drill_type == 'MIDDLE_SYMETRIC') or o.drill_type == 'MIDDLE_ALL': + chunks.append(camPathChunk([(center[0] + l.x, center[1] + l.y, cz + l.z)])) + + elif ob.type == 'MESH': + for v in ob.data.vertices: + chunks.append(camPathChunk([(v.co.x + l.x, v.co.y + l.y, v.co.z + l.z)])) + delob(ob) # delete temporary object with applied transforms + + layers = getLayers(o, o.maxz, checkminz(o)) + + chunklayers = [] + for layer in layers: + for chunk in chunks: + # If using object for minz then use z from points in object + if o.minz_from == 'OBJECT': + z = chunk.get_point(0)[2] + else: # using operation minz + z = o.minz + # only add a chunk layer if the chunk z point is in or lower than the layer + if z <= layer[0]: + if z <= layer[1]: + z = layer[1] + # perform peck drill + newchunk = chunk.copy() + newchunk.setZ(z) + chunklayers.append(newchunk) + # retract tool to maxz (operation depth start in ui) + newchunk = chunk.copy() + newchunk.setZ(o.maxz) + chunklayers.append(newchunk) + + chunklayers = await sortChunks(chunklayers, o) + chunksToMesh(chunklayers, o) + + +async def medial_axis(o): + """Generate the medial axis for a given operation. + + This function computes the medial axis of the specified operation, which + involves processing various cutter types and their parameters. It starts + by removing any existing medial mesh, then calculates the maximum depth + based on the cutter type and its properties. The function refines curves + and computes the Voronoi diagram for the points derived from the + operation's silhouette. It filters points and edges based on their + positions relative to the computed shapes, and generates a mesh + representation of the medial axis. Finally, it handles layers and + optionally adds a pocket operation if specified. + + Args: + o (Operation): An object containing parameters for the operation, including + cutter type, dimensions, and other relevant properties. + + Returns: + dict: A dictionary indicating the completion status of the operation. + + Raises: + CamException: If an unsupported cutter type is provided or if the input curve + is not closed. + """ + + print('Operation: Medial Axis') + + remove_multiple("medialMesh") + + from .voronoi import Site, computeVoronoiDiagram + + chunks = [] + + gpoly = spolygon.Polygon() + angle = o.cutter_tip_angle + slope = tan(pi * (90 - angle / 2) / 180) # angle in degrees + # slope = tan((pi-angle)/2) #angle in radian + new_cutter_diameter = o.cutter_diameter + m_o_ob = o.object_name + if o.cutter_type == 'VCARVE': + angle = o.cutter_tip_angle + # start the max depth calc from the "start depth" of the operation. + maxdepth = o.maxz - slope * o.cutter_diameter / 2 - o.skin + # don't cut any deeper than the "end depth" of the operation. + if maxdepth < o.minz: + maxdepth = o.minz + # the effective cutter diameter can be reduced from it's max + # since we will be cutting shallower than the original maxdepth + # without this, the curve is calculated as if the diameter was at the original maxdepth and we get the bit + # pulling away from the desired cut surface + new_cutter_diameter = (maxdepth - o.maxz) / (- slope) * 2 + elif o.cutter_type == 'BALLNOSE': + maxdepth = - new_cutter_diameter / 2 - o.skin + else: + raise CamException("Only Ballnose and V-carve Cutters Are Supported for Medial Axis.") + # remember resolutions of curves, to refine them, + # otherwise medial axis computation yields too many branches in curved parts + resolutions_before = [] + + for ob in o.objects: + if ob.type == 'CURVE': + if ob.data.splines[0].type == 'BEZIER': + activate(ob) + bpy.ops.object.curve_remove_doubles(merg_distance=0.0001, keep_bezier=True) + else: + bpy.ops.object.curve_remove_doubles() + + for ob in o.objects: + if ob.type == 'CURVE' or ob.type == 'FONT': + resolutions_before.append(ob.data.resolution_u) + if ob.data.resolution_u < 64: + ob.data.resolution_u = 64 + + polys = getOperationSilhouete(o) + if isinstance(polys, list): + if len(polys) == 1 and isinstance(polys[0], shapely.MultiPolygon): + mpoly = polys[0] + else: + mpoly = sgeometry.MultiPolygon(polys) + elif isinstance(polys, shapely.MultiPolygon): + # just a multipolygon + mpoly = polys + else: + raise CamException("Failed Getting Object Silhouette. Is Input Curve Closed?") + + mpoly_boundary = mpoly.boundary + ipol = 0 + for poly in mpoly.geoms: + ipol = ipol + 1 + schunks = shapelyToChunks(poly, -1) + schunks = chunksRefineThreshold(schunks, o.medial_axis_subdivision, + o.medial_axis_threshold) # chunksRefine(schunks,o) + + verts = [] + for ch in schunks: + verts.extend(ch.get_points()) + # for pt in ch.get_points(): + # # pvoro = Site(pt[0], pt[1]) + # verts.append(pt) # (pt[0], pt[1]), pt[2]) + # verts= points#[[vert.x, vert.y, vert.z] for vert in vertsPts] + nDupli, nZcolinear = unique(verts) + nVerts = len(verts) + print(str(nDupli) + " Duplicates Points Ignored") + print(str(nZcolinear) + " Z Colinear Points Excluded") + if nVerts < 3: + print("Not Enough Points") + return {'FINISHED'} + # Check colinear + xValues = [pt[0] for pt in verts] + yValues = [pt[1] for pt in verts] + if checkEqual(xValues) or checkEqual(yValues): + print("Points Are Colinear") + return {'FINISHED'} + # Create diagram + print("Tesselation... (" + str(nVerts) + " Points)") + xbuff, ybuff = 5, 5 # % + zPosition = 0 + vertsPts = [Point(vert[0], vert[1], vert[2]) for vert in verts] + # vertsPts= [Point(vert[0], vert[1]) for vert in verts] + + pts, edgesIdx = computeVoronoiDiagram( + vertsPts, xbuff, ybuff, polygonsOutput=False, formatOutput=True) + + # pts=[[pt[0], pt[1], zPosition] for pt in pts] + newIdx = 0 + vertr = [] + filteredPts = [] + print('Filter Points') + ipts = 0 + for p in pts: + ipts = ipts + 1 + if ipts % 500 == 0: + sys.stdout.write('\r') + # the exact output you're looking for: + prog_message = "Points: " + str(ipts) + " / " + str(len(pts)) + " " + str( + round(100 * ipts / len(pts))) + "%" + sys.stdout.write(prog_message) + sys.stdout.flush() + + if not poly.contains(sgeometry.Point(p)): + vertr.append((True, -1)) + else: + vertr.append((False, newIdx)) + if o.cutter_type == 'VCARVE': + # start the z depth calc from the "start depth" of the operation. + z = o.maxz - mpoly.boundary.distance(sgeometry.Point(p)) * slope + if z < maxdepth: + z = maxdepth + elif o.cutter_type == 'BALL' or o.cutter_type == 'BALLNOSE': + d = mpoly_boundary.distance(sgeometry.Point(p)) + r = new_cutter_diameter / 2.0 + if d >= r: + z = -r + else: + # print(r, d) + z = -r + sqrt(r * r - d * d) + else: + z = 0 # + # print(mpoly.distance(sgeometry.Point(0,0))) + # if(z!=0):print(z) + filteredPts.append((p[0], p[1], z)) + newIdx += 1 + + print('Filter Edges') + filteredEdgs = [] + ledges = [] + for e in edgesIdx: + do = True + # p1 = pts[e[0]] + # p2 = pts[e[1]] + # print(p1,p2,len(vertr)) + if vertr[e[0]][0]: # exclude edges with allready excluded points + do = False + elif vertr[e[1]][0]: + do = False + if do: + filteredEdgs.append((vertr[e[0]][1], vertr[e[1]][1])) + ledges.append(sgeometry.LineString( + (filteredPts[vertr[e[0]][1]], filteredPts[vertr[e[1]][1]]))) + # print(ledges[-1].has_z) + + bufpoly = poly.buffer(-new_cutter_diameter / 2, resolution=64) + + lines = shapely.ops.linemerge(ledges) + # print(lines.type) + + if bufpoly.type == 'Polygon' or bufpoly.type == 'MultiPolygon': + lines = lines.difference(bufpoly) + chunks.extend(shapelyToChunks(bufpoly, maxdepth)) + chunks.extend(shapelyToChunks(lines, 0)) + + # generate a mesh from the medial calculations + if o.add_mesh_for_medial: + shapelyToCurve('medialMesh', lines, 0.0) + bpy.ops.object.convert(target='MESH') + + oi = 0 + for ob in o.objects: + if ob.type == 'CURVE' or ob.type == 'FONT': + ob.data.resolution_u = resolutions_before[oi] + oi += 1 + + # bpy.ops.object.join() + chunks = await sortChunks(chunks, o) + + layers = getLayers(o, o.maxz, o.min.z) + + chunklayers = [] + + for layer in layers: + for chunk in chunks: + if chunk.isbelowZ(layer[0]): + newchunk = chunk.copy() + newchunk.clampZ(layer[1]) + chunklayers.append(newchunk) + + if o.first_down: + chunklayers = await sortChunks(chunklayers, o) + + if o.add_mesh_for_medial: # make curve instead of a path + join_multiple("medialMesh") + + chunksToMesh(chunklayers, o) + # add pocket operation for medial if add pocket checked + if o.add_pocket_for_medial: + # o.add_pocket_for_medial = False + # export medial axis parameter to pocket op + Add_Pocket(None, maxdepth, m_o_ob, new_cutter_diameter) + + +def getLayers(operation, startdepth, enddepth): + """Returns a list of layers bounded by start depth and end depth. + + This function calculates the layers between the specified start and end + depths based on the step down value defined in the operation. If the + operation is set to use layers, it computes the number of layers by + dividing the difference between start and end depths by the step down + value. The function raises an exception if the start depth is lower than + the end depth. + + Args: + operation (object): An object that contains the properties `use_layers`, + `stepdown`, and `maxz` which are used to determine + how layers are generated. + startdepth (float): The starting depth for layer calculation. + enddepth (float): The ending depth for layer calculation. + + Returns: + list: A list of layers, where each layer is represented as a list + containing the start and end depths of that layer. + + Raises: + CamException: If the start depth is lower than the end depth. + """ + if startdepth < enddepth: + raise CamException("Start Depth Is Lower than End Depth. " + "if You Have Set a Custom Depth End, It Must Be Lower than Depth Start, " + "and Should Usually Be Negative. Set This in the CAM Operation Area Panel.") + if operation.use_layers: + layers = [] + n = ceil((startdepth - enddepth) / operation.stepdown) + print("Start " + str(startdepth) + " End " + str(enddepth) + " n " + str(n)) + + layerstart = operation.maxz + for x in range(0, n): + layerend = round(max(startdepth - ((x + 1) * operation.stepdown), enddepth), 6) + if int(layerstart * 10 ** 8) != int(layerend * 10 ** 8): + # it was possible that with precise same end of operation, + # last layer was done 2x on exactly same level... + layers.append([layerstart, layerend]) + layerstart = layerend + else: + layers = [[round(startdepth, 6), round(enddepth, 6)]] + + return layers + + +def chunksToMesh(chunks, o): + """Convert sampled chunks into a mesh path for a given optimization object. + + This function takes a list of sampled chunks and converts them into a + mesh path based on the specified optimization parameters. It handles + different machine axes configurations and applies optimizations as + needed. The resulting mesh is created in the Blender context, and the + function also manages the lifting and dropping of the cutter based on + the chunk positions. + + Args: + chunks (list): A list of chunk objects to be converted into a mesh. + o (object): An object containing optimization parameters and settings. + + Returns: + None: The function creates a mesh in the Blender context but does not return a + value. + """ + t = time.time() + s = bpy.context.scene + m = s.cam_machine + verts = [] + + free_height = o.movement.free_height # o.max.z + + + if o.machine_axes == '3': + if m.use_position_definitions: + origin = (m.starting_position.x, m.starting_position.y, m.starting_position.z) # dhull + else: + origin = (0, 0, free_height) + + verts = [origin] + if o.machine_axes != '3': + verts_rotations = [] # (0,0,0) + if (o.machine_axes == '5' and o.strategy5axis == 'INDEXED') or ( + o.machine_axes == '4' and o.strategy4axis == 'INDEXED'): + extendChunks5axis(chunks, o) + + if o.array: + nchunks = [] + for x in range(0, o.array_x_count): + for y in range(0, o.array_y_count): + print(x, y) + for ch in chunks: + ch = ch.copy() + ch.shift(x * o.array_x_distance, y * o.array_y_distance, 0) + nchunks.append(ch) + chunks = nchunks + + progress('Building Paths from Chunks') + e = 0.0001 + lifted = True + + for chi in range(0, len(chunks)): + + ch = chunks[chi] + # print(chunks) + # print (ch) + # TODO: there is a case where parallel+layers+zigzag ramps send empty chunks here... + if ch.count() > 0: + # print(len(ch.points)) + nverts = [] + if o.optimisation.optimize: + ch = optimizeChunk(ch, o) + + # lift and drop + + if lifted: # did the cutter lift before? if yes, put a new position above of the first point of next chunk. + if o.machine_axes == '3' or (o.machine_axes == '5' and o.strategy5axis == 'INDEXED') or ( + o.machine_axes == '4' and o.strategy4axis == 'INDEXED'): + v = (ch.get_point(0)[0], ch.get_point(0)[1], free_height) + else: # otherwise, continue with the next chunk without lifting/dropping + v = ch.startpoints[0] # startpoints=retract points + verts_rotations.append(ch.rotations[0]) + verts.append(v) + + # add whole chunk + verts.extend(ch.get_points()) + + # add rotations for n-axis + if o.machine_axes != '3': + verts_rotations.extend(ch.rotations) + + lift = True + # check if lifting should happen + if chi < len(chunks) - 1 and chunks[chi + 1].count() > 0: + # TODO: remake this for n axis, and this check should be somewhere else... + last = Vector(ch.get_point(-1)) + first = Vector(chunks[chi + 1].get_point(0)) + vect = first - last + if (o.machine_axes == '3' and (o.strategy == 'PARALLEL' or o.strategy == 'CROSS') + and vect.z == 0 and vect.length < o.dist_between_paths * 2.5) \ + or (o.machine_axes == '4' and vect.length < o.dist_between_paths * 2.5): + # case of neighbouring paths + lift = False + # case of stepdown by cutting. + if abs(vect.x) < e and abs(vect.y) < e: + lift = False + + if lift: + if o.machine_axes == '3' or (o.machine_axes == '5' and o.strategy5axis == 'INDEXED') or ( + o.machine_axes == '4' and o.strategy4axis == 'INDEXED'): + v = (ch.get_point(-1)[0], ch.get_point(-1)[1], free_height) + else: + v = ch.startpoints[-1] + verts_rotations.append(ch.rotations[-1]) + verts.append(v) + lifted = lift + # print(verts_rotations) + if o.optimisation.use_exact and not o.optimisation.use_opencamlib: + cleanupBulletCollision(o) + print(time.time() - t) + t = time.time() + + # actual blender object generation starts here: + edges = [] + for a in range(0, len(verts) - 1): + edges.append((a, a + 1)) + + oname = "cam_path_{}".format(o.name) + + mesh = bpy.data.meshes.new(oname) + mesh.name = oname + mesh.from_pydata(verts, edges, []) + + if oname in s.objects: + s.objects[oname].data = mesh + ob = s.objects[oname] + else: + ob = object_utils.object_data_add(bpy.context, mesh, operator=None) + + if o.machine_axes != '3': + # store rotations into shape keys, only way to store large arrays with correct floating point precision + # - object/mesh attributes can only store array up to 32000 intems. + + ob.shape_key_add() + ob.shape_key_add() + shapek = mesh.shape_keys.key_blocks[1] + shapek.name = 'rotations' + print(len(shapek.data)) + print(len(verts_rotations)) + + # TODO: optimize this. this is just rewritten too many times... + for i, co in enumerate(verts_rotations): + shapek.data[i].co = co + + print(time.time() - t) + + ob.location = (0, 0, 0) + o.path_object_name = oname + + # parent the path object to source object if object mode + if (o.geometry_source == 'OBJECT') and o.parent_path_to_object: + activate(o.objects[0]) + ob.select_set(state=True, view_layer=None) + bpy.ops.object.parent_set(type='OBJECT', keep_transform=True) + else: + ob.select_set(state=True, view_layer=None) + + +def checkminz(o): + """Check the minimum value based on the specified condition. + + This function evaluates the 'minz_from' attribute of the input object + 'o'. If 'minz_from' is set to 'MATERIAL', it returns the value of + 'min.z'. Otherwise, it returns the value of 'minz'. + + Args: + o (object): An object that has attributes 'minz_from', 'min', and 'minz'. + + Returns: + The minimum value, which can be either 'o.min.z' or 'o.minz' depending + on the condition. + """ + if o.minz_from == 'MATERIAL': + return o.min.z + else: + return o.minz diff --git a/scripts/addons/cam/testing.py b/scripts/addons/cam/testing.py index 73e7d288..a12a5d2e 100644 --- a/scripts/addons/cam/testing.py +++ b/scripts/addons/cam/testing.py @@ -1,182 +1,305 @@ -"""BlenderCAM 'testing.py' © 2012 Vilem Novak - -Functions for automated testing. -""" - -import bpy - -from .gcodepath import getPath -from .simple import activate - - -def addTestCurve(loc): - bpy.ops.curve.primitive_bezier_circle_add( - radius=.05, align='WORLD', enter_editmode=False, location=loc) - bpy.ops.object.editmode_toggle() - bpy.ops.curve.duplicate() - bpy.ops.transform.resize(value=(0.5, 0.5, 0.5), constraint_axis=(False, False, False), - orient_type='GLOBAL', mirror=False, use_proportional_edit=False, - proportional_edit_falloff='SMOOTH', proportional_size=1) - bpy.ops.curve.duplicate() - bpy.ops.transform.resize(value=(0.5, 0.5, 0.5), constraint_axis=(False, False, False), - orient_type='GLOBAL', mirror=False, use_proportional_edit=False, - proportional_edit_falloff='SMOOTH', proportional_size=1) - bpy.ops.object.editmode_toggle() - - -def addTestMesh(loc): - bpy.ops.mesh.primitive_monkey_add(radius=.01, align='WORLD', enter_editmode=False, location=loc) - bpy.ops.transform.rotate(value=-1.5708, axis=(1, 0, 0), constraint_axis=(True, False, False), - orient_type='GLOBAL') - bpy.ops.object.transform_apply(location=False, rotation=False, scale=True) - bpy.ops.object.editmode_toggle() - bpy.ops.mesh.primitive_plane_add(radius=1, align='WORLD', enter_editmode=False, location=loc) - bpy.ops.transform.resize(value=(0.01, 0.01, 0.01), constraint_axis=(False, False, False), - orient_type='GLOBAL') - bpy.ops.transform.translate(value=(-0.01, 0, 0), constraint_axis=(True, False, False), - orient_type='GLOBAL') - - bpy.ops.object.editmode_toggle() - - -def deleteFirstVert(ob): - activate(ob) - bpy.ops.object.editmode_toggle() - - bpy.ops.mesh.select_all(action='DESELECT') - - bpy.ops.object.editmode_toggle() - for i, v in enumerate(ob.data.vertices): - v.select = False - if i == 0: - v.select = True - ob.data.update() - - bpy.ops.object.editmode_toggle() - bpy.ops.mesh.delete(type='VERT') - bpy.ops.object.editmode_toggle() - - -def testCalc(o): - bpy.ops.object.calculate_cam_path() - deleteFirstVert(bpy.data.objects[o.name]) - - -def testCutout(pos): - addTestCurve((pos[0], pos[1], -.05)) - bpy.ops.scene.cam_operation_add() - o = bpy.context.scene.cam_operations[-1] - o.strategy = 'CUTOUT' - testCalc(o) - - -def testPocket(pos): - addTestCurve((pos[0], pos[1], -.01)) - bpy.ops.scene.cam_operation_add() - o = bpy.context.scene.cam_operations[-1] - o.strategy = 'POCKET' - o.movement.helix_enter = True - o.movement.retract_tangential = True - testCalc(o) - - -def testParallel(pos): - addTestMesh((pos[0], pos[1], -.02)) - bpy.ops.scene.cam_operation_add() - o = bpy.context.scene.cam_operations[-1] - o.ambient_behaviour = 'AROUND' - o.material.radius_around_model = 0.01 - bpy.ops.object.calculate_cam_path() - - -def testWaterline(pos): - addTestMesh((pos[0], pos[1], -.02)) - bpy.ops.scene.cam_operation_add() - o = bpy.context.scene.cam_operations[-1] - o.strategy = 'WATERLINE' - o.optimisation.pixsize = .0002 - # o.ambient_behaviour='AROUND' - # o.material_radius_around_model=0.01 - - testCalc(o) - - -# bpy.ops.object.cam_simulate() - - -def testSimulation(): - pass - - -def cleanUp(): - bpy.ops.object.select_all(action='SELECT') - bpy.ops.object.delete(use_global=False) - while len(bpy.context.scene.cam_operations): - bpy.ops.scene.cam_operation_remove() - - -def testOperation(i): - s = bpy.context.scene - o = s.cam_operations[i] - report = '' - report += 'testing operation ' + o.name + '\n' - - getPath(bpy.context, o) - - newresult = bpy.data.objects[o.path_object_name] - origname = "test_cam_path_" + o.name - if origname not in s.objects: - report += 'Operation Test Has Nothing to Compare with, Making the New Result as Comparable Result.\n\n' - newresult.name = origname - else: - testresult = bpy.data.objects[origname] - m1 = testresult.data - m2 = newresult.data - test_ok = True - if len(m1.vertices) != len(m2.vertices): - report += "Vertex Counts Don't Match\n\n" - test_ok = False - else: - different_co_count = 0 - for i in range(0, len(m1.vertices)): - v1 = m1.vertices[i] - v2 = m2.vertices[i] - if v1.co != v2.co: - different_co_count += 1 - if different_co_count > 0: - report += 'Vertex Position Is Different on %i Vertices \n\n' % (different_co_count) - test_ok = False - if test_ok: - report += 'Test Ok\n\n' - else: - report += 'Test Result Is Different\n \n ' - print(report) - return report - - -def testAll(): - s = bpy.context.scene - report = '' - for i in range(0, len(s.cam_operations)): - report += testOperation(i) - print(report) - - -tests = [ - testCutout, - testParallel, - testWaterline, - testPocket, - -] - -cleanUp() - -# deleteFirstVert(bpy.context.active_object) -for i, t in enumerate(tests): - p = i * .2 - t((p, 0, 0)) -# cleanUp() - - -# cleanUp() +"""BlenderCAM 'testing.py' © 2012 Vilem Novak + +Functions for automated testing. +""" + +import bpy + +from .gcodepath import getPath +from .simple import activate + + +def addTestCurve(loc): + """Add a test curve to the Blender scene. + + This function creates a Bezier circle at the specified location in the + Blender scene. It first adds a primitive Bezier circle, then enters edit + mode to duplicate the circle twice, resizing each duplicate to half its + original size. The function ensures that the transformations are applied + in the global orientation and does not use proportional editing. + + Args: + loc (tuple): A tuple representing the (x, y, z) coordinates where + the Bezier circle will be added in the 3D space. + """ + bpy.ops.curve.primitive_bezier_circle_add( + radius=.05, align='WORLD', enter_editmode=False, location=loc) + bpy.ops.object.editmode_toggle() + bpy.ops.curve.duplicate() + bpy.ops.transform.resize(value=(0.5, 0.5, 0.5), constraint_axis=(False, False, False), + orient_type='GLOBAL', mirror=False, use_proportional_edit=False, + proportional_edit_falloff='SMOOTH', proportional_size=1) + bpy.ops.curve.duplicate() + bpy.ops.transform.resize(value=(0.5, 0.5, 0.5), constraint_axis=(False, False, False), + orient_type='GLOBAL', mirror=False, use_proportional_edit=False, + proportional_edit_falloff='SMOOTH', proportional_size=1) + bpy.ops.object.editmode_toggle() + + +def addTestMesh(loc): + """Add a test mesh to the Blender scene. + + This function creates a monkey mesh and a plane mesh at the specified + location in the Blender scene. It first adds a monkey mesh with a small + radius, rotates it, and applies the transformation. Then, it toggles + into edit mode, adds a plane mesh, resizes it, and translates it + slightly before toggling back out of edit mode. + + Args: + loc (tuple): A tuple representing the (x, y, z) coordinates where + the meshes will be added in the Blender scene. + """ + bpy.ops.mesh.primitive_monkey_add(radius=.01, align='WORLD', enter_editmode=False, location=loc) + bpy.ops.transform.rotate(value=-1.5708, axis=(1, 0, 0), constraint_axis=(True, False, False), + orient_type='GLOBAL') + bpy.ops.object.transform_apply(location=False, rotation=False, scale=True) + bpy.ops.object.editmode_toggle() + bpy.ops.mesh.primitive_plane_add(radius=1, align='WORLD', enter_editmode=False, location=loc) + bpy.ops.transform.resize(value=(0.01, 0.01, 0.01), constraint_axis=(False, False, False), + orient_type='GLOBAL') + bpy.ops.transform.translate(value=(-0.01, 0, 0), constraint_axis=(True, False, False), + orient_type='GLOBAL') + + bpy.ops.object.editmode_toggle() + + +def deleteFirstVert(ob): + """Delete the first vertex of a given object. + + This function activates the specified object, enters edit mode, + deselects all vertices, selects the first vertex, and then deletes it. + The function ensures that the object is properly updated after the + deletion. + + Args: + ob (bpy.types.Object): The Blender object from which the first + """ + activate(ob) + bpy.ops.object.editmode_toggle() + + bpy.ops.mesh.select_all(action='DESELECT') + + bpy.ops.object.editmode_toggle() + for i, v in enumerate(ob.data.vertices): + v.select = False + if i == 0: + v.select = True + ob.data.update() + + bpy.ops.object.editmode_toggle() + bpy.ops.mesh.delete(type='VERT') + bpy.ops.object.editmode_toggle() + + +def testCalc(o): + """Test the calculation of the camera path for a given object. + + This function invokes the Blender operator to calculate the camera path + for the specified object and then deletes the first vertex of that + object. It is intended to be used within a Blender environment where the + bpy module is available. + + Args: + o (Object): The Blender object for which the camera path is to be calculated. + """ + bpy.ops.object.calculate_cam_path() + deleteFirstVert(bpy.data.objects[o.name]) + + +def testCutout(pos): + """Test the cutout functionality in the scene. + + This function adds a test curve based on the provided position, performs + a camera operation, and sets the strategy to 'CUTOUT'. It then calls the + `testCalc` function to perform further calculations on the camera + operation. + + Args: + pos (tuple): A tuple containing the x and y coordinates for the + position of the test curve. + """ + addTestCurve((pos[0], pos[1], -.05)) + bpy.ops.scene.cam_operation_add() + o = bpy.context.scene.cam_operations[-1] + o.strategy = 'CUTOUT' + testCalc(o) + + +def testPocket(pos): + """Test the pocket operation in a 3D scene. + + This function sets up a pocket operation by adding a test curve based on + the provided position. It configures the camera operation settings for + the pocket strategy, enabling helix entry and tangential retraction. + Finally, it performs a calculation based on the configured operation. + + Args: + pos (tuple): A tuple containing the x and y coordinates for + the position of the test curve. + """ + addTestCurve((pos[0], pos[1], -.01)) + bpy.ops.scene.cam_operation_add() + o = bpy.context.scene.cam_operations[-1] + o.strategy = 'POCKET' + o.movement.helix_enter = True + o.movement.retract_tangential = True + testCalc(o) + + +def testParallel(pos): + """Test the parallel functionality of the camera operations. + + This function adds a test mesh at a specified position and then performs + camera operations in the Blender environment. It sets the ambient + behavior of the camera operation to 'AROUND' and configures the material + radius around the model. Finally, it calculates the camera path based on + the current scene settings. + + Args: + pos (tuple): A tuple containing the x and y coordinates for + positioning the test mesh. + """ + addTestMesh((pos[0], pos[1], -.02)) + bpy.ops.scene.cam_operation_add() + o = bpy.context.scene.cam_operations[-1] + o.ambient_behaviour = 'AROUND' + o.material.radius_around_model = 0.01 + bpy.ops.object.calculate_cam_path() + + +def testWaterline(pos): + """Test the waterline functionality in the scene. + + This function adds a test mesh at a specified position and then performs + a camera operation with the strategy set to 'WATERLINE'. It also + configures the optimization pixel size for the operation. The function + is intended for use in a 3D environment where waterline calculations are + necessary for rendering or simulation. + + Args: + pos (tuple): A tuple containing the x and y coordinates for + the position of the test mesh. + """ + addTestMesh((pos[0], pos[1], -.02)) + bpy.ops.scene.cam_operation_add() + o = bpy.context.scene.cam_operations[-1] + o.strategy = 'WATERLINE' + o.optimisation.pixsize = .0002 + # o.ambient_behaviour='AROUND' + # o.material_radius_around_model=0.01 + + testCalc(o) + + +# bpy.ops.object.cam_simulate() + + +def testSimulation(): + """Testsimulation function.""" + pass + + +def cleanUp(): + """Clean up the Blender scene by removing all objects and camera + operations. + + This function selects all objects in the current Blender scene and + deletes them. It also removes any camera operations that are present in + the scene. This is useful for resetting the scene to a clean state + before performing further operations. + """ + bpy.ops.object.select_all(action='SELECT') + bpy.ops.object.delete(use_global=False) + while len(bpy.context.scene.cam_operations): + bpy.ops.scene.cam_operation_remove() + + +def testOperation(i): + """Test the operation of a camera path in Blender. + + This function tests a specific camera operation by comparing the + generated camera path with an existing reference path. It retrieves the + camera operation from the scene and checks if the generated path matches + the expected path in terms of vertex count and positions. If there is no + existing reference path, it marks the new result as comparable. The + function generates a report detailing the results of the comparison, + including any discrepancies found. + + Args: + i (int): The index of the camera operation to test. + + Returns: + str: A report summarizing the results of the operation test. + """ + s = bpy.context.scene + o = s.cam_operations[i] + report = '' + report += 'testing operation ' + o.name + '\n' + + getPath(bpy.context, o) + + newresult = bpy.data.objects[o.path_object_name] + origname = "test_cam_path_" + o.name + if origname not in s.objects: + report += 'Operation Test Has Nothing to Compare with, Making the New Result as Comparable Result.\n\n' + newresult.name = origname + else: + testresult = bpy.data.objects[origname] + m1 = testresult.data + m2 = newresult.data + test_ok = True + if len(m1.vertices) != len(m2.vertices): + report += "Vertex Counts Don't Match\n\n" + test_ok = False + else: + different_co_count = 0 + for i in range(0, len(m1.vertices)): + v1 = m1.vertices[i] + v2 = m2.vertices[i] + if v1.co != v2.co: + different_co_count += 1 + if different_co_count > 0: + report += 'Vertex Position Is Different on %i Vertices \n\n' % (different_co_count) + test_ok = False + if test_ok: + report += 'Test Ok\n\n' + else: + report += 'Test Result Is Different\n \n ' + print(report) + return report + + +def testAll(): + """Run tests on all camera operations in the current scene. + + This function iterates through all camera operations defined in the + current Blender scene and executes a test for each operation. The + results of these tests are collected into a report string, which is then + printed to the console. This is useful for verifying the functionality + of camera operations within the Blender environment. + """ + s = bpy.context.scene + report = '' + for i in range(0, len(s.cam_operations)): + report += testOperation(i) + print(report) + + +tests = [ + testCutout, + testParallel, + testWaterline, + testPocket, + +] + +cleanUp() + +# deleteFirstVert(bpy.context.active_object) +for i, t in enumerate(tests): + p = i * .2 + t((p, 0, 0)) +# cleanUp() + + +# cleanUp() diff --git a/scripts/addons/cam/utils.py b/scripts/addons/cam/utils.py index 0c66fc5c..534854cc 100644 --- a/scripts/addons/cam/utils.py +++ b/scripts/addons/cam/utils.py @@ -74,6 +74,18 @@ SHAPELY = True # Import OpencamLib # Return available OpenCamLib version on success, None otherwise def opencamlib_version(): + """Return the version of the OpenCamLib library. + + This function attempts to import the OpenCamLib library and returns its + version. If the library is not available, it will return None. The + function first tries to import the library using the name 'ocl', and if + that fails, it attempts to import it using 'opencamlib' as an alias. If + both imports fail, it returns None. + + Returns: + str or None: The version of OpenCamLib if available, None otherwise. + """ + try: import ocl except ImportError: @@ -85,6 +97,21 @@ def opencamlib_version(): def positionObject(operation): + """Position an object based on specified operation parameters. + + This function adjusts the location of a Blender object according to the + provided operation settings. It calculates the bounding box of the + object in world space and modifies its position based on the material's + center settings and specified z-positioning (BELOW, ABOVE, or CENTERED). + The function also applies transformations to the object if it is not of + type 'CURVE'. + + Args: + operation (OperationType): An object containing parameters for positioning, + including object_name, use_modifiers, and material + settings. + """ + ob = bpy.data.objects[operation.object_name] bpy.ops.object.origin_set(type='ORIGIN_GEOMETRY', center='BOUNDS') ob.select_set(True) @@ -117,6 +144,28 @@ def positionObject(operation): def getBoundsWorldspace(obs, use_modifiers=False): + """Get the bounding box of a list of objects in world space. + + This function calculates the minimum and maximum coordinates that + encompass all the specified objects in the 3D world space. It iterates + through each object, taking into account their transformations and + modifiers if specified. The function supports different object types, + including meshes and fonts, and handles the conversion of font objects + to mesh format for accurate bounding box calculations. + + Args: + obs (list): A list of Blender objects to calculate bounds for. + use_modifiers (bool): If True, apply modifiers to the objects + before calculating bounds. Defaults to False. + + Returns: + tuple: A tuple containing the minimum and maximum coordinates + in the format (minx, miny, minz, maxx, maxy, maxz). + + Raises: + CamException: If an object type does not support CAM operations. + """ + # progress('getting bounds of object(s)') t = time.time() @@ -196,6 +245,23 @@ def getBoundsWorldspace(obs, use_modifiers=False): def getSplineBounds(ob, curve): + """Get the bounding box of a spline object. + + This function calculates the minimum and maximum coordinates (x, y, z) + of the given spline object by iterating through its bezier points and + regular points. It transforms the local coordinates to world coordinates + using the object's transformation matrix. The resulting bounds can be + used for various purposes, such as collision detection or rendering. + + Args: + ob (Object): The object containing the spline whose bounds are to be calculated. + curve (Curve): The curve object that contains the bezier points and regular points. + + Returns: + tuple: A tuple containing the minimum and maximum coordinates in the + format (minx, miny, minz, maxx, maxy, maxz). + """ + # progress('getting bounds of object(s)') maxx = maxy = maxz = -10000000 minx = miny = minz = 10000000 @@ -228,6 +294,28 @@ def getSplineBounds(ob, curve): def getOperationSources(o): + """Get operation sources based on the geometry source type. + + This function retrieves and sets the operation sources for a given + object based on its geometry source type. It handles three types of + geometry sources: 'OBJECT', 'COLLECTION', and 'IMAGE'. For 'OBJECT', it + selects the specified object and applies rotations if enabled. For + 'COLLECTION', it retrieves all objects within the specified collection. + For 'IMAGE', it sets a specific optimization flag. Additionally, it + determines whether the objects are curves or meshes based on the + geometry source. + + Args: + o (Object): An object containing properties such as geometry_source, + object_name, collection_name, rotation_A, rotation_B, + enable_A, enable_B, old_rotation_A, old_rotation_B, + A_along_x, and optimisation. + + Returns: + None: This function does not return a value but modifies the + properties of the input object. + """ + if o.geometry_source == 'OBJECT': # bpy.ops.object.select_all(action='DESELECT') ob = bpy.data.objects[o.object_name] @@ -268,6 +356,24 @@ def getOperationSources(o): def getBounds(o): + """Calculate the bounding box for a given object. + + This function determines the minimum and maximum coordinates of an + object's bounding box based on its geometry source. It handles different + geometry types such as OBJECT, COLLECTION, and CURVE. The function also + considers material properties and image cropping if applicable. The + bounding box is adjusted according to the object's material settings and + the optimization parameters defined in the object. + + Args: + o (object): An object containing geometry and material properties, as well as + optimization settings. + + Returns: + None: This function modifies the input object in place and does not return a + value. + """ + # print('kolikrat sem rpijde') if o.geometry_source == 'OBJECT' or o.geometry_source == 'COLLECTION' or o.geometry_source == 'CURVE': print("Valid Geometry") @@ -332,7 +438,24 @@ def getBounds(o): def getBoundsMultiple(operations): - """Gets Bounds of Multiple Operations, Mainly for Purpose of Simulations or Rest Milling. Highly Suboptimal.""" + """Gets bounds of multiple operations for simulations or rest milling. + + This function iterates through a list of operations to determine the + minimum and maximum bounds in three-dimensional space (x, y, z). It + initializes the bounds to extreme values and updates them based on the + bounds of each operation. The function is primarily intended for use in + simulations or rest milling processes, although it is noted that the + implementation may not be optimal. + + Args: + operations (list): A list of operation objects, each containing + 'min' and 'max' attributes with 'x', 'y', + and 'z' coordinates. + + Returns: + tuple: A tuple containing the minimum and maximum bounds in the + order (minx, miny, minz, maxx, maxy, maxz). + """ maxx = maxy = maxz = -10000000 minx = miny = minz = 10000000 for o in operations: @@ -348,6 +471,29 @@ def getBoundsMultiple(operations): def samplePathLow(o, ch1, ch2, dosample): + """Generate a sample path between two channels. + + This function computes a series of points that form a path between two + given channels. It calculates the direction vector from the end of the + first channel to the start of the second channel and generates points + along this vector up to a specified distance. If sampling is enabled, it + modifies the z-coordinate of the generated points based on the cutter + shape or image sampling, ensuring that the path accounts for any + obstacles or features in the environment. + + Args: + o: An object containing optimization parameters and properties related to + the path generation. + ch1: The first channel object, which provides a point for the starting + location of the path. + ch2: The second channel object, which provides a point for the ending + location of the path. + dosample (bool): A flag indicating whether to perform sampling along the generated path. + + Returns: + camPathChunk: An object representing the generated path points. + """ + v1 = Vector(ch1.get_point(-1)) v2 = Vector(ch2.get_point(0)) @@ -392,6 +538,28 @@ def samplePathLow(o, ch1, ch2, dosample): # def threadedSampling():#not really possible at all without running more blenders for same operation :( python! # samples in both modes now - image and bullet collision too. async def sampleChunks(o, pathSamples, layers): + """Sample chunks of paths based on the provided parameters. + + This function processes the given path samples and layers to generate + chunks of points that represent the sampled paths. It takes into account + various optimization settings and strategies to determine how the points + are sampled and organized into layers. The function handles different + scenarios based on the object's properties and the specified layers, + ensuring that the resulting chunks are correctly structured for further + processing. + + Args: + o (object): An object containing various properties and settings + related to the sampling process. + pathSamples (list): A list of path samples to be processed. + layers (list): A list of layers defining the z-coordinate ranges + for sampling. + + Returns: + list: A list of sampled chunks, each containing points that represent + the sampled paths. + """ + # minx, miny, minz, maxx, maxy, maxz = o.min.x, o.min.y, o.min.z, o.max.x, o.max.y, o.max.z getAmbient(o) @@ -634,6 +802,29 @@ async def sampleChunks(o, pathSamples, layers): async def sampleChunksNAxis(o, pathSamples, layers): + """Sample chunks along a specified axis based on provided paths and layers. + + This function processes a set of path samples and organizes them into + chunks according to specified layers. It prepares the collision world if + necessary, updates the cutter's rotation based on the path samples, and + handles the sampling of points along the paths. The function also + manages the relationships between the sampled points and their + respective layers, ensuring that the correct points are added to each + chunk. The resulting chunks can be used for further processing in a 3D + environment. + + Args: + o (object): An object containing properties such as min/max coordinates, + cutter shape, and other relevant parameters. + pathSamples (list): A list of path samples, each containing start points, + end points, and rotations. + layers (list): A list of layer definitions that specify the boundaries + for sampling. + + Returns: + list: A list of sampled chunks organized by layers. + """ + # minx, miny, minz, maxx, maxy, maxz = o.min.x, o.min.y, o.min.z, o.max.x, o.max.y, o.max.z @@ -867,6 +1058,20 @@ async def sampleChunksNAxis(o, pathSamples, layers): def extendChunks5axis(chunks, o): + """Extend chunks with 5-axis cutter start and end points. + + This function modifies the provided chunks by appending calculated start + and end points for a cutter based on the specified orientation and + movement parameters. It determines the starting position of the cutter + based on the machine's settings and the object's movement constraints. + The function iterates through each point in the chunks and updates their + start and end points accordingly. + + Args: + chunks (list): A list of chunk objects that will be modified. + o (object): An object containing movement and orientation data. + """ + s = bpy.context.scene m = s.cam_machine s = bpy.context.scene @@ -896,6 +1101,24 @@ def extendChunks5axis(chunks, o): def curveToShapely(cob, use_modifiers=False): + """Convert a curve object to Shapely polygons. + + This function takes a curve object and converts it into a list of + Shapely polygons. It first breaks the curve into chunks and then + transforms those chunks into Shapely-compatible polygon representations. + The `use_modifiers` parameter allows for additional processing of the + curve before conversion, depending on the specific requirements of the + application. + + Args: + cob: The curve object to be converted. + use_modifiers (bool): A flag indicating whether to apply modifiers + during the conversion process. Defaults to False. + + Returns: + list: A list of Shapely polygons created from the curve object. + """ + chunks = curveToChunks(cob, use_modifiers) polys = chunksToShapely(chunks) return polys @@ -905,6 +1128,24 @@ def curveToShapely(cob, use_modifiers=False): # FIXME: same algorithms as the cutout strategy, because that is hierarchy-respecting. def silhoueteOffset(context, offset, style=1, mitrelimit=1.0): + """Offset the silhouette of a curve or font object in Blender. + + This function takes an active curve or font object in Blender and + creates an offset silhouette based on the specified parameters. It first + retrieves the silhouette of the object and then applies a buffer + operation to create the offset shape. The resulting shape is then + converted back into a curve object in the Blender scene. + + Args: + context (bpy.context): The current Blender context. + offset (float): The distance to offset the silhouette. + style (int?): The join style for the offset. Defaults to 1. + mitrelimit (float?): The mitre limit for the offset. Defaults to 1.0. + + Returns: + dict: A dictionary indicating the operation is finished. + """ + bpy.context.scene.cursor.location = (0, 0, 0) ob = bpy.context.active_object if ob.type == 'CURVE' or ob.type == 'FONT': @@ -923,6 +1164,25 @@ def silhoueteOffset(context, offset, style=1, mitrelimit=1.0): def polygonBoolean(context, boolean_type): + """Perform a boolean operation on selected polygons. + + This function takes the active object and applies a specified boolean + operation (UNION, DIFFERENCE, or INTERSECT) with respect to other + selected objects in the Blender context. It first converts the polygons + of the active object and the selected objects into a Shapely + MultiPolygon. Depending on the boolean type specified, it performs the + corresponding boolean operation and then converts the result back into a + Blender curve. + + Args: + context (bpy.context): The Blender context containing scene and object data. + boolean_type (str): The type of boolean operation to perform. + Must be one of 'UNION', 'DIFFERENCE', or 'INTERSECT'. + + Returns: + dict: A dictionary indicating the operation result, typically {'FINISHED'}. + """ + bpy.context.scene.cursor.location = (0, 0, 0) ob = bpy.context.active_object obs = [] @@ -956,6 +1216,23 @@ def polygonBoolean(context, boolean_type): def polygonConvexHull(context): + """Generate the convex hull of a polygon from the given context. + + This function duplicates the current object, joins it, and converts it + into a 3D mesh. It then extracts the X and Y coordinates of the vertices + to create a MultiPoint data structure using Shapely. Finally, it + computes the convex hull of these points and converts the result back + into a curve named 'ConvexHull'. Temporary objects created during this + process are deleted to maintain a clean workspace. + + Args: + context: The context in which the operation is performed, typically + related to Blender's current state. + + Returns: + dict: A dictionary indicating the operation's completion status. + """ + coords = [] bpy.ops.object.duplicate() @@ -984,6 +1261,27 @@ def polygonConvexHull(context): def Helix(r, np, zstart, pend, rev): + """Generate a helix of points in 3D space. + + This function calculates a series of points that form a helix based on + the specified parameters. It starts from a given radius and + z-coordinate, and generates points by rotating around the z-axis while + moving linearly along the z-axis. The number of points generated is + determined by the number of turns (revolutions) and the number of points + per revolution. + + Args: + r (float): The radius of the helix. + np (int): The number of points per revolution. + zstart (float): The starting z-coordinate for the helix. + pend (tuple): A tuple containing the x, y, and z coordinates of the endpoint. + rev (int): The number of revolutions to complete. + + Returns: + list: A list of tuples representing the coordinates of the points in the + helix. + """ + c = [] v = Vector((r, 0, zstart)) e = Euler((0, 0, 2.0 * pi / np)) @@ -1000,7 +1298,24 @@ def comparezlevel(x): return x[5] -def overlaps(bb1, bb2): # true if bb1 is child of bb2 +def overlaps(bb1, bb2): + """Determine if one bounding box is a child of another. + + This function checks if the first bounding box (bb1) is completely + contained within the second bounding box (bb2). It does this by + comparing the coordinates of both bounding boxes to see if all corners + of bb1 are within the bounds of bb2. + + Args: + bb1 (tuple): A tuple representing the coordinates of the first bounding box + in the format (x_min, y_min, x_max, y_max). + bb2 (tuple): A tuple representing the coordinates of the second bounding box + in the format (x_min, y_min, x_max, y_max). + + Returns: + bool: True if bb1 is a child of bb2, otherwise False. + """ + # true if bb1 is child of bb2 ch1 = bb1 ch2 = bb2 if (ch2[1] > ch1[1] > ch1[0] > ch2[0] and ch2[3] > ch1[3] > ch1[2] > ch2[2]): @@ -1008,7 +1323,24 @@ def overlaps(bb1, bb2): # true if bb1 is child of bb2 async def connectChunksLow(chunks, o): - """ Connects Chunks that Are Close to Each Other without Lifting, Sampling Them 'low' """ + """Connects chunks that are close to each other without lifting, sampling + them 'low'. + + This function processes a list of chunks and connects those that are + within a specified distance based on the provided options. It takes into + account various strategies for connecting the chunks, including 'CARVE', + 'PENCIL', and 'MEDIAL_AXIS', and adjusts the merging distance + accordingly. The function also handles specific movement settings, such + as whether to stay low or to merge distances, and may resample chunks if + certain optimization conditions are met. + + Args: + chunks (list): A list of chunk objects to be connected. + o (object): An options object containing movement and strategy parameters. + + Returns: + list: A list of connected chunk objects. + """ if not o.movement.stay_low or (o.strategy == 'CARVE' and o.carve_depth > 0): return chunks @@ -1063,6 +1395,23 @@ async def connectChunksLow(chunks, o): def getClosest(o, pos, chunks): + """Find the closest chunk to a given position. + + This function iterates through a list of chunks and determines which + chunk is closest to the specified position. It checks if each chunk's + children are sorted before calculating the distance. The chunk with the + minimum distance to the given position is returned. + + Args: + o: An object representing the origin point. + pos: A position to which the closest chunk is calculated. + chunks (list): A list of chunk objects to evaluate. + + Returns: + Chunk: The closest chunk object to the specified position, or None if no valid + chunk is found. + """ + # ch=-1 mind = 2000 d = 100000000000 @@ -1083,6 +1432,25 @@ def getClosest(o, pos, chunks): async def sortChunks(chunks, o, last_pos=None): + """Sort a list of chunks based on a specified strategy. + + This function sorts a list of chunks according to the provided options + and the current position. It utilizes a recursive approach to find the + closest chunk to the current position and adapts its distance if it has + not been sorted before. The function also handles progress updates + asynchronously and adjusts the recursion limit to accommodate deep + recursion scenarios. + + Args: + chunks (list): A list of chunk objects to be sorted. + o (object): An options object that contains sorting strategy and other parameters. + last_pos (tuple?): The last known position as a tuple of coordinates. + Defaults to None, which initializes the position to (0, 0, 0). + + Returns: + list: A sorted list of chunk objects. + """ + if o.strategy != 'WATERLINE': await progress_async('sorting paths') # the getNext() function of CamPathChunk was running out of recursion limits. @@ -1149,6 +1517,25 @@ async def sortChunks(chunks, o, last_pos=None): # most right vector from a set regarding angle.. def getVectorRight(lastv, verts): + """Get the index of the vector that is most to the right based on angle. + + This function calculates the angle between a reference vector (formed by + the last two vectors in `lastv`) and each vector in the `verts` list. It + identifies the vector that has the smallest angle with respect to the + reference vector, indicating that it is the most rightward vector in + relation to the specified direction. + + Args: + lastv (list): A list containing two vectors, where each vector is + represented as a tuple or list of coordinates. + verts (list): A list of vectors represented as tuples or lists of + coordinates. + + Returns: + int: The index of the vector in `verts` that is most to the right + based on the calculated angle. + """ + defa = 100 v1 = Vector(lastv[0]) v2 = Vector(lastv[1]) @@ -1165,6 +1552,22 @@ def getVectorRight(lastv, verts): def cleanUpDict(ndict): + """Remove lonely points from a dictionary. + + This function iterates over the keys of the provided dictionary and + removes any entries that contain one or fewer associated values. It + continues to check for and remove "lonely" points until no more can be + found. The process is repeated until all such entries are eliminated + from the dictionary. + + Args: + ndict (dict): A dictionary where keys are associated with lists of values. + + Returns: + None: This function modifies the input dictionary in place and does not return + a value. + """ + # now it should delete all junk first, iterate over lonely verts. print('Removing Lonely Points') # found_solitaires=True @@ -1190,12 +1593,44 @@ def cleanUpDict(ndict): def dictRemove(dict, val): + """Remove a key and its associated values from a dictionary. + + This function takes a dictionary and a key (val) as input. It iterates + through the list of values associated with the given key and removes the + key from each of those values' lists. Finally, it removes the key itself + from the dictionary. + + Args: + dict (dict): A dictionary where the key is associated with a list of values. + val: The key to be removed from the dictionary and from the lists of its + associated values. + """ + for v in dict[val]: dict[v].remove(val) dict.pop(val) def addLoop(parentloop, start, end): + """Add a loop to a parent loop structure. + + This function recursively checks if the specified start and end values + can be added as a new loop to the parent loop. If an existing loop + encompasses the new loop, it will call itself on that loop. If no such + loop exists, it appends the new loop defined by the start and end values + to the parent loop's list of loops. + + Args: + parentloop (list): A list representing the parent loop, where the + third element is a list of child loops. + start (int): The starting value of the new loop to be added. + end (int): The ending value of the new loop to be added. + + Returns: + None: This function modifies the parentloop in place and does not + return a value. + """ + added = False for l in parentloop[2]: if l[0] < start and l[1] > end: @@ -1205,6 +1640,30 @@ def addLoop(parentloop, start, end): def cutloops(csource, parentloop, loops): + """Cut loops from a source code segment. + + This function takes a source code segment and a parent loop defined by + its start and end indices, along with a list of nested loops. It creates + a copy of the source code segment and removes the specified nested loops + from it. The modified segment is then appended to the provided list of + loops. The function also recursively processes any nested loops found + within the parent loop. + + Args: + csource (str): The source code from which loops will be cut. + parentloop (tuple): A tuple containing the start index, end index, and a list of nested + loops. + The list of nested loops should contain tuples with start and end + indices for each loop. + loops (list): A list that will be populated with the modified source code segments + after + removing the specified loops. + + Returns: + None: This function modifies the `loops` list in place and does not return a + value. + """ + copy = csource[parentloop[0]:parentloop[1]] for li in range(len(parentloop[2]) - 1, -1, -1): @@ -1217,8 +1676,21 @@ def cutloops(csource, parentloop, loops): def getOperationSilhouete(operation): - """Gets Silhouette for the Operation - Uses Image Thresholding for Everything Except Curves. + """Gets the silhouette for the given operation. + + This function determines the silhouette of an operation using image + thresholding techniques. It handles different geometry sources, such as + objects or images, and applies specific methods based on the type of + geometry. If the geometry source is 'OBJECT' or 'COLLECTION', it checks + whether to process curves or not. The function also considers the number + of faces in mesh objects to decide on the appropriate method for + silhouette extraction. + + Args: + operation (Operation): An object containing the necessary data + + Returns: + Silhouette: The computed silhouette for the operation. """ if operation.update_silhouete_tag: image = None @@ -1263,6 +1735,25 @@ def getOperationSilhouete(operation): def getObjectSilhouete(stype, objects=None, use_modifiers=False): + """Get the silhouette of objects based on the specified type. + + This function computes the silhouette of a given set of objects in + Blender based on the specified type. It can handle both curves and mesh + objects, converting curves to polygon format and calculating the + silhouette for mesh objects. The function also considers the use of + modifiers if specified. The silhouette is generated by processing the + geometry of the objects and returning a Shapely representation of the + silhouette. + + Args: + stype (str): The type of silhouette to generate ('CURVES' or 'OBJECTS'). + objects (list?): A list of Blender objects to process. Defaults to None. + use_modifiers (bool?): Whether to apply modifiers to the objects. Defaults to False. + + Returns: + shapely.geometry.MultiPolygon: The computed silhouette as a Shapely MultiPolygon. + """ + # o=operation if stype == 'CURVES': # curve conversion to polygon format allchunks = [] @@ -1341,6 +1832,23 @@ def getObjectSilhouete(stype, objects=None, use_modifiers=False): def getAmbient(o): + """Calculate and update the ambient geometry based on the provided object. + + This function computes the ambient shape for a given object based on its + properties, such as cutter restrictions and ambient behavior. It + determines the appropriate radius and creates the ambient geometry + either from the silhouette or as a polygon defined by the object's + minimum and maximum coordinates. If a limit curve is specified, it will + also intersect the ambient shape with the limit polygon. + + Args: + o (object): An object containing properties that define the ambient behavior, + cutter restrictions, and limit curve. + + Returns: + None: The function updates the ambient property of the object in place. + """ + if o.update_ambient_tag: if o.ambient_cutter_restrict: # cutter stays in ambient & limit curve m = o.cutter_diameter / 2 @@ -1368,7 +1876,25 @@ def getAmbient(o): o.update_ambient_tag = False -def getObjectOutline(radius, o, Offset): # FIXME: make this one operation independent +def getObjectOutline(radius, o, Offset): + """Get the outline of a geometric object based on specified parameters. + + This function generates an outline for a given geometric object by + applying a buffer operation to its polygons. The buffer radius can be + adjusted based on the `radius` parameter, and the operation can be + offset based on the `Offset` flag. The function also considers whether + the polygons should be merged or not, depending on the properties of the + object `o`. + + Args: + radius (float): The radius for the buffer operation. + o (object): An object containing properties that influence the outline generation. + Offset (bool): A flag indicating whether to apply a positive or negative offset. + + Returns: + MultiPolygon: The resulting outline of the geometric object as a MultiPolygon. + """ + # FIXME: make this one operation independent # circle detail, optimize, optimize thresold. polygons = getOperationSilhouete(o) @@ -1410,7 +1936,18 @@ def getObjectOutline(radius, o, Offset): # FIXME: make this one operation indep def addOrientationObject(o): - """The Orientation Object Should Be Used to Set up Orientations of the Object for 4 and 5 Axis Milling.""" + """Set up orientation for a milling object. + + This function creates an orientation object in the Blender scene for + 4-axis and 5-axis milling operations. It checks if an orientation object + with the specified name already exists, and if not, it adds a new empty + object of type 'ARROWS'. The function then configures the rotation locks + and initial rotation angles based on the specified machine axes and + rotary axis. + + Args: + o (object): An object containing properties such as name, + """ name = o.name + ' orientation' s = bpy.context.scene if s.objects.find(name) == -1: @@ -1444,7 +1981,18 @@ def addOrientationObject(o): # def addCutterOrientationObject(o): -def removeOrientationObject(o): # not working +def removeOrientationObject(o): + """Remove an orientation object from the current Blender scene. + + This function constructs the name of the orientation object based on the + name of the provided object and attempts to find and delete it from the + Blender scene. If the orientation object exists, it will be removed + using the `delob` function. + + Args: + o (Object): The object whose orientation object is to be removed. + """ + # not working name = o.name + ' orientation' if bpy.context.scene.objects.find(name) > -1: ob = bpy.context.scene.objects[name] @@ -1452,6 +2000,22 @@ def removeOrientationObject(o): # not working def addTranspMat(ob, mname, color, alpha): + """Add a transparent material to a given object. + + This function checks if a material with the specified name already + exists in the Blender data. If it does, it retrieves that material; if + not, it creates a new material with the given name and enables the use + of nodes. The function then assigns the material to the specified + object, ensuring that it is applied correctly whether the object already + has materials or not. + + Args: + ob (bpy.types.Object): The Blender object to which the material will be assigned. + mname (str): The name of the material to be added or retrieved. + color (tuple): The RGBA color value for the material (not used in this function). + alpha (float): The transparency value for the material (not used in this function). + """ + if mname in bpy.data.materials: mat = bpy.data.materials[mname] else: @@ -1467,6 +2031,20 @@ def addTranspMat(ob, mname, color, alpha): def addMachineAreaObject(): + """Add a machine area object to the current Blender scene. + + This function checks if a machine object named 'CAM_machine' already + exists in the current scene. If it does not exist, it creates a new cube + mesh object, applies transformations, and modifies its geometry to + represent a machine area. The function ensures that the scene's unit + settings are set to metric before creating the object and restores the + original unit settings afterward. It also configures the display + properties of the object for better visibility in the scene. The + function operates within Blender's context and utilizes various Blender + operations to create and modify the mesh. It also handles the selection + state of the active object. + """ + s = bpy.context.scene ao = bpy.context.active_object if s.objects.get('CAM_machine') is not None: @@ -1514,6 +2092,21 @@ def addMachineAreaObject(): def addMaterialAreaObject(): + """Add a material area object to the current Blender scene. + + This function checks if a material area object named 'CAM_material' + already exists in the current scene. If it does, it retrieves that + object; if not, it creates a new cube mesh object to serve as the + material area. The dimensions and location of the object are set based + on the current camera operation's bounds. The function also applies + transformations to ensure the object's location and dimensions are + correctly set. The created or retrieved object is configured to be non- + renderable and non-selectable in the viewport, while still being + selectable for operations. This is useful for visualizing the working + area of the camera without affecting the render output. Raises: + None + """ + s = bpy.context.scene operation = s.cam_operations[s.cam_active_operation] getOperationSources(operation) @@ -1549,6 +2142,20 @@ def addMaterialAreaObject(): def getContainer(): + """Get or create a container object for camera objects. + + This function checks if a container object named 'CAM_OBJECTS' exists in + the current Blender scene. If it does not exist, the function creates a + new empty object of type 'PLAIN_AXES', names it 'CAM_OBJECTS', and sets + its location to the origin (0, 0, 0). The newly created container is + also hidden. If the container already exists, it simply retrieves and + returns that object. + + Returns: + bpy.types.Object: The container object for camera objects, either newly created or + existing. + """ + s = bpy.context.scene if s.objects.get('CAM_OBJECTS') is None: bpy.ops.object.empty_add(type='PLAIN_AXES', align='WORLD') @@ -1571,8 +2178,22 @@ class Point: def unique(L): - """Return a List of Unhashable Elements in S, but without Duplicates. - [[1, 2], [2, 3], [1, 2]] >>> [[1, 2], [2, 3]]""" + """Return a list of unhashable elements in L, but without duplicates. + + This function processes a list of lists, specifically designed to handle + unhashable elements. It sorts the input list and removes duplicates by + comparing the elements based on their coordinates. The function counts + the number of duplicate vertices and the number of collinear points + along the Z-axis. + + Args: + L (list): A list of lists, where each inner list represents a point + + Returns: + tuple: A tuple containing two integers: + - The first integer represents the count of duplicate vertices. + - The second integer represents the count of Z-collinear points. + """ # For unhashable objects, you can sort the sequence and then scan from the end of the list, # deleting duplicates as you go nDupli = 0 @@ -1599,6 +2220,20 @@ def checkEqual(lst): def prepareIndexed(o): + """Prepare and index objects in the given collection. + + This function stores the world matrices and parent relationships of the + objects in the provided collection. It then clears the parent + relationships while maintaining their transformations, sets the + orientation of the objects based on a specified orientation object, and + finally re-establishes the parent-child relationships with the + orientation object. The function also resets the location and rotation + of the orientation object to the origin. + + Args: + o (ObjectCollection): A collection of objects to be prepared and indexed. + """ + s = bpy.context.scene # first store objects positions/rotations o.matrices = [] @@ -1647,6 +2282,19 @@ def prepareIndexed(o): def cleanupIndexed(operation): + """Clean up indexed operations by updating object orientations and paths. + + This function takes an operation object and updates the orientation of a + specified object in the scene based on the provided orientation matrix. + It also sets the location and rotation of a camera path object to match + the updated orientation. Additionally, it reassigns parent-child + relationships for the objects involved in the operation and updates + their world matrices. + + Args: + operation (OperationType): An object containing the necessary data + """ + s = bpy.context.scene oriname = operation.name + 'orientation' @@ -1667,9 +2315,22 @@ def cleanupIndexed(operation): def rotTo2axes(e, axescombination): - """Converts an Orientation Object Rotation to Rotation Defined by 2 Rotational Axes on the Machine - - for Indexed Machining. - Attempting to Do This for All Axes Combinations. + """Converts an Orientation Object Rotation to Rotation Defined by 2 + Rotational Axes on the Machine. + + This function takes an orientation object and a specified axes + combination, and computes the angles of rotation around two axes based + on the provided orientation. It supports different axes combinations for + indexed machining. The function utilizes vector mathematics to determine + the angles of rotation and returns them as a tuple. + + Args: + e (OrientationObject): The orientation object representing the rotation. + axescombination (str): A string indicating the axes combination ('CA' or 'CB'). + + Returns: + tuple: A tuple containing two angles (float) representing the rotation + around the specified axes. """ v = Vector((0, 0, 1)) v.rotate(e) @@ -1722,6 +2383,19 @@ def rotTo2axes(e, axescombination): def reload_paths(o): + """Reload the camera path data from a pickle file. + + This function retrieves the camera path data associated with the given + object `o`. It constructs a new mesh from the path vertices and updates + the object's properties with the loaded data. If a previous path mesh + exists, it is removed to avoid memory leaks. The function also handles + the creation of a new mesh object if one does not already exist in the + current scene. + + Args: + o (Object): The object for which the camera path is being + """ + oname = "cam_path_" + o.name s = bpy.context.scene # for o in s.objects: @@ -1796,17 +2470,55 @@ _IS_LOADING_DEFAULTS = False def updateMachine(self, context): + """Update the machine with the given context. + + This function is responsible for updating the machine state based on the + provided context. It prints a message indicating that the update process + has started. If the global variable _IS_LOADING_DEFAULTS is not set to + True, it proceeds to add a machine area object. + + Args: + context: The context in which the machine update is being performed. + """ + print('Update Machine') if not _IS_LOADING_DEFAULTS: addMachineAreaObject() def updateMaterial(self, context): + """Update the material in the given context. + + This method is responsible for updating the material based on the + provided context. It performs necessary operations to ensure that the + material is updated correctly. Currently, it prints a message indicating + the update process and calls the `addMaterialAreaObject` function to + handle additional material area object updates. + + Args: + context: The context in which the material update is performed. + """ + print('Update Material') addMaterialAreaObject() def updateOperation(self, context): + """Update the visibility and selection state of camera operations in the + scene. + + This method manages the visibility of objects associated with camera + operations based on the current active operation. If the + 'hide_all_others' flag is set to true, it hides all other objects except + for the currently active one. If the flag is false, it restores the + visibility of previously hidden objects. The method also attempts to + highlight the currently active object in the 3D view and make it the + active object in the scene. + + Args: + context (bpy.types.Context): The context containing the current scene and + """ + scene = context.scene ao = scene.cam_operations[scene.cam_active_operation] operationValid(self, context) @@ -1847,6 +2559,27 @@ def updateOperation(self, context): def isValid(o, context): + """Check the validity of a geometry source. + + This function verifies if the provided geometry source is valid based on + its type. It checks for three types of geometry sources: 'OBJECT', + 'COLLECTION', and 'IMAGE'. For 'OBJECT', it ensures that the object name + ends with '_cut_bridges' or exists in the Blender data objects. For + 'COLLECTION', it checks if the collection name exists and contains + objects. For 'IMAGE', it verifies if the source image name exists in the + Blender data images. + + Args: + o (object): An object containing geometry source information, including + attributes like `geometry_source`, `object_name`, `collection_name`, + and `source_image_name`. + context: The context in which the validation is performed (not used in this + function). + + Returns: + bool: True if the geometry source is valid, False otherwise. + """ + valid = True if o.geometry_source == 'OBJECT': if not o.object_name.endswith('_cut_bridges'): # let empty bridge cut be valid @@ -1865,6 +2598,17 @@ def isValid(o, context): def operationValid(self, context): + """Validate the current camera operation in the given context. + + This method checks if the active camera operation is valid based on the + current scene context. It updates the operation's validity status and + provides warnings if the source object is invalid. Additionally, it + configures specific settings related to image geometry sources. + + Args: + context (Context): The context containing the scene and camera operations. + """ + scene = context.scene o = scene.cam_operations[scene.cam_active_operation] o.changed = True @@ -1883,6 +2627,26 @@ def operationValid(self, context): def isChainValid(chain, context): + """Check the validity of a chain of operations within a given context. + + This function verifies if all operations in the provided chain are valid + according to the current scene context. It first checks if the chain + contains any operations. If it does, it iterates through each operation + in the chain and checks if it exists in the scene's camera operations. + If an operation is not found or is deemed invalid, the function returns + a tuple indicating the failure and provides an appropriate error + message. If all operations are valid, it returns a success indication. + + Args: + chain (Chain): The chain of operations to validate. + context (Context): The context containing the scene and camera operations. + + Returns: + tuple: A tuple containing a boolean indicating validity and an error message + (if any). The first element is True if valid, otherwise False. The + second element is an error message string. + """ + s = context.scene if len(chain.operations) == 0: return (False, "") @@ -1904,7 +2668,27 @@ def updateOperationValid(self, context): # Update functions start here def updateChipload(self, context): - """This Is Very Simple Computation of Chip Size, Could Be Very Much Improved""" + """Update the chipload based on feedrate, spindle RPM, and cutter + parameters. + + This function calculates the chipload using the formula: chipload = + feedrate / (spindle_rpm * cutter_flutes). It also attempts to account + for chip thinning when cutting at less than 50% cutter engagement with + cylindrical end mills by combining two formulas. The first formula + provides the nominal chipload based on standard recommendations, while + the second formula adjusts for the cutter diameter and distance between + paths. The current implementation may not yield consistent results, and + there are concerns regarding the correctness of the units used in the + calculations. Further review and refinement of this function may be + necessary to improve accuracy and reliability. + + Args: + context: The context in which the update is performed (not used in this + implementation). + + Returns: + None: This function does not return a value; it updates the chipload in place. + """ print('Update Chipload ') o = self # Old chipload @@ -1926,7 +2710,15 @@ def updateChipload(self, context): def updateOffsetImage(self, context): - """Refresh Offset Image Tag for Rerendering""" + """Refresh the Offset Image Tag for re-rendering. + + This method updates the chip load and marks the offset image tag for re- + rendering. It sets the `changed` attribute to True and indicates that + the offset image tag needs to be updated. + + Args: + context: The context in which the update is performed. + """ updateChipload(self, context) print('Update Offset') self.changed = True @@ -1934,7 +2726,17 @@ def updateOffsetImage(self, context): def updateZbufferImage(self, context): - """Changes Tags so Offset and Zbuffer Images Get Updated on Calculation Time.""" + """Update the Z-buffer and offset image tags for recalculation. + + This method modifies the internal state to indicate that the Z-buffer + image and offset image tags need to be updated during the calculation + process. It sets the `changed` attribute to True and marks the relevant + tags for updating. Additionally, it calls the `getOperationSources` + function to ensure that the necessary operation sources are retrieved. + + Args: + context: The context in which the update is being performed. + """ # print('updatezbuf') # print(self,context) self.changed = True @@ -1944,6 +2746,20 @@ def updateZbufferImage(self, context): def updateStrategy(o, context): + """Update the strategy of the given object. + + This function modifies the state of the object `o` by setting its + `changed` attribute to True and printing a message indicating that the + strategy is being updated. Depending on the value of `machine_axes` and + `strategy4axis`, it either adds or removes an orientation object + associated with `o`. Finally, it calls the `updateExact` function to + perform further updates based on the provided context. + + Args: + o (object): The object whose strategy is to be updated. + context (object): The context in which the update is performed. + """ + """""" o.changed = True print('Update Strategy') @@ -1960,6 +2776,23 @@ def updateCutout(o, context): def updateExact(o, context): + """Update the state of an object for exact operations. + + This function modifies the properties of the given object `o` to + indicate that an update is required. It sets various flags related to + the object's state and checks the optimization settings. If the + optimization is set to use exact mode, it further checks the strategy + and inverse properties to determine if exact mode can be used. If not, + it disables the use of OpenCamLib. + + Args: + o (object): The object to be updated, which contains properties related + context (object): The context in which the update is being performed. + + Returns: + None: This function does not return a value. + """ + print('Update Exact ') o.changed = True o.update_zbufferimage_tag = True @@ -1973,6 +2806,23 @@ def updateExact(o, context): def updateOpencamlib(o, context): + """Update the OpenCAMLib settings for a given operation. + + This function modifies the properties of the provided operation object + based on its current strategy and optimization settings. If the + operation's strategy is either 'POCKET' or 'MEDIAL_AXIS', and if + OpenCAMLib is being used for optimization, it disables the use of both + exact optimization and OpenCAMLib, indicating that the current operation + cannot utilize OpenCAMLib. + + Args: + o (object): The operation object containing optimization and strategy settings. + context (object): The context in which the operation is being updated. + + Returns: + None: This function does not return any value. + """ + print('Update OpenCAMLib ') o.changed = True if o.optimisation.use_opencamlib and ( @@ -1983,11 +2833,36 @@ def updateOpencamlib(o, context): def updateBridges(o, context): + """Update the status of bridges. + + This function marks the bridge object as changed, indicating that an + update has occurred. It prints a message to the console for logging + purposes. The function takes in an object and a context, but the context + is not utilized within the function. + + Args: + o (object): The bridge object that needs to be updated. + context (object): Additional context for the update, not used in this function. + """ + print('Update Bridges ') o.changed = True def updateRotation(o, context): + """Update the rotation of a specified object in Blender. + + This function modifies the rotation of a Blender object based on the + properties of the provided object 'o'. It checks which rotations are + enabled and applies the corresponding rotation values to the active + object in the scene. The rotation can be aligned either along the X or Y + axis, depending on the configuration of 'o'. + + Args: + o (object): An object containing rotation settings and flags. + context (object): The context in which the operation is performed. + """ + print('Update Rotation') if o.enable_B or o.enable_A: print(o, o.rotation_A) @@ -2013,6 +2888,17 @@ def updateRotation(o, context): # o.changed = True def updateRest(o, context): + """Update the state of the object. + + This function modifies the given object by setting its 'changed' + attribute to True. It also prints a message indicating that the update + operation has been performed. + + Args: + o (object): The object to be updated. + context (object): The context in which the update is being performed. + """ + print('Update Rest ') o.changed = True @@ -2022,6 +2908,25 @@ def updateRest(o, context): def getStrategyList(scene, context): + """Get a list of available strategies for operations. + + This function retrieves a predefined list of operation strategies that + can be used in the context of a 3D scene. Each strategy is represented + as a tuple containing an identifier, a user-friendly name, and a + description of the operation. The list includes various operations such + as cutouts, pockets, drilling, and more. If experimental features are + enabled in the preferences, additional experimental strategies may be + included in the returned list. + + Args: + scene: The current scene context. + context: The current context in which the operation is being performed. + + Returns: + list: A list of tuples, each containing the strategy identifier, + name, and description. + """ + use_experimental = bpy.context.preferences.addons[__package__].preferences.experimental items = [ ('CUTOUT', 'Profile(Cutout)', 'Cut the silhouete with offset'), @@ -2058,24 +2963,71 @@ def update_material(self, context): def update_operation(self, context): + """Update the camera operation based on the current context. + + This function retrieves the active camera operation from the Blender + context and updates it using the `updateRest` function. It accesses the + active operation from the scene's camera operations and passes the + current context to the updating function. + + Args: + context: The context in which the operation is being updated. + """ + # from . import updateRest active_op = bpy.context.scene.cam_operations[bpy.context.scene.cam_active_operation] updateRest(active_op, bpy.context) def update_exact_mode(self, context): + """Update the exact mode of the active camera operation. + + This function retrieves the currently active camera operation from the + Blender context and updates its exact mode using the `updateExact` + function. It accesses the active operation through the `cam_operations` + list in the current scene and passes the active operation along with the + current context to the `updateExact` function. + + Args: + context: The context in which the update is performed. + """ + # from . import updateExact active_op = bpy.context.scene.cam_operations[bpy.context.scene.cam_active_operation] updateExact(active_op, bpy.context) def update_opencamlib(self, context): + """Update the OpenCamLib with the current active operation. + + This function retrieves the currently active camera operation from the + Blender context and updates the OpenCamLib accordingly. It accesses the + active operation from the scene's camera operations and passes it along + with the current context to the update function. + + Args: + context: The context in which the operation is being performed, typically + provided by + Blender's internal API. + """ + # from . import updateOpencamlib active_op = bpy.context.scene.cam_operations[bpy.context.scene.cam_active_operation] updateOpencamlib(active_op, bpy.context) def update_zbuffer_image(self, context): + """Update the Z-buffer image based on the active camera operation. + + This function retrieves the currently active camera operation from the + Blender context and updates the Z-buffer image accordingly. It accesses + the scene's camera operations and invokes the `updateZbufferImage` + function with the active operation and context. + + Args: + context (bpy.context): The current Blender context. + """ + # from . import updateZbufferImage active_op = bpy.context.scene.cam_operations[bpy.context.scene.cam_active_operation] updateZbufferImage(active_op, bpy.context) @@ -2085,7 +3037,21 @@ def update_zbuffer_image(self, context): @bpy.app.handlers.persistent def check_operations_on_load(context): - """Checks Any Broken Computations on Load and Reset Them.""" + """Checks for any broken computations on load and resets them. + + This function verifies the presence of necessary Blender add-ons and + installs any that are missing. It also resets any ongoing computations + in camera operations and sets the interface level to the previously used + level when loading a new file. If the add-on has been updated, it copies + the necessary presets from the source to the target directory. + Additionally, it checks for updates to the camera plugin and updates + operation presets if required. + + Args: + context: The context in which the function is executed, typically containing + information about + the current Blender environment. + """ addons = bpy.context.preferences.addons @@ -2135,6 +3101,17 @@ def check_operations_on_load(context): preset_target_path = Path(bpy.utils.script_path_user()) / "presets" def copy_if_not_exists(src, dst): + """Copy a file from source to destination if it does not already exist. + + This function checks if the destination file exists. If it does not, the + function copies the source file to the destination using a high-level + file operation that preserves metadata. + + Args: + src (str): The path to the source file to be copied. + dst (str): The path to the destination where the file should be copied. + """ + if Path(dst).exists() == False: shutil.copy2(src, dst) @@ -2158,6 +3135,21 @@ def check_operations_on_load(context): # add pocket op for medial axis and profile cut inside to clean unremoved material def Add_Pocket(self, maxdepth, sname, new_cutter_diameter): + """Add a pocket operation for the medial axis and profile cut. + + This function first deselects all objects in the scene and then checks + for any existing medial pocket objects, deleting them if found. It + verifies whether a medial pocket operation already exists in the camera + operations. If it does not exist, it creates a new pocket operation with + the specified parameters. The function also modifies the selected + object's silhouette offset based on the new cutter diameter. + + Args: + maxdepth (float): The maximum depth of the pocket to be created. + sname (str): The name of the object to which the pocket will be added. + new_cutter_diameter (float): The diameter of the new cutter to be used. + """ + bpy.ops.object.select_all(action='DESELECT') s = bpy.context.scene mpocket_exists = False diff --git a/scripts/addons/cam/voronoi.py b/scripts/addons/cam/voronoi.py index 800075ac..694d45ff 100644 --- a/scripts/addons/cam/voronoi.py +++ b/scripts/addons/cam/voronoi.py @@ -1,1014 +1,1736 @@ -"""BlenderCAM 'voronoi.py' - -Voronoi diagram calculator/ Delaunay triangulator - -- Voronoi Diagram Sweepline algorithm and C code by Steven Fortune, 1987, http://ect.bell-labs.com/who/sjf/ -- Python translation to file voronoi.py by Bill Simons, 2005, http://www.oxfish.com/ -- Additional changes for QGIS by Carson Farmer added November 2010 -- 2012 Ported to Python 3 and additional clip functions by domlysz at gmail.com - -Calculate Delaunay triangulation or the Voronoi polygons for a set of -2D input points. - -Derived from code bearing the following notice: - -The author of this software is Steven Fortune. Copyright (c) 1994 by AT&T -Bell Laboratories. -Permission to use, copy, modify, and distribute this software for any -purpose without fee is hereby granted, provided that this entire notice -is included in all copies of any software which is or includes a copy -or modification of this software and in all copies of the supporting -documentation for such software. -THIS SOFTWARE IS BEING PROVIDED "AS IS", WITHOUT ANY EXPRESS OR IMPLIED -WARRANTY. IN PARTICULAR, NEITHER THE AUTHORS NOR AT&T MAKE ANY -REPRESENTATION OR WARRANTY OF ANY KIND CONCERNING THE MERCHANTABILITY -OF THIS SOFTWARE OR ITS FITNESS FOR ANY PARTICULAR PURPOSE. - -Comments were incorporated from Shane O'Sullivan's translation of the -original code into C++ (http://mapviewer.skynet.ie/voronoi.html) - -Steve Fortune's homepage: http://netlib.bell-labs.com/cm/cs/who/sjf/index.html - - -For programmatic use two functions are available: - -computeVoronoiDiagram(points, xBuff, yBuff, polygonsOutput=False, formatOutput=False) : -Takes : - - a list of point objects (which must have x and y fields). - - x and y buffer values which are the expansion percentages of the bounding box rectangle including all input points. - Returns : - - With default options : - A list of 2-tuples, representing the two points of each Voronoi diagram edge. - Each point contains 2-tuples which are the x,y coordinates of point. - if formatOutput is True, returns : - - a list of 2-tuples, which are the x,y coordinates of the Voronoi diagram vertices. - - and a list of 2-tuples (v1, v2) representing edges of the Voronoi diagram. - v1 and v2 are the indices of the vertices at the end of the edge. - - If polygonsOutput option is True, returns : - A dictionary of polygons, keys are the indices of the input points, - values contains n-tuples representing the n points of each Voronoi diagram polygon. - Each point contains 2-tuples which are the x,y coordinates of point. - if formatOutput is True, returns : - - A list of 2-tuples, which are the x,y coordinates of the Voronoi diagram vertices. - - and a dictionary of input points indices. Values contains n-tuples representing the n points of each Voronoi diagram polygon. - Each tuple contains the vertex indices of the polygon vertices. - -computeDelaunayTriangulation(points): - Takes a list of point objects (which must have x and y fields). - Returns a list of 3-tuples: the indices of the points that form a Delaunay triangle. -""" - -import math -import sys - -TOLERANCE = 1e-9 -BIG_FLOAT = 1e38 - -if sys.version > '3': - PY3 = True -else: - PY3 = False - - -# ------------------------------------------------------------------ -class Context(object): - def __init__(self): - self.doPrint = 0 - self.debug = 0 - self.extent = () # tuple (xmin, xmax, ymin, ymax) - self.triangulate = False - self.vertices = [] # list of vertex 2-tuples: (x,y) - # equation of line 3-tuple (a b c), for the equation of the line a*x+b*y = c - self.lines = [] - # edge 3-tuple: (line index, vertex 1 index, vertex 2 index) if either vertex index is -1, the edge extends to infinity - self.edges = [] - self.triangles = [] # 3-tuple of vertex indices - self.polygons = {} # a dict of site:[edges] pairs - - ########Clip functions######## - def getClipEdges(self): - xmin, xmax, ymin, ymax = self.extent - clipEdges = [] - for edge in self.edges: - equation = self.lines[edge[0]] # line equation - if edge[1] != -1 and edge[2] != -1: # finite line - x1, y1 = self.vertices[edge[1]][0], self.vertices[edge[1]][1] - x2, y2 = self.vertices[edge[2]][0], self.vertices[edge[2]][1] - pt1, pt2 = (x1, y1), (x2, y2) - inExtentP1, inExtentP2 = self.inExtent(x1, y1), self.inExtent(x2, y2) - if inExtentP1 and inExtentP2: - clipEdges.append((pt1, pt2)) - elif inExtentP1 and not inExtentP2: - pt2 = self.clipLine(x1, y1, equation, leftDir=False) - clipEdges.append((pt1, pt2)) - elif not inExtentP1 and inExtentP2: - pt1 = self.clipLine(x2, y2, equation, leftDir=True) - clipEdges.append((pt1, pt2)) - else: # infinite line - if edge[1] != -1: - x1, y1 = self.vertices[edge[1]][0], self.vertices[edge[1]][1] - leftDir = False - else: - x1, y1 = self.vertices[edge[2]][0], self.vertices[edge[2]][1] - leftDir = True - if self.inExtent(x1, y1): - pt1 = (x1, y1) - pt2 = self.clipLine(x1, y1, equation, leftDir) - clipEdges.append((pt1, pt2)) - return clipEdges - - def getClipPolygons(self, closePoly): - xmin, xmax, ymin, ymax = self.extent - poly = {} - for inPtsIdx, edges in self.polygons.items(): - clipEdges = [] - for edge in edges: - equation = self.lines[edge[0]] # line equation - if edge[1] != -1 and edge[2] != -1: # finite line - x1, y1 = self.vertices[edge[1]][0], self.vertices[edge[1]][1] - x2, y2 = self.vertices[edge[2]][0], self.vertices[edge[2]][1] - pt1, pt2 = (x1, y1), (x2, y2) - inExtentP1, inExtentP2 = self.inExtent(x1, y1), self.inExtent(x2, y2) - if inExtentP1 and inExtentP2: - clipEdges.append((pt1, pt2)) - elif inExtentP1 and not inExtentP2: - pt2 = self.clipLine(x1, y1, equation, leftDir=False) - clipEdges.append((pt1, pt2)) - elif not inExtentP1 and inExtentP2: - pt1 = self.clipLine(x2, y2, equation, leftDir=True) - clipEdges.append((pt1, pt2)) - else: # infinite line - if edge[1] != -1: - x1, y1 = self.vertices[edge[1]][0], self.vertices[edge[1]][1] - leftDir = False - else: - x1, y1 = self.vertices[edge[2]][0], self.vertices[edge[2]][1] - leftDir = True - if self.inExtent(x1, y1): - pt1 = (x1, y1) - pt2 = self.clipLine(x1, y1, equation, leftDir) - clipEdges.append((pt1, pt2)) - # create polygon definition from edges and check if polygon is completely closed - polyPts, complete = self.orderPts(clipEdges) - if not complete: - startPt = polyPts[0] - endPt = polyPts[-1] - if startPt[0] == endPt[0] or startPt[1] == endPt[1]: - # if start & end points are collinear then they are along an extent border - polyPts.append(polyPts[0]) # simple close - else: # close at extent corner - if (startPt[0] == xmin and endPt[1] == ymax) or ( - endPt[0] == xmin and startPt[1] == ymax): # upper left - polyPts.append((xmin, ymax)) # corner point - polyPts.append(polyPts[0]) # close polygon - if (startPt[0] == xmax and endPt[1] == ymax) or ( - endPt[0] == xmax and startPt[1] == ymax): # upper right - polyPts.append((xmax, ymax)) - polyPts.append(polyPts[0]) - if (startPt[0] == xmax and endPt[1] == ymin) or ( - endPt[0] == xmax and startPt[1] == ymin): # bottom right - polyPts.append((xmax, ymin)) - polyPts.append(polyPts[0]) - if (startPt[0] == xmin and endPt[1] == ymin) or ( - endPt[0] == xmin and startPt[1] == ymin): # bottom left - polyPts.append((xmin, ymin)) - polyPts.append(polyPts[0]) - if not closePoly: # unclose polygon - polyPts = polyPts[:-1] - poly[inPtsIdx] = polyPts - return poly - - def clipLine(self, x1, y1, equation, leftDir): - xmin, xmax, ymin, ymax = self.extent - a, b, c = equation - if b == 0: # vertical line - if leftDir: # left is bottom of vertical line - return (x1, ymax) - else: - return (x1, ymin) - elif a == 0: # horizontal line - if leftDir: - return (xmin, y1) - else: - return (xmax, y1) - else: - y2_at_xmin = (c - a * xmin) / b - y2_at_xmax = (c - a * xmax) / b - x2_at_ymin = (c - b * ymin) / a - x2_at_ymax = (c - b * ymax) / a - intersectPts = [] - if ymin <= y2_at_xmin <= ymax: # valid intersect point - intersectPts.append((xmin, y2_at_xmin)) - if ymin <= y2_at_xmax <= ymax: - intersectPts.append((xmax, y2_at_xmax)) - if xmin <= x2_at_ymin <= xmax: - intersectPts.append((x2_at_ymin, ymin)) - if xmin <= x2_at_ymax <= xmax: - intersectPts.append((x2_at_ymax, ymax)) - # delete duplicate (happens if intersect point is at extent corner) - intersectPts = set(intersectPts) - # choose target intersect point - if leftDir: - pt = min(intersectPts) # smaller x value - else: - pt = max(intersectPts) - return pt - - def inExtent(self, x, y): - xmin, xmax, ymin, ymax = self.extent - return x >= xmin and x <= xmax and y >= ymin and y <= ymax - - def orderPts(self, edges): - poly = [] # returned polygon points list [pt1, pt2, pt3, pt4 ....] - pts = [] - # get points list - for edge in edges: - pts.extend([pt for pt in edge]) - # try to get start & end point - try: - # start and end point aren't duplicate - startPt, endPt = [pt for pt in pts if pts.count(pt) < 2] - except: # all points are duplicate --> polygon is complete --> append some or other edge points - complete = True - firstIdx = 0 - poly.append(edges[0][0]) - poly.append(edges[0][1]) - else: # incomplete --> append the first edge points - complete = False - # search first edge - for i, edge in enumerate(edges): - if startPt in edge: # find - firstIdx = i - break - poly.append(edges[firstIdx][0]) - poly.append(edges[firstIdx][1]) - if poly[0] != startPt: - poly.reverse() - # append next points in list - del edges[firstIdx] - while edges: # all points will be treated when edges list will be empty - currentPt = poly[-1] # last item - for i, edge in enumerate(edges): - if currentPt == edge[0]: - poly.append(edge[1]) - break - elif currentPt == edge[1]: - poly.append(edge[0]) - break - del edges[i] - return poly, complete - - def setClipBuffer(self, xpourcent, ypourcent): - xmin, xmax, ymin, ymax = self.extent - witdh = xmax - xmin - height = ymax - ymin - xmin = xmin - witdh * xpourcent / 100 - xmax = xmax + witdh * xpourcent / 100 - ymin = ymin - height * ypourcent / 100 - ymax = ymax + height * ypourcent / 100 - self.extent = xmin, xmax, ymin, ymax - - # End clip functions######## - - def outSite(self, s): - if self.debug: - print("site (%d) at %f %f" % (s.sitenum, s.x, s.y)) - elif self.triangulate: - pass - elif self.doPrint: - print("s %f %f" % (s.x, s.y)) - - def outVertex(self, s): - self.vertices.append((s.x, s.y)) - if self.debug: - print("vertex(%d) at %f %f" % (s.sitenum, s.x, s.y)) - elif self.triangulate: - pass - elif self.doPrint: - print("v %f %f" % (s.x, s.y)) - - def outTriple(self, s1, s2, s3): - self.triangles.append((s1.sitenum, s2.sitenum, s3.sitenum)) - if self.debug: - print("circle through left=%d right=%d bottom=%d" % - (s1.sitenum, s2.sitenum, s3.sitenum)) - elif self.triangulate and self.doPrint: - print("%d %d %d" % (s1.sitenum, s2.sitenum, s3.sitenum)) - - def outBisector(self, edge): - self.lines.append((edge.a, edge.b, edge.c)) - if self.debug: - print("line(%d) %gx+%gy=%g, bisecting %d %d" % ( - edge.edgenum, edge.a, edge.b, edge.c, edge.reg[0].sitenum, edge.reg[1].sitenum)) - elif self.doPrint: - print("l %f %f %f" % (edge.a, edge.b, edge.c)) - - def outEdge(self, edge): - sitenumL = -1 - if edge.ep[Edge.LE] is not None: - sitenumL = edge.ep[Edge.LE].sitenum - sitenumR = -1 - if edge.ep[Edge.RE] is not None: - sitenumR = edge.ep[Edge.RE].sitenum - - # polygons dict add by CF - if edge.reg[0].sitenum not in self.polygons: - self.polygons[edge.reg[0].sitenum] = [] - if edge.reg[1].sitenum not in self.polygons: - self.polygons[edge.reg[1].sitenum] = [] - self.polygons[edge.reg[0].sitenum].append((edge.edgenum, sitenumL, sitenumR)) - self.polygons[edge.reg[1].sitenum].append((edge.edgenum, sitenumL, sitenumR)) - - self.edges.append((edge.edgenum, sitenumL, sitenumR)) - - if not self.triangulate: - if self.doPrint: - print("e %d" % edge.edgenum) - print(" %d " % sitenumL) - print("%d" % sitenumR) - - -# ------------------------------------------------------------------ -def voronoi(siteList, context): - context.extent = siteList.extent - edgeList = EdgeList(siteList.xmin, siteList.xmax, len(siteList)) - priorityQ = PriorityQueue(siteList.ymin, siteList.ymax, len(siteList)) - siteIter = siteList.iterator() - - bottomsite = siteIter.next() - context.outSite(bottomsite) - newsite = siteIter.next() - minpt = Site(-BIG_FLOAT, -BIG_FLOAT) - while True: - if not priorityQ.isEmpty(): - minpt = priorityQ.getMinPt() - - if newsite and (priorityQ.isEmpty() or newsite < minpt): - # newsite is smallest - this is a site event - context.outSite(newsite) - - # get first Halfedge to the LEFT and RIGHT of the new site - lbnd = edgeList.leftbnd(newsite) - rbnd = lbnd.right - - # if this halfedge has no edge, bot = bottom site (whatever that is) - # create a new edge that bisects - bot = lbnd.rightreg(bottomsite) - edge = Edge.bisect(bot, newsite) - context.outBisector(edge) - - # create a new Halfedge, setting its pm field to 0 and insert - # this new bisector edge between the left and right vectors in - # a linked list - bisector = Halfedge(edge, Edge.LE) - edgeList.insert(lbnd, bisector) - - # if the new bisector intersects with the left edge, remove - # the left edge's vertex, and put in the new one - p = lbnd.intersect(bisector) - if p is not None: - priorityQ.delete(lbnd) - priorityQ.insert(lbnd, p, newsite.distance(p)) - - # create a new Halfedge, setting its pm field to 1 - # insert the new Halfedge to the right of the original bisector - lbnd = bisector - bisector = Halfedge(edge, Edge.RE) - edgeList.insert(lbnd, bisector) - - # if this new bisector intersects with the right Halfedge - p = bisector.intersect(rbnd) - if p is not None: - # push the Halfedge into the ordered linked list of vertices - priorityQ.insert(bisector, p, newsite.distance(p)) - - newsite = siteIter.next() - - elif not priorityQ.isEmpty(): - # intersection is smallest - this is a vector (circle) event - - # pop the Halfedge with the lowest vector off the ordered list of - # vectors. Get the Halfedge to the left and right of the above HE - # and also the Halfedge to the right of the right HE - lbnd = priorityQ.popMinHalfedge() - llbnd = lbnd.left - rbnd = lbnd.right - rrbnd = rbnd.right - - # get the Site to the left of the left HE and to the right of - # the right HE which it bisects - bot = lbnd.leftreg(bottomsite) - top = rbnd.rightreg(bottomsite) - - # output the triple of sites, stating that a circle goes through them - mid = lbnd.rightreg(bottomsite) - context.outTriple(bot, top, mid) - - # get the vertex that caused this event and set the vertex number - # couldn't do this earlier since we didn't know when it would be processed - v = lbnd.vertex - siteList.setSiteNumber(v) - context.outVertex(v) - - # set the endpoint of the left and right Halfedge to be this vector - if lbnd.edge.setEndpoint(lbnd.pm, v): - context.outEdge(lbnd.edge) - - if rbnd.edge.setEndpoint(rbnd.pm, v): - context.outEdge(rbnd.edge) - - # delete the lowest HE, remove all vertex events to do with the - # right HE and delete the right HE - edgeList.delete(lbnd) - priorityQ.delete(rbnd) - edgeList.delete(rbnd) - - # if the site to the left of the event is higher than the Site - # to the right of it, then swap them and set 'pm' to RIGHT - pm = Edge.LE - if bot.y > top.y: - bot, top = top, bot - pm = Edge.RE - - # Create an Edge (or line) that is between the two Sites. This - # creates the formula of the line, and assigns a line number to it - edge = Edge.bisect(bot, top) - context.outBisector(edge) - - # create a HE from the edge - bisector = Halfedge(edge, pm) - - # insert the new bisector to the right of the left HE - # set one endpoint to the new edge to be the vector point 'v' - # If the site to the left of this bisector is higher than the right - # Site, then this endpoint is put in position 0; otherwise in pos 1 - edgeList.insert(llbnd, bisector) - if edge.setEndpoint(Edge.RE - pm, v): - context.outEdge(edge) - - # if left HE and the new bisector don't intersect, then delete - # the left HE, and reinsert it - p = llbnd.intersect(bisector) - if p is not None: - priorityQ.delete(llbnd) - priorityQ.insert(llbnd, p, bot.distance(p)) - - # if right HE and the new bisector don't intersect, then reinsert it - p = bisector.intersect(rrbnd) - if p is not None: - priorityQ.insert(bisector, p, bot.distance(p)) - else: - break - - he = edgeList.leftend.right - while he is not edgeList.rightend: - context.outEdge(he.edge) - he = he.right - Edge.EDGE_NUM = 0 # CF - - -# ------------------------------------------------------------------ -def isEqual(a, b, relativeError=TOLERANCE): - # is nearly equal to within the allowed relative error - norm = max(abs(a), abs(b)) - return (norm < relativeError) or (abs(a - b) < (relativeError * norm)) - - -# ------------------------------------------------------------------ -class Site(object): - def __init__(self, x=0.0, y=0.0, sitenum=0): - self.x = x - self.y = y - self.sitenum = sitenum - - def dump(self): - print("Site #%d (%g, %g)" % (self.sitenum, self.x, self.y)) - - def __lt__(self, other): - if self.y < other.y: - return True - elif self.y > other.y: - return False - elif self.x < other.x: - return True - elif self.x > other.x: - return False - else: - return False - - def __eq__(self, other): - if self.y == other.y and self.x == other.x: - return True - - def distance(self, other): - dx = self.x - other.x - dy = self.y - other.y - return math.sqrt(dx * dx + dy * dy) - - -# ------------------------------------------------------------------ -class Edge(object): - LE = 0 # left end indice --> edge.ep[Edge.LE] - RE = 1 # right end indice - EDGE_NUM = 0 - DELETED = {} # marker value - - def __init__(self): - self.a = 0.0 # equation of the line a*x+b*y = c - self.b = 0.0 - self.c = 0.0 - self.ep = [None, None] # end point (2 tuples of site) - self.reg = [None, None] - self.edgenum = 0 - - def dump(self): - print("(#%d a=%g, b=%g, c=%g)" % (self.edgenum, self.a, self.b, self.c)) - print("ep", self.ep) - print("reg", self.reg) - - def setEndpoint(self, lrFlag, site): - self.ep[lrFlag] = site - if self.ep[Edge.RE - lrFlag] is None: - return False - return True - - @staticmethod - def bisect(s1, s2): - newedge = Edge() - newedge.reg[0] = s1 # store the sites that this edge is bisecting - newedge.reg[1] = s2 - - # to begin with, there are no endpoints on the bisector - it goes to infinity - # ep[0] and ep[1] are None - - # get the difference in x dist between the sites - dx = float(s2.x - s1.x) - dy = float(s2.y - s1.y) - adx = abs(dx) # make sure that the difference in positive - ady = abs(dy) - - # get the slope of the line - newedge.c = float(s1.x * dx + s1.y * dy + (dx * dx + dy * dy) * 0.5) - if adx > ady: - # set formula of line, with x fixed to 1 - newedge.a = 1.0 - newedge.b = dy / dx - newedge.c /= dx - else: - # set formula of line, with y fixed to 1 - newedge.b = 1.0 - newedge.a = dx / dy - newedge.c /= dy - - newedge.edgenum = Edge.EDGE_NUM - Edge.EDGE_NUM += 1 - return newedge - - -# ------------------------------------------------------------------ -class Halfedge(object): - def __init__(self, edge=None, pm=Edge.LE): - self.left = None # left Halfedge in the edge list - self.right = None # right Halfedge in the edge list - self.qnext = None # priority queue linked list pointer - self.edge = edge # edge list Edge - self.pm = pm - self.vertex = None # Site() - self.ystar = BIG_FLOAT - - def dump(self): - print("Halfedge--------------------------") - print("Left: ", self.left) - print("Right: ", self.right) - print("Edge: ", self.edge) - print("PM: ", self.pm) - print("Vertex: "), - if self.vertex: - self.vertex.dump() - else: - print("None") - print("Ystar: ", self.ystar) - - def __lt__(self, other): - if self.ystar < other.ystar: - return True - elif self.ystar > other.ystar: - return False - elif self.vertex.x < other.vertex.x: - return True - elif self.vertex.x > other.vertex.x: - return False - else: - return False - - def __eq__(self, other): - if self.ystar == other.ystar and self.vertex.x == other.vertex.x: - return True - - def leftreg(self, default): - if not self.edge: - return default - elif self.pm == Edge.LE: - return self.edge.reg[Edge.LE] - else: - return self.edge.reg[Edge.RE] - - def rightreg(self, default): - if not self.edge: - return default - elif self.pm == Edge.LE: - return self.edge.reg[Edge.RE] - else: - return self.edge.reg[Edge.LE] - - # returns True if p is to right of halfedge self - def isPointRightOf(self, pt): - e = self.edge - topsite = e.reg[1] - right_of_site = pt.x > topsite.x - - if right_of_site and self.pm == Edge.LE: - return True - - if not right_of_site and self.pm == Edge.RE: - return False - - if e.a == 1.0: - dyp = pt.y - topsite.y - dxp = pt.x - topsite.x - fast = 0 - if (not right_of_site and e.b < 0.0) or (right_of_site and e.b >= 0.0): - above = dyp >= e.b * dxp - fast = above - else: - above = pt.x + pt.y * e.b > e.c - if e.b < 0.0: - above = not above - if not above: - fast = 1 - if not fast: - dxs = topsite.x - (e.reg[0]).x - above = e.b * (dxp * dxp - dyp * dyp) < dxs * dyp * \ - (1.0 + 2.0 * dxp / dxs + e.b * e.b) - if e.b < 0.0: - above = not above - else: # e.b == 1.0 - yl = e.c - e.a * pt.x - t1 = pt.y - yl - t2 = pt.x - topsite.x - t3 = yl - topsite.y - above = t1 * t1 > t2 * t2 + t3 * t3 - - if self.pm == Edge.LE: - return above - else: - return not above - - # -------------------------- - # create a new site where the Halfedges el1 and el2 intersect - def intersect(self, other): - e1 = self.edge - e2 = other.edge - if (e1 is None) or (e2 is None): - return None - - # if the two edges bisect the same parent return None - if e1.reg[1] is e2.reg[1]: - return None - - d = e1.a * e2.b - e1.b * e2.a - if isEqual(d, 0.0): - return None - - xint = (e1.c * e2.b - e2.c * e1.b) / d - yint = (e2.c * e1.a - e1.c * e2.a) / d - if e1.reg[1] < e2.reg[1]: - he = self - e = e1 - else: - he = other - e = e2 - - rightOfSite = xint >= e.reg[1].x - if ((rightOfSite and he.pm == Edge.LE) or - (not rightOfSite and he.pm == Edge.RE)): - return None - - # create a new site at the point of intersection - this is a new - # vector event waiting to happen - return Site(xint, yint) - - -# ------------------------------------------------------------------ -class EdgeList(object): - def __init__(self, xmin, xmax, nsites): - if xmin > xmax: - xmin, xmax = xmax, xmin - self.hashsize = int(2 * math.sqrt(nsites + 4)) - - self.xmin = xmin - self.deltax = float(xmax - xmin) - self.hash = [None] * self.hashsize - - self.leftend = Halfedge() - self.rightend = Halfedge() - self.leftend.right = self.rightend - self.rightend.left = self.leftend - self.hash[0] = self.leftend - self.hash[-1] = self.rightend - - def insert(self, left, he): - he.left = left - he.right = left.right - left.right.left = he - left.right = he - - def delete(self, he): - he.left.right = he.right - he.right.left = he.left - he.edge = Edge.DELETED - - # Get entry from hash table, pruning any deleted nodes - def gethash(self, b): - if (b < 0 or b >= self.hashsize): - return None - he = self.hash[b] - if he is None or he.edge is not Edge.DELETED: - return he - - # Hash table points to deleted half edge. Patch as necessary. - self.hash[b] = None - return None - - def leftbnd(self, pt): - # Use hash table to get close to desired halfedge - bucket = int(((pt.x - self.xmin) / self.deltax * self.hashsize)) - - if bucket < 0: - bucket = 0 - - if bucket >= self.hashsize: - bucket = self.hashsize - 1 - - he = self.gethash(bucket) - if (he is None): - i = 1 - while True: - he = self.gethash(bucket - i) - if (he is not None): - break - he = self.gethash(bucket + i) - if (he is not None): - break - i += 1 - - # Now search linear list of halfedges for the corect one - if (he is self.leftend) or (he is not self.rightend and he.isPointRightOf(pt)): - he = he.right - while he is not self.rightend and he.isPointRightOf(pt): - he = he.right - he = he.left - else: - he = he.left - while he is not self.leftend and not he.isPointRightOf(pt): - he = he.left - - # Update hash table and reference counts - if bucket > 0 and bucket < self.hashsize - 1: - self.hash[bucket] = he - return he - - -# ------------------------------------------------------------------ -class PriorityQueue(object): - def __init__(self, ymin, ymax, nsites): - self.ymin = ymin - self.deltay = ymax - ymin - self.hashsize = int(4 * math.sqrt(nsites)) - self.count = 0 - self.minidx = 0 - self.hash = [] - for i in range(self.hashsize): - self.hash.append(Halfedge()) - - def __len__(self): - return self.count - - def isEmpty(self): - return self.count == 0 - - def insert(self, he, site, offset): - he.vertex = site - he.ystar = site.y + offset - last = self.hash[self.getBucket(he)] - next = last.qnext - while (next is not None) and he > next: - last = next - next = last.qnext - he.qnext = last.qnext - last.qnext = he - self.count += 1 - - def delete(self, he): - if he.vertex is not None: - last = self.hash[self.getBucket(he)] - while last.qnext is not he: - last = last.qnext - last.qnext = he.qnext - self.count -= 1 - he.vertex = None - - def getBucket(self, he): - bucket = int(((he.ystar - self.ymin) / self.deltay) * self.hashsize) - if bucket < 0: - bucket = 0 - if bucket >= self.hashsize: - bucket = self.hashsize - 1 - if bucket < self.minidx: - self.minidx = bucket - return bucket - - def getMinPt(self): - while self.hash[self.minidx].qnext is None: - self.minidx += 1 - he = self.hash[self.minidx].qnext - x = he.vertex.x - y = he.ystar - return Site(x, y) - - def popMinHalfedge(self): - curr = self.hash[self.minidx].qnext - self.hash[self.minidx].qnext = curr.qnext - self.count -= 1 - return curr - - -# ------------------------------------------------------------------ -class SiteList(object): - def __init__(self, pointList): - self.__sites = [] - self.__sitenum = 0 - - self.__xmin = min([pt.x for pt in pointList]) - self.__ymin = min([pt.y for pt in pointList]) - self.__xmax = max([pt.x for pt in pointList]) - self.__ymax = max([pt.y for pt in pointList]) - self.__extent = (self.__xmin, self.__xmax, self.__ymin, self.__ymax) - - for i, pt in enumerate(pointList): - self.__sites.append(Site(pt.x, pt.y, i)) - self.__sites.sort() - - def setSiteNumber(self, site): - site.sitenum = self.__sitenum - self.__sitenum += 1 - - class Iterator(object): - def __init__(this, lst): - this.generator = (s for s in lst) - - def __iter__(this): - return this - - def next(this): - try: - if PY3: - return this.generator.__next__() - else: - return this.generator.next() - except StopIteration: - return None - - def iterator(self): - return SiteList.Iterator(self.__sites) - - def __iter__(self): - return SiteList.Iterator(self.__sites) - - def __len__(self): - return len(self.__sites) - - def _getxmin(self): - return self.__xmin - - def _getymin(self): - return self.__ymin - - def _getxmax(self): - return self.__xmax - - def _getymax(self): - return self.__ymax - - def _getextent(self): - return self.__extent - - xmin = property(_getxmin) - ymin = property(_getymin) - xmax = property(_getxmax) - ymax = property(_getymax) - extent = property(_getextent) - - -# ------------------------------------------------------------------ -def computeVoronoiDiagram(points, xBuff=0, yBuff=0, polygonsOutput=False, formatOutput=False, closePoly=True): - """ - Takes : - - a list of point objects (which must have x and y fields). - - x and y buffer values which are the expansion percentages of the bounding box rectangle including all input points. - Returns : - - With default options : - A list of 2-tuples, representing the two points of each Voronoi diagram edge. - Each point contains 2-tuples which are the x,y coordinates of point. - if formatOutput is True, returns : - - a list of 2-tuples, which are the x,y coordinates of the Voronoi diagram vertices. - - and a list of 2-tuples (v1, v2) representing edges of the Voronoi diagram. - v1 and v2 are the indices of the vertices at the end of the edge. - - If polygonsOutput option is True, returns : - A dictionary of polygons, keys are the indices of the input points, - values contains n-tuples representing the n points of each Voronoi diagram polygon. - Each point contains 2-tuples which are the x,y coordinates of point. - if formatOutput is True, returns : - - A list of 2-tuples, which are the x,y coordinates of the Voronoi diagram vertices. - - and a dictionary of input points indices. Values contains n-tuples representing the n points of each Voronoi diagram polygon. - Each tuple contains the vertex indices of the polygon vertices. - - if closePoly is True then, in the list of points of a polygon, last point will be the same of first point - """ - siteList = SiteList(points) - context = Context() - voronoi(siteList, context) - context.setClipBuffer(xBuff, yBuff) - if not polygonsOutput: - clipEdges = context.getClipEdges() - if formatOutput: - vertices, edgesIdx = formatEdgesOutput(clipEdges) - return vertices, edgesIdx - else: - return clipEdges - else: - clipPolygons = context.getClipPolygons(closePoly) - if formatOutput: - vertices, polyIdx = formatPolygonsOutput(clipPolygons) - return vertices, polyIdx - else: - return clipPolygons - - -def formatEdgesOutput(edges): - # get list of points - pts = [] - for edge in edges: - pts.extend(edge) - # get unique values - pts = set(pts) # unique values (tuples are hashable) - # get dict {values:index} - valuesIdxDict = dict(zip(pts, range(len(pts)))) - # get edges index reference - edgesIdx = [] - for edge in edges: - edgesIdx.append([valuesIdxDict[pt] for pt in edge]) - return list(pts), edgesIdx - - -def formatPolygonsOutput(polygons): - # get list of points - pts = [] - for poly in polygons.values(): - pts.extend(poly) - # get unique values - pts = set(pts) # unique values (tuples are hashable) - # get dict {values:index} - valuesIdxDict = dict(zip(pts, range(len(pts)))) - # get polygons index reference - polygonsIdx = {} - for inPtsIdx, poly in polygons.items(): - polygonsIdx[inPtsIdx] = [valuesIdxDict[pt] for pt in poly] - return list(pts), polygonsIdx - - -# ------------------------------------------------------------------ -def computeDelaunayTriangulation(points): - """ Takes a list of point objects (which must have x and y fields). - Returns a list of 3-tuples: the indices of the points that form a - Delaunay triangle. - """ - siteList = SiteList(points) - context = Context() - context.triangulate = True - voronoi(siteList, context) - return context.triangles - -# ----------------------------------------------------------------------------- -# def shapely_voronoi(amount): -# import random -# -# rcoord = [] -# x = 0 -# while x < self.amount: -# rcoord.append((width * random.random(), height * random.random(), 0.02 * random.random())) -# x += 1 -# -# points = MultiPoint(rcoord) -# voronoi = shapely.ops.voronoi_diagram(points, tolerance=0, edges=False) -# -# utils.shapelyToCurve('voronoi', voronoi, 0) +"""BlenderCAM 'voronoi.py' + +Voronoi diagram calculator/ Delaunay triangulator + +- Voronoi Diagram Sweepline algorithm and C code by Steven Fortune, 1987, http://ect.bell-labs.com/who/sjf/ +- Python translation to file voronoi.py by Bill Simons, 2005, http://www.oxfish.com/ +- Additional changes for QGIS by Carson Farmer added November 2010 +- 2012 Ported to Python 3 and additional clip functions by domlysz at gmail.com + +Calculate Delaunay triangulation or the Voronoi polygons for a set of +2D input points. + +Derived from code bearing the following notice: + +The author of this software is Steven Fortune. Copyright (c) 1994 by AT&T +Bell Laboratories. +Permission to use, copy, modify, and distribute this software for any +purpose without fee is hereby granted, provided that this entire notice +is included in all copies of any software which is or includes a copy +or modification of this software and in all copies of the supporting +documentation for such software. +THIS SOFTWARE IS BEING PROVIDED "AS IS", WITHOUT ANY EXPRESS OR IMPLIED +WARRANTY. IN PARTICULAR, NEITHER THE AUTHORS NOR AT&T MAKE ANY +REPRESENTATION OR WARRANTY OF ANY KIND CONCERNING THE MERCHANTABILITY +OF THIS SOFTWARE OR ITS FITNESS FOR ANY PARTICULAR PURPOSE. + +Comments were incorporated from Shane O'Sullivan's translation of the +original code into C++ (http://mapviewer.skynet.ie/voronoi.html) + +Steve Fortune's homepage: http://netlib.bell-labs.com/cm/cs/who/sjf/index.html + + +For programmatic use two functions are available: + +computeVoronoiDiagram(points, xBuff, yBuff, polygonsOutput=False, formatOutput=False) : +Takes : + - a list of point objects (which must have x and y fields). + - x and y buffer values which are the expansion percentages of the bounding box rectangle including all input points. + Returns : + - With default options : + A list of 2-tuples, representing the two points of each Voronoi diagram edge. + Each point contains 2-tuples which are the x,y coordinates of point. + if formatOutput is True, returns : + - a list of 2-tuples, which are the x,y coordinates of the Voronoi diagram vertices. + - and a list of 2-tuples (v1, v2) representing edges of the Voronoi diagram. + v1 and v2 are the indices of the vertices at the end of the edge. + - If polygonsOutput option is True, returns : + A dictionary of polygons, keys are the indices of the input points, + values contains n-tuples representing the n points of each Voronoi diagram polygon. + Each point contains 2-tuples which are the x,y coordinates of point. + if formatOutput is True, returns : + - A list of 2-tuples, which are the x,y coordinates of the Voronoi diagram vertices. + - and a dictionary of input points indices. Values contains n-tuples representing the n points of each Voronoi diagram polygon. + Each tuple contains the vertex indices of the polygon vertices. + +computeDelaunayTriangulation(points): + Takes a list of point objects (which must have x and y fields). + Returns a list of 3-tuples: the indices of the points that form a Delaunay triangle. +""" + +import math +import sys + +TOLERANCE = 1e-9 +BIG_FLOAT = 1e38 + +if sys.version > '3': + PY3 = True +else: + PY3 = False + + +# ------------------------------------------------------------------ +class Context(object): + def __init__(self): + """Init function.""" + self.doPrint = 0 + self.debug = 0 + self.extent = () # tuple (xmin, xmax, ymin, ymax) + self.triangulate = False + self.vertices = [] # list of vertex 2-tuples: (x,y) + # equation of line 3-tuple (a b c), for the equation of the line a*x+b*y = c + self.lines = [] + # edge 3-tuple: (line index, vertex 1 index, vertex 2 index) if either vertex index is -1, the edge extends to infinity + self.edges = [] + self.triangles = [] # 3-tuple of vertex indices + self.polygons = {} # a dict of site:[edges] pairs + + ########Clip functions######## + def getClipEdges(self): + """Get the clipped edges based on the current extent. + + This function iterates through the edges of a geometric shape and + determines which edges are within the specified extent. It handles both + finite and infinite lines, clipping them as necessary to fit within the + defined boundaries. For finite lines, it checks if both endpoints are + within the extent, and if not, it calculates the intersection points + using the line equations. For infinite lines, it checks if at least one + endpoint is within the extent and clips accordingly. + + Returns: + list: A list of tuples, where each tuple contains two points representing the + clipped edges. + """ + xmin, xmax, ymin, ymax = self.extent + clipEdges = [] + for edge in self.edges: + equation = self.lines[edge[0]] # line equation + if edge[1] != -1 and edge[2] != -1: # finite line + x1, y1 = self.vertices[edge[1]][0], self.vertices[edge[1]][1] + x2, y2 = self.vertices[edge[2]][0], self.vertices[edge[2]][1] + pt1, pt2 = (x1, y1), (x2, y2) + inExtentP1, inExtentP2 = self.inExtent(x1, y1), self.inExtent(x2, y2) + if inExtentP1 and inExtentP2: + clipEdges.append((pt1, pt2)) + elif inExtentP1 and not inExtentP2: + pt2 = self.clipLine(x1, y1, equation, leftDir=False) + clipEdges.append((pt1, pt2)) + elif not inExtentP1 and inExtentP2: + pt1 = self.clipLine(x2, y2, equation, leftDir=True) + clipEdges.append((pt1, pt2)) + else: # infinite line + if edge[1] != -1: + x1, y1 = self.vertices[edge[1]][0], self.vertices[edge[1]][1] + leftDir = False + else: + x1, y1 = self.vertices[edge[2]][0], self.vertices[edge[2]][1] + leftDir = True + if self.inExtent(x1, y1): + pt1 = (x1, y1) + pt2 = self.clipLine(x1, y1, equation, leftDir) + clipEdges.append((pt1, pt2)) + return clipEdges + + def getClipPolygons(self, closePoly): + """Get clipped polygons based on the provided edges. + + This function processes a set of polygons defined by their edges and + vertices, clipping them according to the specified extent. It checks + whether each edge is finite or infinite and determines if the endpoints + of each edge are within the defined extent. If they are not, the + function calculates the intersection points with the extent boundaries. + The resulting clipped edges are then used to create polygons, which are + returned as a dictionary. The user can specify whether to close the + polygons or leave them open. + + Args: + closePoly (bool): A flag indicating whether to close the polygons. + + Returns: + dict: A dictionary where keys are polygon indices and values are lists of + points defining the clipped polygons. + """ + xmin, xmax, ymin, ymax = self.extent + poly = {} + for inPtsIdx, edges in self.polygons.items(): + clipEdges = [] + for edge in edges: + equation = self.lines[edge[0]] # line equation + if edge[1] != -1 and edge[2] != -1: # finite line + x1, y1 = self.vertices[edge[1]][0], self.vertices[edge[1]][1] + x2, y2 = self.vertices[edge[2]][0], self.vertices[edge[2]][1] + pt1, pt2 = (x1, y1), (x2, y2) + inExtentP1, inExtentP2 = self.inExtent(x1, y1), self.inExtent(x2, y2) + if inExtentP1 and inExtentP2: + clipEdges.append((pt1, pt2)) + elif inExtentP1 and not inExtentP2: + pt2 = self.clipLine(x1, y1, equation, leftDir=False) + clipEdges.append((pt1, pt2)) + elif not inExtentP1 and inExtentP2: + pt1 = self.clipLine(x2, y2, equation, leftDir=True) + clipEdges.append((pt1, pt2)) + else: # infinite line + if edge[1] != -1: + x1, y1 = self.vertices[edge[1]][0], self.vertices[edge[1]][1] + leftDir = False + else: + x1, y1 = self.vertices[edge[2]][0], self.vertices[edge[2]][1] + leftDir = True + if self.inExtent(x1, y1): + pt1 = (x1, y1) + pt2 = self.clipLine(x1, y1, equation, leftDir) + clipEdges.append((pt1, pt2)) + # create polygon definition from edges and check if polygon is completely closed + polyPts, complete = self.orderPts(clipEdges) + if not complete: + startPt = polyPts[0] + endPt = polyPts[-1] + if startPt[0] == endPt[0] or startPt[1] == endPt[1]: + # if start & end points are collinear then they are along an extent border + polyPts.append(polyPts[0]) # simple close + else: # close at extent corner + if (startPt[0] == xmin and endPt[1] == ymax) or ( + endPt[0] == xmin and startPt[1] == ymax): # upper left + polyPts.append((xmin, ymax)) # corner point + polyPts.append(polyPts[0]) # close polygon + if (startPt[0] == xmax and endPt[1] == ymax) or ( + endPt[0] == xmax and startPt[1] == ymax): # upper right + polyPts.append((xmax, ymax)) + polyPts.append(polyPts[0]) + if (startPt[0] == xmax and endPt[1] == ymin) or ( + endPt[0] == xmax and startPt[1] == ymin): # bottom right + polyPts.append((xmax, ymin)) + polyPts.append(polyPts[0]) + if (startPt[0] == xmin and endPt[1] == ymin) or ( + endPt[0] == xmin and startPt[1] == ymin): # bottom left + polyPts.append((xmin, ymin)) + polyPts.append(polyPts[0]) + if not closePoly: # unclose polygon + polyPts = polyPts[:-1] + poly[inPtsIdx] = polyPts + return poly + + def clipLine(self, x1, y1, equation, leftDir): + """Clip a line segment defined by its endpoints against a bounding box. + + This function calculates the intersection points of a line defined by + the given equation with the bounding box defined by the extent of the + object. Depending on the direction specified (left or right), it will + return the appropriate intersection point that lies within the bounds. + + Args: + x1 (float): The x-coordinate of the first endpoint of the line. + y1 (float): The y-coordinate of the first endpoint of the line. + equation (tuple): A tuple containing the coefficients (a, b, c) of + the line equation in the form ax + by + c = 0. + leftDir (bool): A boolean indicating the direction to clip the line. + If True, clip towards the left; otherwise, clip + towards the right. + + Returns: + tuple: The coordinates of the clipped point as (x, y). + """ + xmin, xmax, ymin, ymax = self.extent + a, b, c = equation + if b == 0: # vertical line + if leftDir: # left is bottom of vertical line + return (x1, ymax) + else: + return (x1, ymin) + elif a == 0: # horizontal line + if leftDir: + return (xmin, y1) + else: + return (xmax, y1) + else: + y2_at_xmin = (c - a * xmin) / b + y2_at_xmax = (c - a * xmax) / b + x2_at_ymin = (c - b * ymin) / a + x2_at_ymax = (c - b * ymax) / a + intersectPts = [] + if ymin <= y2_at_xmin <= ymax: # valid intersect point + intersectPts.append((xmin, y2_at_xmin)) + if ymin <= y2_at_xmax <= ymax: + intersectPts.append((xmax, y2_at_xmax)) + if xmin <= x2_at_ymin <= xmax: + intersectPts.append((x2_at_ymin, ymin)) + if xmin <= x2_at_ymax <= xmax: + intersectPts.append((x2_at_ymax, ymax)) + # delete duplicate (happens if intersect point is at extent corner) + intersectPts = set(intersectPts) + # choose target intersect point + if leftDir: + pt = min(intersectPts) # smaller x value + else: + pt = max(intersectPts) + return pt + + def inExtent(self, x, y): + """Check if a point is within the defined extent. + + This function determines whether the given coordinates (x, y) fall + within the boundaries defined by the extent of the object. The extent is + defined by its minimum and maximum x and y values (xmin, xmax, ymin, + ymax). The function returns True if the point is within these bounds, + and False otherwise. + + Args: + x (float): The x-coordinate of the point to check. + y (float): The y-coordinate of the point to check. + + Returns: + bool: True if the point (x, y) is within the extent, False otherwise. + """ + xmin, xmax, ymin, ymax = self.extent + return x >= xmin and x <= xmax and y >= ymin and y <= ymax + + def orderPts(self, edges): + """Order points to form a polygon. + + This function takes a list of edges, where each edge is represented as a + pair of points, and orders the points to create a polygon. It identifies + the starting and ending points of the polygon and ensures that the + points are connected in the correct order. If all points are duplicates, + it recognizes that the polygon is complete and handles it accordingly. + + Args: + edges (list): A list of edges, where each edge is a tuple or list containing two + points. + + Returns: + tuple: A tuple containing: + - list: The ordered list of polygon points. + - bool: A flag indicating whether the polygon is complete. + """ + poly = [] # returned polygon points list [pt1, pt2, pt3, pt4 ....] + pts = [] + # get points list + for edge in edges: + pts.extend([pt for pt in edge]) + # try to get start & end point + try: + # start and end point aren't duplicate + startPt, endPt = [pt for pt in pts if pts.count(pt) < 2] + except: # all points are duplicate --> polygon is complete --> append some or other edge points + complete = True + firstIdx = 0 + poly.append(edges[0][0]) + poly.append(edges[0][1]) + else: # incomplete --> append the first edge points + complete = False + # search first edge + for i, edge in enumerate(edges): + if startPt in edge: # find + firstIdx = i + break + poly.append(edges[firstIdx][0]) + poly.append(edges[firstIdx][1]) + if poly[0] != startPt: + poly.reverse() + # append next points in list + del edges[firstIdx] + while edges: # all points will be treated when edges list will be empty + currentPt = poly[-1] # last item + for i, edge in enumerate(edges): + if currentPt == edge[0]: + poly.append(edge[1]) + break + elif currentPt == edge[1]: + poly.append(edge[0]) + break + del edges[i] + return poly, complete + + def setClipBuffer(self, xpourcent, ypourcent): + """Set the clipping buffer based on percentage adjustments. + + This function modifies the clipping extent of an object by adjusting its + boundaries according to the specified percentage values for both the x + and y axes. It calculates the new minimum and maximum values for the x + and y coordinates by applying the given percentages to the current + extent. + + Args: + xpourcent (float): The percentage adjustment for the x-axis. + ypourcent (float): The percentage adjustment for the y-axis. + + Returns: + None: This function does not return a value; it modifies the + object's extent in place. + """ + xmin, xmax, ymin, ymax = self.extent + witdh = xmax - xmin + height = ymax - ymin + xmin = xmin - witdh * xpourcent / 100 + xmax = xmax + witdh * xpourcent / 100 + ymin = ymin - height * ypourcent / 100 + ymax = ymax + height * ypourcent / 100 + self.extent = xmin, xmax, ymin, ymax + + # End clip functions######## + + def outSite(self, s): + """Handle output for a site object. + + This function processes the output based on the current settings of the + instance. If debugging is enabled, it prints the site number and its + coordinates. If triangulation is enabled, no action is taken. If + printing is enabled, it prints the coordinates of the site. + + Args: + s (object): An object representing a site, which should have + attributes 'sitenum', 'x', and 'y'. + + Returns: + None: This function does not return a value. + """ + if self.debug: + print("site (%d) at %f %f" % (s.sitenum, s.x, s.y)) + elif self.triangulate: + pass + elif self.doPrint: + print("s %f %f" % (s.x, s.y)) + + def outVertex(self, s): + """Add a vertex to the list of vertices. + + This function appends the coordinates of a given vertex to the internal + list of vertices. Depending on the state of the debug, triangulate, and + doPrint flags, it may also print debug information or vertex coordinates + to the console. + + Args: + s (object): An object containing the attributes `x`, `y`, and + `sitenum` which represent the coordinates and + identifier of the vertex. + + Returns: + None: This function does not return a value. + """ + self.vertices.append((s.x, s.y)) + if self.debug: + print("vertex(%d) at %f %f" % (s.sitenum, s.x, s.y)) + elif self.triangulate: + pass + elif self.doPrint: + print("v %f %f" % (s.x, s.y)) + + def outTriple(self, s1, s2, s3): + """Add a triangle defined by three site numbers to the list of triangles. + + This function takes three site objects, extracts their site numbers, and + appends a tuple of these site numbers to the `triangles` list. If + debugging is enabled, it prints the site numbers to the console. + Additionally, if triangulation is enabled and printing is allowed, it + prints the site numbers in a formatted manner. + + Args: + s1 (Site): The first site object. + s2 (Site): The second site object. + s3 (Site): The third site object. + + Returns: + None: This function does not return a value. + """ + self.triangles.append((s1.sitenum, s2.sitenum, s3.sitenum)) + if self.debug: + print("circle through left=%d right=%d bottom=%d" % + (s1.sitenum, s2.sitenum, s3.sitenum)) + elif self.triangulate and self.doPrint: + print("%d %d %d" % (s1.sitenum, s2.sitenum, s3.sitenum)) + + def outBisector(self, edge): + """Process and log the outbisector of a given edge. + + This function appends the parameters of the edge (a, b, c) to the lines + list and optionally prints debugging information or the parameters based + on the state of the debug and doPrint flags. The function is designed to + handle geometric edges and their properties in a computational geometry + context. + + Args: + edge (Edge): An object representing an edge with attributes + a, b, c, edgenum, and reg. + + Returns: + None: This function does not return a value. + """ + self.lines.append((edge.a, edge.b, edge.c)) + if self.debug: + print("line(%d) %gx+%gy=%g, bisecting %d %d" % ( + edge.edgenum, edge.a, edge.b, edge.c, edge.reg[0].sitenum, edge.reg[1].sitenum)) + elif self.doPrint: + print("l %f %f %f" % (edge.a, edge.b, edge.c)) + + def outEdge(self, edge): + """Process an edge and update the associated polygons and edges. + + This function takes an edge as input and retrieves the site numbers + associated with its left and right endpoints. It then updates the + polygons dictionary to include the edge information for the regions + associated with the edge. If the regions are not already present in the + polygons dictionary, they are initialized. The function also appends the + edge information to the edges list. If triangulation is not enabled, it + prints the edge number and its associated site numbers. + + Args: + edge (Edge): An instance of the Edge class containing information + + Returns: + None: This function does not return a value. + """ + sitenumL = -1 + if edge.ep[Edge.LE] is not None: + sitenumL = edge.ep[Edge.LE].sitenum + sitenumR = -1 + if edge.ep[Edge.RE] is not None: + sitenumR = edge.ep[Edge.RE].sitenum + + # polygons dict add by CF + if edge.reg[0].sitenum not in self.polygons: + self.polygons[edge.reg[0].sitenum] = [] + if edge.reg[1].sitenum not in self.polygons: + self.polygons[edge.reg[1].sitenum] = [] + self.polygons[edge.reg[0].sitenum].append((edge.edgenum, sitenumL, sitenumR)) + self.polygons[edge.reg[1].sitenum].append((edge.edgenum, sitenumL, sitenumR)) + + self.edges.append((edge.edgenum, sitenumL, sitenumR)) + + if not self.triangulate: + if self.doPrint: + print("e %d" % edge.edgenum) + print(" %d " % sitenumL) + print("%d" % sitenumR) + + +# ------------------------------------------------------------------ +def voronoi(siteList, context): + """Generate a Voronoi diagram from a list of sites. + + This function computes the Voronoi diagram for a given list of sites. It + utilizes a sweep line algorithm to process site events and circle + events, maintaining a priority queue and edge list to manage the + geometric relationships between the sites. The function outputs the + resulting edges, vertices, and bisectors to the provided context. + + Args: + siteList (SiteList): A list of sites represented by their coordinates. + context (Context): An object that handles the output of the Voronoi diagram + elements, including sites, edges, and vertices. + + Returns: + None: This function does not return a value; it outputs results directly + to the context provided. + """ + context.extent = siteList.extent + edgeList = EdgeList(siteList.xmin, siteList.xmax, len(siteList)) + priorityQ = PriorityQueue(siteList.ymin, siteList.ymax, len(siteList)) + siteIter = siteList.iterator() + + bottomsite = siteIter.next() + context.outSite(bottomsite) + newsite = siteIter.next() + minpt = Site(-BIG_FLOAT, -BIG_FLOAT) + while True: + if not priorityQ.isEmpty(): + minpt = priorityQ.getMinPt() + + if newsite and (priorityQ.isEmpty() or newsite < minpt): + # newsite is smallest - this is a site event + context.outSite(newsite) + + # get first Halfedge to the LEFT and RIGHT of the new site + lbnd = edgeList.leftbnd(newsite) + rbnd = lbnd.right + + # if this halfedge has no edge, bot = bottom site (whatever that is) + # create a new edge that bisects + bot = lbnd.rightreg(bottomsite) + edge = Edge.bisect(bot, newsite) + context.outBisector(edge) + + # create a new Halfedge, setting its pm field to 0 and insert + # this new bisector edge between the left and right vectors in + # a linked list + bisector = Halfedge(edge, Edge.LE) + edgeList.insert(lbnd, bisector) + + # if the new bisector intersects with the left edge, remove + # the left edge's vertex, and put in the new one + p = lbnd.intersect(bisector) + if p is not None: + priorityQ.delete(lbnd) + priorityQ.insert(lbnd, p, newsite.distance(p)) + + # create a new Halfedge, setting its pm field to 1 + # insert the new Halfedge to the right of the original bisector + lbnd = bisector + bisector = Halfedge(edge, Edge.RE) + edgeList.insert(lbnd, bisector) + + # if this new bisector intersects with the right Halfedge + p = bisector.intersect(rbnd) + if p is not None: + # push the Halfedge into the ordered linked list of vertices + priorityQ.insert(bisector, p, newsite.distance(p)) + + newsite = siteIter.next() + + elif not priorityQ.isEmpty(): + # intersection is smallest - this is a vector (circle) event + + # pop the Halfedge with the lowest vector off the ordered list of + # vectors. Get the Halfedge to the left and right of the above HE + # and also the Halfedge to the right of the right HE + lbnd = priorityQ.popMinHalfedge() + llbnd = lbnd.left + rbnd = lbnd.right + rrbnd = rbnd.right + + # get the Site to the left of the left HE and to the right of + # the right HE which it bisects + bot = lbnd.leftreg(bottomsite) + top = rbnd.rightreg(bottomsite) + + # output the triple of sites, stating that a circle goes through them + mid = lbnd.rightreg(bottomsite) + context.outTriple(bot, top, mid) + + # get the vertex that caused this event and set the vertex number + # couldn't do this earlier since we didn't know when it would be processed + v = lbnd.vertex + siteList.setSiteNumber(v) + context.outVertex(v) + + # set the endpoint of the left and right Halfedge to be this vector + if lbnd.edge.setEndpoint(lbnd.pm, v): + context.outEdge(lbnd.edge) + + if rbnd.edge.setEndpoint(rbnd.pm, v): + context.outEdge(rbnd.edge) + + # delete the lowest HE, remove all vertex events to do with the + # right HE and delete the right HE + edgeList.delete(lbnd) + priorityQ.delete(rbnd) + edgeList.delete(rbnd) + + # if the site to the left of the event is higher than the Site + # to the right of it, then swap them and set 'pm' to RIGHT + pm = Edge.LE + if bot.y > top.y: + bot, top = top, bot + pm = Edge.RE + + # Create an Edge (or line) that is between the two Sites. This + # creates the formula of the line, and assigns a line number to it + edge = Edge.bisect(bot, top) + context.outBisector(edge) + + # create a HE from the edge + bisector = Halfedge(edge, pm) + + # insert the new bisector to the right of the left HE + # set one endpoint to the new edge to be the vector point 'v' + # If the site to the left of this bisector is higher than the right + # Site, then this endpoint is put in position 0; otherwise in pos 1 + edgeList.insert(llbnd, bisector) + if edge.setEndpoint(Edge.RE - pm, v): + context.outEdge(edge) + + # if left HE and the new bisector don't intersect, then delete + # the left HE, and reinsert it + p = llbnd.intersect(bisector) + if p is not None: + priorityQ.delete(llbnd) + priorityQ.insert(llbnd, p, bot.distance(p)) + + # if right HE and the new bisector don't intersect, then reinsert it + p = bisector.intersect(rrbnd) + if p is not None: + priorityQ.insert(bisector, p, bot.distance(p)) + else: + break + + he = edgeList.leftend.right + while he is not edgeList.rightend: + context.outEdge(he.edge) + he = he.right + Edge.EDGE_NUM = 0 # CF + + +# ------------------------------------------------------------------ +def isEqual(a, b, relativeError=TOLERANCE): + """Check if two values are nearly equal within a specified relative error. + + This function determines if the absolute difference between two values + is within a specified relative error of the larger of the two values. It + is useful for comparing floating-point numbers where precision issues + may arise. + + Args: + a (float): The first value to compare. + b (float): The second value to compare. + relativeError (float): The allowed relative error for the comparison. + + Returns: + bool: True if the values are considered nearly equal, False otherwise. + """ + # is nearly equal to within the allowed relative error + norm = max(abs(a), abs(b)) + return (norm < relativeError) or (abs(a - b) < (relativeError * norm)) + + +# ------------------------------------------------------------------ +class Site(object): + def __init__(self, x=0.0, y=0.0, sitenum=0): + """Init function.""" + self.x = x + self.y = y + self.sitenum = sitenum + + def dump(self): + """Dump the site information. + + This function prints the site number along with its x and y coordinates + in a formatted string. It is primarily used for debugging or logging + purposes to provide a quick overview of the site's attributes. + + Returns: + None: This function does not return any value. + """ + print("Site #%d (%g, %g)" % (self.sitenum, self.x, self.y)) + + def __lt__(self, other): + """Compare two objects based on their coordinates. + + This method implements the less-than comparison for objects that have x + and y attributes. It first compares the y coordinates; if they are + equal, it then compares the x coordinates. The method returns True if + the current object is considered less than the other object based on + these comparisons. + + Args: + other (object): The object to compare against, which must have + x and y attributes. + + Returns: + bool: True if the current object is less than the other object, + otherwise False. + """ + if self.y < other.y: + return True + elif self.y > other.y: + return False + elif self.x < other.x: + return True + elif self.x > other.x: + return False + else: + return False + + def __eq__(self, other): + """Determine equality between two objects. + + This method checks if the current object is equal to another object by + comparing their 'x' and 'y' attributes. If both attributes are equal for + the two objects, it returns True; otherwise, it returns False. + + Args: + other (object): The object to compare with the current object. + + Returns: + bool: True if both objects are equal, False otherwise. + """ + if self.y == other.y and self.x == other.x: + return True + + def distance(self, other): + """Calculate the distance between two points in a 2D space. + + This function computes the Euclidean distance between the current point + (represented by the instance's coordinates) and another point provided + as an argument. It uses the Pythagorean theorem to calculate the + distance based on the differences in the x and y coordinates of the two + points. + + Args: + other (Point): Another point in 2D space to calculate the distance from. + + Returns: + float: The Euclidean distance between the two points. + """ + dx = self.x - other.x + dy = self.y - other.y + return math.sqrt(dx * dx + dy * dy) + + +# ------------------------------------------------------------------ +class Edge(object): + LE = 0 # left end indice --> edge.ep[Edge.LE] + RE = 1 # right end indice + EDGE_NUM = 0 + DELETED = {} # marker value + + def __init__(self): + """Init function.""" + self.a = 0.0 # equation of the line a*x+b*y = c + self.b = 0.0 + self.c = 0.0 + self.ep = [None, None] # end point (2 tuples of site) + self.reg = [None, None] + self.edgenum = 0 + + def dump(self): + """Dump the current state of the object. + + This function prints the values of the object's attributes, including + the edge number, and the values of a, b, c, as well as the ep and reg + attributes. It is useful for debugging purposes to understand the + current state of the object. + + Attributes: + edgenum (int): The edge number of the object. + a (float): The value of attribute a. + b (float): The value of attribute b. + c (float): The value of attribute c. + ep: The value of the ep attribute. + reg: The value of the reg attribute. + """ + print("(#%d a=%g, b=%g, c=%g)" % (self.edgenum, self.a, self.b, self.c)) + print("ep", self.ep) + print("reg", self.reg) + + def setEndpoint(self, lrFlag, site): + """Set the endpoint for a given flag. + + This function assigns a site to the specified endpoint flag. It checks + if the corresponding endpoint for the opposite flag is not set to None. + If it is None, the function returns False; otherwise, it returns True. + + Args: + lrFlag (int): The flag indicating which endpoint to set. + site (str): The site to be assigned to the specified endpoint. + + Returns: + bool: True if the opposite endpoint is set, False otherwise. + """ + self.ep[lrFlag] = site + if self.ep[Edge.RE - lrFlag] is None: + return False + return True + + @staticmethod + def bisect(s1, s2): + """Bisect two sites to create a new edge. + + This function takes two site objects and computes the bisector edge + between them. It calculates the slope and intercept of the line that + bisects the two sites, storing the necessary parameters in a new edge + object. The edge is initialized with no endpoints, as it extends to + infinity. The function determines whether to fix x or y based on the + relative distances between the sites. + + Args: + s1 (Site): The first site to be bisected. + s2 (Site): The second site to be bisected. + + Returns: + Edge: A new edge object representing the bisector between the two sites. + """ + newedge = Edge() + newedge.reg[0] = s1 # store the sites that this edge is bisecting + newedge.reg[1] = s2 + + # to begin with, there are no endpoints on the bisector - it goes to infinity + # ep[0] and ep[1] are None + + # get the difference in x dist between the sites + dx = float(s2.x - s1.x) + dy = float(s2.y - s1.y) + adx = abs(dx) # make sure that the difference in positive + ady = abs(dy) + + # get the slope of the line + newedge.c = float(s1.x * dx + s1.y * dy + (dx * dx + dy * dy) * 0.5) + if adx > ady: + # set formula of line, with x fixed to 1 + newedge.a = 1.0 + newedge.b = dy / dx + newedge.c /= dx + else: + # set formula of line, with y fixed to 1 + newedge.b = 1.0 + newedge.a = dx / dy + newedge.c /= dy + + newedge.edgenum = Edge.EDGE_NUM + Edge.EDGE_NUM += 1 + return newedge + + +# ------------------------------------------------------------------ +class Halfedge(object): + def __init__(self, edge=None, pm=Edge.LE): + """Init function.""" + self.left = None # left Halfedge in the edge list + self.right = None # right Halfedge in the edge list + self.qnext = None # priority queue linked list pointer + self.edge = edge # edge list Edge + self.pm = pm + self.vertex = None # Site() + self.ystar = BIG_FLOAT + + def dump(self): + """Dump the internal state of the object. + + This function prints the current values of the object's attributes, + including left, right, edge, pm, vertex, and ystar. If the vertex + attribute is present and has a dump method, it will call that method to + print the vertex's internal state. Otherwise, it will print "None" for + the vertex. + + Attributes: + left: The left halfedge associated with this object. + right: The right halfedge associated with this object. + edge: The edge associated with this object. + pm: The PM associated with this object. + vertex: The vertex associated with this object, which may have its + own dump method. + ystar: The ystar value associated with this object. + """ + print("Halfedge--------------------------") + print("Left: ", self.left) + print("Right: ", self.right) + print("Edge: ", self.edge) + print("PM: ", self.pm) + print("Vertex: "), + if self.vertex: + self.vertex.dump() + else: + print("None") + print("Ystar: ", self.ystar) + + def __lt__(self, other): + """Compare two objects based on their ystar and vertex attributes. + + This method implements the less-than comparison for objects. It first + compares the `ystar` attributes of the two objects. If they are equal, + it then compares the x-coordinate of their `vertex` attributes to + determine the order. + + Args: + other (YourClass): The object to compare against. + + Returns: + bool: True if the current object is less than the other object, False + otherwise. + """ + if self.ystar < other.ystar: + return True + elif self.ystar > other.ystar: + return False + elif self.vertex.x < other.vertex.x: + return True + elif self.vertex.x > other.vertex.x: + return False + else: + return False + + def __eq__(self, other): + """Check equality of two objects. + + This method compares the current object with another object to determine + if they are equal. It checks if the 'ystar' attribute and the 'x' + coordinate of the 'vertex' attribute are the same for both objects. + + Args: + other (object): The object to compare with the current instance. + + Returns: + bool: True if both objects are considered equal, False otherwise. + """ + if self.ystar == other.ystar and self.vertex.x == other.vertex.x: + return True + + def leftreg(self, default): + """Retrieve the left registration value based on the edge state. + + This function checks the state of the edge attribute. If the edge is not + set, it returns the provided default value. If the edge is set and its + property indicates a left edge (Edge.LE), it returns the left + registration value. Otherwise, it returns the right registration value. + + Args: + default: The value to return if the edge is not set. + + Returns: + The left registration value if applicable, otherwise the default value. + """ + if not self.edge: + return default + elif self.pm == Edge.LE: + return self.edge.reg[Edge.LE] + else: + return self.edge.reg[Edge.RE] + + def rightreg(self, default): + """Retrieve the appropriate registration value based on the edge state. + + This function checks if the current edge is set. If it is not set, it + returns the provided default value. If the edge is set and the current + state is Edge.LE, it returns the registration value associated with + Edge.RE. Otherwise, it returns the registration value associated with + Edge.LE. + + Args: + default: The value to return if there is no edge set. + + Returns: + The registration value corresponding to the current edge state or the + default value if no edge is set. + """ + if not self.edge: + return default + elif self.pm == Edge.LE: + return self.edge.reg[Edge.RE] + else: + return self.edge.reg[Edge.LE] + + # returns True if p is to right of halfedge self + def isPointRightOf(self, pt): + """Determine if a point is to the right of a half-edge. + + This function checks whether the given point `pt` is located to the + right of the half-edge represented by the current object. It takes into + account the position of the top site of the edge and various geometric + properties to make this determination. The function uses the edge's + parameters to evaluate the relationship between the point and the half- + edge. + + Args: + pt (Point): A point object with x and y coordinates. + + Returns: + bool: True if the point is to the right of the half-edge, False otherwise. + """ + e = self.edge + topsite = e.reg[1] + right_of_site = pt.x > topsite.x + + if right_of_site and self.pm == Edge.LE: + return True + + if not right_of_site and self.pm == Edge.RE: + return False + + if e.a == 1.0: + dyp = pt.y - topsite.y + dxp = pt.x - topsite.x + fast = 0 + if (not right_of_site and e.b < 0.0) or (right_of_site and e.b >= 0.0): + above = dyp >= e.b * dxp + fast = above + else: + above = pt.x + pt.y * e.b > e.c + if e.b < 0.0: + above = not above + if not above: + fast = 1 + if not fast: + dxs = topsite.x - (e.reg[0]).x + above = e.b * (dxp * dxp - dyp * dyp) < dxs * dyp * \ + (1.0 + 2.0 * dxp / dxs + e.b * e.b) + if e.b < 0.0: + above = not above + else: # e.b == 1.0 + yl = e.c - e.a * pt.x + t1 = pt.y - yl + t2 = pt.x - topsite.x + t3 = yl - topsite.y + above = t1 * t1 > t2 * t2 + t3 * t3 + + if self.pm == Edge.LE: + return above + else: + return not above + + # -------------------------- + # create a new site where the Halfedges el1 and el2 intersect + def intersect(self, other): + """Create a new site where two edges intersect. + + This function calculates the intersection point of two edges, + represented by the current instance and another instance passed as an + argument. It first checks if either edge is None, and if they belong to + the same parent region. If the edges are parallel or do not intersect, + it returns None. If an intersection point is found, it creates and + returns a new Site object at the intersection coordinates. + + Args: + other (Edge): Another edge to intersect with the current edge. + + Returns: + Site or None: A Site object representing the intersection point + if an intersection occurs; otherwise, None. + """ + e1 = self.edge + e2 = other.edge + if (e1 is None) or (e2 is None): + return None + + # if the two edges bisect the same parent return None + if e1.reg[1] is e2.reg[1]: + return None + + d = e1.a * e2.b - e1.b * e2.a + if isEqual(d, 0.0): + return None + + xint = (e1.c * e2.b - e2.c * e1.b) / d + yint = (e2.c * e1.a - e1.c * e2.a) / d + if e1.reg[1] < e2.reg[1]: + he = self + e = e1 + else: + he = other + e = e2 + + rightOfSite = xint >= e.reg[1].x + if ((rightOfSite and he.pm == Edge.LE) or + (not rightOfSite and he.pm == Edge.RE)): + return None + + # create a new site at the point of intersection - this is a new + # vector event waiting to happen + return Site(xint, yint) + + +# ------------------------------------------------------------------ +class EdgeList(object): + def __init__(self, xmin, xmax, nsites): + """Init function.""" + if xmin > xmax: + xmin, xmax = xmax, xmin + self.hashsize = int(2 * math.sqrt(nsites + 4)) + + self.xmin = xmin + self.deltax = float(xmax - xmin) + self.hash = [None] * self.hashsize + + self.leftend = Halfedge() + self.rightend = Halfedge() + self.leftend.right = self.rightend + self.rightend.left = self.leftend + self.hash[0] = self.leftend + self.hash[-1] = self.rightend + + def insert(self, left, he): + """Insert a node into a doubly linked list. + + This function takes a node and inserts it into the list immediately + after the specified left node. It updates the pointers of the + surrounding nodes to maintain the integrity of the doubly linked list. + + Args: + left (Node): The node after which the new node will be inserted. + he (Node): The new node to be inserted into the list. + """ + he.left = left + he.right = left.right + left.right.left = he + left.right = he + + def delete(self, he): + """Delete a node from a doubly linked list. + + This function updates the pointers of the neighboring nodes to remove + the specified node from the list. It also marks the node as deleted by + setting its edge attribute to Edge.DELETED. + + Args: + he (Node): The node to be deleted from the list. + """ + he.left.right = he.right + he.right.left = he.left + he.edge = Edge.DELETED + + # Get entry from hash table, pruning any deleted nodes + def gethash(self, b): + """Retrieve an entry from the hash table, ignoring deleted nodes. + + This function checks if the provided index is within the valid range of + the hash table. If the index is valid, it retrieves the corresponding + entry. If the entry is marked as deleted, it updates the hash table to + remove the reference to the deleted entry and returns None. + + Args: + b (int): The index in the hash table to retrieve the entry from. + + Returns: + object: The entry at the specified index, or None if the index is out of bounds + or if the entry is marked as deleted. + """ + if (b < 0 or b >= self.hashsize): + return None + he = self.hash[b] + if he is None or he.edge is not Edge.DELETED: + return he + + # Hash table points to deleted half edge. Patch as necessary. + self.hash[b] = None + return None + + def leftbnd(self, pt): + """Find the left boundary half-edge for a given point. + + This function computes the appropriate half-edge that is to the left of + the specified point. It utilizes a hash table to quickly locate the + half-edge that is closest to the desired position based on the + x-coordinate of the point. If the initial bucket derived from the + point's x-coordinate does not contain a valid half-edge, the function + will search adjacent buckets until it finds one. Once a half-edge is + located, it will traverse through the linked list of half-edges to find + the correct one that lies to the left of the point. + + Args: + pt (Point): A point object containing x and y coordinates. + + Returns: + HalfEdge: The half-edge that is to the left of the given point. + """ + # Use hash table to get close to desired halfedge + bucket = int(((pt.x - self.xmin) / self.deltax * self.hashsize)) + + if bucket < 0: + bucket = 0 + + if bucket >= self.hashsize: + bucket = self.hashsize - 1 + + he = self.gethash(bucket) + if (he is None): + i = 1 + while True: + he = self.gethash(bucket - i) + if (he is not None): + break + he = self.gethash(bucket + i) + if (he is not None): + break + i += 1 + + # Now search linear list of halfedges for the corect one + if (he is self.leftend) or (he is not self.rightend and he.isPointRightOf(pt)): + he = he.right + while he is not self.rightend and he.isPointRightOf(pt): + he = he.right + he = he.left + else: + he = he.left + while he is not self.leftend and not he.isPointRightOf(pt): + he = he.left + + # Update hash table and reference counts + if bucket > 0 and bucket < self.hashsize - 1: + self.hash[bucket] = he + return he + + +# ------------------------------------------------------------------ +class PriorityQueue(object): + def __init__(self, ymin, ymax, nsites): + """Init function.""" + self.ymin = ymin + self.deltay = ymax - ymin + self.hashsize = int(4 * math.sqrt(nsites)) + self.count = 0 + self.minidx = 0 + self.hash = [] + for i in range(self.hashsize): + self.hash.append(Halfedge()) + + def __len__(self): + """Return the length of the object. + + This method returns the count of items in the object, which is useful + for determining how many elements are present. It is typically used to + support the built-in `len()` function. + + Returns: + int: The number of items in the object. + """ + return self.count + + def isEmpty(self): + """Check if the object is empty. + + This method determines whether the object contains any elements by + checking the value of the count attribute. If the count is zero, the + object is considered empty; otherwise, it is not. + + Returns: + bool: True if the object is empty, False otherwise. + """ + return self.count == 0 + + def insert(self, he, site, offset): + """Insert a new element into the data structure. + + This function inserts a new element represented by `he` into the + appropriate position in the data structure based on its value. It + updates the `ystar` attribute of the element and links it to the next + element in the list. The function also manages the count of elements in + the structure. + + Args: + he (Element): The element to be inserted, which contains a vertex and + a y-coordinate. + site (Site): The site object that provides the y-coordinate for the + insertion. + offset (float): The offset to be added to the y-coordinate of the site. + + Returns: + None: This function does not return a value. + """ + he.vertex = site + he.ystar = site.y + offset + last = self.hash[self.getBucket(he)] + next = last.qnext + while (next is not None) and he > next: + last = next + next = last.qnext + he.qnext = last.qnext + last.qnext = he + self.count += 1 + + def delete(self, he): + """Delete a specified element from the data structure. + + This function removes the specified element (he) from the linked list + associated with the corresponding bucket in the hash table. It traverses + the linked list until it finds the element to delete, updates the + pointers to bypass the deleted element, and decrements the count of + elements in the structure. If the element is found and deleted, its + vertex is set to None to indicate that it is no longer valid. + + Args: + he (Element): The element to be deleted from the data structure. + """ + if he.vertex is not None: + last = self.hash[self.getBucket(he)] + while last.qnext is not he: + last = last.qnext + last.qnext = he.qnext + self.count -= 1 + he.vertex = None + + def getBucket(self, he): + """Get the appropriate bucket index for a given value. + + This function calculates the bucket index based on the provided value + and the object's parameters. It ensures that the bucket index is within + the valid range, adjusting it if necessary. The calculation is based on + the difference between a specified value and a minimum value, scaled by + a delta value and the size of the hash table. The function also updates + the minimum index if the calculated bucket is lower than the current + minimum index. + + Args: + he: An object that contains the attribute `ystar`, which is used + in the bucket calculation. + + Returns: + int: The calculated bucket index, constrained within the valid range. + """ + bucket = int(((he.ystar - self.ymin) / self.deltay) * self.hashsize) + if bucket < 0: + bucket = 0 + if bucket >= self.hashsize: + bucket = self.hashsize - 1 + if bucket < self.minidx: + self.minidx = bucket + return bucket + + def getMinPt(self): + """Retrieve the minimum point from a hash table. + + This function iterates through the hash table starting from the current + minimum index and finds the next non-null entry. It then extracts the + coordinates (x, y) of the vertex associated with that entry and returns + it as a Site object. + + Returns: + Site: An object representing the minimum point with x and y coordinates. + """ + while self.hash[self.minidx].qnext is None: + self.minidx += 1 + he = self.hash[self.minidx].qnext + x = he.vertex.x + y = he.ystar + return Site(x, y) + + def popMinHalfedge(self): + """Remove and return the minimum half-edge from the data structure. + + This function retrieves the minimum half-edge from a hash table, updates + the necessary pointers to maintain the integrity of the data structure, + and decrements the count of half-edges. It effectively removes the + minimum half-edge while ensuring that the next half-edge in the sequence + is correctly linked. + + Returns: + HalfEdge: The minimum half-edge that was removed from the data structure. + """ + curr = self.hash[self.minidx].qnext + self.hash[self.minidx].qnext = curr.qnext + self.count -= 1 + return curr + + +# ------------------------------------------------------------------ +class SiteList(object): + def __init__(self, pointList): + """Init function.""" + self.__sites = [] + self.__sitenum = 0 + + self.__xmin = min([pt.x for pt in pointList]) + self.__ymin = min([pt.y for pt in pointList]) + self.__xmax = max([pt.x for pt in pointList]) + self.__ymax = max([pt.y for pt in pointList]) + self.__extent = (self.__xmin, self.__xmax, self.__ymin, self.__ymax) + + for i, pt in enumerate(pointList): + self.__sites.append(Site(pt.x, pt.y, i)) + self.__sites.sort() + + def setSiteNumber(self, site): + """Set the site number for a given site. + + This function assigns a unique site number to the provided site object. + It updates the site object's 'sitenum' attribute with the current value + of the instance's private '__sitenum' attribute and then increments the + '__sitenum' for the next site. + + Args: + site (object): An object representing a site that has a 'sitenum' attribute. + + Returns: + None: This function does not return a value. + """ + site.sitenum = self.__sitenum + self.__sitenum += 1 + + class Iterator(object): + def __init__(this, lst): + """Init function.""" + this.generator = (s for s in lst) + + def __iter__(this): + """Return the iterator object itself. + + This method is part of the iterator protocol. It allows an object to be + iterable by returning the iterator object itself when the `__iter__` + method is called. This is typically used in conjunction with the + `__next__` method to iterate over the elements of the object. + + Returns: + self: The iterator object itself. + """ + return this + + def next(this): + """Retrieve the next item from a generator. + + This function attempts to get the next value from the provided + generator. It handles both Python 2 and Python 3 syntax for retrieving + the next item. If the generator is exhausted, it returns None instead of + raising an exception. + + Args: + this (object): An object that contains a generator attribute. + + Returns: + object: The next item from the generator, or None if the generator is exhausted. + """ + try: + if PY3: + return this.generator.__next__() + else: + return this.generator.next() + except StopIteration: + return None + + def iterator(self): + """Create an iterator for the sites. + + This function returns an iterator object that allows iteration over the + collection of sites stored in the instance. It utilizes the + SiteList.Iterator class to facilitate the iteration process. + + Returns: + Iterator: An iterator for the sites in the SiteList. + """ + return SiteList.Iterator(self.__sites) + + def __iter__(self): + """Iterate over the sites in the SiteList. + + This method returns an iterator for the SiteList, allowing for traversal + of the contained sites. It utilizes the internal Iterator class to + manage the iteration process. + + Returns: + Iterator: An iterator for the sites in the SiteList. + """ + return SiteList.Iterator(self.__sites) + + def __len__(self): + """Return the number of sites. + + This method returns the length of the internal list of sites. It is used + to determine how many sites are currently stored in the object. The + length is calculated using the built-in `len()` function on the + `__sites` attribute. + + Returns: + int: The number of sites in the object. + """ + return len(self.__sites) + + def _getxmin(self): + """Retrieve the minimum x-coordinate value. + + This function accesses and returns the private attribute __xmin, which + holds the minimum x-coordinate value for the object. It is typically + used in contexts where the minimum x value is needed for calculations or + comparisons. + + Returns: + float: The minimum x-coordinate value. + """ + return self.__xmin + + def _getymin(self): + """Retrieve the minimum y-coordinate value. + + This function returns the minimum y-coordinate value stored in the + instance variable `__ymin`. It is typically used in contexts where the + minimum y-value is needed for calculations or comparisons. + + Returns: + float: The minimum y-coordinate value. + """ + return self.__ymin + + def _getxmax(self): + """Retrieve the maximum x value. + + This function returns the maximum x value stored in the instance. It is + a private method intended for internal use within the class and provides + access to the __xmax attribute. + + Returns: + float: The maximum x value. + """ + return self.__xmax + + def _getymax(self): + """Retrieve the maximum y-coordinate value. + + This function accesses and returns the private attribute __ymax, which + represents the maximum y-coordinate value stored in the instance. + + Returns: + float: The maximum y-coordinate value. + """ + return self.__ymax + + def _getextent(self): + """Retrieve the extent of the object. + + This function returns the current extent of the object, which is + typically a representation of its boundaries or limits. The extent is + stored as a private attribute and can be used for various purposes such + as rendering, collision detection, or spatial analysis. + + Returns: + The extent of the object, which may be in a specific format depending + on the implementation (e.g., a tuple, list, or custom object). + """ + return self.__extent + + xmin = property(_getxmin) + ymin = property(_getymin) + xmax = property(_getxmax) + ymax = property(_getymax) + extent = property(_getextent) + + +# ------------------------------------------------------------------ +def computeVoronoiDiagram(points, xBuff=0, yBuff=0, polygonsOutput=False, formatOutput=False, closePoly=True): + """Compute the Voronoi diagram for a set of points. + + This function takes a list of point objects and computes the Voronoi + diagram, which partitions the plane into regions based on the distance + to the input points. The function allows for optional buffering of the + bounding box and can return various formats of the output, including + edges or polygons of the Voronoi diagram. + + Args: + points (list): A list of point objects, each having 'x' and 'y' attributes. + xBuff (float?): The expansion percentage of the bounding box in the x-direction. + Defaults to 0. + yBuff (float?): The expansion percentage of the bounding box in the y-direction. + Defaults to 0. + polygonsOutput (bool?): If True, returns polygons instead of edges. Defaults to False. + formatOutput (bool?): If True, formats the output to include vertex coordinates. Defaults to + False. + closePoly (bool?): If True, closes the polygons by repeating the first point at the end. + Defaults to True. + + Returns: + If `polygonsOutput` is False: + - list: A list of 2-tuples representing the edges of the Voronoi + diagram, + where each tuple contains the x and y coordinates of the points. + If `formatOutput` is True: + - tuple: A list of 2-tuples for vertex coordinates and a list of edges + indices. + If `polygonsOutput` is True: + - dict: A dictionary where keys are indices of input points and values + are n-tuples + representing the vertices of each Voronoi polygon. + If `formatOutput` is True: + - tuple: A list of 2-tuples for vertex coordinates and a dictionary of + polygon vertex indices. + """ + siteList = SiteList(points) + context = Context() + voronoi(siteList, context) + context.setClipBuffer(xBuff, yBuff) + if not polygonsOutput: + clipEdges = context.getClipEdges() + if formatOutput: + vertices, edgesIdx = formatEdgesOutput(clipEdges) + return vertices, edgesIdx + else: + return clipEdges + else: + clipPolygons = context.getClipPolygons(closePoly) + if formatOutput: + vertices, polyIdx = formatPolygonsOutput(clipPolygons) + return vertices, polyIdx + else: + return clipPolygons + + +def formatEdgesOutput(edges): + """Format edges output for a list of edges. + + This function takes a list of edges, where each edge is represented as a + tuple of points. It extracts unique points from the edges and creates a + mapping of these points to their corresponding indices. The function + then returns a list of unique points and a list of edges represented by + their indices. + + Args: + edges (list): A list of edges, where each edge is a tuple containing points. + + Returns: + tuple: A tuple containing: + - list: A list of unique points extracted from the edges. + - list: A list of edges represented by their corresponding indices. + """ + # get list of points + pts = [] + for edge in edges: + pts.extend(edge) + # get unique values + pts = set(pts) # unique values (tuples are hashable) + # get dict {values:index} + valuesIdxDict = dict(zip(pts, range(len(pts)))) + # get edges index reference + edgesIdx = [] + for edge in edges: + edgesIdx.append([valuesIdxDict[pt] for pt in edge]) + return list(pts), edgesIdx + + +def formatPolygonsOutput(polygons): + """Format the output of polygons into a standardized structure. + + This function takes a dictionary of polygons, where each polygon is + represented as a list of points. It extracts unique points from all + polygons and creates an index mapping for these points. The output + consists of a list of unique points and a dictionary that maps each + polygon's original indices to their corresponding indices in the unique + points list. + + Args: + polygons (dict): A dictionary where keys are polygon identifiers and values + are lists of points (tuples) representing the vertices of + the polygons. + + Returns: + tuple: A tuple containing: + - list: A list of unique points (tuples) extracted from the input + polygons. + - dict: A dictionary mapping each polygon's identifier to a list of + indices + corresponding to the unique points. + """ + # get list of points + pts = [] + for poly in polygons.values(): + pts.extend(poly) + # get unique values + pts = set(pts) # unique values (tuples are hashable) + # get dict {values:index} + valuesIdxDict = dict(zip(pts, range(len(pts)))) + # get polygons index reference + polygonsIdx = {} + for inPtsIdx, poly in polygons.items(): + polygonsIdx[inPtsIdx] = [valuesIdxDict[pt] for pt in poly] + return list(pts), polygonsIdx + + +# ------------------------------------------------------------------ +def computeDelaunayTriangulation(points): + """Compute the Delaunay triangulation for a set of points. + + This function takes a list of point objects, each of which must have 'x' + and 'y' fields. It computes the Delaunay triangulation and returns a + list of 3-tuples, where each tuple contains the indices of the points + that form a Delaunay triangle. The triangulation is performed using the + Voronoi diagram method. + + Args: + points (list): A list of point objects with 'x' and 'y' attributes. + + Returns: + list: A list of 3-tuples representing the indices of points that + form Delaunay triangles. + """ + siteList = SiteList(points) + context = Context() + context.triangulate = True + voronoi(siteList, context) + return context.triangles + +# ----------------------------------------------------------------------------- +# def shapely_voronoi(amount): +# import random +# +# rcoord = [] +# x = 0 +# while x < self.amount: +# rcoord.append((width * random.random(), height * random.random(), 0.02 * random.random())) +# x += 1 +# +# points = MultiPoint(rcoord) +# voronoi = shapely.ops.voronoi_diagram(points, tolerance=0, edges=False) +# +# utils.shapelyToCurve('voronoi', voronoi, 0) diff --git a/scripts/addons/docs/Makefile b/scripts/addons/docs/Makefile new file mode 100644 index 00000000..d4bb2cbb --- /dev/null +++ b/scripts/addons/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = . +BUILDDIR = _build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/scripts/addons/docs/_static/logo_blendercam.png b/scripts/addons/docs/_static/logo_blendercam.png new file mode 100644 index 00000000..ccdcc7d0 Binary files /dev/null and b/scripts/addons/docs/_static/logo_blendercam.png differ diff --git a/scripts/addons/docs/cam.opencamlib.rst b/scripts/addons/docs/cam.opencamlib.rst new file mode 100644 index 00000000..16ad5bb1 --- /dev/null +++ b/scripts/addons/docs/cam.opencamlib.rst @@ -0,0 +1,29 @@ +cam.opencamlib package +====================== + +Submodules +---------- + +cam.opencamlib.oclSample module +------------------------------- + +.. automodule:: cam.opencamlib.oclSample + :members: + :undoc-members: + :show-inheritance: + +cam.opencamlib.opencamlib module +-------------------------------- + +.. automodule:: cam.opencamlib.opencamlib + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: cam.opencamlib + :members: + :undoc-members: + :show-inheritance: diff --git a/scripts/addons/docs/cam.rst b/scripts/addons/docs/cam.rst new file mode 100644 index 00000000..e6ceb768 --- /dev/null +++ b/scripts/addons/docs/cam.rst @@ -0,0 +1,327 @@ +cam package +=========== + +Subpackages +----------- + +.. toctree:: + :maxdepth: 4 + + cam.nc + cam.opencamlib + cam.ui_panels + +Submodules +---------- + +cam.async\_op module +-------------------- + +.. automodule:: cam.async_op + :members: + :undoc-members: + :show-inheritance: + +cam.autoupdate module +--------------------- + +.. automodule:: cam.autoupdate + :members: + :undoc-members: + :show-inheritance: + +cam.basrelief module +-------------------- + +.. automodule:: cam.basrelief + :members: + :undoc-members: + :show-inheritance: + +cam.bridges module +------------------ + +.. automodule:: cam.bridges + :members: + :undoc-members: + :show-inheritance: + +cam.cam\_chunk module +--------------------- + +.. automodule:: cam.cam_chunk + :members: + :undoc-members: + :show-inheritance: + +cam.cam\_operation module +------------------------- + +.. automodule:: cam.cam_operation + :members: + :undoc-members: + :show-inheritance: + +cam.chain module +---------------- + +.. automodule:: cam.chain + :members: + :undoc-members: + :show-inheritance: + +cam.collision module +-------------------- + +.. automodule:: cam.collision + :members: + :undoc-members: + :show-inheritance: + +cam.constants module +-------------------- + +.. automodule:: cam.constants + :members: + :undoc-members: + :show-inheritance: + +cam.curvecamcreate module +------------------------- + +.. automodule:: cam.curvecamcreate + :members: + :undoc-members: + :show-inheritance: + +cam.curvecamequation module +--------------------------- + +.. automodule:: cam.curvecamequation + :members: + :undoc-members: + :show-inheritance: + +cam.curvecamtools module +------------------------ + +.. automodule:: cam.curvecamtools + :members: + :undoc-members: + :show-inheritance: + +cam.engine module +----------------- + +.. automodule:: cam.engine + :members: + :undoc-members: + :show-inheritance: + +cam.exception module +-------------------- + +.. automodule:: cam.exception + :members: + :undoc-members: + :show-inheritance: + +cam.gcodeimportparser module +---------------------------- + +.. automodule:: cam.gcodeimportparser + :members: + :undoc-members: + :show-inheritance: + +cam.gcodepath module +-------------------- + +.. automodule:: cam.gcodepath + :members: + :undoc-members: + :show-inheritance: + +cam.image\_utils module +----------------------- + +.. automodule:: cam.image_utils + :members: + :undoc-members: + :show-inheritance: + +cam.involute\_gear module +------------------------- + +.. automodule:: cam.involute_gear + :members: + :undoc-members: + :show-inheritance: + +cam.joinery module +------------------ + +.. automodule:: cam.joinery + :members: + :undoc-members: + :show-inheritance: + +cam.machine\_settings module +---------------------------- + +.. automodule:: cam.machine_settings + :members: + :undoc-members: + :show-inheritance: + +cam.numba\_wrapper module +------------------------- + +.. automodule:: cam.numba_wrapper + :members: + :undoc-members: + :show-inheritance: + +cam.ops module +-------------- + +.. automodule:: cam.ops + :members: + :undoc-members: + :show-inheritance: + +cam.pack module +--------------- + +.. automodule:: cam.pack + :members: + :undoc-members: + :show-inheritance: + +cam.parametric module +--------------------- + +.. automodule:: cam.parametric + :members: + :undoc-members: + :show-inheritance: + +cam.pattern module +------------------ + +.. automodule:: cam.pattern + :members: + :undoc-members: + :show-inheritance: + +cam.polygon\_utils\_cam module +------------------------------ + +.. automodule:: cam.polygon_utils_cam + :members: + :undoc-members: + :show-inheritance: + +cam.preferences module +---------------------- + +.. automodule:: cam.preferences + :members: + :undoc-members: + :show-inheritance: + +cam.preset\_managers module +--------------------------- + +.. automodule:: cam.preset_managers + :members: + :undoc-members: + :show-inheritance: + +cam.puzzle\_joinery module +-------------------------- + +.. automodule:: cam.puzzle_joinery + :members: + :undoc-members: + :show-inheritance: + +cam.simple module +----------------- + +.. automodule:: cam.simple + :members: + :undoc-members: + :show-inheritance: + +cam.simulation module +--------------------- + +.. automodule:: cam.simulation + :members: + :undoc-members: + :show-inheritance: + +cam.slice module +---------------- + +.. automodule:: cam.slice + :members: + :undoc-members: + :show-inheritance: + +cam.strategy module +------------------- + +.. automodule:: cam.strategy + :members: + :undoc-members: + :show-inheritance: + +cam.testing module +------------------ + +.. automodule:: cam.testing + :members: + :undoc-members: + :show-inheritance: + +cam.ui module +------------- + +.. automodule:: cam.ui + :members: + :undoc-members: + :show-inheritance: + +cam.utils module +---------------- + +.. automodule:: cam.utils + :members: + :undoc-members: + :show-inheritance: + +cam.version module +------------------ + +.. automodule:: cam.version + :members: + :undoc-members: + :show-inheritance: + +cam.voronoi module +------------------ + +.. automodule:: cam.voronoi + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: cam + :members: + :undoc-members: + :show-inheritance: diff --git a/scripts/addons/docs/conf.py b/scripts/addons/docs/conf.py new file mode 100644 index 00000000..887db7d6 --- /dev/null +++ b/scripts/addons/docs/conf.py @@ -0,0 +1,58 @@ +# Configuration file for the Sphinx documentation builder. + +import os +import sys +sys.path.insert(0, os.path.abspath('../cam/')) + + +# -- Project information ----------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information + +project = 'BlenderCAM' +copyright = '2024' +author = 'Vilem Novak, Alain Pelletier & Contributors' +release = '1.0.38' + +# -- General configuration --------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration + +extensions = [ + 'sphinx.ext.autodoc', + 'sphinx.ext.viewcode', + 'autoapi.extension', + 'sphinx.ext.napoleon', + 'sphinx.ext.graphviz', + 'sphinx.ext.inheritance_diagram' +] + +autoapi_type = 'python' +autoapi_dirs = ['../cam'] +autoapi_ignore = ['*nc*', '*presets*', '*ui_panels*', '*pie_menu*', '*tests*', '*wheels*'] + +templates_path = ['_templates'] +exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store', '*nc*', '*presets*', '*ui_panels*', '*pie_menu*', '*tests*', '*wheels*'] + +add_module_names = False + +# -- Options for HTML output ------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output + +html_theme = 'sphinx_book_theme' +html_static_path = ['_static'] +html_logo = "_static/logo_blendercam.png" +html_theme_options = { + "icon_links": [ + { + "name": "GitHub", + "url": "https://github.com/pppalain/blendercam", + "icon": "fa-brands fa-square-github", + "type": "fontawesome", + }, + { + "name": "Matrix", + "url": "https://riot.im/app/#/room/#blendercam:matrix.org", + "icon": "fa-solid fa-comments", + "type": "fontawesome", + }, + ] +} diff --git a/scripts/addons/docs/index.rst b/scripts/addons/docs/index.rst new file mode 100644 index 00000000..c1f7851c --- /dev/null +++ b/scripts/addons/docs/index.rst @@ -0,0 +1,30 @@ +.. BlenderCAM documentation master file, created by + sphinx-quickstart on Sun Sep 8 08:23:06 2024. + You can adapt this file completely to your liking, but it should at least + contain the root `toctree` directive. + +Welcome to BlenderCAM's API Documentation! +========================================== + +This site serves as an introduction to the code behind BlenderCAM. + +If you just want to know how to use the addon to mill projects, check out the `wiki `_ + +This resource is for people who want to contribute code to BlenderCAM, people who want to modify the addon for their needs, or anyone who simply want to better understand what is happening 'under the hood'. + +:doc:`overview` offers a guide to the addon files and how they relate to one another. + +:doc:`styleguide` gives tips on editors, linting, formatting etc. + +:doc:`testing` has information on how to run and contribute to the Test Suite. + +:doc:`workflows` contains an explanation of how the addon, testing and documentation are automated via Github Actions. + +.. toctree:: + :maxdepth: 1 + :caption: Contents: + + overview + styleguide + testing + workflows diff --git a/scripts/addons/docs/make.bat b/scripts/addons/docs/make.bat new file mode 100644 index 00000000..32bb2452 --- /dev/null +++ b/scripts/addons/docs/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=. +set BUILDDIR=_build + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.https://www.sphinx-doc.org/ + exit /b 1 +) + +if "%1" == "" goto help + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/scripts/addons/docs/modules.rst b/scripts/addons/docs/modules.rst new file mode 100644 index 00000000..21fe0e84 --- /dev/null +++ b/scripts/addons/docs/modules.rst @@ -0,0 +1,7 @@ +cam +=== + +.. toctree:: + :maxdepth: 4 + + cam diff --git a/scripts/addons/docs/overview.rst b/scripts/addons/docs/overview.rst new file mode 100644 index 00000000..3bb3bc2d --- /dev/null +++ b/scripts/addons/docs/overview.rst @@ -0,0 +1,4 @@ +Overview +=========== + +The package cam diff --git a/scripts/addons/docs/styleguide.rst b/scripts/addons/docs/styleguide.rst new file mode 100644 index 00000000..efe6a4f7 --- /dev/null +++ b/scripts/addons/docs/styleguide.rst @@ -0,0 +1,4 @@ +Style Guide +=========== + +Use this style! diff --git a/scripts/addons/docs/testing.rst b/scripts/addons/docs/testing.rst new file mode 100644 index 00000000..10c79ffe --- /dev/null +++ b/scripts/addons/docs/testing.rst @@ -0,0 +1,4 @@ +Test Suite +=========== + +This is how tests work diff --git a/scripts/addons/docs/workflows.rst b/scripts/addons/docs/workflows.rst new file mode 100644 index 00000000..19bb4236 --- /dev/null +++ b/scripts/addons/docs/workflows.rst @@ -0,0 +1,4 @@ +Workflows & Actions +=================== + +Github Actions automate part of the workflow