Source code for fabex.simulation

"""Fabex 'simulation.py' © 2012 Vilem Novak

Functions to generate a mesh simulation from CAM Chain / Operation data.

"""

import math
import os
import time

import numpy as np


import bpy

from mathutils import Vector


from . import __package__ as base_package

from .utilities.async_utils import progress_async
from .utilities.bounds_utils import get_bounds_multiple
from .utilities.image_utils import numpy_save
from .utilities.logging_utils import log
from .utilities.operation_utils import (
    get_operation_sources,
    get_cutter_array,
)
from .utilities.simple_utils import get_simulation_path


[docs] def create_simulation_object(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 = bpy.context.scene.cam_names.simulation_name_full 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 log.info(f"{o.max.x}, {o.min.x}") log.info(f"{o.max.y}, {o.min.y}") log.info("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() # Assign Simulation Material library_name = "Fabex Assets" filename = "Fabex_Assets.blend" addon_prefs = bpy.context.preferences.addons[base_package].preferences material_name = str(addon_prefs.default_simulation_material).title() filepaths = bpy.context.preferences.filepaths folder = filepaths.asset_libraries[library_name].path library_path = os.path.join(folder, filename) with bpy.data.libraries.load( library_path, assets_only=True, link=False, ) as ( asset_library, current_file, ): for material in asset_library.materials: if material == material_name: current_file.materials.append(material) ob.data.materials.append(bpy.data.materials[material_name]) ob.active_material_index = len(ob.material_slots) - 1 bpy.context.collection.objects.unlink(ob) bpy.data.collections["Simulations"].objects.link(ob)
[docs] async def do_simulation(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: get_operation_sources(o) limits = get_bounds_multiple(operations) # this is here because some background computed operations still didn't have bounds data i = await generate_simulation_image(operations, limits) cp = get_simulation_path() + name log.info(f"Cache Path = {cp}") iname = cp + "_sim.exr" numpy_save(i, iname) i = bpy.data.images.load(iname) create_simulation_object(name, operations, i)
[docs] async def generate_simulation_image(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 # getting sim detail and others from first op. first_op = operations[0] simulation_detail = first_op.optimisation.simulation_detail borderwidth = first_op.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): path_prefix = bpy.context.scene.cam_names.path_prefix ob = bpy.data.objects[f"{path_prefix}_{o.name}"] m = ob.data verts = m.vertices if o.do_simulation_feedrate: kname = "feedrates" m.attributes.new(".edge_creases", "FLOAT", "EDGE") 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 = get_cutter_array(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 # only simulate inside material, and exclude lift-ups if (lasts.z < maxz or s.z < maxz) and not (v.x == 0 and v.y == 0 and v.z > 0): # if the cutter goes straight down, we don't have to interpolate. if v.x == 0 and v.y == 0 and v.z < 0: pass elif v.length > simulation_detail: 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 ) ys = int( (lasts.y + v.y - miny) / simulation_detail + borderwidth + simulation_detail / 2 ) z = lasts.z + v.z if lastxs != xs or lastys != ys: volume_partial = sim_cutter_spot( 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) ys = int((s.y - miny) / simulation_detail + borderwidth + simulation_detail / 2) volume_partial = sim_cutter_spot( xs, ys, s.z, cutterArray, si, o.do_simulation_feedrate, ) # compute volumes and write data into shapekey. if o.do_simulation_feedrate: volume += volume_partial totalvolume += volume load = volume / l if l > 0 else 0 # this will show the shapekey as debugging graph and will use same data to estimate parts # with heavy load shapek.data[i].co.x = shapek.data[i - 1].co.x + l * 0.04 shapek.data[i].co.y = (load) * 0.000002 if l != 0 else shapek.data[i - 1].co.y shapek.data[i].co.z = 0 lasts = s # smoothing ,but only backward! if o.do_simulation_feedrate: xcoef = shapek.data[len(shapek.data) - 1].co.x / len(shapek.data) for a in range(0, 10): 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) 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): d.co.z = ( scale_graph * max(0.3, normal_load / d.co.y) if d.co.y > normal_load else scale_graph * 1 ) if i < totverts - 1: m.attributes[".edge_creases"].data[i].value = 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
[docs] def sim_cutter_spot(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 m < xs < si.shape[0] - m and m < 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()) return vsum elif -m < xs < si.shape[0] + m and -m < 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()) return vsum return 0